diff --git a/.context/README.md b/.context/README.md index 1d1151aad..557c930fb 100644 --- a/.context/README.md +++ b/.context/README.md @@ -7,9 +7,9 @@ This directory contains git subtrees of library documentation and examples for r The following repositories are included as git subtrees: - **Effect** (`.context/effect/`) - - Repository: https://github.com/Effect-TS/effect + - Repository: https://github.com/Effect-TS/effect-smol - Branch: main - - Purpose: Effect-TS functional programming library documentation and examples + - Purpose: Effect v4 (effect-smol) functional programming library documentation and examples - **Effect Atom** (`.context/effect-atom/`) - Repository: https://github.com/tim-smart/effect-atom @@ -39,7 +39,7 @@ git subtree pull --prefix=.context/tanstack-db tanstack-db-subtree main --squash Note: The git remotes should already be configured. If not, add them first: ```bash -git remote add effect-subtree https://github.com/Effect-TS/effect +git remote add effect-subtree https://github.com/Effect-TS/effect-smol git remote add effect-atom-subtree https://github.com/tim-smart/effect-atom git remote add tanstack-db-subtree https://github.com/TanStack/db ``` diff --git a/CLAUDE.md b/CLAUDE.md index b8180e4e5..651296ca0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -208,25 +208,28 @@ Without both changes, Electric sync requests for the new table will be rejected ## Effect-TS Best Practices -> **Skill Available**: Run `/effect-best-practices` for comprehensive Effect-TS patterns. The skill auto-activates when writing Effect.Service, Schema.TaggedError, Layer composition, or effect-atom code. +> **Skill Available**: Run `/effect-best-practices` for comprehensive Effect-TS patterns. The skill auto-activates when writing ServiceMap.Service, Schema.TaggedError, Layer composition, or effect-atom code. -### Always Use `Effect.Service` Instead of `Context.Tag` +### Always Use `ServiceMap.Service` Instead of `Context.Tag` -**ALWAYS** prefer `Effect.Service` over `Context.Tag` for defining services. Effect.Service provides built-in `Default` layer, automatic accessors, and proper dependency declaration. +**ALWAYS** prefer `ServiceMap.Service` (from `effect`) over `Context.Tag` for defining services. `ServiceMap.Service` with a `make` option stores the constructor effect on the class. You must define the layer explicitly using `Layer.effect`. ```typescript -// ✅ CORRECT - Use Effect.Service -export class MyService extends Effect.Service()("MyService", { - accessors: true, - effect: Effect.gen(function* () { +// ✅ CORRECT - Use ServiceMap.Service with make and explicit layer +import { ServiceMap, Effect, Layer } from "effect" + +export class MyService extends ServiceMap.Service()("MyService", { + make: Effect.gen(function* () { // ... implementation return { /* methods */ } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} -// Usage: MyService.Default, yield* MyService +// Usage: MyService.layer, yield* MyService ``` ```typescript @@ -237,7 +240,7 @@ export class MyService extends Context.Tag("MyService")< /* shape */ } >() { - static Default = Layer.effect( + static layer = Layer.effect( this, Effect.gen(function* () { /* ... */ @@ -246,50 +249,58 @@ export class MyService extends Context.Tag("MyService")< } ``` +```typescript +// ❌ WRONG - Don't use v3 Effect.Service pattern +export class MyService extends Effect.Service()("MyService", { + accessors: true, + effect: Effect.gen(function* () { + /* ... */ + }), +}) {} +``` + **When Context.Tag is acceptable:** - Infrastructure layers with runtime injection (e.g., Cloudflare KV namespace, worker bindings) - Factory patterns where the resource is provided externally at runtime -### Use `dependencies` Array in Effect.Service +### Wire Dependencies with `Layer.provide` -**ALWAYS** declare service dependencies in the `dependencies` array when using `Effect.Service`. This ensures proper layer composition and avoids "leaked dependencies" that require manual `Layer.provide` calls at the usage site. +Wire service dependencies using `Layer.provide` on the layer. The v3 `dependencies` array no longer exists. ```typescript -// ✅ CORRECT - Dependencies declared in the service -export class MyService extends Effect.Service()("MyService", { - accessors: true, - dependencies: [DatabaseService.Default, CacheService.Default], - effect: Effect.gen(function* () { +// ✅ CORRECT - Dependencies wired via Layer.provide on the layer +export class MyService extends ServiceMap.Service()("MyService", { + make: Effect.gen(function* () { const db = yield* DatabaseService const cache = yield* CacheService // ... implementation + return { + /* methods */ + } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(DatabaseService.layer), + Layer.provide(CacheService.layer), + ) +} -// Usage is simple - MyService.Default includes all dependencies -const MainLive = Layer.mergeAll(MyService.Default, OtherService.Default) +// Usage is simple - MyService.layer includes all dependencies +const MainLive = Layer.mergeAll(MyService.layer, OtherService.layer) ``` ```typescript -// ❌ WRONG - Dependencies leaked to usage site +// ❌ WRONG - v3 dependencies array no longer exists export class MyService extends Effect.Service()("MyService", { - accessors: true, + dependencies: [DatabaseService.Default, CacheService.Default], effect: Effect.gen(function* () { - const db = yield* DatabaseService - const cache = yield* CacheService - // ... implementation + /* ... */ }), }) {} - -// Now every usage site must manually wire dependencies -const MainLive = Layer.mergeAll( - MyService.Default.pipe(Layer.provide(DatabaseService.Default), Layer.provide(CacheService.Default)), - OtherService.Default, -) ``` -**When it's acceptable to omit dependencies:** +**When it's acceptable to omit `Layer.provide`:** - Infrastructure layers that are globally provided (e.g., Redis, Database) may be intentionally "leaked" to be provided once at the application root - When a dependency is explicitly meant to be provided by the consumer diff --git a/apps/backend/.artifacts/chat-sync/chat-sync-diagnostics.jsonl b/apps/backend/.artifacts/chat-sync/chat-sync-diagnostics.jsonl new file mode 100644 index 000000000..7750b942d --- /dev/null +++ b/apps/backend/.artifacts/chat-sync/chat-sync-diagnostics.jsonl @@ -0,0 +1,8 @@ +{"timestamp":"2026-03-16T14:12:48.118Z","suite":"discord-gateway-dispatch","testCase":"message-create-routing","workerMethod":"ingestMessageCreate","action":"dispatch","dedupeKey":"discord:gateway:create:223456789012345678","syncConnectionId":"00000000-0000-0000-0000-000000000001","expected":"one inbound dispatch for both-direction link","actual":"1 dispatches"} +{"timestamp":"2026-03-16T14:39:47.254Z","suite":"discord-gateway-dispatch","testCase":"message-create-routing","workerMethod":"ingestMessageCreate","action":"dispatch","dedupeKey":"discord:gateway:create:223456789012345678","syncConnectionId":"00000000-0000-4000-8000-000000000001","expected":"one inbound dispatch for both-direction link","actual":"1 dispatches"} +{"timestamp":"2026-03-16T14:39:52.824Z","suite":"discord-gateway-dispatch","testCase":"message-create-routing","workerMethod":"ingestMessageCreate","action":"dispatch","dedupeKey":"discord:gateway:create:223456789012345678","syncConnectionId":"00000000-0000-4000-8000-000000000001","expected":"one inbound dispatch for both-direction link","actual":"1 dispatches"} +{"timestamp":"2026-03-16T14:39:57.213Z","suite":"chat-sync-core-worker.integration","testCase":"hazel-message-create-update-delete","workerMethod":"syncHazelMessageToProvider","action":"create","syncConnectionId":"4921e0de-a39a-4490-9568-c7f81d1fa558","expected":"synced","actual":"synced"} +{"timestamp":"2026-03-16T14:39:57.997Z","suite":"chat-sync-core-worker.integration","testCase":"direction-inactive-webhook-origin-guards","workerMethod":"syncHazelMessageCreateToAllConnections","action":"direction_gate","expected":"1 synced","actual":"1 synced"} +{"timestamp":"2026-03-16T14:48:20.247Z","suite":"discord-gateway-dispatch","testCase":"message-create-routing","workerMethod":"ingestMessageCreate","action":"dispatch","dedupeKey":"discord:gateway:create:223456789012345678","syncConnectionId":"00000000-0000-4000-8000-000000000001","expected":"one inbound dispatch for both-direction link","actual":"1 dispatches"} +{"timestamp":"2026-03-16T14:48:28.056Z","suite":"chat-sync-core-worker.integration","testCase":"hazel-message-create-update-delete","workerMethod":"syncHazelMessageToProvider","action":"create","syncConnectionId":"58d534a7-35af-470d-8a28-89220a0988a3","expected":"synced","actual":"synced"} +{"timestamp":"2026-03-16T14:48:30.436Z","suite":"chat-sync-core-worker.integration","testCase":"direction-inactive-webhook-origin-guards","workerMethod":"syncHazelMessageCreateToAllConnections","action":"direction_gate","expected":"1 synced","actual":"1 synced"} diff --git a/apps/backend/package.json b/apps/backend/package.json index a0f97d41f..1c8ef8de5 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -24,14 +24,9 @@ "rebuild-channel-access": "bun run scripts/rebuild-channel-access.ts" }, "dependencies": { - "@effect/cluster": "catalog:effect", - "@effect/experimental": "catalog:effect", "@effect/opentelemetry": "catalog:effect", - "@effect/platform": "catalog:effect", "@effect/platform-bun": "catalog:effect", "@effect/platform-node": "catalog:effect", - "@effect/rpc": "catalog:effect", - "@effect/sql": "catalog:effect", "@effect/sql-pg": "catalog:effect", "@hazel/auth": "workspace:*", "@hazel/backend-core": "workspace:*", @@ -41,14 +36,13 @@ "@hazel/integrations": "workspace:*", "@hazel/schema": "workspace:*", "@workos-inc/node": "^7.77.0", - "dfx": "^0.127.0", + "dfx": "^1.0.10", "drizzle-orm": "^0.45.1", "effect": "catalog:effect", "jose": "^6.1.3", "pg": "^8.16.3" }, "devDependencies": { - "@effect/language-service": "catalog:effect", "@testcontainers/postgresql": "^10.18.0", "@types/bun": "1.3.9", "drizzle-kit": "^0.31.8", diff --git a/apps/backend/scripts/create-bot.ts b/apps/backend/scripts/create-bot.ts index 69430835a..a9d824ff0 100644 --- a/apps/backend/scripts/create-bot.ts +++ b/apps/backend/scripts/create-bot.ts @@ -15,7 +15,7 @@ import { Database, schema } from "@hazel/db" import type { BotId, BotInstallationId, OrganizationId, OrganizationMemberId, UserId } from "@hazel/schema" -import { Effect, Logger, LogLevel } from "effect" +import { Effect, References } from "effect" import { randomUUID } from "crypto" import { DatabaseLive } from "../src/services/database" @@ -138,7 +138,7 @@ const createBotScript = Effect.gen(function* () { // Run the script with proper Effect runtime const runnable = createBotScript.pipe( Effect.provide(DatabaseLive), - Effect.provide(Logger.minimumLogLevel(LogLevel.Info)), + Effect.provideService(References.MinimumLogLevel, "Info"), ) Effect.runPromise(runnable).catch((error) => { diff --git a/apps/backend/scripts/rebuild-channel-access.ts b/apps/backend/scripts/rebuild-channel-access.ts index af293218e..39a49092f 100644 --- a/apps/backend/scripts/rebuild-channel-access.ts +++ b/apps/backend/scripts/rebuild-channel-access.ts @@ -1,7 +1,7 @@ #!/usr/bin/env bun import { and, Database, eq, isNull, ne, schema } from "@hazel/db" -import { Effect, Layer, Logger, LogLevel } from "effect" +import { Effect, Layer, References } from "effect" import { ChannelAccessSyncService } from "../src/services/channel-access-sync" import { DatabaseLive } from "../src/services/database" @@ -21,7 +21,7 @@ const rebuildChannelAccess = Effect.gen(function* () { yield* Effect.forEach( activeNonThreadChannels, - (channel) => ChannelAccessSyncService.syncChannel(channel.id), + (channel) => ChannelAccessSyncService.use((service) => service.syncChannel(channel.id)), { concurrency: 20 }, ) @@ -32,9 +32,13 @@ const rebuildChannelAccess = Effect.gen(function* () { .where(and(isNull(schema.channelsTable.deletedAt), eq(schema.channelsTable.type, "thread"))), ) - yield* Effect.forEach(threadChannels, (channel) => ChannelAccessSyncService.syncChannel(channel.id), { - concurrency: 20, - }) + yield* Effect.forEach( + threadChannels, + (channel) => ChannelAccessSyncService.use((service) => service.syncChannel(channel.id)), + { + concurrency: 20, + }, + ) const countResult = yield* db.execute( (client) => client.$client`SELECT COUNT(*)::int AS count FROM channel_access`, @@ -46,13 +50,13 @@ const rebuildChannelAccess = Effect.gen(function* () { ) }) -const ChannelAccessSyncLive = ChannelAccessSyncService.Default.pipe(Layer.provideMerge(DatabaseLive)) +const ChannelAccessSyncLive = ChannelAccessSyncService.layer.pipe(Layer.provideMerge(DatabaseLive)) Effect.runPromise( rebuildChannelAccess.pipe( Effect.provide(ChannelAccessSyncLive), Effect.provide(DatabaseLive), - Effect.provide(Logger.minimumLogLevel(LogLevel.Info)), + Effect.provideService(References.MinimumLogLevel, "Info"), ), ).catch((error) => { console.error("Failed to rebuild channel_access", error) diff --git a/apps/backend/scripts/reset-all.ts b/apps/backend/scripts/reset-all.ts index c724bea2e..c47c813fb 100644 --- a/apps/backend/scripts/reset-all.ts +++ b/apps/backend/scripts/reset-all.ts @@ -2,7 +2,7 @@ import { Database } from "@hazel/db" import { WorkOSClient } from "@hazel/backend-core" -import { Effect, Logger, LogLevel } from "effect" +import { Effect, References } from "effect" import { DatabaseLive } from "../src/services/database" // Parse command line arguments @@ -257,8 +257,8 @@ const resetScript = Effect.gen(function* () { // Run the script with proper Effect runtime const runnable = resetScript.pipe( Effect.provide(DatabaseLive), - Effect.provide(WorkOSClient.Default), - Effect.provide(Logger.minimumLogLevel(LogLevel.Info)), + Effect.provide(WorkOSClient.layer), + Effect.provideService(References.MinimumLogLevel, "Info"), ) Effect.runPromise(runnable).catch((error) => { diff --git a/apps/backend/scripts/seed-internal-bots.ts b/apps/backend/scripts/seed-internal-bots.ts index 85c34733f..8e4887041 100644 --- a/apps/backend/scripts/seed-internal-bots.ts +++ b/apps/backend/scripts/seed-internal-bots.ts @@ -15,7 +15,7 @@ import { Database, schema } from "@hazel/db" import type { BotId, UserId } from "@hazel/schema" import { createHash, randomUUID } from "crypto" import { eq } from "drizzle-orm" -import { Effect, Logger, LogLevel } from "effect" +import { Effect, References } from "effect" import { DatabaseLive } from "../src/services/database" // Fixed namespace for deterministic UUID generation (DNS namespace UUID) @@ -217,7 +217,7 @@ const seedInternalBots = Effect.gen(function* () { // Run the script const runnable = seedInternalBots.pipe( Effect.provide(DatabaseLive), - Effect.provide(Logger.minimumLogLevel(LogLevel.Info)), + Effect.provideService(References.MinimumLogLevel, "Info"), ) Effect.runPromise(runnable).catch((error) => { diff --git a/apps/backend/scripts/setup.ts b/apps/backend/scripts/setup.ts index a65f5bdb6..098ebf24a 100644 --- a/apps/backend/scripts/setup.ts +++ b/apps/backend/scripts/setup.ts @@ -9,7 +9,7 @@ import { WorkOSClient, WorkOSSync, } from "@hazel/backend-core" -import { Effect, Layer, Logger, LogLevel } from "effect" +import { Effect, Layer, References } from "effect" import { DatabaseLive } from "../src/services/database" // ANSI color codes @@ -110,20 +110,20 @@ const setupScript = Effect.gen(function* () { // Run the script // Build layers with proper dependency wiring const RepoLive = Layer.mergeAll( - UserRepo.Default, - OrganizationRepo.Default, - OrganizationMemberRepo.Default, - InvitationRepo.Default, + UserRepo.layer, + OrganizationRepo.layer, + OrganizationMemberRepo.layer, + InvitationRepo.layer, ).pipe(Layer.provideMerge(DatabaseLive)) -const MainLive = Layer.mergeAll(WorkOSSync.Default, WorkOSClient.Default).pipe( +const MainLive = Layer.mergeAll(WorkOSSync.layer, WorkOSClient.layer).pipe( Layer.provideMerge(RepoLive), Layer.provideMerge(DatabaseLive), ) const runnable = setupScript.pipe( Effect.provide(MainLive), - Effect.provide(Logger.minimumLogLevel(LogLevel.Info)), + Effect.provideService(References.MinimumLogLevel, "Info"), ) Effect.runPromise(runnable).catch((error) => { diff --git a/apps/backend/scripts/test-mock-data.ts b/apps/backend/scripts/test-mock-data.ts index 5f84b4a25..7c7c4655d 100644 --- a/apps/backend/scripts/test-mock-data.ts +++ b/apps/backend/scripts/test-mock-data.ts @@ -168,7 +168,7 @@ const runTests = Effect.gen(function* () { yield* Console.error("\n⚠️ Some tests failed") } }).pipe( - Effect.catchAll((error) => + Effect.catch((error) => Effect.gen(function* () { yield* Console.error("\n❌ Test suite error:", error) yield* Console.error("\n💡 Make sure the backend is running on port 3003") diff --git a/apps/backend/src/http.ts b/apps/backend/src/http.ts index ef17685ca..5efd037a6 100644 --- a/apps/backend/src/http.ts +++ b/apps/backend/src/http.ts @@ -1,5 +1,5 @@ -import { HttpLayerRouter } from "@effect/platform" import { Layer } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" import { HazelApi } from "./api" import { HttpMessagesApiLive } from "./routes/api-v1" import { HttpAuthLive } from "./routes/auth.http" @@ -17,7 +17,7 @@ import { HttpRootLive } from "./routes/root.http" import { HttpUploadsLive } from "./routes/uploads.http" import { HttpWebhookLive } from "./routes/webhooks.http" -export const HttpApiRoutes = HttpLayerRouter.addHttpApi(HazelApi).pipe( +export const HttpApiRoutes = HttpApiBuilder.layer(HazelApi).pipe( Layer.provide(HttpRootLive), Layer.provide(HttpAuthLive), Layer.provide(HttpMessagesApiLive), diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index c1443e386..6c20b46e5 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -1,12 +1,7 @@ -import { - FetchHttpClient, - HttpApiScalar, - HttpLayerRouter, - HttpMiddleware, - HttpServerResponse, -} from "@effect/platform" +import { HttpApiScalar } from "effect/unstable/httpapi" +import { FetchHttpClient, HttpRouter, HttpMiddleware, HttpServerResponse } from "effect/unstable/http" import { BunHttpServer, BunRuntime } from "@effect/platform-bun" -import { RpcSerialization, RpcServer } from "@effect/rpc" +import { RpcSerialization, RpcServer } from "effect/unstable/rpc" import { AttachmentRepo, BotCommandRepo, @@ -46,7 +41,7 @@ import { import { Redis, RedisResultPersistenceLive, S3 } from "@hazel/effect-bun" import { createTracingLayer } from "@hazel/effect-bun/Telemetry" import { GitHub } from "@hazel/integrations" -import { Config, ConfigProvider, Effect, Layer, Scope } from "effect" +import { Config, ConfigProvider, Effect, Layer, ServiceMap } from "effect" import { HazelApi } from "./api" import { HttpApiRoutes } from "./http" import { AttachmentPolicy } from "./policies/attachment-policy" @@ -75,7 +70,7 @@ import { DatabaseLive } from "./services/database" import { IntegrationTokenService } from "./services/integration-token-service" import { IntegrationBotService } from "./services/integrations/integration-bot-service" import { ChatSyncAttributionReconciler } from "./services/chat-sync/chat-sync-attribution-reconciler" -import { DiscordSyncWorker } from "./services/chat-sync/discord-sync-worker" +import { DiscordSyncWorkerLayer } from "./services/chat-sync/discord-sync-worker" import { DiscordGatewayService } from "./services/chat-sync/discord-gateway-service" import { MessageOutboxDispatcher } from "./services/message-outbox-dispatcher" import { MessageSideEffectService } from "./services/message-side-effect-service" @@ -85,6 +80,7 @@ import { RateLimiter } from "./services/rate-limiter" import { SessionManager } from "./services/session-manager" import { WebhookBotService } from "./services/webhook-bot-service" import { BotGatewayService } from "./services/bot-gateway-service" +import { AuthRedemptionStore } from "./services/auth-redemption-store" import { ChannelAccessSyncService } from "./services/channel-access-sync" import { ConnectConversationService } from "./services/connect-conversation-service" import { OrgResolver } from "./services/org-resolver" @@ -96,17 +92,14 @@ export { HazelApi } // Export RPC groups for frontend consumption export { AuthMiddleware, InvitationRpcs, MessageRpcs, NotificationRpcs } from "@hazel/domain/rpc" -const HealthRouter = HttpLayerRouter.use((router) => - router.add("GET", "/health", HttpServerResponse.text("OK")), -) +const HealthRouter = HttpRouter.use((router) => router.add("GET", "/health", HttpServerResponse.text("OK"))) -const DocsRoute = HttpApiScalar.layerHttpLayerRouter({ - api: HazelApi, +const DocsRoute = HttpApiScalar.layer(HazelApi, { path: "/docs", }) // HTTP RPC endpoint -const RpcRoute = RpcServer.layerHttpRouter({ +const RpcRoute = RpcServer.layerHttp({ group: AllRpcs, path: "/rpc", protocol: "http", @@ -114,7 +107,7 @@ const RpcRoute = RpcServer.layerHttpRouter({ const AllRoutes = Layer.mergeAll(HttpApiRoutes, HealthRouter, DocsRoute, RpcRoute).pipe( Layer.provide( - HttpLayerRouter.cors({ + HttpRouter.cors({ allowedOrigins: [ "http://localhost:3000", "http://localhost:5173", @@ -131,62 +124,62 @@ const AllRoutes = Layer.mergeAll(HttpApiRoutes, HealthRouter, DocsRoute, RpcRout const TracerLive = createTracingLayer("api") const RepoLive = Layer.mergeAll( - MessageRepo.Default, - ChannelRepo.Default, - ChannelMemberRepo.Default, - ChannelSectionRepo.Default, - ChatSyncConnectionRepo.Default, - ChatSyncChannelLinkRepo.Default, - ChatSyncMessageLinkRepo.Default, - ChatSyncEventReceiptRepo.Default, - ConnectConversationRepo.Default, - ConnectConversationChannelRepo.Default, - ConnectInviteRepo.Default, - ConnectParticipantRepo.Default, - UserRepo.Default, - OrganizationRepo.Default, - OrganizationMemberRepo.Default, - InvitationRepo.Default, - PinnedMessageRepo.Default, - AttachmentRepo.Default, - NotificationRepo.Default, - TypingIndicatorRepo.Default, - MessageReactionRepo.Default, - MessageOutboxRepo.Default, - UserPresenceStatusRepo.Default, - IntegrationConnectionRepo.Default, - IntegrationTokenRepo.Default, - ChannelWebhookRepo.Default, - GitHubSubscriptionRepo.Default, - RssSubscriptionRepo.Default, - BotRepo.Default, - BotCommandRepo.Default, - BotInstallationRepo.Default, - CustomEmojiRepo.Default, + MessageRepo.layer, + ChannelRepo.layer, + ChannelMemberRepo.layer, + ChannelSectionRepo.layer, + ChatSyncConnectionRepo.layer, + ChatSyncChannelLinkRepo.layer, + ChatSyncMessageLinkRepo.layer, + ChatSyncEventReceiptRepo.layer, + ConnectConversationRepo.layer, + ConnectConversationChannelRepo.layer, + ConnectInviteRepo.layer, + ConnectParticipantRepo.layer, + UserRepo.layer, + OrganizationRepo.layer, + OrganizationMemberRepo.layer, + InvitationRepo.layer, + PinnedMessageRepo.layer, + AttachmentRepo.layer, + NotificationRepo.layer, + TypingIndicatorRepo.layer, + MessageReactionRepo.layer, + MessageOutboxRepo.layer, + UserPresenceStatusRepo.layer, + IntegrationConnectionRepo.layer, + IntegrationTokenRepo.layer, + ChannelWebhookRepo.layer, + GitHubSubscriptionRepo.layer, + RssSubscriptionRepo.layer, + BotRepo.layer, + BotCommandRepo.layer, + BotInstallationRepo.layer, + CustomEmojiRepo.layer, ) const PolicyLive = Layer.mergeAll( - OrgResolver.Default, - OrganizationPolicy.Default, - ChannelPolicy.Default, - ChannelSectionPolicy.Default, - MessagePolicy.Default, - InvitationPolicy.Default, - OrganizationMemberPolicy.Default, - ChannelMemberPolicy.Default, - MessageReactionPolicy.Default, - UserPolicy.Default, - AttachmentPolicy.Default, - PinnedMessagePolicy.Default, - TypingIndicatorPolicy.Default, - NotificationPolicy.Default, - UserPresenceStatusPolicy.Default, - IntegrationConnectionPolicy.Default, - ChannelWebhookPolicy.Default, - GitHubSubscriptionPolicy.Default, - RssSubscriptionPolicy.Default, - BotPolicy.Default, - CustomEmojiPolicy.Default, + OrgResolver.layer, + OrganizationPolicy.layer, + ChannelPolicy.layer, + ChannelSectionPolicy.layer, + MessagePolicy.layer, + InvitationPolicy.layer, + OrganizationMemberPolicy.layer, + ChannelMemberPolicy.layer, + MessageReactionPolicy.layer, + UserPolicy.layer, + AttachmentPolicy.layer, + PinnedMessagePolicy.layer, + TypingIndicatorPolicy.layer, + NotificationPolicy.layer, + UserPresenceStatusPolicy.layer, + IntegrationConnectionPolicy.layer, + ChannelWebhookPolicy.layer, + GitHubSubscriptionPolicy.layer, + RssSubscriptionPolicy.layer, + BotPolicy.layer, + CustomEmojiPolicy.layer, ) // ResultPersistence layer for session caching (uses Redis backing) @@ -195,48 +188,52 @@ const PersistenceLive = RedisResultPersistenceLive.pipe(Layer.provide(Redis.Defa const MainLive = Layer.mergeAll( RepoLive, PolicyLive, - MockDataGenerator.Default, - WorkOSAuth.Default, - WorkOSClient.Default, - WorkOSSync.Default, - WorkOSWebhookVerifier.Default, + MockDataGenerator.layer, + WorkOSAuth.layer, + AuthRedemptionStore.layer, + WorkOSClient.layer, + WorkOSSync.layer, + WorkOSWebhookVerifier.layer, DatabaseLive, S3.Default, Redis.Default, PersistenceLive, - GitHub.GitHubAppJWTService.Default, - GitHub.GitHubApiClient.Default, - IntegrationTokenService.Default, - OAuthProviderRegistry.Default, - IntegrationBotService.Default, - ChatSyncAttributionReconciler.Default, - DiscordSyncWorker.Default, - DiscordGatewayService.Default, - MessageSideEffectService.Default, - MessageOutboxDispatcher.Default, - BotGatewayService.Default, - WebhookBotService.Default, - ChannelAccessSyncService.Default, - ConnectConversationService.Default, - RateLimiter.Default, - // SessionManager.Default includes BackendAuth.Default via dependencies - SessionManager.Default, + GitHub.GitHubAppJWTService.layer, + GitHub.GitHubApiClient.layer, + IntegrationTokenService.layer, + OAuthProviderRegistry.layer, + IntegrationBotService.layer, + ChatSyncAttributionReconciler.layer, + DiscordSyncWorkerLayer, + DiscordGatewayService.layer, + MessageSideEffectService.layer, + MessageOutboxDispatcher.layer, + BotGatewayService.layer, + WebhookBotService.layer, + ChannelAccessSyncService.layer, + ConnectConversationService.layer, + RateLimiter.layer, + // SessionManager.layer includes BackendAuth.layer via dependencies + SessionManager.layer, ).pipe( Layer.provideMerge(FetchHttpClient.layer), - Layer.provideMerge(Layer.setConfigProvider(ConfigProvider.fromEnv())), + Layer.provideMerge(ConfigProvider.layer(ConfigProvider.fromEnv())), ) -const ServerLayer = HttpLayerRouter.serve(AllRoutes).pipe( - HttpMiddleware.withTracerDisabledWhen( - (request) => request.url === "/health" || request.method === "OPTIONS", +const ServerLayer = HttpRouter.serve(AllRoutes).pipe( + Layer.provide( + Layer.succeed( + HttpMiddleware.TracerDisabledWhen, + (request) => request.url === "/health" || request.method === "OPTIONS", + ), ), Layer.provide(MainLive), Layer.provide(TracerLive), Layer.provide( AuthorizationLive.pipe( - // SessionManager.Default includes BackendAuth and UserRepo via dependencies - Layer.provideMerge(SessionManager.Default), - Layer.provideMerge(WorkOSAuth.Default), + // SessionManager.layer includes BackendAuth and UserRepo via dependencies + Layer.provideMerge(SessionManager.layer), + Layer.provideMerge(WorkOSAuth.layer), Layer.provideMerge(PersistenceLive), Layer.provideMerge(Redis.Default), Layer.provideMerge(DatabaseLive), @@ -252,8 +249,9 @@ const ServerLayer = HttpLayerRouter.serve(AllRoutes).pipe( ), ) -const ServerProgram = Effect.scoped( - ServerLayer.pipe(Layer.launch) as unknown as Effect.Effect, -) - -BunRuntime.runMain(ServerProgram) +// The `as never` cast is required because ChatSyncCoreWorkerMake (in chat-sync-core-worker.ts) +// is explicitly typed as Effect<..., unknown, unknown> to break a circular type dependency. +// Those `unknown` types propagate through DiscordSyncWorkerLayer -> ServiceLive -> MainLive -> ServerLayer, +// causing TypeScript to collapse the layer's type parameters to `unknown`. +// All actual dependencies are wired correctly at runtime. +ServerLayer.pipe(Layer.launch as never, BunRuntime.runMain) diff --git a/apps/backend/src/lib/create-transactionId.ts b/apps/backend/src/lib/create-transactionId.ts index f77b64e2b..b5af8b6d7 100644 --- a/apps/backend/src/lib/create-transactionId.ts +++ b/apps/backend/src/lib/create-transactionId.ts @@ -1,6 +1,6 @@ import { Database } from "@hazel/db" import { TransactionIdFromString } from "@hazel/schema" -import { Effect, Option, Schema } from "effect" +import { Effect, Option, Predicate, Schema } from "effect" export const generateTransactionId = Effect.fn("generateTransactionId")(function* ( tx?: ( @@ -30,14 +30,15 @@ export const generateTransactionId = Effect.fn("generateTransactionId")(function Effect.tap((rawTxid) => Effect.log(`[txid-debug] Raw PostgreSQL txid string: "${rawTxid}", type: ${typeof rawTxid}`), ), - Effect.flatMap((txid) => Schema.decode(TransactionIdFromString)(txid)), + Effect.flatMap((txid) => Schema.decodeEffect(TransactionIdFromString)(txid)), Effect.tap((decodedTxid) => Effect.log(`[txid-debug] Decoded transactionId: ${decodedTxid}, type: ${typeof decodedTxid}`), ), - Effect.catchTags({ - DatabaseError: (err) => Effect.die(`Database error generating transaction ID: ${err}`), - ParseError: (err) => Effect.die(`Failed to parse transaction ID: ${err}`), - }), + Effect.catchIf( + (e): e is Database.DatabaseError => Predicate.isTagged(e, "DatabaseError"), + (err) => Effect.die(`Database error generating transaction ID: ${err}`), + ), + Effect.catchTag("SchemaError", (err) => Effect.die(`Failed to parse transaction ID: ${err}`)), ) return result diff --git a/apps/backend/src/lib/env-vars.ts b/apps/backend/src/lib/env-vars.ts index a6f2444eb..ba7099caf 100644 --- a/apps/backend/src/lib/env-vars.ts +++ b/apps/backend/src/lib/env-vars.ts @@ -1,12 +1,15 @@ import * as Config from "effect/Config" import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as ServiceMap from "effect/ServiceMap" -export class EnvVars extends Effect.Service()("EnvVars", { - accessors: true, - effect: Effect.gen(function* () { +export class EnvVars extends ServiceMap.Service()("EnvVars", { + make: Effect.gen(function* () { return { IS_DEV: yield* Config.boolean("IS_DEV").pipe(Config.withDefault(false)), DATABASE_URL: yield* Config.redacted("DATABASE_URL"), } as const }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/apps/backend/src/lib/policy-utils.test.ts b/apps/backend/src/lib/policy-utils.test.ts index 6b3497bb8..0e8f9c79b 100644 --- a/apps/backend/src/lib/policy-utils.test.ts +++ b/apps/backend/src/lib/policy-utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest" import { UnauthorizedError } from "@hazel/domain" -import { Effect, Either } from "effect" +import { Effect, Result } from "effect" import { makePolicy, withPolicyUnauthorized } from "./policy-utils.ts" import { makeActor } from "../policies/policy-test-helpers.ts" import { CurrentUser } from "@hazel/domain" @@ -12,11 +12,11 @@ describe("policy-utils", () => { const result = await Effect.runPromise( authorize("read", () => Effect.succeed(true)).pipe( Effect.provideService(CurrentUser.Context, makeActor()), - Effect.either, + Effect.result, ), ) - expect(Either.isRight(result)).toBe(true) + expect(Result.isSuccess(result)).toBe(true) }) it("fails with UnauthorizedError when check denies", async () => { @@ -24,13 +24,13 @@ describe("policy-utils", () => { const result = await Effect.runPromise( authorize("read", () => Effect.succeed(false)).pipe( Effect.provideService(CurrentUser.Context, makeActor()), - Effect.either, + Effect.result, ), ) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(UnauthorizedError.is(result.left)).toBe(true) + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + expect(UnauthorizedError.is(result.failure)).toBe(true) } }) @@ -39,13 +39,13 @@ describe("policy-utils", () => { const result = await Effect.runPromise( authorize("read", () => Effect.fail({ _tag: "DatabaseError" as const })).pipe( Effect.provideService(CurrentUser.Context, makeActor()), - Effect.either, + Effect.result, ), ) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(UnauthorizedError.is(result.left)).toBe(true) + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + expect(UnauthorizedError.is(result.failure)).toBe(true) } }) }) @@ -60,13 +60,13 @@ describe("policy-utils", () => { const result = await Effect.runPromise( withPolicyUnauthorized("Widget", "read", Effect.fail(existing)).pipe( Effect.provideService(CurrentUser.Context, makeActor()), - Effect.either, + Effect.result, ), ) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(result.left).toBe(existing) + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + expect(result.failure).toBe(existing) } }) }) diff --git a/apps/backend/src/lib/policy-utils.ts b/apps/backend/src/lib/policy-utils.ts index 3022cb15c..6ee70ad92 100644 --- a/apps/backend/src/lib/policy-utils.ts +++ b/apps/backend/src/lib/policy-utils.ts @@ -1,7 +1,7 @@ import { CurrentUser, ErrorUtils, policy } from "@hazel/domain" import type { ApiScope } from "@hazel/domain/scopes" import { CurrentRpcScopes } from "@hazel/domain/scopes" -import { Effect, FiberRef } from "effect" +import { Effect } from "effect" export type OrganizationRole = "admin" | "member" | "owner" @@ -27,8 +27,8 @@ export const makePolicy = export const withPolicyUnauthorized = ( entity: string, action: string, - effect: Effect.Effect, -) => ErrorUtils.refailUnauthorized(entity, action)(effect) + make: Effect.Effect, +) => ErrorUtils.refailUnauthorized(entity, action)(make) /** * Reads the annotated scope from CurrentRpcScopes (injected by ScopeInjectionMiddleware) @@ -44,17 +44,18 @@ export const withPolicyUnauthorized = ( */ export const withAnnotatedScope = ( fn: (scope: ApiScope) => Effect.Effect, -): Effect.Effect => - Effect.flatMap(FiberRef.get(CurrentRpcScopes), (scopes) => { +): Effect.Effect => + Effect.gen(function* () { + const scopes = yield* CurrentRpcScopes if (scopes.length === 0) { - return Effect.die( + return yield* Effect.die( new Error("No RequiredScopes annotation on this RPC — cannot resolve annotated scope"), ) } if (scopes.length > 1) { - return Effect.die( + return yield* Effect.die( new Error(`withAnnotatedScope only supports single-scope RPCs; got [${scopes.join(", ")}]`), ) } - return fn(scopes[0]!) + return yield* fn(scopes[0]!) }) diff --git a/apps/backend/src/lib/schema.ts b/apps/backend/src/lib/schema.ts index 4a8dee3a8..b796c21de 100644 --- a/apps/backend/src/lib/schema.ts +++ b/apps/backend/src/lib/schema.ts @@ -1,11 +1,9 @@ import { Schema } from "effect" -export const RelativeUrl = Schema.String.pipe( - Schema.nonEmptyString(), - Schema.startsWith("/"), - Schema.filter((url) => !url.startsWith("//"), { - message: () => "Protocol-relative URLs are not allowed", - }), +export const RelativeUrl = Schema.String.check( + Schema.isNonEmpty(), + Schema.isStartsWith("/"), + Schema.makeFilter((url: string) => !url.startsWith("//") || "Protocol-relative URLs are not allowed"), ) export const AuthState = Schema.Struct({ diff --git a/apps/backend/src/policies/attachment-policy.test.ts b/apps/backend/src/policies/attachment-policy.test.ts index f2c938a9d..dd06e818c 100644 --- a/apps/backend/src/policies/attachment-policy.test.ts +++ b/apps/backend/src/policies/attachment-policy.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest" import { AttachmentRepo, ChannelMemberRepo, ChannelRepo, MessageRepo } from "@hazel/backend-core" import type { AttachmentId, ChannelId, MessageId, OrganizationId, UserId } from "@hazel/schema" -import { Effect, Either, Layer, Option } from "effect" +import { Effect, Result, Layer, Option } from "effect" import { AttachmentPolicy } from "./attachment-policy.ts" import { makeActor, @@ -9,76 +9,92 @@ import { makeOrganizationMemberRepoLayer, makeOrgResolverLayer, runWithActorEither, + serviceShape, TEST_ORG_ID, TEST_USER_ID, } from "./policy-test-helpers.ts" type Role = "admin" | "member" | "owner" -const ATTACHMENT_ID = "00000000-0000-0000-0000-000000000831" as AttachmentId -const MESSAGE_ID = "00000000-0000-0000-0000-000000000832" as MessageId -const CHANNEL_ID = "00000000-0000-0000-0000-000000000833" as ChannelId -const OTHER_USER_ID = "00000000-0000-0000-0000-000000000834" as UserId -const ADMIN_USER_ID = "00000000-0000-0000-0000-000000000835" as UserId -const MESSAGE_AUTHOR_ID = "00000000-0000-0000-0000-000000000836" as UserId +const ATTACHMENT_ID = "00000000-0000-4000-8000-000000000831" as AttachmentId +const MESSAGE_ID = "00000000-0000-4000-8000-000000000832" as MessageId +const CHANNEL_ID = "00000000-0000-4000-8000-000000000833" as ChannelId +const OTHER_USER_ID = "00000000-0000-4000-8000-000000000834" as UserId +const ADMIN_USER_ID = "00000000-0000-4000-8000-000000000835" as UserId +const MESSAGE_AUTHOR_ID = "00000000-0000-4000-8000-000000000836" as UserId const makeAttachmentRepoLayer = ( attachments: Record, ) => - Layer.succeed(AttachmentRepo, { - with: ( - id: AttachmentId, - f: (attachment: { uploadedBy: UserId; messageId: MessageId | null }) => Effect.Effect, - ) => { - const attachment = attachments[id] - if (!attachment) { - return Effect.fail(makeEntityNotFound("Attachment")) - } - return f(attachment) - }, - } as unknown as AttachmentRepo) + Layer.succeed( + AttachmentRepo, + serviceShape({ + with: ( + id: AttachmentId, + f: (attachment: { + uploadedBy: UserId + messageId: MessageId | null + }) => Effect.Effect, + ) => { + const attachment = attachments[id] + if (!attachment) { + return Effect.fail(makeEntityNotFound("Attachment")) + } + return f(attachment) + }, + }), + ) const makeMessageRepoLayer = (messages: Record) => - Layer.succeed(MessageRepo, { - with: ( - id: MessageId, - f: (message: { authorId: UserId; channelId: ChannelId }) => Effect.Effect, - ) => { - const message = messages[id] - if (!message) { - return Effect.fail(makeEntityNotFound("Message")) - } - return f(message) - }, - } as unknown as MessageRepo) + Layer.succeed( + MessageRepo, + serviceShape({ + with: ( + id: MessageId, + f: (message: { authorId: UserId; channelId: ChannelId }) => Effect.Effect, + ) => { + const message = messages[id] + if (!message) { + return Effect.fail(makeEntityNotFound("Message")) + } + return f(message) + }, + }), + ) const makeChannelRepoLayer = ( channels: Record, ) => - Layer.succeed(ChannelRepo, { - with: ( - id: ChannelId, - f: (channel: { - organizationId: OrganizationId - type: string - id: ChannelId - }) => Effect.Effect, - ) => { - const channel = channels[id] - if (!channel) { - return Effect.fail(makeEntityNotFound("Channel")) - } - return f(channel) - }, - } as unknown as ChannelRepo) + Layer.succeed( + ChannelRepo, + serviceShape({ + with: ( + id: ChannelId, + f: (channel: { + organizationId: OrganizationId + type: string + id: ChannelId + }) => Effect.Effect, + ) => { + const channel = channels[id] + if (!channel) { + return Effect.fail(makeEntityNotFound("Channel")) + } + return f(channel) + }, + }), + ) const makeChannelMemberRepoLayer = (memberships: Record) => - Layer.succeed(ChannelMemberRepo, { - findByChannelAndUser: (channelId: ChannelId, userId: UserId) => { - const key = `${channelId}:${userId}` - return Effect.succeed(memberships[key] ? Option.some({ channelId, userId }) : Option.none()) - }, - } as unknown as ChannelMemberRepo) + Layer.succeed( + ChannelMemberRepo, + serviceShape({ + findByChannelAndUser: (channelId: ChannelId, userId: UserId) => { + const key = `${channelId}:${userId}` + return Effect.succeed(memberships[key] ? Option.some({ channelId, userId }) : Option.none()) + }, + }), + ) const makePolicyLayer = (opts: { members?: Record @@ -87,7 +103,7 @@ const makePolicyLayer = (opts: { channels?: Record channelMemberships?: Record }) => - AttachmentPolicy.DefaultWithoutDependencies.pipe( + Layer.effect(AttachmentPolicy, AttachmentPolicy.make).pipe( Layer.provide(makeAttachmentRepoLayer(opts.attachments ?? {})), Layer.provide(makeMessageRepoLayer(opts.messages ?? {})), Layer.provide(makeChannelRepoLayer(opts.channels ?? {})), @@ -101,8 +117,12 @@ describe("AttachmentPolicy", () => { const actor = makeActor() const layer = makePolicyLayer({}) - const result = await runWithActorEither(AttachmentPolicy.canCreate(), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + AttachmentPolicy.use((policy) => policy.canCreate()), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canUpdate allows uploader", async () => { @@ -113,8 +133,12 @@ describe("AttachmentPolicy", () => { }, }) - const result = await runWithActorEither(AttachmentPolicy.canUpdate(ATTACHMENT_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + AttachmentPolicy.use((policy) => policy.canUpdate(ATTACHMENT_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canUpdate denies non-uploader", async () => { @@ -125,8 +149,12 @@ describe("AttachmentPolicy", () => { }, }) - const result = await runWithActorEither(AttachmentPolicy.canUpdate(ATTACHMENT_ID), layer, actor) - expect(Either.isLeft(result)).toBe(true) + const result = await runWithActorEither( + AttachmentPolicy.use((policy) => policy.canUpdate(ATTACHMENT_ID)), + layer, + actor, + ) + expect(Result.isFailure(result)).toBe(true) }) it("canDelete without messageId allows uploader", async () => { @@ -137,8 +165,12 @@ describe("AttachmentPolicy", () => { }, }) - const result = await runWithActorEither(AttachmentPolicy.canDelete(ATTACHMENT_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + AttachmentPolicy.use((policy) => policy.canDelete(ATTACHMENT_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canDelete without messageId denies other user", async () => { @@ -149,8 +181,12 @@ describe("AttachmentPolicy", () => { }, }) - const result = await runWithActorEither(AttachmentPolicy.canDelete(ATTACHMENT_ID), layer, actor) - expect(Either.isLeft(result)).toBe(true) + const result = await runWithActorEither( + AttachmentPolicy.use((policy) => policy.canDelete(ATTACHMENT_ID)), + layer, + actor, + ) + expect(Result.isFailure(result)).toBe(true) }) it("canDelete with messageId allows uploader", async () => { @@ -167,8 +203,12 @@ describe("AttachmentPolicy", () => { }, }) - const result = await runWithActorEither(AttachmentPolicy.canDelete(ATTACHMENT_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + AttachmentPolicy.use((policy) => policy.canDelete(ATTACHMENT_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canDelete with messageId allows message author", async () => { @@ -185,8 +225,12 @@ describe("AttachmentPolicy", () => { }, }) - const result = await runWithActorEither(AttachmentPolicy.canDelete(ATTACHMENT_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + AttachmentPolicy.use((policy) => policy.canDelete(ATTACHMENT_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canDelete with messageId allows org admin", async () => { @@ -206,8 +250,12 @@ describe("AttachmentPolicy", () => { }, }) - const result = await runWithActorEither(AttachmentPolicy.canDelete(ATTACHMENT_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + AttachmentPolicy.use((policy) => policy.canDelete(ATTACHMENT_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canDelete with messageId denies random user", async () => { @@ -227,8 +275,12 @@ describe("AttachmentPolicy", () => { }, }) - const result = await runWithActorEither(AttachmentPolicy.canDelete(ATTACHMENT_ID), layer, actor) - expect(Either.isLeft(result)).toBe(true) + const result = await runWithActorEither( + AttachmentPolicy.use((policy) => policy.canDelete(ATTACHMENT_ID)), + layer, + actor, + ) + expect(Result.isFailure(result)).toBe(true) }) it("canView without messageId allows uploader", async () => { @@ -239,8 +291,12 @@ describe("AttachmentPolicy", () => { }, }) - const result = await runWithActorEither(AttachmentPolicy.canView(ATTACHMENT_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + AttachmentPolicy.use((policy) => policy.canView(ATTACHMENT_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canView without messageId denies other user", async () => { @@ -251,8 +307,12 @@ describe("AttachmentPolicy", () => { }, }) - const result = await runWithActorEither(AttachmentPolicy.canView(ATTACHMENT_ID), layer, actor) - expect(Either.isLeft(result)).toBe(true) + const result = await runWithActorEither( + AttachmentPolicy.use((policy) => policy.canView(ATTACHMENT_ID)), + layer, + actor, + ) + expect(Result.isFailure(result)).toBe(true) }) it("canView with public channel allows org member", async () => { @@ -272,8 +332,12 @@ describe("AttachmentPolicy", () => { }, }) - const result = await runWithActorEither(AttachmentPolicy.canView(ATTACHMENT_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + AttachmentPolicy.use((policy) => policy.canView(ATTACHMENT_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canView with private channel allows admin", async () => { @@ -293,8 +357,12 @@ describe("AttachmentPolicy", () => { }, }) - const result = await runWithActorEither(AttachmentPolicy.canView(ATTACHMENT_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + AttachmentPolicy.use((policy) => policy.canView(ATTACHMENT_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canView with private channel allows channel member", async () => { @@ -314,8 +382,12 @@ describe("AttachmentPolicy", () => { }, }) - const result = await runWithActorEither(AttachmentPolicy.canView(ATTACHMENT_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + AttachmentPolicy.use((policy) => policy.canView(ATTACHMENT_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canView with private channel denies non-member non-admin", async () => { @@ -335,7 +407,11 @@ describe("AttachmentPolicy", () => { }, }) - const result = await runWithActorEither(AttachmentPolicy.canView(ATTACHMENT_ID), layer, actor) - expect(Either.isLeft(result)).toBe(true) + const result = await runWithActorEither( + AttachmentPolicy.use((policy) => policy.canView(ATTACHMENT_ID)), + layer, + actor, + ) + expect(Result.isFailure(result)).toBe(true) }) }) diff --git a/apps/backend/src/policies/attachment-policy.ts b/apps/backend/src/policies/attachment-policy.ts index abd0555d0..1826c8c79 100644 --- a/apps/backend/src/policies/attachment-policy.ts +++ b/apps/backend/src/policies/attachment-policy.ts @@ -7,12 +7,12 @@ import { } from "@hazel/backend-core" import { ErrorUtils, policy } from "@hazel/domain" import type { AttachmentId } from "@hazel/schema" -import { Effect, Option } from "effect" +import { ServiceMap, Effect, Layer, Option } from "effect" import { isAdminOrOwner } from "../lib/policy-utils" import { OrgResolver } from "../services/org-resolver" -export class AttachmentPolicy extends Effect.Service()("AttachmentPolicy/Policy", { - effect: Effect.gen(function* () { +export class AttachmentPolicy extends ServiceMap.Service()("AttachmentPolicy/Policy", { + make: Effect.gen(function* () { const policyEntity = "Attachment" as const const attachmentRepo = yield* AttachmentRepo @@ -160,13 +160,13 @@ export class AttachmentPolicy extends Effect.Service()("Attach return { canCreate, canUpdate, canDelete, canView } as const }), - dependencies: [ - AttachmentRepo.Default, - MessageRepo.Default, - ChannelRepo.Default, - OrganizationMemberRepo.Default, - ChannelMemberRepo.Default, - OrgResolver.Default, - ], - accessors: true, -}) {} +}) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(AttachmentRepo.layer), + Layer.provide(MessageRepo.layer), + Layer.provide(ChannelRepo.layer), + Layer.provide(OrganizationMemberRepo.layer), + Layer.provide(ChannelMemberRepo.layer), + Layer.provide(OrgResolver.layer), + ) +} diff --git a/apps/backend/src/policies/bot-policy.test.ts b/apps/backend/src/policies/bot-policy.test.ts index db7e4c55d..6b543bd13 100644 --- a/apps/backend/src/policies/bot-policy.test.ts +++ b/apps/backend/src/policies/bot-policy.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "@effect/vitest" import { BotRepo } from "@hazel/backend-core" import { UnauthorizedError } from "@hazel/domain" import type { BotId, UserId } from "@hazel/schema" -import { Effect, Either, Layer } from "effect" +import { Effect, Result, Layer, ServiceMap } from "effect" import { BotPolicy } from "./bot-policy.ts" import { makeActor, @@ -15,8 +15,8 @@ import { type Role = "admin" | "member" | "owner" -const BOT_ID = "00000000-0000-0000-0000-000000000401" as BotId -const MISSING_BOT_ID = "00000000-0000-0000-0000-000000000499" as BotId +const BOT_ID = "00000000-0000-4000-8000-000000000401" as BotId +const MISSING_BOT_ID = "00000000-0000-4000-8000-000000000499" as BotId const makeBotRepoLayer = (bots: Record) => Layer.succeed(BotRepo, { @@ -27,10 +27,10 @@ const makeBotRepoLayer = (bots: Record) => } return f(bot) }, - } as unknown as BotRepo) + } as ServiceMap.Service.Shape) const makePolicyLayer = (members: Record, bots: Record) => - BotPolicy.DefaultWithoutDependencies.pipe( + Layer.effect(BotPolicy, BotPolicy.make).pipe( Layer.provide(makeOrgResolverLayer(members)), Layer.provide(makeBotRepoLayer(bots)), ) @@ -45,20 +45,28 @@ describe("BotPolicy", () => { {}, ) - const allowed = await runWithActorEither(BotPolicy.canCreate(TEST_ORG_ID), layer, actor) - const denied = await runWithActorEither(BotPolicy.canCreate(TEST_ALT_ORG_ID), layer, actor) + const allowed = await runWithActorEither( + BotPolicy.use((policy) => policy.canCreate(TEST_ORG_ID)), + layer, + actor, + ) + const denied = await runWithActorEither( + BotPolicy.use((policy) => policy.canCreate(TEST_ALT_ORG_ID)), + layer, + actor, + ) - expect(Either.isRight(allowed)).toBe(true) - expect(Either.isLeft(denied)).toBe(true) + expect(Result.isSuccess(allowed)).toBe(true) + expect(Result.isFailure(denied)).toBe(true) }) it("canRead allows creator or org admin", async () => { const creator = makeActor() const admin = makeActor({ - id: "00000000-0000-0000-0000-000000000402" as UserId, + id: "00000000-0000-4000-8000-000000000402" as UserId, }) const outsider = makeActor({ - id: "00000000-0000-0000-0000-000000000403" as UserId, + id: "00000000-0000-4000-8000-000000000403" as UserId, organizationId: TEST_ORG_ID, }) @@ -72,42 +80,62 @@ describe("BotPolicy", () => { }, ) - const creatorAllowed = await runWithActorEither(BotPolicy.canRead(BOT_ID), layer, creator) + const creatorAllowed = await runWithActorEither( + BotPolicy.use((policy) => policy.canRead(BOT_ID)), + layer, + creator, + ) const adminAllowed = await runWithActorEither( - BotPolicy.canRead(BOT_ID), + BotPolicy.use((policy) => policy.canRead(BOT_ID)), layer, makeActor({ ...admin, organizationId: TEST_ORG_ID }), ) - const outsiderDenied = await runWithActorEither(BotPolicy.canRead(BOT_ID), layer, outsider) + const outsiderDenied = await runWithActorEither( + BotPolicy.use((policy) => policy.canRead(BOT_ID)), + layer, + outsider, + ) - expect(Either.isRight(creatorAllowed)).toBe(true) - expect(Either.isRight(adminAllowed)).toBe(true) - expect(Either.isLeft(outsiderDenied)).toBe(true) + expect(Result.isSuccess(creatorAllowed)).toBe(true) + expect(Result.isSuccess(adminAllowed)).toBe(true) + expect(Result.isFailure(outsiderDenied)).toBe(true) }) it("canUpdate/canDelete require creator and map missing bot to UnauthorizedError", async () => { const creator = makeActor() const otherUser = makeActor({ - id: "00000000-0000-0000-0000-000000000404" as UserId, + id: "00000000-0000-4000-8000-000000000404" as UserId, }) const layer = makePolicyLayer({}, { [BOT_ID]: { createdBy: creator.id } }) - const updateCreator = await runWithActorEither(BotPolicy.canUpdate(BOT_ID), layer, creator) - const updateOther = await runWithActorEither(BotPolicy.canUpdate(BOT_ID), layer, otherUser) - const deleteMissing = await runWithActorEither(BotPolicy.canDelete(MISSING_BOT_ID), layer, creator) + const updateCreator = await runWithActorEither( + BotPolicy.use((policy) => policy.canUpdate(BOT_ID)), + layer, + creator, + ) + const updateOther = await runWithActorEither( + BotPolicy.use((policy) => policy.canUpdate(BOT_ID)), + layer, + otherUser, + ) + const deleteMissing = await runWithActorEither( + BotPolicy.use((policy) => policy.canDelete(MISSING_BOT_ID)), + layer, + creator, + ) - expect(Either.isRight(updateCreator)).toBe(true) - expect(Either.isLeft(updateOther)).toBe(true) - expect(Either.isLeft(deleteMissing)).toBe(true) - if (Either.isLeft(deleteMissing)) { - expect(UnauthorizedError.is(deleteMissing.left)).toBe(true) + expect(Result.isSuccess(updateCreator)).toBe(true) + expect(Result.isFailure(updateOther)).toBe(true) + expect(Result.isFailure(deleteMissing)).toBe(true) + if (Result.isFailure(deleteMissing)) { + expect(UnauthorizedError.is(deleteMissing.failure)).toBe(true) } }) it("canInstall and canUninstall require admin-or-owner", async () => { const admin = makeActor() const member = makeActor({ - id: "00000000-0000-0000-0000-000000000405" as UserId, + id: "00000000-0000-4000-8000-000000000405" as UserId, }) const layer = makePolicyLayer( { @@ -117,12 +145,24 @@ describe("BotPolicy", () => { {}, ) - const installAdmin = await runWithActorEither(BotPolicy.canInstall(TEST_ORG_ID), layer, admin) - const uninstallAdmin = await runWithActorEither(BotPolicy.canUninstall(TEST_ORG_ID), layer, admin) - const installMember = await runWithActorEither(BotPolicy.canInstall(TEST_ORG_ID), layer, member) + const installAdmin = await runWithActorEither( + BotPolicy.use((policy) => policy.canInstall(TEST_ORG_ID)), + layer, + admin, + ) + const uninstallAdmin = await runWithActorEither( + BotPolicy.use((policy) => policy.canUninstall(TEST_ORG_ID)), + layer, + admin, + ) + const installMember = await runWithActorEither( + BotPolicy.use((policy) => policy.canInstall(TEST_ORG_ID)), + layer, + member, + ) - expect(Either.isRight(installAdmin)).toBe(true) - expect(Either.isRight(uninstallAdmin)).toBe(true) - expect(Either.isLeft(installMember)).toBe(true) + expect(Result.isSuccess(installAdmin)).toBe(true) + expect(Result.isSuccess(uninstallAdmin)).toBe(true) + expect(Result.isFailure(installMember)).toBe(true) }) }) diff --git a/apps/backend/src/policies/bot-policy.ts b/apps/backend/src/policies/bot-policy.ts index 380944804..dc05c7a76 100644 --- a/apps/backend/src/policies/bot-policy.ts +++ b/apps/backend/src/policies/bot-policy.ts @@ -1,13 +1,13 @@ import { BotRepo } from "@hazel/backend-core" import { ErrorUtils, policy } from "@hazel/domain" import type { BotId, OrganizationId } from "@hazel/schema" -import { Effect } from "effect" +import { ServiceMap, Effect, Layer } from "effect" import { withAnnotatedScope } from "../lib/policy-utils" import { OrgResolver } from "../services/org-resolver" /** @effect-leakable-service */ -export class BotPolicy extends Effect.Service()("BotPolicy/Policy", { - effect: Effect.gen(function* () { +export class BotPolicy extends ServiceMap.Service()("BotPolicy/Policy", { + make: Effect.gen(function* () { const policyEntity = "Bot" as const const botRepo = yield* BotRepo @@ -109,6 +109,9 @@ export class BotPolicy extends Effect.Service()("BotPolicy/Policy", { return { canCreate, canRead, canUpdate, canDelete, canInstall, canUninstall } as const }), - dependencies: [BotRepo.Default, OrgResolver.Default], - accessors: true, -}) {} +}) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(BotRepo.layer), + Layer.provide(OrgResolver.layer), + ) +} diff --git a/apps/backend/src/policies/channel-member-policy.test.ts b/apps/backend/src/policies/channel-member-policy.test.ts index 4a82cf808..b932551cf 100644 --- a/apps/backend/src/policies/channel-member-policy.test.ts +++ b/apps/backend/src/policies/channel-member-policy.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "@effect/vitest" import { ChannelMemberRepo, ChannelRepo } from "@hazel/backend-core" import { UnauthorizedError } from "@hazel/domain" import type { ChannelId, ChannelMemberId, OrganizationId, UserId } from "@hazel/schema" -import { Effect, Either, Layer, Option } from "effect" +import { Effect, Result, Layer, Option } from "effect" import { ChannelMemberPolicy } from "./channel-member-policy.ts" import { makeActor, @@ -10,17 +10,18 @@ import { makeOrganizationMemberRepoLayer, makeOrgResolverLayer, runWithActorEither, + serviceShape, TEST_ORG_ID, TEST_USER_ID, } from "./policy-test-helpers.ts" type Role = "admin" | "member" | "owner" -const CHANNEL_ID = "00000000-0000-0000-0000-000000000811" as ChannelId -const CHANNEL_MEMBER_ID = "00000000-0000-0000-0000-000000000812" as ChannelMemberId -const MISSING_CHANNEL_MEMBER_ID = "00000000-0000-0000-0000-000000000819" as ChannelMemberId -const ADMIN_USER_ID = "00000000-0000-0000-0000-000000000813" as UserId -const OWNER_USER_ID = "00000000-0000-0000-0000-000000000814" as UserId +const CHANNEL_ID = "00000000-0000-4000-8000-000000000811" as ChannelId +const CHANNEL_MEMBER_ID = "00000000-0000-4000-8000-000000000812" as ChannelMemberId +const MISSING_CHANNEL_MEMBER_ID = "00000000-0000-4000-8000-000000000819" as ChannelMemberId +const ADMIN_USER_ID = "00000000-0000-4000-8000-000000000813" as UserId +const OWNER_USER_ID = "00000000-0000-4000-8000-000000000814" as UserId interface ChannelMemberEntry { userId: UserId @@ -36,31 +37,40 @@ const makeChannelMemberRepoLayer = ( channelMembers: Record, membershipsByChannelAndUser: Record = {}, ) => - Layer.succeed(ChannelMemberRepo, { - with: (id: ChannelMemberId, f: (member: ChannelMemberEntry) => Effect.Effect) => { - const member = channelMembers[id] - if (!member) { - return Effect.fail(makeEntityNotFound("ChannelMember")) - } - return f(member) - }, - findByChannelAndUser: (channelId: ChannelId, userId: UserId) => { - const key = `${channelId}:${userId}` - const entry = membershipsByChannelAndUser[key] - return Effect.succeed(entry ? Option.some(entry) : Option.none()) - }, - } as unknown as ChannelMemberRepo) + Layer.succeed( + ChannelMemberRepo, + serviceShape({ + with: ( + id: ChannelMemberId, + f: (member: ChannelMemberEntry) => Effect.Effect, + ) => { + const member = channelMembers[id] + if (!member) { + return Effect.fail(makeEntityNotFound("ChannelMember")) + } + return f(member) + }, + findByChannelAndUser: (channelId: ChannelId, userId: UserId) => { + const key = `${channelId}:${userId}` + const entry = membershipsByChannelAndUser[key] + return Effect.succeed(entry ? Option.some(entry) : Option.none()) + }, + }), + ) const makeChannelRepoLayer = (channels: Record) => - Layer.succeed(ChannelRepo, { - with: (id: ChannelId, f: (channel: ChannelEntry) => Effect.Effect) => { - const channel = channels[id] - if (!channel) { - return Effect.fail(makeEntityNotFound("Channel")) - } - return f(channel) - }, - } as unknown as ChannelRepo) + Layer.succeed( + ChannelRepo, + serviceShape({ + with: (id: ChannelId, f: (channel: ChannelEntry) => Effect.Effect) => { + const channel = channels[id] + if (!channel) { + return Effect.fail(makeEntityNotFound("Channel")) + } + return f(channel) + }, + }), + ) const makePolicyLayer = (opts: { members: Record @@ -68,7 +78,7 @@ const makePolicyLayer = (opts: { channelMembers?: Record membershipsByChannelAndUser?: Record }) => - ChannelMemberPolicy.DefaultWithoutDependencies.pipe( + Layer.effect(ChannelMemberPolicy, ChannelMemberPolicy.make).pipe( Layer.provide( makeChannelMemberRepoLayer(opts.channelMembers ?? {}, opts.membershipsByChannelAndUser ?? {}), ), @@ -88,8 +98,12 @@ describe("ChannelMemberPolicy", () => { }, }) - const result = await runWithActorEither(ChannelMemberPolicy.isOwner(CHANNEL_MEMBER_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + ChannelMemberPolicy.use((policy) => policy.isOwner(CHANNEL_MEMBER_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("isOwner denies different user", async () => { @@ -102,10 +116,14 @@ describe("ChannelMemberPolicy", () => { }, }) - const result = await runWithActorEither(ChannelMemberPolicy.isOwner(CHANNEL_MEMBER_ID), layer, actor) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(UnauthorizedError.is(result.left)).toBe(true) + const result = await runWithActorEither( + ChannelMemberPolicy.use((policy) => policy.isOwner(CHANNEL_MEMBER_ID)), + layer, + actor, + ) + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + expect(UnauthorizedError.is(result.failure)).toBe(true) } }) @@ -123,11 +141,19 @@ describe("ChannelMemberPolicy", () => { }, }) - const adminResult = await runWithActorEither(ChannelMemberPolicy.canCreate(CHANNEL_ID), layer, admin) - const ownerResult = await runWithActorEither(ChannelMemberPolicy.canCreate(CHANNEL_ID), layer, owner) + const adminResult = await runWithActorEither( + ChannelMemberPolicy.use((policy) => policy.canCreate(CHANNEL_ID)), + layer, + admin, + ) + const ownerResult = await runWithActorEither( + ChannelMemberPolicy.use((policy) => policy.canCreate(CHANNEL_ID)), + layer, + owner, + ) - expect(Either.isRight(adminResult)).toBe(true) - expect(Either.isRight(ownerResult)).toBe(true) + expect(Result.isSuccess(adminResult)).toBe(true) + expect(Result.isSuccess(ownerResult)).toBe(true) }) it("canCreate allows member for public channel", async () => { @@ -141,8 +167,12 @@ describe("ChannelMemberPolicy", () => { }, }) - const result = await runWithActorEither(ChannelMemberPolicy.canCreate(CHANNEL_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + ChannelMemberPolicy.use((policy) => policy.canCreate(CHANNEL_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canCreate denies member for private channel", async () => { @@ -156,10 +186,14 @@ describe("ChannelMemberPolicy", () => { }, }) - const result = await runWithActorEither(ChannelMemberPolicy.canCreate(CHANNEL_ID), layer, actor) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(UnauthorizedError.is(result.left)).toBe(true) + const result = await runWithActorEither( + ChannelMemberPolicy.use((policy) => policy.canCreate(CHANNEL_ID)), + layer, + actor, + ) + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + expect(UnauthorizedError.is(result.failure)).toBe(true) } }) @@ -177,8 +211,12 @@ describe("ChannelMemberPolicy", () => { }, }) - const result = await runWithActorEither(ChannelMemberPolicy.canRead(CHANNEL_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + ChannelMemberPolicy.use((policy) => policy.canRead(CHANNEL_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canRead allows org admin even without channel membership", async () => { @@ -193,8 +231,12 @@ describe("ChannelMemberPolicy", () => { // No channel membership for admin }) - const result = await runWithActorEither(ChannelMemberPolicy.canRead(CHANNEL_ID), layer, admin) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + ChannelMemberPolicy.use((policy) => policy.canRead(CHANNEL_ID)), + layer, + admin, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canRead denies non-admin non-channel-member", async () => { @@ -209,10 +251,14 @@ describe("ChannelMemberPolicy", () => { // No channel membership }) - const result = await runWithActorEither(ChannelMemberPolicy.canRead(CHANNEL_ID), layer, actor) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(UnauthorizedError.is(result.left)).toBe(true) + const result = await runWithActorEither( + ChannelMemberPolicy.use((policy) => policy.canRead(CHANNEL_ID)), + layer, + actor, + ) + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + expect(UnauthorizedError.is(result.failure)).toBe(true) } }) @@ -231,11 +277,11 @@ describe("ChannelMemberPolicy", () => { }) const result = await runWithActorEither( - ChannelMemberPolicy.canUpdate(CHANNEL_MEMBER_ID), + ChannelMemberPolicy.use((policy) => policy.canUpdate(CHANNEL_MEMBER_ID)), layer, actor, ) - expect(Either.isRight(result)).toBe(true) + expect(Result.isSuccess(result)).toBe(true) }) it("canUpdate allows org admin but NOT org owner", async () => { @@ -257,22 +303,22 @@ describe("ChannelMemberPolicy", () => { }) const adminResult = await runWithActorEither( - ChannelMemberPolicy.canUpdate(CHANNEL_MEMBER_ID), + ChannelMemberPolicy.use((policy) => policy.canUpdate(CHANNEL_MEMBER_ID)), layer, admin, ) const ownerResult = await runWithActorEither( - ChannelMemberPolicy.canUpdate(CHANNEL_MEMBER_ID), + ChannelMemberPolicy.use((policy) => policy.canUpdate(CHANNEL_MEMBER_ID)), layer, owner, ) // Admin is allowed - expect(Either.isRight(adminResult)).toBe(true) + expect(Result.isSuccess(adminResult)).toBe(true) // Owner is NOT allowed (canUpdate checks role === "admin", not isAdminOrOwner) - expect(Either.isLeft(ownerResult)).toBe(true) - if (Either.isLeft(ownerResult)) { - expect(UnauthorizedError.is(ownerResult.left)).toBe(true) + expect(Result.isFailure(ownerResult)).toBe(true) + if (Result.isFailure(ownerResult)) { + expect(UnauthorizedError.is(ownerResult.failure)).toBe(true) } }) @@ -291,11 +337,11 @@ describe("ChannelMemberPolicy", () => { }) const result = await runWithActorEither( - ChannelMemberPolicy.canDelete(CHANNEL_MEMBER_ID), + ChannelMemberPolicy.use((policy) => policy.canDelete(CHANNEL_MEMBER_ID)), layer, actor, ) - expect(Either.isRight(result)).toBe(true) + expect(Result.isSuccess(result)).toBe(true) }) it("canDelete allows org admin but NOT org owner", async () => { @@ -317,22 +363,22 @@ describe("ChannelMemberPolicy", () => { }) const adminResult = await runWithActorEither( - ChannelMemberPolicy.canDelete(CHANNEL_MEMBER_ID), + ChannelMemberPolicy.use((policy) => policy.canDelete(CHANNEL_MEMBER_ID)), layer, admin, ) const ownerResult = await runWithActorEither( - ChannelMemberPolicy.canDelete(CHANNEL_MEMBER_ID), + ChannelMemberPolicy.use((policy) => policy.canDelete(CHANNEL_MEMBER_ID)), layer, owner, ) // Admin is allowed - expect(Either.isRight(adminResult)).toBe(true) + expect(Result.isSuccess(adminResult)).toBe(true) // Owner is NOT allowed (canDelete checks role === "admin", not isAdminOrOwner) - expect(Either.isLeft(ownerResult)).toBe(true) - if (Either.isLeft(ownerResult)) { - expect(UnauthorizedError.is(ownerResult.left)).toBe(true) + expect(Result.isFailure(ownerResult)).toBe(true) + if (Result.isFailure(ownerResult)) { + expect(UnauthorizedError.is(ownerResult.failure)).toBe(true) } }) }) diff --git a/apps/backend/src/policies/channel-member-policy.ts b/apps/backend/src/policies/channel-member-policy.ts index 6774f5cc0..8d010d02c 100644 --- a/apps/backend/src/policies/channel-member-policy.ts +++ b/apps/backend/src/policies/channel-member-policy.ts @@ -1,124 +1,58 @@ import { ChannelMemberRepo, ChannelRepo, OrganizationMemberRepo } from "@hazel/backend-core" import { ErrorUtils, policy } from "@hazel/domain" import type { ChannelId, ChannelMemberId } from "@hazel/schema" -import { Effect, Option } from "effect" +import { ServiceMap, Effect, Layer, Option } from "effect" import { isAdminOrOwner } from "../lib/policy-utils" import { OrgResolver } from "../services/org-resolver" -export class ChannelMemberPolicy extends Effect.Service()("ChannelMemberPolicy/Policy", { - effect: Effect.gen(function* () { - const policyEntity = "ChannelMember" as const - - const channelMemberRepo = yield* ChannelMemberRepo - const channelRepo = yield* ChannelRepo - const organizationMemberRepo = yield* OrganizationMemberRepo - const orgResolver = yield* OrgResolver - - const isOwner = (id: ChannelMemberId) => - ErrorUtils.refailUnauthorized( - policyEntity, - "isOwner", - )( - channelMemberRepo.with(id, (member) => - policy( - policyEntity, - "isOwner", - Effect.fn(`${policyEntity}.isOwner`)(function* (actor) { - return yield* Effect.succeed(actor.id === member.userId) - }), - ), - ), - ) - - const canCreate = (channelId: ChannelId) => - ErrorUtils.refailUnauthorized( - policyEntity, - "create", - )( - channelRepo.with(channelId, (channel) => - policy( - policyEntity, - "create", - Effect.fn(`${policyEntity}.create`)(function* (actor) { - const orgMember = yield* organizationMemberRepo.findByOrgAndUser( - channel.organizationId, - actor.id, - ) - - if (Option.isSome(orgMember) && isAdminOrOwner(orgMember.value.role)) { - return yield* Effect.succeed(true) - } - - // For public channels, any org member can join - if (channel.type === "public" && Option.isSome(orgMember)) { - return yield* Effect.succeed(true) - } - - return yield* Effect.succeed(false) - }), - ), - ), - ) - - const canRead = (channelId: ChannelId) => - ErrorUtils.refailUnauthorized( - policyEntity, - "select", - )( - channelRepo.with(channelId, (channel) => - policy( - policyEntity, - "select", - Effect.fn(`${policyEntity}.select`)(function* (actor) { - // Check if user is a member of the channel - const membership = yield* channelMemberRepo.findByChannelAndUser( - channelId, - actor.id, - ) - - if (Option.isSome(membership)) { - return yield* Effect.succeed(true) - } - - // Organization admins can read all channel members - const orgMember = yield* organizationMemberRepo.findByOrgAndUser( - channel.organizationId, - actor.id, - ) - - if (Option.isSome(orgMember) && isAdminOrOwner(orgMember.value.role)) { - return yield* Effect.succeed(true) - } - - return yield* Effect.succeed(false) - }), +export class ChannelMemberPolicy extends ServiceMap.Service()( + "ChannelMemberPolicy/Policy", + { + make: Effect.gen(function* () { + const policyEntity = "ChannelMember" as const + + const channelMemberRepo = yield* ChannelMemberRepo + const channelRepo = yield* ChannelRepo + const organizationMemberRepo = yield* OrganizationMemberRepo + const orgResolver = yield* OrgResolver + + const isOwner = (id: ChannelMemberId) => + ErrorUtils.refailUnauthorized( + policyEntity, + "isOwner", + )( + channelMemberRepo.with(id, (member) => + policy( + policyEntity, + "isOwner", + Effect.fn(`${policyEntity}.isOwner`)(function* (actor) { + return yield* Effect.succeed(actor.id === member.userId) + }), + ), ), - ), - ) - - const canUpdate = (id: ChannelMemberId) => - ErrorUtils.refailUnauthorized( - policyEntity, - "update", - )( - channelMemberRepo.with(id, (member) => - channelRepo.with(member.channelId, (channel) => + ) + + const canCreate = (channelId: ChannelId) => + ErrorUtils.refailUnauthorized( + policyEntity, + "create", + )( + channelRepo.with(channelId, (channel) => policy( policyEntity, - "update", - Effect.fn(`${policyEntity}.update`)(function* (actor) { - // Self-update always allowed - if (actor.id === member.userId) { - return yield* Effect.succeed(true) - } - - // Organization admins can update any membership + "create", + Effect.fn(`${policyEntity}.create`)(function* (actor) { const orgMember = yield* organizationMemberRepo.findByOrgAndUser( channel.organizationId, actor.id, ) - if (Option.isSome(orgMember) && orgMember.value.role === "admin") { + if (Option.isSome(orgMember) && isAdminOrOwner(orgMember.value.role)) { + return yield* Effect.succeed(true) + } + + // For public channels, any org member can join + if (channel.type === "public" && Option.isSome(orgMember)) { return yield* Effect.succeed(true) } @@ -126,32 +60,35 @@ export class ChannelMemberPolicy extends Effect.Service()(" }), ), ), - ), - ) - - const canDelete = (id: ChannelMemberId) => - ErrorUtils.refailUnauthorized( - policyEntity, - "delete", - )( - channelMemberRepo.with(id, (member) => - channelRepo.with(member.channelId, (channel) => + ) + + const canRead = (channelId: ChannelId) => + ErrorUtils.refailUnauthorized( + policyEntity, + "select", + )( + channelRepo.with(channelId, (channel) => policy( policyEntity, - "delete", - Effect.fn(`${policyEntity}.delete`)(function* (actor) { - // Self-removal always allowed - if (actor.id === member.userId) { + "select", + Effect.fn(`${policyEntity}.select`)(function* (actor) { + // Check if user is a member of the channel + const membership = yield* channelMemberRepo.findByChannelAndUser( + channelId, + actor.id, + ) + + if (Option.isSome(membership)) { return yield* Effect.succeed(true) } - // Organization admins can remove members + // Organization admins can read all channel members const orgMember = yield* organizationMemberRepo.findByOrgAndUser( channel.organizationId, actor.id, ) - if (Option.isSome(orgMember) && orgMember.value.role === "admin") { + if (Option.isSome(orgMember) && isAdminOrOwner(orgMember.value.role)) { return yield* Effect.succeed(true) } @@ -159,16 +96,82 @@ export class ChannelMemberPolicy extends Effect.Service()(" }), ), ), - ), - ) - - return { canCreate, canRead, canUpdate, canDelete, isOwner } as const - }), - dependencies: [ - ChannelMemberRepo.Default, - ChannelRepo.Default, - OrganizationMemberRepo.Default, - OrgResolver.Default, - ], - accessors: true, -}) {} + ) + + const canUpdate = (id: ChannelMemberId) => + ErrorUtils.refailUnauthorized( + policyEntity, + "update", + )( + channelMemberRepo.with(id, (member) => + channelRepo.with(member.channelId, (channel) => + policy( + policyEntity, + "update", + Effect.fn(`${policyEntity}.update`)(function* (actor) { + // Self-update always allowed + if (actor.id === member.userId) { + return yield* Effect.succeed(true) + } + + // Organization admins can update any membership + const orgMember = yield* organizationMemberRepo.findByOrgAndUser( + channel.organizationId, + actor.id, + ) + + if (Option.isSome(orgMember) && orgMember.value.role === "admin") { + return yield* Effect.succeed(true) + } + + return yield* Effect.succeed(false) + }), + ), + ), + ), + ) + + const canDelete = (id: ChannelMemberId) => + ErrorUtils.refailUnauthorized( + policyEntity, + "delete", + )( + channelMemberRepo.with(id, (member) => + channelRepo.with(member.channelId, (channel) => + policy( + policyEntity, + "delete", + Effect.fn(`${policyEntity}.delete`)(function* (actor) { + // Self-removal always allowed + if (actor.id === member.userId) { + return yield* Effect.succeed(true) + } + + // Organization admins can remove members + const orgMember = yield* organizationMemberRepo.findByOrgAndUser( + channel.organizationId, + actor.id, + ) + + if (Option.isSome(orgMember) && orgMember.value.role === "admin") { + return yield* Effect.succeed(true) + } + + return yield* Effect.succeed(false) + }), + ), + ), + ), + ) + + return { canCreate, canRead, canUpdate, canDelete, isOwner } as const + }), + }, +) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(ChannelMemberRepo.layer), + Layer.provide(ChannelRepo.layer), + Layer.provide(OrganizationMemberRepo.layer), + Layer.provide(OrgResolver.layer), + ) +} diff --git a/apps/backend/src/policies/channel-policy.test.ts b/apps/backend/src/policies/channel-policy.test.ts index af362db00..de3b12811 100644 --- a/apps/backend/src/policies/channel-policy.test.ts +++ b/apps/backend/src/policies/channel-policy.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "@effect/vitest" import { ChannelRepo } from "@hazel/backend-core" import { UnauthorizedError } from "@hazel/domain" import type { ChannelId, OrganizationId } from "@hazel/schema" -import { Effect, Either, Layer } from "effect" +import { Effect, Result, Layer, ServiceMap } from "effect" import { ChannelPolicy } from "./channel-policy.ts" import { makeActor, @@ -15,8 +15,8 @@ import { type Role = "admin" | "member" | "owner" -const CHANNEL_ID = "00000000-0000-0000-0000-000000000301" as ChannelId -const MISSING_CHANNEL_ID = "00000000-0000-0000-0000-000000000399" as ChannelId +const CHANNEL_ID = "00000000-0000-4000-8000-000000000301" as ChannelId +const MISSING_CHANNEL_ID = "00000000-0000-4000-8000-000000000399" as ChannelId const makeChannelRepoLayer = (channels: Record) => Layer.succeed(ChannelRepo, { @@ -30,13 +30,13 @@ const makeChannelRepoLayer = (channels: Record) const makePolicyLayer = ( members: Record, channels: Record, ) => - ChannelPolicy.DefaultWithoutDependencies.pipe( + Layer.effect(ChannelPolicy, ChannelPolicy.make).pipe( Layer.provide(makeChannelRepoLayer(channels)), Layer.provide(makeOrgResolverLayer(members)), ) @@ -50,34 +50,34 @@ describe("ChannelPolicy", () => { const ownerLayer = makePolicyLayer({ [`${TEST_ORG_ID}:${actor.id}`]: "owner" }, {}) const memberResult = await runWithActorEither( - ChannelPolicy.canCreate(TEST_ORG_ID), + ChannelPolicy.use((policy) => policy.canCreate(TEST_ORG_ID)), memberLayer, actor, ["channels:write"], ) const adminResult = await runWithActorEither( - ChannelPolicy.canCreate(TEST_ORG_ID), + ChannelPolicy.use((policy) => policy.canCreate(TEST_ORG_ID)), adminLayer, actor, ["channels:write"], ) const ownerResult = await runWithActorEither( - ChannelPolicy.canCreate(TEST_ORG_ID), + ChannelPolicy.use((policy) => policy.canCreate(TEST_ORG_ID)), ownerLayer, actor, ["channels:write"], ) const noMembership = await runWithActorEither( - ChannelPolicy.canCreate(TEST_ALT_ORG_ID), + ChannelPolicy.use((policy) => policy.canCreate(TEST_ALT_ORG_ID)), memberLayer, actor, ["channels:write"], ) - expect(Either.isLeft(memberResult)).toBe(true) - expect(Either.isRight(adminResult)).toBe(true) - expect(Either.isRight(ownerResult)).toBe(true) - expect(Either.isLeft(noMembership)).toBe(true) + expect(Result.isFailure(memberResult)).toBe(true) + expect(Result.isSuccess(adminResult)).toBe(true) + expect(Result.isSuccess(ownerResult)).toBe(true) + expect(Result.isFailure(noMembership)).toBe(true) }) it("canUpdate allows org admins and maps not-found to UnauthorizedError", async () => { @@ -91,13 +91,21 @@ describe("ChannelPolicy", () => { }, ) - const allowed = await runWithActorEither(ChannelPolicy.canUpdate(CHANNEL_ID), layer, actor) - const missing = await runWithActorEither(ChannelPolicy.canUpdate(MISSING_CHANNEL_ID), layer, actor) + const allowed = await runWithActorEither( + ChannelPolicy.use((policy) => policy.canUpdate(CHANNEL_ID)), + layer, + actor, + ) + const missing = await runWithActorEither( + ChannelPolicy.use((policy) => policy.canUpdate(MISSING_CHANNEL_ID)), + layer, + actor, + ) - expect(Either.isRight(allowed)).toBe(true) - expect(Either.isLeft(missing)).toBe(true) - if (Either.isLeft(missing)) { - expect(UnauthorizedError.is(missing.left)).toBe(true) + expect(Result.isSuccess(allowed)).toBe(true) + expect(Result.isFailure(missing)).toBe(true) + if (Result.isFailure(missing)) { + expect(UnauthorizedError.is(missing.failure)).toBe(true) } }) @@ -112,7 +120,11 @@ describe("ChannelPolicy", () => { }, ) - const result = await runWithActorEither(ChannelPolicy.canDelete(CHANNEL_ID), layer, actor) - expect(Either.isLeft(result)).toBe(true) + const result = await runWithActorEither( + ChannelPolicy.use((policy) => policy.canDelete(CHANNEL_ID)), + layer, + actor, + ) + expect(Result.isFailure(result)).toBe(true) }) }) diff --git a/apps/backend/src/policies/channel-policy.ts b/apps/backend/src/policies/channel-policy.ts index ac07f1d22..3dbc5929b 100644 --- a/apps/backend/src/policies/channel-policy.ts +++ b/apps/backend/src/policies/channel-policy.ts @@ -1,12 +1,12 @@ import { ChannelRepo } from "@hazel/backend-core" import { ErrorUtils } from "@hazel/domain" import type { ChannelId, OrganizationId } from "@hazel/schema" -import { Effect } from "effect" +import { ServiceMap, Effect, Layer } from "effect" import { withAnnotatedScope } from "../lib/policy-utils" import { OrgResolver } from "../services/org-resolver" -export class ChannelPolicy extends Effect.Service()("ChannelPolicy/Policy", { - effect: Effect.gen(function* () { +export class ChannelPolicy extends ServiceMap.Service()("ChannelPolicy/Policy", { + make: Effect.gen(function* () { const policyEntity = "Channel" as const const orgResolver = yield* OrgResolver @@ -58,6 +58,9 @@ export class ChannelPolicy extends Effect.Service()("ChannelPolic return { canUpdate, canDelete, canCreate } as const }), - dependencies: [ChannelRepo.Default, OrgResolver.Default], - accessors: true, -}) {} +}) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(ChannelRepo.layer), + Layer.provide(OrgResolver.layer), + ) +} diff --git a/apps/backend/src/policies/channel-section-policy.ts b/apps/backend/src/policies/channel-section-policy.ts index adaa42e3b..1576716eb 100644 --- a/apps/backend/src/policies/channel-section-policy.ts +++ b/apps/backend/src/policies/channel-section-policy.ts @@ -1,14 +1,14 @@ import { ChannelSectionRepo } from "@hazel/backend-core" import { ErrorUtils } from "@hazel/domain" import type { ChannelSectionId, OrganizationId } from "@hazel/schema" -import { Effect } from "effect" +import { ServiceMap, Effect, Layer } from "effect" import { withAnnotatedScope } from "../lib/policy-utils" import { OrgResolver } from "../services/org-resolver" -export class ChannelSectionPolicy extends Effect.Service()( +export class ChannelSectionPolicy extends ServiceMap.Service()( "ChannelSectionPolicy/Policy", { - effect: Effect.gen(function* () { + make: Effect.gen(function* () { const policyEntity = "ChannelSection" as const const orgResolver = yield* OrgResolver @@ -70,7 +70,10 @@ export class ChannelSectionPolicy extends Effect.Service() return { canCreate, canUpdate, canDelete, canReorder } as const }), - dependencies: [ChannelSectionRepo.Default, OrgResolver.Default], - accessors: true, }, -) {} +) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(ChannelSectionRepo.layer), + Layer.provide(OrgResolver.layer), + ) +} diff --git a/apps/backend/src/policies/channel-webhook-policy.ts b/apps/backend/src/policies/channel-webhook-policy.ts index 73c460783..65c429c68 100644 --- a/apps/backend/src/policies/channel-webhook-policy.ts +++ b/apps/backend/src/policies/channel-webhook-policy.ts @@ -1,15 +1,15 @@ import { ChannelRepo, ChannelWebhookRepo } from "@hazel/backend-core" import { ErrorUtils } from "@hazel/domain" import type { ChannelId, ChannelWebhookId } from "@hazel/schema" -import { Effect } from "effect" +import { ServiceMap, Effect, Layer } from "effect" import { withAnnotatedScope } from "../lib/policy-utils" import { OrgResolver } from "../services/org-resolver" /** @effect-leakable-service */ -export class ChannelWebhookPolicy extends Effect.Service()( +export class ChannelWebhookPolicy extends ServiceMap.Service()( "ChannelWebhookPolicy/Policy", { - effect: Effect.gen(function* () { + make: Effect.gen(function* () { const policyEntity = "ChannelWebhook" as const const channelRepo = yield* ChannelRepo @@ -86,7 +86,11 @@ export class ChannelWebhookPolicy extends Effect.Service() return { canCreate, canRead, canUpdate, canDelete } as const }), - dependencies: [ChannelRepo.Default, ChannelWebhookRepo.Default, OrgResolver.Default], - accessors: true, }, -) {} +) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(ChannelRepo.layer), + Layer.provide(ChannelWebhookRepo.layer), + Layer.provide(OrgResolver.layer), + ) +} diff --git a/apps/backend/src/policies/custom-emoji-policy.ts b/apps/backend/src/policies/custom-emoji-policy.ts index ddff15f16..58d7c8fe4 100644 --- a/apps/backend/src/policies/custom-emoji-policy.ts +++ b/apps/backend/src/policies/custom-emoji-policy.ts @@ -1,12 +1,12 @@ import { CustomEmojiRepo } from "@hazel/backend-core" import { ErrorUtils } from "@hazel/domain" import type { CustomEmojiId, OrganizationId } from "@hazel/schema" -import { Effect } from "effect" +import { ServiceMap, Effect, Layer } from "effect" import { withAnnotatedScope } from "../lib/policy-utils" import { OrgResolver } from "../services/org-resolver" -export class CustomEmojiPolicy extends Effect.Service()("CustomEmojiPolicy/Policy", { - effect: Effect.gen(function* () { +export class CustomEmojiPolicy extends ServiceMap.Service()("CustomEmojiPolicy/Policy", { + make: Effect.gen(function* () { const policyEntity = "CustomEmoji" as const const orgResolver = yield* OrgResolver @@ -48,6 +48,9 @@ export class CustomEmojiPolicy extends Effect.Service()("Cust return { canCreate, canUpdate, canDelete } as const }), - dependencies: [CustomEmojiRepo.Default, OrgResolver.Default], - accessors: true, -}) {} +}) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(CustomEmojiRepo.layer), + Layer.provide(OrgResolver.layer), + ) +} diff --git a/apps/backend/src/policies/github-subscription-policy.ts b/apps/backend/src/policies/github-subscription-policy.ts index 50f37f513..e0c7a4a75 100644 --- a/apps/backend/src/policies/github-subscription-policy.ts +++ b/apps/backend/src/policies/github-subscription-policy.ts @@ -1,15 +1,15 @@ import { ChannelRepo, GitHubSubscriptionRepo } from "@hazel/backend-core" import { ErrorUtils } from "@hazel/domain" import type { ChannelId, GitHubSubscriptionId, OrganizationId } from "@hazel/schema" -import { Effect } from "effect" +import { ServiceMap, Effect, Layer } from "effect" import { withAnnotatedScope } from "../lib/policy-utils" import { OrgResolver } from "../services/org-resolver" /** @effect-leakable-service */ -export class GitHubSubscriptionPolicy extends Effect.Service()( +export class GitHubSubscriptionPolicy extends ServiceMap.Service()( "GitHubSubscriptionPolicy/Policy", { - effect: Effect.gen(function* () { + make: Effect.gen(function* () { const policyEntity = "GitHubSubscription" as const const channelRepo = yield* ChannelRepo @@ -96,7 +96,11 @@ export class GitHubSubscriptionPolicy extends Effect.Service) => - IntegrationConnectionPolicy.DefaultWithoutDependencies.pipe(Layer.provide(makeOrgResolverLayer(members))) + Layer.effect(IntegrationConnectionPolicy, IntegrationConnectionPolicy.make).pipe( + Layer.provide(makeOrgResolverLayer(members)), + ) describe("IntegrationConnectionPolicy", () => { it("allows select for any org member", async () => { @@ -17,11 +19,11 @@ describe("IntegrationConnectionPolicy", () => { }) const result = await runWithActorEither( - IntegrationConnectionPolicy.canSelect(TEST_ORG_ID), + IntegrationConnectionPolicy.use((policy) => policy.canSelect(TEST_ORG_ID)), layer, actor, ) - expect(Either.isRight(result)).toBe(true) + expect(Result.isSuccess(result)).toBe(true) }) it("allows insert/update/delete for admin-or-owner only", async () => { @@ -31,23 +33,27 @@ describe("IntegrationConnectionPolicy", () => { }) const insert = await runWithActorEither( - IntegrationConnectionPolicy.canInsert(TEST_ORG_ID), + IntegrationConnectionPolicy.use((policy) => policy.canInsert(TEST_ORG_ID)), layer, actor, ) const update = await runWithActorEither( - IntegrationConnectionPolicy.canUpdate(TEST_ORG_ID), + IntegrationConnectionPolicy.use((policy) => policy.canUpdate(TEST_ORG_ID)), + layer, + actor, + ) + const del = await runWithActorEither( + IntegrationConnectionPolicy.use((policy) => policy.canDelete(TEST_ORG_ID)), layer, actor, ) - const del = await runWithActorEither(IntegrationConnectionPolicy.canDelete(TEST_ORG_ID), layer, actor) - expect(Either.isLeft(insert)).toBe(true) - expect(Either.isLeft(update)).toBe(true) - expect(Either.isLeft(del)).toBe(true) + expect(Result.isFailure(insert)).toBe(true) + expect(Result.isFailure(update)).toBe(true) + expect(Result.isFailure(del)).toBe(true) - if (Either.isLeft(insert)) { - expect(UnauthorizedError.is(insert.left)).toBe(true) + if (Result.isFailure(insert)) { + expect(UnauthorizedError.is(insert.failure)).toBe(true) } }) }) diff --git a/apps/backend/src/policies/integration-connection-policy.ts b/apps/backend/src/policies/integration-connection-policy.ts index 0a26f8d99..2d59138bf 100644 --- a/apps/backend/src/policies/integration-connection-policy.ts +++ b/apps/backend/src/policies/integration-connection-policy.ts @@ -1,13 +1,13 @@ import { ErrorUtils } from "@hazel/domain" import type { OrganizationId } from "@hazel/schema" -import { Effect } from "effect" +import { ServiceMap, Effect, Layer } from "effect" import { withAnnotatedScope } from "../lib/policy-utils" import { OrgResolver } from "../services/org-resolver" -export class IntegrationConnectionPolicy extends Effect.Service()( +export class IntegrationConnectionPolicy extends ServiceMap.Service()( "IntegrationConnectionPolicy/Policy", { - effect: Effect.gen(function* () { + make: Effect.gen(function* () { const policyEntity = "IntegrationConnection" as const const orgResolver = yield* OrgResolver @@ -54,7 +54,7 @@ export class IntegrationConnectionPolicy extends Effect.Service, @@ -38,22 +39,25 @@ const makeInvitationRepoLayer = ( } return f(invitation) }, - } as unknown as InvitationRepo) + } as ServiceMap.Service.Shape) const makeUserRepoLayer = (users: Record) => - Layer.succeed(UserRepo, { - findById: (id: UserId) => { - const user = users[id] - return Effect.succeed(user ? Option.some(user) : Option.none()) - }, - } as unknown as UserRepo) + Layer.succeed( + UserRepo, + serviceShape({ + findById: (id: UserId) => { + const user = users[id] + return Effect.succeed(user ? Option.some(user) : Option.none()) + }, + }), + ) const makePolicyLayer = ( members: Record, invitations: Record, users: Record = {}, ) => - InvitationPolicy.DefaultWithoutDependencies.pipe( + Layer.effect(InvitationPolicy, InvitationPolicy.make).pipe( Layer.provide(makeOrgResolverLayer(members)), Layer.provide(makeOrganizationMemberRepoLayer(members)), Layer.provide(makeInvitationRepoLayer(invitations)), @@ -65,9 +69,13 @@ describe("InvitationPolicy", () => { const actor = makeActor() const layer = makePolicyLayer({}, {}) - const result = await runWithActorEither(InvitationPolicy.canRead(INVITATION_ID), layer, actor) + const result = await runWithActorEither( + InvitationPolicy.use((policy) => policy.canRead(INVITATION_ID)), + layer, + actor, + ) - expect(Either.isRight(result)).toBe(true) + expect(Result.isSuccess(result)).toBe(true) }) it("canCreate allows admin-or-owner", async () => { @@ -79,9 +87,13 @@ describe("InvitationPolicy", () => { {}, ) - const result = await runWithActorEither(InvitationPolicy.canCreate(TEST_ORG_ID), layer, actor) + const result = await runWithActorEither( + InvitationPolicy.use((policy) => policy.canCreate(TEST_ORG_ID)), + layer, + actor, + ) - expect(Either.isRight(result)).toBe(true) + expect(Result.isSuccess(result)).toBe(true) }) it("canCreate denies regular member", async () => { @@ -93,9 +105,13 @@ describe("InvitationPolicy", () => { {}, ) - const result = await runWithActorEither(InvitationPolicy.canCreate(TEST_ORG_ID), layer, actor) + const result = await runWithActorEither( + InvitationPolicy.use((policy) => policy.canCreate(TEST_ORG_ID)), + layer, + actor, + ) - expect(Either.isLeft(result)).toBe(true) + expect(Result.isFailure(result)).toBe(true) }) it("canUpdate allows creator", async () => { @@ -111,9 +127,13 @@ describe("InvitationPolicy", () => { }, ) - const result = await runWithActorEither(InvitationPolicy.canUpdate(INVITATION_ID), layer, actor) + const result = await runWithActorEither( + InvitationPolicy.use((policy) => policy.canUpdate(INVITATION_ID)), + layer, + actor, + ) - expect(Either.isRight(result)).toBe(true) + expect(Result.isSuccess(result)).toBe(true) }) it("canUpdate allows org admin who is not creator", async () => { @@ -131,9 +151,13 @@ describe("InvitationPolicy", () => { }, ) - const result = await runWithActorEither(InvitationPolicy.canUpdate(INVITATION_ID), layer, admin) + const result = await runWithActorEither( + InvitationPolicy.use((policy) => policy.canUpdate(INVITATION_ID)), + layer, + admin, + ) - expect(Either.isRight(result)).toBe(true) + expect(Result.isSuccess(result)).toBe(true) }) it("canUpdate denies non-creator non-admin", async () => { @@ -151,9 +175,13 @@ describe("InvitationPolicy", () => { }, ) - const result = await runWithActorEither(InvitationPolicy.canUpdate(INVITATION_ID), layer, outsider) + const result = await runWithActorEither( + InvitationPolicy.use((policy) => policy.canUpdate(INVITATION_ID)), + layer, + outsider, + ) - expect(Either.isLeft(result)).toBe(true) + expect(Result.isFailure(result)).toBe(true) }) it("canDelete allows creator", async () => { @@ -169,9 +197,13 @@ describe("InvitationPolicy", () => { }, ) - const result = await runWithActorEither(InvitationPolicy.canDelete(INVITATION_ID), layer, actor) + const result = await runWithActorEither( + InvitationPolicy.use((policy) => policy.canDelete(INVITATION_ID)), + layer, + actor, + ) - expect(Either.isRight(result)).toBe(true) + expect(Result.isSuccess(result)).toBe(true) }) it("canDelete allows org admin who is not creator", async () => { @@ -189,9 +221,13 @@ describe("InvitationPolicy", () => { }, ) - const result = await runWithActorEither(InvitationPolicy.canDelete(INVITATION_ID), layer, admin) + const result = await runWithActorEither( + InvitationPolicy.use((policy) => policy.canDelete(INVITATION_ID)), + layer, + admin, + ) - expect(Either.isRight(result)).toBe(true) + expect(Result.isSuccess(result)).toBe(true) }) it("canAccept allows when user email matches invitation email", async () => { @@ -210,9 +246,13 @@ describe("InvitationPolicy", () => { }, ) - const result = await runWithActorEither(InvitationPolicy.canAccept(INVITATION_ID), layer, actor) + const result = await runWithActorEither( + InvitationPolicy.use((policy) => policy.canAccept(INVITATION_ID)), + layer, + actor, + ) - expect(Either.isRight(result)).toBe(true) + expect(Result.isSuccess(result)).toBe(true) }) it("canAccept denies when user email does not match", async () => { @@ -231,9 +271,13 @@ describe("InvitationPolicy", () => { }, ) - const result = await runWithActorEither(InvitationPolicy.canAccept(INVITATION_ID), layer, actor) + const result = await runWithActorEither( + InvitationPolicy.use((policy) => policy.canAccept(INVITATION_ID)), + layer, + actor, + ) - expect(Either.isLeft(result)).toBe(true) + expect(Result.isFailure(result)).toBe(true) }) it("canAccept denies when user is not found", async () => { @@ -250,9 +294,13 @@ describe("InvitationPolicy", () => { {}, ) - const result = await runWithActorEither(InvitationPolicy.canAccept(INVITATION_ID), layer, actor) + const result = await runWithActorEither( + InvitationPolicy.use((policy) => policy.canAccept(INVITATION_ID)), + layer, + actor, + ) - expect(Either.isLeft(result)).toBe(true) + expect(Result.isFailure(result)).toBe(true) }) it("canList allows admin-or-owner", async () => { @@ -264,9 +312,13 @@ describe("InvitationPolicy", () => { {}, ) - const result = await runWithActorEither(InvitationPolicy.canList(TEST_ORG_ID), layer, actor) + const result = await runWithActorEither( + InvitationPolicy.use((policy) => policy.canList(TEST_ORG_ID)), + layer, + actor, + ) - expect(Either.isRight(result)).toBe(true) + expect(Result.isSuccess(result)).toBe(true) }) it("canList denies regular member", async () => { @@ -278,8 +330,12 @@ describe("InvitationPolicy", () => { {}, ) - const result = await runWithActorEither(InvitationPolicy.canList(TEST_ORG_ID), layer, actor) + const result = await runWithActorEither( + InvitationPolicy.use((policy) => policy.canList(TEST_ORG_ID)), + layer, + actor, + ) - expect(Either.isLeft(result)).toBe(true) + expect(Result.isFailure(result)).toBe(true) }) }) diff --git a/apps/backend/src/policies/invitation-policy.ts b/apps/backend/src/policies/invitation-policy.ts index ffbe98257..817f5fa5f 100644 --- a/apps/backend/src/policies/invitation-policy.ts +++ b/apps/backend/src/policies/invitation-policy.ts @@ -1,15 +1,15 @@ import { InvitationRepo, OrganizationMemberRepo, UserRepo } from "@hazel/backend-core" import { ErrorUtils, policy } from "@hazel/domain" import type { InvitationId, OrganizationId } from "@hazel/schema" -import { Effect, Option } from "effect" +import { ServiceMap, Effect, Layer, Option } from "effect" import { isAdminOrOwner, withAnnotatedScope } from "../lib/policy-utils" import { OrgResolver } from "../services/org-resolver" /** * @effect-leakable-service */ -export class InvitationPolicy extends Effect.Service()("InvitationPolicy/Policy", { - effect: Effect.gen(function* () { +export class InvitationPolicy extends ServiceMap.Service()("InvitationPolicy/Policy", { + make: Effect.gen(function* () { const policyEntity = "Invitation" as const const invitationRepo = yield* InvitationRepo @@ -141,11 +141,11 @@ export class InvitationPolicy extends Effect.Service()("Invita return { canRead, canCreate, canUpdate, canDelete, canAccept, canList } as const }), - dependencies: [ - InvitationRepo.Default, - OrganizationMemberRepo.Default, - UserRepo.Default, - OrgResolver.Default, - ], - accessors: true, -}) {} +}) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(InvitationRepo.layer), + Layer.provide(OrganizationMemberRepo.layer), + Layer.provide(UserRepo.layer), + Layer.provide(OrgResolver.layer), + ) +} diff --git a/apps/backend/src/policies/message-policy.test.ts b/apps/backend/src/policies/message-policy.test.ts index 0eb188cce..9caad3609 100644 --- a/apps/backend/src/policies/message-policy.test.ts +++ b/apps/backend/src/policies/message-policy.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "@effect/vitest" import { ChannelMemberRepo, ChannelRepo, MessageRepo, OrganizationMemberRepo } from "@hazel/backend-core" import { UnauthorizedError } from "@hazel/domain" import type { ChannelId, MessageId, OrganizationId, UserId } from "@hazel/schema" -import { Effect, Either, Layer, Option } from "effect" +import { Effect, Result, Layer, Option } from "effect" import { OrgResolver } from "../services/org-resolver.ts" import { MessagePolicy } from "./message-policy.ts" import { @@ -10,15 +10,16 @@ import { makeEntityNotFound, makeOrganizationMemberRepoLayer, runWithActorEither, + serviceShape, TEST_ORG_ID, TEST_USER_ID, } from "./policy-test-helpers.ts" type Role = "admin" | "member" | "owner" -const CHANNEL_ID = "00000000-0000-0000-0000-000000000801" as ChannelId -const MESSAGE_ID = "00000000-0000-0000-0000-000000000802" as MessageId -const MISSING_MESSAGE_ID = "00000000-0000-0000-0000-000000000899" as MessageId +const CHANNEL_ID = "00000000-0000-4000-8000-000000000801" as ChannelId +const MESSAGE_ID = "00000000-0000-4000-8000-000000000802" as MessageId +const MISSING_MESSAGE_ID = "00000000-0000-4000-8000-000000000899" as MessageId /** * Creates a ChannelRepo mock with both `findById` (for OrgResolver) and `with` (for MessagePolicy). @@ -26,51 +27,60 @@ const MISSING_MESSAGE_ID = "00000000-0000-0000-0000-000000000899" as MessageId const makeChannelRepoLayer = ( channels: Record, ) => - Layer.succeed(ChannelRepo, { - findById: (id: ChannelId) => { - const channel = channels[id] - return Effect.succeed(channel ? Option.some(channel) : Option.none()) - }, - with: ( - id: ChannelId, - f: (channel: { - organizationId: OrganizationId - type: string - id: ChannelId - }) => Effect.Effect, - ) => { - const channel = channels[id] - if (!channel) { - return Effect.fail(makeEntityNotFound("Channel")) - } - return f(channel) - }, - } as unknown as ChannelRepo) + Layer.succeed( + ChannelRepo, + serviceShape({ + findById: (id: ChannelId) => { + const channel = channels[id] + return Effect.succeed(channel ? Option.some(channel) : Option.none()) + }, + with: ( + id: ChannelId, + f: (channel: { + organizationId: OrganizationId + type: string + id: ChannelId + }) => Effect.Effect, + ) => { + const channel = channels[id] + if (!channel) { + return Effect.fail(makeEntityNotFound("Channel")) + } + return f(channel) + }, + }), + ) /** * Creates a MessageRepo mock with a `with` method. */ const makeMessageRepoLayer = (messages: Record) => - Layer.succeed(MessageRepo, { - findById: (id: MessageId) => { - const message = messages[id] - return Effect.succeed(message ? Option.some(message) : Option.none()) - }, - with: ( - id: MessageId, - f: (message: { authorId: UserId; channelId: ChannelId }) => Effect.Effect, - ) => { - const message = messages[id] - if (!message) { - return Effect.fail(makeEntityNotFound("Message")) - } - return f(message) - }, - } as unknown as MessageRepo) + Layer.succeed( + MessageRepo, + serviceShape({ + findById: (id: MessageId) => { + const message = messages[id] + return Effect.succeed(message ? Option.some(message) : Option.none()) + }, + with: ( + id: MessageId, + f: (message: { authorId: UserId; channelId: ChannelId }) => Effect.Effect, + ) => { + const message = messages[id] + if (!message) { + return Effect.fail(makeEntityNotFound("Message")) + } + return f(message) + }, + }), + ) -const emptyChannelMemberRepoLayer = Layer.succeed(ChannelMemberRepo, { - findByChannelAndUser: (_channelId: ChannelId, _userId: UserId) => Effect.succeed(Option.none()), -} as unknown as ChannelMemberRepo) +const emptyChannelMemberRepoLayer = Layer.succeed( + ChannelMemberRepo, + serviceShape({ + findByChannelAndUser: (_channelId: ChannelId, _userId: UserId) => Effect.succeed(Option.none()), + }), +) /** * Builds the full layer stack for MessagePolicy tests. @@ -85,14 +95,14 @@ const makePolicyLayer = ( const messageRepoLayer = makeMessageRepoLayer(messages) const orgMemberRepoLayer = makeOrganizationMemberRepoLayer(members) - const orgResolverLayer = OrgResolver.DefaultWithoutDependencies.pipe( + const orgResolverLayer = Layer.effect(OrgResolver, OrgResolver.make).pipe( Layer.provide(orgMemberRepoLayer), Layer.provide(channelRepoLayer), Layer.provide(emptyChannelMemberRepoLayer), Layer.provide(messageRepoLayer), ) - return MessagePolicy.DefaultWithoutDependencies.pipe( + return Layer.effect(MessagePolicy, MessagePolicy.make).pipe( Layer.provide(orgResolverLayer), Layer.provide(messageRepoLayer), Layer.provide(channelRepoLayer), @@ -113,13 +123,17 @@ describe("MessagePolicy", () => { }, ) - const result = await runWithActorEither(MessagePolicy.canCreate(CHANNEL_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + MessagePolicy.use((policy) => policy.canCreate(CHANNEL_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canCreate denies non-org-member", async () => { const actor = makeActor({ - id: "00000000-0000-0000-0000-000000000199" as UserId, + id: "00000000-0000-4000-8000-000000000199" as UserId, }) const layer = makePolicyLayer( {}, @@ -129,8 +143,12 @@ describe("MessagePolicy", () => { }, ) - const result = await runWithActorEither(MessagePolicy.canCreate(CHANNEL_ID), layer, actor) - expect(Either.isLeft(result)).toBe(true) + const result = await runWithActorEither( + MessagePolicy.use((policy) => policy.canCreate(CHANNEL_ID)), + layer, + actor, + ) + expect(Result.isFailure(result)).toBe(true) }) it("canRead allows org member with channel access", async () => { @@ -145,8 +163,12 @@ describe("MessagePolicy", () => { }, ) - const result = await runWithActorEither(MessagePolicy.canRead(CHANNEL_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + MessagePolicy.use((policy) => policy.canRead(CHANNEL_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canUpdate allows message author", async () => { @@ -163,13 +185,17 @@ describe("MessagePolicy", () => { }, ) - const result = await runWithActorEither(MessagePolicy.canUpdate(MESSAGE_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + MessagePolicy.use((policy) => policy.canUpdate(MESSAGE_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canUpdate denies non-author", async () => { const otherUser = makeActor({ - id: "00000000-0000-0000-0000-000000000199" as UserId, + id: "00000000-0000-4000-8000-000000000199" as UserId, }) const layer = makePolicyLayer( { @@ -183,8 +209,12 @@ describe("MessagePolicy", () => { }, ) - const result = await runWithActorEither(MessagePolicy.canUpdate(MESSAGE_ID), layer, otherUser) - expect(Either.isLeft(result)).toBe(true) + const result = await runWithActorEither( + MessagePolicy.use((policy) => policy.canUpdate(MESSAGE_ID)), + layer, + otherUser, + ) + expect(Result.isFailure(result)).toBe(true) }) it("canDelete allows message author", async () => { @@ -201,13 +231,17 @@ describe("MessagePolicy", () => { }, ) - const result = await runWithActorEither(MessagePolicy.canDelete(MESSAGE_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + MessagePolicy.use((policy) => policy.canDelete(MESSAGE_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canDelete allows org admin who is not author", async () => { const admin = makeActor({ - id: "00000000-0000-0000-0000-000000000199" as UserId, + id: "00000000-0000-4000-8000-000000000199" as UserId, }) const layer = makePolicyLayer( { @@ -221,13 +255,17 @@ describe("MessagePolicy", () => { }, ) - const result = await runWithActorEither(MessagePolicy.canDelete(MESSAGE_ID), layer, admin) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + MessagePolicy.use((policy) => policy.canDelete(MESSAGE_ID)), + layer, + admin, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canDelete denies org member who is not author and not admin", async () => { const member = makeActor({ - id: "00000000-0000-0000-0000-000000000199" as UserId, + id: "00000000-0000-4000-8000-000000000199" as UserId, }) const layer = makePolicyLayer( { @@ -241,8 +279,12 @@ describe("MessagePolicy", () => { }, ) - const result = await runWithActorEither(MessagePolicy.canDelete(MESSAGE_ID), layer, member) - expect(Either.isLeft(result)).toBe(true) + const result = await runWithActorEither( + MessagePolicy.use((policy) => policy.canDelete(MESSAGE_ID)), + layer, + member, + ) + expect(Result.isFailure(result)).toBe(true) }) it("canDelete maps missing message to UnauthorizedError", async () => { @@ -257,10 +299,14 @@ describe("MessagePolicy", () => { }, ) - const result = await runWithActorEither(MessagePolicy.canDelete(MISSING_MESSAGE_ID), layer, actor) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(UnauthorizedError.is(result.left)).toBe(true) + const result = await runWithActorEither( + MessagePolicy.use((policy) => policy.canDelete(MISSING_MESSAGE_ID)), + layer, + actor, + ) + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + expect(UnauthorizedError.is(result.failure)).toBe(true) } }) }) diff --git a/apps/backend/src/policies/message-policy.ts b/apps/backend/src/policies/message-policy.ts index 98b649c08..51133e0de 100644 --- a/apps/backend/src/policies/message-policy.ts +++ b/apps/backend/src/policies/message-policy.ts @@ -1,12 +1,12 @@ import { ChannelRepo, MessageRepo, OrganizationMemberRepo } from "@hazel/backend-core" import { ErrorUtils, policy } from "@hazel/domain" import type { ChannelId, MessageId } from "@hazel/schema" -import { Effect, Option } from "effect" +import { ServiceMap, Effect, Layer, Option } from "effect" import { isAdminOrOwner, withAnnotatedScope } from "../lib/policy-utils" import { OrgResolver } from "../services/org-resolver" -export class MessagePolicy extends Effect.Service()("MessagePolicy/Policy", { - effect: Effect.gen(function* () { +export class MessagePolicy extends ServiceMap.Service()("MessagePolicy/Policy", { + make: Effect.gen(function* () { const policyEntity = "Message" as const const messageRepo = yield* MessageRepo @@ -86,11 +86,11 @@ export class MessagePolicy extends Effect.Service()("MessagePolic return { canCreate, canRead, canUpdate, canDelete } as const }), - dependencies: [ - MessageRepo.Default, - ChannelRepo.Default, - OrganizationMemberRepo.Default, - OrgResolver.Default, - ], - accessors: true, -}) {} +}) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(MessageRepo.layer), + Layer.provide(ChannelRepo.layer), + Layer.provide(OrganizationMemberRepo.layer), + Layer.provide(OrgResolver.layer), + ) +} diff --git a/apps/backend/src/policies/message-reaction-policy.test.ts b/apps/backend/src/policies/message-reaction-policy.test.ts index 4d7fa7e3f..92177306d 100644 --- a/apps/backend/src/policies/message-reaction-policy.test.ts +++ b/apps/backend/src/policies/message-reaction-policy.test.ts @@ -15,72 +15,99 @@ import type { OrganizationId, UserId, } from "@hazel/schema" -import { Effect, Either, Layer, Option } from "effect" +import { Effect, Result, Layer, Option } from "effect" import { MessageReactionPolicy } from "./message-reaction-policy.ts" import { ConnectConversationService } from "../services/connect-conversation-service.ts" import { OrgResolver } from "../services/org-resolver.ts" -import { makeActor, makeEntityNotFound, runWithActorEither, TEST_ORG_ID } from "./policy-test-helpers.ts" +import { + makeActor, + makeEntityNotFound, + runWithActorEither, + serviceShape, + TEST_ORG_ID, +} from "./policy-test-helpers.ts" type Role = "admin" | "member" | "owner" -const CHANNEL_ID = "00000000-0000-0000-0000-000000000871" as ChannelId -const MESSAGE_ID = "00000000-0000-0000-0000-000000000872" as MessageId -const REACTION_ID = "00000000-0000-0000-0000-000000000873" as MessageReactionId -const OTHER_USER_ID = "00000000-0000-0000-0000-000000000874" as UserId +const CHANNEL_ID = "00000000-0000-4000-8000-000000000871" as ChannelId +const MESSAGE_ID = "00000000-0000-4000-8000-000000000872" as MessageId +const REACTION_ID = "00000000-0000-4000-8000-000000000873" as MessageReactionId +const OTHER_USER_ID = "00000000-0000-4000-8000-000000000874" as UserId type ReactionData = { userId: UserId } type MessageData = { channelId: ChannelId } type ChannelData = { organizationId: OrganizationId; type: string; id: string } const makeReactionRepoLayer = (reactions: Record) => - Layer.succeed(MessageReactionRepo, { - with: (id: MessageReactionId, f: (r: ReactionData) => Effect.Effect) => { - const reaction = reactions[id] - if (!reaction) return Effect.fail(makeEntityNotFound("MessageReaction")) - return f(reaction) - }, - } as unknown as MessageReactionRepo) + Layer.succeed( + MessageReactionRepo, + serviceShape({ + with: (id: MessageReactionId, f: (r: ReactionData) => Effect.Effect) => { + const reaction = reactions[id] + if (!reaction) return Effect.fail(makeEntityNotFound("MessageReaction")) + return f(reaction) + }, + }), + ) const makeMessageRepoLayer = (messages: Record) => - Layer.succeed(MessageRepo, { - with: (id: MessageId, f: (m: MessageData) => Effect.Effect) => { - const message = messages[id] - if (!message) return Effect.fail(makeEntityNotFound("Message")) - return f(message) - }, - findById: (id: MessageId) => { - const message = messages[id] - return Effect.succeed(message ? Option.some(message) : Option.none()) - }, - } as unknown as MessageRepo) + Layer.succeed( + MessageRepo, + serviceShape({ + with: (id: MessageId, f: (m: MessageData) => Effect.Effect) => { + const message = messages[id] + if (!message) return Effect.fail(makeEntityNotFound("Message")) + return f(message) + }, + findById: (id: MessageId) => { + const message = messages[id] + return Effect.succeed(message ? Option.some(message) : Option.none()) + }, + }), + ) const makeChannelRepoLayer = (channels: Record) => - Layer.succeed(ChannelRepo, { - findById: (id: ChannelId) => { - const channel = channels[id] - return Effect.succeed(channel ? Option.some(channel) : Option.none()) - }, - } as unknown as ChannelRepo) + Layer.succeed( + ChannelRepo, + serviceShape({ + findById: (id: ChannelId) => { + const channel = channels[id] + return Effect.succeed(channel ? Option.some(channel) : Option.none()) + }, + }), + ) const makeOrgMemberRepoLayer = (orgMembers: Record) => - Layer.succeed(OrganizationMemberRepo, { - findByOrgAndUser: (organizationId: OrganizationId, userId: UserId) => { - const role = orgMembers[`${organizationId}:${userId}`] - return Effect.succeed(role ? Option.some({ organizationId, userId, role }) : Option.none()) - }, - } as unknown as OrganizationMemberRepo) - -const emptyChannelMemberRepoLayer = Layer.succeed(ChannelMemberRepo, { - findByChannelAndUser: (_channelId: ChannelId, _userId: UserId) => Effect.succeed(Option.none()), -} as unknown as ChannelMemberRepo) + Layer.succeed( + OrganizationMemberRepo, + serviceShape({ + findByOrgAndUser: (organizationId: OrganizationId, userId: UserId) => { + const role = orgMembers[`${organizationId}:${userId}`] + return Effect.succeed(role ? Option.some({ organizationId, userId, role }) : Option.none()) + }, + }), + ) -const emptyMessageRepoLayer = Layer.succeed(MessageRepo, { - findById: (_id: MessageId) => Effect.succeed(Option.none()), -} as unknown as MessageRepo) +const emptyChannelMemberRepoLayer = Layer.succeed( + ChannelMemberRepo, + serviceShape({ + findByChannelAndUser: (_channelId: ChannelId, _userId: UserId) => Effect.succeed(Option.none()), + }), +) -const connectConversationServiceLayer = Layer.succeed(ConnectConversationService, { - canAccessConversation: () => Effect.succeed(false), -} as unknown as ConnectConversationService) +const emptyMessageRepoLayer = Layer.succeed( + MessageRepo, + serviceShape({ + findById: (_id: MessageId) => Effect.succeed(Option.none()), + }), +) + +const connectConversationServiceLayer = Layer.succeed( + ConnectConversationService, + serviceShape({ + canAccessConversation: () => Effect.succeed(false), + }), +) const makePolicyLayer = ( orgMembers: Record, @@ -93,14 +120,14 @@ const makePolicyLayer = ( const orgMemberRepoLayer = makeOrgMemberRepoLayer(orgMembers) // Build OrgResolver with actual channel data (not empty stubs) - const orgResolverLayer = OrgResolver.DefaultWithoutDependencies.pipe( + const orgResolverLayer = Layer.effect(OrgResolver, OrgResolver.make).pipe( Layer.provide(orgMemberRepoLayer), Layer.provide(channelRepoLayer), Layer.provide(emptyChannelMemberRepoLayer), Layer.provide(emptyMessageRepoLayer), ) - return MessageReactionPolicy.DefaultWithoutDependencies.pipe( + return Layer.effect(MessageReactionPolicy, MessageReactionPolicy.make).pipe( Layer.provide(makeReactionRepoLayer(reactions)), Layer.provide(messageRepoLayer), Layer.provide(orgResolverLayer), @@ -113,8 +140,12 @@ describe("MessageReactionPolicy", () => { const actor = makeActor() const layer = makePolicyLayer({}, {}, {}, {}) - const result = await runWithActorEither(MessageReactionPolicy.canList(MESSAGE_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + MessageReactionPolicy.use((policy) => policy.canList(MESSAGE_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canCreate allows org member with channel access", async () => { @@ -126,8 +157,12 @@ describe("MessageReactionPolicy", () => { { [CHANNEL_ID]: { organizationId: TEST_ORG_ID, type: "public", id: CHANNEL_ID } }, ) - const result = await runWithActorEither(MessageReactionPolicy.canCreate(MESSAGE_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + MessageReactionPolicy.use((policy) => policy.canCreate(MESSAGE_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canCreate denies non-org-member", async () => { @@ -139,42 +174,62 @@ describe("MessageReactionPolicy", () => { { [CHANNEL_ID]: { organizationId: TEST_ORG_ID, type: "public", id: CHANNEL_ID } }, ) - const result = await runWithActorEither(MessageReactionPolicy.canCreate(MESSAGE_ID), layer, outsider) - expect(Either.isLeft(result)).toBe(true) + const result = await runWithActorEither( + MessageReactionPolicy.use((policy) => policy.canCreate(MESSAGE_ID)), + layer, + outsider, + ) + expect(Result.isFailure(result)).toBe(true) }) it("canUpdate allows reaction owner", async () => { const actor = makeActor() const layer = makePolicyLayer({}, { [REACTION_ID]: { userId: actor.id } }, {}, {}) - const result = await runWithActorEither(MessageReactionPolicy.canUpdate(REACTION_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + MessageReactionPolicy.use((policy) => policy.canUpdate(REACTION_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canUpdate denies non-owner", async () => { const actor = makeActor() const layer = makePolicyLayer({}, { [REACTION_ID]: { userId: OTHER_USER_ID } }, {}, {}) - const result = await runWithActorEither(MessageReactionPolicy.canUpdate(REACTION_ID), layer, actor) - expect(Either.isLeft(result)).toBe(true) + const result = await runWithActorEither( + MessageReactionPolicy.use((policy) => policy.canUpdate(REACTION_ID)), + layer, + actor, + ) + expect(Result.isFailure(result)).toBe(true) }) it("canDelete allows reaction owner", async () => { const actor = makeActor() const layer = makePolicyLayer({}, { [REACTION_ID]: { userId: actor.id } }, {}, {}) - const result = await runWithActorEither(MessageReactionPolicy.canDelete(REACTION_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + MessageReactionPolicy.use((policy) => policy.canDelete(REACTION_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canDelete denies non-owner", async () => { const actor = makeActor() const layer = makePolicyLayer({}, { [REACTION_ID]: { userId: OTHER_USER_ID } }, {}, {}) - const result = await runWithActorEither(MessageReactionPolicy.canDelete(REACTION_ID), layer, actor) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(UnauthorizedError.is(result.left)).toBe(true) + const result = await runWithActorEither( + MessageReactionPolicy.use((policy) => policy.canDelete(REACTION_ID)), + layer, + actor, + ) + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + expect(UnauthorizedError.is(result.failure)).toBe(true) } }) }) diff --git a/apps/backend/src/policies/message-reaction-policy.ts b/apps/backend/src/policies/message-reaction-policy.ts index e8237278a..14b9c0846 100644 --- a/apps/backend/src/policies/message-reaction-policy.ts +++ b/apps/backend/src/policies/message-reaction-policy.ts @@ -1,15 +1,15 @@ import { MessageReactionRepo, MessageRepo } from "@hazel/backend-core" import { ErrorUtils, PermissionError, policy } from "@hazel/domain" import type { MessageId, MessageReactionId } from "@hazel/schema" -import { Effect } from "effect" +import { ServiceMap, Effect, Layer } from "effect" import { withAnnotatedScope } from "../lib/policy-utils" import { ConnectConversationService } from "../services/connect-conversation-service" import { OrgResolver } from "../services/org-resolver" -export class MessageReactionPolicy extends Effect.Service()( +export class MessageReactionPolicy extends ServiceMap.Service()( "MessageReactionPolicy/Policy", { - effect: Effect.gen(function* () { + make: Effect.gen(function* () { const policyEntity = "MessageReaction" as const const messageReactionRepo = yield* MessageReactionRepo @@ -99,12 +99,12 @@ export class MessageReactionPolicy extends Effect.Service return { canCreate, canDelete, canUpdate, canList } as const }), - dependencies: [ - MessageReactionRepo.Default, - MessageRepo.Default, - OrgResolver.Default, - ConnectConversationService.Default, - ], - accessors: true, }, -) {} +) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(MessageReactionRepo.layer), + Layer.provide(MessageRepo.layer), + Layer.provide(OrgResolver.layer), + Layer.provide(ConnectConversationService.layer), + ) +} diff --git a/apps/backend/src/policies/notification-policy.test.ts b/apps/backend/src/policies/notification-policy.test.ts index 366bb26a0..836fe961e 100644 --- a/apps/backend/src/policies/notification-policy.test.ts +++ b/apps/backend/src/policies/notification-policy.test.ts @@ -2,61 +2,68 @@ import { describe, expect, it } from "@effect/vitest" import { NotificationRepo, OrganizationMemberRepo } from "@hazel/backend-core" import { UnauthorizedError } from "@hazel/domain" import type { NotificationId, OrganizationId, OrganizationMemberId, UserId } from "@hazel/schema" -import { Effect, Either, Layer, Option } from "effect" +import { Effect, Result, Layer, Option } from "effect" import { NotificationPolicy } from "./notification-policy.ts" import { makeActor, makeEntityNotFound, makeOrgResolverLayer, runWithActorEither, + serviceShape, TEST_ORG_ID, } from "./policy-test-helpers.ts" type Role = "admin" | "member" | "owner" -const NOTIFICATION_ID = "00000000-0000-0000-0000-000000000841" as NotificationId -const MEMBER_ID = "00000000-0000-0000-0000-000000000842" as OrganizationMemberId -const ADMIN_USER_ID = "00000000-0000-0000-0000-000000000843" as UserId -const OTHER_USER_ID = "00000000-0000-0000-0000-000000000844" as UserId +const NOTIFICATION_ID = "00000000-0000-4000-8000-000000000841" as NotificationId +const MEMBER_ID = "00000000-0000-4000-8000-000000000842" as OrganizationMemberId +const ADMIN_USER_ID = "00000000-0000-4000-8000-000000000843" as UserId +const OTHER_USER_ID = "00000000-0000-4000-8000-000000000844" as UserId type NotificationData = { memberId: OrganizationMemberId } type MemberData = { userId: UserId; organizationId: OrganizationId; role: string } const makeNotificationRepoLayer = (notifications: Record) => - Layer.succeed(NotificationRepo, { - with: ( - id: NotificationId, - f: (notification: NotificationData) => Effect.Effect, - ) => { - const notification = notifications[id] - if (!notification) return Effect.fail(makeEntityNotFound("Notification")) - return f(notification) - }, - } as unknown as NotificationRepo) + Layer.succeed( + NotificationRepo, + serviceShape({ + with: ( + id: NotificationId, + f: (notification: NotificationData) => Effect.Effect, + ) => { + const notification = notifications[id] + if (!notification) return Effect.fail(makeEntityNotFound("Notification")) + return f(notification) + }, + }), + ) const makeOrgMemberRepoLayer = (members: Record, orgMembers: Record) => - Layer.succeed(OrganizationMemberRepo, { - with: (id: OrganizationMemberId, f: (m: MemberData) => Effect.Effect) => { - const member = members[id] - if (!member) return Effect.fail(makeEntityNotFound("OrganizationMember")) - return f(member) - }, - findById: (id: OrganizationMemberId) => { - const member = members[id] - return Effect.succeed(member ? Option.some(member) : Option.none()) - }, - findByOrgAndUser: (organizationId: OrganizationId, userId: UserId) => { - const role = orgMembers[`${organizationId}:${userId}`] - return Effect.succeed(role ? Option.some({ organizationId, userId, role }) : Option.none()) - }, - } as unknown as OrganizationMemberRepo) + Layer.succeed( + OrganizationMemberRepo, + serviceShape({ + with: (id: OrganizationMemberId, f: (m: MemberData) => Effect.Effect) => { + const member = members[id] + if (!member) return Effect.fail(makeEntityNotFound("OrganizationMember")) + return f(member) + }, + findById: (id: OrganizationMemberId) => { + const member = members[id] + return Effect.succeed(member ? Option.some(member) : Option.none()) + }, + findByOrgAndUser: (organizationId: OrganizationId, userId: UserId) => { + const role = orgMembers[`${organizationId}:${userId}`] + return Effect.succeed(role ? Option.some({ organizationId, userId, role }) : Option.none()) + }, + }), + ) const makePolicyLayer = ( notifications: Record, members: Record, orgMembers: Record, ) => - NotificationPolicy.DefaultWithoutDependencies.pipe( + Layer.effect(NotificationPolicy, NotificationPolicy.make).pipe( Layer.provide(makeNotificationRepoLayer(notifications)), Layer.provide(makeOrgMemberRepoLayer(members, orgMembers)), Layer.provide(makeOrgResolverLayer(orgMembers)), @@ -67,8 +74,12 @@ describe("NotificationPolicy", () => { const actor = makeActor() const layer = makePolicyLayer({}, {}, {}) - const result = await runWithActorEither(NotificationPolicy.canCreate(MEMBER_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + NotificationPolicy.use((policy) => policy.canCreate(MEMBER_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canView allows notification owner", async () => { @@ -79,8 +90,12 @@ describe("NotificationPolicy", () => { {}, ) - const result = await runWithActorEither(NotificationPolicy.canView(NOTIFICATION_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + NotificationPolicy.use((policy) => policy.canView(NOTIFICATION_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canView denies other user", async () => { @@ -91,8 +106,12 @@ describe("NotificationPolicy", () => { {}, ) - const result = await runWithActorEither(NotificationPolicy.canView(NOTIFICATION_ID), layer, actor) - expect(Either.isLeft(result)).toBe(true) + const result = await runWithActorEither( + NotificationPolicy.use((policy) => policy.canView(NOTIFICATION_ID)), + layer, + actor, + ) + expect(Result.isFailure(result)).toBe(true) }) it("canUpdate allows notification owner", async () => { @@ -103,8 +122,12 @@ describe("NotificationPolicy", () => { { [`${TEST_ORG_ID}:${actor.id}`]: "member" }, ) - const result = await runWithActorEither(NotificationPolicy.canUpdate(NOTIFICATION_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + NotificationPolicy.use((policy) => policy.canUpdate(NOTIFICATION_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canUpdate allows org admin", async () => { @@ -115,8 +138,12 @@ describe("NotificationPolicy", () => { { [`${TEST_ORG_ID}:${ADMIN_USER_ID}`]: "admin" }, ) - const result = await runWithActorEither(NotificationPolicy.canUpdate(NOTIFICATION_ID), layer, admin) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + NotificationPolicy.use((policy) => policy.canUpdate(NOTIFICATION_ID)), + layer, + admin, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canUpdate denies non-owner non-admin", async () => { @@ -125,7 +152,7 @@ describe("NotificationPolicy", () => { { [NOTIFICATION_ID]: { memberId: MEMBER_ID } }, { [MEMBER_ID]: { - userId: "00000000-0000-0000-0000-000000000849" as UserId, + userId: "00000000-0000-4000-8000-000000000849" as UserId, organizationId: TEST_ORG_ID, role: "member", }, @@ -134,11 +161,11 @@ describe("NotificationPolicy", () => { ) const result = await runWithActorEither( - NotificationPolicy.canUpdate(NOTIFICATION_ID), + NotificationPolicy.use((policy) => policy.canUpdate(NOTIFICATION_ID)), layer, outsider, ) - expect(Either.isLeft(result)).toBe(true) + expect(Result.isFailure(result)).toBe(true) }) it("canDelete allows notification owner", async () => { @@ -149,8 +176,12 @@ describe("NotificationPolicy", () => { { [`${TEST_ORG_ID}:${actor.id}`]: "member" }, ) - const result = await runWithActorEither(NotificationPolicy.canDelete(NOTIFICATION_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + NotificationPolicy.use((policy) => policy.canDelete(NOTIFICATION_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canDelete allows org admin", async () => { @@ -161,8 +192,12 @@ describe("NotificationPolicy", () => { { [`${TEST_ORG_ID}:${ADMIN_USER_ID}`]: "admin" }, ) - const result = await runWithActorEither(NotificationPolicy.canDelete(NOTIFICATION_ID), layer, admin) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + NotificationPolicy.use((policy) => policy.canDelete(NOTIFICATION_ID)), + layer, + admin, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canMarkAsRead allows notification owner", async () => { @@ -174,11 +209,11 @@ describe("NotificationPolicy", () => { ) const result = await runWithActorEither( - NotificationPolicy.canMarkAsRead(NOTIFICATION_ID), + NotificationPolicy.use((policy) => policy.canMarkAsRead(NOTIFICATION_ID)), layer, actor, ) - expect(Either.isRight(result)).toBe(true) + expect(Result.isSuccess(result)).toBe(true) }) it("canMarkAllAsRead allows member owner", async () => { @@ -189,8 +224,12 @@ describe("NotificationPolicy", () => { { [`${TEST_ORG_ID}:${actor.id}`]: "member" }, ) - const result = await runWithActorEither(NotificationPolicy.canMarkAllAsRead(MEMBER_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + NotificationPolicy.use((policy) => policy.canMarkAllAsRead(MEMBER_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canMarkAllAsRead allows org admin", async () => { @@ -201,8 +240,12 @@ describe("NotificationPolicy", () => { { [`${TEST_ORG_ID}:${ADMIN_USER_ID}`]: "admin" }, ) - const result = await runWithActorEither(NotificationPolicy.canMarkAllAsRead(MEMBER_ID), layer, admin) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + NotificationPolicy.use((policy) => policy.canMarkAllAsRead(MEMBER_ID)), + layer, + admin, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canMarkAllAsRead denies outsider", async () => { @@ -211,7 +254,7 @@ describe("NotificationPolicy", () => { {}, { [MEMBER_ID]: { - userId: "00000000-0000-0000-0000-000000000849" as UserId, + userId: "00000000-0000-4000-8000-000000000849" as UserId, organizationId: TEST_ORG_ID, role: "member", }, @@ -220,10 +263,10 @@ describe("NotificationPolicy", () => { ) const result = await runWithActorEither( - NotificationPolicy.canMarkAllAsRead(MEMBER_ID), + NotificationPolicy.use((policy) => policy.canMarkAllAsRead(MEMBER_ID)), layer, outsider, ) - expect(Either.isLeft(result)).toBe(true) + expect(Result.isFailure(result)).toBe(true) }) }) diff --git a/apps/backend/src/policies/notification-policy.ts b/apps/backend/src/policies/notification-policy.ts index 03788ba33..1f26688e4 100644 --- a/apps/backend/src/policies/notification-policy.ts +++ b/apps/backend/src/policies/notification-policy.ts @@ -1,125 +1,157 @@ import { NotificationRepo, OrganizationMemberRepo } from "@hazel/backend-core" import { ErrorUtils, policy } from "@hazel/domain" import type { NotificationId, OrganizationMemberId } from "@hazel/schema" -import { Effect, Option } from "effect" +import { ServiceMap, Effect, Layer, Option } from "effect" import { isAdminOrOwner } from "../lib/policy-utils" -export class NotificationPolicy extends Effect.Service()("NotificationPolicy/Policy", { - effect: Effect.gen(function* () { - const policyEntity = "Notification" as const +export class NotificationPolicy extends ServiceMap.Service()( + "NotificationPolicy/Policy", + { + make: Effect.gen(function* () { + const policyEntity = "Notification" as const - const notificationRepo = yield* NotificationRepo - const organizationMemberRepo = yield* OrganizationMemberRepo + const notificationRepo = yield* NotificationRepo + const organizationMemberRepo = yield* OrganizationMemberRepo - const canCreate = (_memberId: OrganizationMemberId) => - ErrorUtils.refailUnauthorized( - policyEntity, - "create", - )( - policy( + const canCreate = (_memberId: OrganizationMemberId) => + ErrorUtils.refailUnauthorized( policyEntity, "create", - Effect.fn(`${policyEntity}.create`)(function* (_actor) { - return yield* Effect.succeed(true) - }), - ), - ) - - const canView = (id: NotificationId) => - ErrorUtils.refailUnauthorized( - policyEntity, - "view", - )( - notificationRepo.with(id, (notification) => + )( policy( policyEntity, - "view", - Effect.fn(`${policyEntity}.view`)(function* (actor) { - const member = yield* organizationMemberRepo.findById(notification.memberId) - - if (Option.isSome(member) && member.value.userId === actor.id) { - return yield* Effect.succeed(true) - } - - return yield* Effect.succeed(false) + "create", + Effect.fn(`${policyEntity}.create`)(function* (_actor) { + return yield* Effect.succeed(true) }), ), - ), - ) - - const canUpdate = (id: NotificationId) => - ErrorUtils.refailUnauthorized( - policyEntity, - "update", - )( - notificationRepo.with(id, (notification) => - organizationMemberRepo.with(notification.memberId, (member) => + ) + + const canView = (id: NotificationId) => + ErrorUtils.refailUnauthorized( + policyEntity, + "view", + )( + notificationRepo.with(id, (notification) => policy( policyEntity, - "update", - Effect.fn(`${policyEntity}.update`)(function* (actor) { - if (member.userId === actor.id) { - return yield* Effect.succeed(true) - } + "view", + Effect.fn(`${policyEntity}.view`)(function* (actor) { + const member = yield* organizationMemberRepo.findById(notification.memberId) - const actorMember = yield* organizationMemberRepo.findByOrgAndUser( - member.organizationId, - actor.id, - ) - - if (Option.isSome(actorMember)) { - return yield* Effect.succeed(isAdminOrOwner(actorMember.value.role)) + if (Option.isSome(member) && member.value.userId === actor.id) { + return yield* Effect.succeed(true) } return yield* Effect.succeed(false) }), ), ), - ), - ) - - const canDelete = (id: NotificationId) => - ErrorUtils.refailUnauthorized( - policyEntity, - "delete", - )( - notificationRepo.with(id, (notification) => - organizationMemberRepo.with(notification.memberId, (member) => - policy( - policyEntity, - "delete", - Effect.fn(`${policyEntity}.delete`)(function* (actor) { - if (member.userId === actor.id) { - return yield* Effect.succeed(true) - } + ) - const actorMember = yield* organizationMemberRepo.findByOrgAndUser( - member.organizationId, - actor.id, - ) + const canUpdate = (id: NotificationId) => + ErrorUtils.refailUnauthorized( + policyEntity, + "update", + )( + notificationRepo.with(id, (notification) => + organizationMemberRepo.with(notification.memberId, (member) => + policy( + policyEntity, + "update", + Effect.fn(`${policyEntity}.update`)(function* (actor) { + if (member.userId === actor.id) { + return yield* Effect.succeed(true) + } + + const actorMember = yield* organizationMemberRepo.findByOrgAndUser( + member.organizationId, + actor.id, + ) + + if (Option.isSome(actorMember)) { + return yield* Effect.succeed(isAdminOrOwner(actorMember.value.role)) + } + + return yield* Effect.succeed(false) + }), + ), + ), + ), + ) - if (Option.isSome(actorMember)) { - return yield* Effect.succeed(isAdminOrOwner(actorMember.value.role)) - } + const canDelete = (id: NotificationId) => + ErrorUtils.refailUnauthorized( + policyEntity, + "delete", + )( + notificationRepo.with(id, (notification) => + organizationMemberRepo.with(notification.memberId, (member) => + policy( + policyEntity, + "delete", + Effect.fn(`${policyEntity}.delete`)(function* (actor) { + if (member.userId === actor.id) { + return yield* Effect.succeed(true) + } + + const actorMember = yield* organizationMemberRepo.findByOrgAndUser( + member.organizationId, + actor.id, + ) + + if (Option.isSome(actorMember)) { + return yield* Effect.succeed(isAdminOrOwner(actorMember.value.role)) + } + + return yield* Effect.succeed(false) + }), + ), + ), + ), + ) - return yield* Effect.succeed(false) - }), + const canMarkAsRead = (id: NotificationId) => + ErrorUtils.refailUnauthorized( + policyEntity, + "markAsRead", + )( + notificationRepo.with(id, (notification) => + organizationMemberRepo.with(notification.memberId, (member) => + policy( + policyEntity, + "markAsRead", + Effect.fn(`${policyEntity}.markAsRead`)(function* (actor) { + if (member.userId === actor.id) { + return yield* Effect.succeed(true) + } + + const actorMember = yield* organizationMemberRepo.findByOrgAndUser( + member.organizationId, + actor.id, + ) + + if (Option.isSome(actorMember)) { + return yield* Effect.succeed(isAdminOrOwner(actorMember.value.role)) + } + + return yield* Effect.succeed(false) + }), + ), ), ), - ), - ) - - const canMarkAsRead = (id: NotificationId) => - ErrorUtils.refailUnauthorized( - policyEntity, - "markAsRead", - )( - notificationRepo.with(id, (notification) => - organizationMemberRepo.with(notification.memberId, (member) => + ) + + const canMarkAllAsRead = (memberId: OrganizationMemberId) => + ErrorUtils.refailUnauthorized( + policyEntity, + "markAllAsRead", + )( + organizationMemberRepo.with(memberId, (member) => policy( policyEntity, - "markAsRead", - Effect.fn(`${policyEntity}.markAsRead`)(function* (actor) { + "markAllAsRead", + Effect.fn(`${policyEntity}.markAllAsRead`)(function* (actor) { if (member.userId === actor.id) { return yield* Effect.succeed(true) } @@ -130,49 +162,24 @@ export class NotificationPolicy extends Effect.Service()("No ) if (Option.isSome(actorMember)) { - return yield* Effect.succeed(isAdminOrOwner(actorMember.value.role)) + return yield* Effect.succeed( + actorMember.value.role === "admin" || + actorMember.value.role === "owner", + ) } return yield* Effect.succeed(false) }), ), ), - ), - ) - - const canMarkAllAsRead = (memberId: OrganizationMemberId) => - ErrorUtils.refailUnauthorized( - policyEntity, - "markAllAsRead", - )( - organizationMemberRepo.with(memberId, (member) => - policy( - policyEntity, - "markAllAsRead", - Effect.fn(`${policyEntity}.markAllAsRead`)(function* (actor) { - if (member.userId === actor.id) { - return yield* Effect.succeed(true) - } - - const actorMember = yield* organizationMemberRepo.findByOrgAndUser( - member.organizationId, - actor.id, - ) - - if (Option.isSome(actorMember)) { - return yield* Effect.succeed( - actorMember.value.role === "admin" || actorMember.value.role === "owner", - ) - } - - return yield* Effect.succeed(false) - }), - ), - ), - ) - - return { canCreate, canView, canUpdate, canDelete, canMarkAsRead, canMarkAllAsRead } as const - }), - dependencies: [NotificationRepo.Default, OrganizationMemberRepo.Default], - accessors: true, -}) {} + ) + + return { canCreate, canView, canUpdate, canDelete, canMarkAsRead, canMarkAllAsRead } as const + }), + }, +) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(NotificationRepo.layer), + Layer.provide(OrganizationMemberRepo.layer), + ) +} diff --git a/apps/backend/src/policies/organization-member-policy.test.ts b/apps/backend/src/policies/organization-member-policy.test.ts index b7f0a51c1..a6ae7c2e5 100644 --- a/apps/backend/src/policies/organization-member-policy.test.ts +++ b/apps/backend/src/policies/organization-member-policy.test.ts @@ -2,40 +2,44 @@ import { describe, expect, it } from "@effect/vitest" import { OrganizationMemberRepo } from "@hazel/backend-core" import { UnauthorizedError } from "@hazel/domain" import type { OrganizationId, OrganizationMemberId, UserId } from "@hazel/schema" -import { Effect, Either, Layer, Option } from "effect" +import { Effect, Result, Layer, Option } from "effect" import { OrganizationMemberPolicy } from "./organization-member-policy.ts" import { makeActor, makeEntityNotFound, makeOrgResolverLayer, runWithActorEither, + serviceShape, TEST_ORG_ID, } from "./policy-test-helpers.ts" type Role = "admin" | "member" | "owner" -const MEMBER_ID = "00000000-0000-0000-0000-000000000851" as OrganizationMemberId -const TARGET_USER_ID = "00000000-0000-0000-0000-000000000852" as UserId -const ADMIN_USER_ID = "00000000-0000-0000-0000-000000000853" as UserId -const OWNER_USER_ID = "00000000-0000-0000-0000-000000000854" as UserId +const MEMBER_ID = "00000000-0000-4000-8000-000000000851" as OrganizationMemberId +const TARGET_USER_ID = "00000000-0000-4000-8000-000000000852" as UserId +const ADMIN_USER_ID = "00000000-0000-4000-8000-000000000853" as UserId +const OWNER_USER_ID = "00000000-0000-4000-8000-000000000854" as UserId type MemberData = { userId: UserId; organizationId: OrganizationId; role: string } const makeOrgMemberRepoLayer = (membersById: Record, orgMembers: Record) => - Layer.succeed(OrganizationMemberRepo, { - with: (id: OrganizationMemberId, f: (m: MemberData) => Effect.Effect) => { - const member = membersById[id] - if (!member) return Effect.fail(makeEntityNotFound("OrganizationMember")) - return f(member) - }, - findByOrgAndUser: (organizationId: OrganizationId, userId: UserId) => { - const role = orgMembers[`${organizationId}:${userId}`] - return Effect.succeed(role ? Option.some({ organizationId, userId, role }) : Option.none()) - }, - } as unknown as OrganizationMemberRepo) + Layer.succeed( + OrganizationMemberRepo, + serviceShape({ + with: (id: OrganizationMemberId, f: (m: MemberData) => Effect.Effect) => { + const member = membersById[id] + if (!member) return Effect.fail(makeEntityNotFound("OrganizationMember")) + return f(member) + }, + findByOrgAndUser: (organizationId: OrganizationId, userId: UserId) => { + const role = orgMembers[`${organizationId}:${userId}`] + return Effect.succeed(role ? Option.some({ organizationId, userId, role }) : Option.none()) + }, + }), + ) const makePolicyLayer = (membersById: Record, orgMembers: Record) => - OrganizationMemberPolicy.DefaultWithoutDependencies.pipe( + Layer.effect(OrganizationMemberPolicy, OrganizationMemberPolicy.make).pipe( Layer.provide(makeOrgMemberRepoLayer(membersById, orgMembers)), Layer.provide(makeOrgResolverLayer(orgMembers)), ) @@ -45,16 +49,24 @@ describe("OrganizationMemberPolicy", () => { const actor = makeActor() const layer = makePolicyLayer({}, {}) - const result = await runWithActorEither(OrganizationMemberPolicy.canCreate(TEST_ORG_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + OrganizationMemberPolicy.use((policy) => policy.canCreate(TEST_ORG_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canCreate denies already-existing member", async () => { const actor = makeActor() const layer = makePolicyLayer({}, { [`${TEST_ORG_ID}:${actor.id}`]: "member" }) - const result = await runWithActorEither(OrganizationMemberPolicy.canCreate(TEST_ORG_ID), layer, actor) - expect(Either.isLeft(result)).toBe(true) + const result = await runWithActorEither( + OrganizationMemberPolicy.use((policy) => policy.canCreate(TEST_ORG_ID)), + layer, + actor, + ) + expect(Result.isFailure(result)).toBe(true) }) it("canUpdate allows self-update", async () => { @@ -64,8 +76,12 @@ describe("OrganizationMemberPolicy", () => { { [`${TEST_ORG_ID}:${actor.id}`]: "member" }, ) - const result = await runWithActorEither(OrganizationMemberPolicy.canUpdate(MEMBER_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + OrganizationMemberPolicy.use((policy) => policy.canUpdate(MEMBER_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canUpdate allows org admin", async () => { @@ -75,8 +91,12 @@ describe("OrganizationMemberPolicy", () => { { [`${TEST_ORG_ID}:${ADMIN_USER_ID}`]: "admin" }, ) - const result = await runWithActorEither(OrganizationMemberPolicy.canUpdate(MEMBER_ID), layer, admin) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + OrganizationMemberPolicy.use((policy) => policy.canUpdate(MEMBER_ID)), + layer, + admin, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canUpdate denies org owner (only admin allowed)", async () => { @@ -86,26 +106,30 @@ describe("OrganizationMemberPolicy", () => { { [`${TEST_ORG_ID}:${OWNER_USER_ID}`]: "owner" }, ) - const result = await runWithActorEither(OrganizationMemberPolicy.canUpdate(MEMBER_ID), layer, owner) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(UnauthorizedError.is(result.left)).toBe(true) + const result = await runWithActorEither( + OrganizationMemberPolicy.use((policy) => policy.canUpdate(MEMBER_ID)), + layer, + owner, + ) + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + expect(UnauthorizedError.is(result.failure)).toBe(true) } }) it("canUpdate denies outsider", async () => { - const outsider = makeActor({ id: "00000000-0000-0000-0000-000000000859" as UserId }) + const outsider = makeActor({ id: "00000000-0000-4000-8000-000000000859" as UserId }) const layer = makePolicyLayer( { [MEMBER_ID]: { userId: TARGET_USER_ID, organizationId: TEST_ORG_ID, role: "member" } }, {}, ) const result = await runWithActorEither( - OrganizationMemberPolicy.canUpdate(MEMBER_ID), + OrganizationMemberPolicy.use((policy) => policy.canUpdate(MEMBER_ID)), layer, outsider, ) - expect(Either.isLeft(result)).toBe(true) + expect(Result.isFailure(result)).toBe(true) }) it("canDelete allows self-removal", async () => { @@ -115,8 +139,12 @@ describe("OrganizationMemberPolicy", () => { { [`${TEST_ORG_ID}:${actor.id}`]: "member" }, ) - const result = await runWithActorEither(OrganizationMemberPolicy.canDelete(MEMBER_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + OrganizationMemberPolicy.use((policy) => policy.canDelete(MEMBER_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canDelete allows org admin", async () => { @@ -126,8 +154,12 @@ describe("OrganizationMemberPolicy", () => { { [`${TEST_ORG_ID}:${ADMIN_USER_ID}`]: "admin" }, ) - const result = await runWithActorEither(OrganizationMemberPolicy.canDelete(MEMBER_ID), layer, admin) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + OrganizationMemberPolicy.use((policy) => policy.canDelete(MEMBER_ID)), + layer, + admin, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canDelete denies org owner (only admin allowed)", async () => { @@ -137,10 +169,14 @@ describe("OrganizationMemberPolicy", () => { { [`${TEST_ORG_ID}:${OWNER_USER_ID}`]: "owner" }, ) - const result = await runWithActorEither(OrganizationMemberPolicy.canDelete(MEMBER_ID), layer, owner) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(UnauthorizedError.is(result.left)).toBe(true) + const result = await runWithActorEither( + OrganizationMemberPolicy.use((policy) => policy.canDelete(MEMBER_ID)), + layer, + owner, + ) + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + expect(UnauthorizedError.is(result.failure)).toBe(true) } }) }) diff --git a/apps/backend/src/policies/organization-member-policy.ts b/apps/backend/src/policies/organization-member-policy.ts index d13d3091b..1b0def8cd 100644 --- a/apps/backend/src/policies/organization-member-policy.ts +++ b/apps/backend/src/policies/organization-member-policy.ts @@ -1,13 +1,13 @@ import { OrganizationMemberRepo } from "@hazel/backend-core" import { ErrorUtils, policy } from "@hazel/domain" import type { OrganizationId, OrganizationMemberId } from "@hazel/schema" -import { Effect, Option } from "effect" +import { ServiceMap, Effect, Layer, Option } from "effect" import { OrgResolver } from "../services/org-resolver" -export class OrganizationMemberPolicy extends Effect.Service()( +export class OrganizationMemberPolicy extends ServiceMap.Service()( "OrganizationMemberPolicy/Policy", { - effect: Effect.gen(function* () { + make: Effect.gen(function* () { const policyEntity = "OrganizationMember" as const const organizationMemberRepo = yield* OrganizationMemberRepo @@ -103,7 +103,10 @@ export class OrganizationMemberPolicy extends Effect.Service) => - OrganizationPolicy.DefaultWithoutDependencies.pipe( + Layer.effect(OrganizationPolicy, OrganizationPolicy.make).pipe( Layer.provide(makeOrgResolverLayer(members)), Layer.provide(makeOrganizationMemberRepoLayer(members)), ) describe("OrganizationPolicy", () => { it("canCreate allows any authenticated actor", async () => { - const result = await runWithActorEither(OrganizationPolicy.canCreate(), makePolicyLayer({})) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + OrganizationPolicy.use((policy) => policy.canCreate()), + makePolicyLayer({}), + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canUpdate allows admin and owner, denies plain member", async () => { const adminActor = makeActor() const memberActor = makeActor({ - id: "00000000-0000-0000-0000-000000000222" as UserId, + id: "00000000-0000-4000-8000-000000000222" as UserId, }) const layer = makePolicyLayer({ @@ -38,27 +41,27 @@ describe("OrganizationPolicy", () => { }) const adminResult = await runWithActorEither( - OrganizationPolicy.canUpdate(TEST_ORG_ID), + OrganizationPolicy.use((policy) => policy.canUpdate(TEST_ORG_ID)), layer, adminActor, ) const memberResult = await runWithActorEither( - OrganizationPolicy.canUpdate(TEST_ORG_ID), + OrganizationPolicy.use((policy) => policy.canUpdate(TEST_ORG_ID)), layer, memberActor, ) - expect(Either.isRight(adminResult)).toBe(true) - expect(Either.isLeft(memberResult)).toBe(true) - if (Either.isLeft(memberResult)) { - expect(UnauthorizedError.is(memberResult.left)).toBe(true) + expect(Result.isSuccess(adminResult)).toBe(true) + expect(Result.isFailure(memberResult)).toBe(true) + if (Result.isFailure(memberResult)) { + expect(UnauthorizedError.is(memberResult.failure)).toBe(true) } }) it("canDelete allows owner only", async () => { const ownerActor = makeActor() const adminActor = makeActor({ - id: "00000000-0000-0000-0000-000000000223" as UserId, + id: "00000000-0000-4000-8000-000000000223" as UserId, }) const layer = makePolicyLayer({ @@ -67,18 +70,18 @@ describe("OrganizationPolicy", () => { }) const ownerResult = await runWithActorEither( - OrganizationPolicy.canDelete(TEST_ORG_ID), + OrganizationPolicy.use((policy) => policy.canDelete(TEST_ORG_ID)), layer, ownerActor, ) const adminResult = await runWithActorEither( - OrganizationPolicy.canDelete(TEST_ORG_ID), + OrganizationPolicy.use((policy) => policy.canDelete(TEST_ORG_ID)), layer, adminActor, ) - expect(Either.isRight(ownerResult)).toBe(true) - expect(Either.isLeft(adminResult)).toBe(true) + expect(Result.isSuccess(ownerResult)).toBe(true) + expect(Result.isFailure(adminResult)).toBe(true) }) it("isMember denies users without membership in target org", async () => { @@ -87,10 +90,14 @@ describe("OrganizationPolicy", () => { [`${TEST_ALT_ORG_ID}:${actor.id}`]: "member", }) - const result = await runWithActorEither(OrganizationPolicy.isMember(TEST_ORG_ID), layer, actor) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(UnauthorizedError.is(result.left)).toBe(true) + const result = await runWithActorEither( + OrganizationPolicy.use((policy) => policy.isMember(TEST_ORG_ID)), + layer, + actor, + ) + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + expect(UnauthorizedError.is(result.failure)).toBe(true) } }) @@ -101,10 +108,10 @@ describe("OrganizationPolicy", () => { }) const result = await runWithActorEither( - OrganizationPolicy.canManagePublicInvite(TEST_ORG_ID), + OrganizationPolicy.use((policy) => policy.canManagePublicInvite(TEST_ORG_ID)), layer, actor, ) - expect(Either.isRight(result)).toBe(true) + expect(Result.isSuccess(result)).toBe(true) }) }) diff --git a/apps/backend/src/policies/organization-policy.ts b/apps/backend/src/policies/organization-policy.ts index 7fcaecb8e..522546187 100644 --- a/apps/backend/src/policies/organization-policy.ts +++ b/apps/backend/src/policies/organization-policy.ts @@ -1,52 +1,59 @@ import { ErrorUtils } from "@hazel/domain" import type { OrganizationId } from "@hazel/schema" -import { Effect } from "effect" +import { ServiceMap, Effect, Layer } from "effect" import { makePolicy, withAnnotatedScope } from "../lib/policy-utils" import { OrgResolver } from "../services/org-resolver" -export class OrganizationPolicy extends Effect.Service()("OrganizationPolicy/Policy", { - effect: Effect.gen(function* () { - const policyEntity = "Organization" as const - const authorize = makePolicy(policyEntity) - - const orgResolver = yield* OrgResolver - - const canCreate = () => authorize("create", (_actor) => Effect.succeed(true)) - - const canUpdate = (id: OrganizationId) => - ErrorUtils.refailUnauthorized( - policyEntity, - "update", - )( - withAnnotatedScope((scope) => - orgResolver.requireAdminOrOwner(id, scope, policyEntity, "update"), - ), - ) - - const isMember = (id: OrganizationId) => - ErrorUtils.refailUnauthorized( - policyEntity, - "isMember", - )(withAnnotatedScope((scope) => orgResolver.requireScope(id, scope, policyEntity, "isMember"))) - - const canDelete = (id: OrganizationId) => - ErrorUtils.refailUnauthorized( - policyEntity, - "delete", - )(withAnnotatedScope((scope) => orgResolver.requireOwner(id, scope, policyEntity, "delete"))) - - const canManagePublicInvite = (id: OrganizationId) => - ErrorUtils.refailUnauthorized( - policyEntity, - "managePublicInvite", - )( - withAnnotatedScope((scope) => - orgResolver.requireAdminOrOwner(id, scope, policyEntity, "managePublicInvite"), - ), - ) - - return { canUpdate, canDelete, canCreate, isMember, canManagePublicInvite } as const - }), - dependencies: [OrgResolver.Default], - accessors: true, -}) {} +export class OrganizationPolicy extends ServiceMap.Service()( + "OrganizationPolicy/Policy", + { + make: Effect.gen(function* () { + const policyEntity = "Organization" as const + const authorize = makePolicy(policyEntity) + + const orgResolver = yield* OrgResolver + + const canCreate = () => authorize("create", (_actor) => Effect.succeed(true)) + + const canUpdate = (id: OrganizationId) => + ErrorUtils.refailUnauthorized( + policyEntity, + "update", + )( + withAnnotatedScope((scope) => + orgResolver.requireAdminOrOwner(id, scope, policyEntity, "update"), + ), + ) + + const isMember = (id: OrganizationId) => + ErrorUtils.refailUnauthorized( + policyEntity, + "isMember", + )( + withAnnotatedScope((scope) => + orgResolver.requireScope(id, scope, policyEntity, "isMember"), + ), + ) + + const canDelete = (id: OrganizationId) => + ErrorUtils.refailUnauthorized( + policyEntity, + "delete", + )(withAnnotatedScope((scope) => orgResolver.requireOwner(id, scope, policyEntity, "delete"))) + + const canManagePublicInvite = (id: OrganizationId) => + ErrorUtils.refailUnauthorized( + policyEntity, + "managePublicInvite", + )( + withAnnotatedScope((scope) => + orgResolver.requireAdminOrOwner(id, scope, policyEntity, "managePublicInvite"), + ), + ) + + return { canUpdate, canDelete, canCreate, isMember, canManagePublicInvite } as const + }), + }, +) { + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(OrgResolver.layer)) +} diff --git a/apps/backend/src/policies/pinned-message-policy.test.ts b/apps/backend/src/policies/pinned-message-policy.test.ts index 7a3d8f335..c3fc0c1fc 100644 --- a/apps/backend/src/policies/pinned-message-policy.test.ts +++ b/apps/backend/src/policies/pinned-message-policy.test.ts @@ -2,58 +2,68 @@ import { describe, expect, it } from "@effect/vitest" import { ChannelRepo, OrganizationMemberRepo, PinnedMessageRepo } from "@hazel/backend-core" import { UnauthorizedError } from "@hazel/domain" import type { ChannelId, OrganizationId, PinnedMessageId, UserId } from "@hazel/schema" -import { Effect, Either, Layer, Option } from "effect" +import { Effect, Result, Layer, Option } from "effect" import { PinnedMessagePolicy } from "./pinned-message-policy.ts" import { makeActor, makeEntityNotFound, makeOrgResolverLayer, runWithActorEither, + serviceShape, TEST_ORG_ID, } from "./policy-test-helpers.ts" type Role = "admin" | "member" | "owner" -const CHANNEL_ID = "00000000-0000-0000-0000-000000000861" as ChannelId -const PINNED_MSG_ID = "00000000-0000-0000-0000-000000000862" as PinnedMessageId -const ADMIN_USER_ID = "00000000-0000-0000-0000-000000000863" as UserId -const OTHER_USER_ID = "00000000-0000-0000-0000-000000000864" as UserId +const CHANNEL_ID = "00000000-0000-4000-8000-000000000861" as ChannelId +const PINNED_MSG_ID = "00000000-0000-4000-8000-000000000862" as PinnedMessageId +const ADMIN_USER_ID = "00000000-0000-4000-8000-000000000863" as UserId +const OTHER_USER_ID = "00000000-0000-4000-8000-000000000864" as UserId type ChannelData = { organizationId: OrganizationId; type: string; id: string } type PinnedData = { pinnedBy: UserId; channelId: ChannelId } const makePinnedMessageRepoLayer = (pinnedMessages: Record) => - Layer.succeed(PinnedMessageRepo, { - with: (id: PinnedMessageId, f: (pm: PinnedData) => Effect.Effect) => { - const pm = pinnedMessages[id] - if (!pm) return Effect.fail(makeEntityNotFound("PinnedMessage")) - return f(pm) - }, - } as unknown as PinnedMessageRepo) + Layer.succeed( + PinnedMessageRepo, + serviceShape({ + with: (id: PinnedMessageId, f: (pm: PinnedData) => Effect.Effect) => { + const pm = pinnedMessages[id] + if (!pm) return Effect.fail(makeEntityNotFound("PinnedMessage")) + return f(pm) + }, + }), + ) const makeChannelRepoLayer = (channels: Record) => - Layer.succeed(ChannelRepo, { - with: (id: ChannelId, f: (ch: ChannelData) => Effect.Effect) => { - const ch = channels[id] - if (!ch) return Effect.fail(makeEntityNotFound("Channel")) - return f(ch) - }, - } as unknown as ChannelRepo) + Layer.succeed( + ChannelRepo, + serviceShape({ + with: (id: ChannelId, f: (ch: ChannelData) => Effect.Effect) => { + const ch = channels[id] + if (!ch) return Effect.fail(makeEntityNotFound("Channel")) + return f(ch) + }, + }), + ) const makeOrgMemberRepoLayer = (orgMembers: Record) => - Layer.succeed(OrganizationMemberRepo, { - findByOrgAndUser: (organizationId: OrganizationId, userId: UserId) => { - const role = orgMembers[`${organizationId}:${userId}`] - return Effect.succeed(role ? Option.some({ organizationId, userId, role }) : Option.none()) - }, - } as unknown as OrganizationMemberRepo) + Layer.succeed( + OrganizationMemberRepo, + serviceShape({ + findByOrgAndUser: (organizationId: OrganizationId, userId: UserId) => { + const role = orgMembers[`${organizationId}:${userId}`] + return Effect.succeed(role ? Option.some({ organizationId, userId, role }) : Option.none()) + }, + }), + ) const makePolicyLayer = ( orgMembers: Record, channels: Record, pinnedMessages: Record, ) => - PinnedMessagePolicy.DefaultWithoutDependencies.pipe( + Layer.effect(PinnedMessagePolicy, PinnedMessagePolicy.make).pipe( Layer.provide(makePinnedMessageRepoLayer(pinnedMessages)), Layer.provide(makeChannelRepoLayer(channels)), Layer.provide(makeOrgMemberRepoLayer(orgMembers)), @@ -69,8 +79,12 @@ describe("PinnedMessagePolicy", () => { {}, ) - const result = await runWithActorEither(PinnedMessagePolicy.canCreate(CHANNEL_ID), layer, admin) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + PinnedMessagePolicy.use((policy) => policy.canCreate(CHANNEL_ID)), + layer, + admin, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canCreate allows member in public channel", async () => { @@ -81,8 +95,12 @@ describe("PinnedMessagePolicy", () => { {}, ) - const result = await runWithActorEither(PinnedMessagePolicy.canCreate(CHANNEL_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + PinnedMessagePolicy.use((policy) => policy.canCreate(CHANNEL_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canCreate denies member in private channel", async () => { @@ -93,8 +111,12 @@ describe("PinnedMessagePolicy", () => { {}, ) - const result = await runWithActorEither(PinnedMessagePolicy.canCreate(CHANNEL_ID), layer, actor) - expect(Either.isLeft(result)).toBe(true) + const result = await runWithActorEither( + PinnedMessagePolicy.use((policy) => policy.canCreate(CHANNEL_ID)), + layer, + actor, + ) + expect(Result.isFailure(result)).toBe(true) }) it("canCreate denies non-org-member", async () => { @@ -105,8 +127,12 @@ describe("PinnedMessagePolicy", () => { {}, ) - const result = await runWithActorEither(PinnedMessagePolicy.canCreate(CHANNEL_ID), layer, outsider) - expect(Either.isLeft(result)).toBe(true) + const result = await runWithActorEither( + PinnedMessagePolicy.use((policy) => policy.canCreate(CHANNEL_ID)), + layer, + outsider, + ) + expect(Result.isFailure(result)).toBe(true) }) it("canUpdate allows pinner", async () => { @@ -117,8 +143,12 @@ describe("PinnedMessagePolicy", () => { { [PINNED_MSG_ID]: { pinnedBy: actor.id, channelId: CHANNEL_ID } }, ) - const result = await runWithActorEither(PinnedMessagePolicy.canUpdate(PINNED_MSG_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + PinnedMessagePolicy.use((policy) => policy.canUpdate(PINNED_MSG_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canUpdate allows org admin who is not pinner", async () => { @@ -129,8 +159,12 @@ describe("PinnedMessagePolicy", () => { { [PINNED_MSG_ID]: { pinnedBy: OTHER_USER_ID, channelId: CHANNEL_ID } }, ) - const result = await runWithActorEither(PinnedMessagePolicy.canUpdate(PINNED_MSG_ID), layer, admin) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + PinnedMessagePolicy.use((policy) => policy.canUpdate(PINNED_MSG_ID)), + layer, + admin, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canUpdate denies non-pinner non-admin", async () => { @@ -141,8 +175,12 @@ describe("PinnedMessagePolicy", () => { { [PINNED_MSG_ID]: { pinnedBy: ADMIN_USER_ID, channelId: CHANNEL_ID } }, ) - const result = await runWithActorEither(PinnedMessagePolicy.canUpdate(PINNED_MSG_ID), layer, outsider) - expect(Either.isLeft(result)).toBe(true) + const result = await runWithActorEither( + PinnedMessagePolicy.use((policy) => policy.canUpdate(PINNED_MSG_ID)), + layer, + outsider, + ) + expect(Result.isFailure(result)).toBe(true) }) it("canDelete allows pinner", async () => { @@ -153,8 +191,12 @@ describe("PinnedMessagePolicy", () => { { [PINNED_MSG_ID]: { pinnedBy: actor.id, channelId: CHANNEL_ID } }, ) - const result = await runWithActorEither(PinnedMessagePolicy.canDelete(PINNED_MSG_ID), layer, actor) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + PinnedMessagePolicy.use((policy) => policy.canDelete(PINNED_MSG_ID)), + layer, + actor, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canDelete allows org admin who is not pinner", async () => { @@ -165,8 +207,12 @@ describe("PinnedMessagePolicy", () => { { [PINNED_MSG_ID]: { pinnedBy: OTHER_USER_ID, channelId: CHANNEL_ID } }, ) - const result = await runWithActorEither(PinnedMessagePolicy.canDelete(PINNED_MSG_ID), layer, admin) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + PinnedMessagePolicy.use((policy) => policy.canDelete(PINNED_MSG_ID)), + layer, + admin, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canDelete denies non-pinner non-admin", async () => { @@ -177,10 +223,14 @@ describe("PinnedMessagePolicy", () => { { [PINNED_MSG_ID]: { pinnedBy: ADMIN_USER_ID, channelId: CHANNEL_ID } }, ) - const result = await runWithActorEither(PinnedMessagePolicy.canDelete(PINNED_MSG_ID), layer, outsider) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(UnauthorizedError.is(result.left)).toBe(true) + const result = await runWithActorEither( + PinnedMessagePolicy.use((policy) => policy.canDelete(PINNED_MSG_ID)), + layer, + outsider, + ) + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + expect(UnauthorizedError.is(result.failure)).toBe(true) } }) }) diff --git a/apps/backend/src/policies/pinned-message-policy.ts b/apps/backend/src/policies/pinned-message-policy.ts index 3cf600e3c..01e86431b 100644 --- a/apps/backend/src/policies/pinned-message-policy.ts +++ b/apps/backend/src/policies/pinned-message-policy.ts @@ -1,105 +1,77 @@ import { ChannelRepo, OrganizationMemberRepo, PinnedMessageRepo } from "@hazel/backend-core" import { ErrorUtils, policy } from "@hazel/domain" import type { ChannelId, PinnedMessageId } from "@hazel/schema" -import { Effect, Option } from "effect" +import { ServiceMap, Effect, Layer, Option } from "effect" import { isAdminOrOwner } from "../lib/policy-utils" import { OrgResolver } from "../services/org-resolver" -export class PinnedMessagePolicy extends Effect.Service()("PinnedMessagePolicy/Policy", { - effect: Effect.gen(function* () { - const policyEntity = "PinnedMessage" as const - - const pinnedMessageRepo = yield* PinnedMessageRepo - const channelRepo = yield* ChannelRepo - const organizationMemberRepo = yield* OrganizationMemberRepo - const orgResolver = yield* OrgResolver - - const canUpdate = (id: PinnedMessageId) => - ErrorUtils.refailUnauthorized( - policyEntity, - "update", - )( - pinnedMessageRepo.with(id, (pinnedMessage) => - channelRepo.with(pinnedMessage.channelId, (channel) => +export class PinnedMessagePolicy extends ServiceMap.Service()( + "PinnedMessagePolicy/Policy", + { + make: Effect.gen(function* () { + const policyEntity = "PinnedMessage" as const + + const pinnedMessageRepo = yield* PinnedMessageRepo + const channelRepo = yield* ChannelRepo + const organizationMemberRepo = yield* OrganizationMemberRepo + const orgResolver = yield* OrgResolver + + const canUpdate = (id: PinnedMessageId) => + ErrorUtils.refailUnauthorized( + policyEntity, + "update", + )( + pinnedMessageRepo.with(id, (pinnedMessage) => + channelRepo.with(pinnedMessage.channelId, (channel) => + policy( + policyEntity, + "update", + Effect.fn(`${policyEntity}.update`)(function* (actor) { + if (actor.id === pinnedMessage.pinnedBy) { + return yield* Effect.succeed(true) + } + + const orgMember = yield* organizationMemberRepo.findByOrgAndUser( + channel.organizationId, + actor.id, + ) + + if (Option.isSome(orgMember) && isAdminOrOwner(orgMember.value.role)) { + return yield* Effect.succeed(true) + } + + return yield* Effect.succeed(false) + }), + ), + ), + ), + ) + + const canCreate = (channelId: ChannelId) => + ErrorUtils.refailUnauthorized( + policyEntity, + "create", + )( + channelRepo.with(channelId, (channel) => policy( policyEntity, - "update", - Effect.fn(`${policyEntity}.update`)(function* (actor) { - if (actor.id === pinnedMessage.pinnedBy) { - return yield* Effect.succeed(true) - } - + "create", + Effect.fn(`${policyEntity}.create`)(function* (actor) { const orgMember = yield* organizationMemberRepo.findByOrgAndUser( channel.organizationId, actor.id, ) - if (Option.isSome(orgMember) && isAdminOrOwner(orgMember.value.role)) { - return yield* Effect.succeed(true) + if (Option.isNone(orgMember)) { + return yield* Effect.succeed(false) } - return yield* Effect.succeed(false) - }), - ), - ), - ), - ) - - const canCreate = (channelId: ChannelId) => - ErrorUtils.refailUnauthorized( - policyEntity, - "create", - )( - channelRepo.with(channelId, (channel) => - policy( - policyEntity, - "create", - Effect.fn(`${policyEntity}.create`)(function* (actor) { - const orgMember = yield* organizationMemberRepo.findByOrgAndUser( - channel.organizationId, - actor.id, - ) - - if (Option.isNone(orgMember)) { - return yield* Effect.succeed(false) - } - - if (isAdminOrOwner(orgMember.value.role)) { - return yield* Effect.succeed(true) - } - - // Regular members can pin in public channels - if (channel.type === "public") { - return yield* Effect.succeed(true) - } - - return yield* Effect.succeed(false) - }), - ), - ), - ) - - const canDelete = (id: PinnedMessageId) => - ErrorUtils.refailUnauthorized( - policyEntity, - "delete", - )( - pinnedMessageRepo.with(id, (pinnedMessage) => - channelRepo.with(pinnedMessage.channelId, (channel) => - policy( - policyEntity, - "delete", - Effect.fn(`${policyEntity}.delete`)(function* (actor) { - if (actor.id === pinnedMessage.pinnedBy) { + if (isAdminOrOwner(orgMember.value.role)) { return yield* Effect.succeed(true) } - const orgMember = yield* organizationMemberRepo.findByOrgAndUser( - channel.organizationId, - actor.id, - ) - - if (Option.isSome(orgMember) && isAdminOrOwner(orgMember.value.role)) { + // Regular members can pin in public channels + if (channel.type === "public") { return yield* Effect.succeed(true) } @@ -107,16 +79,47 @@ export class PinnedMessagePolicy extends Effect.Service()(" }), ), ), - ), - ) - - return { canCreate, canDelete, canUpdate } as const - }), - dependencies: [ - PinnedMessageRepo.Default, - ChannelRepo.Default, - OrganizationMemberRepo.Default, - OrgResolver.Default, - ], - accessors: true, -}) {} + ) + + const canDelete = (id: PinnedMessageId) => + ErrorUtils.refailUnauthorized( + policyEntity, + "delete", + )( + pinnedMessageRepo.with(id, (pinnedMessage) => + channelRepo.with(pinnedMessage.channelId, (channel) => + policy( + policyEntity, + "delete", + Effect.fn(`${policyEntity}.delete`)(function* (actor) { + if (actor.id === pinnedMessage.pinnedBy) { + return yield* Effect.succeed(true) + } + + const orgMember = yield* organizationMemberRepo.findByOrgAndUser( + channel.organizationId, + actor.id, + ) + + if (Option.isSome(orgMember) && isAdminOrOwner(orgMember.value.role)) { + return yield* Effect.succeed(true) + } + + return yield* Effect.succeed(false) + }), + ), + ), + ), + ) + + return { canCreate, canDelete, canUpdate } as const + }), + }, +) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(PinnedMessageRepo.layer), + Layer.provide(ChannelRepo.layer), + Layer.provide(OrganizationMemberRepo.layer), + Layer.provide(OrgResolver.layer), + ) +} diff --git a/apps/backend/src/policies/policy-test-helpers.ts b/apps/backend/src/policies/policy-test-helpers.ts index 37b6092f9..c008b4e60 100644 --- a/apps/backend/src/policies/policy-test-helpers.ts +++ b/apps/backend/src/policies/policy-test-helpers.ts @@ -3,8 +3,10 @@ import { CurrentUser } from "@hazel/domain" import type { ApiScope } from "@hazel/domain/scopes" import { CurrentRpcScopes } from "@hazel/domain/scopes" import type { ChannelId, ChannelMemberId, MessageId, OrganizationId, UserId } from "@hazel/schema" -import { Effect, FiberRef, Layer, Option } from "effect" +import { Effect, Layer, Option } from "effect" import { OrgResolver } from "../services/org-resolver" +import { serviceShape } from "../test/effect-helpers" +export { serviceShape } from "../test/effect-helpers" export const TEST_ORG_ID = "00000000-0000-0000-0000-000000000001" as OrganizationId export const TEST_ALT_ORG_ID = "00000000-0000-0000-0000-000000000002" as OrganizationId @@ -25,18 +27,18 @@ export const makeActor = (overrides?: Partial): CurrentUser. }) export const runWithActorEither = ( - effect: Effect.Effect, - layer: Layer.Layer, + make: Effect.Effect, + layer: Layer.Layer, actor: CurrentUser.Schema = makeActor(), scopes: ReadonlyArray = ["messages:read"], ) => Effect.runPromise( - effect.pipe( - Effect.locally(CurrentRpcScopes, scopes), + make.pipe( + Effect.provideService(CurrentRpcScopes, scopes), Effect.provide(layer), Effect.provideService(CurrentUser.Context, actor), - Effect.either, - ), + Effect.result, + ) as Effect.Effect, ) export const makeEntityNotFound = (entity = "Entity") => @@ -51,41 +53,53 @@ type Role = "admin" | "member" | "owner" * Creates a mock OrganizationMemberRepo layer for testing. */ export const makeOrganizationMemberRepoLayer = (members: Record) => - Layer.succeed(OrganizationMemberRepo, { - findByOrgAndUser: (organizationId: OrganizationId, userId: UserId) => { - const role = members[`${organizationId}:${userId}`] - return Effect.succeed(role ? Option.some({ organizationId, userId, role }) : Option.none()) - }, - } as unknown as OrganizationMemberRepo) + Layer.succeed( + OrganizationMemberRepo, + serviceShape({ + findByOrgAndUser: (organizationId: OrganizationId, userId: UserId) => { + const role = members[`${organizationId}:${userId}`] + return Effect.succeed(role ? Option.some({ organizationId, userId, role }) : Option.none()) + }, + }), + ) /** * Creates a stub repo layer that returns none/empty for all lookups. * Used for OrgResolver dependencies that aren't relevant to a specific test. */ -const emptyChannelRepoLayer = Layer.succeed(ChannelRepo, { - findById: (_id: ChannelId) => Effect.succeed(Option.none()), - with: (_id: ChannelId, _f: (c: any) => Effect.Effect) => - Effect.fail(makeEntityNotFound("Channel")), -} as unknown as ChannelRepo) +const emptyChannelRepoLayer = Layer.succeed( + ChannelRepo, + serviceShape({ + findById: (_id: ChannelId) => Effect.succeed(Option.none()), + with: (_id: ChannelId, _f: (c: any) => Effect.Effect) => + Effect.fail(makeEntityNotFound("Channel")), + }), +) -const emptyChannelMemberRepoLayer = Layer.succeed(ChannelMemberRepo, { - findByChannelAndUser: (_channelId: ChannelId, _userId: UserId) => Effect.succeed(Option.none()), - with: (_id: ChannelMemberId, _f: (c: any) => Effect.Effect) => - Effect.fail(makeEntityNotFound("ChannelMember")), -} as unknown as ChannelMemberRepo) +const emptyChannelMemberRepoLayer = Layer.succeed( + ChannelMemberRepo, + serviceShape({ + findByChannelAndUser: (_channelId: ChannelId, _userId: UserId) => Effect.succeed(Option.none()), + with: (_id: ChannelMemberId, _f: (c: any) => Effect.Effect) => + Effect.fail(makeEntityNotFound("ChannelMember")), + }), +) -const emptyMessageRepoLayer = Layer.succeed(MessageRepo, { - findById: (_id: MessageId) => Effect.succeed(Option.none()), - with: (_id: MessageId, _f: (c: any) => Effect.Effect) => - Effect.fail(makeEntityNotFound("Message")), -} as unknown as MessageRepo) +const emptyMessageRepoLayer = Layer.succeed( + MessageRepo, + serviceShape({ + findById: (_id: MessageId) => Effect.succeed(Option.none()), + with: (_id: MessageId, _f: (c: any) => Effect.Effect) => + Effect.fail(makeEntityNotFound("Message")), + }), +) /** * Creates an OrgResolver layer backed by the given member mock. * Provides stub repos for channels, channel members, and messages. */ export const makeOrgResolverLayer = (members: Record) => - OrgResolver.DefaultWithoutDependencies.pipe( + Layer.effect(OrgResolver, OrgResolver.make).pipe( Layer.provide(makeOrganizationMemberRepoLayer(members)), Layer.provide(emptyChannelRepoLayer), Layer.provide(emptyChannelMemberRepoLayer), diff --git a/apps/backend/src/policies/rss-subscription-policy.ts b/apps/backend/src/policies/rss-subscription-policy.ts index 35872bcb9..d24bf55b0 100644 --- a/apps/backend/src/policies/rss-subscription-policy.ts +++ b/apps/backend/src/policies/rss-subscription-policy.ts @@ -1,15 +1,15 @@ import { ChannelRepo, RssSubscriptionRepo } from "@hazel/backend-core" import { ErrorUtils } from "@hazel/domain" import type { ChannelId, OrganizationId, RssSubscriptionId } from "@hazel/schema" -import { Effect } from "effect" +import { ServiceMap, Effect, Layer } from "effect" import { withAnnotatedScope } from "../lib/policy-utils" import { OrgResolver } from "../services/org-resolver" /** @effect-leakable-service */ -export class RssSubscriptionPolicy extends Effect.Service()( +export class RssSubscriptionPolicy extends ServiceMap.Service()( "RssSubscriptionPolicy/Policy", { - effect: Effect.gen(function* () { + make: Effect.gen(function* () { const policyEntity = "RssSubscription" as const const channelRepo = yield* ChannelRepo @@ -96,7 +96,11 @@ export class RssSubscriptionPolicy extends Effect.Service return { canCreate, canRead, canReadByOrganization, canUpdate, canDelete } as const }), - dependencies: [ChannelRepo.Default, RssSubscriptionRepo.Default, OrgResolver.Default], - accessors: true, }, -) {} +) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(ChannelRepo.layer), + Layer.provide(RssSubscriptionRepo.layer), + Layer.provide(OrgResolver.layer), + ) +} diff --git a/apps/backend/src/policies/typing-indicator-policy.test.ts b/apps/backend/src/policies/typing-indicator-policy.test.ts index c71534230..3208f77b0 100644 --- a/apps/backend/src/policies/typing-indicator-policy.test.ts +++ b/apps/backend/src/policies/typing-indicator-policy.test.ts @@ -2,15 +2,21 @@ import { describe, expect, it } from "@effect/vitest" import { ChannelMemberRepo, TypingIndicatorRepo } from "@hazel/backend-core" import { UnauthorizedError } from "@hazel/domain" import type { ChannelId, ChannelMemberId, TypingIndicatorId, UserId } from "@hazel/schema" -import { Effect, Either, Layer, Option } from "effect" +import { Effect, Result, Layer, Option } from "effect" import { TypingIndicatorPolicy } from "./typing-indicator-policy.ts" -import { makeActor, makeEntityNotFound, runWithActorEither, TEST_ORG_ID } from "./policy-test-helpers.ts" - -const CHANNEL_ID = "00000000-0000-0000-0000-000000000501" as ChannelId -const MEMBER_ID = "00000000-0000-0000-0000-000000000601" as ChannelMemberId -const OTHER_MEMBER_ID = "00000000-0000-0000-0000-000000000602" as ChannelMemberId -const INDICATOR_ID = "00000000-0000-0000-0000-000000000701" as TypingIndicatorId -const MISSING_INDICATOR_ID = "00000000-0000-0000-0000-000000000799" as TypingIndicatorId +import { + makeActor, + makeEntityNotFound, + runWithActorEither, + serviceShape, + TEST_ORG_ID, +} from "./policy-test-helpers.ts" + +const CHANNEL_ID = "00000000-0000-4000-8000-000000000501" as ChannelId +const MEMBER_ID = "00000000-0000-4000-8000-000000000601" as ChannelMemberId +const OTHER_MEMBER_ID = "00000000-0000-4000-8000-000000000602" as ChannelMemberId +const INDICATOR_ID = "00000000-0000-4000-8000-000000000701" as TypingIndicatorId +const MISSING_INDICATOR_ID = "00000000-0000-4000-8000-000000000799" as TypingIndicatorId type MemberRecord = { id: ChannelMemberId @@ -24,35 +30,44 @@ const makeChannelMemberRepoLayer = ( recordsByMemberId: Record, recordsByChannelAndUser: Record, ) => - Layer.succeed(ChannelMemberRepo, { - findByChannelAndUser: (channelId: ChannelId, userId: UserId) => - Effect.succeed(Option.fromNullable(recordsByChannelAndUser[`${channelId}:${userId}`])), - with: (id: ChannelMemberId, f: (member: MemberRecord) => Effect.Effect) => { - const member = recordsByMemberId[id] - if (!member) { - return Effect.fail(makeEntityNotFound("ChannelMember")) - } - return f(member) - }, - } as unknown as ChannelMemberRepo) + Layer.succeed( + ChannelMemberRepo, + serviceShape({ + findByChannelAndUser: (channelId: ChannelId, userId: UserId) => + Effect.succeed(Option.fromNullishOr(recordsByChannelAndUser[`${channelId}:${userId}`])), + with: (id: ChannelMemberId, f: (member: MemberRecord) => Effect.Effect) => { + const member = recordsByMemberId[id] + if (!member) { + return Effect.fail(makeEntityNotFound("ChannelMember")) + } + return f(member) + }, + }), + ) const makeTypingIndicatorRepoLayer = (recordsById: Record) => - Layer.succeed(TypingIndicatorRepo, { - with: (id: TypingIndicatorId, f: (indicator: IndicatorRecord) => Effect.Effect) => { - const indicator = recordsById[id] - if (!indicator) { - return Effect.fail(makeEntityNotFound("TypingIndicator")) - } - return f(indicator) - }, - } as unknown as TypingIndicatorRepo) + Layer.succeed( + TypingIndicatorRepo, + serviceShape({ + with: ( + id: TypingIndicatorId, + f: (indicator: IndicatorRecord) => Effect.Effect, + ) => { + const indicator = recordsById[id] + if (!indicator) { + return Effect.fail(makeEntityNotFound("TypingIndicator")) + } + return f(indicator) + }, + }), + ) const makePolicyLayer = ( channelMembersById: Record, channelMembersByChannelAndUser: Record, indicatorsById: Record, ) => - TypingIndicatorPolicy.DefaultWithoutDependencies.pipe( + Layer.effect(TypingIndicatorPolicy, TypingIndicatorPolicy.make).pipe( Layer.provide(makeChannelMemberRepoLayer(channelMembersById, channelMembersByChannelAndUser)), Layer.provide(makeTypingIndicatorRepoLayer(indicatorsById)), ) @@ -60,8 +75,11 @@ const makePolicyLayer = ( describe("TypingIndicatorPolicy", () => { it("canRead always allows authenticated actors", async () => { const layer = makePolicyLayer({}, {}, {}) - const result = await runWithActorEither(TypingIndicatorPolicy.canRead(INDICATOR_ID), layer) - expect(Either.isRight(result)).toBe(true) + const result = await runWithActorEither( + TypingIndicatorPolicy.use((policy) => policy.canRead(INDICATOR_ID)), + layer, + ) + expect(Result.isSuccess(result)).toBe(true) }) it("canCreate requires channel membership", async () => { @@ -79,21 +97,27 @@ describe("TypingIndicatorPolicy", () => { {}, ) - const allowed = await runWithActorEither(TypingIndicatorPolicy.canCreate(CHANNEL_ID), layer, actor) + const allowed = await runWithActorEither( + TypingIndicatorPolicy.use((policy) => policy.canCreate(CHANNEL_ID)), + layer, + actor, + ) const denied = await runWithActorEither( - TypingIndicatorPolicy.canCreate("00000000-0000-0000-0000-000000000599" as ChannelId), + TypingIndicatorPolicy.use((policy) => + policy.canCreate("00000000-0000-4000-8000-000000000599" as ChannelId), + ), layer, actor, ) - expect(Either.isRight(allowed)).toBe(true) - expect(Either.isLeft(denied)).toBe(true) + expect(Result.isSuccess(allowed)).toBe(true) + expect(Result.isFailure(denied)).toBe(true) }) it("canUpdate allows only the member owner and maps missing indicator to UnauthorizedError", async () => { const actor = makeActor() const otherActor = makeActor({ - id: "00000000-0000-0000-0000-000000000103" as UserId, + id: "00000000-0000-4000-8000-000000000103" as UserId, }) const layer = makePolicyLayer( @@ -116,26 +140,26 @@ describe("TypingIndicatorPolicy", () => { ) const ownerAllowed = await runWithActorEither( - TypingIndicatorPolicy.canUpdate(INDICATOR_ID), + TypingIndicatorPolicy.use((policy) => policy.canUpdate(INDICATOR_ID)), layer, actor, ) const otherDenied = await runWithActorEither( - TypingIndicatorPolicy.canUpdate(INDICATOR_ID), + TypingIndicatorPolicy.use((policy) => policy.canUpdate(INDICATOR_ID)), layer, otherActor, ) const missingDenied = await runWithActorEither( - TypingIndicatorPolicy.canUpdate(MISSING_INDICATOR_ID), + TypingIndicatorPolicy.use((policy) => policy.canUpdate(MISSING_INDICATOR_ID)), layer, actor, ) - expect(Either.isRight(ownerAllowed)).toBe(true) - expect(Either.isLeft(otherDenied)).toBe(true) - expect(Either.isLeft(missingDenied)).toBe(true) - if (Either.isLeft(missingDenied)) { - expect(UnauthorizedError.is(missingDenied.left)).toBe(true) + expect(Result.isSuccess(ownerAllowed)).toBe(true) + expect(Result.isFailure(otherDenied)).toBe(true) + expect(Result.isFailure(missingDenied)).toBe(true) + if (Result.isFailure(missingDenied)) { + expect(UnauthorizedError.is(missingDenied.failure)).toBe(true) } }) @@ -152,7 +176,7 @@ describe("TypingIndicatorPolicy", () => { [OTHER_MEMBER_ID]: { id: OTHER_MEMBER_ID, channelId: CHANNEL_ID, - userId: "00000000-0000-0000-0000-000000000109" as UserId, + userId: "00000000-0000-4000-8000-000000000109" as UserId, organizationId: TEST_ORG_ID, }, }, @@ -167,23 +191,23 @@ describe("TypingIndicatorPolicy", () => { ) const byMemberAllowed = await runWithActorEither( - TypingIndicatorPolicy.canDelete({ memberId: MEMBER_ID }), + TypingIndicatorPolicy.use((policy) => policy.canDelete({ memberId: MEMBER_ID })), layer, actor, ) const byIndicatorAllowed = await runWithActorEither( - TypingIndicatorPolicy.canDelete({ id: INDICATOR_ID }), + TypingIndicatorPolicy.use((policy) => policy.canDelete({ id: INDICATOR_ID })), layer, actor, ) const byMemberDenied = await runWithActorEither( - TypingIndicatorPolicy.canDelete({ memberId: OTHER_MEMBER_ID }), + TypingIndicatorPolicy.use((policy) => policy.canDelete({ memberId: OTHER_MEMBER_ID })), layer, actor, ) - expect(Either.isRight(byMemberAllowed)).toBe(true) - expect(Either.isRight(byIndicatorAllowed)).toBe(true) - expect(Either.isLeft(byMemberDenied)).toBe(true) + expect(Result.isSuccess(byMemberAllowed)).toBe(true) + expect(Result.isSuccess(byIndicatorAllowed)).toBe(true) + expect(Result.isFailure(byMemberDenied)).toBe(true) }) }) diff --git a/apps/backend/src/policies/typing-indicator-policy.ts b/apps/backend/src/policies/typing-indicator-policy.ts index 74a030b41..11da12c77 100644 --- a/apps/backend/src/policies/typing-indicator-policy.ts +++ b/apps/backend/src/policies/typing-indicator-policy.ts @@ -1,12 +1,12 @@ import { ChannelMemberRepo, TypingIndicatorRepo } from "@hazel/backend-core" import type { ChannelId, ChannelMemberId, TypingIndicatorId } from "@hazel/schema" -import { Effect, Option } from "effect" +import { ServiceMap, Effect, Layer, Option } from "effect" import { makePolicy, withPolicyUnauthorized } from "../lib/policy-utils" -export class TypingIndicatorPolicy extends Effect.Service()( +export class TypingIndicatorPolicy extends ServiceMap.Service()( "TypingIndicatorPolicy/Policy", { - effect: Effect.gen(function* () { + make: Effect.gen(function* () { const policyEntity = "TypingIndicator" as const const authorize = makePolicy(policyEntity) @@ -52,7 +52,10 @@ export class TypingIndicatorPolicy extends Effect.Service return { canCreate, canUpdate, canDelete, canRead } as const }), - dependencies: [ChannelMemberRepo.Default, TypingIndicatorRepo.Default], - accessors: true, }, -) {} +) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(ChannelMemberRepo.layer), + Layer.provide(TypingIndicatorRepo.layer), + ) +} diff --git a/apps/backend/src/policies/user-policy.ts b/apps/backend/src/policies/user-policy.ts index eb2523b8e..5ea488f3e 100644 --- a/apps/backend/src/policies/user-policy.ts +++ b/apps/backend/src/policies/user-policy.ts @@ -1,9 +1,9 @@ import type { UserId } from "@hazel/schema" -import { Effect } from "effect" +import { ServiceMap, Effect, Layer } from "effect" import { makePolicy } from "../lib/policy-utils" -export class UserPolicy extends Effect.Service()("UserPolicy/Policy", { - effect: Effect.gen(function* () { +export class UserPolicy extends ServiceMap.Service()("UserPolicy/Policy", { + make: Effect.gen(function* () { const policyEntity = "User" as const const authorize = makePolicy(policyEntity) @@ -17,6 +17,6 @@ export class UserPolicy extends Effect.Service()("UserPolicy/Policy" return { canCreate, canUpdate, canDelete, canRead } as const }), - dependencies: [], - accessors: true, -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/apps/backend/src/policies/user-presence-status-policy.ts b/apps/backend/src/policies/user-presence-status-policy.ts index 9b3741fc2..beedade47 100644 --- a/apps/backend/src/policies/user-presence-status-policy.ts +++ b/apps/backend/src/policies/user-presence-status-policy.ts @@ -1,10 +1,10 @@ -import { Effect } from "effect" +import { ServiceMap, Effect, Layer } from "effect" import { makePolicy } from "../lib/policy-utils" -export class UserPresenceStatusPolicy extends Effect.Service()( +export class UserPresenceStatusPolicy extends ServiceMap.Service()( "UserPresenceStatusPolicy/Policy", { - effect: Effect.gen(function* () { + make: Effect.gen(function* () { const policyEntity = "UserPresenceStatus" as const const authorize = makePolicy(policyEntity) @@ -18,7 +18,7 @@ export class UserPresenceStatusPolicy extends Effect.Service( scopes: ReadonlyArray, - effect: Effect.Effect, -): Effect.Effect => Effect.locally(CurrentRpcScopes, scopes)(effect) + make: Effect.Effect, +): Effect.Effect => Effect.provideService(make, CurrentRpcScopes, scopes) as Effect.Effect export const HttpMessagesApiLive = HttpApiBuilder.group(HazelApi, "api-v1-messages", (handlers) => Effect.gen(function* () { const db = yield* Database.Database const botGateway = yield* BotGatewayService const outboxRepo = yield* MessageOutboxRepo + const messagePolicy = yield* MessagePolicy + const messageRepo = yield* MessageRepo + const messageReactionPolicy = yield* MessageReactionPolicy + const messageReactionRepo = yield* MessageReactionRepo + const attachmentPolicy = yield* AttachmentPolicy + const attachmentRepo = yield* AttachmentRepo return ( handlers // List Messages (with cursor-based pagination) - .handle("listMessages", ({ urlParams }) => + .handle("listMessages", ({ query }) => withHttpScopes( ["messages:read"], Effect.gen(function* () { const bot = yield* authenticateBotFromToken const currentUser = createBotUserContext(bot) - const { channel_id, starting_after, ending_before, limit } = urlParams + const { channel_id, starting_after, ending_before, limit } = query // Validate: cannot specify both cursors if (starting_after && ending_before) { @@ -122,9 +129,9 @@ export const HttpMessagesApiLive = HttpApiBuilder.group(HazelApi, "api-v1-messag const effectiveLimit = limit ?? 25 // First, check if user can read this channel (policy authorization) - yield* MessagePolicy.canRead(channel_id).pipe( - Effect.provideService(CurrentUser.Context, currentUser), - ) + yield* messagePolicy + .canRead(channel_id) + .pipe(Effect.provideService(CurrentUser.Context, currentUser)) // Resolve cursor IDs to stable cursor tuples. let cursorBefore: @@ -141,7 +148,7 @@ export const HttpMessagesApiLive = HttpApiBuilder.group(HazelApi, "api-v1-messag | undefined = undefined if (starting_after) { - const cursorMsg = yield* MessageRepo.findByIdForCursor({ + const cursorMsg = yield* messageRepo.findByIdForCursor({ id: starting_after, channelId: channel_id, }) @@ -157,7 +164,7 @@ export const HttpMessagesApiLive = HttpApiBuilder.group(HazelApi, "api-v1-messag createdAt: cursorMsg.value.createdAt, } } else if (ending_before) { - const cursorMsg = yield* MessageRepo.findByIdForCursor({ + const cursorMsg = yield* messageRepo.findByIdForCursor({ id: ending_before, channelId: channel_id, }) @@ -175,7 +182,7 @@ export const HttpMessagesApiLive = HttpApiBuilder.group(HazelApi, "api-v1-messag } // Query messages (policy already checked, use system actor for db access) - const messages = yield* MessageRepo.listByChannel({ + const messages = yield* messageRepo.listByChannel({ channelId: channel_id, cursorBefore, cursorAfter, @@ -218,22 +225,24 @@ export const HttpMessagesApiLive = HttpApiBuilder.group(HazelApi, "api-v1-messag const response = yield* db .transaction( Effect.gen(function* () { - yield* MessagePolicy.canCreate(rest.channelId) - const createdMessage = yield* MessageRepo.insert({ - ...rest, - embeds: embeds ?? null, - replyToMessageId: replyToMessageId ?? null, - threadChannelId: threadChannelId ?? null, - authorId: bot.userId, - deletedAt: null, - }).pipe(Effect.map((res) => res[0]!)) + yield* messagePolicy.canCreate(rest.channelId) + const createdMessage = yield* messageRepo + .insert({ + ...rest, + embeds: embeds ?? null, + replyToMessageId: replyToMessageId ?? null, + threadChannelId: threadChannelId ?? null, + authorId: bot.userId, + deletedAt: null, + }) + .pipe(Effect.map((res) => res[0]!)) // Link attachments if provided if (attachmentIds && attachmentIds.length > 0) { yield* Effect.forEach(attachmentIds, (attachmentId) => Effect.gen(function* () { - yield* AttachmentPolicy.canUpdate(attachmentId) - yield* AttachmentRepo.update({ + yield* attachmentPolicy.canUpdate(attachmentId) + yield* attachmentRepo.update({ id: attachmentId, messageId: createdMessage.id, }) @@ -291,7 +300,7 @@ export const HttpMessagesApiLive = HttpApiBuilder.group(HazelApi, "api-v1-messag ) // Update Message - .handle("updateMessage", ({ path, payload }) => + .handle("updateMessage", ({ params, payload }) => withHttpScopes( ["messages:write"], Effect.gen(function* () { @@ -305,9 +314,9 @@ export const HttpMessagesApiLive = HttpApiBuilder.group(HazelApi, "api-v1-messag const response = yield* db .transaction( Effect.gen(function* () { - yield* MessagePolicy.canUpdate(path.id) - const updatedMessage = yield* MessageRepo.update({ - id: path.id, + yield* messagePolicy.canUpdate(params.id) + const updatedMessage = yield* messageRepo.update({ + id: params.id, ...rest, ...(embeds !== undefined ? { embeds } : {}), }) @@ -358,21 +367,21 @@ export const HttpMessagesApiLive = HttpApiBuilder.group(HazelApi, "api-v1-messag ) // Delete Message - .handle("deleteMessage", ({ path }) => + .handle("deleteMessage", ({ params }) => withHttpScopes( ["messages:write"], Effect.gen(function* () { const bot = yield* authenticateBotFromToken const currentUser = createBotUserContext(bot) - const existingMessage = yield* MessageRepo.findById(path.id) + const existingMessage = yield* messageRepo.findById(params.id) yield* checkMessageRateLimit(bot.userId) const response = yield* db .transaction( Effect.gen(function* () { - yield* MessagePolicy.canDelete(path.id) - yield* MessageRepo.deleteById(path.id) + yield* messagePolicy.canDelete(params.id) + yield* messageRepo.deleteById(params.id) if (Option.isSome(existingMessage)) { yield* outboxRepo.insert({ @@ -427,7 +436,7 @@ export const HttpMessagesApiLive = HttpApiBuilder.group(HazelApi, "api-v1-messag ) // Toggle Reaction - .handle("toggleReaction", ({ path, payload }) => + .handle("toggleReaction", ({ params, payload }) => withHttpScopes( ["message-reactions:write"], Effect.gen(function* () { @@ -438,11 +447,11 @@ export const HttpMessagesApiLive = HttpApiBuilder.group(HazelApi, "api-v1-messag .transaction( Effect.gen(function* () { const { emoji, channelId } = payload - const messageId = path.id + const messageId = params.id - yield* MessageReactionPolicy.canList(messageId) + yield* messageReactionPolicy.canList(messageId) const existingReaction = - yield* MessageReactionRepo.findByMessageUserEmoji( + yield* messageReactionRepo.findByMessageUserEmoji( messageId, bot.userId, emoji, @@ -460,8 +469,8 @@ export const HttpMessagesApiLive = HttpApiBuilder.group(HazelApi, "api-v1-messag userId: existingReaction.value.userId, } as const - yield* MessageReactionPolicy.canDelete(existingReaction.value.id) - yield* MessageReactionRepo.deleteById(existingReaction.value.id) + yield* messageReactionPolicy.canDelete(existingReaction.value.id) + yield* messageReactionRepo.deleteById(existingReaction.value.id) yield* outboxRepo.insert({ eventType: "reaction_deleted", aggregateId: existingReaction.value.id, @@ -483,13 +492,15 @@ export const HttpMessagesApiLive = HttpApiBuilder.group(HazelApi, "api-v1-messag } // Otherwise, create a new reaction - yield* MessageReactionPolicy.canCreate(messageId) - const createdReaction = yield* MessageReactionRepo.insert({ - messageId, - channelId, - emoji, - userId: bot.userId, - }).pipe(Effect.map((res) => res[0]!)) + yield* messageReactionPolicy.canCreate(messageId) + const createdReaction = yield* messageReactionRepo + .insert({ + messageId, + channelId, + emoji, + userId: bot.userId, + }) + .pipe(Effect.map((res) => res[0]!)) yield* outboxRepo.insert({ eventType: "reaction_created", diff --git a/apps/backend/src/routes/auth.http.test.ts b/apps/backend/src/routes/auth.http.test.ts index e9ad1de29..7d535249e 100644 --- a/apps/backend/src/routes/auth.http.test.ts +++ b/apps/backend/src/routes/auth.http.test.ts @@ -1,28 +1,81 @@ +import { WorkOS as WorkOSNodeAPI } from "@workos-inc/node" +import { NodeHttpPlatform, NodeServices } from "@effect/platform-node" import { describe, expect, it, layer } from "@effect/vitest" import { OrganizationMemberRepo, UserRepo } from "@hazel/backend-core" +import { Etag, HttpRouter } from "effect/unstable/http" +import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi" +import { AuthGroup, RefreshTokenResponse, TokenResponse } from "@hazel/domain/http" +import { OrganizationMember, User } from "@hazel/domain/models" import type { OrganizationId, UserId } from "@hazel/schema" -import { ConfigProvider, Effect, Layer, Option, Schema } from "effect" +import { Effect, Layer, Option, Schema, ServiceMap } from "effect" +import { vi } from "vitest" import { AuthState, RelativeUrl } from "../lib/schema.ts" +import { HttpAuthLive } from "./auth.http.ts" +import { AuthRedemptionStore } from "../services/auth-redemption-store.ts" +import { configLayer, serviceShape } from "../test/effect-helpers" import { WorkOSAuth as WorkOS, WorkOSAuthError as WorkOSApiError } from "../services/workos-auth.ts" +vi.mock("@hazel/effect-bun", async () => { + const { Layer, ServiceMap } = await import("effect") + class Redis extends ServiceMap.Service< + Redis, + { + readonly get: (key: string) => unknown + readonly del: (key: string) => unknown + readonly send: (command: string, args: string[]) => T + } + >()("@hazel/effect-bun/Redis") {} + + return { + Redis: Object.assign(Redis, { + Default: Layer.empty, + }), + } +}) + +import { Redis } from "@hazel/effect-bun" + // ===== Mock Configuration ===== -const TestConfigProvider = ConfigProvider.fromMap( - new Map([ - ["WORKOS_CLIENT_ID", "client_test_123"], - ["WORKOS_REDIRECT_URI", "http://localhost:3000/auth/callback"], - ["FRONTEND_URL", "http://localhost:3000"], - ["WORKOS_API_KEY", "sk_test_123"], - ]), -) +const TestConfigLive = configLayer({ + WORKOS_CLIENT_ID: "client_test_123", + WORKOS_REDIRECT_URI: "http://localhost:3000/auth/callback", + FRONTEND_URL: "http://localhost:3000", + WORKOS_API_KEY: "sk_test_123", +}) -const TestConfigLive = Layer.setConfigProvider(TestConfigProvider) +const NOW = new Date("2026-03-05T12:00:00.000Z") +const makeJwt = (exp: number = Math.floor(Date.now() / 1000) + 3600) => { + const encode = (value: Record) => + Buffer.from(JSON.stringify(value)).toString("base64url") + return `${encode({ alg: "none", typ: "JWT" })}.${encode({ exp, sid: "session_test_123" })}.` +} + +const makeUserRecord = (overrides: Partial> = {}) => + ({ + id: "usr_default123" as UserId, + externalId: "user_default", + email: "test@example.com", + firstName: "Test", + lastName: "User", + avatarUrl: null, + userType: "user", + settings: null, + isOnboarded: false, + timezone: null, + createdAt: NOW, + updatedAt: NOW, + deletedAt: null, + ...overrides, + }) satisfies Schema.Schema.Type // ===== Mock WorkOS Service ===== const createMockWorkOSLive = (options?: { authorizationUrl?: string authenticateResponse?: { + accessToken?: string + refreshToken?: string user: { id: string email: string @@ -33,17 +86,22 @@ const createMockWorkOSLive = (options?: { sealedSession?: string organizationId?: string } + refreshResponse?: { + accessToken?: string + refreshToken?: string + } shouldFailAuth?: boolean + shouldFailRefresh?: boolean shouldFailLogin?: boolean shouldFailGetOrg?: boolean }) => Layer.succeed(WorkOS, { - call: (f: (client: any, signal: AbortSignal) => Promise) => + call: (f: (client: WorkOSNodeAPI, signal: AbortSignal) => Promise) => Effect.tryPromise({ try: async () => { const mockClient = { userManagement: { - getAuthorizationUrl: (params: any) => { + getAuthorizationUrl: (params: { clientId: string; state?: string }) => { if (options?.shouldFailLogin) { throw new Error("WorkOS API error") } @@ -57,6 +115,9 @@ const createMockWorkOSLive = (options?: { throw new Error("Authentication failed") } return { + accessToken: options?.authenticateResponse?.accessToken ?? makeJwt(), + refreshToken: + options?.authenticateResponse?.refreshToken ?? "refresh-token", user: options?.authenticateResponse?.user ?? { id: "user_01ABC123", email: "test@example.com", @@ -70,6 +131,16 @@ const createMockWorkOSLive = (options?: { organizationId: options?.authenticateResponse?.organizationId, } }, + authenticateWithRefreshToken: async () => { + if (options?.shouldFailRefresh) { + throw new Error("Refresh failed") + } + return { + accessToken: options?.refreshResponse?.accessToken ?? makeJwt(), + refreshToken: + options?.refreshResponse?.refreshToken ?? "refresh-token-next", + } + }, listOrganizationMemberships: async () => ({ data: [{ role: { slug: "member" } }], }), @@ -95,11 +166,12 @@ const createMockWorkOSLive = (options?: { }, }, } - return f(mockClient as any, new AbortController().signal) + + return f(mockClient as unknown as WorkOSNodeAPI, new AbortController().signal) }, catch: (cause) => new WorkOSApiError({ cause }), }), - } as unknown as WorkOS) + } satisfies ServiceMap.Service.Shape) // ===== Mock UserRepo ===== @@ -117,24 +189,46 @@ const createMockUserRepoLive = (options?: { Layer.succeed(UserRepo, { findByExternalId: (_externalId: string) => Effect.succeed(options?.existingUser ? Option.some(options.existingUser) : Option.none()), - upsertByExternalId: (user: any) => - Effect.succeed({ - id: "usr_new123" as UserId, - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - avatarUrl: user.avatarUrl, - isOnboarded: user.isOnboarded, - timezone: user.timezone, - }), - } as unknown as UserRepo) + upsertByExternalId: (user: Schema.Schema.Type) => + Effect.succeed( + makeUserRecord({ + id: "usr_new123" as UserId, + externalId: user.externalId, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + avatarUrl: user.avatarUrl ?? null, + isOnboarded: user.isOnboarded, + timezone: user.timezone, + }), + ), + upsertWorkOSUser: (user: Schema.Schema.Type) => + Effect.succeed( + makeUserRecord({ + id: "usr_workos123" as UserId, + externalId: user.externalId, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + avatarUrl: user.avatarUrl ?? null, + isOnboarded: user.isOnboarded, + timezone: user.timezone, + }), + ), + } as unknown as ServiceMap.Service.Shape) // ===== Mock OrganizationMemberRepo ===== -const MockOrganizationMemberRepoLive = Layer.succeed(OrganizationMemberRepo, { - findByOrgAndUser: (_orgId: OrganizationId, _userId: UserId) => Effect.succeed(Option.none()), - upsertByOrgAndUser: (_membership: any) => Effect.succeed({}), -} as unknown as OrganizationMemberRepo) +const MockOrganizationMemberRepoLive = Layer.succeed( + OrganizationMemberRepo, + serviceShape({ + findByOrgAndUser: (_orgId: OrganizationId, _userId: UserId) => Effect.succeed(Option.none()), + upsertByOrgAndUser: (_membership: Schema.Schema.Type) => + Effect.succeed({ + id: "00000000-0000-4000-8000-000000000099", + }), + }), +) // ===== Test Layer Factory ===== @@ -150,6 +244,86 @@ const makeTestLayer = (options?: { // Default test layer const TestLayer = makeTestLayer() +const TestAuthApi = HttpApi.make("HazelApp").add(AuthGroup) + +const makeRedisLayer = () => { + const store = new Map() + + const getValue = (key: string): string | null => { + const entry = store.get(key) + if (!entry) return null + if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) { + store.delete(key) + return null + } + return entry.value + } + + const setValue = (key: string, value: string, ttlMs?: number) => { + store.set(key, { + value, + expiresAt: ttlMs === undefined ? null : Date.now() + ttlMs, + }) + } + + return Layer.succeed( + Redis, + serviceShape({ + get: (key: string) => Effect.succeed(getValue(key)), + del: (key: string) => + Effect.sync(() => { + store.delete(key) + }), + send: (command: string, args: string[]) => + Effect.sync(() => { + if (command === "EVAL") { + const [, , key, processingValue, ttlMs] = args + const existing = getValue(key) + if (existing === null) { + setValue(key, processingValue, Number(ttlMs)) + return ["claimed", ""] as T + } + return ["existing", existing] as T + } + + if (command === "SET") { + const [key, value, , ttlMs] = args + setValue(key, value, Number(ttlMs)) + return "OK" as T + } + + throw new Error(`Unsupported Redis command in test: ${command}`) + }), + }), + ) +} + +const makeAuthRouteHandler = (options?: { + workosLayer?: Layer.Layer + userRepoLayer?: Layer.Layer +}) => { + const authStoreLayer = Layer.effect(AuthRedemptionStore, AuthRedemptionStore.make).pipe( + Layer.provide(makeRedisLayer()), + ) + const authGroupLayer = HttpAuthLive.pipe( + Layer.provideMerge(authStoreLayer), + Layer.provideMerge(options?.workosLayer ?? createMockWorkOSLive()), + Layer.provideMerge(options?.userRepoLayer ?? createMockUserRepoLive()), + Layer.provideMerge(TestConfigLive), + ) + + const appLayer = HttpApiBuilder.layer(TestAuthApi).pipe( + Layer.provideMerge(authGroupLayer), + Layer.provideMerge(HttpRouter.layer), + Layer.provideMerge(Etag.layer), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(NodeHttpPlatform.layer), + ) + + return HttpRouter.toWebHandler(appLayer as never, { + disableLogger: true, + }) +} // ===== Tests ===== @@ -181,14 +355,14 @@ describe("Auth HTTP Endpoint Logic", () => { describe("AuthState schema", () => { it("creates valid AuthState", () => { - const state = AuthState.make({ returnTo: "/dashboard" }) + const state = Schema.decodeSync(AuthState)({ returnTo: "/dashboard" }) expect(state.returnTo).toBe("/dashboard") }) it("serializes and deserializes correctly", () => { - const state = AuthState.make({ returnTo: "/settings/profile" }) + const state = Schema.decodeSync(AuthState)({ returnTo: "/settings/profile" }) const serialized = JSON.stringify(state) - const parsed = AuthState.make(JSON.parse(serialized)) + const parsed = Schema.decodeSync(AuthState)(JSON.parse(serialized)) expect(parsed.returnTo).toBe("/settings/profile") }) }) @@ -303,9 +477,7 @@ describe("Auth HTTP Endpoint Logic", () => { Effect.gen(function* () { const userRepo = yield* UserRepo - const existingUser = yield* userRepo.findByExternalId( - "user_new", - ) as Effect.Effect + const existingUser = yield* userRepo.findByExternalId("user_new") expect(Option.isNone(existingUser)).toBe(true) const createdUser = yield* userRepo.upsertByExternalId({ @@ -319,7 +491,7 @@ describe("Auth HTTP Endpoint Logic", () => { isOnboarded: false, timezone: null, deletedAt: null, - }) as Effect.Effect + }) expect(createdUser.id).toBe("usr_new123") expect(createdUser.email).toBe("new@example.com") @@ -350,7 +522,7 @@ describe("Auth HTTP Endpoint Logic", () => { id: UserId email: string isOnboarded: boolean - }> = yield* userRepo.findByExternalId("user_existing") as Effect.Effect + }> = yield* userRepo.findByExternalId("user_existing") expect(Option.isSome(existingUser)).toBe(true) if (Option.isSome(existingUser)) { @@ -422,4 +594,68 @@ describe("Auth HTTP Endpoint Logic", () => { }) }) }) + + describe("HTTP route success encoding", () => { + it("returns HTTP 200 with a decodable TokenResponse for /auth/token", async () => { + const { handler, dispose } = makeAuthRouteHandler() + + try { + const response = await handler( + new Request("http://localhost/auth/token", { + method: "POST", + headers: { + "content-type": "application/json", + "x-auth-attempt-id": "attempt_token_123", + }, + body: JSON.stringify({ + code: "authorization_code", + state: JSON.stringify({ returnTo: "/" }), + }), + }), + ServiceMap.empty() as ServiceMap.ServiceMap, + ) + + expect(response.status).toBe(200) + + const body = await response.json() + const decoded = Schema.decodeUnknownSync(TokenResponse)(body) + + expect(decoded.accessToken).toContain(".") + expect(decoded.refreshToken).toBe("refresh-token") + expect(decoded.user.email).toBe("test@example.com") + } finally { + await dispose() + } + }) + + it("returns HTTP 200 with a decodable RefreshTokenResponse for /auth/refresh", async () => { + const { handler, dispose } = makeAuthRouteHandler() + + try { + const response = await handler( + new Request("http://localhost/auth/refresh", { + method: "POST", + headers: { + "content-type": "application/json", + "x-auth-attempt-id": "attempt_refresh_123", + }, + body: JSON.stringify({ + refreshToken: "refresh-token", + }), + }), + ServiceMap.empty() as ServiceMap.ServiceMap, + ) + + expect(response.status).toBe(200) + + const body = await response.json() + const decoded = Schema.decodeUnknownSync(RefreshTokenResponse)(body) + + expect(decoded.accessToken).toContain(".") + expect(decoded.refreshToken).toBe("refresh-token-next") + } finally { + await dispose() + } + }) + }) }) diff --git a/apps/backend/src/routes/auth.http.ts b/apps/backend/src/routes/auth.http.ts index b81e77a5f..40932147b 100644 --- a/apps/backend/src/routes/auth.http.ts +++ b/apps/backend/src/routes/auth.http.ts @@ -1,32 +1,81 @@ -import { HttpApiBuilder, HttpServerResponse } from "@effect/platform" +import { createHash } from "node:crypto" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { HttpServerResponse } from "effect/unstable/http" import { getJwtExpiry } from "@hazel/auth" import { UserRepo } from "@hazel/backend-core" +import { RefreshTokenResponse, TokenResponse } from "@hazel/domain/http" import { WorkOSUserId } from "@hazel/schema" import { InternalServerError, OAuthCodeExpiredError, UnauthorizedError } from "@hazel/domain" -import { Config, Effect, Option, Schema } from "effect" +import { Config, Effect, Schema } from "effect" import { HazelApi } from "../api" -import { AuthState, DesktopAuthState, RelativeUrl } from "../lib/schema" +import { RelativeUrl } from "../lib/schema" +import { AuthRedemptionStore } from "../services/auth-redemption-store" import { WorkOSAuth as WorkOS } from "../services/workos-auth" +type TokenExchangeResponse = Schema.Schema.Type +type AuthHeaders = { + readonly "x-auth-attempt-id"?: string +} + +const hashValue = (value: string): string => createHash("sha256").update(value).digest("hex").slice(0, 12) +const getAttemptId = (headers: AuthHeaders | undefined): string => headers?.["x-auth-attempt-id"] ?? "missing" + +const getWorkOSCauseDetails = (cause: unknown) => { + const details = cause as { + status?: number + error?: string + errorDescription?: string + rawData?: { + error?: string + error_description?: string + } + message?: string + requestID?: string + } + + return { + status: details.status, + error: details.error ?? details.rawData?.error, + errorDescription: details.errorDescription ?? details.rawData?.error_description, + message: details.message ?? String(cause), + requestId: details.requestID ?? "", + } +} + +const mapWorkOSCodeExchangeError = (error: { cause: unknown }): OAuthCodeExpiredError | UnauthorizedError => { + const details = getWorkOSCauseDetails(error.cause) + + if (details.error === "invalid_grant") { + return new OAuthCodeExpiredError({ + message: details.errorDescription || "Authorization code expired or already used", + }) + } + + return new UnauthorizedError({ + message: "Failed to authenticate with WorkOS", + detail: details.errorDescription || details.message, + }) +} + export const HttpAuthLive = HttpApiBuilder.group(HazelApi, "auth", (handlers) => handlers - .handle("login", ({ urlParams }) => + .handle("login", ({ query }) => Effect.gen(function* () { const workos = yield* WorkOS - const clientId = yield* Config.string("WORKOS_CLIENT_ID").pipe(Effect.orDie) - const redirectUri = yield* Config.string("WORKOS_REDIRECT_URI").pipe(Effect.orDie) + const clientId = yield* Config.string("WORKOS_CLIENT_ID") + const redirectUri = yield* Config.string("WORKOS_REDIRECT_URI") // Validate returnTo is a relative URL (defense in depth) - const validatedReturnTo = Schema.decodeSync(RelativeUrl)(urlParams.returnTo) - const state = JSON.stringify(AuthState.make({ returnTo: validatedReturnTo })) + const validatedReturnTo = Schema.decodeSync(RelativeUrl)(query.returnTo) + const state = JSON.stringify({ returnTo: validatedReturnTo }) let workosOrgId: string - if (urlParams.organizationId) { + if (query.organizationId) { const workosOrg = yield* workos .call(async (client) => - client.organizations.getOrganizationByExternalId(urlParams.organizationId!), + client.organizations.getOrganizationByExternalId(query.organizationId!), ) .pipe( Effect.catchTag("WorkOSAuthError", (error) => @@ -54,7 +103,7 @@ export const HttpAuthLive = HttpApiBuilder.group(HazelApi, "auth", (handlers) => ...(workosOrgId && { organizationId: workosOrgId, }), - ...(urlParams.invitationToken && { invitationToken: urlParams.invitationToken }), + ...(query.invitationToken && { invitationToken: query.invitationToken }), }) return authUrl }) @@ -78,14 +127,20 @@ export const HttpAuthLive = HttpApiBuilder.group(HazelApi, "auth", (handlers) => Location: authorizationUrl, }, }) - }), + }).pipe( + Effect.catchTag("ConfigError", (err) => + Effect.fail( + new InternalServerError({ message: "Missing configuration", detail: String(err) }), + ), + ), + ), ) - .handle("callback", ({ urlParams }) => + .handle("callback", ({ query }) => Effect.gen(function* () { - const frontendUrl = yield* Config.string("FRONTEND_URL").pipe(Effect.orDie) + const frontendUrl = yield* Config.string("FRONTEND_URL") - const code = urlParams.code - const state = urlParams.state + const code = query.code + const state = query.state if (!code) { return yield* Effect.fail( @@ -108,14 +163,20 @@ export const HttpAuthLive = HttpApiBuilder.group(HazelApi, "auth", (handlers) => Location: callbackUrl.toString(), }, }) - }), + }).pipe( + Effect.catchTag("ConfigError", (err) => + Effect.fail( + new InternalServerError({ message: "Missing configuration", detail: String(err) }), + ), + ), + ), ) - .handle("logout", ({ urlParams }) => + .handle("logout", ({ query }) => Effect.gen(function* () { - const frontendUrl = yield* Config.string("FRONTEND_URL").pipe(Effect.orDie) + const frontendUrl = yield* Config.string("FRONTEND_URL") // Build the full return URL - redirect to frontend after logout - const returnTo = urlParams.redirectTo ? `${frontendUrl}${urlParams.redirectTo}` : frontendUrl + const returnTo = query.redirectTo ? `${frontendUrl}${query.redirectTo}` : frontendUrl return HttpServerResponse.empty({ status: 302, @@ -123,35 +184,41 @@ export const HttpAuthLive = HttpApiBuilder.group(HazelApi, "auth", (handlers) => Location: returnTo, }, }) - }), + }).pipe( + Effect.catchTag("ConfigError", (err) => + Effect.fail( + new InternalServerError({ message: "Missing configuration", detail: String(err) }), + ), + ), + ), ) - .handle("loginDesktop", ({ urlParams }) => + .handle("loginDesktop", ({ query }) => Effect.gen(function* () { const workos = yield* WorkOS - const clientId = yield* Config.string("WORKOS_CLIENT_ID").pipe(Effect.orDie) - const frontendUrl = yield* Config.string("FRONTEND_URL").pipe(Effect.orDie) + const clientId = yield* Config.string("WORKOS_CLIENT_ID") + const frontendUrl = yield* Config.string("FRONTEND_URL") // Always use web app callback page const redirectUri = `${frontendUrl}/auth/desktop-callback` // Validate returnTo is a relative URL (defense in depth) - const validatedReturnTo = Schema.decodeSync(RelativeUrl)(urlParams.returnTo) + const validatedReturnTo = Schema.decodeSync(RelativeUrl)(query.returnTo) // Build state with desktop connection info - const stateObj = DesktopAuthState.make({ + const stateObj = { returnTo: validatedReturnTo, - desktopPort: urlParams.desktopPort, - desktopNonce: urlParams.desktopNonce, - }) + desktopPort: query.desktopPort, + desktopNonce: query.desktopNonce, + } const state = JSON.stringify(stateObj) let workosOrgId: string | undefined - if (urlParams.organizationId) { + if (query.organizationId) { const workosOrg = yield* workos .call(async (client) => - client.organizations.getOrganizationByExternalId(urlParams.organizationId!), + client.organizations.getOrganizationByExternalId(query.organizationId!), ) .pipe(Effect.catchTag("WorkOSAuthError", () => Effect.succeed(null))) @@ -166,7 +233,7 @@ export const HttpAuthLive = HttpApiBuilder.group(HazelApi, "auth", (handlers) => redirectUri, state, ...(workosOrgId && { organizationId: workosOrgId }), - ...(urlParams.invitationToken && { invitationToken: urlParams.invitationToken }), + ...(query.invitationToken && { invitationToken: query.invitationToken }), }) }) .pipe( @@ -187,118 +254,150 @@ export const HttpAuthLive = HttpApiBuilder.group(HazelApi, "auth", (handlers) => Location: authorizationUrl, }, }) - }), + }).pipe( + Effect.catchTag("ConfigError", (err) => + Effect.fail( + new InternalServerError({ message: "Missing configuration", detail: String(err) }), + ), + ), + ), ) - .handle("token", ({ payload }) => + .handle("token", ({ payload, headers }) => Effect.gen(function* () { const workos = yield* WorkOS + const authRedemptionStore = yield* AuthRedemptionStore const userRepo = yield* UserRepo const { code, state } = payload + const attemptId = getAttemptId(headers) - const clientId = yield* Config.string("WORKOS_CLIENT_ID").pipe(Effect.orDie) + const clientId = yield* Config.string("WORKOS_CLIENT_ID") - // Exchange code for tokens (without sealing - we want the JWT for desktop) - const authResponse = yield* workos - .call(async (client) => { - return await client.userManagement.authenticateWithCode({ - clientId, - code, - // Don't seal - we need the accessToken for desktop apps + yield* Effect.logInfo("[auth/token] Handling token exchange request", { + attemptId, + codeHash: hashValue(code), + stateHash: hashValue(state), + }) + + const tokens = yield* authRedemptionStore.exchangeCodeOnce( + { + code, + state, + attemptId, + }, + workos + .call(async (client) => { + return await client.userManagement.authenticateWithCode({ + clientId, + code, + // Don't seal - we need the accessToken for desktop apps + }) }) - }) - .pipe( - Effect.catchTag( - "WorkOSAuthError", - (error): Effect.Effect => { - const errorStr = String(error.cause) - // Detect expired/invalid code from WorkOS (invalid_grant) - if (errorStr.includes("invalid_grant")) { - return Effect.fail( - new OAuthCodeExpiredError({ - message: "Authorization code expired or already used", - }), - ) + .pipe( + Effect.catchTag("WorkOSAuthError", (error) => + Effect.fail(mapWorkOSCodeExchangeError(error)), + ), + Effect.map((authResponse): TokenExchangeResponse => { + const expiresIn = + getJwtExpiry(authResponse.accessToken) - Math.floor(Date.now() / 1000) + + return { + accessToken: authResponse.accessToken, + refreshToken: authResponse.refreshToken!, + expiresIn, + user: { + id: authResponse.user.id, + email: authResponse.user.email, + firstName: authResponse.user.firstName || "", + lastName: authResponse.user.lastName || "", + }, } - return Effect.fail( - new UnauthorizedError({ - message: "Failed to authenticate with WorkOS", - detail: errorStr, + }), + Effect.catchTag("UnauthorizedError", (error) => + Effect.fail( + new InternalServerError({ + message: error.message, + detail: error.detail, + cause: error, }), - ) - }, - ), - ) - - const { user: workosUser, accessToken, refreshToken } = authResponse - const workosUserId = Schema.decodeUnknownSync(WorkOSUserId)(workosUser.id) - - // Ensure user exists in our DB - const userOption = yield* userRepo.findByWorkOSUserId(workosUserId).pipe( - Effect.catchTags({ - DatabaseError: (err) => - Effect.fail( - new InternalServerError({ - message: "Failed to query user", - detail: String(err), - }), + ), ), - }), + ), ) - yield* Option.match(userOption, { - onNone: () => - userRepo - .upsertWorkOSUser({ - externalId: workosUserId, - email: workosUser.email, - firstName: workosUser.firstName || "", - lastName: workosUser.lastName || "", - avatarUrl: workosUser.profilePictureUrl?.trim() - ? workosUser.profilePictureUrl - : null, - userType: "user", - settings: null, - isOnboarded: false, - timezone: null, - deletedAt: null, - }) - .pipe( - Effect.catchTags({ - DatabaseError: (err) => - Effect.fail( - new InternalServerError({ - message: "Failed to create user", - detail: String(err), - }), - ), - }), - ), - onSome: (user) => Effect.succeed(user), + yield* Effect.logInfo("[auth/token] Ensuring local user exists", { + attemptId, + workosUserId: tokens.user.id, }) - // Calculate expires in seconds from JWT expiry - const expiresIn = getJwtExpiry(accessToken) - Math.floor(Date.now() / 1000) + const workosUser = tokens.user + const workosUserId = Schema.decodeUnknownSync(WorkOSUserId)(workosUser.id) - return { - accessToken, - refreshToken: refreshToken!, - expiresIn, - user: { - id: workosUser.id, + yield* userRepo + .upsertWorkOSUser({ + externalId: workosUserId, email: workosUser.email, firstName: workosUser.firstName || "", lastName: workosUser.lastName || "", - }, - } - }), + avatarUrl: null, + userType: "user", + settings: null, + isOnboarded: false, + timezone: null, + deletedAt: null, + }) + .pipe( + Effect.catchTags({ + DatabaseError: (err) => + Effect.fail( + new InternalServerError({ + message: "Failed to upsert user after OAuth redemption", + detail: String(err), + }), + ), + }), + ) + + yield* Effect.logInfo("[auth/token] Token exchange request completed", { + attemptId, + workosUserId: tokens.user.id, + outcome: "success", + }) + + const response = new TokenResponse(tokens) + yield* Effect.logInfo("[auth/token] Constructed schema success response", { + attemptId, + outcome: "success_response", + }) + + return response + }).pipe( + Effect.tapError((error) => + Effect.logError("[auth/token] Token exchange request failed", { + attemptId: getAttemptId(headers), + errorTag: error._tag, + message: error.message, + }), + ), + Effect.catchTag("ConfigError", (err) => + Effect.fail( + new InternalServerError({ message: "Missing configuration", detail: String(err) }), + ), + ), + ), ) - .handle("refresh", ({ payload }) => + .handle("refresh", ({ payload, headers }) => Effect.gen(function* () { const workos = yield* WorkOS const { refreshToken } = payload + const attemptId = getAttemptId(headers) + + const clientId = yield* Config.string("WORKOS_CLIENT_ID") - const clientId = yield* Config.string("WORKOS_CLIENT_ID").pipe(Effect.orDie) + yield* Effect.logInfo("[auth/refresh] Handling refresh request", { + attemptId, + refreshTokenHash: hashValue(refreshToken), + }) // Exchange refresh token for new tokens const authResponse = yield* workos @@ -321,11 +420,36 @@ export const HttpAuthLive = HttpApiBuilder.group(HazelApi, "auth", (handlers) => const expiresIn = getJwtExpiry(authResponse.accessToken) - Math.floor(Date.now() / 1000) - return { + yield* Effect.logInfo("[auth/refresh] Refresh request completed", { + attemptId, + outcome: "success", + }) + + const response = new RefreshTokenResponse({ accessToken: authResponse.accessToken, refreshToken: authResponse.refreshToken!, expiresIn, - } - }), + }) + + yield* Effect.logInfo("[auth/refresh] Constructed schema success response", { + attemptId, + outcome: "success_response", + }) + + return response + }).pipe( + Effect.tapError((error) => + Effect.logError("[auth/refresh] Refresh request failed", { + attemptId: getAttemptId(headers), + errorTag: error._tag, + message: error.message, + }), + ), + Effect.catchTag("ConfigError", (err) => + Effect.fail( + new InternalServerError({ message: "Missing configuration", detail: String(err) }), + ), + ), + ), ), ) diff --git a/apps/backend/src/routes/bot-commands.http.test.ts b/apps/backend/src/routes/bot-commands.http.test.ts index 250992f0a..cd0bf1d8a 100644 --- a/apps/backend/src/routes/bot-commands.http.test.ts +++ b/apps/backend/src/routes/bot-commands.http.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest" import { Effect, Fiber, Stream } from "effect" -import { createCommandSseStream, createSseHeartbeatStream } from "./bot-commands.http.ts" +import { createCommandSseStream, createSseHeartbeatStream, type CommandSseRedis } from "./bot-commands.sse.ts" describe("bot command SSE streams", () => { it("emits an immediate heartbeat on connect", () => @@ -20,10 +20,10 @@ describe("bot command SSE streams", () => { const fiber = yield* createSseHeartbeatStream("5 millis").pipe( Stream.take(3), Stream.runCollect, - Effect.fork, + Effect.forkDetach, ) - const events = yield* Fiber.join(fiber) + const events = Array.from(yield* Fiber.join(fiber)) as string[] expect(events).toHaveLength(3) for (const event of events) { @@ -43,7 +43,7 @@ describe("bot command SSE streams", () => { timestamp: Date.now(), }) - const redisMock = { + const redisMock: CommandSseRedis = { subscribe: (channel: string, handler: (message: string, chan: string) => void) => Effect.sync(() => { queueMicrotask(() => { @@ -57,14 +57,14 @@ describe("bot command SSE streams", () => { botId: "bot_test", botName: "Test Bot", channel: "bot:bot_test:commands", - redis: redisMock as any, + redis: redisMock, heartbeatInterval: "1 hour", }) - const collector = yield* stream.pipe(Stream.take(2), Stream.runCollect, Effect.fork) + const collector = yield* stream.pipe(Stream.take(2), Stream.runCollect, Effect.forkDetach) - const events = yield* Fiber.join(collector) - const commandEvent = Array.from(events).find((event) => event.includes("event: command")) + const events = Array.from(yield* Fiber.join(collector)) as string[] + const commandEvent = events.find((event) => event.includes("event: command")) expect(commandEvent).toBeDefined() expect(commandEvent).toContain(`data: ${payload}`) diff --git a/apps/backend/src/routes/bot-commands.http.ts b/apps/backend/src/routes/bot-commands.http.ts index 88e5c5836..51589aeea 100644 --- a/apps/backend/src/routes/bot-commands.http.ts +++ b/apps/backend/src/routes/bot-commands.http.ts @@ -1,5 +1,5 @@ -import { HttpApiBuilder, HttpServerRequest, HttpServerResponse } from "@effect/platform" -import { Sse } from "@effect/experimental" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { BotCommandRepo, BotInstallationRepo, BotRepo, IntegrationConnectionRepo } from "@hazel/backend-core" import { CurrentUser, InternalServerError, UnauthorizedError } from "@hazel/domain" import { @@ -17,10 +17,11 @@ import { UpdateBotSettingsResponse, } from "@hazel/domain/http" import { Redis } from "@hazel/effect-bun" -import { Context, Duration, Effect, Option, Schedule, Stream } from "effect" +import { Cause, Effect, Option, Stream } from "effect" import { HazelApi } from "../api.ts" import { BotGatewayService } from "../services/bot-gateway-service.ts" import { IntegrationTokenService } from "../services/integration-token-service.ts" +import { createCommandSseStream } from "./bot-commands.sse.ts" /** * Hash a token using SHA-256 (Web Crypto API) @@ -67,92 +68,6 @@ const validateBotToken = Effect.gen(function* () { return botOption.value }) -const HEARTBEAT_INTERVAL = "25 seconds" as const - -const encodeSseEvent = (event: string, data: string) => - Sse.encoder.write({ - _tag: "Event", - event, - id: undefined, - data, - }) - -export const createSseHeartbeatStream = (interval: Duration.DurationInput = HEARTBEAT_INTERVAL) => - Stream.make( - encodeSseEvent( - "heartbeat", - JSON.stringify({ - type: "heartbeat", - timestamp: Date.now(), - }), - ), - ).pipe( - Stream.concat( - Stream.fromSchedule(Schedule.spaced(interval)).pipe( - Stream.map(() => - encodeSseEvent( - "heartbeat", - JSON.stringify({ - type: "heartbeat", - timestamp: Date.now(), - }), - ), - ), - ), - ), - ) - -interface CommandSseStreamOptions { - readonly botId: string - readonly botName: string - readonly channel: string - readonly redis: Pick, "subscribe"> - readonly heartbeatInterval?: Duration.DurationInput -} - -export const createCommandSseStream = ({ - botId, - botName, - channel, - redis, - heartbeatInterval = HEARTBEAT_INTERVAL, -}: CommandSseStreamOptions) => { - const commandStream = Stream.async((emit) => { - Effect.gen(function* () { - const { unsubscribe } = yield* redis.subscribe(channel, (message) => { - // Encode the message as an SSE event - emit.single(encodeSseEvent("command", message)) - }) - - // Add finalizer to unsubscribe when stream closes - yield* Effect.addFinalizer(() => - unsubscribe.pipe( - Effect.tap(() => - Effect.logDebug(`Bot ${botId} (${botName}) disconnected from SSE stream`), - ), - Effect.catchAll(() => Effect.void), - ), - ) - - // Keep the subscription alive until the stream is closed - yield* Effect.never - }).pipe( - Effect.scoped, - Effect.catchAll((error) => { - // Log the error but don't fail the stream - end it gracefully - Effect.runFork(Effect.logError("Redis subscription error", { error, botId, botName })) - emit.end() - return Effect.void - }), - Effect.runFork, - ) - }) - - return Stream.merge(commandStream, createSseHeartbeatStream(heartbeatInterval), { - haltStrategy: "either", - }) -} - export const HttpBotCommandsLive = HttpApiBuilder.group(HazelApi, "bot-commands", (handlers) => handlers // SSE stream for bot commands (bot token auth) @@ -261,10 +176,10 @@ export const HttpBotCommandsLive = HttpApiBuilder.group(HazelApi, "bot-commands" ), ) // Execute a bot command (user auth) - .handle("executeBotCommand", ({ path, payload }) => + .handle("executeBotCommand", ({ params, payload }) => Effect.gen(function* () { const currentUser = yield* CurrentUser.Context - const { orgId, botId, commandName } = path + const { orgId, botId, commandName } = params const { channelId, arguments: args } = payload const botRepo = yield* BotRepo @@ -342,7 +257,7 @@ export const HttpBotCommandsLive = HttpApiBuilder.group(HazelApi, "bot-commands" Effect.catchTag("DurableStreamRequestError", (error) => Effect.fail( new BotCommandExecutionError({ - commandName: path.commandName, + commandName: params.commandName, message: "Failed to append command to bot gateway", details: String(error.message), }), @@ -351,10 +266,10 @@ export const HttpBotCommandsLive = HttpApiBuilder.group(HazelApi, "bot-commands" ), ) // Get integration token (bot token auth) - .handle("getIntegrationToken", ({ path }) => + .handle("getIntegrationToken", ({ params }) => Effect.gen(function* () { const bot = yield* validateBotToken - const { orgId, provider } = path + const { orgId, provider } = params // Check provider is in bot's allowedIntegrations const allowed = bot.allowedIntegrations ?? [] @@ -411,23 +326,23 @@ export const HttpBotCommandsLive = HttpApiBuilder.group(HazelApi, "bot-commands" ), ), Effect.catchTag("TokenNotFoundError", () => - Effect.fail(new IntegrationNotConnectedError({ provider: path.provider })), + Effect.fail(new IntegrationNotConnectedError({ provider: params.provider })), ), Effect.catchTag("TokenRefreshError", (error) => Effect.fail( new InternalServerError({ - message: `Failed to refresh ${path.provider} token`, + message: `Failed to refresh ${params.provider} token`, detail: String(error.cause), }), ), ), Effect.catchTag("ConnectionNotFoundError", () => - Effect.fail(new IntegrationNotConnectedError({ provider: path.provider })), + Effect.fail(new IntegrationNotConnectedError({ provider: params.provider })), ), Effect.catchTag("IntegrationEncryptionError", (error) => Effect.fail( new InternalServerError({ - message: `Failed to decrypt ${path.provider} token`, + message: `Failed to decrypt ${params.provider} token`, detail: String(error), }), ), @@ -435,7 +350,7 @@ export const HttpBotCommandsLive = HttpApiBuilder.group(HazelApi, "bot-commands" Effect.catchTag("KeyVersionNotFoundError", (error) => Effect.fail( new InternalServerError({ - message: `Encryption key version not found for ${path.provider} token`, + message: `Encryption key version not found for ${params.provider} token`, detail: String(error), }), ), @@ -443,10 +358,10 @@ export const HttpBotCommandsLive = HttpApiBuilder.group(HazelApi, "bot-commands" ), ) // Get enabled integrations (bot token auth) - .handle("getEnabledIntegrations", ({ path }) => + .handle("getEnabledIntegrations", ({ params }) => Effect.gen(function* () { const bot = yield* validateBotToken - const { orgId } = path + const { orgId } = params // Verify bot is installed in this org const installationRepo = yield* BotInstallationRepo diff --git a/apps/backend/src/routes/bot-commands.sse.ts b/apps/backend/src/routes/bot-commands.sse.ts new file mode 100644 index 000000000..4cc574871 --- /dev/null +++ b/apps/backend/src/routes/bot-commands.sse.ts @@ -0,0 +1,93 @@ +import { Sse } from "effect/unstable/encoding" +import { Duration, Effect, Queue, Schedule, Stream } from "effect" + +const HEARTBEAT_INTERVAL = "25 seconds" as const + +const encodeSseEvent = (event: string, data: string) => + Sse.encoder.write({ + _tag: "Event", + event, + id: undefined, + data, + }) + +export type CommandSseRedis = { + readonly subscribe: ( + channel: string, + handler: (message: string, channel: string) => void, + ) => Effect.Effect< + { + readonly unsubscribe: Effect.Effect + }, + unknown + > +} + +export const createSseHeartbeatStream = (interval: Duration.Input = HEARTBEAT_INTERVAL) => + Stream.make( + encodeSseEvent( + "heartbeat", + JSON.stringify({ + type: "heartbeat", + timestamp: Date.now(), + }), + ), + ).pipe( + Stream.concat( + Stream.fromSchedule(Schedule.spaced(interval)).pipe( + Stream.map(() => + encodeSseEvent( + "heartbeat", + JSON.stringify({ + type: "heartbeat", + timestamp: Date.now(), + }), + ), + ), + ), + ), + ) + +interface CommandSseStreamOptions { + readonly botId: string + readonly botName: string + readonly channel: string + readonly redis: CommandSseRedis + readonly heartbeatInterval?: Duration.Input +} + +export const createCommandSseStream = ({ + botId, + botName, + channel, + redis, + heartbeatInterval = HEARTBEAT_INTERVAL, +}: CommandSseStreamOptions) => { + const commandStream = Stream.callback((queue) => + Effect.gen(function* () { + const { unsubscribe } = yield* redis.subscribe(channel, (message) => { + Queue.offerUnsafe(queue, encodeSseEvent("command", message)) + }) + + yield* Effect.addFinalizer(() => + unsubscribe.pipe( + Effect.tap(() => + Effect.logDebug(`Bot ${botId} (${botName}) disconnected from SSE stream`), + ), + Effect.catch(() => Effect.void), + ), + ) + + yield* Effect.never + }).pipe( + Effect.catch((error) => { + Effect.runFork(Effect.logError("Redis subscription error", { error, botId, botName })) + return Queue.end(queue) + }), + ), + ) + + return Stream.merge(commandStream, createSseHeartbeatStream(heartbeatInterval), { + haltStrategy: "either", + }) +} diff --git a/apps/backend/src/routes/chat-sync.http.ts b/apps/backend/src/routes/chat-sync.http.ts index 0430142b4..1e049b833 100644 --- a/apps/backend/src/routes/chat-sync.http.ts +++ b/apps/backend/src/routes/chat-sync.http.ts @@ -17,7 +17,7 @@ import { } from "@hazel/backend-core" import { ExternalChannelId } from "@hazel/schema" import { CurrentUser, InternalServerError, UnauthorizedError } from "@hazel/domain" -import { HttpApiBuilder } from "@effect/platform" +import { HttpApiBuilder } from "effect/unstable/httpapi" import { Effect, Option } from "effect" import { HazelApi } from "../api" import { generateTransactionId } from "../lib/create-transactionId" @@ -58,9 +58,9 @@ export const HttpChatSyncLive = HttpApiBuilder.group(HazelApi, "chat-sync", (han const integrationConnectionRepo = yield* IntegrationConnectionRepo return handlers - .handle("createConnection", ({ path, payload }) => + .handle("createConnection", ({ params, payload }) => Effect.gen(function* () { - yield* ensureOrgAccess(path.orgId) + yield* ensureOrgAccess(params.orgId) const currentUser = yield* CurrentUser.Context const integrationConnectionId = yield* Effect.gen(function* () { @@ -73,13 +73,13 @@ export const HttpChatSyncLive = HttpApiBuilder.group(HazelApi, "chat-sync", (han } const integrationOption = yield* integrationConnectionRepo.findOrgConnection( - path.orgId, + params.orgId, "discord", ) if (Option.isNone(integrationOption) || integrationOption.value.status !== "active") { return yield* Effect.fail( new ChatSyncIntegrationNotConnectedError({ - organizationId: path.orgId, + organizationId: params.orgId, provider: "discord", }), ) @@ -89,14 +89,14 @@ export const HttpChatSyncLive = HttpApiBuilder.group(HazelApi, "chat-sync", (han }) const existing = yield* connectionRepo.findByProviderAndWorkspace( - path.orgId, + params.orgId, payload.provider, payload.externalWorkspaceId, ) if (Option.isSome(existing)) { return yield* Effect.fail( new ChatSyncConnectionExistsError({ - organizationId: path.orgId, + organizationId: params.orgId, provider: payload.provider, externalWorkspaceId: payload.externalWorkspaceId, }), @@ -104,7 +104,7 @@ export const HttpChatSyncLive = HttpApiBuilder.group(HazelApi, "chat-sync", (han } const [connection] = yield* connectionRepo.insert({ - organizationId: path.orgId, + organizationId: params.orgId, integrationConnectionId, provider: payload.provider, externalWorkspaceId: payload.externalWorkspaceId, @@ -121,7 +121,7 @@ export const HttpChatSyncLive = HttpApiBuilder.group(HazelApi, "chat-sync", (han const txid = yield* generateTransactionId() return new ChatSyncConnectionResponse({ data: connection, transactionId: txid }) }).pipe( - Effect.catchTag("ParseError", (error) => + Effect.catchTag("SchemaError", (error) => Effect.fail(toInternalServerError("Invalid sync connection data", error)), ), Effect.catchTag("DatabaseError", (error) => @@ -131,10 +131,10 @@ export const HttpChatSyncLive = HttpApiBuilder.group(HazelApi, "chat-sync", (han ), ), ) - .handle("listConnections", ({ path }) => + .handle("listConnections", ({ params }) => Effect.gen(function* () { - yield* ensureOrgAccess(path.orgId) - const connections = yield* connectionRepo.findByOrganization(path.orgId) + yield* ensureOrgAccess(params.orgId) + const connections = yield* connectionRepo.findByOrganization(params.orgId) return new ChatSyncConnectionListResponse({ data: connections }) }).pipe( Effect.catchTag("DatabaseError", (error) => @@ -144,22 +144,22 @@ export const HttpChatSyncLive = HttpApiBuilder.group(HazelApi, "chat-sync", (han ), ), ) - .handle("deleteConnection", ({ path }) => + .handle("deleteConnection", ({ params }) => Effect.gen(function* () { - const connectionOption = yield* connectionRepo.findById(path.syncConnectionId) + const connectionOption = yield* connectionRepo.findById(params.syncConnectionId) if (Option.isNone(connectionOption)) { return yield* Effect.fail( new ChatSyncConnectionNotFoundError({ - syncConnectionId: path.syncConnectionId, + syncConnectionId: params.syncConnectionId, }), ) } const connection = connectionOption.value yield* ensureOrgAccess(connection.organizationId) - yield* connectionRepo.softDelete(path.syncConnectionId) + yield* connectionRepo.softDelete(params.syncConnectionId) - const links = yield* channelLinkRepo.findBySyncConnection(path.syncConnectionId) + const links = yield* channelLinkRepo.findBySyncConnection(params.syncConnectionId) yield* Effect.forEach(links, (link) => channelLinkRepo.softDelete(link.id), { concurrency: 10, }) @@ -174,13 +174,13 @@ export const HttpChatSyncLive = HttpApiBuilder.group(HazelApi, "chat-sync", (han ), ), ) - .handle("createChannelLink", ({ path, payload }) => + .handle("createChannelLink", ({ params, payload }) => Effect.gen(function* () { - const connectionOption = yield* connectionRepo.findById(path.syncConnectionId) + const connectionOption = yield* connectionRepo.findById(params.syncConnectionId) if (Option.isNone(connectionOption)) { return yield* Effect.fail( new ChatSyncConnectionNotFoundError({ - syncConnectionId: path.syncConnectionId, + syncConnectionId: params.syncConnectionId, }), ) } @@ -188,13 +188,13 @@ export const HttpChatSyncLive = HttpApiBuilder.group(HazelApi, "chat-sync", (han yield* ensureOrgAccess(connection.organizationId) const existingHazel = yield* channelLinkRepo.findByHazelChannel( - path.syncConnectionId, + params.syncConnectionId, payload.hazelChannelId, ) if (Option.isSome(existingHazel)) { return yield* Effect.fail( new ChatSyncChannelLinkExistsError({ - syncConnectionId: path.syncConnectionId, + syncConnectionId: params.syncConnectionId, hazelChannelId: payload.hazelChannelId, externalChannelId: payload.externalChannelId, }), @@ -202,13 +202,13 @@ export const HttpChatSyncLive = HttpApiBuilder.group(HazelApi, "chat-sync", (han } const existingExternal = yield* channelLinkRepo.findByExternalChannel( - path.syncConnectionId, + params.syncConnectionId, payload.externalChannelId, ) if (Option.isSome(existingExternal)) { return yield* Effect.fail( new ChatSyncChannelLinkExistsError({ - syncConnectionId: path.syncConnectionId, + syncConnectionId: params.syncConnectionId, hazelChannelId: payload.hazelChannelId, externalChannelId: payload.externalChannelId, }), @@ -216,7 +216,7 @@ export const HttpChatSyncLive = HttpApiBuilder.group(HazelApi, "chat-sync", (han } const [link] = yield* channelLinkRepo.insert({ - syncConnectionId: path.syncConnectionId, + syncConnectionId: params.syncConnectionId, hazelChannelId: payload.hazelChannelId, externalChannelId: payload.externalChannelId, externalChannelName: payload.externalChannelName ?? null, @@ -234,7 +234,7 @@ export const HttpChatSyncLive = HttpApiBuilder.group(HazelApi, "chat-sync", (han const txid = yield* generateTransactionId() return new ChatSyncChannelLinkResponse({ data: brandedLink, transactionId: txid }) }).pipe( - Effect.catchTag("ParseError", (error) => + Effect.catchTag("SchemaError", (error) => Effect.fail(toInternalServerError("Invalid channel link data", error)), ), Effect.catchTag("DatabaseError", (error) => @@ -244,20 +244,20 @@ export const HttpChatSyncLive = HttpApiBuilder.group(HazelApi, "chat-sync", (han ), ), ) - .handle("listChannelLinks", ({ path }) => + .handle("listChannelLinks", ({ params }) => Effect.gen(function* () { - const connectionOption = yield* connectionRepo.findById(path.syncConnectionId) + const connectionOption = yield* connectionRepo.findById(params.syncConnectionId) if (Option.isNone(connectionOption)) { return yield* Effect.fail( new ChatSyncConnectionNotFoundError({ - syncConnectionId: path.syncConnectionId, + syncConnectionId: params.syncConnectionId, }), ) } const connection = connectionOption.value yield* ensureOrgAccess(connection.organizationId) - const links = yield* channelLinkRepo.findBySyncConnection(path.syncConnectionId) + const links = yield* channelLinkRepo.findBySyncConnection(params.syncConnectionId) return new ChatSyncChannelLinkListResponse({ data: links }) }).pipe( Effect.catchTag("DatabaseError", (error) => @@ -267,13 +267,13 @@ export const HttpChatSyncLive = HttpApiBuilder.group(HazelApi, "chat-sync", (han ), ), ) - .handle("deleteChannelLink", ({ path }) => + .handle("deleteChannelLink", ({ params }) => Effect.gen(function* () { - const linkOption = yield* channelLinkRepo.findById(path.syncChannelLinkId) + const linkOption = yield* channelLinkRepo.findById(params.syncChannelLinkId) if (Option.isNone(linkOption)) { return yield* Effect.fail( new ChatSyncChannelLinkNotFoundError({ - syncChannelLinkId: path.syncChannelLinkId, + syncChannelLinkId: params.syncChannelLinkId, }), ) } @@ -289,7 +289,7 @@ export const HttpChatSyncLive = HttpApiBuilder.group(HazelApi, "chat-sync", (han } yield* ensureOrgAccess(connectionOption.value.organizationId) - yield* channelLinkRepo.softDelete(path.syncChannelLinkId) + yield* channelLinkRepo.softDelete(params.syncChannelLinkId) const txid = yield* generateTransactionId() return new ChatSyncDeleteResponse({ transactionId: txid }) }).pipe( diff --git a/apps/backend/src/routes/http-success-encoding.test.ts b/apps/backend/src/routes/http-success-encoding.test.ts new file mode 100644 index 000000000..e5e59d3e3 --- /dev/null +++ b/apps/backend/src/routes/http-success-encoding.test.ts @@ -0,0 +1,256 @@ +import { NodeHttpPlatform, NodeServices } from "@effect/platform-node" +import { describe, expect, it } from "@effect/vitest" +import { BotRepo, UserPresenceStatusRepo } from "@hazel/backend-core" +import { Database } from "@hazel/db" +import { CurrentUser } from "@hazel/domain" +import { + InternalApiGroup, + MarkOfflinePayload, + MarkOfflineResponse, + MockDataGroup, + PresencePublicGroup, + ValidateBotTokenRequest, + ValidateBotTokenResponse, + GenerateMockDataRequest, + GenerateMockDataResponse, +} from "@hazel/domain/http" +import { BotId, OrganizationId, UserId } from "@hazel/schema" +import { Effect, Layer, Option, Schema, ServiceMap } from "effect" +import { Etag, HttpRouter } from "effect/unstable/http" +import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi" +import { vi } from "vitest" +import { HttpInternalLive } from "./internal.http" +import { HttpMockDataLive } from "./mock-data.http" +import { HttpPresencePublicLive } from "./presence.http" +import { MockDataGenerator } from "../services/mock-data-generator" +import { configLayer, serviceShape } from "../test/effect-helpers" + +vi.mock("@hazel/effect-bun", async () => { + const { Layer, ServiceMap } = await import("effect") + + class Redis extends ServiceMap.Service< + Redis, + { + readonly get: (key: string) => unknown + readonly del: (key: string) => unknown + readonly send: (command: string, args: string[]) => T + } + >()("@hazel/effect-bun/Redis") {} + + return { + Redis: Object.assign(Redis, { + Default: Layer.empty, + }), + } +}) + +const makeCurrentUser = () => + ({ + id: UserId.makeUnsafe("00000000-0000-4000-8000-000000000111"), + organizationId: OrganizationId.makeUnsafe("00000000-0000-4000-8000-000000000123"), + role: "owner", + avatarUrl: undefined, + firstName: "Test", + lastName: "User", + email: "test@example.com", + isOnboarded: true, + timezone: null, + settings: null, + }) satisfies Schema.Schema.Type + +const makeAuthorizationLayer = (currentUser = makeCurrentUser()) => + Layer.succeed( + CurrentUser.Authorization, + CurrentUser.Authorization.of({ + bearer: (httpEffect) => Effect.provideService(httpEffect, CurrentUser.Context, currentUser), + }), + ) + +const makeDatabaseLayer = () => + Layer.succeed( + Database.Database, + serviceShape({ + transaction: (effect: Effect.Effect) => + Effect.provideService(effect, Database.TransactionContext, { + execute: (fn) => + Effect.promise(() => + fn({ + execute: async () => [{ txid: "42" }], + } as never), + ), + }), + }), + ) + +const makeHandler = (api: any, routeLayer: any) => { + const appLayer = HttpApiBuilder.layer(api).pipe( + Layer.provideMerge(routeLayer), + Layer.provideMerge(HttpRouter.layer), + Layer.provideMerge(Etag.layer), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(NodeHttpPlatform.layer), + ) + + return HttpRouter.toWebHandler(appLayer as never, { + disableLogger: true, + }) +} + +describe("HTTP success response encoding", () => { + it("returns HTTP 200 with a decodable GenerateMockDataResponse", async () => { + const api = HttpApi.make("HazelApp").add(MockDataGroup) + const routeLayer = HttpMockDataLive.pipe( + Layer.provideMerge( + Layer.succeed( + MockDataGenerator, + serviceShape({ + generateForMarketingScreenshots: () => + Effect.succeed({ + summary: { + users: 1, + channels: 2, + channelSections: 3, + messages: 4, + organizationMembers: 5, + channelMembers: 6, + threads: 7, + }, + }), + }), + ), + ), + Layer.provideMerge(makeDatabaseLayer()), + Layer.provideMerge(makeAuthorizationLayer()), + ) + + const { handler, dispose } = makeHandler(api, routeLayer) + + try { + const response = await handler( + new Request("http://localhost/mock-data/generate", { + method: "POST", + headers: { + authorization: "Bearer test-token", + "content-type": "application/json", + }, + body: JSON.stringify( + new GenerateMockDataRequest({ + organizationId: "00000000-0000-4000-8000-000000000123", + }), + ), + }), + ServiceMap.empty() as ServiceMap.ServiceMap, + ) + + if (response.status !== 200) { + throw new Error(await response.text()) + } + + const body = await response.json() + const decoded = Schema.decodeUnknownSync(GenerateMockDataResponse)(body) + + expect(decoded.transactionId).toBeDefined() + expect(decoded.created.messages).toBe(4) + } finally { + await dispose() + } + }) + + it("returns HTTP 200 with a decodable MarkOfflineResponse", async () => { + const api = HttpApi.make("HazelApp").add(PresencePublicGroup) + const routeLayer = HttpPresencePublicLive.pipe( + Layer.provideMerge( + Layer.succeed( + UserPresenceStatusRepo, + serviceShape({ + updateStatus: () => Effect.void, + }), + ), + ), + Layer.provideMerge(makeDatabaseLayer()), + ) + + const { handler, dispose } = makeHandler(api, routeLayer) + + try { + const response = await handler( + new Request("http://localhost/presence/offline", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify( + new MarkOfflinePayload({ + userId: UserId.makeUnsafe("00000000-0000-4000-8000-000000000222"), + }), + ), + }), + ServiceMap.empty() as ServiceMap.ServiceMap, + ) + + if (response.status !== 200) { + throw new Error(await response.text()) + } + + const body = await response.json() + const decoded = Schema.decodeUnknownSync(MarkOfflineResponse)(body) + + expect(decoded.success).toBe(true) + } finally { + await dispose() + } + }) + + it("returns HTTP 200 with a decodable ValidateBotTokenResponse", async () => { + const api = HttpApi.make("HazelApp").add(InternalApiGroup) + const routeLayer = HttpInternalLive.pipe( + Layer.provideMerge( + Layer.succeed( + BotRepo, + serviceShape({ + findByTokenHash: () => + Effect.succeed( + Option.some({ + id: BotId.makeUnsafe("00000000-0000-4000-8000-000000000456"), + userId: UserId.makeUnsafe("00000000-0000-4000-8000-000000000333"), + scopes: ["messages:write"], + }), + ), + }), + ), + ), + Layer.provideMerge(configLayer({})), + ) + + const { handler, dispose } = makeHandler(api, routeLayer) + + try { + const response = await handler( + new Request("http://localhost/internal/actors/validate-bot-token", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify( + new ValidateBotTokenRequest({ + token: "hzl_bot_valid_token", + }), + ), + }), + ServiceMap.empty() as ServiceMap.ServiceMap, + ) + + if (response.status !== 200) { + throw new Error(await response.text()) + } + + const body = await response.json() + const decoded = Schema.decodeUnknownSync(ValidateBotTokenResponse)(body) + + expect(decoded.userId).toBe("00000000-0000-4000-8000-000000000333") + expect(decoded.scopes).toEqual(["messages:write"]) + } finally { + await dispose() + } + }) +}) diff --git a/apps/backend/src/routes/incoming-webhooks.http.ts b/apps/backend/src/routes/incoming-webhooks.http.ts index 5fcd68e77..a5c103dee 100644 --- a/apps/backend/src/routes/incoming-webhooks.http.ts +++ b/apps/backend/src/routes/incoming-webhooks.http.ts @@ -1,5 +1,5 @@ import { createHash, timingSafeEqual } from "node:crypto" -import { HttpApiBuilder } from "@effect/platform" +import { HttpApiBuilder } from "effect/unstable/httpapi" import { ChannelWebhookRepo, MessageOutboxRepo, MessageRepo } from "@hazel/backend-core" import { Database } from "@hazel/db" import type { MessageEmbed as DbMessageEmbed } from "@hazel/db" @@ -48,9 +48,9 @@ const convertEmbedToDb = (embed: MessageEmbed.MessageEmbed): DbMessageEmbed => ( export const HttpIncomingWebhookLive = HttpApiBuilder.group(HazelApi, "incoming-webhooks", (handlers) => handlers - .handle("execute", ({ path, payload }) => + .handle("execute", ({ params, payload }) => Effect.gen(function* () { - const { webhookId, token } = path + const { webhookId, token } = params const db = yield* Database.Database const webhookRepo = yield* ChannelWebhookRepo const messageRepo = yield* MessageRepo @@ -154,7 +154,7 @@ export const HttpIncomingWebhookLive = HttpApiBuilder.group(HazelApi, "incoming- detail: String(error), }), ), - ParseError: (error: unknown) => + SchemaError: (error: unknown) => Effect.fail( new InternalServerError({ message: "Invalid request data", @@ -164,9 +164,9 @@ export const HttpIncomingWebhookLive = HttpApiBuilder.group(HazelApi, "incoming- }), ), ) - .handle("executeOpenStatus", ({ path, payload }) => + .handle("executeOpenStatus", ({ params, payload }) => Effect.gen(function* () { - const { webhookId, token } = path + const { webhookId, token } = params const db = yield* Database.Database const webhookRepo = yield* ChannelWebhookRepo const messageRepo = yield* MessageRepo @@ -257,7 +257,7 @@ export const HttpIncomingWebhookLive = HttpApiBuilder.group(HazelApi, "incoming- detail: String(error), }), ), - ParseError: (error: unknown) => + SchemaError: (error: unknown) => Effect.fail( new InternalServerError({ message: "Invalid request data", @@ -267,9 +267,9 @@ export const HttpIncomingWebhookLive = HttpApiBuilder.group(HazelApi, "incoming- }), ), ) - .handle("executeRailway", ({ path, payload }) => + .handle("executeRailway", ({ params, payload }) => Effect.gen(function* () { - const { webhookId, token } = path + const { webhookId, token } = params const db = yield* Database.Database const webhookRepo = yield* ChannelWebhookRepo const messageRepo = yield* MessageRepo @@ -357,7 +357,7 @@ export const HttpIncomingWebhookLive = HttpApiBuilder.group(HazelApi, "incoming- detail: String(error), }), ), - ParseError: (error: unknown) => + SchemaError: (error: unknown) => Effect.fail( new InternalServerError({ message: "Invalid request data", diff --git a/apps/backend/src/routes/integration-commands.http.ts b/apps/backend/src/routes/integration-commands.http.ts index ba910ec03..c9ac4ba88 100644 --- a/apps/backend/src/routes/integration-commands.http.ts +++ b/apps/backend/src/routes/integration-commands.http.ts @@ -1,4 +1,4 @@ -import { HttpApiBuilder } from "@effect/platform" +import { HttpApiBuilder } from "effect/unstable/httpapi" import { BotCommandRepo, BotInstallationRepo, BotRepo } from "@hazel/backend-core" import { InternalServerError } from "@hazel/domain" import { AvailableCommandsResponse } from "@hazel/domain/http" @@ -8,9 +8,9 @@ import { HazelApi } from "../api" export const HttpIntegrationCommandLive = HttpApiBuilder.group(HazelApi, "integration-commands", (handlers) => handlers // Get all available commands for the current organization's installed bots - .handle("getAvailableCommands", ({ path }) => + .handle("getAvailableCommands", ({ params }) => Effect.gen(function* () { - const { orgId } = path + const { orgId } = params const botInstallationRepo = yield* BotInstallationRepo const botCommandRepo = yield* BotCommandRepo diff --git a/apps/backend/src/routes/integration-resources.http.ts b/apps/backend/src/routes/integration-resources.http.ts index 21ee5d55f..7da96b359 100644 --- a/apps/backend/src/routes/integration-resources.http.ts +++ b/apps/backend/src/routes/integration-resources.http.ts @@ -1,4 +1,4 @@ -import { HttpApiBuilder } from "@effect/platform" +import { HttpApiBuilder } from "effect/unstable/httpapi" import { IntegrationConnectionRepo } from "@hazel/backend-core" import { InternalServerError } from "@hazel/domain" import type { ExternalChannelId, OrganizationId } from "@hazel/schema" @@ -47,10 +47,10 @@ export const HttpIntegrationResourceLive = HttpApiBuilder.group( "integration-resources", (handlers) => handlers - .handle("fetchLinearIssue", ({ path, urlParams }) => + .handle("fetchLinearIssue", ({ params, query }) => Effect.gen(function* () { - const { orgId } = path - const { url } = urlParams + const { orgId } = params + const { url } = query // Parse the Linear issue URL const parsed = parseLinearIssueUrl(url) @@ -115,7 +115,7 @@ export const HttpIntegrationResourceLive = HttpApiBuilder.group( LinearApiError: (error: LinearApiError) => Effect.fail( new IntegrationResourceError({ - url: urlParams.url, + url: query.url, message: error.message, provider: "linear", }), @@ -123,7 +123,7 @@ export const HttpIntegrationResourceLive = HttpApiBuilder.group( LinearRateLimitError: (error: LinearRateLimitError) => Effect.fail( new IntegrationResourceError({ - url: urlParams.url, + url: query.url, message: error.retryAfter ? `Rate limit exceeded. Try again in ${error.retryAfter} seconds.` : error.message, @@ -133,7 +133,7 @@ export const HttpIntegrationResourceLive = HttpApiBuilder.group( LinearIssueNotFoundError: (error: LinearIssueNotFoundError) => Effect.fail( new ResourceNotFoundError({ - url: urlParams.url, + url: query.url, message: `Issue not found: ${error.issueId}`, }), ), @@ -156,10 +156,10 @@ export const HttpIntegrationResourceLive = HttpApiBuilder.group( }), ), ) - .handle("fetchGitHubPR", ({ path, urlParams }) => + .handle("fetchGitHubPR", ({ params, query }) => Effect.gen(function* () { - const { orgId } = path - const { url } = urlParams + const { orgId } = params + const { url } = query // Parse the GitHub PR URL const parsed = GitHub.parseGitHubPRUrl(url) @@ -205,7 +205,7 @@ export const HttpIntegrationResourceLive = HttpApiBuilder.group( Effect.catchTag("GitHubPRNotFoundError", (error) => Effect.fail( new ResourceNotFoundError({ - url: urlParams.url, + url: query.url, message: `PR not found: ${error.owner}/${error.repo}#${error.number}`, }), ), @@ -214,7 +214,7 @@ export const HttpIntegrationResourceLive = HttpApiBuilder.group( Effect.catchTag("GitHubApiError", (error) => Effect.fail( new IntegrationResourceError({ - url: urlParams.url, + url: query.url, message: error.message, provider: "github", }), @@ -223,7 +223,7 @@ export const HttpIntegrationResourceLive = HttpApiBuilder.group( Effect.catchTag("GitHubRateLimitError", (error) => Effect.fail( new IntegrationResourceError({ - url: urlParams.url, + url: query.url, message: error.retryAfter ? `Rate limit exceeded. Try again in ${error.retryAfter} seconds.` : error.message, @@ -259,7 +259,7 @@ export const HttpIntegrationResourceLive = HttpApiBuilder.group( Effect.catchTag("GitHubRateLimitError", (error) => Effect.fail( new IntegrationResourceError({ - url: urlParams.url, + url: query.url, message: error.message, provider: "github", }), @@ -268,7 +268,7 @@ export const HttpIntegrationResourceLive = HttpApiBuilder.group( Effect.catchTag("GitHubApiError", (error) => Effect.fail( new IntegrationResourceError({ - url: urlParams.url, + url: query.url, message: error.message, provider: "github", }), @@ -285,8 +285,8 @@ export const HttpIntegrationResourceLive = HttpApiBuilder.group( ), ), ) - .handle("getGitHubRepositories", ({ path, urlParams }) => - handleGetGitHubRepositories(path, urlParams).pipe( + .handle("getGitHubRepositories", ({ params, query }) => + handleGetGitHubRepositories(params, query).pipe( Effect.catchTags({ DatabaseError: (error) => Effect.fail( @@ -308,8 +308,8 @@ export const HttpIntegrationResourceLive = HttpApiBuilder.group( }), ), ) - .handle("getDiscordGuilds", ({ path }) => - handleGetDiscordGuilds(path).pipe( + .handle("getDiscordGuilds", ({ params }) => + handleGetDiscordGuilds(params).pipe( Effect.catchTags({ DatabaseError: (error) => Effect.fail( @@ -331,8 +331,8 @@ export const HttpIntegrationResourceLive = HttpApiBuilder.group( }), ), ) - .handle("getDiscordGuildChannels", ({ path }) => - handleGetDiscordGuildChannels(path).pipe( + .handle("getDiscordGuildChannels", ({ params }) => + handleGetDiscordGuildChannels(params).pipe( Effect.catchTags({ DatabaseError: (error) => Effect.fail( @@ -341,6 +341,13 @@ export const HttpIntegrationResourceLive = HttpApiBuilder.group( detail: String(error), }), ), + ConfigError: (error) => + Effect.fail( + new InternalServerError({ + message: "Discord bot token not configured", + detail: String(error), + }), + ), }), ), ), @@ -351,10 +358,11 @@ export const HttpIntegrationResourceLive = HttpApiBuilder.group( */ const handleGetGitHubRepositories = Effect.fn("integration-resources.getGitHubRepositories")(function* ( path: { orgId: OrganizationId }, - urlParams: { page: number; perPage: number }, + query: { page?: number; perPage?: number }, ) { const { orgId } = path - const { page, perPage } = urlParams + const page = query.page ?? 1 + const perPage = query.perPage ?? 30 const connectionRepo = yield* IntegrationConnectionRepo const tokenService = yield* IntegrationTokenService @@ -435,8 +443,8 @@ const handleGetDiscordGuilds = Effect.fn("integration-resources.getDiscordGuilds const connection = yield* getActiveDiscordConnection(orgId) const accessToken = yield* tokenService.getValidAccessToken(connection.id) - const guilds = yield* Discord.DiscordApiClient.listGuilds(accessToken).pipe( - Effect.provide(Discord.DiscordApiClient.Default), + const discordApiClient = yield* Discord.DiscordApiClient + const guilds = yield* discordApiClient.listGuilds(accessToken).pipe( Effect.mapError( (error) => new IntegrationResourceError({ @@ -455,21 +463,9 @@ const handleGetDiscordGuildChannels = Effect.fn("integration-resources.getDiscor const { orgId, guildId } = path yield* getActiveDiscordConnection(orgId) - const botToken = yield* Config.redacted("DISCORD_BOT_TOKEN").pipe( - Effect.mapError( - (error) => - new InternalServerError({ - message: "Discord bot token is not configured", - detail: String(error), - }), - ), - ) - - const channels = yield* Discord.DiscordApiClient.listGuildChannels( - guildId, - Redacted.value(botToken), - ).pipe( - Effect.provide(Discord.DiscordApiClient.Default), + const botToken = yield* Config.redacted("DISCORD_BOT_TOKEN") + const discordApiClient = yield* Discord.DiscordApiClient + const channels = yield* discordApiClient.listGuildChannels(guildId, Redacted.value(botToken)).pipe( Effect.mapError( (error) => new IntegrationResourceError({ @@ -481,7 +477,10 @@ const handleGetDiscordGuildChannels = Effect.fn("integration-resources.getDiscor ) return new DiscordGuildChannelsResponse({ - channels: channels.map((c) => ({ ...c, id: c.id as ExternalChannelId })), + channels: channels.map((c) => ({ + ...c, + id: c.id as ExternalChannelId, + })), }) }, ) diff --git a/apps/backend/src/routes/integrations.http.test.ts b/apps/backend/src/routes/integrations.http.test.ts index d65b74bc6..ece39ad3d 100644 --- a/apps/backend/src/routes/integrations.http.test.ts +++ b/apps/backend/src/routes/integrations.http.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest" import { CraftApiError, CraftNotFoundError, CraftRateLimitError } from "@hazel/integrations/craft" -import { Effect, Either } from "effect" +import { Effect, Result } from "effect" import { mapCraftConnectApiKeyError, parseOAuthStateParam, @@ -8,10 +8,10 @@ import { } from "./integrations.http.ts" const expectInvalidBaseUrl = async (url: string) => { - const result = await Effect.runPromise(validateCraftBaseUrl(url).pipe(Effect.either)) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(result.left._tag).toBe("InvalidApiKeyError") + const result = await Effect.runPromise(validateCraftBaseUrl(url).pipe(Effect.result)) + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + expect(result.failure._tag).toBe("InvalidApiKeyError") } } @@ -64,12 +64,12 @@ describe("integration connect API key helpers", () => { describe("validateCraftBaseUrl", () => { it("accepts and normalizes a valid Craft connect URL", async () => { const result = await Effect.runPromise( - validateCraftBaseUrl("https://connect.craft.do/links/link_123/api/v1/").pipe(Effect.either), + validateCraftBaseUrl("https://connect.craft.do/links/link_123/api/v1/").pipe(Effect.result), ) - expect(Either.isRight(result)).toBe(true) - if (Either.isRight(result)) { - expect(result.right).toBe("https://connect.craft.do/links/link_123/api/v1") + expect(Result.isSuccess(result)).toBe(true) + if (Result.isSuccess(result)) { + expect(result.success).toBe("https://connect.craft.do/links/link_123/api/v1") } }) diff --git a/apps/backend/src/routes/integrations.http.ts b/apps/backend/src/routes/integrations.http.ts index a2bac4284..99182ae3c 100644 --- a/apps/backend/src/routes/integrations.http.ts +++ b/apps/backend/src/routes/integrations.http.ts @@ -1,5 +1,5 @@ -import { HttpApiBuilder, HttpServerRequest, HttpServerResponse } from "@effect/platform" -import * as Cookies from "@effect/platform/Cookies" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { HttpServerRequest, HttpServerResponse, Cookies } from "effect/unstable/http" import { IntegrationConnectionRepo, OrganizationRepo } from "@hazel/backend-core" import { CurrentUser, InternalServerError, UnauthorizedError } from "@hazel/domain" import type { OrganizationId, UserId } from "@hazel/schema" @@ -18,7 +18,7 @@ import { CraftRateLimitError, } from "@hazel/integrations/craft" import type { IntegrationConnection } from "@hazel/domain/models" -import { Config, Effect, Layer, Option, Schedule, Schema } from "effect" +import { Config, DateTime, Effect, Layer, Option, Schedule, Schema, SchemaGetter } from "effect" import * as Duration from "effect/Duration" import { HazelApi } from "../api" import { ChatSyncAttributionReconciler } from "../services/chat-sync/chat-sync-attribution-reconciler" @@ -33,20 +33,23 @@ import { OAuthProviderRegistry } from "../services/oauth" const OAuthState = Schema.Struct({ organizationId: Schema.String, userId: Schema.String, - level: Schema.optionalWith(Schema.Literal("organization", "user"), { - default: () => "organization", - }), + level: Schema.optional(Schema.Literals(["organization", "user"])).pipe( + Schema.decodeTo(Schema.toType(Schema.Literals(["organization", "user"])), { + decode: SchemaGetter.withDefault((): "organization" | "user" => "organization"), + encode: SchemaGetter.required(), + }), + ), /** Full URL to redirect after OAuth completes (e.g., http://localhost:3000/org/settings/integrations/github) */ returnTo: Schema.String, /** Environment that initiated the OAuth flow. Used to redirect back to localhost for local dev. */ - environment: Schema.optional(Schema.Literal("local", "production")), + environment: Schema.optionalKey(Schema.Literals(["local", "production"])), }) /** * Retry schedule for OAuth operations. * Retries up to 3 times with exponential backoff (100ms, 200ms, 400ms) */ -const oauthRetrySchedule = Schedule.exponential("100 millis").pipe(Schedule.intersect(Schedule.recurs(3))) +const oauthRetrySchedule = Schedule.exponential("100 millis").pipe(Schedule.both(Schedule.recurs(3))) const CRAFT_ALLOWED_HOST = "connect.craft.do" const CRAFT_BASE_URL_PATH_PATTERN = /^\/links\/[^/]+\/api\/v1$/ @@ -248,7 +251,7 @@ const makeOAuthSessionCookie = ( ) => Effect.try({ try: () => - Cookies.unsafeMakeCookie(name, value, { + Cookies.makeCookieUnsafe(name, value, { domain: options.cookieDomain, path: "/", httpOnly: true, @@ -264,7 +267,7 @@ const makeOAuthSessionCookie = ( }) const expireOAuthSessionCookie = (name: string, options: { cookieDomain: string; secure: boolean }) => - HttpServerResponse.expireCookie(name, { + HttpServerResponse.expireCookieUnsafe(name, { domain: options.cookieDomain, path: "/", httpOnly: true, @@ -281,11 +284,11 @@ const handleGetOAuthUrl = Effect.fn("integrations.getOAuthUrl")(function* ( orgId: OrganizationId provider: IntegrationConnection.IntegrationProvider }, - urlParams: { level?: IntegrationConnection.ConnectionLevel }, + query: { level?: IntegrationConnection.ConnectionLevel }, ) { const currentUser = yield* CurrentUser.Context const { orgId, provider } = path - const level = urlParams.level ?? "organization" + const level = query.level ?? "organization" if (!currentUser.organizationId || currentUser.organizationId !== orgId) { return yield* Effect.fail( @@ -308,8 +311,8 @@ const handleGetOAuthUrl = Effect.fn("integrations.getOAuthUrl")(function* ( ), ) - const frontendUrl = yield* Config.string("FRONTEND_URL").pipe(Effect.orDie) - const cookieDomain = yield* Config.string("WORKOS_COOKIE_DOMAIN").pipe(Effect.orDie) + const frontendUrl = yield* Config.string("FRONTEND_URL") + const cookieDomain = yield* Config.string("WORKOS_COOKIE_DOMAIN") // Get org slug for redirect URL const orgRepo = yield* OrganizationRepo @@ -335,7 +338,7 @@ const handleGetOAuthUrl = Effect.fn("integrations.getOAuthUrl")(function* ( // Determine environment from NODE_ENV config // Local dev uses "local" so production can redirect callbacks back to localhost - const nodeEnv = yield* Config.string("NODE_ENV").pipe(Config.withDefault("production"), Effect.orDie) + const nodeEnv = yield* Config.string("NODE_ENV").pipe(Config.withDefault("production")) const environment = nodeEnv === "development" ? "local" : "production" const cookieSecure = nodeEnv !== "development" @@ -414,11 +417,14 @@ const handleGetOAuthUrl = Effect.fn("integrations.getOAuthUrl")(function* ( const OAuthSessionState = Schema.Struct({ organizationId: Schema.String, userId: Schema.String, - level: Schema.optionalWith(Schema.Literal("organization", "user"), { - default: () => "organization", - }), + level: Schema.optional(Schema.Literals(["organization", "user"])).pipe( + Schema.decodeTo(Schema.toType(Schema.Literals(["organization", "user"])), { + decode: SchemaGetter.withDefault((): "organization" | "user" => "organization"), + encode: SchemaGetter.required(), + }), + ), returnTo: Schema.String, - environment: Schema.optional(Schema.Literal("local", "production")), + environment: Schema.optionalKey(Schema.Literals(["local", "production"])), createdAt: Schema.Number, }) @@ -436,7 +442,7 @@ const OAuthSessionState = Schema.Struct({ */ const handleOAuthCallback = Effect.fn("integrations.oauthCallback")(function* ( path: { provider: IntegrationConnection.IntegrationProvider }, - urlParams: { + query: { code?: string state?: string guild_id?: string @@ -446,14 +452,14 @@ const handleOAuthCallback = Effect.fn("integrations.oauthCallback")(function* ( }, ) { const { provider } = path - const { code, state: encodedState, installation_id, setup_action, guild_id, permissions } = urlParams + const { code, state: encodedState, installation_id, setup_action, guild_id, permissions } = query // Get request to read cookies const request = yield* HttpServerRequest.HttpServerRequest const sessionCookieName = `${OAUTH_SESSION_COOKIE_PREFIX}${provider}` const sessionCookie = request.cookies[sessionCookieName] - const cookieDomain = yield* Config.string("WORKOS_COOKIE_DOMAIN").pipe(Effect.orDie) - const nodeEnv = yield* Config.string("NODE_ENV").pipe(Config.withDefault("production"), Effect.orDie) + const cookieDomain = yield* Config.string("WORKOS_COOKIE_DOMAIN") + const nodeEnv = yield* Config.string("NODE_ENV").pipe(Config.withDefault("production")) const cookieSecure = nodeEnv !== "development" yield* Effect.logInfo("OAuth callback received", { @@ -540,7 +546,7 @@ const handleOAuthCallback = Effect.fn("integrations.oauthCallback")(function* ( if (!parsedState && installation_id && setup_action === "update") { const connectionRepo = yield* IntegrationConnectionRepo const orgRepo = yield* OrganizationRepo - const frontendUrl = yield* Config.string("FRONTEND_URL").pipe(Effect.orDie) + const frontendUrl = yield* Config.string("FRONTEND_URL") yield* Effect.logInfo("GitHub update callback - looking up by installation ID", { event: "integration_callback_installation_lookup", @@ -707,11 +713,11 @@ const handleOAuthCallback = Effect.fn("integrations.oauthCallback")(function* ( schedule: oauthRetrySchedule, while: isRetryableError, }), - Effect.either, + Effect.result, ) - if (tokensResult._tag === "Left") { - const error = tokensResult.left + if (tokensResult._tag === "Failure") { + const error = tokensResult.failure yield* Effect.logError("OAuth token exchange failed", { event: "integration_token_exchange_failed", provider, @@ -721,7 +727,7 @@ const handleOAuthCallback = Effect.fn("integrations.oauthCallback")(function* ( return redirectWithError("token_exchange_failed") } - const tokens = tokensResult.right + const tokens = tokensResult.success yield* Effect.logInfo("OAuth token exchange succeeded", { event: "integration_token_exchange_success", provider, @@ -740,11 +746,11 @@ const handleOAuthCallback = Effect.fn("integrations.oauthCallback")(function* ( schedule: oauthRetrySchedule, while: isRetryableError, }), - Effect.either, + Effect.result, ) - if (accountInfoResult._tag === "Left") { - const error = accountInfoResult.left + if (accountInfoResult._tag === "Failure") { + const error = accountInfoResult.failure yield* Effect.logError("OAuth account info fetch failed", { event: "integration_account_info_failed", provider, @@ -753,7 +759,7 @@ const handleOAuthCallback = Effect.fn("integrations.oauthCallback")(function* ( return redirectWithError("account_info_failed") } - const accountInfo = accountInfoResult.right + const accountInfo = accountInfoResult.success yield* Effect.logDebug("OAuth account info fetch succeeded", { event: "integration_account_info_success", provider, @@ -812,18 +818,18 @@ const handleOAuthCallback = Effect.fn("integrations.oauthCallback")(function* ( lastUsedAt: null, deletedAt: null, }) - ).pipe(Effect.either) + ).pipe(Effect.result) - if (connectionResult._tag === "Left") { + if (connectionResult._tag === "Failure") { yield* Effect.logError("OAuth database upsert failed", { event: "integration_db_upsert_failed", provider, - error: String(connectionResult.left), + error: String(connectionResult.failure), }) return redirectWithError("db_error") } - const connection = connectionResult.right + const connection = connectionResult.success yield* Effect.logDebug("OAuth database upsert succeeded", { event: "integration_db_upsert_success", provider, @@ -844,14 +850,14 @@ const handleOAuthCallback = Effect.fn("integrations.oauthCallback")(function* ( expiresAt: tokens.expiresAt, scope: tokens.scope, }) - .pipe(Effect.either) + .pipe(Effect.result) - if (storeResult._tag === "Left") { + if (storeResult._tag === "Failure") { yield* Effect.logError("OAuth token storage failed", { event: "integration_token_storage_failed", provider, connectionId: connection.id, - error: String(storeResult.left), + error: String(storeResult.failure), }) return redirectWithError("encryption_error") } @@ -876,9 +882,9 @@ const handleOAuthCallback = Effect.fn("integrations.oauthCallback")(function* ( externalAccountId: accountInfo.externalAccountId, externalAccountName: accountInfo.externalAccountName, }) - .pipe(Effect.either) + .pipe(Effect.result) - if (reconcileResult._tag === "Left") { + if (reconcileResult._tag === "Failure") { yield* Effect.logWarning( "Failed to re-attribute historical external messages after account link", { @@ -887,7 +893,7 @@ const handleOAuthCallback = Effect.fn("integrations.oauthCallback")(function* ( organizationId: parsedState.organizationId, userId: parsedState.userId, externalAccountId: accountInfo.externalAccountId, - error: String(reconcileResult.left), + error: String(reconcileResult.failure), }, ) } @@ -907,7 +913,8 @@ const handleOAuthCallback = Effect.fn("integrations.oauthCallback")(function* ( if (parsedState.level === "organization") { // Add seeded bot to org for org-level OAuth integration providers. - yield* IntegrationBotService.addBotToOrg(provider, parsedState.organizationId as OrganizationId).pipe( + const integrationBotService = yield* IntegrationBotService + yield* integrationBotService.addBotToOrg(provider, parsedState.organizationId as OrganizationId).pipe( Effect.tap((result) => Option.isSome(result) ? Effect.logInfo("Integration bot added to organization", { @@ -923,7 +930,7 @@ const handleOAuthCallback = Effect.fn("integrations.oauthCallback")(function* ( ), // Note: catchAll is intentional here - this is a best-effort operation // after OAuth success. We catch all errors to prevent disrupting the flow. - Effect.catchAll((error) => + Effect.catch((error) => Effect.logWarning("Failed to add integration bot to org (non-critical)", { event: "integration_bot_add_failed", provider, @@ -969,9 +976,10 @@ const handleConnectApiKey = Effect.fn("integrations.connectApiKey")(function* ( if (provider === "craft") { validatedBaseUrl = yield* validateCraftBaseUrl(baseUrl) const parsedBaseUrl = new URL(validatedBaseUrl) - const spaceInfo = yield* CraftApiClient.getSpaceInfo(validatedBaseUrl, token).pipe( + const craftApiClient = yield* CraftApiClient + const spaceInfo = yield* craftApiClient.getSpaceInfo(validatedBaseUrl, token).pipe( Effect.catchTags({ - CraftApiError: (error) => + CraftApiError: (error: CraftApiError) => Effect.logWarning("Craft API key validation failed", { event: "craft_api_key_validation_failed", provider, @@ -979,8 +987,8 @@ const handleConnectApiKey = Effect.fn("integrations.connectApiKey")(function* ( baseUrlHost: parsedBaseUrl.hostname, baseUrlPath: parsedBaseUrl.pathname, ...craftConnectApiKeyErrorLogFields(error), - }).pipe(Effect.zipRight(Effect.fail(mapCraftConnectApiKeyError(error)))), - CraftNotFoundError: (error) => + }).pipe(Effect.andThen(Effect.fail(mapCraftConnectApiKeyError(error)))), + CraftNotFoundError: (error: CraftNotFoundError) => Effect.logWarning("Craft API key validation failed", { event: "craft_api_key_validation_failed", provider, @@ -988,8 +996,8 @@ const handleConnectApiKey = Effect.fn("integrations.connectApiKey")(function* ( baseUrlHost: parsedBaseUrl.hostname, baseUrlPath: parsedBaseUrl.pathname, ...craftConnectApiKeyErrorLogFields(error), - }).pipe(Effect.zipRight(Effect.fail(mapCraftConnectApiKeyError(error)))), - CraftRateLimitError: (error) => + }).pipe(Effect.andThen(Effect.fail(mapCraftConnectApiKeyError(error)))), + CraftRateLimitError: (error: CraftRateLimitError) => Effect.logWarning("Craft API key validation failed", { event: "craft_api_key_validation_failed", provider, @@ -997,7 +1005,7 @@ const handleConnectApiKey = Effect.fn("integrations.connectApiKey")(function* ( baseUrlHost: parsedBaseUrl.hostname, baseUrlPath: parsedBaseUrl.pathname, ...craftConnectApiKeyErrorLogFields(error), - }).pipe(Effect.zipRight(Effect.fail(mapCraftConnectApiKeyError(error)))), + }).pipe(Effect.andThen(Effect.fail(mapCraftConnectApiKeyError(error)))), }), ) externalAccountName = spaceInfo.name ?? "Craft Space" @@ -1041,8 +1049,9 @@ const handleConnectApiKey = Effect.fn("integrations.connectApiKey")(function* ( }) // Best-effort: add integration bot to org - yield* IntegrationBotService.addBotToOrg(provider, orgId).pipe( - Effect.catchAll((error) => + const integrationBotService = yield* IntegrationBotService + yield* integrationBotService.addBotToOrg(provider, orgId).pipe( + Effect.catch((error) => Effect.logWarning("Failed to add integration bot to org (non-critical)", { event: "integration_bot_add_failed", provider, @@ -1067,12 +1076,12 @@ const handleGetConnectionStatus = Effect.fn("integrations.getConnectionStatus")( orgId: OrganizationId provider: IntegrationConnection.IntegrationProvider }, - urlParams: { level?: IntegrationConnection.ConnectionLevel }, + query: { level?: IntegrationConnection.ConnectionLevel }, ) { const { orgId, provider } = path const currentUser = yield* CurrentUser.Context const connectionRepo = yield* IntegrationConnectionRepo - const level = urlParams.level ?? "organization" + const level = query.level ?? "organization" if (!currentUser.organizationId || currentUser.organizationId !== orgId) { return yield* Effect.fail( @@ -1104,8 +1113,8 @@ const handleGetConnectionStatus = Effect.fn("integrations.getConnectionStatus")( provider, externalAccountName: connection.externalAccountName, status: connection.status, - connectedAt: connection.createdAt ?? null, - lastUsedAt: connection.lastUsedAt ?? null, + connectedAt: connection.createdAt ? DateTime.fromDateUnsafe(connection.createdAt) : null, + lastUsedAt: connection.lastUsedAt ? DateTime.fromDateUnsafe(connection.lastUsedAt) : null, }) }) @@ -1117,14 +1126,14 @@ const handleDisconnect = Effect.fn("integrations.disconnect")(function* ( orgId: OrganizationId provider: IntegrationConnection.IntegrationProvider }, - urlParams: { level?: IntegrationConnection.ConnectionLevel }, + query: { level?: IntegrationConnection.ConnectionLevel }, ) { const { orgId, provider } = path const currentUser = yield* CurrentUser.Context const connectionRepo = yield* IntegrationConnectionRepo const tokenService = yield* IntegrationTokenService const chatSyncAttributionReconciler = yield* ChatSyncAttributionReconciler - const level = urlParams.level ?? "organization" + const level = query.level ?? "organization" if (!currentUser.organizationId || currentUser.organizationId !== orgId) { return yield* Effect.fail( @@ -1160,9 +1169,9 @@ const handleDisconnect = Effect.fn("integrations.disconnect")(function* ( externalAccountId, externalAccountName: connection.externalAccountName, }) - .pipe(Effect.either) + .pipe(Effect.result) - if (reconcileResult._tag === "Left") { + if (reconcileResult._tag === "Failure") { yield* Effect.logWarning( "Failed to re-attribute historical external messages after account unlink", { @@ -1171,7 +1180,7 @@ const handleDisconnect = Effect.fn("integrations.disconnect")(function* ( organizationId: orgId, userId: currentUser.id, externalAccountId, - error: String(reconcileResult.left), + error: String(reconcileResult.failure), }, ) } @@ -1197,9 +1206,17 @@ const handleDisconnect = Effect.fn("integrations.disconnect")(function* ( export const HttpIntegrationLive = HttpApiBuilder.group(HazelApi, "integrations", (handlers) => handlers - .handle("getOAuthUrl", ({ path, urlParams }) => handleGetOAuthUrl(path, urlParams)) - .handle("oauthCallback", ({ path, urlParams }) => - handleOAuthCallback(path, urlParams).pipe( + .handle("getOAuthUrl", ({ params, query }) => + handleGetOAuthUrl(params, query).pipe( + Effect.catchTag("ConfigError", (err) => + Effect.fail( + new InternalServerError({ message: "Missing configuration", detail: String(err) }), + ), + ), + ), + ) + .handle("oauthCallback", ({ params, query }) => + handleOAuthCallback(params, query).pipe( Effect.catchTag("DatabaseError", (error) => Effect.fail( new InternalServerError({ @@ -1208,26 +1225,34 @@ export const HttpIntegrationLive = HttpApiBuilder.group(HazelApi, "integrations" }), ), ), + Effect.catchTag("ConfigError", (err) => + Effect.fail( + new InternalServerError({ message: "Missing configuration", detail: String(err) }), + ), + ), ), ) - .handle("connectApiKey", ({ path, payload }) => - handleConnectApiKey(path, payload).pipe( + .handle("connectApiKey", ({ params, payload }) => + handleConnectApiKey(params, payload).pipe( Effect.catchTags({ - DatabaseError: (error) => + DatabaseError: (error: { readonly _tag: "DatabaseError"; readonly message?: string }) => Effect.fail( new InternalServerError({ message: "Database error during API key connection", detail: String(error), }), ), - ParseError: (error) => + SchemaError: (error: { readonly _tag: "SchemaError"; readonly message?: string }) => Effect.fail( new InternalServerError({ message: "Failed to parse API response", detail: String(error), }), ), - IntegrationEncryptionError: (error) => + IntegrationEncryptionError: (error: { + readonly _tag: "IntegrationEncryptionError" + readonly message?: string + }) => Effect.fail( new InternalServerError({ message: "Failed to encrypt token", @@ -1237,8 +1262,8 @@ export const HttpIntegrationLive = HttpApiBuilder.group(HazelApi, "integrations" }), ), ) - .handle("getConnectionStatus", ({ path, urlParams }) => - handleGetConnectionStatus(path, urlParams).pipe( + .handle("getConnectionStatus", ({ params, query }) => + handleGetConnectionStatus(params, query).pipe( Effect.catchTag("DatabaseError", (error) => Effect.fail( new InternalServerError({ @@ -1249,8 +1274,8 @@ export const HttpIntegrationLive = HttpApiBuilder.group(HazelApi, "integrations" ), ), ) - .handle("disconnect", ({ path, urlParams }) => - handleDisconnect(path, urlParams).pipe( + .handle("disconnect", ({ params, query }) => + handleDisconnect(params, query).pipe( Effect.catchTag("DatabaseError", (error) => Effect.fail( new InternalServerError({ @@ -1261,4 +1286,4 @@ export const HttpIntegrationLive = HttpApiBuilder.group(HazelApi, "integrations" ), ), ), -).pipe(Layer.provide(CraftApiClient.Default), Layer.provide(IntegrationBotService.Default)) +).pipe(Layer.provide(CraftApiClient.layer), Layer.provide(IntegrationBotService.layer)) diff --git a/apps/backend/src/routes/internal.http.ts b/apps/backend/src/routes/internal.http.ts index af50c83da..754fa4ced 100644 --- a/apps/backend/src/routes/internal.http.ts +++ b/apps/backend/src/routes/internal.http.ts @@ -1,6 +1,8 @@ -import { HttpApiBuilder, HttpServerRequest } from "@effect/platform" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { HttpServerRequest } from "effect/unstable/http" import { BotRepo } from "@hazel/backend-core" import { InvalidBearerTokenError, UnauthorizedError } from "@hazel/domain" +import { ValidateBotTokenResponse } from "@hazel/domain/http" import { Config, Effect, Option } from "effect" import { HazelApi } from "../api" @@ -21,10 +23,10 @@ export const HttpInternalLive = HttpApiBuilder.group(HazelApi, "internal", (hand const request = yield* HttpServerRequest.HttpServerRequest // Optionally verify internal secret for server-to-server auth - const internalSecret = yield* Config.string("INTERNAL_SECRET").pipe( - Effect.option, - Effect.map(Option.getOrUndefined), + const internalSecretOption = yield* Effect.orDie( + Config.string("INTERNAL_SECRET").pipe(Config.option).asEffect(), ) + const internalSecret = Option.getOrUndefined(internalSecretOption) if (internalSecret) { const providedSecret = request.headers["x-internal-secret"] @@ -78,12 +80,12 @@ export const HttpInternalLive = HttpApiBuilder.group(HazelApi, "internal", (hand const bot = botOption.value // Return the bot identity for actor authentication - return { + return new ValidateBotTokenResponse({ userId: bot.userId, botId: bot.id, organizationId: null, // Bot's org is determined by where it's installed, not stored on the bot itself scopes: bot.scopes, - } + }) }), ), ) diff --git a/apps/backend/src/routes/klipy.http.ts b/apps/backend/src/routes/klipy.http.ts index facf24f17..8b56b5b33 100644 --- a/apps/backend/src/routes/klipy.http.ts +++ b/apps/backend/src/routes/klipy.http.ts @@ -1,4 +1,5 @@ -import { HttpApiBuilder, HttpClient } from "@effect/platform" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { HttpClient } from "effect/unstable/http" import { KlipyApiError } from "@hazel/domain/http" import { Config, Effect, Redacted, Schema } from "effect" import { HazelApi } from "../api" @@ -85,29 +86,26 @@ const fetchKlipy = ( return response.json }), Effect.scoped, - Effect.catchTag("RequestError", (error) => + Effect.catchTag("HttpClientError", (error) => Effect.fail(new KlipyApiError({ message: `Klipy request failed: ${String(error)}` })), ), - Effect.catchTag("ResponseError", (error) => - Effect.fail(new KlipyApiError({ message: `Klipy response error: ${String(error)}` })), - ), ) } export const HttpKlipyLive = HttpApiBuilder.group(HazelApi, "klipy", (handlers) => Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient - const apiKeyRedacted = yield* Config.redacted("KLIPY_API_KEY").pipe(Effect.orDie) + const apiKeyRedacted = yield* Config.redacted("KLIPY_API_KEY") const apiKey = Redacted.value(apiKeyRedacted) return handlers - .handle("trending", ({ urlParams }) => + .handle("trending", ({ query }) => Effect.gen(function* () { const raw = yield* fetchKlipy(httpClient, apiKey, "/gifs/trending", { - page: String(urlParams.page), - per_page: String(urlParams.per_page), + page: String(query.page), + per_page: String(query.per_page), }) - const parsed = yield* Schema.decodeUnknown(KlipyRawSearchResponse)(raw).pipe( + const parsed = yield* Schema.decodeUnknownEffect(KlipyRawSearchResponse)(raw).pipe( Effect.mapError( (error) => new KlipyApiError({ @@ -118,14 +116,14 @@ export const HttpKlipyLive = HttpApiBuilder.group(HazelApi, "klipy", (handlers) return parsed.data }), ) - .handle("search", ({ urlParams }) => + .handle("search", ({ query }) => Effect.gen(function* () { const raw = yield* fetchKlipy(httpClient, apiKey, "/gifs/search", { - q: urlParams.q, - page: String(urlParams.page), - per_page: String(urlParams.per_page), + q: query.q, + page: String(query.page), + per_page: String(query.per_page), }) - const parsed = yield* Schema.decodeUnknown(KlipyRawSearchResponse)(raw).pipe( + const parsed = yield* Schema.decodeUnknownEffect(KlipyRawSearchResponse)(raw).pipe( Effect.mapError( (error) => new KlipyApiError({ @@ -141,7 +139,7 @@ export const HttpKlipyLive = HttpApiBuilder.group(HazelApi, "klipy", (handlers) const raw = yield* fetchKlipy(httpClient, apiKey, "/gifs/categories", { locale: "en_US", }) - const parsed = yield* Schema.decodeUnknown(KlipyRawCategoriesResponse)(raw).pipe( + const parsed = yield* Schema.decodeUnknownEffect(KlipyRawCategoriesResponse)(raw).pipe( Effect.mapError( (error) => new KlipyApiError({ diff --git a/apps/backend/src/routes/mock-data.http.ts b/apps/backend/src/routes/mock-data.http.ts index 5e4cd2606..40c0df306 100644 --- a/apps/backend/src/routes/mock-data.http.ts +++ b/apps/backend/src/routes/mock-data.http.ts @@ -1,6 +1,7 @@ -import { HttpApiBuilder } from "@effect/platform" +import { HttpApiBuilder } from "effect/unstable/httpapi" import { Database } from "@hazel/db" import { CurrentUser, withRemapDbErrors } from "@hazel/domain" +import { GenerateMockDataResponse } from "@hazel/domain/http" import { OrganizationId, UserId } from "@hazel/schema" import { Effect } from "effect" import { HazelApi } from "../api" @@ -21,8 +22,8 @@ export const HttpMockDataLive = HttpApiBuilder.group(HazelApi, "mockData", (hand .transaction( Effect.gen(function* () { const result = yield* mockDataService.generateForMarketingScreenshots({ - organizationId: OrganizationId.make(payload.organizationId), - currentUserId: UserId.make(currentUser.id), + organizationId: OrganizationId.makeUnsafe(payload.organizationId), + currentUserId: UserId.makeUnsafe(currentUser.id), }) const txid = yield* generateTransactionId() @@ -32,10 +33,10 @@ export const HttpMockDataLive = HttpApiBuilder.group(HazelApi, "mockData", (hand ) .pipe(withRemapDbErrors("MockDataGenerator", "create")) - return { + return new GenerateMockDataResponse({ transactionId: txid, created: result.summary, - } + }) }), ) }), diff --git a/apps/backend/src/routes/presence.http.ts b/apps/backend/src/routes/presence.http.ts index 77c3ad6f7..d51009293 100644 --- a/apps/backend/src/routes/presence.http.ts +++ b/apps/backend/src/routes/presence.http.ts @@ -1,13 +1,15 @@ -import { HttpApiBuilder } from "@effect/platform" +import { HttpApiBuilder } from "effect/unstable/httpapi" import { UserPresenceStatusRepo } from "@hazel/backend-core" import { Database } from "@hazel/db" import { withRemapDbErrors } from "@hazel/domain" +import { MarkOfflineResponse } from "@hazel/domain/http" import { Effect } from "effect" import { HazelApi } from "../api" export const HttpPresencePublicLive = HttpApiBuilder.group(HazelApi, "presencePublic", (handlers) => Effect.gen(function* () { const db = yield* Database.Database + const userPresenceStatusRepo = yield* UserPresenceStatusRepo return handlers.handle( "markOffline", @@ -16,7 +18,7 @@ export const HttpPresencePublicLive = HttpApiBuilder.group(HazelApi, "presencePu yield* db .transaction( Effect.asVoid( - UserPresenceStatusRepo.updateStatus({ + userPresenceStatusRepo.updateStatus({ userId: payload.userId, status: "offline", customMessage: null, @@ -25,9 +27,9 @@ export const HttpPresencePublicLive = HttpApiBuilder.group(HazelApi, "presencePu ) .pipe(withRemapDbErrors("UserPresenceStatus", "update")) - return { + return new MarkOfflineResponse({ success: true, - } + }) }), ) }), diff --git a/apps/backend/src/routes/root.http.ts b/apps/backend/src/routes/root.http.ts index 8c54dad7f..a114a6907 100644 --- a/apps/backend/src/routes/root.http.ts +++ b/apps/backend/src/routes/root.http.ts @@ -1,4 +1,4 @@ -import { HttpApiBuilder } from "@effect/platform" +import { HttpApiBuilder } from "effect/unstable/httpapi" import { Effect } from "effect" import { HazelApi } from "../api" diff --git a/apps/backend/src/routes/uploads.http.ts b/apps/backend/src/routes/uploads.http.ts index b1e2f6cbb..a87f31f60 100644 --- a/apps/backend/src/routes/uploads.http.ts +++ b/apps/backend/src/routes/uploads.http.ts @@ -1,10 +1,11 @@ -import { HttpApiBuilder } from "@effect/platform" +import { HttpApiBuilder } from "effect/unstable/httpapi" import { AttachmentRepo, BotRepo, OrganizationRepo } from "@hazel/backend-core" import { Database } from "@hazel/db" import { CurrentUser, UnauthorizedError, withRemapDbErrors } from "@hazel/domain" import { BotNotFoundForUploadError, OrganizationNotFoundForUploadError, + PresignUploadResponse, UploadError, } from "@hazel/domain/http" import { AttachmentId } from "@hazel/schema" @@ -24,10 +25,20 @@ const getPublicUrlBase = (): string => { return process.env.S3_PUBLIC_URL ?? "" } +const makePresignUploadResponse = (input: { + uploadUrl: string + key: string + publicUrl: string + resourceId?: AttachmentId +}) => new PresignUploadResponse(input) + export const HttpUploadsLive = HttpApiBuilder.group(HazelApi, "uploads", (handlers) => Effect.gen(function* () { const db = yield* Database.Database const s3 = yield* S3 + const attachmentPolicy = yield* AttachmentPolicy + const organizationPolicy = yield* OrganizationPolicy + const attachmentRepo = yield* AttachmentRepo return handlers.handle( "presign", @@ -66,11 +77,11 @@ export const HttpUploadsLive = HttpApiBuilder.group(HazelApi, "uploads", (handle yield* Effect.logDebug(`Generated presigned URL for user avatar: ${key}`) - return { + return makePresignUploadResponse({ uploadUrl, key, publicUrl: publicUrlBase ? `${publicUrlBase}/${key}` : key, - } + }) }), ), @@ -124,11 +135,11 @@ export const HttpUploadsLive = HttpApiBuilder.group(HazelApi, "uploads", (handle yield* Effect.logDebug(`Generated presigned URL for bot avatar: ${key}`) - return { + return makePresignUploadResponse({ uploadUrl, key, publicUrl: publicUrlBase ? `${publicUrlBase}/${key}` : key, - } + }) }), ), @@ -148,7 +159,7 @@ export const HttpUploadsLive = HttpApiBuilder.group(HazelApi, "uploads", (handle } // Check if user is an admin or owner of the organization - yield* OrganizationPolicy.canUpdate(req.organizationId) + yield* organizationPolicy.canUpdate(req.organizationId) // Check rate limit (5 per hour) yield* checkAvatarRateLimit(user.id) @@ -177,11 +188,11 @@ export const HttpUploadsLive = HttpApiBuilder.group(HazelApi, "uploads", (handle yield* Effect.logDebug(`Generated presigned URL for organization avatar: ${key}`) - return { + return makePresignUploadResponse({ uploadUrl, key, publicUrl: publicUrlBase ? `${publicUrlBase}/${key}` : key, - } + }) }), ), @@ -189,7 +200,7 @@ export const HttpUploadsLive = HttpApiBuilder.group(HazelApi, "uploads", (handle Match.when({ type: "custom-emoji" }, (req) => Effect.gen(function* () { // Check if user is admin/owner of the org - yield* OrganizationPolicy.canUpdate(req.organizationId) + yield* organizationPolicy.canUpdate(req.organizationId) // Check rate limit (reuse avatar rate limit) yield* checkAvatarRateLimit(user.id) @@ -218,18 +229,18 @@ export const HttpUploadsLive = HttpApiBuilder.group(HazelApi, "uploads", (handle yield* Effect.logDebug(`Generated presigned URL for custom emoji: ${key}`) - return { + return makePresignUploadResponse({ uploadUrl, key, publicUrl: publicUrlBase ? `${publicUrlBase}/${key}` : key, - } + }) }), ), // ============ Attachment Upload ============ Match.when({ type: "attachment" }, (req) => Effect.gen(function* () { - const attachmentId = AttachmentId.make(randomUUIDv7()) + const attachmentId = AttachmentId.makeUnsafe(randomUUIDv7()) yield* Effect.logDebug( `Generating presigned URL for attachment upload: ${attachmentId} (size: ${req.fileSize} bytes, type: ${req.contentType})`, @@ -237,11 +248,11 @@ export const HttpUploadsLive = HttpApiBuilder.group(HazelApi, "uploads", (handle // Create attachment record with "uploading" status // Validates user has permission to upload to the specified channel/org - yield* AttachmentPolicy.canCreate() + yield* attachmentPolicy.canCreate() yield* db .transaction( Effect.gen(function* () { - yield* AttachmentRepo.insert({ + yield* attachmentRepo.insert({ id: attachmentId, uploadedBy: user.id, organizationId: req.organizationId, @@ -276,12 +287,12 @@ export const HttpUploadsLive = HttpApiBuilder.group(HazelApi, "uploads", (handle yield* Effect.logDebug(`Generated presigned URL for attachment: ${attachmentId}`) - return { + return makePresignUploadResponse({ uploadUrl, key: attachmentId, publicUrl: publicUrlBase ? `${publicUrlBase}/${attachmentId}` : attachmentId, resourceId: attachmentId, - } + }) }), ), diff --git a/apps/backend/src/routes/webhooks.http.ts b/apps/backend/src/routes/webhooks.http.ts index f7bc42321..e2d542406 100644 --- a/apps/backend/src/routes/webhooks.http.ts +++ b/apps/backend/src/routes/webhooks.http.ts @@ -1,5 +1,7 @@ import { createHmac, timingSafeEqual } from "node:crypto" -import { HttpApiBuilder, HttpServerRequest } from "@effect/platform" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { HttpServerRequest } from "effect/unstable/http" +import { InternalServerError } from "@hazel/domain" import { GitHubWebhookResponse, InvalidGitHubWebhookSignature } from "@hazel/domain/http" import type { Event } from "@workos-inc/node" import { Config, Effect, pipe, Redacted } from "effect" @@ -24,7 +26,7 @@ export const HttpWebhookLive = HttpApiBuilder.group(HazelApi, "webhooks", (handl const rawBody = yield* pipe( request.text, - Effect.orElseFail( + Effect.mapError( () => new InvalidWebhookSignature({ message: "Invalid request body", @@ -100,7 +102,7 @@ export const HttpWebhookLive = HttpApiBuilder.group(HazelApi, "webhooks", (handl const rawBody = yield* pipe( request.text, - Effect.orElseFail( + Effect.mapError( () => new InvalidGitHubWebhookSignature({ message: "Invalid request body", @@ -109,10 +111,12 @@ export const HttpWebhookLive = HttpApiBuilder.group(HazelApi, "webhooks", (handl ) const skipSignatureVerification = yield* Config.boolean("GITHUB_WEBHOOK_SKIP_SIGNATURE").pipe( - Effect.orElseSucceed(() => false), + Config.withDefault(false), ) - const webhookSecret = yield* Config.redacted("GITHUB_WEBHOOK_SECRET").pipe( + const webhookSecret = yield* Effect.gen(function* () { + return yield* Config.redacted("GITHUB_WEBHOOK_SECRET") + }).pipe( Effect.catchTag("ConfigError", () => skipSignatureVerification ? Effect.succeed(Redacted.make("")) @@ -168,6 +172,12 @@ export const HttpWebhookLive = HttpApiBuilder.group(HazelApi, "webhooks", (handl processed: true, messagesCreated: 0, }) - }), + }).pipe( + Effect.catchTag("ConfigError", (err) => + Effect.fail( + new InternalServerError({ message: "Missing configuration", detail: String(err) }), + ), + ), + ), ), ) diff --git a/apps/backend/src/rpc/handlers/attachments.ts b/apps/backend/src/rpc/handlers/attachments.ts index 2f52e7266..2f583675d 100644 --- a/apps/backend/src/rpc/handlers/attachments.ts +++ b/apps/backend/src/rpc/handlers/attachments.ts @@ -9,14 +9,16 @@ import { AttachmentPolicy } from "../../policies/attachment-policy" export const AttachmentRpcLive = AttachmentRpcs.toLayer( Effect.gen(function* () { const db = yield* Database.Database + const attachmentPolicy = yield* AttachmentPolicy + const attachmentRepo = yield* AttachmentRepo return { "attachment.delete": ({ id }) => db .transaction( Effect.gen(function* () { - yield* AttachmentPolicy.canDelete(id) - yield* AttachmentRepo.deleteById(id) + yield* attachmentPolicy.canDelete(id) + yield* attachmentRepo.deleteById(id) const txid = yield* generateTransactionId() @@ -29,8 +31,8 @@ export const AttachmentRpcLive = AttachmentRpcs.toLayer( db .transaction( Effect.gen(function* () { - yield* AttachmentPolicy.canUpdate(id) - const attachment = yield* AttachmentRepo.update({ id, status: "complete" }) + yield* attachmentPolicy.canUpdate(id) + const attachment = yield* attachmentRepo.update({ id, status: "complete" }) return attachment }), @@ -45,8 +47,8 @@ export const AttachmentRpcLive = AttachmentRpcs.toLayer( `Marking attachment ${id} as failed${reason ? `: ${reason}` : ""}`, ) - yield* AttachmentPolicy.canUpdate(id) - yield* AttachmentRepo.update({ id, status: "failed" }) + yield* attachmentPolicy.canUpdate(id) + yield* attachmentRepo.update({ id, status: "failed" }) }), ) .pipe(withRemapDbErrors("Attachment", "update")), diff --git a/apps/backend/src/rpc/handlers/bots.ts b/apps/backend/src/rpc/handlers/bots.ts index bf8220dd7..a6d32756b 100644 --- a/apps/backend/src/rpc/handlers/bots.ts +++ b/apps/backend/src/rpc/handlers/bots.ts @@ -35,6 +35,8 @@ const generateBotToken = async (): Promise<{ token: string; tokenHash: string }> export const BotRpcLive = BotRpcs.toLayer( Effect.gen(function* () { const db = yield* Database.Database + const botPolicy = yield* BotPolicy + const channelAccessSync = yield* ChannelAccessSyncService return { "bot.create": (payload) => @@ -53,7 +55,7 @@ export const BotRpcLive = BotRpcs.toLayer( return yield* db .transaction( Effect.gen(function* () { - yield* BotPolicy.canCreate(organizationId) + yield* botPolicy.canCreate(organizationId) const botRepo = yield* BotRepo const installationRepo = yield* BotInstallationRepo @@ -126,10 +128,7 @@ export const BotRpcLive = BotRpcs.toLayer( }), ) - yield* ChannelAccessSyncService.syncUserInOrganization( - botUserId, - organizationId, - ) + yield* channelAccessSync.syncUserInOrganization(botUserId, organizationId) const txid = yield* generateTransactionId() @@ -156,7 +155,7 @@ export const BotRpcLive = BotRpcs.toLayer( "bot.get": ({ id }) => Effect.gen(function* () { - yield* BotPolicy.canRead(id) + yield* botPolicy.canRead(id) const botRepo = yield* BotRepo const botOption = yield* botRepo.findById(id) @@ -182,7 +181,7 @@ export const BotRpcLive = BotRpcs.toLayer( return yield* db .transaction( Effect.gen(function* () { - yield* BotPolicy.canUpdate(id) + yield* botPolicy.canUpdate(id) const botRepo = yield* BotRepo // Check bot exists @@ -227,7 +226,7 @@ export const BotRpcLive = BotRpcs.toLayer( db .transaction( Effect.gen(function* () { - yield* BotPolicy.canDelete(id) + yield* botPolicy.canDelete(id) const botRepo = yield* BotRepo // Check bot exists @@ -256,7 +255,7 @@ export const BotRpcLive = BotRpcs.toLayer( return yield* db .transaction( Effect.gen(function* () { - yield* BotPolicy.canUpdate(id) + yield* botPolicy.canUpdate(id) const botRepo = yield* BotRepo // Check bot exists @@ -285,7 +284,7 @@ export const BotRpcLive = BotRpcs.toLayer( "bot.getCommands": ({ botId }) => Effect.gen(function* () { - yield* BotPolicy.canRead(botId) + yield* botPolicy.canRead(botId) const botRepo = yield* BotRepo const commandRepo = yield* BotCommandRepo @@ -393,7 +392,7 @@ export const BotRpcLive = BotRpcs.toLayer( return yield* db .transaction( Effect.gen(function* () { - yield* BotPolicy.canInstall(organizationId) + yield* botPolicy.canInstall(organizationId) const botRepo = yield* BotRepo const installationRepo = yield* BotInstallationRepo @@ -446,10 +445,7 @@ export const BotRpcLive = BotRpcs.toLayer( }), ) - yield* ChannelAccessSyncService.syncUserInOrganization( - bot.userId, - organizationId, - ) + yield* channelAccessSync.syncUserInOrganization(bot.userId, organizationId) // Increment install count yield* botRepo.incrementInstallCount(botId) @@ -478,7 +474,7 @@ export const BotRpcLive = BotRpcs.toLayer( return yield* db .transaction( Effect.gen(function* () { - yield* BotPolicy.canUninstall(organizationId) + yield* botPolicy.canUninstall(organizationId) const botRepo = yield* BotRepo const installationRepo = yield* BotInstallationRepo @@ -523,7 +519,7 @@ export const BotRpcLive = BotRpcs.toLayer( ), ) - yield* ChannelAccessSyncService.syncUserInOrganization( + yield* channelAccessSync.syncUserInOrganization( botOption.value.userId, organizationId, ) @@ -556,7 +552,7 @@ export const BotRpcLive = BotRpcs.toLayer( return yield* db .transaction( Effect.gen(function* () { - yield* BotPolicy.canInstall(organizationId) + yield* botPolicy.canInstall(organizationId) const botRepo = yield* BotRepo const installationRepo = yield* BotInstallationRepo @@ -606,10 +602,7 @@ export const BotRpcLive = BotRpcs.toLayer( }), ) - yield* ChannelAccessSyncService.syncUserInOrganization( - bot.userId, - organizationId, - ) + yield* channelAccessSync.syncUserInOrganization(bot.userId, organizationId) // Increment install count yield* botRepo.incrementInstallCount(botId) @@ -626,7 +619,7 @@ export const BotRpcLive = BotRpcs.toLayer( db .transaction( Effect.gen(function* () { - yield* BotPolicy.canUpdate(id) + yield* botPolicy.canUpdate(id) const botRepo = yield* BotRepo // Check bot exists diff --git a/apps/backend/src/rpc/handlers/channel-members.ts b/apps/backend/src/rpc/handlers/channel-members.ts index a0a0dbae1..035e6df14 100644 --- a/apps/backend/src/rpc/handlers/channel-members.ts +++ b/apps/backend/src/rpc/handlers/channel-members.ts @@ -1,7 +1,7 @@ import { ChannelMemberRepo, ChannelRepo, NotificationRepo, OrganizationMemberRepo } from "@hazel/backend-core" import { Database } from "@hazel/db" import { CurrentUser, withRemapDbErrors } from "@hazel/domain" -import { ChannelMemberRpcs } from "@hazel/domain/rpc" +import { ChannelMemberResponse, ChannelMemberRpcs } from "@hazel/domain/rpc" import { Effect, Option } from "effect" import { generateTransactionId } from "../../lib/create-transactionId" import { ChannelMemberPolicy } from "../../policies/channel-member-policy" @@ -12,6 +12,12 @@ export const ChannelMemberRpcLive = ChannelMemberRpcs.toLayer( Effect.gen(function* () { const db = yield* Database.Database const botGateway = yield* BotGatewayService + const channelMemberPolicy = yield* ChannelMemberPolicy + const channelMemberRepo = yield* ChannelMemberRepo + const channelRepo = yield* ChannelRepo + const channelAccessSync = yield* ChannelAccessSyncService + const organizationMemberRepo = yield* OrganizationMemberRepo + const notificationRepo = yield* NotificationRepo return { "channelMember.create": (payload) => @@ -20,22 +26,24 @@ export const ChannelMemberRpcLive = ChannelMemberRpcs.toLayer( Effect.gen(function* () { const user = yield* CurrentUser.Context - yield* ChannelMemberPolicy.canCreate(payload.channelId) - const createdChannelMember = yield* ChannelMemberRepo.insert({ - channelId: payload.channelId, - userId: user.id, - isHidden: false, - isMuted: false, - isFavorite: false, - lastSeenMessageId: null, - notificationCount: 0, - joinedAt: new Date(), - deletedAt: null, - }).pipe(Effect.map((res) => res[0]!)) - - const channelOption = yield* ChannelRepo.findById(payload.channelId) + yield* channelMemberPolicy.canCreate(payload.channelId) + const createdChannelMember = yield* channelMemberRepo + .insert({ + channelId: payload.channelId, + userId: user.id, + isHidden: false, + isMuted: false, + isFavorite: false, + lastSeenMessageId: null, + notificationCount: 0, + joinedAt: new Date(), + deletedAt: null, + }) + .pipe(Effect.map((res) => res[0]!)) + + const channelOption = yield* channelRepo.findById(payload.channelId) if (Option.isSome(channelOption)) { - yield* ChannelAccessSyncService.syncUserInOrganization( + yield* channelAccessSync.syncUserInOrganization( user.id, channelOption.value.organizationId, ) @@ -43,10 +51,10 @@ export const ChannelMemberRpcLive = ChannelMemberRpcs.toLayer( const txid = yield* generateTransactionId() - return { + return new ChannelMemberResponse({ data: createdChannelMember, transactionId: txid, - } + }) }), ) .pipe( @@ -67,39 +75,39 @@ export const ChannelMemberRpcLive = ChannelMemberRpcs.toLayer( db .transaction( Effect.gen(function* () { - yield* ChannelMemberPolicy.canUpdate(id) - const updatedChannelMember = yield* ChannelMemberRepo.update({ + yield* channelMemberPolicy.canUpdate(id) + const updatedChannelMember = yield* channelMemberRepo.update({ id, ...payload, }) const txid = yield* generateTransactionId() - return { + return new ChannelMemberResponse({ data: updatedChannelMember, transactionId: txid, - } + }) }), ) .pipe(withRemapDbErrors("ChannelMember", "update")), "channelMember.delete": ({ id }) => Effect.gen(function* () { - const deletedMemberOption = yield* ChannelMemberRepo.findById(id).pipe( - withRemapDbErrors("ChannelMember", "select"), - ) + const deletedMemberOption = yield* channelMemberRepo + .findById(id) + .pipe(withRemapDbErrors("ChannelMember", "select")) const response = yield* db .transaction( Effect.gen(function* () { - yield* ChannelMemberPolicy.canDelete(id) - yield* ChannelMemberRepo.deleteById(id) + yield* channelMemberPolicy.canDelete(id) + yield* channelMemberRepo.deleteById(id) if (Option.isSome(deletedMemberOption)) { - const channelOption = yield* ChannelRepo.findById( - deletedMemberOption.value.channelId, - ).pipe(withRemapDbErrors("Channel", "select")) + const channelOption = yield* channelRepo + .findById(deletedMemberOption.value.channelId) + .pipe(withRemapDbErrors("Channel", "select")) if (Option.isSome(channelOption)) { - yield* ChannelAccessSyncService.syncUserInOrganization( + yield* channelAccessSync.syncUserInOrganization( deletedMemberOption.value.userId, channelOption.value.organizationId, ) @@ -137,23 +145,21 @@ export const ChannelMemberRpcLive = ChannelMemberRpcs.toLayer( const user = yield* CurrentUser.Context // Find the channel member record for this user and channel - yield* ChannelMemberPolicy.canRead(channelId) - const memberOption = yield* ChannelMemberRepo.findByChannelAndUser( - channelId, - user.id, - ).pipe(withRemapDbErrors("ChannelMember", "select")) + yield* channelMemberPolicy.canRead(channelId) + const memberOption = yield* channelMemberRepo + .findByChannelAndUser(channelId, user.id) + .pipe(withRemapDbErrors("ChannelMember", "select")) // Get channel to find organizationId - const channelOption = yield* ChannelRepo.findById(channelId).pipe( - withRemapDbErrors("Channel", "select"), - ) + const channelOption = yield* channelRepo + .findById(channelId) + .pipe(withRemapDbErrors("Channel", "select")) // Get organization member for notification deletion const orgMemberOption = Option.isSome(channelOption) - ? yield* OrganizationMemberRepo.findByOrgAndUser( - channelOption.value.organizationId, - user.id, - ).pipe(withRemapDbErrors("OrganizationMember", "select")) + ? yield* organizationMemberRepo + .findByOrgAndUser(channelOption.value.organizationId, user.id) + .pipe(withRemapDbErrors("OrganizationMember", "select")) : Option.none() // Wrap the update and transaction ID generation in a single transaction @@ -162,8 +168,8 @@ export const ChannelMemberRpcLive = ChannelMemberRpcs.toLayer( Effect.gen(function* () { // If member exists, clear the notification count if (Option.isSome(memberOption)) { - yield* ChannelMemberPolicy.canUpdate(memberOption.value.id) - yield* ChannelMemberRepo.update({ + yield* channelMemberPolicy.canUpdate(memberOption.value.id) + yield* channelMemberRepo.update({ id: memberOption.value.id, notificationCount: 0, }) @@ -171,7 +177,7 @@ export const ChannelMemberRpcLive = ChannelMemberRpcs.toLayer( // Delete all notifications for this channel if (Option.isSome(orgMemberOption)) { - yield* NotificationRepo.deleteByChannelId( + yield* notificationRepo.deleteByChannelId( channelId, orgMemberOption.value.id, ) diff --git a/apps/backend/src/rpc/handlers/channel-sections.ts b/apps/backend/src/rpc/handlers/channel-sections.ts index f3be3012a..66e4825c9 100644 --- a/apps/backend/src/rpc/handlers/channel-sections.ts +++ b/apps/backend/src/rpc/handlers/channel-sections.ts @@ -1,7 +1,12 @@ import { ChannelRepo, ChannelSectionRepo } from "@hazel/backend-core" import { Database, schema } from "@hazel/db" import { ErrorUtils, withRemapDbErrors } from "@hazel/domain" -import { ChannelNotFoundError, ChannelSectionNotFoundError, ChannelSectionRpcs } from "@hazel/domain/rpc" +import { + ChannelNotFoundError, + ChannelSectionNotFoundError, + ChannelSectionResponse, + ChannelSectionRpcs, +} from "@hazel/domain/rpc" import { and, eq, inArray, sql } from "drizzle-orm" import { Effect, Option } from "effect" import { generateTransactionId } from "../../lib/create-transactionId" @@ -13,6 +18,10 @@ import { OrgResolver } from "../../services/org-resolver" export const ChannelSectionRpcLive = ChannelSectionRpcs.toLayer( Effect.gen(function* () { const db = yield* Database.Database + const channelSectionPolicy = yield* ChannelSectionPolicy + const channelRepo = yield* ChannelRepo + const channelSectionRepo = yield* ChannelSectionRepo + const orgResolver = yield* OrgResolver return { "channelSection.create": ({ id, ...payload }) => @@ -44,17 +53,17 @@ export const ChannelSectionRpcLive = ChannelSectionRpcs.toLayer( ? { id, ...payload, order, deletedAt: null } : { ...payload, order, deletedAt: null } - yield* ChannelSectionPolicy.canCreate(payload.organizationId) - const createdSection = yield* ChannelSectionRepo.insert( - insertData as typeof payload & { order: number; deletedAt: null }, - ).pipe(Effect.map((res) => res[0]!)) + yield* channelSectionPolicy.canCreate(payload.organizationId) + const createdSection = yield* channelSectionRepo + .insert(insertData as typeof payload & { order: number; deletedAt: null }) + .pipe(Effect.map((res) => res[0]!)) const txid = yield* generateTransactionId() - return { + return new ChannelSectionResponse({ data: createdSection, transactionId: txid, - } + }) }), ) .pipe(withRemapDbErrors("ChannelSection", "create")), @@ -63,18 +72,18 @@ export const ChannelSectionRpcLive = ChannelSectionRpcs.toLayer( db .transaction( Effect.gen(function* () { - yield* ChannelSectionPolicy.canUpdate(id) - const updatedSection = yield* ChannelSectionRepo.update({ + yield* channelSectionPolicy.canUpdate(id) + const updatedSection = yield* channelSectionRepo.update({ id, ...payload, }) const txid = yield* generateTransactionId() - return { + return new ChannelSectionResponse({ data: updatedSection, transactionId: txid, - } + }) }), ) .pipe(withRemapDbErrors("ChannelSection", "update")), @@ -83,9 +92,9 @@ export const ChannelSectionRpcLive = ChannelSectionRpcs.toLayer( db .transaction( Effect.gen(function* () { - yield* ChannelSectionPolicy.canDelete(id) + yield* channelSectionPolicy.canDelete(id) // First, move all channels in this section back to default (sectionId = null) - const section = yield* ChannelSectionRepo.findById(id) + const section = yield* channelSectionRepo.findById(id) if (Option.isNone(section)) { return yield* Effect.fail(new ChannelSectionNotFoundError({ sectionId: id })) @@ -100,7 +109,7 @@ export const ChannelSectionRpcLive = ChannelSectionRpcs.toLayer( ) // Delete the section - yield* ChannelSectionRepo.deleteById(id) + yield* channelSectionRepo.deleteById(id) const txid = yield* generateTransactionId() @@ -113,7 +122,7 @@ export const ChannelSectionRpcLive = ChannelSectionRpcs.toLayer( db .transaction( Effect.gen(function* () { - yield* ChannelSectionPolicy.canReorder(organizationId) + yield* channelSectionPolicy.canReorder(organizationId) yield* transactionAwareExecute((client) => client .update(schema.channelSectionsTable) @@ -143,14 +152,14 @@ export const ChannelSectionRpcLive = ChannelSectionRpcs.toLayer( .transaction( Effect.gen(function* () { // Get channel first to know its organization - const channel = yield* ChannelRepo.findById(channelId) + const channel = yield* channelRepo.findById(channelId) if (Option.isNone(channel)) { return yield* Effect.fail(new ChannelNotFoundError({ channelId })) } // Validate target section exists and belongs to same org if (sectionId !== null) { - const section = yield* ChannelSectionRepo.findById(sectionId) + const section = yield* channelSectionRepo.findById(sectionId) if (Option.isNone(section)) { return yield* Effect.fail(new ChannelSectionNotFoundError({ sectionId })) } @@ -160,14 +169,14 @@ export const ChannelSectionRpcLive = ChannelSectionRpcs.toLayer( } if (sectionId !== null) { - yield* ChannelSectionPolicy.canUpdate(sectionId) + yield* channelSectionPolicy.canUpdate(sectionId) } else { yield* ErrorUtils.refailUnauthorized( "ChannelSection", "moveChannel", )( withAnnotatedScope((scope) => - OrgResolver.requireAdminOrOwner( + orgResolver.requireAdminOrOwner( channel.value.organizationId, scope, "ChannelSection", @@ -177,7 +186,7 @@ export const ChannelSectionRpcLive = ChannelSectionRpcs.toLayer( ) } // Update the channel's sectionId - yield* ChannelRepo.update({ + yield* channelRepo.update({ id: channelId, sectionId, }) diff --git a/apps/backend/src/rpc/handlers/channel-webhooks.ts b/apps/backend/src/rpc/handlers/channel-webhooks.ts index 0f8f1a4ab..c89db7c17 100644 --- a/apps/backend/src/rpc/handlers/channel-webhooks.ts +++ b/apps/backend/src/rpc/handlers/channel-webhooks.ts @@ -42,6 +42,8 @@ const buildWebhookUrl = (webhookId: string, token: string) => { export const ChannelWebhookRpcLive = ChannelWebhookRpcs.toLayer( Effect.gen(function* () { const db = yield* Database.Database + const channelWebhookPolicy = yield* ChannelWebhookPolicy + const orgResolver = yield* OrgResolver return { "channelWebhook.create": (payload) => @@ -67,7 +69,7 @@ export const ChannelWebhookRpcLive = ChannelWebhookRpcs.toLayer( const { token, tokenHash, tokenSuffix } = generateToken() // Get or create bot user based on whether this is an integration webhook - const botUser = yield* Option.fromNullable(payload.integrationProvider).pipe( + const botUser = yield* Option.fromNullishOr(payload.integrationProvider).pipe( Option.match({ onNone: () => { const botReferenceId = crypto.randomUUID() as ChannelWebhookId @@ -86,7 +88,7 @@ export const ChannelWebhookRpcLive = ChannelWebhookRpcs.toLayer( }), ) - yield* ChannelWebhookPolicy.canCreate(payload.channelId) + yield* channelWebhookPolicy.canCreate(payload.channelId) // Create webhook const [webhook] = yield* webhookRepo.insert({ @@ -120,7 +122,7 @@ export const ChannelWebhookRpcLive = ChannelWebhookRpcs.toLayer( Effect.gen(function* () { const webhookRepo = yield* ChannelWebhookRepo - yield* ChannelWebhookPolicy.canRead(channelId) + yield* channelWebhookPolicy.canRead(channelId) const webhooks = yield* webhookRepo.findByChannel(channelId) return new ChannelWebhookListResponse({ data: webhooks }) @@ -133,7 +135,7 @@ export const ChannelWebhookRpcLive = ChannelWebhookRpcs.toLayer( const webhookRepo = yield* ChannelWebhookRepo const botService = yield* WebhookBotService - yield* ChannelWebhookPolicy.canUpdate(id) + yield* channelWebhookPolicy.canUpdate(id) // Get current webhook const webhookOption = yield* webhookRepo.findById(id) @@ -178,7 +180,7 @@ export const ChannelWebhookRpcLive = ChannelWebhookRpcs.toLayer( Effect.gen(function* () { const webhookRepo = yield* ChannelWebhookRepo - yield* ChannelWebhookPolicy.canUpdate(id) + yield* channelWebhookPolicy.canUpdate(id) // Get current webhook const webhookOption = yield* webhookRepo.findById(id) @@ -214,7 +216,7 @@ export const ChannelWebhookRpcLive = ChannelWebhookRpcs.toLayer( Effect.gen(function* () { const webhookRepo = yield* ChannelWebhookRepo - yield* ChannelWebhookPolicy.canDelete(id) + yield* channelWebhookPolicy.canDelete(id) // Soft delete webhook only - bot user cleanup can be handled separately // (e.g., by admin or background job) since integrations may create webhooks @@ -245,7 +247,7 @@ export const ChannelWebhookRpcLive = ChannelWebhookRpcs.toLayer( "list", )( withAnnotatedScope((scope) => - OrgResolver.requireScope(organizationId, scope, "ChannelWebhook", "list"), + orgResolver.requireScope(organizationId, scope, "ChannelWebhook", "list"), ), ) diff --git a/apps/backend/src/rpc/handlers/channels.ts b/apps/backend/src/rpc/handlers/channels.ts index 624d6e8b9..3eb3e3a58 100644 --- a/apps/backend/src/rpc/handlers/channels.ts +++ b/apps/backend/src/rpc/handlers/channels.ts @@ -1,4 +1,4 @@ -import { HttpApiClient } from "@effect/platform" +import { HttpApiClient } from "effect/unstable/httpapi" import { ChannelMemberRepo, ChannelRepo, @@ -16,7 +16,7 @@ import { WorkflowServiceUnavailableError, } from "@hazel/domain" import { OrganizationId } from "@hazel/schema" -import { ChannelNotFoundError, ChannelRpcs, MessageNotFoundError } from "@hazel/domain/rpc" +import { ChannelNotFoundError, ChannelResponse, ChannelRpcs, MessageNotFoundError } from "@hazel/domain/rpc" import { eq } from "drizzle-orm" import { Config, Effect, Option } from "effect" import { generateTransactionId } from "../../lib/create-transactionId" @@ -30,6 +30,14 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( Effect.gen(function* () { const db = yield* Database.Database const botGateway = yield* BotGatewayService + const channelMemberRepo = yield* ChannelMemberRepo + const channelPolicy = yield* ChannelPolicy + const userPolicy = yield* UserPolicy + const messageRepo = yield* MessageRepo + const channelRepo = yield* ChannelRepo + const organizationMemberRepo = yield* OrganizationMemberRepo + const userRepo = yield* UserRepo + const channelAccessSync = yield* ChannelAccessSyncService return { "channel.create": ({ id, addAllMembers, ...payload }) => @@ -42,12 +50,12 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( ? { id, ...payload, deletedAt: null } : { ...payload, deletedAt: null } - yield* ChannelPolicy.canCreate(payload.organizationId) - const createdChannel = yield* ChannelRepo.insert( - insertData as typeof payload & { deletedAt: null }, - ).pipe(Effect.map((res) => res[0]!)) + yield* channelPolicy.canCreate(payload.organizationId) + const createdChannel = yield* channelRepo + .insert(insertData as typeof payload & { deletedAt: null }) + .pipe(Effect.map((res) => res[0]!)) - yield* ChannelMemberRepo.insert({ + yield* channelMemberRepo.insert({ channelId: createdChannel.id, userId: user.id, isHidden: false, @@ -60,14 +68,14 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( }) if (addAllMembers) { - const orgMembers = yield* OrganizationMemberRepo.findAllByOrganization( + const orgMembers = yield* organizationMemberRepo.findAllByOrganization( payload.organizationId, ) yield* Effect.forEach( orgMembers.filter((m) => m.userId !== user.id), (member) => - ChannelMemberRepo.insert({ + channelMemberRepo.insert({ channelId: createdChannel.id, userId: member.userId, isHidden: false, @@ -82,14 +90,14 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( ) } - yield* ChannelAccessSyncService.syncChannel(createdChannel.id) + yield* channelAccessSync.syncChannel(createdChannel.id) const txid = yield* generateTransactionId() - return { + return new ChannelResponse({ data: createdChannel, transactionId: txid, - } + }) }), ) .pipe( @@ -110,21 +118,21 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( db .transaction( Effect.gen(function* () { - yield* ChannelPolicy.canUpdate(id) - const updatedChannel = yield* ChannelRepo.update({ + yield* channelPolicy.canUpdate(id) + const updatedChannel = yield* channelRepo.update({ id, ...payload, }) - yield* ChannelAccessSyncService.syncChannel(id) - yield* ChannelAccessSyncService.syncChildThreads(id) + yield* channelAccessSync.syncChannel(id) + yield* channelAccessSync.syncChildThreads(id) const txid = yield* generateTransactionId() - return { + return new ChannelResponse({ data: updatedChannel, transactionId: txid, - } + }) }), ) .pipe( @@ -143,16 +151,16 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( "channel.delete": ({ id }) => Effect.gen(function* () { - const existingChannel = yield* ChannelRepo.findById(id).pipe( - withRemapDbErrors("Channel", "select"), - ) + const existingChannel = yield* channelRepo + .findById(id) + .pipe(withRemapDbErrors("Channel", "select")) const response = yield* db .transaction( Effect.gen(function* () { - yield* ChannelPolicy.canDelete(id) - yield* ChannelRepo.deleteById(id) - yield* ChannelAccessSyncService.removeChannel(id) - yield* ChannelAccessSyncService.syncChildThreads(id) + yield* channelPolicy.canDelete(id) + yield* channelRepo.deleteById(id) + yield* channelAccessSync.removeChannel(id) + yield* channelAccessSync.syncChildThreads(id) const txid = yield* generateTransactionId() @@ -193,10 +201,10 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( // Check for existing DM channel if (payload.type === "single") { - const existingChannel = yield* ChannelMemberRepo.findExistingSingleDmChannel( + const existingChannel = yield* channelMemberRepo.findExistingSingleDmChannel( user.id, payload.participantIds[0], - OrganizationId.make(payload.organizationId), + OrganizationId.makeUnsafe(payload.organizationId), ) if (Option.isSome(existingChannel)) { @@ -212,9 +220,9 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( // Generate channel name for DMs let channelName = payload.name if (payload.type === "single") { - yield* UserPolicy.canRead(payload.participantIds[0]!) - const otherUser = yield* UserRepo.findById(payload.participantIds[0]) - const currentUser = yield* UserRepo.findById(user.id) + yield* userPolicy.canRead(payload.participantIds[0]!) + const otherUser = yield* userRepo.findById(payload.participantIds[0]) + const currentUser = yield* userRepo.findById(user.id) if (Option.isSome(otherUser) && Option.isSome(currentUser)) { // Create a consistent name for DMs using first and last name @@ -228,19 +236,21 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( } // Create channel - yield* ChannelPolicy.canCreate(OrganizationId.make(payload.organizationId)) - const createdChannel = yield* ChannelRepo.insert({ - name: channelName || "Group Channel", - icon: null, - type: payload.type, - organizationId: OrganizationId.make(payload.organizationId), - parentChannelId: null, - sectionId: null, - deletedAt: null, - }).pipe(Effect.map((res) => res[0]!)) + yield* channelPolicy.canCreate(OrganizationId.makeUnsafe(payload.organizationId)) + const createdChannel = yield* channelRepo + .insert({ + name: channelName || "Group Channel", + icon: null, + type: payload.type, + organizationId: OrganizationId.makeUnsafe(payload.organizationId), + parentChannelId: null, + sectionId: null, + deletedAt: null, + }) + .pipe(Effect.map((res) => res[0]!)) // Add creator as member - yield* ChannelMemberRepo.insert({ + yield* channelMemberRepo.insert({ channelId: createdChannel.id, userId: user.id, isHidden: false, @@ -254,7 +264,7 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( // Add all participants as members for (const participantId of payload.participantIds) { - yield* ChannelMemberRepo.insert({ + yield* channelMemberRepo.insert({ channelId: createdChannel.id, userId: participantId, isHidden: false, @@ -267,14 +277,14 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( }) } - yield* ChannelAccessSyncService.syncChannel(createdChannel.id) + yield* channelAccessSync.syncChannel(createdChannel.id) const txid = yield* generateTransactionId() - return { + return new ChannelResponse({ data: createdChannel, transactionId: txid, - } + }) }), ) .pipe( @@ -298,7 +308,7 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( const user = yield* CurrentUser.Context // 1. Find the message and resolve thread context from authoritative DB data - const message = yield* MessageRepo.findById(messageId) + const message = yield* messageRepo.findById(messageId) if (Option.isNone(message)) { return yield* Effect.fail(new MessageNotFoundError({ messageId })) @@ -306,7 +316,7 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( // If the message already points to a thread, return that thread. if (message.value.threadChannelId) { - const existingThread = yield* ChannelRepo.findById( + const existingThread = yield* channelRepo.findById( message.value.threadChannelId, ) if (Option.isNone(existingThread)) { @@ -318,16 +328,16 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( ) } - yield* ChannelAccessSyncService.syncChannel(existingThread.value.id) + yield* channelAccessSync.syncChannel(existingThread.value.id) const txid = yield* generateTransactionId() - return { + return new ChannelResponse({ data: existingThread.value, transactionId: txid, - } + }) } - const parentChannel = yield* ChannelRepo.findById(message.value.channelId) + const parentChannel = yield* channelRepo.findById(message.value.channelId) if (Option.isNone(parentChannel)) { return yield* Effect.fail( new InternalServerError({ @@ -339,13 +349,13 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( // If the message is already in a thread channel, return that thread. if (parentChannel.value.type === "thread") { - yield* ChannelAccessSyncService.syncChannel(parentChannel.value.id) + yield* channelAccessSync.syncChannel(parentChannel.value.id) const txid = yield* generateTransactionId() - return { + return new ChannelResponse({ data: parentChannel.value, transactionId: txid, - } + }) } // Derive organization from parent channel (source of truth). @@ -383,13 +393,13 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( deletedAt: null, } - yield* ChannelPolicy.canCreate(organizationId) - const createdChannel = yield* ChannelRepo.insert(insertData).pipe( - Effect.map((res) => res[0]!), - ) + yield* channelPolicy.canCreate(organizationId) + const createdChannel = yield* channelRepo + .insert(insertData) + .pipe(Effect.map((res) => res[0]!)) // 3. Add creator as member - yield* ChannelMemberRepo.insert({ + yield* channelMemberRepo.insert({ channelId: createdChannel.id, userId: user.id, isHidden: false, @@ -409,23 +419,23 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( .where(eq(schema.messagesTable.id, messageId)), ) - yield* ChannelAccessSyncService.syncChannel(createdChannel.id) + yield* channelAccessSync.syncChannel(createdChannel.id) const txid = yield* generateTransactionId() - return { + return new ChannelResponse({ data: createdChannel, transactionId: txid, - } + }) }), ) .pipe(withRemapDbErrors("Channel", "create")), "channel.generateName": ({ channelId }) => Effect.gen(function* () { - yield* ChannelPolicy.canUpdate(channelId) + yield* channelPolicy.canUpdate(channelId) - const channel = yield* ChannelRepo.findById(channelId).pipe( + const channel = yield* channelRepo.findById(channelId).pipe( Effect.catchTag("DatabaseError", (err) => Effect.fail( new InternalServerError({ @@ -468,7 +478,17 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( const originalMessageId = originalMessageResult[0]!.id - const clusterUrl = yield* Config.string("CLUSTER_URL").pipe(Effect.orDie) + const clusterUrl = yield* Config.string("CLUSTER_URL") + .asEffect() + .pipe( + Effect.mapError( + () => + new WorkflowServiceUnavailableError({ + message: "CLUSTER_URL not configured", + cause: "Missing CLUSTER_URL environment variable", + }), + ), + ) const client = yield* HttpApiClient.make(Cluster.WorkflowApi, { baseUrl: clusterUrl, }) @@ -490,7 +510,7 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( ), // Workflow errors (ThreadChannelNotFoundError, AIProviderUnavailableError, etc.) // pass through directly since they're in the RPC union - only handle HTTP client errors - Effect.catchTag("RequestError", (err) => + Effect.catchTag("HttpClientError", (err) => Effect.fail( new WorkflowServiceUnavailableError({ message: "Cannot connect to workflow service", @@ -498,23 +518,15 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( }), ), ), - Effect.catchTag("ResponseError", (err) => - Effect.fail( - new InternalServerError({ - message: `Thread naming failed: ${err.reason}`, - cause: String(err), - }), - ), - ), - Effect.catchTag("HttpApiDecodeError", (err) => + Effect.catchTag("BadRequest", (err) => Effect.fail( new InternalServerError({ - message: "Failed to decode workflow response", + message: "Failed to trigger thread naming workflow", cause: String(err), }), ), ), - Effect.catchTag("ParseError", (err) => + Effect.catchTag("SchemaError", (err) => Effect.fail( new InternalServerError({ message: "Failed to parse workflow response", diff --git a/apps/backend/src/rpc/handlers/chat-sync.ts b/apps/backend/src/rpc/handlers/chat-sync.ts index 22523b939..7cc0359b2 100644 --- a/apps/backend/src/rpc/handlers/chat-sync.ts +++ b/apps/backend/src/rpc/handlers/chat-sync.ts @@ -39,13 +39,14 @@ export const ChatSyncRpcLive = ChatSyncRpcs.toLayer( const connectionRepo = yield* ChatSyncConnectionRepo const channelLinkRepo = yield* ChatSyncChannelLinkRepo const integrationConnectionRepo = yield* IntegrationConnectionRepo + const integrationConnectionPolicy = yield* IntegrationConnectionPolicy return { "chatSync.connection.create": (payload) => db .transaction( Effect.gen(function* () { - yield* IntegrationConnectionPolicy.canInsert(payload.organizationId) + yield* integrationConnectionPolicy.canInsert(payload.organizationId) const currentUser = yield* CurrentUser.Context const integrationConnectionId = yield* Effect.gen(function* () { @@ -114,7 +115,7 @@ export const ChatSyncRpcLive = ChatSyncRpcs.toLayer( }), ) .pipe( - Effect.catchTag("ParseError", (error) => + Effect.catchTag("SchemaError", (error) => Effect.fail( new InternalServerError({ message: "Invalid sync connection data", @@ -134,7 +135,7 @@ export const ChatSyncRpcLive = ChatSyncRpcs.toLayer( "chatSync.connection.list": ({ organizationId }) => Effect.gen(function* () { - yield* IntegrationConnectionPolicy.canSelect(organizationId) + yield* integrationConnectionPolicy.canSelect(organizationId) const data = yield* connectionRepo.findByOrganization(organizationId) return new ChatSyncConnectionListResponse({ data }) }).pipe( @@ -161,7 +162,7 @@ export const ChatSyncRpcLive = ChatSyncRpcs.toLayer( ) } const connection = connectionOption.value - yield* IntegrationConnectionPolicy.canDelete(connection.organizationId) + yield* integrationConnectionPolicy.canDelete(connection.organizationId) yield* connectionRepo.softDelete(syncConnectionId) const links = yield* channelLinkRepo.findBySyncConnection(syncConnectionId) @@ -197,7 +198,7 @@ export const ChatSyncRpcLive = ChatSyncRpcs.toLayer( ) } const connection = connectionOption.value - yield* IntegrationConnectionPolicy.canInsert(connection.organizationId) + yield* integrationConnectionPolicy.canInsert(connection.organizationId) const existingHazel = yield* channelLinkRepo.findByHazelChannel( payload.syncConnectionId, @@ -251,7 +252,7 @@ export const ChatSyncRpcLive = ChatSyncRpcs.toLayer( }), ) .pipe( - Effect.catchTag("ParseError", (error) => + Effect.catchTag("SchemaError", (error) => Effect.fail( new InternalServerError({ message: "Invalid channel link data", @@ -280,7 +281,7 @@ export const ChatSyncRpcLive = ChatSyncRpcs.toLayer( ) } const connection = connectionOption.value - yield* IntegrationConnectionPolicy.canSelect(connection.organizationId) + yield* integrationConnectionPolicy.canSelect(connection.organizationId) const data = yield* channelLinkRepo.findBySyncConnection(syncConnectionId) return new ChatSyncChannelLinkListResponse({ data }) @@ -318,7 +319,7 @@ export const ChatSyncRpcLive = ChatSyncRpcs.toLayer( }), ) } - yield* IntegrationConnectionPolicy.canDelete( + yield* integrationConnectionPolicy.canDelete( connectionOption.value.organizationId, ) @@ -360,7 +361,7 @@ export const ChatSyncRpcLive = ChatSyncRpcs.toLayer( }), ) } - yield* IntegrationConnectionPolicy.canUpdate( + yield* integrationConnectionPolicy.canUpdate( connectionOption.value.organizationId, ) diff --git a/apps/backend/src/rpc/handlers/connect-shares.test.ts b/apps/backend/src/rpc/handlers/connect-shares.test.ts index 3975afacb..43d36307a 100644 --- a/apps/backend/src/rpc/handlers/connect-shares.test.ts +++ b/apps/backend/src/rpc/handlers/connect-shares.test.ts @@ -11,10 +11,10 @@ import { validateInviteAcceptanceTarget, } from "./connect-shares" -const HOST_ORG_ID = "00000000-0000-0000-0000-000000000101" as OrganizationId -const GUEST_ORG_ID = "00000000-0000-0000-0000-000000000102" as OrganizationId -const OTHER_GUEST_ORG_ID = "00000000-0000-0000-0000-000000000103" as OrganizationId -const GUEST_CHANNEL_ID = "00000000-0000-0000-0000-000000000301" as ChannelId +const HOST_ORG_ID = "00000000-0000-4000-8000-000000000101" as OrganizationId +const GUEST_ORG_ID = "00000000-0000-4000-8000-000000000102" as OrganizationId +const OTHER_GUEST_ORG_ID = "00000000-0000-4000-8000-000000000103" as OrganizationId +const GUEST_CHANNEL_ID = "00000000-0000-4000-8000-000000000301" as ChannelId describe("connect-shares helpers", () => { it("rejects invite creation when provided org and slug resolve to different workspaces", async () => { @@ -23,12 +23,12 @@ describe("connect-shares helpers", () => { providedOrgId: GUEST_ORG_ID, target: { kind: "slug", value: "other-workspace" }, findBySlug: () => Effect.succeed(Option.some({ id: OTHER_GUEST_ORG_ID })), - }).pipe(Effect.either) as Effect.Effect, + }).pipe(Effect.result) as Effect.Effect, ) - expect(result._tag).toBe("Left") - if (result._tag === "Left") { - expect(result.left).toBeInstanceOf(ConnectWorkspaceNotFoundError) + expect(result._tag).toBe("Failure") + if (result._tag === "Failure") { + expect(result.failure).toBeInstanceOf(ConnectWorkspaceNotFoundError) } }) @@ -42,12 +42,12 @@ describe("connect-shares helpers", () => { }, guestOrganizationId: OTHER_GUEST_ORG_ID, findBySlug: () => Effect.succeed(Option.some({ id: GUEST_ORG_ID })), - }).pipe(Effect.either) as Effect.Effect, + }).pipe(Effect.result) as Effect.Effect, ) - expect(result._tag).toBe("Left") - if (result._tag === "Left") { - expect(result.left).toBeInstanceOf(ConnectWorkspaceNotFoundError) + expect(result._tag).toBe("Failure") + if (result._tag === "Failure") { + expect(result.failure).toBeInstanceOf(ConnectWorkspaceNotFoundError) } }) @@ -61,12 +61,12 @@ describe("connect-shares helpers", () => { }, guestOrganizationId: GUEST_ORG_ID, findBySlug: () => Effect.succeed(Option.none()), - }).pipe(Effect.either) as Effect.Effect, + }).pipe(Effect.result) as Effect.Effect, ) - expect(result._tag).toBe("Left") - if (result._tag === "Left") { - expect(result.left).toBeInstanceOf(ConnectWorkspaceNotFoundError) + expect(result._tag).toBe("Failure") + if (result._tag === "Failure") { + expect(result.failure).toBeInstanceOf(ConnectWorkspaceNotFoundError) } }) @@ -75,12 +75,12 @@ describe("connect-shares helpers", () => { assertGuestMemberAddsAllowed({ role: "guest", allowGuestMemberAdds: false, - }).pipe(Effect.either) as Effect.Effect, + }).pipe(Effect.result) as Effect.Effect, ) - expect(result._tag).toBe("Left") - if (result._tag === "Left") { - expect(result.left).toBeInstanceOf(PermissionError) + expect(result._tag).toBe("Failure") + if (result._tag === "Failure") { + expect(result.failure).toBeInstanceOf(PermissionError) } }) @@ -89,17 +89,17 @@ describe("connect-shares helpers", () => { assertGuestMemberAddsAllowed({ role: "host", allowGuestMemberAdds: false, - }).pipe(Effect.either) as Effect.Effect, + }).pipe(Effect.result) as Effect.Effect, ) const guestResult = await Effect.runPromise( assertGuestMemberAddsAllowed({ role: "guest", allowGuestMemberAdds: true, - }).pipe(Effect.either) as Effect.Effect, + }).pipe(Effect.result) as Effect.Effect, ) - expect(hostResult._tag).toBe("Right") - expect(guestResult._tag).toBe("Right") + expect(hostResult._tag).toBe("Success") + expect(guestResult._tag).toBe("Success") }) it("remaps guest mount unique conflicts to an already-shared error", async () => { @@ -111,12 +111,12 @@ describe("connect-shares helpers", () => { }), guestOrganizationId: GUEST_ORG_ID, findExistingMount: () => Effect.succeed(Option.some({ channelId: GUEST_CHANNEL_ID })), - }).pipe(Effect.either) as Effect.Effect, + }).pipe(Effect.result) as Effect.Effect, ) - expect(result._tag).toBe("Left") - if (result._tag === "Left") { - expect(result.left).toBeInstanceOf(ConnectChannelAlreadySharedError) + expect(result._tag).toBe("Failure") + if (result._tag === "Failure") { + expect(result.failure).toBeInstanceOf(ConnectChannelAlreadySharedError) } }) }) diff --git a/apps/backend/src/rpc/handlers/connect-shares.ts b/apps/backend/src/rpc/handlers/connect-shares.ts index 6b2d9d951..158b65528 100644 --- a/apps/backend/src/rpc/handlers/connect-shares.ts +++ b/apps/backend/src/rpc/handlers/connect-shares.ts @@ -163,9 +163,9 @@ export const remapGuestMountInsertConflict = ({ } function remapPermissionError( - effect: Effect.Effect, + make: Effect.Effect, ): Effect.Effect | UnauthorizedError, R> { - return Effect.catchIf(effect, PermissionError.is, (err) => + return Effect.catchIf(make, PermissionError.is, (err) => Effect.fail( new UnauthorizedError({ message: err.message, @@ -202,8 +202,8 @@ export const ConnectShareRpcLive = ConnectShareRpcs.toLayer( return } - const hostAttempt = yield* requireAdminOrOwner(hostOrganizationId).pipe(Effect.either) - if (hostAttempt._tag === "Right") return + const hostAttempt = yield* requireAdminOrOwner(hostOrganizationId).pipe(Effect.result) + if (hostAttempt._tag === "Success") return yield* requireAdminOrOwner(targetOrganizationId) }) diff --git a/apps/backend/src/rpc/handlers/custom-emojis.ts b/apps/backend/src/rpc/handlers/custom-emojis.ts index 6e2f3db16..da459bebb 100644 --- a/apps/backend/src/rpc/handlers/custom-emojis.ts +++ b/apps/backend/src/rpc/handlers/custom-emojis.ts @@ -5,6 +5,7 @@ import { CustomEmojiDeletedExistsError, CustomEmojiNameConflictError, CustomEmojiNotFoundError, + CustomEmojiResponse, CustomEmojiRpcs, } from "@hazel/domain/rpc" import { Effect, Option } from "effect" @@ -14,6 +15,8 @@ import { CustomEmojiPolicy } from "../../policies/custom-emoji-policy" export const CustomEmojiRpcLive = CustomEmojiRpcs.toLayer( Effect.gen(function* () { const db = yield* Database.Database + const customEmojiPolicy = yield* CustomEmojiPolicy + const customEmojiRepo = yield* CustomEmojiRepo return { "customEmoji.create": (payload) => @@ -23,7 +26,7 @@ export const CustomEmojiRpcLive = CustomEmojiRpcs.toLayer( const user = yield* CurrentUser.Context // Check name uniqueness - const existing = yield* CustomEmojiRepo.findByOrgAndName( + const existing = yield* customEmojiRepo.findByOrgAndName( payload.organizationId, payload.name, ) @@ -37,7 +40,7 @@ export const CustomEmojiRpcLive = CustomEmojiRpcs.toLayer( } // Check if a soft-deleted emoji with same name exists - const deleted = yield* CustomEmojiRepo.findDeletedByOrgAndName( + const deleted = yield* customEmojiRepo.findDeletedByOrgAndName( payload.organizationId, payload.name, ) @@ -52,20 +55,22 @@ export const CustomEmojiRpcLive = CustomEmojiRpcs.toLayer( ) } - yield* CustomEmojiPolicy.canCreate(payload.organizationId) - const created = yield* CustomEmojiRepo.insert({ - organizationId: payload.organizationId, - name: payload.name, - imageUrl: payload.imageUrl, - createdBy: user.id, - }).pipe(Effect.map((res) => res[0]!)) + yield* customEmojiPolicy.canCreate(payload.organizationId) + const created = yield* customEmojiRepo + .insert({ + organizationId: payload.organizationId, + name: payload.name, + imageUrl: payload.imageUrl, + createdBy: user.id, + }) + .pipe(Effect.map((res) => res[0]!)) const txid = yield* generateTransactionId() - return { + return new CustomEmojiResponse({ data: created, transactionId: txid, - } + }) }), ) .pipe(withRemapDbErrors("CustomEmoji", "create")), @@ -75,14 +80,14 @@ export const CustomEmojiRpcLive = CustomEmojiRpcs.toLayer( .transaction( Effect.gen(function* () { // Check if emoji exists - const existing = yield* CustomEmojiRepo.findById(id) + const existing = yield* customEmojiRepo.findById(id) if (Option.isNone(existing)) { return yield* Effect.fail(new CustomEmojiNotFoundError({ customEmojiId: id })) } // Check name uniqueness if renaming if (payload.name !== undefined) { - const nameConflict = yield* CustomEmojiRepo.findByOrgAndName( + const nameConflict = yield* customEmojiRepo.findByOrgAndName( existing.value.organizationId, payload.name, ) @@ -96,18 +101,18 @@ export const CustomEmojiRpcLive = CustomEmojiRpcs.toLayer( } } - yield* CustomEmojiPolicy.canUpdate(id) - const updated = yield* CustomEmojiRepo.update({ + yield* customEmojiPolicy.canUpdate(id) + const updated = yield* customEmojiRepo.update({ id, ...payload, }) const txid = yield* generateTransactionId() - return { + return new CustomEmojiResponse({ data: updated, transactionId: txid, - } + }) }), ) .pipe(withRemapDbErrors("CustomEmoji", "update")), @@ -117,13 +122,13 @@ export const CustomEmojiRpcLive = CustomEmojiRpcs.toLayer( .transaction( Effect.gen(function* () { // Check existence first so missing IDs map to NotFound (not Unauthorized). - const existing = yield* CustomEmojiRepo.findById(id) + const existing = yield* customEmojiRepo.findById(id) if (Option.isNone(existing) || existing.value.deletedAt !== null) { return yield* Effect.fail(new CustomEmojiNotFoundError({ customEmojiId: id })) } - yield* CustomEmojiPolicy.canDelete(id) - const deleted = yield* CustomEmojiRepo.softDelete(id) + yield* customEmojiPolicy.canDelete(id) + const deleted = yield* customEmojiRepo.softDelete(id) if (Option.isNone(deleted)) { return yield* Effect.fail(new CustomEmojiNotFoundError({ customEmojiId: id })) @@ -141,13 +146,13 @@ export const CustomEmojiRpcLive = CustomEmojiRpcs.toLayer( .transaction( Effect.gen(function* () { // Look up the deleted emoji first - const existing = yield* CustomEmojiRepo.findById(id) + const existing = yield* customEmojiRepo.findById(id) if (Option.isNone(existing) || existing.value.deletedAt === null) { return yield* Effect.fail(new CustomEmojiNotFoundError({ customEmojiId: id })) } // Check that no active emoji with the same name exists - const nameConflict = yield* CustomEmojiRepo.findByOrgAndName( + const nameConflict = yield* customEmojiRepo.findByOrgAndName( existing.value.organizationId, existing.value.name, ) @@ -160,8 +165,8 @@ export const CustomEmojiRpcLive = CustomEmojiRpcs.toLayer( ) } - yield* CustomEmojiPolicy.canCreate(existing.value.organizationId) - const restored = yield* CustomEmojiRepo.restore(id, imageUrl) + yield* customEmojiPolicy.canCreate(existing.value.organizationId) + const restored = yield* customEmojiRepo.restore(id, imageUrl) if (Option.isNone(restored)) { return yield* Effect.fail(new CustomEmojiNotFoundError({ customEmojiId: id })) @@ -169,10 +174,10 @@ export const CustomEmojiRpcLive = CustomEmojiRpcs.toLayer( const txid = yield* generateTransactionId() - return { + return new CustomEmojiResponse({ data: restored.value, transactionId: txid, - } + }) }), ) .pipe(withRemapDbErrors("CustomEmoji", "update")), diff --git a/apps/backend/src/rpc/handlers/github-subscriptions.ts b/apps/backend/src/rpc/handlers/github-subscriptions.ts index f1a863790..f70d4d166 100644 --- a/apps/backend/src/rpc/handlers/github-subscriptions.ts +++ b/apps/backend/src/rpc/handlers/github-subscriptions.ts @@ -27,6 +27,7 @@ export const GitHubSubscriptionRpcLive = GitHubSubscriptionRpcs.toLayer( const channelRepo = yield* ChannelRepo const subscriptionRepo = yield* GitHubSubscriptionRepo const integrationRepo = yield* IntegrationConnectionRepo + const gitHubSubscriptionPolicy = yield* GitHubSubscriptionPolicy return { "githubSubscription.create": (payload) => @@ -67,7 +68,7 @@ export const GitHubSubscriptionRpcLive = GitHubSubscriptionRpcs.toLayer( ) } - yield* GitHubSubscriptionPolicy.canCreate(payload.channelId) + yield* gitHubSubscriptionPolicy.canCreate(payload.channelId) // Create subscription const [subscription] = yield* subscriptionRepo.insert({ @@ -96,7 +97,7 @@ export const GitHubSubscriptionRpcLive = GitHubSubscriptionRpcs.toLayer( "githubSubscription.list": ({ channelId }) => Effect.gen(function* () { - yield* GitHubSubscriptionPolicy.canRead(channelId) + yield* gitHubSubscriptionPolicy.canRead(channelId) const subscriptions = yield* subscriptionRepo.findByChannel(channelId) return new GitHubSubscriptionListResponse({ data: subscriptions }) @@ -113,7 +114,7 @@ export const GitHubSubscriptionRpcLive = GitHubSubscriptionRpcs.toLayer( const organizationId = user.organizationId - yield* GitHubSubscriptionPolicy.canReadByOrganization(organizationId) + yield* gitHubSubscriptionPolicy.canReadByOrganization(organizationId) const subscriptions = yield* subscriptionRepo.findByOrganization(organizationId) return new GitHubSubscriptionListResponse({ data: subscriptions }) @@ -131,7 +132,7 @@ export const GitHubSubscriptionRpcLive = GitHubSubscriptionRpcs.toLayer( ) } - yield* GitHubSubscriptionPolicy.canUpdate(id) + yield* gitHubSubscriptionPolicy.canUpdate(id) // Update subscription const [updatedSubscription] = yield* subscriptionRepo.updateSettings(id, { @@ -154,7 +155,7 @@ export const GitHubSubscriptionRpcLive = GitHubSubscriptionRpcs.toLayer( db .transaction( Effect.gen(function* () { - yield* GitHubSubscriptionPolicy.canDelete(id) + yield* gitHubSubscriptionPolicy.canDelete(id) // Check subscription exists const subscriptionOption = yield* subscriptionRepo.findById(id) diff --git a/apps/backend/src/rpc/handlers/integration-requests.ts b/apps/backend/src/rpc/handlers/integration-requests.ts index 2ddc39fe9..9ec42f981 100644 --- a/apps/backend/src/rpc/handlers/integration-requests.ts +++ b/apps/backend/src/rpc/handlers/integration-requests.ts @@ -1,6 +1,6 @@ import { Database, schema } from "@hazel/db" import { CurrentUser, ErrorUtils, withRemapDbErrors } from "@hazel/domain" -import { IntegrationRequestRpcs } from "@hazel/domain/rpc" +import { IntegrationRequestResponse, IntegrationRequestRpcs } from "@hazel/domain/rpc" import { Effect } from "effect" import { generateTransactionId } from "../../lib/create-transactionId" import { withAnnotatedScope } from "../../lib/policy-utils" @@ -55,10 +55,10 @@ export const IntegrationRequestRpcLive = IntegrationRequestRpcs.toLayer( const txid = yield* generateTransactionId() - return { + return new IntegrationRequestResponse({ data: result, transactionId: txid, - } + }) }), ) .pipe(withRemapDbErrors("IntegrationRequest", "create")), diff --git a/apps/backend/src/rpc/handlers/invitations.ts b/apps/backend/src/rpc/handlers/invitations.ts index fa9a2583e..01a7d4bc6 100644 --- a/apps/backend/src/rpc/handlers/invitations.ts +++ b/apps/backend/src/rpc/handlers/invitations.ts @@ -6,6 +6,7 @@ import { InvitationBatchResponse, InvitationBatchResult, InvitationNotFoundError, + InvitationResponse, InvitationRpcs, } from "@hazel/domain/rpc" import { Effect, Option, Schema } from "effect" @@ -17,6 +18,8 @@ export const InvitationRpcLive = InvitationRpcs.toLayer( Effect.gen(function* () { const db = yield* Database.Database const workos = yield* WorkOS + const invitationPolicy = yield* InvitationPolicy + const invitationRepo = yield* InvitationRepo return { "invitation.create": (payload) => @@ -60,8 +63,8 @@ export const InvitationRpcLive = InvitationRpcs.toLayer( expiresAt.setDate(expiresAt.getDate() + 7) // Store invitation in local database - yield* InvitationPolicy.canCreate(payload.organizationId) - const createdInvitation = yield* InvitationRepo.upsertByWorkosId({ + yield* invitationPolicy.canCreate(payload.organizationId) + const createdInvitation = yield* invitationRepo.upsertByWorkosId({ workosInvitationId: Schema.decodeUnknownSync(WorkOSInvitationId)( workosInvitation.id, ), @@ -87,7 +90,7 @@ export const InvitationRpcLive = InvitationRpcs.toLayer( }).pipe( // Note: catchAll is intentional here for batch processing - // we want to convert ALL errors to InvitationBatchResult entries - Effect.catchAll((error) => + Effect.catch((error) => Effect.succeed( new InvitationBatchResult({ email: invite.email, @@ -117,8 +120,8 @@ export const InvitationRpcLive = InvitationRpcs.toLayer( db .transaction( Effect.gen(function* () { - yield* InvitationPolicy.canRead(invitationId) - const invitationOption = yield* InvitationRepo.findById(invitationId) + yield* invitationPolicy.canRead(invitationId) + const invitationOption = yield* invitationRepo.findById(invitationId) if (Option.isNone(invitationOption)) { return yield* Effect.fail(new InvitationNotFoundError({ invitationId })) } @@ -126,7 +129,7 @@ export const InvitationRpcLive = InvitationRpcs.toLayer( const invitation = invitationOption.value // Resend invitation via WorkOS (send new invitation to same email) - yield* InvitationPolicy.canUpdate(invitationId) + yield* invitationPolicy.canUpdate(invitationId) yield* workos .call((client) => client.userManagement.sendInvitation({ @@ -152,18 +155,21 @@ export const InvitationRpcLive = InvitationRpcs.toLayer( ) .pipe( withRemapDbErrors("Invitation", "update"), - Effect.map(({ invitation, txid }) => ({ - data: invitation, - transactionId: txid, - })), + Effect.map( + ({ invitation, txid }) => + new InvitationResponse({ + data: invitation, + transactionId: txid, + }), + ), ), "invitation.revoke": ({ invitationId }) => db .transaction( Effect.gen(function* () { - yield* InvitationPolicy.canRead(invitationId) - const invitationOption = yield* InvitationRepo.findById(invitationId) + yield* invitationPolicy.canRead(invitationId) + const invitationOption = yield* invitationRepo.findById(invitationId) if (Option.isNone(invitationOption)) { return yield* Effect.fail(new InvitationNotFoundError({ invitationId })) @@ -187,8 +193,8 @@ export const InvitationRpcLive = InvitationRpcs.toLayer( ), ) - yield* InvitationPolicy.canUpdate(invitationId) - yield* InvitationRepo.updateStatus(invitationId, "revoked") + yield* invitationPolicy.canUpdate(invitationId) + yield* invitationRepo.updateStatus(invitationId, "revoked") const txid = yield* generateTransactionId() @@ -204,8 +210,8 @@ export const InvitationRpcLive = InvitationRpcs.toLayer( db .transaction( Effect.gen(function* () { - yield* InvitationPolicy.canUpdate(id) - const updatedInvitation = yield* InvitationRepo.update({ + yield* invitationPolicy.canUpdate(id) + const updatedInvitation = yield* invitationRepo.update({ id, ...payload, }) @@ -217,18 +223,21 @@ export const InvitationRpcLive = InvitationRpcs.toLayer( ) .pipe( withRemapDbErrors("Invitation", "update"), - Effect.map(({ updatedInvitation, txid }) => ({ - data: updatedInvitation, - transactionId: txid, - })), + Effect.map( + ({ updatedInvitation, txid }) => + new InvitationResponse({ + data: updatedInvitation, + transactionId: txid, + }), + ), ), "invitation.delete": ({ id }) => db .transaction( Effect.gen(function* () { - yield* InvitationPolicy.canDelete(id) - yield* InvitationRepo.deleteById(id) + yield* invitationPolicy.canDelete(id) + yield* invitationRepo.deleteById(id) const txid = yield* generateTransactionId() diff --git a/apps/backend/src/rpc/handlers/message-reactions.ts b/apps/backend/src/rpc/handlers/message-reactions.ts index c77e5c3e3..84266eeb1 100644 --- a/apps/backend/src/rpc/handlers/message-reactions.ts +++ b/apps/backend/src/rpc/handlers/message-reactions.ts @@ -1,7 +1,7 @@ import { MessageOutboxRepo, MessageReactionRepo } from "@hazel/backend-core" import { Database } from "@hazel/db" import { CurrentUser, withRemapDbErrors } from "@hazel/domain" -import { MessageReactionRpcs } from "@hazel/domain/rpc" +import { MessageReactionResponse, MessageReactionRpcs } from "@hazel/domain/rpc" import type { ChannelId, MessageId, UserId } from "@hazel/schema" import { Effect, Option } from "effect" import { generateTransactionId } from "../../lib/create-transactionId" @@ -13,6 +13,8 @@ export const MessageReactionRpcLive = MessageReactionRpcs.toLayer( const db = yield* Database.Database const outboxRepo = yield* MessageOutboxRepo const connectConversationService = yield* ConnectConversationService + const messageReactionPolicy = yield* MessageReactionPolicy + const messageReactionRepo = yield* MessageReactionRepo return { "messageReaction.toggle": (payload) => @@ -23,8 +25,8 @@ export const MessageReactionRpcLive = MessageReactionRpcs.toLayer( const user = yield* CurrentUser.Context const { messageId, channelId, emoji } = payload - yield* MessageReactionPolicy.canList(messageId) - const existingReaction = yield* MessageReactionRepo.findByMessageUserEmoji( + yield* messageReactionPolicy.canList(messageId) + const existingReaction = yield* messageReactionRepo.findByMessageUserEmoji( messageId, user.id, emoji, @@ -41,8 +43,8 @@ export const MessageReactionRpcLive = MessageReactionRpcs.toLayer( emoji: existingReaction.value.emoji, userId: existingReaction.value.userId, } as const - yield* MessageReactionPolicy.canDelete(existingReaction.value.id) - yield* MessageReactionRepo.deleteById(existingReaction.value.id) + yield* messageReactionPolicy.canDelete(existingReaction.value.id) + yield* messageReactionRepo.deleteById(existingReaction.value.id) yield* outboxRepo.insert({ eventType: "reaction_deleted", aggregateId: existingReaction.value.id, @@ -64,16 +66,18 @@ export const MessageReactionRpcLive = MessageReactionRpcs.toLayer( } // Otherwise, create a new reaction - yield* MessageReactionPolicy.canCreate(messageId) + yield* messageReactionPolicy.canCreate(messageId) const conversationId = yield* connectConversationService.getConversationIdForChannel(channelId) - const createdMessageReaction = yield* MessageReactionRepo.insert({ - messageId, - channelId, - conversationId, - emoji, - userId: user.id, - }).pipe(Effect.map((res) => res[0]!)) + const createdMessageReaction = yield* messageReactionRepo + .insert({ + messageId, + channelId, + conversationId, + emoji, + userId: user.id, + }) + .pipe(Effect.map((res) => res[0]!)) yield* outboxRepo.insert({ eventType: "reaction_created", @@ -108,16 +112,18 @@ export const MessageReactionRpcLive = MessageReactionRpcs.toLayer( Effect.gen(function* () { const user = yield* CurrentUser.Context - yield* MessageReactionPolicy.canCreate(payload.messageId) + yield* messageReactionPolicy.canCreate(payload.messageId) const conversationId = yield* connectConversationService.getConversationIdForChannel( payload.channelId, ) - const createdMessageReaction = yield* MessageReactionRepo.insert({ - ...payload, - conversationId, - userId: user.id, - }).pipe(Effect.map((res) => res[0]!)) + const createdMessageReaction = yield* messageReactionRepo + .insert({ + ...payload, + conversationId, + userId: user.id, + }) + .pipe(Effect.map((res) => res[0]!)) yield* outboxRepo.insert({ eventType: "reaction_created", @@ -130,10 +136,10 @@ export const MessageReactionRpcLive = MessageReactionRpcs.toLayer( const txid = yield* generateTransactionId() - return { + return new MessageReactionResponse({ data: createdMessageReaction, transactionId: txid, - } + }) }), ) .pipe(withRemapDbErrors("MessageReaction", "create")) @@ -145,18 +151,18 @@ export const MessageReactionRpcLive = MessageReactionRpcs.toLayer( db .transaction( Effect.gen(function* () { - yield* MessageReactionPolicy.canUpdate(id) - const updatedMessageReaction = yield* MessageReactionRepo.update({ + yield* messageReactionPolicy.canUpdate(id) + const updatedMessageReaction = yield* messageReactionRepo.update({ id, ...payload, }) const txid = yield* generateTransactionId() - return { + return new MessageReactionResponse({ data: updatedMessageReaction, transactionId: txid, - } + }) }), ) .pipe(withRemapDbErrors("MessageReaction", "update")), @@ -166,7 +172,7 @@ export const MessageReactionRpcLive = MessageReactionRpcs.toLayer( const txResult = yield* db .transaction( Effect.gen(function* () { - const existing = yield* MessageReactionRepo.findById(id) + const existing = yield* messageReactionRepo.findById(id) const deletedSyncPayload = Option.match(existing, { onNone: () => null as null, onSome: (value) => @@ -183,8 +189,8 @@ export const MessageReactionRpcLive = MessageReactionRpcs.toLayer( }, }) - yield* MessageReactionPolicy.canDelete(id) - yield* MessageReactionRepo.deleteById(id) + yield* messageReactionPolicy.canDelete(id) + yield* messageReactionRepo.deleteById(id) if (deletedSyncPayload !== null && Option.isSome(existing)) { yield* outboxRepo.insert({ diff --git a/apps/backend/src/rpc/handlers/messages.ts b/apps/backend/src/rpc/handlers/messages.ts index b13136639..b6cf77451 100644 --- a/apps/backend/src/rpc/handlers/messages.ts +++ b/apps/backend/src/rpc/handlers/messages.ts @@ -1,7 +1,7 @@ import { AttachmentRepo, MessageOutboxRepo, MessageRepo } from "@hazel/backend-core" import { Database } from "@hazel/db" import { CurrentUser, withRemapDbErrors } from "@hazel/domain" -import { MessageRpcs } from "@hazel/domain/rpc" +import { MessageResponse, MessageRpcs } from "@hazel/domain/rpc" import { Effect, Option } from "effect" import { generateTransactionId } from "../../lib/create-transactionId" import { AttachmentPolicy } from "../../policies/attachment-policy" @@ -30,6 +30,10 @@ export const MessageRpcLive = MessageRpcs.toLayer( const botGateway = yield* BotGatewayService const outboxRepo = yield* MessageOutboxRepo const connectConversationService = yield* ConnectConversationService + const messagePolicy = yield* MessagePolicy + const messageRepo = yield* MessageRepo + const attachmentPolicy = yield* AttachmentPolicy + const attachmentRepo = yield* AttachmentRepo return { "message.create": ({ attachmentIds, ...messageData }) => @@ -42,24 +46,26 @@ export const MessageRpcLive = MessageRpcs.toLayer( const response = yield* db .transaction( Effect.gen(function* () { - yield* MessagePolicy.canCreate(messageData.channelId) + yield* messagePolicy.canCreate(messageData.channelId) const conversationId = yield* connectConversationService.getConversationIdForChannel( messageData.channelId, ) - const createdMessage = yield* MessageRepo.insert({ - ...messageData, - conversationId, - authorId: user.id, - deletedAt: null, - }).pipe(Effect.map((res) => res[0]!)) + const createdMessage = yield* messageRepo + .insert({ + ...messageData, + conversationId, + authorId: user.id, + deletedAt: null, + }) + .pipe(Effect.map((res) => res[0]!)) // Update attachments with messageId if provided if (attachmentIds && attachmentIds.length > 0) { yield* Effect.forEach(attachmentIds, (attachmentId) => Effect.gen(function* () { - yield* AttachmentPolicy.canUpdate(attachmentId) - yield* AttachmentRepo.update({ + yield* attachmentPolicy.canUpdate(attachmentId) + yield* attachmentRepo.update({ id: attachmentId, messageId: createdMessage.id, }) @@ -82,10 +88,10 @@ export const MessageRpcLive = MessageRpcs.toLayer( const txid = yield* generateTransactionId() - return { + return new MessageResponse({ data: createdMessage, transactionId: txid, - } + }) }), ) .pipe(withRemapDbErrors("Message", "create")) @@ -112,8 +118,8 @@ export const MessageRpcLive = MessageRpcs.toLayer( const response = yield* db .transaction( Effect.gen(function* () { - yield* MessagePolicy.canUpdate(id) - const updatedMessage = yield* MessageRepo.update({ + yield* messagePolicy.canUpdate(id) + const updatedMessage = yield* messageRepo.update({ id, ...payload, }) @@ -129,10 +135,10 @@ export const MessageRpcLive = MessageRpcs.toLayer( const txid = yield* generateTransactionId() - return { + return new MessageResponse({ data: updatedMessage, transactionId: txid, - } + }) }), ) .pipe(withRemapDbErrors("Message", "update")) @@ -152,9 +158,9 @@ export const MessageRpcLive = MessageRpcs.toLayer( "message.delete": ({ id }) => Effect.gen(function* () { const user = yield* CurrentUser.Context - const existingMessage = yield* MessageRepo.findById(id).pipe( - withRemapDbErrors("Message", "select"), - ) + const existingMessage = yield* messageRepo + .findById(id) + .pipe(withRemapDbErrors("Message", "select")) // Check rate limit before processing yield* checkMessageRateLimit(user.id) @@ -162,8 +168,8 @@ export const MessageRpcLive = MessageRpcs.toLayer( const response = yield* db .transaction( Effect.gen(function* () { - yield* MessagePolicy.canDelete(id) - yield* MessageRepo.deleteById(id) + yield* messagePolicy.canDelete(id) + yield* messageRepo.deleteById(id) if (Option.isSome(existingMessage)) { yield* outboxRepo.insert({ diff --git a/apps/backend/src/rpc/handlers/notifications.ts b/apps/backend/src/rpc/handlers/notifications.ts index c1d68b6d2..17ef9974c 100644 --- a/apps/backend/src/rpc/handlers/notifications.ts +++ b/apps/backend/src/rpc/handlers/notifications.ts @@ -1,7 +1,7 @@ import { ChannelRepo, NotificationRepo, OrganizationMemberRepo } from "@hazel/backend-core" import { Database } from "@hazel/db" import { CurrentUser, UnauthorizedError, withRemapDbErrors } from "@hazel/domain" -import { NotificationRpcs } from "@hazel/domain/rpc" +import { NotificationResponse, NotificationRpcs } from "@hazel/domain/rpc" import { Effect, Option } from "effect" import { generateTransactionId } from "../../lib/create-transactionId" import { NotificationPolicy } from "../../policies/notification-policy" @@ -22,23 +22,29 @@ import { NotificationPolicy } from "../../policies/notification-policy" export const NotificationRpcLive = NotificationRpcs.toLayer( Effect.gen(function* () { const db = yield* Database.Database + const notificationPolicy = yield* NotificationPolicy + const channelRepo = yield* ChannelRepo + const organizationMemberRepo = yield* OrganizationMemberRepo + const notificationRepo = yield* NotificationRepo return { "notification.create": (payload) => db .transaction( Effect.gen(function* () { - yield* NotificationPolicy.canCreate(payload.memberId) - const createdNotification = yield* NotificationRepo.insert({ - ...payload, - }).pipe(Effect.map((res) => res[0]!)) + yield* notificationPolicy.canCreate(payload.memberId) + const createdNotification = yield* notificationRepo + .insert({ + ...payload, + }) + .pipe(Effect.map((res) => res[0]!)) const txid = yield* generateTransactionId() - return { + return new NotificationResponse({ data: createdNotification, transactionId: txid, - } + }) }), ) .pipe(withRemapDbErrors("Notification", "create")), @@ -47,18 +53,18 @@ export const NotificationRpcLive = NotificationRpcs.toLayer( db .transaction( Effect.gen(function* () { - yield* NotificationPolicy.canUpdate(id) - const updatedNotification = yield* NotificationRepo.update({ + yield* notificationPolicy.canUpdate(id) + const updatedNotification = yield* notificationRepo.update({ id, ...payload, }) const txid = yield* generateTransactionId() - return { + return new NotificationResponse({ data: updatedNotification, transactionId: txid, - } + }) }), ) .pipe(withRemapDbErrors("Notification", "update")), @@ -67,8 +73,8 @@ export const NotificationRpcLive = NotificationRpcs.toLayer( db .transaction( Effect.gen(function* () { - yield* NotificationPolicy.canDelete(id) - yield* NotificationRepo.deleteById(id) + yield* notificationPolicy.canDelete(id) + yield* notificationRepo.deleteById(id) const txid = yield* generateTransactionId() @@ -88,9 +94,9 @@ export const NotificationRpcLive = NotificationRpcs.toLayer( const user = yield* CurrentUser.Context // Get the channel to find the organization (system operation) - const channelOption = yield* ChannelRepo.findById(channelId).pipe( - withRemapDbErrors("Channel", "select"), - ) + const channelOption = yield* channelRepo + .findById(channelId) + .pipe(withRemapDbErrors("Channel", "select")) if (Option.isNone(channelOption)) { return yield* Effect.fail( @@ -104,10 +110,9 @@ export const NotificationRpcLive = NotificationRpcs.toLayer( const channel = channelOption.value // Get the organization member for this user (system operation) - const memberOption = yield* OrganizationMemberRepo.findByOrgAndUser( - channel.organizationId, - user.id, - ).pipe(withRemapDbErrors("OrganizationMember", "select")) + const memberOption = yield* organizationMemberRepo + .findByOrgAndUser(channel.organizationId, user.id) + .pipe(withRemapDbErrors("OrganizationMember", "select")) if (Option.isNone(memberOption)) { return yield* Effect.fail( @@ -125,7 +130,7 @@ export const NotificationRpcLive = NotificationRpcs.toLayer( const result = yield* db .transaction( Effect.gen(function* () { - const deleted = yield* NotificationRepo.deleteByMessageIds( + const deleted = yield* notificationRepo.deleteByMessageIds( messageIds, member.id, ) diff --git a/apps/backend/src/rpc/handlers/organization-members.ts b/apps/backend/src/rpc/handlers/organization-members.ts index c098f6f5d..9719c1d2e 100644 --- a/apps/backend/src/rpc/handlers/organization-members.ts +++ b/apps/backend/src/rpc/handlers/organization-members.ts @@ -1,7 +1,11 @@ import { OrganizationMemberRepo } from "@hazel/backend-core" import { Database } from "@hazel/db" import { CurrentUser, InternalServerError, withRemapDbErrors } from "@hazel/domain" -import { OrganizationMemberNotFoundError, OrganizationMemberRpcs } from "@hazel/domain/rpc" +import { + OrganizationMemberNotFoundError, + OrganizationMemberResponse, + OrganizationMemberRpcs, +} from "@hazel/domain/rpc" import { Effect, Option } from "effect" import { generateTransactionId } from "../../lib/create-transactionId" import { OrganizationMemberPolicy } from "../../policies/organization-member-policy" @@ -23,6 +27,9 @@ import { ChannelAccessSyncService } from "../../services/channel-access-sync" export const OrganizationMemberRpcLive = OrganizationMemberRpcs.toLayer( Effect.gen(function* () { const db = yield* Database.Database + const organizationMemberPolicy = yield* OrganizationMemberPolicy + const organizationMemberRepo = yield* OrganizationMemberRepo + const channelAccessSync = yield* ChannelAccessSyncService return { "organizationMember.create": (payload) => @@ -31,24 +38,26 @@ export const OrganizationMemberRpcLive = OrganizationMemberRpcs.toLayer( Effect.gen(function* () { const user = yield* CurrentUser.Context - yield* OrganizationMemberPolicy.canCreate(payload.organizationId) - const createdOrganizationMember = yield* OrganizationMemberRepo.insert({ - ...payload, - userId: user.id, - deletedAt: null, - }).pipe(Effect.map((res) => res[0]!)) + yield* organizationMemberPolicy.canCreate(payload.organizationId) + const createdOrganizationMember = yield* organizationMemberRepo + .insert({ + ...payload, + userId: user.id, + deletedAt: null, + }) + .pipe(Effect.map((res) => res[0]!)) - yield* ChannelAccessSyncService.syncUserInOrganization( + yield* channelAccessSync.syncUserInOrganization( createdOrganizationMember.userId, createdOrganizationMember.organizationId, ) const txid = yield* generateTransactionId() - return { + return new OrganizationMemberResponse({ data: createdOrganizationMember, transactionId: txid, - } + }) }), ) .pipe(withRemapDbErrors("OrganizationMember", "create")), @@ -57,18 +66,18 @@ export const OrganizationMemberRpcLive = OrganizationMemberRpcs.toLayer( db .transaction( Effect.gen(function* () { - yield* OrganizationMemberPolicy.canUpdate(id) - const updatedOrganizationMember = yield* OrganizationMemberRepo.update({ + yield* organizationMemberPolicy.canUpdate(id) + const updatedOrganizationMember = yield* organizationMemberRepo.update({ id, ...payload, }) const txid = yield* generateTransactionId() - return { + return new OrganizationMemberResponse({ data: updatedOrganizationMember, transactionId: txid, - } + }) }), ) .pipe(withRemapDbErrors("OrganizationMember", "update")), @@ -77,9 +86,9 @@ export const OrganizationMemberRpcLive = OrganizationMemberRpcs.toLayer( db .transaction( Effect.gen(function* () { - yield* OrganizationMemberPolicy.canUpdate(id) + yield* organizationMemberPolicy.canUpdate(id) const updatedOrganizationMemberOption = - yield* OrganizationMemberRepo.updateMetadata(id, metadata) + yield* organizationMemberRepo.updateMetadata(id, metadata) const updatedOrganizationMember = yield* Option.match( updatedOrganizationMemberOption, @@ -96,10 +105,10 @@ export const OrganizationMemberRpcLive = OrganizationMemberRpcs.toLayer( const txid = yield* generateTransactionId() - return { + return new OrganizationMemberResponse({ data: updatedOrganizationMember, transactionId: txid, - } + }) }), ) .pipe(withRemapDbErrors("OrganizationMember", "update")), @@ -108,13 +117,13 @@ export const OrganizationMemberRpcLive = OrganizationMemberRpcs.toLayer( db .transaction( Effect.gen(function* () { - yield* OrganizationMemberPolicy.canDelete(id) - const deletedMemberOption = yield* OrganizationMemberRepo.findById(id) + yield* organizationMemberPolicy.canDelete(id) + const deletedMemberOption = yield* organizationMemberRepo.findById(id) - yield* OrganizationMemberRepo.deleteById(id) + yield* organizationMemberRepo.deleteById(id) if (Option.isSome(deletedMemberOption)) { - yield* ChannelAccessSyncService.syncUserInOrganization( + yield* channelAccessSync.syncUserInOrganization( deletedMemberOption.value.userId, deletedMemberOption.value.organizationId, ) diff --git a/apps/backend/src/rpc/handlers/organizations.ts b/apps/backend/src/rpc/handlers/organizations.ts index 7ac1878f0..d02b29c2d 100644 --- a/apps/backend/src/rpc/handlers/organizations.ts +++ b/apps/backend/src/rpc/handlers/organizations.ts @@ -1,4 +1,5 @@ import { GeneratePortalLinkIntent } from "@workos-inc/node" +import type { OrganizationId } from "@hazel/schema" import { ChannelMemberRepo, ChannelRepo, @@ -11,16 +12,19 @@ import { CurrentUser, InternalServerError, withRemapDbErrors } from "@hazel/doma import { AlreadyMemberError, OrganizationNotFoundError, + OrganizationResponse, OrganizationRpcs, OrganizationSlugAlreadyExistsError, PublicInviteDisabledError, } from "@hazel/domain/rpc" -import { Effect, Option } from "effect" +import { Effect, Option, Predicate } from "effect" import { generateTransactionId } from "../../lib/create-transactionId" import { OrganizationPolicy } from "../../policies/organization-policy" import { ChannelAccessSyncService } from "../../services/channel-access-sync" import { WorkOSAuth as WorkOS } from "../../services/workos-auth" +const UNKNOWN_ORGANIZATION_ID = "00000000-0000-4000-8000-000000000000" as OrganizationId + /** * Custom error handler for organization database operations that provides * specific error handling for duplicate slug violations @@ -33,21 +37,26 @@ const handleOrganizationDbErrors = ( effect: Effect.Effect, ): Effect.Effect< R, - | Exclude + | Exclude | InternalServerError | OrganizationSlugAlreadyExistsError, A > => { return effect.pipe( - Effect.catchTags({ - DatabaseError: (err: any) => { + Effect.catchIf( + (e): e is Extract => Predicate.isTagged(e, "DatabaseError"), + (err): Effect.Effect => { + const dbErr = err as unknown as { + type: string + cause: { constraint_name?: string; detail?: string } + } // Check if it's a unique violation on the slug column if ( - err.type === "unique_violation" && - err.cause.constraint_name === "organizations_slug_unique" + dbErr.type === "unique_violation" && + dbErr.cause.constraint_name === "organizations_slug_unique" ) { // Extract slug from error detail if possible - const slugMatch = err.cause.detail?.match(/Key \(slug\)=\(([^)]+)\)/) + const slugMatch = dbErr.cause.detail?.match(/Key \(slug\)=\(([^)]+)\)/) const slug = slugMatch?.[1] || "unknown" return Effect.fail( new OrganizationSlugAlreadyExistsError({ @@ -65,7 +74,10 @@ const handleOrganizationDbErrors = ( }), ) }, - ParseError: (err: any) => + ), + Effect.catchIf( + (e): e is Extract => Predicate.isTagged(e, "SchemaError"), + (err) => Effect.fail( new InternalServerError({ message: `Error ${action}ing ${entityType}`, @@ -73,8 +85,14 @@ const handleOrganizationDbErrors = ( cause: String(err), }), ), - }), - ) + ), + ) as Effect.Effect< + R, + | Exclude + | InternalServerError + | OrganizationSlugAlreadyExistsError, + A + > } } @@ -95,6 +113,13 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( Effect.gen(function* () { const db = yield* Database.Database const workos = yield* WorkOS + const organizationRepo = yield* OrganizationRepo + const organizationPolicy = yield* OrganizationPolicy + const channelRepo = yield* ChannelRepo + const channelMemberRepo = yield* ChannelMemberRepo + const organizationMemberRepo = yield* OrganizationMemberRepo + const userRepo = yield* UserRepo + const channelAccessSync = yield* ChannelAccessSyncService return { "organization.create": (payload) => @@ -105,7 +130,7 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( const currentUser = yield* CurrentUser.Context // Get the user's external ID (WorkOS user ID) - const userOption = yield* UserRepo.findById(currentUser.id).pipe( + const userOption = yield* userRepo.findById(currentUser.id).pipe( Effect.catchTags({ DatabaseError: (err) => Effect.fail( @@ -129,7 +154,7 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( // Check if slug already exists if (payload.slug) { - const existingOrganization = yield* OrganizationRepo.findBySlug(payload.slug) + const existingOrganization = yield* organizationRepo.findBySlug(payload.slug) if (Option.isSome(existingOrganization)) { return yield* Effect.fail( @@ -142,15 +167,17 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( } // Create organization in local database first - yield* OrganizationPolicy.canCreate() - const createdOrganization = yield* OrganizationRepo.insert({ - name: payload.name, - slug: payload.slug, - logoUrl: payload.logoUrl, - settings: payload.settings, - isPublic: false, - deletedAt: null, - }).pipe(Effect.map((res) => res[0]!)) + yield* organizationPolicy.canCreate() + const createdOrganization = yield* organizationRepo + .insert({ + name: payload.name, + slug: payload.slug, + logoUrl: payload.logoUrl, + settings: payload.settings, + isPublic: false, + deletedAt: null, + }) + .pipe(Effect.map((res) => res[0]!)) // Create organization in WorkOS using our DB ID as externalId const workosOrg = yield* workos @@ -190,7 +217,7 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( ), ) - yield* OrganizationMemberRepo.upsertByOrgAndUser({ + yield* organizationMemberRepo.upsertByOrgAndUser({ organizationId: createdOrganization.id, userId: currentUser.id, role: "owner", @@ -201,19 +228,19 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( }) // Setup default channels for the organization - yield* OrganizationRepo.setupDefaultChannels( + yield* organizationRepo.setupDefaultChannels( createdOrganization.id, currentUser.id, ) - yield* ChannelAccessSyncService.syncUserInOrganization( + yield* channelAccessSync.syncUserInOrganization( currentUser.id, createdOrganization.id, ) const txid = yield* generateTransactionId() - return { + return new OrganizationResponse({ data: { ...createdOrganization, settings: createdOrganization.settings as { @@ -221,7 +248,7 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( } | null, }, transactionId: txid, - } + }) }), ) .pipe(handleOrganizationDbErrors("Organization", "create")), @@ -230,16 +257,16 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( db .transaction( Effect.gen(function* () { - yield* Effect.logInfo("OrganizationRepo.update", payload) - yield* OrganizationPolicy.canUpdate(id) - const updatedOrganization = yield* OrganizationRepo.update({ + yield* Effect.logInfo("organizationRepo.update", payload) + yield* organizationPolicy.canUpdate(id) + const updatedOrganization = yield* organizationRepo.update({ id, ...payload, }) const txid = yield* generateTransactionId() - return { + return new OrganizationResponse({ data: { ...updatedOrganization, settings: updatedOrganization.settings as { @@ -247,7 +274,7 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( } | null, }, transactionId: txid, - } + }) }), ) .pipe(handleOrganizationDbErrors("Organization", "update")), @@ -256,8 +283,8 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( db .transaction( Effect.gen(function* () { - yield* OrganizationPolicy.canDelete(id) - yield* OrganizationRepo.deleteById(id) + yield* organizationPolicy.canDelete(id) + yield* organizationRepo.deleteById(id) const txid = yield* generateTransactionId() @@ -270,15 +297,15 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( db .transaction( Effect.gen(function* () { - yield* OrganizationPolicy.canUpdate(id) - const updatedOrganization = yield* OrganizationRepo.update({ + yield* organizationPolicy.canUpdate(id) + const updatedOrganization = yield* organizationRepo.update({ id, slug, }) const txid = yield* generateTransactionId() - return { + return new OrganizationResponse({ data: { ...updatedOrganization, settings: updatedOrganization.settings as { @@ -286,7 +313,7 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( } | null, }, transactionId: txid, - } + }) }), ) .pipe(handleOrganizationDbErrors("Organization", "update")), @@ -295,15 +322,15 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( db .transaction( Effect.gen(function* () { - yield* OrganizationPolicy.canUpdate(id) - const updatedOrganization = yield* OrganizationRepo.update({ + yield* organizationPolicy.canUpdate(id) + const updatedOrganization = yield* organizationRepo.update({ id, isPublic, }) const txid = yield* generateTransactionId() - return { + return new OrganizationResponse({ data: { ...updatedOrganization, settings: updatedOrganization.settings as { @@ -311,14 +338,14 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( } | null, }, transactionId: txid, - } + }) }), ) .pipe(withRemapDbErrors("Organization", "update")), "organization.getBySlugPublic": ({ slug }) => Effect.gen(function* () { - const orgOption = yield* OrganizationRepo.findBySlugIfPublic(slug) + const orgOption = yield* organizationRepo.findBySlugIfPublic(slug) if (Option.isNone(orgOption)) { return null @@ -327,7 +354,7 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( const org = orgOption.value // Count members for this organization - const memberCount = yield* OrganizationMemberRepo.countByOrganization(org.id) + const memberCount = yield* organizationMemberRepo.countByOrganization(org.id) return { id: org.id, @@ -355,11 +382,11 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( const currentUser = yield* CurrentUser.Context // Find the organization by slug - const orgOption = yield* OrganizationRepo.findBySlug(slug) + const orgOption = yield* organizationRepo.findBySlug(slug) if (Option.isNone(orgOption)) { return yield* new OrganizationNotFoundError({ - organizationId: "unknown" as any, + organizationId: UNKNOWN_ORGANIZATION_ID, }) } @@ -373,7 +400,7 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( } // Get the user's external ID for WorkOS sync - const userOption = yield* UserRepo.findById(currentUser.id).pipe( + const userOption = yield* userRepo.findById(currentUser.id).pipe( Effect.catchTags({ DatabaseError: (err) => Effect.fail( @@ -408,7 +435,7 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( ) // Check if user is already a member in local DB - const existingMember = yield* OrganizationMemberRepo.findByOrgAndUser( + const existingMember = yield* organizationMemberRepo.findByOrgAndUser( org.id, currentUser.id, ) @@ -434,7 +461,7 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( } // Create/ensure membership in local database - yield* OrganizationMemberRepo.upsertByOrgAndUser({ + yield* organizationMemberRepo.upsertByOrgAndUser({ organizationId: org.id, userId: currentUser.id, role: "member", @@ -466,10 +493,10 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( } // Add user to the default "general" channel - const generalChannel = yield* ChannelRepo.findByOrgAndName(org.id, "general") + const generalChannel = yield* channelRepo.findByOrgAndName(org.id, "general") if (Option.isSome(generalChannel)) { - yield* ChannelMemberRepo.insert({ + yield* channelMemberRepo.insert({ channelId: generalChannel.value.id, userId: currentUser.id, isHidden: false, @@ -482,11 +509,11 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( }) } - yield* ChannelAccessSyncService.syncUserInOrganization(currentUser.id, org.id) + yield* channelAccessSync.syncUserInOrganization(currentUser.id, org.id) const txid = yield* generateTransactionId() - return { + return new OrganizationResponse({ data: { ...org, settings: org.settings as { @@ -494,7 +521,7 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( } | null, }, transactionId: txid, - } + }) }), ) .pipe(withRemapDbErrors("Organization", "update")), @@ -502,7 +529,7 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( "organization.getAdminPortalLink": ({ id, intent }) => Effect.gen(function* () { // Policy check - only admins/owners can access admin portal - yield* OrganizationPolicy.canUpdate(id) + yield* organizationPolicy.canUpdate(id) // Get the WorkOS organization by our local org ID const workosOrg = yield* workos @@ -551,7 +578,7 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( "organization.listDomains": ({ id }) => Effect.gen(function* () { // Policy check - only admins/owners can list domains - yield* OrganizationPolicy.canUpdate(id) + yield* organizationPolicy.canUpdate(id) // Get the WorkOS organization by our local org ID const workosOrg = yield* workos @@ -578,7 +605,7 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( "organization.addDomain": ({ id, domain }) => Effect.gen(function* () { // Policy check - only admins/owners can add domains - yield* OrganizationPolicy.canUpdate(id) + yield* organizationPolicy.canUpdate(id) // Get the WorkOS organization by our local org ID const workosOrg = yield* workos @@ -623,7 +650,7 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( "organization.removeDomain": ({ id, domainId }) => Effect.gen(function* () { // Policy check - only admins/owners can remove domains - yield* OrganizationPolicy.canUpdate(id) + yield* organizationPolicy.canUpdate(id) // Delete domain from WorkOS yield* workos diff --git a/apps/backend/src/rpc/handlers/pinned-messages.ts b/apps/backend/src/rpc/handlers/pinned-messages.ts index 1b60063a2..658d4962e 100644 --- a/apps/backend/src/rpc/handlers/pinned-messages.ts +++ b/apps/backend/src/rpc/handlers/pinned-messages.ts @@ -1,9 +1,10 @@ -import { PinnedMessageRepo } from "@hazel/backend-core" +import { MessageRepo, PinnedMessageRepo } from "@hazel/backend-core" import { Database } from "@hazel/db" import { CurrentUser, withRemapDbErrors } from "@hazel/domain" -import { PinnedMessageRpcs } from "@hazel/domain/rpc" +import { PinnedMessageResponse, PinnedMessageRpcs } from "@hazel/domain/rpc" import { Effect } from "effect" import { generateTransactionId } from "../../lib/create-transactionId" +import { MessagePolicy } from "../../policies/message-policy" import { PinnedMessagePolicy } from "../../policies/pinned-message-policy" /** @@ -22,6 +23,10 @@ import { PinnedMessagePolicy } from "../../policies/pinned-message-policy" export const PinnedMessageRpcLive = PinnedMessageRpcs.toLayer( Effect.gen(function* () { const db = yield* Database.Database + const messagePolicy = yield* MessagePolicy + const messageRepo = yield* MessageRepo + const pinnedMessagePolicy = yield* PinnedMessagePolicy + const pinnedMessageRepo = yield* PinnedMessageRepo return { "pinnedMessage.create": (payload) => @@ -30,20 +35,22 @@ export const PinnedMessageRpcLive = PinnedMessageRpcs.toLayer( Effect.gen(function* () { const user = yield* CurrentUser.Context - yield* PinnedMessagePolicy.canCreate(payload.channelId) - const createdPinnedMessage = yield* PinnedMessageRepo.insert({ - channelId: payload.channelId, - messageId: payload.messageId, - pinnedBy: user.id, - pinnedAt: new Date(), - }).pipe(Effect.map((res) => res[0]!)) + yield* pinnedMessagePolicy.canCreate(payload.channelId) + const createdPinnedMessage = yield* pinnedMessageRepo + .insert({ + channelId: payload.channelId, + messageId: payload.messageId, + pinnedBy: user.id, + pinnedAt: new Date(), + }) + .pipe(Effect.map((res) => res[0]!)) const txid = yield* generateTransactionId() - return { + return new PinnedMessageResponse({ data: createdPinnedMessage, transactionId: txid, - } + }) }), ) .pipe(withRemapDbErrors("PinnedMessage", "create")), @@ -52,18 +59,18 @@ export const PinnedMessageRpcLive = PinnedMessageRpcs.toLayer( db .transaction( Effect.gen(function* () { - yield* PinnedMessagePolicy.canUpdate(id) - const updatedPinnedMessage = yield* PinnedMessageRepo.update({ + yield* pinnedMessagePolicy.canUpdate(id) + const updatedPinnedMessage = yield* pinnedMessageRepo.update({ id, ...payload, }) const txid = yield* generateTransactionId() - return { + return new PinnedMessageResponse({ data: updatedPinnedMessage, transactionId: txid, - } + }) }), ) .pipe(withRemapDbErrors("PinnedMessage", "update")), @@ -72,8 +79,8 @@ export const PinnedMessageRpcLive = PinnedMessageRpcs.toLayer( db .transaction( Effect.gen(function* () { - yield* PinnedMessagePolicy.canDelete(id) - yield* PinnedMessageRepo.deleteById(id) + yield* pinnedMessagePolicy.canDelete(id) + yield* pinnedMessageRepo.deleteById(id) const txid = yield* generateTransactionId() diff --git a/apps/backend/src/rpc/handlers/rss-subscriptions.ts b/apps/backend/src/rpc/handlers/rss-subscriptions.ts index a1056a8bf..84580242d 100644 --- a/apps/backend/src/rpc/handlers/rss-subscriptions.ts +++ b/apps/backend/src/rpc/handlers/rss-subscriptions.ts @@ -22,6 +22,7 @@ export const RssSubscriptionRpcLive = RssSubscriptionRpcs.toLayer( const channelRepo = yield* ChannelRepo const subscriptionRepo = yield* RssSubscriptionRepo const integrationBotService = yield* IntegrationBotService + const rssSubscriptionPolicy = yield* RssSubscriptionPolicy return { "rssSubscription.create": (payload) => @@ -64,7 +65,7 @@ export const RssSubscriptionRpcLive = RssSubscriptionRpcs.toLayer( }), }) - yield* RssSubscriptionPolicy.canCreate(payload.channelId) + yield* rssSubscriptionPolicy.canCreate(payload.channelId) // Create subscription const [subscription] = yield* subscriptionRepo.insert({ @@ -97,7 +98,7 @@ export const RssSubscriptionRpcLive = RssSubscriptionRpcs.toLayer( "rssSubscription.list": ({ channelId }) => Effect.gen(function* () { - yield* RssSubscriptionPolicy.canRead(channelId) + yield* rssSubscriptionPolicy.canRead(channelId) const subscriptions = yield* subscriptionRepo.findByChannel(channelId) return new RssSubscriptionListResponse({ data: subscriptions }) @@ -113,7 +114,7 @@ export const RssSubscriptionRpcLive = RssSubscriptionRpcs.toLayer( const organizationId = user.organizationId - yield* RssSubscriptionPolicy.canReadByOrganization(organizationId) + yield* rssSubscriptionPolicy.canReadByOrganization(organizationId) const subscriptions = yield* subscriptionRepo.findByOrganization(organizationId) return new RssSubscriptionListResponse({ data: subscriptions }) @@ -130,7 +131,7 @@ export const RssSubscriptionRpcLive = RssSubscriptionRpcs.toLayer( ) } - yield* RssSubscriptionPolicy.canUpdate(id) + yield* rssSubscriptionPolicy.canUpdate(id) const [updatedSubscription] = yield* subscriptionRepo.updateSettings(id, { isEnabled: payload.isEnabled, @@ -158,7 +159,7 @@ export const RssSubscriptionRpcLive = RssSubscriptionRpcs.toLayer( ) } - yield* RssSubscriptionPolicy.canDelete(id) + yield* rssSubscriptionPolicy.canDelete(id) yield* subscriptionRepo.softDelete(id) diff --git a/apps/backend/src/rpc/handlers/typing-indicators.ts b/apps/backend/src/rpc/handlers/typing-indicators.ts index d96319ac3..c599515c1 100644 --- a/apps/backend/src/rpc/handlers/typing-indicators.ts +++ b/apps/backend/src/rpc/handlers/typing-indicators.ts @@ -1,7 +1,7 @@ import { TypingIndicatorRepo } from "@hazel/backend-core" import { Database } from "@hazel/db" import { withRemapDbErrors } from "@hazel/domain" -import { TypingIndicatorNotFoundError, TypingIndicatorRpcs } from "@hazel/domain/rpc" +import { TypingIndicatorNotFoundError, TypingIndicatorResponse, TypingIndicatorRpcs } from "@hazel/domain/rpc" import { Effect, Option } from "effect" import { generateTransactionId } from "../../lib/create-transactionId" import { TypingIndicatorPolicy } from "../../policies/typing-indicator-policy" @@ -25,6 +25,8 @@ import { TypingIndicatorPolicy } from "../../policies/typing-indicator-policy" export const TypingIndicatorRpcLive = TypingIndicatorRpcs.toLayer( Effect.gen(function* () { const db = yield* Database.Database + const typingIndicatorPolicy = yield* TypingIndicatorPolicy + const typingIndicatorRepo = yield* TypingIndicatorRepo return { "typingIndicator.create": (payload) => @@ -38,8 +40,8 @@ export const TypingIndicatorRpcLive = TypingIndicatorRpcs.toLayer( }) // Use upsert to create or update typing indicator - yield* TypingIndicatorPolicy.canCreate(payload.channelId) - const result = yield* TypingIndicatorRepo.upsertByChannelAndMember({ + yield* typingIndicatorPolicy.canCreate(payload.channelId) + const result = yield* typingIndicatorRepo.upsertByChannelAndMember({ channelId: payload.channelId, memberId: payload.memberId, lastTyped: payload.lastTyped ?? Date.now(), @@ -55,10 +57,10 @@ export const TypingIndicatorRpcLive = TypingIndicatorRpcs.toLayer( durationMs: Date.now() - startedAt, }) - return { + return new TypingIndicatorResponse({ data: typingIndicator, transactionId: txid, - } + }) }), ) .pipe(withRemapDbErrors("TypingIndicator", "create")), @@ -72,8 +74,8 @@ export const TypingIndicatorRpcLive = TypingIndicatorRpcs.toLayer( typingIndicatorId: id, }) - yield* TypingIndicatorPolicy.canUpdate(id) - const typingIndicator = yield* TypingIndicatorRepo.update({ + yield* typingIndicatorPolicy.canUpdate(id) + const typingIndicator = yield* typingIndicatorRepo.update({ ...payload, id, lastTyped: Date.now(), @@ -85,10 +87,10 @@ export const TypingIndicatorRpcLive = TypingIndicatorRpcs.toLayer( durationMs: Date.now() - startedAt, }) - return { + return new TypingIndicatorResponse({ data: typingIndicator, transactionId: txid, - } + }) }), ) .pipe(withRemapDbErrors("TypingIndicator", "update")), @@ -103,8 +105,8 @@ export const TypingIndicatorRpcLive = TypingIndicatorRpcs.toLayer( }) // First find the typing indicator to return it - yield* TypingIndicatorPolicy.canRead(id) - const existingOption = yield* TypingIndicatorRepo.findById(id) + yield* typingIndicatorPolicy.canRead(id) + const existingOption = yield* typingIndicatorRepo.findById(id) if (Option.isNone(existingOption)) { return yield* Effect.fail( @@ -114,8 +116,8 @@ export const TypingIndicatorRpcLive = TypingIndicatorRpcs.toLayer( const existing = existingOption.value - yield* TypingIndicatorPolicy.canDelete({ id }) - yield* TypingIndicatorRepo.deleteById(id) + yield* typingIndicatorPolicy.canDelete({ id }) + yield* typingIndicatorRepo.deleteById(id) const txid = yield* generateTransactionId() yield* Effect.logDebug("typingIndicator.delete succeeded", { @@ -125,7 +127,7 @@ export const TypingIndicatorRpcLive = TypingIndicatorRpcs.toLayer( durationMs: Date.now() - startedAt, }) - return { data: existing, transactionId: txid } + return new TypingIndicatorResponse({ data: existing, transactionId: txid }) }), ) .pipe(withRemapDbErrors("TypingIndicator", "delete")), diff --git a/apps/backend/src/rpc/handlers/user-presence-status.ts b/apps/backend/src/rpc/handlers/user-presence-status.ts index 3013c938a..ac2534482 100644 --- a/apps/backend/src/rpc/handlers/user-presence-status.ts +++ b/apps/backend/src/rpc/handlers/user-presence-status.ts @@ -1,7 +1,7 @@ import { UserPresenceStatusRepo } from "@hazel/backend-core" import { Database } from "@hazel/db" import { CurrentUser, withRemapDbErrors } from "@hazel/domain" -import { UserPresenceStatusRpcs } from "@hazel/domain/rpc" +import { UserPresenceStatusResponse, UserPresenceStatusRpcs } from "@hazel/domain/rpc" import { Effect, Option } from "effect" import { generateTransactionId } from "../../lib/create-transactionId" import { UserPresenceStatusPolicy } from "../../policies/user-presence-status-policy" @@ -9,6 +9,8 @@ import { UserPresenceStatusPolicy } from "../../policies/user-presence-status-po export const UserPresenceStatusRpcLive = UserPresenceStatusRpcs.toLayer( Effect.gen(function* () { const db = yield* Database.Database + const userPresenceStatusPolicy = yield* UserPresenceStatusPolicy + const userPresenceStatusRepo = yield* UserPresenceStatusRepo return { "userPresenceStatus.update": (payload) => @@ -17,13 +19,13 @@ export const UserPresenceStatusRpcLive = UserPresenceStatusRpcs.toLayer( Effect.gen(function* () { const user = yield* CurrentUser.Context - yield* UserPresenceStatusPolicy.canRead() - const existingOption = yield* UserPresenceStatusRepo.findByUserId(user.id) + yield* userPresenceStatusPolicy.canRead() + const existingOption = yield* userPresenceStatusRepo.findByUserId(user.id) const existing = Option.getOrNull(existingOption) const now = new Date() - const updatedStatus = yield* UserPresenceStatusRepo.upsertByUserId({ + const updatedStatus = yield* userPresenceStatusRepo.upsertByUserId({ userId: user.id, status: (payload.status ?? existing?.status ?? "online") as | "online" @@ -57,10 +59,10 @@ export const UserPresenceStatusRpcLive = UserPresenceStatusRpcs.toLayer( const txid = yield* generateTransactionId() - return { + return new UserPresenceStatusResponse({ data: updatedStatus!, transactionId: txid, - } + }) }), ) .pipe(withRemapDbErrors("UserPresenceStatus", "update")), @@ -71,13 +73,13 @@ export const UserPresenceStatusRpcLive = UserPresenceStatusRpcs.toLayer( Effect.gen(function* () { const user = yield* CurrentUser.Context - yield* UserPresenceStatusPolicy.canUpdate() - const result = yield* UserPresenceStatusRepo.updateHeartbeat(user.id) + yield* userPresenceStatusPolicy.canUpdate() + const result = yield* userPresenceStatusRepo.updateHeartbeat(user.id) // If no record exists, create one with online status if (Option.isNone(result)) { const now = new Date() - yield* UserPresenceStatusRepo.upsertByUserId({ + yield* userPresenceStatusRepo.upsertByUserId({ userId: user.id, status: "online", customMessage: null, @@ -103,14 +105,14 @@ export const UserPresenceStatusRpcLive = UserPresenceStatusRpcs.toLayer( Effect.gen(function* () { const user = yield* CurrentUser.Context - yield* UserPresenceStatusPolicy.canRead() - const existingOption = yield* UserPresenceStatusRepo.findByUserId(user.id) + yield* userPresenceStatusPolicy.canRead() + const existingOption = yield* userPresenceStatusRepo.findByUserId(user.id) const existing = Option.getOrNull(existingOption) const now = new Date() - yield* UserPresenceStatusPolicy.canCreate() - const updatedStatus = yield* UserPresenceStatusRepo.upsertByUserId({ + yield* userPresenceStatusPolicy.canCreate() + const updatedStatus = yield* userPresenceStatusRepo.upsertByUserId({ userId: user.id, status: existing?.status ?? "online", customMessage: null, @@ -124,10 +126,10 @@ export const UserPresenceStatusRpcLive = UserPresenceStatusRpcs.toLayer( const txid = yield* generateTransactionId() - return { + return new UserPresenceStatusResponse({ data: updatedStatus!, transactionId: txid, - } + }) }), ) .pipe(withRemapDbErrors("UserPresenceStatus", "update")), diff --git a/apps/backend/src/rpc/handlers/users.ts b/apps/backend/src/rpc/handlers/users.ts index 8c06675f7..e62444484 100644 --- a/apps/backend/src/rpc/handlers/users.ts +++ b/apps/backend/src/rpc/handlers/users.ts @@ -1,7 +1,7 @@ import { UserRepo } from "@hazel/backend-core" import { Database } from "@hazel/db" import { CurrentUser, InternalServerError, withRemapDbErrors } from "@hazel/domain" -import { UserNotFoundError, UserRpcs } from "@hazel/domain/rpc" +import { UserNotFoundError, UserResponse, UserRpcs } from "@hazel/domain/rpc" import { Effect, Option } from "effect" import { generateTransactionId } from "../../lib/create-transactionId" import { UserPolicy } from "../../policies/user-policy" @@ -11,16 +11,18 @@ export const UserRpcLive = UserRpcs.toLayer( Effect.gen(function* () { const db = yield* Database.Database const workos = yield* WorkOS + const userPolicy = yield* UserPolicy + const userRepo = yield* UserRepo return { - "user.me": () => CurrentUser.Context, + "user.me": () => CurrentUser.Context.asEffect(), "user.update": ({ id, ...payload }) => db .transaction( Effect.gen(function* () { - yield* UserPolicy.canUpdate(id) - const updatedUser = yield* UserRepo.update({ + yield* userPolicy.canUpdate(id) + const updatedUser = yield* userRepo.update({ id, ...payload, }) @@ -46,10 +48,10 @@ export const UserRpcLive = UserRpcs.toLayer( const txid = yield* generateTransactionId() - return { + return new UserResponse({ data: updatedUser, transactionId: txid, - } + }) }), ) .pipe(withRemapDbErrors("User", "update")), @@ -58,16 +60,16 @@ export const UserRpcLive = UserRpcs.toLayer( db .transaction( Effect.gen(function* () { - yield* UserPolicy.canRead(id) - const userOption = yield* UserRepo.findById(id) + yield* userPolicy.canRead(id) + const userOption = yield* userRepo.findById(id) const user = yield* Option.match(userOption, { onNone: () => Effect.fail(new UserNotFoundError({ userId: id })), onSome: (user) => Effect.succeed(user), }) - yield* UserPolicy.canDelete(id) - yield* UserRepo.deleteById(id) + yield* userPolicy.canDelete(id) + yield* userRepo.deleteById(id) yield* workos .call((client) => client.userManagement.deleteUser(user.externalId)) @@ -96,18 +98,18 @@ export const UserRpcLive = UserRpcs.toLayer( const currentUser = yield* CurrentUser.Context // Update the current user's isOnboarded flag - yield* UserPolicy.canUpdate(currentUser.id) - const updatedUser = yield* UserRepo.update({ + yield* userPolicy.canUpdate(currentUser.id) + const updatedUser = yield* userRepo.update({ id: currentUser.id, isOnboarded: true, }) const txid = yield* generateTransactionId() - return { + return new UserResponse({ data: updatedUser, transactionId: txid, - } + }) }), ) .pipe(withRemapDbErrors("User", "update")), @@ -119,8 +121,8 @@ export const UserRpcLive = UserRpcs.toLayer( const currentUser = yield* CurrentUser.Context // Fetch user from database to get externalId - yield* UserPolicy.canRead(currentUser.id) - const userOption = yield* UserRepo.findById(currentUser.id) + yield* userPolicy.canRead(currentUser.id) + const userOption = yield* userRepo.findById(currentUser.id) const user = yield* Option.match(userOption, { onNone: () => @@ -153,18 +155,18 @@ export const UserRpcLive = UserRpcs.toLayer( : null // Update user's avatar in our database - yield* UserPolicy.canUpdate(currentUser.id) - const updatedUser = yield* UserRepo.update({ + yield* userPolicy.canUpdate(currentUser.id) + const updatedUser = yield* userRepo.update({ id: currentUser.id, avatarUrl, }) const txid = yield* generateTransactionId() - return { + return new UserResponse({ data: updatedUser, transactionId: txid, - } + }) }), ) .pipe(withRemapDbErrors("User", "update")), diff --git a/apps/backend/src/rpc/middleware/auth-class.ts b/apps/backend/src/rpc/middleware/auth-class.ts index 9ec53c147..bc64ae184 100644 --- a/apps/backend/src/rpc/middleware/auth-class.ts +++ b/apps/backend/src/rpc/middleware/auth-class.ts @@ -8,7 +8,7 @@ * when frontend imports RPC group definitions that reference this middleware. */ -import { RpcMiddleware } from "@effect/rpc" +import { RpcMiddleware } from "effect/unstable/rpc" import { CurrentUser, InvalidBearerTokenError, @@ -45,7 +45,7 @@ import { Schema as S } from "effect" * }) * ``` */ -const AuthFailure = S.Union( +const AuthFailure = S.Union([ UnauthorizedError, SessionLoadError, SessionAuthenticationError, @@ -55,10 +55,14 @@ const AuthFailure = S.Union( SessionExpiredError, InvalidBearerTokenError, WorkOSUserFetchError, -) +]) -export class AuthMiddleware extends RpcMiddleware.Tag()("AuthMiddleware", { - provides: CurrentUser.Context, - failure: AuthFailure, +export class AuthMiddleware extends RpcMiddleware.Service< + AuthMiddleware, + { + provides: CurrentUser.Context + } +>()("AuthMiddleware", { + error: AuthFailure, requiredForClient: true, }) {} diff --git a/apps/backend/src/rpc/middleware/auth.test.ts b/apps/backend/src/rpc/middleware/auth.test.ts index be3717e23..ea0420bd1 100644 --- a/apps/backend/src/rpc/middleware/auth.test.ts +++ b/apps/backend/src/rpc/middleware/auth.test.ts @@ -1,22 +1,35 @@ -import { describe, expect, it, layer } from "@effect/vitest" -import { Headers } from "@effect/platform" -import { BotRepo, UserPresenceStatusRepo, UserRepo } from "@hazel/backend-core" -import { Effect, Exit, Layer, Option, FiberRef } from "effect" -import { AuthMiddleware } from "./auth-class.ts" -import { SessionManager } from "../../services/session-manager.ts" -import type { CurrentUser } from "@hazel/domain" -import { SessionExpiredError, InvalidJwtPayloadError, InvalidBearerTokenError } from "@hazel/domain" +import { describe, expect, it } from "@effect/vitest" +import { Headers } from "effect/unstable/http" +import type { SuccessValue } from "effect/unstable/rpc/RpcMiddleware" +import { BotRepo, UserRepo } from "@hazel/backend-core" +import { CurrentUser, type CurrentUser as CurrentUserNamespace } from "@hazel/domain" import type { UserId } from "@hazel/schema" - -// ===== Mock CurrentUser Factory ===== - -const createMockCurrentUser = (overrides?: Partial): CurrentUser.Schema => ({ - id: "usr_test123" as UserId, +import { Effect, Layer, Option, Ref, Result, ServiceMap } from "effect" +import { AuthMiddleware, AuthMiddlewareLive } from "./auth.ts" +import { SessionManager } from "../../services/session-manager.ts" +import { serviceShape } from "../../test/effect-helpers" + +const USER_ID = "00000000-0000-4000-8000-000000000001" as UserId +const BOT_USER_ID = "00000000-0000-4000-8000-000000000002" as UserId + +type SessionManagerShape = ServiceMap.Service.Shape +type BotRepoShape = ServiceMap.Service.Shape +type UserRepoShape = ServiceMap.Service.Shape +type EffectSuccess = T extends Effect.Effect ? A : never +type OptionValue = T extends Option.Option ? A : never +type BotRecord = OptionValue>> +type UserRecord = OptionValue>> +const successValue = { _: Symbol("success") } as SuccessValue + +const makeCurrentUser = ( + overrides: Partial = {}, +): CurrentUserNamespace.Schema => ({ + id: USER_ID, email: "test@example.com", firstName: "Test", lastName: "User", avatarUrl: "https://example.com/avatar.png", - role: "member" as const, + role: "member", isOnboarded: true, timezone: "UTC", organizationId: null, @@ -24,304 +37,182 @@ const createMockCurrentUser = (overrides?: Partial): Current ...overrides, }) -// ===== Mock RPC Context ===== -// Helper to create the full middleware context -const createMiddlewareContext = (headers: Headers.Headers) => ({ - clientId: 1, - rpc: {} as any, // Mock RPC definition - payload: {}, - headers, -}) - -// ===== Mock SessionManager Factory ===== - -const createMockSessionManagerLive = (options?: { - currentUser?: CurrentUser.Schema - shouldFail?: Effect.Effect -}) => - Layer.succeed(SessionManager, { - authenticateWithBearer: (_token: string) => { - if (options?.shouldFail) { - return options.shouldFail - } - return Effect.succeed(options?.currentUser ?? createMockCurrentUser()) - }, - } as unknown as SessionManager) - -// ===== Mock UserPresenceStatusRepo ===== - -const createMockPresenceRepoLive = (options?: { onUpdateStatus?: (params: any) => void }) => - Layer.succeed(UserPresenceStatusRepo, { - updateStatus: (params: any) => { - options?.onUpdateStatus?.(params) - return Effect.void - }, - findByUserId: (_userId: string) => Effect.succeed(Option.none()), - } as unknown as UserPresenceStatusRepo) - -// ===== Mock BotRepo ===== - -const MockBotRepoLive = Layer.succeed(BotRepo, { - findByTokenHash: (_hash: string) => Effect.succeed(Option.none()), -} as unknown as BotRepo) - -// ===== Mock UserRepo ===== - -const MockUserRepoLive = Layer.succeed(UserRepo, { - findById: (_id: string) => Effect.succeed(Option.none()), -} as unknown as UserRepo) - -// ===== Auth Middleware Layer Factory ===== - -const makeAuthMiddlewareLayer = (options?: { - sessionManagerLayer?: Layer.Layer - presenceRepoLayer?: Layer.Layer -}): Layer.Layer => { - const sessionManagerLayer = options?.sessionManagerLayer ?? createMockSessionManagerLive() - const presenceRepoLayer = options?.presenceRepoLayer ?? createMockPresenceRepoLive() - - return Layer.scoped( - AuthMiddleware, - Effect.gen(function* () { - const sessionManager = yield* SessionManager - const presenceRepo = yield* UserPresenceStatusRepo - - // Create a FiberRef to track the current user - const currentUserRef = yield* FiberRef.make>(Option.none()) - - // Add finalizer - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - const userOption = yield* FiberRef.get(currentUserRef) - if (Option.isSome(userOption)) { - yield* ( - presenceRepo.updateStatus({ - userId: userOption.value.id, - status: "offline", - customMessage: null, - }) as unknown as Effect.Effect - ).pipe(Effect.catchAll(() => Effect.void)) - } - }), - ) +const hashToken = async (token: string) => { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token)) + return Array.from(new Uint8Array(digest)) + .map((value) => value.toString(16).padStart(2, "0")) + .join("") +} - return AuthMiddleware.of(({ headers }) => - Effect.gen(function* () { - // Require Bearer token - const authHeader = Headers.get(headers, "authorization") - if (Option.isNone(authHeader) || !authHeader.value.startsWith("Bearer ")) { - return yield* new InvalidBearerTokenError({ - message: "No Bearer token provided", - detail: "Authentication requires a Bearer token", - }) - } +const invokeMiddleware = (headers: Headers.Headers) => + Effect.gen(function* () { + const currentUserRef = yield* Ref.make>(Option.none()) + const middleware = yield* AuthMiddleware + yield* middleware( + Effect.gen(function* () { + const currentUser = yield* CurrentUser.Context + yield* Ref.set(currentUserRef, Option.some(currentUser)) + return successValue + }), + { + clientId: 1, + requestId: 1n as never, + rpc: {} as never, + payload: undefined, + headers, + }, + ) + return yield* Ref.get(currentUserRef) + }) - const token = authHeader.value.slice(7) - const currentUser = yield* sessionManager.authenticateWithBearer(token) +const makeSessionManagerLayer = (currentUser: CurrentUserNamespace.Schema) => + Layer.succeed( + SessionManager, + serviceShape({ + authenticateWithBearer: () => Effect.succeed(currentUser), + }), + ) - // Store user in FiberRef - yield* FiberRef.set(currentUserRef, Option.some(currentUser)) +const makeBotRepoLayer = (findByTokenHash: BotRepoShape["findByTokenHash"]) => + Layer.succeed( + BotRepo, + serviceShape({ + findByTokenHash, + }), + ) - return currentUser - }), - ) +const makeUserRepoLayer = (findById: UserRepoShape["findById"]) => + Layer.succeed( + UserRepo, + serviceShape({ + findById, }), - ).pipe( - Layer.provide(sessionManagerLayer), - Layer.provide(presenceRepoLayer), - Layer.provide(MockBotRepoLive), - Layer.provide(MockUserRepoLive), - ) as Layer.Layer + ) + +const BOT_RECORD: BotRecord = { + id: "00000000-0000-4000-8000-000000000010" as BotRecord["id"], + userId: BOT_USER_ID, + createdBy: USER_ID, + name: "Test Bot", + description: null, + webhookUrl: null, + apiTokenHash: "token-hash", + scopes: ["messages:read"], + metadata: null, + isPublic: false, + installCount: 0, + allowedIntegrations: null, + mentionable: true, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + deletedAt: null, } -// Default test layer -const TestAuthMiddlewareLive = makeAuthMiddlewareLayer() - -// ===== Tests ===== - -describe("AuthMiddleware", () => { - describe("bearer token extraction", () => { - layer(TestAuthMiddlewareLive)("bearer parsing", (it) => { - it.scoped("parses Bearer token from Authorization header", () => - Effect.gen(function* () { - const middleware = yield* AuthMiddleware - const headers = Headers.fromInput({ - authorization: "Bearer valid-bearer-token", - }) - - const result = yield* middleware(createMiddlewareContext(headers)) +const USER_RECORD: UserRecord = { + id: BOT_USER_ID, + externalId: "external-user-id", + email: "bot@example.com", + firstName: "Hazel", + lastName: "Bot", + avatarUrl: null, + userType: "machine", + settings: null, + isOnboarded: true, + timezone: "UTC", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + deletedAt: null, +} - expect(result.id).toBe("usr_test123") - expect(result.email).toBe("test@example.com") - }), - ) +const runAuth = ( + headers: Headers.Headers, + overrides: { + sessionManager?: ReturnType + botRepo?: ReturnType + userRepo?: ReturnType + } = {}, +) => + Effect.runPromise( + Effect.scoped( + invokeMiddleware(headers).pipe( + Effect.provide(AuthMiddlewareLive), + Effect.provide(overrides.sessionManager ?? makeSessionManagerLayer(makeCurrentUser())), + Effect.provide( + overrides.botRepo ?? makeBotRepoLayer(() => Effect.succeed(Option.none())), + ), + Effect.provide( + overrides.userRepo ?? makeUserRepoLayer(() => Effect.succeed(Option.none())), + ), + Effect.result, + ), + ), + ) + +describe("AuthMiddlewareLive", () => { + it("authenticates JWT bearer tokens through SessionManager", async () => { + const currentUser = makeCurrentUser({ email: "jwt@example.com" }) + const result = await runAuth(Headers.fromInput({ authorization: "Bearer a.b.c" }), { + sessionManager: makeSessionManagerLayer(currentUser), }) - layer(TestAuthMiddlewareLive)("missing token", (it) => { - it.scoped("fails with InvalidBearerTokenError when Authorization header is missing", () => - Effect.gen(function* () { - const middleware = yield* AuthMiddleware - const headers = Headers.fromInput({}) - - const exit = yield* middleware(createMiddlewareContext(headers)).pipe(Effect.exit) - - expect(Exit.isFailure(exit)).toBe(true) - }), - ) - - it.scoped("fails when Authorization header has no Bearer prefix", () => - Effect.gen(function* () { - const middleware = yield* AuthMiddleware - const headers = Headers.fromInput({ - authorization: "Basic some-credentials", - }) - - const exit = yield* middleware(createMiddlewareContext(headers)).pipe(Effect.exit) - - expect(Exit.isFailure(exit)).toBe(true) - }), - ) - }) + expect(Result.isSuccess(result)).toBe(true) + if (Result.isSuccess(result)) { + expect(Option.isSome(result.success)).toBe(true) + if (Option.isSome(result.success)) { + expect(result.success.value).toEqual(currentUser) + } + } }) - describe("session validation", () => { - layer(TestAuthMiddlewareLive)("valid session", (it) => { - it.scoped("returns CurrentUser on successful validation", () => - Effect.gen(function* () { - const middleware = yield* AuthMiddleware - const headers = Headers.fromInput({ - authorization: "Bearer valid-token", - }) - - const result = yield* middleware(createMiddlewareContext(headers)) - - expect(result.id).toBe("usr_test123") - expect(result.email).toBe("test@example.com") - expect(result.role).toBe("member") - expect(result.isOnboarded).toBe(true) - }), - ) - }) - - describe("error propagation", () => { - const expiredTokenLayer = makeAuthMiddlewareLayer({ - sessionManagerLayer: createMockSessionManagerLive({ - shouldFail: Effect.fail( - new SessionExpiredError({ - message: "Token expired", - detail: "Could not verify", - }), - ), - }), - }) - - layer(expiredTokenLayer)("expired token", (it) => { - it.scoped("propagates SessionExpiredError from SessionManager", () => - Effect.gen(function* () { - const middleware = yield* AuthMiddleware - const headers = Headers.fromInput({ - authorization: "Bearer expired-token", - }) - - const exit = yield* middleware(createMiddlewareContext(headers)).pipe(Effect.exit) - - expect(Exit.isFailure(exit)).toBe(true) - }), - ) - }) - - const invalidJwtLayer = makeAuthMiddlewareLayer({ - sessionManagerLayer: createMockSessionManagerLive({ - shouldFail: Effect.fail( - new InvalidJwtPayloadError({ - message: "Invalid JWT", - detail: "Malformed token", - }), - ), - }), - }) - - layer(invalidJwtLayer)("invalid JWT", (it) => { - it.scoped("propagates InvalidJwtPayloadError from SessionManager", () => - Effect.gen(function* () { - const middleware = yield* AuthMiddleware - const headers = Headers.fromInput({ - authorization: "Bearer invalid-jwt", - }) - - const exit = yield* middleware(createMiddlewareContext(headers)).pipe(Effect.exit) - - expect(Exit.isFailure(exit)).toBe(true) - }), - ) - }) - }) + it("fails when no bearer token is provided", async () => { + const result = await runAuth(Headers.fromInput({})) + + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + const failureTag = + typeof result.failure === "object" && result.failure !== null && "_tag" in result.failure + ? result.failure._tag + : "unhandled" + expect(failureTag).toBe("SessionNotProvidedError") + } }) - describe("user context", () => { - const customUser = createMockCurrentUser({ - id: "usr_custom_user" as UserId, - email: "custom@example.com", - organizationId: "org_123" as any, - role: "admin", - }) - - const customUserLayer = makeAuthMiddlewareLayer({ - sessionManagerLayer: createMockSessionManagerLive({ - currentUser: customUser, - }), + it("authenticates bot bearer tokens through BotRepo and UserRepo", async () => { + const token = "bot-token" + const tokenHash = await hashToken(token) + const result = await runAuth(Headers.fromInput({ authorization: `Bearer ${token}` }), { + botRepo: makeBotRepoLayer((candidateHash) => + Effect.succeed( + candidateHash === tokenHash ? Option.some(BOT_RECORD) : Option.none(), + ), + ), + userRepo: makeUserRepoLayer((id) => + Effect.succeed(id === BOT_USER_ID ? Option.some(USER_RECORD) : Option.none()), + ), }) - layer(customUserLayer)("custom user data", (it) => { - it.scoped("includes organization context when present", () => - Effect.gen(function* () { - const middleware = yield* AuthMiddleware - const headers = Headers.fromInput({ - authorization: "Bearer org-token", - }) - - const result = yield* middleware(createMiddlewareContext(headers)) - - expect(result.organizationId).toBe("org_123") - expect(result.role).toBe("admin") - }), - ) - - it.scoped("includes all user fields from session", () => - Effect.gen(function* () { - const middleware = yield* AuthMiddleware - const headers = Headers.fromInput({ - authorization: "Bearer full-data-token", - }) - - const result = yield* middleware(createMiddlewareContext(headers)) - - expect(result.id).toBe("usr_custom_user") - expect(result.email).toBe("custom@example.com") - expect(result.firstName).toBe("Test") - expect(result.lastName).toBe("User") - expect(result.avatarUrl).toBe("https://example.com/avatar.png") - }), - ) - }) + expect(Result.isSuccess(result)).toBe(true) + if (Result.isSuccess(result)) { + expect(Option.isSome(result.success)).toBe(true) + if (Option.isSome(result.success)) { + expect(result.success.value.id).toBe(BOT_USER_ID) + expect(result.success.value.email).toBe("bot@example.com") + expect(result.success.value.firstName).toBe("Hazel") + expect(result.success.value.lastName).toBe("Bot") + } + } }) - describe("FiberRef tracking", () => { - layer(TestAuthMiddlewareLive)("user tracking", (it) => { - it.scoped("successfully authenticates and returns user", () => - Effect.gen(function* () { - const middleware = yield* AuthMiddleware - const headers = Headers.fromInput({ - authorization: "Bearer tracked-user-token", - }) - - const result = yield* middleware(createMiddlewareContext(headers)) - - // Verify user is returned correctly - expect(result.id).toBe("usr_test123") - expect(result.email).toBe("test@example.com") - }), - ) - }) + it("fails for invalid bot bearer tokens", async () => { + const result = await runAuth(Headers.fromInput({ authorization: "Bearer invalid-bot-token" })) + + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + const failureTag = + typeof result.failure === "object" && result.failure !== null && "_tag" in result.failure + ? result.failure._tag + : "unhandled" + expect(failureTag).toBe("InvalidBearerTokenError") + } }) }) diff --git a/apps/backend/src/rpc/middleware/auth.ts b/apps/backend/src/rpc/middleware/auth.ts index 665576be9..cebcc1084 100644 --- a/apps/backend/src/rpc/middleware/auth.ts +++ b/apps/backend/src/rpc/middleware/auth.ts @@ -1,7 +1,7 @@ -import { Headers } from "@effect/platform" +import { Headers } from "effect/unstable/http" import { BotRepo, UserRepo } from "@hazel/backend-core" -import { InvalidBearerTokenError, type CurrentUser, SessionNotProvidedError } from "@hazel/domain" -import { Effect, FiberRef, Layer, Option } from "effect" +import { CurrentUser, InvalidBearerTokenError, SessionNotProvidedError } from "@hazel/domain" +import { Effect, Layer, Option } from "effect" import { AuthMiddleware } from "@hazel/domain/rpc" import { type ApiScope, CurrentBotScopes } from "@hazel/domain/scopes" import { SessionManager } from "../../services/session-manager" @@ -34,7 +34,7 @@ export const AuthMiddlewareLive = Layer.effect( const botRepo = yield* BotRepo const userRepo = yield* UserRepo - return AuthMiddleware.of(({ headers }) => + return AuthMiddleware.of((effect, { headers }) => Effect.gen(function* () { // Check for Bearer token first (bot SDK or desktop app authentication) const authHeader = Headers.get(headers, "authorization") @@ -46,7 +46,7 @@ export const AuthMiddlewareLive = Layer.effect( if (isJwtToken(token)) { // Authenticate using WorkOS JWT const currentUser = yield* sessionManager.authenticateWithBearer(token) - return currentUser + return yield* Effect.provideService(effect, CurrentUser.Context, currentUser) } // Otherwise, treat as bot token (hash-based lookup) @@ -74,9 +74,7 @@ export const AuthMiddlewareLive = Layer.effect( const bot = botOption.value - // Set the bot's declared scopes for authorization const botApiScopes = new Set(bot.scopes ?? []) as ReadonlySet - yield* FiberRef.set(CurrentBotScopes, Option.some(botApiScopes)) // Get the bot's user from users table const userOption = yield* userRepo.findById(bot.userId).pipe( @@ -114,7 +112,11 @@ export const AuthMiddlewareLive = Layer.effect( settings: user.settings, } - return botUser + return yield* Effect.provideService( + Effect.provideService(effect, CurrentUser.Context, botUser), + CurrentBotScopes, + Option.some(botApiScopes), + ) } // No valid authentication provided diff --git a/apps/backend/src/rpc/middleware/scope-injection.ts b/apps/backend/src/rpc/middleware/scope-injection.ts index be8e59708..112b97c18 100644 --- a/apps/backend/src/rpc/middleware/scope-injection.ts +++ b/apps/backend/src/rpc/middleware/scope-injection.ts @@ -1,4 +1,4 @@ -import { Context, Effect, Layer, Option } from "effect" +import { ServiceMap, Effect, Layer, Option } from "effect" import { ScopeInjectionMiddleware } from "@hazel/domain/rpc" import { RequiredScopes } from "@hazel/domain/scopes" import { CurrentRpcScopes } from "@hazel/domain/scopes" @@ -11,11 +11,11 @@ import { CurrentRpcScopes } from "@hazel/domain/scopes" */ export const ScopeInjectionMiddlewareLive = Layer.succeed( ScopeInjectionMiddleware, - ScopeInjectionMiddleware.of(({ rpc, next }) => { - const scopesOption = Context.getOption(rpc.annotations, RequiredScopes) + ScopeInjectionMiddleware.of((effect, { rpc }) => { + const scopesOption = ServiceMap.getOption(rpc.annotations, RequiredScopes) if (Option.isNone(scopesOption)) { - return next + return effect } - return Effect.locally(CurrentRpcScopes, scopesOption.value)(next) + return Effect.provideService(effect, CurrentRpcScopes, scopesOption.value) }), ) diff --git a/apps/backend/src/rpc/server.ts b/apps/backend/src/rpc/server.ts index 196bdf843..271779f6a 100644 --- a/apps/backend/src/rpc/server.ts +++ b/apps/backend/src/rpc/server.ts @@ -1,4 +1,4 @@ -import { RpcServer } from "@effect/rpc" +import { RpcServer } from "effect/unstable/rpc" import { AttachmentRpcs, BotRpcs, diff --git a/apps/backend/src/scripts/sync-workos.ts b/apps/backend/src/scripts/sync-workos.ts index b95e82c32..76018f6c8 100644 --- a/apps/backend/src/scripts/sync-workos.ts +++ b/apps/backend/src/scripts/sync-workos.ts @@ -10,13 +10,13 @@ import { Effect, Layer, Logger } from "effect" import { DatabaseLive } from "../services/database" const RepoLive = Layer.mergeAll( - UserRepo.Default, - OrganizationRepo.Default, - OrganizationMemberRepo.Default, - InvitationRepo.Default, + UserRepo.layer, + OrganizationRepo.layer, + OrganizationMemberRepo.layer, + InvitationRepo.layer, ).pipe(Layer.provideMerge(DatabaseLive)) -const MainLive = Layer.mergeAll(WorkOSSync.Default, WorkOSClient.Default).pipe( +const MainLive = Layer.mergeAll(WorkOSSync.layer, WorkOSClient.layer).pipe( Layer.provideMerge(RepoLive), Layer.provideMerge(DatabaseLive), ) @@ -25,6 +25,6 @@ const syncWorkos = Effect.gen(function* () { const workOsSync = yield* WorkOSSync yield* workOsSync.syncAll -}).pipe(Effect.provide(MainLive), Effect.provide(Logger.pretty)) +}).pipe(Effect.provide(MainLive), Effect.provide(Logger.layer([Logger.consolePretty()]))) -Effect.runPromise(syncWorkos) +Effect.runPromise(syncWorkos as Effect.Effect) diff --git a/apps/backend/src/services/auth-redemption-store.test.ts b/apps/backend/src/services/auth-redemption-store.test.ts new file mode 100644 index 000000000..7b0c8c6a7 --- /dev/null +++ b/apps/backend/src/services/auth-redemption-store.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it, vi } from "vitest" +import { Effect, Layer, Schema } from "effect" +import { InternalServerError, OAuthCodeExpiredError, OAuthStateMismatchError } from "@hazel/domain" +import { TokenResponse } from "@hazel/domain/http" +import { serviceShape } from "../test/effect-helpers" + +vi.mock("@hazel/effect-bun", async () => { + const { Layer, ServiceMap } = await import("effect") + class Redis extends ServiceMap.Service< + Redis, + { + readonly get: (key: string) => unknown + readonly del: (key: string) => unknown + readonly send: (command: string, args: string[]) => T + } + >()("@hazel/effect-bun/Redis") {} + + return { + Redis: Object.assign(Redis, { + Default: Layer.empty, + }), + } +}) + +import { Redis } from "@hazel/effect-bun" +import { AuthRedemptionStore } from "./auth-redemption-store" + +type TokenExchangeResponse = Schema.Schema.Type + +interface RedisValue { + value: string + expiresAt: number | null +} + +const makeResponse = (): TokenExchangeResponse => ({ + accessToken: "access-token", + refreshToken: "refresh-token", + expiresIn: 3600, + user: { + id: "user_123", + email: "test@example.com", + firstName: "Test", + lastName: "User", + }, +}) + +const makeRedisLayer = () => { + const store = new Map() + + const getValue = (key: string): string | null => { + const entry = store.get(key) + if (!entry) return null + if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) { + store.delete(key) + return null + } + return entry.value + } + + const setValue = (key: string, value: string, ttlMs?: number) => { + store.set(key, { + value, + expiresAt: ttlMs === undefined ? null : Date.now() + ttlMs, + }) + } + + return Layer.succeed( + Redis, + serviceShape({ + get: (key: string) => Effect.succeed(getValue(key)), + del: (key: string) => + Effect.sync(() => { + store.delete(key) + }), + send: (command: string, args: string[]) => + Effect.sync(() => { + if (command === "EVAL") { + const [, , key, processingValue, ttlMs] = args + const existing = getValue(key) + if (existing === null) { + setValue(key, processingValue, Number(ttlMs)) + return ["claimed", ""] as T + } + return ["existing", existing] as T + } + + if (command === "SET") { + const [key, value, , ttlMs] = args + setValue(key, value, Number(ttlMs)) + return "OK" as T + } + + throw new Error(`Unsupported Redis command in test: ${command}`) + }), + }), + ) +} + +const makeStore = async () => + await Effect.runPromise( + Effect.gen(function* () { + return yield* AuthRedemptionStore + }).pipe( + Effect.provide( + Layer.effect(AuthRedemptionStore, AuthRedemptionStore.make).pipe( + Layer.provide(makeRedisLayer()), + ), + ), + ), + ) + +describe("AuthRedemptionStore", () => { + it("deduplicates concurrent redemptions and calls WorkOS once", async () => { + const store = await makeStore() + let resolveGate: () => void + const gate = { + promise: new Promise((r) => { + resolveGate = r + }), + resolve: () => resolveGate(), + } + let calls = 0 + const response = makeResponse() + const exchange = Effect.gen(function* () { + calls += 1 + yield* Effect.promise(() => gate.promise) + return response + }) + + const first = Effect.runPromise( + store.exchangeCodeOnce({ code: "code-1", state: JSON.stringify({ returnTo: "/" }) }, exchange), + ) + const second = Effect.runPromise( + store.exchangeCodeOnce({ code: "code-1", state: JSON.stringify({ returnTo: "/" }) }, exchange), + ) + + gate.resolve() + const result = await Promise.all([first, second]) + + expect(result).toEqual([response, response]) + expect(calls).toBe(1) + }) + + it("returns cached success on a duplicate request after completion", async () => { + const store = await makeStore() + let calls = 0 + const response = makeResponse() + const exchange = Effect.gen(function* () { + calls += 1 + return response + }) + + const first = await Effect.runPromise( + store.exchangeCodeOnce({ code: "code-2", state: JSON.stringify({ returnTo: "/" }) }, exchange), + ) + const second = await Effect.runPromise( + store.exchangeCodeOnce({ code: "code-2", state: JSON.stringify({ returnTo: "/" }) }, exchange), + ) + + expect([first, second]).toEqual([response, response]) + expect(calls).toBe(1) + }) + + it("caches invalid_grant failures and replays them to duplicates", async () => { + const store = await makeStore() + let calls = 0 + const exchange = Effect.gen(function* () { + calls += 1 + return yield* Effect.fail( + new OAuthCodeExpiredError({ + message: "Authorization code expired or already used", + }), + ) + }) + + const first = await Effect.runPromise( + Effect.flip( + store.exchangeCodeOnce( + { code: "code-3", state: JSON.stringify({ returnTo: "/" }) }, + exchange, + ), + ), + ) + const second = await Effect.runPromise( + Effect.flip( + store.exchangeCodeOnce( + { code: "code-3", state: JSON.stringify({ returnTo: "/" }) }, + exchange, + ), + ), + ) + + expect(first).toEqual( + new OAuthCodeExpiredError({ + message: "Authorization code expired or already used", + }), + ) + expect(second).toEqual( + new OAuthCodeExpiredError({ + message: "Authorization code expired or already used", + }), + ) + expect(calls).toBe(1) + }) + + it("clears the processing lock on transient failures so retries can re-run", async () => { + const store = await makeStore() + let calls = 0 + const response = makeResponse() + + const first = await Effect.runPromise( + Effect.flip( + store.exchangeCodeOnce( + { code: "code-4", state: JSON.stringify({ returnTo: "/" }) }, + Effect.gen(function* () { + calls += 1 + return yield* Effect.fail( + new InternalServerError({ + message: "Temporary database failure", + }), + ) + }), + ), + ), + ) + + const second = await Effect.runPromise( + store.exchangeCodeOnce( + { code: "code-4", state: JSON.stringify({ returnTo: "/" }) }, + Effect.gen(function* () { + calls += 1 + return response + }), + ), + ) + + expect(first).toEqual( + new InternalServerError({ + message: "Temporary database failure", + }), + ) + expect(second).toEqual(response) + expect(calls).toBe(2) + }) + + it("rejects reused codes when the duplicate request has a different state payload", async () => { + const store = await makeStore() + + await Effect.runPromise( + store.exchangeCodeOnce( + { code: "code-5", state: JSON.stringify({ returnTo: "/" }) }, + Effect.succeed(makeResponse()), + ), + ) + + const result = await Effect.runPromise( + Effect.flip( + store.exchangeCodeOnce( + { code: "code-5", state: JSON.stringify({ returnTo: "/other" }) }, + Effect.succeed(makeResponse()), + ), + ), + ) + + expect(result).toEqual( + new OAuthStateMismatchError({ + message: "Received a duplicate OAuth redemption with mismatched state. Please restart login.", + }), + ) + }) +}) diff --git a/apps/backend/src/services/auth-redemption-store.ts b/apps/backend/src/services/auth-redemption-store.ts new file mode 100644 index 000000000..2c0365bca --- /dev/null +++ b/apps/backend/src/services/auth-redemption-store.ts @@ -0,0 +1,478 @@ +import { createHash } from "node:crypto" +import { Redis, type RedisErrors } from "@hazel/effect-bun" +import { + InternalServerError, + OAuthCodeExpiredError, + OAuthRedemptionPendingError, + OAuthStateMismatchError, +} from "@hazel/domain" +import { TokenResponse } from "@hazel/domain/http" +import { Duration, Effect, Layer, Schema, ServiceMap } from "effect" + +const AUTH_REDEMPTION_PREFIX = "auth:redemption" +const PROCESSING_TTL_MS = 30_000 +const RESULT_TTL_MS = 5 * 60_000 +const POLL_INTERVAL = Duration.millis(50) +const POLL_TIMEOUT_MS = 2_000 + +const CLAIM_PROCESSING_SCRIPT = ` +local key = KEYS[1] +local processingValue = ARGV[1] +local ttlMs = ARGV[2] + +local existing = redis.call("GET", key) +if not existing then + redis.call("SET", key, processingValue, "PX", ttlMs) + return { "claimed", "" } +end + +return { "existing", existing } +` + +const PermanentFailureSchema = Schema.Struct({ + _tag: Schema.Literal("OAuthCodeExpiredError"), + message: Schema.String, +}) + +const ProcessingRecordSchema = Schema.Struct({ + status: Schema.Literal("processing"), + requestHash: Schema.String, + codeHash: Schema.String, + createdAt: Schema.Number, +}) + +const SucceededRecordSchema = Schema.Struct({ + status: Schema.Literal("succeeded"), + requestHash: Schema.String, + codeHash: Schema.String, + createdAt: Schema.Number, + response: TokenResponse, +}) + +const FailedPermanentRecordSchema = Schema.Struct({ + status: Schema.Literal("failed_permanent"), + requestHash: Schema.String, + codeHash: Schema.String, + createdAt: Schema.Number, + error: PermanentFailureSchema, +}) + +const StoredRedemptionSchema = Schema.Union([ + ProcessingRecordSchema, + SucceededRecordSchema, + FailedPermanentRecordSchema, +]) + +type StoredRedemption = Schema.Schema.Type +type TokenExchangeResponse = Schema.Schema.Type +type ClaimResult = { _tag: "claimed" } | { _tag: "existing"; record: StoredRedemption } +type ExchangeResult = + | { _tag: "success"; response: TokenExchangeResponse } + | { _tag: "expired"; error: OAuthCodeExpiredError } + | { _tag: "internal"; error: InternalServerError } + +const mapRedisError = (message: string) => (error: RedisErrors) => + new InternalServerError({ + message, + detail: String(error), + cause: error, + }) + +const hashString = (value: string): string => createHash("sha256").update(value).digest("hex") + +const normalizeJsonValue = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(normalizeJsonValue) + } + + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nested]) => [key, normalizeJsonValue(nested)]), + ) + } + + return value +} + +const canonicalizeState = (state: string): string => { + try { + return JSON.stringify(normalizeJsonValue(JSON.parse(state))) + } catch { + return state + } +} + +const getCodeHash = (code: string): string => hashString(code) +const getStateHash = (state: string): string => hashString(canonicalizeState(state)) +const getRequestHash = (code: string, state: string): string => + hashString(JSON.stringify({ code, state: canonicalizeState(state) })) + +const getRedisKey = (codeHash: string): string => `${AUTH_REDEMPTION_PREFIX}:${codeHash}` +const shortHash = (value: string): string => value.slice(0, 12) + +const decodeStoredRedemption = (raw: string): Effect.Effect => + Effect.try({ + try: () => Schema.decodeSync(StoredRedemptionSchema)(JSON.parse(raw)), + catch: (cause) => + new InternalServerError({ + message: "Failed to decode cached auth redemption", + detail: String(cause), + cause, + }), + }) + +const encodeStoredRedemption = (record: StoredRedemption): Effect.Effect => + Effect.try({ + try: () => JSON.stringify(record), + catch: (cause) => + new InternalServerError({ + message: "Failed to encode auth redemption state", + detail: String(cause), + cause, + }), + }) + +const toPermanentFailure = ( + error: OAuthCodeExpiredError, +): Schema.Schema.Type => ({ + _tag: "OAuthCodeExpiredError", + message: error.message, +}) + +const ensureMatchingRequest = ( + record: StoredRedemption, + requestHash: string, +): Effect.Effect => + record.requestHash === requestHash + ? Effect.void + : Effect.fail( + new OAuthStateMismatchError({ + message: + "Received a duplicate OAuth redemption with mismatched state. Please restart login.", + }), + ) + +const revivePermanentFailure = ( + error: Schema.Schema.Type, +): OAuthCodeExpiredError => + new OAuthCodeExpiredError({ + message: error.message, + }) + +export class AuthRedemptionStore extends ServiceMap.Service()("AuthRedemptionStore", { + make: Effect.gen(function* () { + const redis = yield* Redis + + const writeRecord = (key: string, record: StoredRedemption, ttlMs: number) => + Effect.gen(function* () { + const value = yield* encodeStoredRedemption(record) + yield* redis + .send("SET", [key, value, "PX", String(ttlMs)]) + .pipe(Effect.mapError(mapRedisError("Failed to persist OAuth redemption state"))) + }) + + const deleteRecord = (key: string) => + redis.del(key).pipe(Effect.mapError(mapRedisError("Failed to clear OAuth redemption state"))) + + const readRecord = (key: string) => + Effect.gen(function* () { + const raw = yield* redis + .get(key) + .pipe(Effect.mapError(mapRedisError("Failed to read OAuth redemption state"))) + + if (raw === null) { + return null + } + + return yield* decodeStoredRedemption(raw).pipe( + Effect.catchTag("InternalServerError", (error) => + deleteRecord(key).pipe(Effect.flatMap(() => Effect.fail(error))), + ), + ) + }) + + const claimProcessing = (key: string, record: Schema.Schema.Type) => + Effect.gen(function* () { + const processingValue = yield* encodeStoredRedemption(record) + const [status, existingValue] = yield* redis + .send<[string, string]>("EVAL", [ + CLAIM_PROCESSING_SCRIPT, + "1", + key, + processingValue, + String(PROCESSING_TTL_MS), + ]) + .pipe(Effect.mapError(mapRedisError("Failed to claim OAuth redemption state"))) + + if (status === "claimed") { + return { _tag: "claimed" } satisfies ClaimResult + } + + const existingRecord = yield* decodeStoredRedemption(existingValue) + return { + _tag: "existing", + record: existingRecord, + } satisfies ClaimResult + }) + + const awaitCompletion = ( + key: string, + requestHash: string, + codeHash: string, + stateHash: string, + attemptId: string, + startedAt: number, + ): Effect.Effect< + TokenExchangeResponse | null, + | OAuthCodeExpiredError + | OAuthStateMismatchError + | OAuthRedemptionPendingError + | InternalServerError + > => + Effect.gen(function* () { + const current = yield* readRecord(key) + if (current === null) { + yield* Effect.logInfo("[auth/token] Redemption lock cleared before completion", { + attemptId, + codeHash: shortHash(codeHash), + stateHash: shortHash(stateHash), + outcome: "lock_cleared", + }) + return null + } + + yield* ensureMatchingRequest(current, requestHash) + + switch (current.status) { + case "succeeded": + yield* Effect.logInfo("[auth/token] Reused cached OAuth redemption", { + attemptId, + codeHash: shortHash(codeHash), + stateHash: shortHash(stateHash), + outcome: "awaited_success", + }) + return current.response + case "failed_permanent": + yield* Effect.logInfo("[auth/token] Reused cached OAuth redemption failure", { + attemptId, + codeHash: shortHash(codeHash), + stateHash: shortHash(stateHash), + outcome: "awaited_failure", + errorTag: current.error._tag, + }) + return yield* Effect.fail(revivePermanentFailure(current.error)) + case "processing": + if (Date.now() - startedAt >= POLL_TIMEOUT_MS) { + yield* Effect.logError( + "[auth/token] OAuth redemption still pending after poll timeout", + { + attemptId, + codeHash: shortHash(codeHash), + stateHash: shortHash(stateHash), + outcome: "pending_timeout", + }, + ) + return yield* Effect.fail( + new OAuthRedemptionPendingError({ + message: + "Another login callback is still finishing. Please retry in a moment.", + }), + ) + } + + yield* Effect.sleep(POLL_INTERVAL) + return yield* awaitCompletion( + key, + requestHash, + codeHash, + stateHash, + attemptId, + startedAt, + ) + } + }) + + const exchangeCodeOnce = ( + params: { + code: string + state: string + attemptId?: string + }, + exchange: Effect.Effect, + ): Effect.Effect< + TokenExchangeResponse, + | OAuthCodeExpiredError + | OAuthStateMismatchError + | OAuthRedemptionPendingError + | InternalServerError, + R + > => + Effect.gen(function* () { + const codeHash = getCodeHash(params.code) + const stateHash = getStateHash(params.state) + const requestHash = getRequestHash(params.code, params.state) + const key = getRedisKey(codeHash) + const createdAt = Date.now() + const attemptId = params.attemptId ?? "missing" + + yield* Effect.logInfo("[auth/token] OAuth redemption requested", { + attemptId, + codeHash: shortHash(codeHash), + stateHash: shortHash(stateHash), + requestHash: shortHash(requestHash), + outcome: "requested", + }) + + const processingRecord = { + status: "processing" as const, + requestHash, + codeHash, + createdAt, + } + + const claimResult: ClaimResult = yield* claimProcessing(key, processingRecord) + if (claimResult._tag === "existing") { + yield* ensureMatchingRequest(claimResult.record, requestHash).pipe( + Effect.catchTag("OAuthStateMismatchError", (error) => + Effect.logError("[auth/token] OAuth redemption state mismatch", { + attemptId, + codeHash: shortHash(codeHash), + stateHash: shortHash(stateHash), + outcome: "mismatch", + }).pipe(Effect.flatMap(() => Effect.fail(error))), + ), + ) + + if (claimResult.record.status === "processing") { + yield* Effect.logInfo("[auth/token] Waiting for in-flight OAuth redemption", { + attemptId, + codeHash: shortHash(codeHash), + stateHash: shortHash(stateHash), + outcome: "waiting", + }) + const awaited = yield* awaitCompletion( + key, + requestHash, + codeHash, + stateHash, + attemptId, + Date.now(), + ) + if (awaited !== null) { + return awaited + } + return yield* exchangeCodeOnce(params, exchange) + } + + if (claimResult.record.status === "succeeded") { + yield* Effect.logInfo("[auth/token] Returning cached OAuth redemption", { + attemptId, + codeHash: shortHash(codeHash), + stateHash: shortHash(stateHash), + outcome: "cached_success", + }) + return claimResult.record.response + } + + yield* Effect.logInfo("[auth/token] Returning cached OAuth redemption failure", { + attemptId, + codeHash: shortHash(codeHash), + stateHash: shortHash(stateHash), + outcome: "cached_failure", + errorTag: claimResult.record.error._tag, + }) + return yield* Effect.fail(revivePermanentFailure(claimResult.record.error)) + } + + yield* Effect.logInfo("[auth/token] Claim acquired, redeeming with WorkOS", { + attemptId, + codeHash: shortHash(codeHash), + stateHash: shortHash(stateHash), + outcome: "fresh", + }) + + const exchangeResult: ExchangeResult = yield* exchange.pipe( + Effect.map((response) => ({ _tag: "success", response }) satisfies ExchangeResult), + Effect.catchTag("OAuthCodeExpiredError", (error) => + Effect.succeed({ _tag: "expired", error } satisfies ExchangeResult), + ), + Effect.catchTag("InternalServerError", (error) => + Effect.succeed({ _tag: "internal", error } satisfies ExchangeResult), + ), + ) + + switch (exchangeResult._tag) { + case "success": + yield* writeRecord( + key, + { + status: "succeeded", + requestHash, + codeHash, + createdAt, + response: exchangeResult.response, + }, + RESULT_TTL_MS, + ) + yield* Effect.logInfo("[auth/token] OAuth redemption completed", { + attemptId, + codeHash: shortHash(codeHash), + stateHash: shortHash(stateHash), + outcome: "succeeded", + }) + return exchangeResult.response + case "expired": + yield* writeRecord( + key, + { + status: "failed_permanent", + requestHash, + codeHash, + createdAt, + error: toPermanentFailure(exchangeResult.error), + }, + RESULT_TTL_MS, + ) + yield* Effect.logInfo( + "[auth/token] OAuth redemption completed with permanent failure", + { + attemptId, + codeHash: shortHash(codeHash), + stateHash: shortHash(stateHash), + outcome: "expired", + errorTag: exchangeResult.error._tag, + }, + ) + return yield* Effect.fail(exchangeResult.error) + case "internal": + yield* deleteRecord(key) + yield* Effect.logError( + "[auth/token] OAuth redemption reset after transient failure", + { + attemptId, + codeHash: shortHash(codeHash), + stateHash: shortHash(stateHash), + outcome: "transient_reset", + errorTag: exchangeResult.error._tag, + }, + ) + return yield* Effect.fail(exchangeResult.error) + } + }).pipe( + Effect.annotateLogs({ + authAttemptId: params.attemptId ?? "missing", + authCodeHash: shortHash(getCodeHash(params.code)), + authStateHash: shortHash(getStateHash(params.state)), + }), + Effect.withSpan("AuthRedemptionStore.exchangeCodeOnce"), + ) + + return { + exchangeCodeOnce, + } + }), +}) { + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(Redis.Default)) +} diff --git a/apps/backend/src/services/auth.ts b/apps/backend/src/services/auth.ts index f03c22fd8..14f4c172b 100644 --- a/apps/backend/src/services/auth.ts +++ b/apps/backend/src/services/auth.ts @@ -9,14 +9,15 @@ export const AuthorizationLive = Layer.effect( const sessionManager = yield* SessionManager - return { - bearer: (bearerToken) => + return CurrentUser.Authorization.of({ + bearer: (httpEffect, { credential: bearerToken }) => Effect.gen(function* () { yield* Effect.logDebug("checking bearer token") // Use SessionManager to handle bearer token authentication - return yield* sessionManager.authenticateWithBearer(Redacted.value(bearerToken)) + const user = yield* sessionManager.authenticateWithBearer(Redacted.value(bearerToken)) + return yield* Effect.provideService(httpEffect, CurrentUser.Context, user) }), - } + }) }), ) diff --git a/apps/backend/src/services/bot-gateway-service.test.ts b/apps/backend/src/services/bot-gateway-service.test.ts index f2a42f8d1..fb87e66e2 100644 --- a/apps/backend/src/services/bot-gateway-service.test.ts +++ b/apps/backend/src/services/bot-gateway-service.test.ts @@ -1,39 +1,48 @@ import { describe, expect, it } from "@effect/vitest" import { BotInstallationRepo, ChannelRepo } from "@hazel/backend-core" import { createBotGatewayPartitionKey } from "@hazel/domain" -import type { BotId, ChannelId, OrganizationId, UserId } from "@hazel/schema" -import { ConfigProvider, Effect, Layer, Option } from "effect" +import { Message } from "@hazel/domain/models" +import type { BotId, ChannelId, MessageId, OrganizationId, UserId } from "@hazel/schema" +import { Effect, Layer, Option, Schema } from "effect" +import { configLayer, serviceShape } from "../test/effect-helpers" import { BotGatewayService } from "./bot-gateway-service" const DURABLE_STREAMS_URL = "http://durable.test/v1/stream" -const BOT_ID = "00000000-0000-0000-0000-000000000111" as BotId -const SECOND_BOT_ID = "00000000-0000-0000-0000-000000000112" as BotId -const CHANNEL_ID = "00000000-0000-0000-0000-000000000444" as ChannelId -const ORG_ID = "00000000-0000-0000-0000-000000000333" as OrganizationId -const USER_ID = "00000000-0000-0000-0000-000000000222" as UserId +const BOT_ID = "00000000-0000-4000-8000-000000000111" as BotId +const SECOND_BOT_ID = "00000000-0000-4000-8000-000000000112" as BotId +const CHANNEL_ID = "00000000-0000-4000-8000-000000000444" as ChannelId +const ORG_ID = "00000000-0000-4000-8000-000000000333" as OrganizationId +const USER_ID = "00000000-0000-4000-8000-000000000222" as UserId +const MESSAGE_ID = "00000000-0000-4000-8000-000000000555" as MessageId -const TestConfigLive = Layer.setConfigProvider( - ConfigProvider.fromMap(new Map([["DURABLE_STREAMS_URL", DURABLE_STREAMS_URL]])), -) +const TestConfigLive = configLayer({ + DURABLE_STREAMS_URL, +}) const makeBotInstallationRepoLayer = (botIds: ReadonlyArray) => - Layer.succeed(BotInstallationRepo, { - getBotIdsForOrg: () => Effect.succeed([...botIds]), - } as unknown as BotInstallationRepo) + Layer.succeed( + BotInstallationRepo, + serviceShape({ + getBotIdsForOrg: () => Effect.succeed([...botIds]), + }), + ) const makeChannelRepoLayer = (organizationId: OrganizationId) => - Layer.succeed(ChannelRepo, { - findById: (id: ChannelId) => - Effect.succeed( - Option.some({ - id, - organizationId, - }), - ), - } as unknown as ChannelRepo) + Layer.succeed( + ChannelRepo, + serviceShape({ + findById: (id: ChannelId) => + Effect.succeed( + Option.some({ + id, + organizationId, + }), + ), + }), + ) const makeServiceLayer = (botIds: ReadonlyArray) => - BotGatewayService.DefaultWithoutDependencies.pipe( + Layer.effect(BotGatewayService, BotGatewayService.make).pipe( Layer.provide(makeBotInstallationRepoLayer(botIds)), Layer.provide(makeChannelRepoLayer(ORG_ID)), Layer.provide(TestConfigLive), @@ -99,6 +108,19 @@ describe("BotGatewayService", () => { it("fans message events out to every bot installed in the organization", () => { const originalFetch = globalThis.fetch const requests: Array<{ url: string; method: string; body: string | null }> = [] + const message = { + id: MESSAGE_ID, + channelId: CHANNEL_ID, + conversationId: null, + authorId: USER_ID, + content: "hello from hazel", + embeds: null, + replyToMessageId: null, + threadChannelId: null, + createdAt: new Date("2026-03-05T12:00:00.000Z"), + updatedAt: null, + deletedAt: null, + } satisfies Schema.Schema.Type globalThis.fetch = (async (input, init) => { requests.push({ @@ -112,12 +134,7 @@ describe("BotGatewayService", () => { return Effect.runPromise( Effect.gen(function* () { const gateway = yield* BotGatewayService - yield* gateway.publishMessageEvent("message.create", { - id: "00000000-0000-0000-0000-000000000555", - channelId: CHANNEL_ID, - createdAt: new Date("2026-03-05T12:00:00.000Z"), - updatedAt: null, - } as any) + yield* gateway.publishMessageEvent("message.create", message) const appendRequests = requests.filter((request) => request.method === "POST") expect(appendRequests).toHaveLength(2) diff --git a/apps/backend/src/services/bot-gateway-service.ts b/apps/backend/src/services/bot-gateway-service.ts index 65d7ea9d6..d43033113 100644 --- a/apps/backend/src/services/bot-gateway-service.ts +++ b/apps/backend/src/services/bot-gateway-service.ts @@ -6,10 +6,14 @@ import { } from "@hazel/domain" import type { Channel, ChannelMember, Message } from "@hazel/domain/models" import type { BotId, ChannelId, OrganizationId } from "@hazel/schema" -import { Config, Effect, Option, Ref, Schema } from "effect" +import { ServiceMap, Config, DateTime, Effect, Layer, Option, Ref, Schema } from "effect" const DEFAULT_DURABLE_STREAMS_URL = "http://localhost:4437/v1/stream" +/** Get epoch milliseconds from Date or DateTime.Utc */ +const toEpochMs = (d: Date | DateTime.Utc): number => + d instanceof Date ? d.getTime() : DateTime.toEpochMillis(d) + const normalizeBaseUrl = (value: string): string => value.replace(/\/+$/, "") const createDeliveryId = (): string => crypto.randomUUID() @@ -20,7 +24,7 @@ const buildStreamPath = (baseUrl: string, botId: BotId): string => const responseText = (response: Response): Promise => response.text().catch(() => `${response.status} ${response.statusText}`) -export class DurableStreamRequestError extends Schema.TaggedError()( +export class DurableStreamRequestError extends Schema.TaggedErrorClass()( "DurableStreamRequestError", { message: Schema.String, @@ -28,10 +32,8 @@ export class DurableStreamRequestError extends Schema.TaggedError()("BotGatewayService", { - accessors: true, - dependencies: [BotInstallationRepo.Default, ChannelRepo.Default], - effect: Effect.gen(function* () { +export class BotGatewayService extends ServiceMap.Service()("BotGatewayService", { + make: Effect.gen(function* () { const installationRepo = yield* BotInstallationRepo const channelRepo = yield* ChannelRepo const durableStreamsUrl = yield* Config.string("DURABLE_STREAMS_URL").pipe( @@ -181,15 +183,18 @@ export class BotGatewayService extends Effect.Service()("BotG const publishMessageEvent = Effect.fn("BotGatewayService.publishMessageEvent")(function* ( eventType: "message.create" | "message.update" | "message.delete", - message: Schema.Schema.Type, + message: Schema.Schema.Type, ) { const organizationId = yield* resolveOrganizationIdForChannel(message.channelId) if (!organizationId) { return } - const eventTimestamp = - message.updatedAt?.getTime?.() ?? message.createdAt?.getTime?.() ?? Date.now() + const eventTimestamp = message.updatedAt + ? toEpochMs(message.updatedAt) + : message.createdAt + ? toEpochMs(message.createdAt) + : Date.now() yield* publishToInstalledBots(organizationId, () => ({ schemaVersion: 1, @@ -207,10 +212,13 @@ export class BotGatewayService extends Effect.Service()("BotG const publishChannelEvent = Effect.fn("BotGatewayService.publishChannelEvent")(function* ( eventType: "channel.create" | "channel.update" | "channel.delete", - channel: Schema.Schema.Type, + channel: Schema.Schema.Type, ) { - const eventTimestamp = - channel.updatedAt?.getTime?.() ?? channel.createdAt?.getTime?.() ?? Date.now() + const eventTimestamp = channel.updatedAt + ? toEpochMs(channel.updatedAt) + : channel.createdAt + ? toEpochMs(channel.createdAt) + : Date.now() yield* publishToInstalledBots(channel.organizationId, () => ({ schemaVersion: 1, @@ -228,14 +236,18 @@ export class BotGatewayService extends Effect.Service()("BotG const publishChannelMemberEvent = Effect.fn("BotGatewayService.publishChannelMemberEvent")(function* ( eventType: "channel_member.add" | "channel_member.remove", - member: Schema.Schema.Type, + member: Schema.Schema.Type, ) { const organizationId = yield* resolveOrganizationIdForChannel(member.channelId) if (!organizationId) { return } - const eventTimestamp = member.createdAt?.getTime?.() ?? member.joinedAt?.getTime?.() ?? Date.now() + const eventTimestamp = member.createdAt + ? toEpochMs(member.createdAt) + : member.joinedAt + ? toEpochMs(member.joinedAt) + : Date.now() yield* publishToInstalledBots(organizationId, () => ({ schemaVersion: 1, @@ -281,4 +293,9 @@ export class BotGatewayService extends Effect.Service()("BotG proxyRead, } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(BotInstallationRepo.layer), + Layer.provide(ChannelRepo.layer), + ) +} diff --git a/apps/backend/src/services/channel-access-sync.ts b/apps/backend/src/services/channel-access-sync.ts index a5fe1fa61..acceafabd 100644 --- a/apps/backend/src/services/channel-access-sync.ts +++ b/apps/backend/src/services/channel-access-sync.ts @@ -1,13 +1,13 @@ import { and, eq, isNull, notInArray, schema } from "@hazel/db" import type { ChannelId, ConnectConversationId, OrganizationId, UserId } from "@hazel/schema" -import { Effect } from "effect" +import { ServiceMap, Effect, Layer } from "effect" import { transactionAwareExecute } from "../lib/transaction-aware-execute" +import { DatabaseLive } from "./database" -export class ChannelAccessSyncService extends Effect.Service()( +export class ChannelAccessSyncService extends ServiceMap.Service()( "ChannelAccessSyncService", { - accessors: true, - effect: Effect.gen(function* () { + make: Effect.gen(function* () { const upsertChannelUsers = Effect.fn("ChannelAccessSyncService.upsertChannelUsers")(function* ( channelId: ChannelId, organizationId: OrganizationId, @@ -394,4 +394,6 @@ export class ChannelAccessSyncService extends Effect.Service { content: "release notes", attachments: [ makeAttachment({ - id: "00000000-0000-0000-0000-000000000001", + id: "00000000-0000-4000-8000-000000000001", fileName: "design.png", fileSize: 2048, publicUrl: "https://cdn.example.com/1", }), makeAttachment({ - id: "00000000-0000-0000-0000-000000000002", + id: "00000000-0000-4000-8000-000000000002", fileName: "quarterly-report.pdf", fileSize: 1024 * 1024, publicUrl: "https://cdn.example.com/2", @@ -66,13 +66,13 @@ describe("formatMessageContentWithAttachments", () => { it("renders deterministically based on provided attachment order", () => { const attachments = [ makeAttachment({ - id: "00000000-0000-0000-0000-0000000000b0", + id: "00000000-0000-4000-8000-0000000000b0", fileName: "b.txt", fileSize: 1, publicUrl: "https://cdn.example.com/b", }), makeAttachment({ - id: "00000000-0000-0000-0000-0000000000a0", + id: "00000000-0000-4000-8000-0000000000a0", fileName: "a.txt", fileSize: 1, publicUrl: "https://cdn.example.com/a", @@ -89,7 +89,7 @@ describe("formatMessageContentWithAttachments", () => { it("caps long attachment lists and includes summary line", () => { const attachments = Array.from({ length: 20 }, (_, index) => makeAttachment({ - id: `00000000-0000-0000-0000-0000000000${String(index).padStart(2, "0")}`, + id: `00000000-0000-4000-8000-0000000000${String(index).padStart(2, "0")}`, fileName: `file-${index}.txt`, fileSize: 10, publicUrl: `https://cdn.example.com/${index}`, diff --git a/apps/backend/src/services/chat-sync/chat-sync-attribution-reconciler.test.ts b/apps/backend/src/services/chat-sync/chat-sync-attribution-reconciler.test.ts index 31060438b..ddb1d899b 100644 --- a/apps/backend/src/services/chat-sync/chat-sync-attribution-reconciler.test.ts +++ b/apps/backend/src/services/chat-sync/chat-sync-attribution-reconciler.test.ts @@ -1,19 +1,20 @@ import { describe, expect, it } from "@effect/vitest" import { MessageRepo, OrganizationMemberRepo, UserRepo } from "@hazel/backend-core" import type { OrganizationId, UserId } from "@hazel/schema" -import { Effect, Layer } from "effect" +import { Effect, Layer, ServiceMap } from "effect" import { ChatSyncAttributionReconciler } from "./chat-sync-attribution-reconciler.ts" +import { serviceShape } from "../../test/effect-helpers" -const ORGANIZATION_ID = "00000000-0000-0000-0000-000000000001" as OrganizationId -const USER_ID = "00000000-0000-0000-0000-000000000002" as UserId -const SHADOW_USER_ID = "00000000-0000-0000-0000-000000000003" as UserId +const ORGANIZATION_ID = "00000000-0000-4000-8000-000000000001" as OrganizationId +const USER_ID = "00000000-0000-4000-8000-000000000002" as UserId +const SHADOW_USER_ID = "00000000-0000-4000-8000-000000000003" as UserId const makeLayer = (deps: { - messageRepo: MessageRepo - userRepo: UserRepo - organizationMemberRepo: OrganizationMemberRepo + messageRepo: ServiceMap.Service.Shape + userRepo: ServiceMap.Service.Shape + organizationMemberRepo: ServiceMap.Service.Shape }) => - ChatSyncAttributionReconciler.DefaultWithoutDependencies.pipe( + Layer.effect(ChatSyncAttributionReconciler, ChatSyncAttributionReconciler.make).pipe( Layer.provide(Layer.succeed(MessageRepo, deps.messageRepo)), Layer.provide(Layer.succeed(UserRepo, deps.userRepo)), Layer.provide(Layer.succeed(OrganizationMemberRepo, deps.organizationMemberRepo)), @@ -24,28 +25,30 @@ describe("ChatSyncAttributionReconciler", () => { let reassignParams: unknown = null const layer = makeLayer({ - messageRepo: { + messageRepo: serviceShape({ reassignExternalSyncedAuthors: (params: unknown) => { reassignParams = params return Effect.succeed(4) }, - } as unknown as MessageRepo, - userRepo: { + }), + userRepo: serviceShape({ upsertByExternalId: () => Effect.succeed({ id: SHADOW_USER_ID }), - } as unknown as UserRepo, - organizationMemberRepo: { + }), + organizationMemberRepo: serviceShape({ upsertByOrgAndUser: () => Effect.succeed({}), - } as unknown as OrganizationMemberRepo, + }), }) const result = await Effect.runPromise( - ChatSyncAttributionReconciler.relinkHistoricalProviderMessages({ - organizationId: ORGANIZATION_ID, - provider: "discord", - userId: USER_ID, - externalAccountId: "123", - externalAccountName: "Maki", - }).pipe(Effect.provide(layer)), + ChatSyncAttributionReconciler.use((service) => + service.relinkHistoricalProviderMessages({ + organizationId: ORGANIZATION_ID, + provider: "discord", + userId: USER_ID, + externalAccountId: "123", + externalAccountName: "Maki", + }), + ).pipe(Effect.provide(layer)), ) expect(result.updatedCount).toBe(4) @@ -61,28 +64,30 @@ describe("ChatSyncAttributionReconciler", () => { let reassignParams: unknown = null const layer = makeLayer({ - messageRepo: { + messageRepo: serviceShape({ reassignExternalSyncedAuthors: (params: unknown) => { reassignParams = params return Effect.succeed(2) }, - } as unknown as MessageRepo, - userRepo: { + }), + userRepo: serviceShape({ upsertByExternalId: () => Effect.succeed({ id: SHADOW_USER_ID }), - } as unknown as UserRepo, - organizationMemberRepo: { + }), + organizationMemberRepo: serviceShape({ upsertByOrgAndUser: () => Effect.succeed({}), - } as unknown as OrganizationMemberRepo, + }), }) const result = await Effect.runPromise( - ChatSyncAttributionReconciler.unlinkHistoricalProviderMessages({ - organizationId: ORGANIZATION_ID, - provider: "discord", - userId: USER_ID, - externalAccountId: "123", - externalAccountName: "Maki", - }).pipe(Effect.provide(layer)), + ChatSyncAttributionReconciler.use((service) => + service.unlinkHistoricalProviderMessages({ + organizationId: ORGANIZATION_ID, + provider: "discord", + userId: USER_ID, + externalAccountId: "123", + externalAccountName: "Maki", + }), + ).pipe(Effect.provide(layer)), ) expect(result.updatedCount).toBe(2) diff --git a/apps/backend/src/services/chat-sync/chat-sync-attribution-reconciler.ts b/apps/backend/src/services/chat-sync/chat-sync-attribution-reconciler.ts index de7d1aed7..2503994c3 100644 --- a/apps/backend/src/services/chat-sync/chat-sync-attribution-reconciler.ts +++ b/apps/backend/src/services/chat-sync/chat-sync-attribution-reconciler.ts @@ -1,7 +1,7 @@ import { MessageRepo, OrganizationMemberRepo, UserRepo } from "@hazel/backend-core" import type { OrganizationId, UserId } from "@hazel/schema" import type { IntegrationConnection } from "@hazel/domain/models" -import { Effect } from "effect" +import { ServiceMap, Effect, Layer } from "effect" interface ReconcileAttributionParams { organizationId: OrganizationId @@ -14,11 +14,10 @@ interface ReconcileAttributionParams { const defaultShadowDisplayName = (provider: string): string => `${provider.charAt(0).toUpperCase()}${provider.slice(1)} User` -export class ChatSyncAttributionReconciler extends Effect.Service()( +export class ChatSyncAttributionReconciler extends ServiceMap.Service()( "ChatSyncAttributionReconciler", { - accessors: true, - effect: Effect.gen(function* () { + make: Effect.gen(function* () { const messageRepo = yield* MessageRepo const userRepo = yield* UserRepo const organizationMemberRepo = yield* OrganizationMemberRepo @@ -127,6 +126,11 @@ export class ChatSyncAttributionReconciler extends Effect.Service { const repoLayer = Layer.mergeAll( - ChatSyncConnectionRepo.Default, - ChatSyncChannelLinkRepo.Default, - ChatSyncMessageLinkRepo.Default, - ChatSyncEventReceiptRepo.Default, - MessageRepo.Default, - MessageOutboxRepo.Default, - MessageReactionRepo.Default, - ChannelRepo.Default, - IntegrationConnectionRepo.Default, - UserRepo.Default, - OrganizationMemberRepo.Default, + ChatSyncConnectionRepo.layer, + ChatSyncChannelLinkRepo.layer, + ChatSyncMessageLinkRepo.layer, + ChatSyncEventReceiptRepo.layer, + MessageRepo.layer, + MessageOutboxRepo.layer, + MessageReactionRepo.layer, + ChannelRepo.layer, + IntegrationConnectionRepo.layer, + UserRepo.layer, + OrganizationMemberRepo.layer, ).pipe(Layer.provide(harness.dbLayer)) const deps = Layer.mergeAll( @@ -369,16 +369,16 @@ const makeWorkerLayer = ( repoLayer, Layer.succeed(ChatSyncProviderRegistry, { getAdapter: () => Effect.succeed(params.providerAdapter), - } as unknown as ChatSyncProviderRegistry), + } as never), Layer.succeed(IntegrationBotService, { getOrCreateBotUser: () => Effect.succeed({ id: params.botUserId }), - } as unknown as IntegrationBotService), + } as never), Layer.succeed(ChannelAccessSyncService, { syncChannel: (channelId: ChannelId) => Effect.sync(() => { params.onSyncChannel?.(channelId) }), - } as unknown as ChannelAccessSyncService), + } as never), Layer.succeed(Discord.DiscordApiClient, { createWebhook: () => Effect.fail(new Error("not used in deterministic integration tests")), executeWebhookMessage: () => @@ -391,10 +391,10 @@ const makeWorkerLayer = ( addReaction: () => Effect.fail(new Error("not used in deterministic integration tests")), removeReaction: () => Effect.fail(new Error("not used in deterministic integration tests")), createThread: () => Effect.fail(new Error("not used in deterministic integration tests")), - } as unknown as Discord.DiscordApiClient), + } as never), ) - return ChatSyncCoreWorker.DefaultWithoutDependencies.pipe(Layer.provide(deps)) + return Layer.effect(ChatSyncCoreWorker, ChatSyncCoreWorkerMake).pipe(Layer.provide(deps)) } describe("ChatSyncCoreWorker integration", () => { @@ -436,9 +436,10 @@ describe("ChatSyncCoreWorker integration", () => { }) const createResult = await runEffect( - ChatSyncCoreWorker.syncHazelMessageToProvider(syncConnectionId, messageId).pipe( - Effect.provide(workerLayer), - ), + Effect.gen(function* () { + const w = yield* ChatSyncCoreWorker + return yield* w.syncHazelMessageToProvider(syncConnectionId, messageId) + }).pipe(Effect.provide(workerLayer)), ) recordChatSyncDiagnostic({ suite: "chat-sync-core-worker.integration", @@ -453,9 +454,10 @@ describe("ChatSyncCoreWorker integration", () => { expect(recorder.calls.createMessage).toHaveLength(1) const dedupedResult = await runEffect( - ChatSyncCoreWorker.syncHazelMessageToProvider(syncConnectionId, messageId).pipe( - Effect.provide(workerLayer), - ), + Effect.gen(function* () { + const w = yield* ChatSyncCoreWorker + return yield* w.syncHazelMessageToProvider(syncConnectionId, messageId) + }).pipe(Effect.provide(workerLayer)), ) expect(dedupedResult.status).toBe("deduped") @@ -472,17 +474,19 @@ describe("ChatSyncCoreWorker integration", () => { ) const updateResult = await runEffect( - ChatSyncCoreWorker.syncHazelMessageUpdateToProvider(syncConnectionId, messageId).pipe( - Effect.provide(workerLayer), - ), + Effect.gen(function* () { + const w = yield* ChatSyncCoreWorker + return yield* w.syncHazelMessageUpdateToProvider(syncConnectionId, messageId) + }).pipe(Effect.provide(workerLayer)), ) expect(updateResult.status).toBe("updated") expect(recorder.calls.updateMessage).toHaveLength(1) const deleteResult = await runEffect( - ChatSyncCoreWorker.syncHazelMessageDeleteToProvider(syncConnectionId, messageId).pipe( - Effect.provide(workerLayer), - ), + Effect.gen(function* () { + const w = yield* ChatSyncCoreWorker + return yield* w.syncHazelMessageDeleteToProvider(syncConnectionId, messageId) + }).pipe(Effect.provide(workerLayer)), ) expect(deleteResult.status).toBe("deleted") expect(recorder.calls.deleteMessage).toHaveLength(1) @@ -538,9 +542,10 @@ describe("ChatSyncCoreWorker integration", () => { content: "hello", }) const createMessageResult = await runEffect( - ChatSyncCoreWorker.syncHazelMessageToProvider(syncConnectionId, messageId).pipe( - Effect.provide(workerLayer), - ), + Effect.gen(function* () { + const w = yield* ChatSyncCoreWorker + return yield* w.syncHazelMessageToProvider(syncConnectionId, messageId) + }).pipe(Effect.provide(workerLayer)), ) expect(createMessageResult.status).toBe("synced") const externalMessageId = createMessageResult.externalMessageId @@ -566,19 +571,23 @@ describe("ChatSyncCoreWorker integration", () => { }) const createReactionResult = await runEffect( - ChatSyncCoreWorker.syncHazelReactionCreateToProvider(syncConnectionId, reactionId).pipe( - Effect.provide(workerLayer), - ), + Effect.gen(function* () { + const w = yield* ChatSyncCoreWorker + return yield* w.syncHazelReactionCreateToProvider(syncConnectionId, reactionId) + }).pipe(Effect.provide(workerLayer)), ) expect(createReactionResult.status).toBe("created") expect(recorder.calls.addReaction).toHaveLength(1) const ignoredDelete = await runEffect( - ChatSyncCoreWorker.syncHazelReactionDeleteToProvider(syncConnectionId, { - hazelChannelId: ctx.channelId, - hazelMessageId: messageId, - emoji: "🔥", - userId: ctx.authorUserId, + Effect.gen(function* () { + const w = yield* ChatSyncCoreWorker + return yield* w.syncHazelReactionDeleteToProvider(syncConnectionId, { + hazelChannelId: ctx.channelId, + hazelMessageId: messageId, + emoji: "🔥", + userId: ctx.authorUserId, + }) }).pipe(Effect.provide(workerLayer)), ) expect(ignoredDelete.status).toBe("ignored_remaining_reactions") @@ -595,16 +604,19 @@ describe("ChatSyncCoreWorker integration", () => { ) const deleteReactionResult = await runEffect( - ChatSyncCoreWorker.syncHazelReactionDeleteToProvider( - syncConnectionId, - { - hazelChannelId: ctx.channelId, - hazelMessageId: messageId, - emoji: "🔥", - userId: ctx.authorUserId, - }, - "hazel:reaction:delete:second-pass", - ).pipe(Effect.provide(workerLayer)), + Effect.gen(function* () { + const w = yield* ChatSyncCoreWorker + return yield* w.syncHazelReactionDeleteToProvider( + syncConnectionId, + { + hazelChannelId: ctx.channelId, + hazelMessageId: messageId, + emoji: "🔥", + userId: ctx.authorUserId, + }, + "hazel:reaction:delete:second-pass", + ) + }).pipe(Effect.provide(workerLayer)), ) expect(deleteReactionResult.status).toBe("deleted") expect(recorder.calls.removeReaction).toHaveLength(1) @@ -632,14 +644,17 @@ describe("ChatSyncCoreWorker integration", () => { }) const ingressCreate = await runEffect( - ChatSyncCoreWorker.ingestMessageCreate({ - syncConnectionId, - externalChannelId: externalParentChannelId, - externalMessageId: "22222222222222221" as ExternalMessageId, - externalAuthorId: "ext-user-1" as ExternalUserId, - externalAuthorDisplayName: "External User", - content: "from discord", - dedupeKey: "ext:create:1", + Effect.gen(function* () { + const w = yield* ChatSyncCoreWorker + return yield* w.ingestMessageCreate({ + syncConnectionId, + externalChannelId: externalParentChannelId, + externalMessageId: "22222222222222221" as ExternalMessageId, + externalAuthorId: "ext-user-1" as ExternalUserId, + externalAuthorDisplayName: "External User", + content: "from discord", + dedupeKey: "ext:create:1", + }) }).pipe(Effect.provide(workerLayer)), ) expect(ingressCreate.status).toBe("created") @@ -649,62 +664,77 @@ describe("ChatSyncCoreWorker integration", () => { const hazelMessageId = ingressCreate.hazelMessageId const ingressUpdate = await runEffect( - ChatSyncCoreWorker.ingestMessageUpdate({ - syncConnectionId, - externalChannelId: externalParentChannelId, - externalMessageId: "22222222222222221" as ExternalMessageId, - content: "from discord updated", - dedupeKey: "ext:update:1", + Effect.gen(function* () { + const w = yield* ChatSyncCoreWorker + return yield* w.ingestMessageUpdate({ + syncConnectionId, + externalChannelId: externalParentChannelId, + externalMessageId: "22222222222222221" as ExternalMessageId, + content: "from discord updated", + dedupeKey: "ext:update:1", + }) }).pipe(Effect.provide(workerLayer)), ) expect(ingressUpdate.status).toBe("updated") expect(ingressUpdate.hazelMessageId).toBe(hazelMessageId) const reactionAdd = await runEffect( - ChatSyncCoreWorker.ingestReactionAdd({ - syncConnectionId, - externalChannelId: externalParentChannelId, - externalMessageId: "22222222222222221" as ExternalMessageId, - externalUserId: "ext-user-2" as ExternalUserId, - externalAuthorDisplayName: "React User", - emoji: "🔥", - dedupeKey: "ext:reaction:add:1", + Effect.gen(function* () { + const w = yield* ChatSyncCoreWorker + return yield* w.ingestReactionAdd({ + syncConnectionId, + externalChannelId: externalParentChannelId, + externalMessageId: "22222222222222221" as ExternalMessageId, + externalUserId: "ext-user-2" as ExternalUserId, + externalAuthorDisplayName: "React User", + emoji: "🔥", + dedupeKey: "ext:reaction:add:1", + }) }).pipe(Effect.provide(workerLayer)), ) expect(reactionAdd.status).toBe("created") const reactionDelete = await runEffect( - ChatSyncCoreWorker.ingestReactionRemove({ - syncConnectionId, - externalChannelId: externalParentChannelId, - externalMessageId: "22222222222222221" as ExternalMessageId, - externalUserId: "ext-user-2" as ExternalUserId, - externalAuthorDisplayName: "React User", - emoji: "🔥", - dedupeKey: "ext:reaction:remove:1", + Effect.gen(function* () { + const w = yield* ChatSyncCoreWorker + return yield* w.ingestReactionRemove({ + syncConnectionId, + externalChannelId: externalParentChannelId, + externalMessageId: "22222222222222221" as ExternalMessageId, + externalUserId: "ext-user-2" as ExternalUserId, + externalAuthorDisplayName: "React User", + emoji: "🔥", + dedupeKey: "ext:reaction:remove:1", + }) }).pipe(Effect.provide(workerLayer)), ) expect(reactionDelete.status).toBe("deleted") const threadCreate = await runEffect( - ChatSyncCoreWorker.ingestThreadCreate({ - syncConnectionId, - externalParentChannelId, - externalThreadId: "33333333333333331" as ExternalThreadId, - externalRootMessageId: "22222222222222221" as ExternalMessageId, - name: "Thread From Discord", - dedupeKey: "ext:thread:create:1", + Effect.gen(function* () { + const w = yield* ChatSyncCoreWorker + return yield* w.ingestThreadCreate({ + syncConnectionId, + externalParentChannelId, + externalThreadId: "33333333333333331" as ExternalThreadId, + externalRootMessageId: "22222222222222221" as ExternalMessageId, + name: "Thread From Discord", + dedupeKey: "ext:thread:create:1", + }) }).pipe(Effect.provide(workerLayer)), ) expect(threadCreate.status).toBe("created") expect(syncedChannels).toContain(threadCreate.hazelThreadChannelId) const ingressDelete = await runEffect( - ChatSyncCoreWorker.ingestMessageDelete({ - syncConnectionId, - externalChannelId: externalParentChannelId, - externalMessageId: "22222222222222221" as ExternalMessageId, - dedupeKey: "ext:delete:1", + Effect.gen(function* () { + const w = yield* ChatSyncCoreWorker + return yield* w.ingestMessageDelete({ + syncConnectionId, + externalChannelId: externalParentChannelId, + externalMessageId: "22222222222222221" as ExternalMessageId, + dedupeKey: "ext:delete:1", + }) }).pipe(Effect.provide(workerLayer)), ) expect(ingressDelete.status).toBe("deleted") @@ -783,9 +813,10 @@ describe("ChatSyncCoreWorker integration", () => { }) const outboundResult = await runEffect( - ChatSyncCoreWorker.syncHazelMessageCreateToAllConnections("discord", outboundMessageId).pipe( - Effect.provide(workerLayer), - ), + Effect.gen(function* () { + const w = yield* ChatSyncCoreWorker + return yield* w.syncHazelMessageCreateToAllConnections("discord", outboundMessageId) + }).pipe(Effect.provide(workerLayer)), ) recordChatSyncDiagnostic({ suite: "chat-sync-core-worker.integration", @@ -825,24 +856,30 @@ describe("ChatSyncCoreWorker integration", () => { }) const webhookIgnored = await runEffect( - ChatSyncCoreWorker.ingestMessageCreate({ - syncConnectionId: guardedLinkConnection, - externalChannelId: "11111111111111114" as ExternalChannelId, - externalMessageId: "22222222222222224" as ExternalMessageId, - externalWebhookId: "99999999999999999" as ExternalWebhookId, - content: "from webhook", - dedupeKey: "ext:webhook-origin", + Effect.gen(function* () { + const w = yield* ChatSyncCoreWorker + return yield* w.ingestMessageCreate({ + syncConnectionId: guardedLinkConnection, + externalChannelId: "11111111111111114" as ExternalChannelId, + externalMessageId: "22222222222222224" as ExternalMessageId, + externalWebhookId: "99999999999999999" as ExternalWebhookId, + content: "from webhook", + dedupeKey: "ext:webhook-origin", + }) }).pipe(Effect.provide(workerLayer)), ) expect(webhookIgnored.status).toBe("ignored_webhook_origin") const inactiveIgnored = await runEffect( - ChatSyncCoreWorker.ingestMessageCreate({ - syncConnectionId: inactiveConnection, - externalChannelId: "11111111111111113" as ExternalChannelId, - externalMessageId: "22222222222222223" as ExternalMessageId, - content: "ignored", - dedupeKey: "ext:inactive", + Effect.gen(function* () { + const w = yield* ChatSyncCoreWorker + return yield* w.ingestMessageCreate({ + syncConnectionId: inactiveConnection, + externalChannelId: "11111111111111113" as ExternalChannelId, + externalMessageId: "22222222222222223" as ExternalMessageId, + content: "ignored", + dedupeKey: "ext:inactive", + }) }).pipe(Effect.provide(workerLayer)), ) expect(inactiveIgnored.status).toBe("ignored_connection_inactive") diff --git a/apps/backend/src/services/chat-sync/chat-sync-core-worker.ts b/apps/backend/src/services/chat-sync/chat-sync-core-worker.ts index 8decb7010..2359ac069 100644 --- a/apps/backend/src/services/chat-sync/chat-sync-core-worker.ts +++ b/apps/backend/src/services/chat-sync/chat-sync-core-worker.ts @@ -28,7 +28,7 @@ import { ExternalWebhookId, ExternalThreadId, } from "@hazel/schema" -import { Config, Effect, Option, Redacted, Schema } from "effect" +import { ServiceMap, Config, Effect, Layer, Option, Redacted, Schema } from "effect" import { transactionAwareExecute } from "../../lib/transaction-aware-execute" import { ChannelAccessSyncService } from "../channel-access-sync" import { IntegrationBotService } from "../integrations/integration-bot-service" @@ -43,21 +43,21 @@ import { export const DEFAULT_MAX_MESSAGES_PER_CHANNEL = 50 export const DEFAULT_CHAT_SYNC_CONCURRENCY = 5 -export class DiscordSyncConfigurationError extends Schema.TaggedError()( +export class DiscordSyncConfigurationError extends Schema.TaggedErrorClass()( "DiscordSyncConfigurationError", { message: Schema.String, }, ) {} -export class DiscordSyncConnectionNotFoundError extends Schema.TaggedError()( +export class DiscordSyncConnectionNotFoundError extends Schema.TaggedErrorClass()( "DiscordSyncConnectionNotFoundError", { syncConnectionId: SyncConnectionId, }, ) {} -export class DiscordSyncChannelLinkNotFoundError extends Schema.TaggedError()( +export class DiscordSyncChannelLinkNotFoundError extends Schema.TaggedErrorClass()( "DiscordSyncChannelLinkNotFoundError", { syncConnectionId: SyncConnectionId, @@ -65,18 +65,21 @@ export class DiscordSyncChannelLinkNotFoundError extends Schema.TaggedError()( +export class DiscordSyncMessageNotFoundError extends Schema.TaggedErrorClass()( "DiscordSyncMessageNotFoundError", { messageId: MessageId, }, ) {} -export class DiscordSyncApiError extends Schema.TaggedError()("DiscordSyncApiError", { - message: Schema.String, - status: Schema.optional(Schema.Number), - detail: Schema.optional(Schema.String), -}) {} +export class DiscordSyncApiError extends Schema.TaggedErrorClass()( + "DiscordSyncApiError", + { + message: Schema.String, + status: Schema.optional(Schema.Number), + detail: Schema.optional(Schema.String), + }, +) {} type ChatSyncProvider = ChatSyncConnection.ChatSyncProvider @@ -147,9 +150,20 @@ export interface ChatSyncIngressThreadCreate { readonly dedupeKey?: string } -export class ChatSyncCoreWorker extends Effect.Service()("ChatSyncCoreWorker", { - accessors: true, - effect: Effect.gen(function* () { +// Tag-only service: real implementation is provided via ChatSyncCoreWorkerMake + ChatSyncCoreWorkerLayer. +// Uses two-type-param overload to avoid requiring `make` (which would cause circular inference with DiscordSyncWorker). +// Shape uses `Record` because the real shape can't be inferred without circular deps. +// Consumers can access methods via `yield* ChatSyncCoreWorker` — method calls are checked at each call site. +export class ChatSyncCoreWorker extends ServiceMap.Service< + ChatSyncCoreWorker, + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + Record +>()("ChatSyncCoreWorker") {} + +/** @internal — exported for integration tests that provide their own deps */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +export const ChatSyncCoreWorkerMake: Effect.Effect, unknown, unknown> = Effect.gen( + function* () { const db = yield* Database.Database const connectionRepo = yield* ChatSyncConnectionRepo const channelLinkRepo = yield* ChatSyncChannelLinkRepo @@ -170,7 +184,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch const payloadHash = (value: unknown): string => createHash("sha256").update(JSON.stringify(value)).digest("hex") - const claimReceipt = Effect.fn("DiscordSyncWorker.claimReceipt")(function* (params: { + const claimReceipt = Effect.fn("discordSyncWorker.claimReceipt")(function* (params: { syncConnectionId: SyncConnectionId channelLinkId?: SyncChannelLinkId source: "hazel" | "external" @@ -184,7 +198,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch }) }) - const writeReceipt = Effect.fn("DiscordSyncWorker.writeReceipt")(function* (params: { + const writeReceipt = Effect.fn("discordSyncWorker.writeReceipt")(function* (params: { syncConnectionId: SyncConnectionId channelLinkId?: SyncChannelLinkId source: "hazel" | "external" @@ -216,9 +230,9 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch return `${normalizedBase}/${attachmentId}` } - const getAttachmentPublicUrlBase = Effect.fn("DiscordSyncWorker.getAttachmentPublicUrlBase")( + const getAttachmentPublicUrlBase = Effect.fn("discordSyncWorker.getAttachmentPublicUrlBase")( function* () { - const configuredBaseUrl = yield* Config.string("S3_PUBLIC_URL").pipe(Effect.option) + const configuredBaseUrl = yield* Config.string("S3_PUBLIC_URL").pipe(Config.option) if (Option.isNone(configuredBaseUrl) || configuredBaseUrl.value.trim().length === 0) { return yield* Effect.fail( new DiscordSyncConfigurationError({ @@ -231,7 +245,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch ) const listMessageAttachmentsForOutboundSync = Effect.fn( - "DiscordSyncWorker.listMessageAttachmentsForOutboundSync", + "discordSyncWorker.listMessageAttachmentsForOutboundSync", )(function* (hazelMessageId: MessageId) { const rows = yield* db.execute((client) => client @@ -264,7 +278,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch })) }) - const getOrCreateShadowUserId = Effect.fn("DiscordSyncWorker.getOrCreateShadowUserId")( + const getOrCreateShadowUserId = Effect.fn("discordSyncWorker.getOrCreateShadowUserId")( function* (params: { provider: ChatSyncProvider organizationId: OrganizationId @@ -356,7 +370,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch const isWebhookStrategyEnabled = ( outboundIdentity: ChatSyncChannelLink.OutboundIdentitySettings, - ): boolean => outboundIdentity.strategy === "webhook" + ): boolean => outboundIdentity.enabled && outboundIdentity.strategy === "webhook" const defaultOutboundIdentitySettings = (): ChatSyncChannelLink.OutboundIdentitySettings => ({ enabled: false, @@ -431,7 +445,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch return Option.isSome(webhookConfig) && webhookConfig.value.webhookId === externalWebhookId } - const persistWebhookIdentity = Effect.fn("DiscordSyncWorker.persistWebhookIdentity")(function* ( + const persistWebhookIdentity = Effect.fn("discordSyncWorker.persistWebhookIdentity")(function* ( link: { id: SyncChannelLinkId settings: Record | null @@ -463,7 +477,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch yield* channelLinkRepo.updateSettings(link.id, nextSettings) }) - const ensureDiscordWebhookIdentity = Effect.fn("DiscordSyncWorker.ensureDiscordWebhookIdentity")( + const ensureDiscordWebhookIdentity = Effect.fn("discordSyncWorker.ensureDiscordWebhookIdentity")( function* (params: { provider: ChatSyncProvider link: { @@ -497,7 +511,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch return currentConfig } - const botTokenOption = yield* Config.redacted("DISCORD_BOT_TOKEN").pipe(Effect.option) + const botTokenOption = yield* Config.redacted("DISCORD_BOT_TOKEN").pipe(Config.option) if (Option.isNone(botTokenOption)) { return Option.none() } @@ -530,7 +544,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch ) return Option.some(nextConfig) }).pipe( - Effect.catchAll((error) => + Effect.catch((error) => Effect.gen(function* () { if (isDiscordApiError(error) && error.status === 403) { const fallbackOutboundIdentity: ChatSyncChannelLink.OutboundIdentitySettings = @@ -574,7 +588,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch ) const getDiscordWebhookIdentityMessageMetadata = Effect.fn( - "DiscordSyncWorker.getDiscordWebhookIdentityMessageMetadata", + "discordSyncWorker.getDiscordWebhookIdentityMessageMetadata", )(function* (authorId: UserId) { const userOption = yield* userRepo.findById(authorId) if (Option.isNone(userOption)) { @@ -591,7 +605,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch return { username, avatarUrl } }) - const sendDiscordMessageViaWebhook = Effect.fn("DiscordSyncWorker.sendDiscordMessageViaWebhook")( + const sendDiscordMessageViaWebhook = Effect.fn("discordSyncWorker.sendDiscordMessageViaWebhook")( function* (params: { link: { id: SyncChannelLinkId @@ -636,7 +650,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch return Option.some(outboundMessageId as ExternalMessageId) }).pipe( - Effect.catchAll((error) => + Effect.catch((error) => Effect.gen(function* () { yield* Effect.logWarning("Discord webhook send failed; falling back to bot API", { error: String(error), @@ -648,7 +662,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch }, ) - const updateDiscordMessageViaWebhook = Effect.fn("DiscordSyncWorker.updateDiscordMessageViaWebhook")( + const updateDiscordMessageViaWebhook = Effect.fn("discordSyncWorker.updateDiscordMessageViaWebhook")( function* (params: { link: { id: SyncChannelLinkId @@ -681,7 +695,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch return true }).pipe( - Effect.catchAll((error) => + Effect.catch((error) => Effect.gen(function* () { yield* Effect.logWarning( "Discord webhook update failed; falling back to bot API", @@ -696,7 +710,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch }, ) - const deleteDiscordMessageViaWebhook = Effect.fn("DiscordSyncWorker.deleteDiscordMessageViaWebhook")( + const deleteDiscordMessageViaWebhook = Effect.fn("discordSyncWorker.deleteDiscordMessageViaWebhook")( function* (params: { link: { id: SyncChannelLinkId @@ -727,7 +741,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch return true }).pipe( - Effect.catchAll((error) => + Effect.catch((error) => Effect.gen(function* () { yield* Effect.logWarning( "Discord webhook delete failed; falling back to bot API", @@ -742,7 +756,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch }, ) - const resolveAuthorUserId = Effect.fn("DiscordSyncWorker.resolveAuthorUserId")(function* (params: { + const resolveAuthorUserId = Effect.fn("discordSyncWorker.resolveAuthorUserId")(function* (params: { provider: ChatSyncProvider organizationId: OrganizationId externalUserId: ExternalUserId @@ -778,7 +792,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch const DISCORD_USER_MENTION_PATTERN = /@\[userId:([^\]]+)\]/g const getDiscordMentionFallbackDisplayName = Effect.fn( - "DiscordSyncWorker.getDiscordMentionFallbackDisplayName", + "discordSyncWorker.getDiscordMentionFallbackDisplayName", )(function* (userId: UserId) { const userOption = yield* userRepo.findById(userId) if (Option.isNone(userOption)) { @@ -800,7 +814,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch }) const translateHazelMentionsForDiscord = Effect.fn( - "DiscordSyncWorker.translateHazelMentionsForDiscord", + "discordSyncWorker.translateHazelMentionsForDiscord", )(function* (params: { organizationId: OrganizationId; content: string }) { const userIds = [...params.content.matchAll(DISCORD_USER_MENTION_PATTERN)].map( (match) => match[1] as UserId, @@ -849,7 +863,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch externalMessageId: messageLink.externalMessageId as ExternalMessageId, }) - const resolveExternalMessageId = Effect.fn("DiscordSyncWorker.resolveExternalMessageId")( + const resolveExternalMessageId = Effect.fn("discordSyncWorker.resolveExternalMessageId")( function* (params: { syncConnectionId: SyncConnectionId hazelMessageId: MessageId @@ -894,11 +908,11 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch ) .limit(1), ) - return Option.fromNullable(links[0]?.externalMessageId as ExternalMessageId | undefined) + return Option.fromNullishOr(links[0]?.externalMessageId as ExternalMessageId | undefined) }, ) - const resolveHazelMessageId = Effect.fn("DiscordSyncWorker.resolveHazelMessageId")( + const resolveHazelMessageId = Effect.fn("discordSyncWorker.resolveHazelMessageId")( function* (params: { syncConnectionId: SyncConnectionId externalMessageId: ExternalMessageId @@ -946,12 +960,12 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch ) .limit(1), ) - return Option.fromNullable(links[0]?.hazelMessageId) + return Option.fromNullishOr(links[0]?.hazelMessageId) }, ) const resolveOrCreateOutboundLinkForMessage = Effect.fn( - "DiscordSyncWorker.resolveOrCreateOutboundLinkForMessage", + "discordSyncWorker.resolveOrCreateOutboundLinkForMessage", )(function* (params: { syncConnectionId: SyncConnectionId provider: ChatSyncProvider @@ -1069,7 +1083,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch return normalizeChannelLinkExternalId(threadLink) }) - const syncHazelMessageToProvider = Effect.fn("DiscordSyncWorker.syncHazelMessageToProvider")( + const syncHazelMessageToProvider = Effect.fn("discordSyncWorker.syncHazelMessageToProvider")( function* ( syncConnectionId: SyncConnectionId, hazelMessageId: MessageId, @@ -1221,7 +1235,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch }, ) - const syncConnection = Effect.fn("DiscordSyncWorker.syncConnection")(function* ( + const syncConnection = Effect.fn("discordSyncWorker.syncConnection")(function* ( syncConnectionId: SyncConnectionId, maxMessagesPerChannel = DEFAULT_MAX_MESSAGES_PER_CHANNEL, ) { @@ -1274,9 +1288,9 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch const result = yield* syncHazelMessageToProvider( syncConnectionId, unsyncedMessage.id, - ).pipe(Effect.either) - if (result._tag === "Right") { - if (result.right.status === "synced") { + ).pipe(Effect.result) + if (result._tag === "Success") { + if (result.success.status === "synced") { sent++ } else { skipped++ @@ -1287,7 +1301,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch provider: connection.provider, syncConnectionId, hazelMessageId: unsyncedMessage.id, - error: result.left, + error: result.failure, }) } } @@ -1297,7 +1311,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch }) const syncHazelMessageUpdateToProvider = Effect.fn( - "DiscordSyncWorker.syncHazelMessageUpdateToProvider", + "discordSyncWorker.syncHazelMessageUpdateToProvider", )(function* ( syncConnectionId: SyncConnectionId, hazelMessageId: MessageId, @@ -1403,7 +1417,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch }) const syncHazelMessageDeleteToProvider = Effect.fn( - "DiscordSyncWorker.syncHazelMessageDeleteToProvider", + "discordSyncWorker.syncHazelMessageDeleteToProvider", )(function* ( syncConnectionId: SyncConnectionId, hazelMessageId: MessageId, @@ -1499,7 +1513,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch }) const syncHazelReactionCreateToProvider = Effect.fn( - "DiscordSyncWorker.syncHazelReactionCreateToProvider", + "discordSyncWorker.syncHazelReactionCreateToProvider", )(function* ( syncConnectionId: SyncConnectionId, hazelReactionId: MessageReactionId, @@ -1586,7 +1600,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch }) const syncHazelReactionDeleteToProvider = Effect.fn( - "DiscordSyncWorker.syncHazelReactionDeleteToProvider", + "discordSyncWorker.syncHazelReactionDeleteToProvider", )(function* ( syncConnectionId: SyncConnectionId, payload: { @@ -1696,7 +1710,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch } }) - const getActiveOutboundTargets = Effect.fn("DiscordSyncWorker.getActiveOutboundTargets")(function* ( + const getActiveOutboundTargets = Effect.fn("discordSyncWorker.getActiveOutboundTargets")(function* ( hazelChannelId: ChannelId, provider: ChatSyncProvider, ) { @@ -1730,7 +1744,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch }) const syncHazelMessageCreateToAllConnections = Effect.fn( - "DiscordSyncWorker.syncHazelMessageCreateToAllConnections", + "discordSyncWorker.syncHazelMessageCreateToAllConnections", )(function* (provider: ChatSyncProvider, hazelMessageId: MessageId, dedupeKey?: string) { const messageOption = yield* messageRepo.findById(hazelMessageId) if (Option.isNone(messageOption)) { @@ -1746,9 +1760,9 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch target.syncConnectionId, hazelMessageId, dedupeKey, - ).pipe(Effect.either) - if (result._tag === "Right") { - if (result.right.status === "synced" || result.right.status === "already_linked") { + ).pipe(Effect.result) + if (result._tag === "Success") { + if (result.success.status === "synced" || result.success.status === "already_linked") { synced++ } } else { @@ -1757,7 +1771,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch provider, hazelMessageId, syncConnectionId: target.syncConnectionId, - error: result.left, + error: result.failure, }) } } @@ -1766,7 +1780,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch }) const syncHazelMessageUpdateToAllConnections = Effect.fn( - "DiscordSyncWorker.syncHazelMessageUpdateToAllConnections", + "discordSyncWorker.syncHazelMessageUpdateToAllConnections", )(function* (provider: ChatSyncProvider, hazelMessageId: MessageId, dedupeKey?: string) { const messageOption = yield* messageRepo.findById(hazelMessageId) if (Option.isNone(messageOption)) { @@ -1782,9 +1796,9 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch target.syncConnectionId, hazelMessageId, dedupeKey, - ).pipe(Effect.either) - if (result._tag === "Right") { - if (result.right.status === "updated") { + ).pipe(Effect.result) + if (result._tag === "Success") { + if (result.success.status === "updated") { synced++ } } else { @@ -1793,7 +1807,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch provider, hazelMessageId, syncConnectionId: target.syncConnectionId, - error: result.left, + error: result.failure, }) } } @@ -1802,7 +1816,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch }) const syncHazelMessageDeleteToAllConnections = Effect.fn( - "DiscordSyncWorker.syncHazelMessageDeleteToAllConnections", + "discordSyncWorker.syncHazelMessageDeleteToAllConnections", )(function* (provider: ChatSyncProvider, hazelMessageId: MessageId, dedupeKey?: string) { const messageOption = yield* messageRepo.findById(hazelMessageId) if (Option.isNone(messageOption)) { @@ -1818,9 +1832,9 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch target.syncConnectionId, hazelMessageId, dedupeKey, - ).pipe(Effect.either) - if (result._tag === "Right") { - if (result.right.status === "deleted") { + ).pipe(Effect.result) + if (result._tag === "Success") { + if (result.success.status === "deleted") { synced++ } } else { @@ -1829,7 +1843,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch provider, hazelMessageId, syncConnectionId: target.syncConnectionId, - error: result.left, + error: result.failure, }) } } @@ -1838,7 +1852,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch }) const syncHazelReactionCreateToAllConnections = Effect.fn( - "DiscordSyncWorker.syncHazelReactionCreateToAllConnections", + "discordSyncWorker.syncHazelReactionCreateToAllConnections", )(function* (provider: ChatSyncProvider, hazelReactionId: MessageReactionId, dedupeKey?: string) { const reactionOption = yield* messageReactionRepo.findById(hazelReactionId) if (Option.isNone(reactionOption)) { @@ -1855,9 +1869,9 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch target.syncConnectionId, hazelReactionId, dedupeKey, - ).pipe(Effect.either) - if (result._tag === "Right") { - if (result.right.status === "created") { + ).pipe(Effect.result) + if (result._tag === "Success") { + if (result.success.status === "created") { synced++ } } else { @@ -1866,7 +1880,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch provider, hazelReactionId, syncConnectionId: target.syncConnectionId, - error: result.left, + error: result.failure, }) } } @@ -1875,7 +1889,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch }) const syncHazelReactionDeleteToAllConnections = Effect.fn( - "DiscordSyncWorker.syncHazelReactionDeleteToAllConnections", + "discordSyncWorker.syncHazelReactionDeleteToAllConnections", )(function* ( provider: ChatSyncProvider, payload: { @@ -1896,9 +1910,9 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch target.syncConnectionId, payload, dedupeKey, - ).pipe(Effect.either) - if (result._tag === "Right") { - if (result.right.status === "deleted") { + ).pipe(Effect.result) + if (result._tag === "Success") { + if (result.success.status === "deleted") { synced++ } } else { @@ -1907,7 +1921,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch provider, hazelMessageId: payload.hazelMessageId, syncConnectionId: target.syncConnectionId, - error: result.left, + error: result.failure, }) } } @@ -1915,7 +1929,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch return { synced, failed } }) - const syncAllActiveConnections = Effect.fn("DiscordSyncWorker.syncAllActiveConnections")(function* ( + const syncAllActiveConnections = Effect.fn("discordSyncWorker.syncAllActiveConnections")(function* ( provider: ChatSyncProvider, maxMessagesPerChannel = DEFAULT_MAX_MESSAGES_PER_CHANNEL, ) { @@ -1933,7 +1947,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch ) }) - const ingestMessageCreate = Effect.fn("DiscordSyncWorker.ingestMessageCreate")(function* ( + const ingestMessageCreate = Effect.fn("discordSyncWorker.ingestMessageCreate")(function* ( payload: ChatSyncIngressMessageCreate, ) { const dedupeKey = payload.dedupeKey ?? `external:message:create:${payload.externalMessageId}` @@ -2127,7 +2141,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch return { status: "created" as const, hazelMessageId: message.id } }) - const ingestMessageUpdate = Effect.fn("DiscordSyncWorker.ingestMessageUpdate")(function* ( + const ingestMessageUpdate = Effect.fn("discordSyncWorker.ingestMessageUpdate")(function* ( payload: ChatSyncIngressMessageUpdate, ) { const dedupeKey = payload.dedupeKey ?? `external:message:update:${payload.externalMessageId}` @@ -2237,7 +2251,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch return { status: "updated" as const, hazelMessageId: messageLink.hazelMessageId } }) - const ingestMessageDelete = Effect.fn("DiscordSyncWorker.ingestMessageDelete")(function* ( + const ingestMessageDelete = Effect.fn("discordSyncWorker.ingestMessageDelete")(function* ( payload: ChatSyncIngressMessageDelete, ) { const dedupeKey = payload.dedupeKey ?? `external:message:delete:${payload.externalMessageId}` @@ -2348,7 +2362,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch return { status: "deleted" as const, hazelMessageId: messageLink.hazelMessageId } }) - const ingestReactionAdd = Effect.fn("DiscordSyncWorker.ingestReactionAdd")(function* ( + const ingestReactionAdd = Effect.fn("discordSyncWorker.ingestReactionAdd")(function* ( payload: ChatSyncIngressReactionAdd, ) { const dedupeKey = @@ -2477,7 +2491,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch return { status: "created" as const, hazelReactionId: reaction.id } }) - const ingestReactionRemove = Effect.fn("DiscordSyncWorker.ingestReactionRemove")(function* ( + const ingestReactionRemove = Effect.fn("discordSyncWorker.ingestReactionRemove")(function* ( payload: ChatSyncIngressReactionRemove, ) { const dedupeKey = @@ -2603,7 +2617,7 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch return { status: "deleted" as const, hazelReactionId: existingReaction.value.id } }) - const ingestThreadCreate = Effect.fn("DiscordSyncWorker.ingestThreadCreate")(function* ( + const ingestThreadCreate = Effect.fn("discordSyncWorker.ingestThreadCreate")(function* ( payload: ChatSyncIngressThreadCreate, ) { const dedupeKey = payload.dedupeKey ?? `external:thread:create:${payload.externalThreadId}` @@ -2760,22 +2774,23 @@ export class ChatSyncCoreWorker extends Effect.Service()("Ch ingestReactionRemove, ingestThreadCreate, } - }), - dependencies: [ - ChatSyncConnectionRepo.Default, - ChatSyncChannelLinkRepo.Default, - ChatSyncMessageLinkRepo.Default, - ChatSyncEventReceiptRepo.Default, - MessageRepo.Default, - MessageOutboxRepo.Default, - MessageReactionRepo.Default, - ChannelRepo.Default, - IntegrationConnectionRepo.Default, - UserRepo.Default, - OrganizationMemberRepo.Default, - IntegrationBotService.Default, - ChannelAccessSyncService.Default, - ChatSyncProviderRegistry.Default, - Discord.DiscordApiClient.Default, - ], -}) {} + }, +) + +export const ChatSyncCoreWorkerLayer = Layer.effect(ChatSyncCoreWorker, ChatSyncCoreWorkerMake).pipe( + Layer.provide(ChatSyncConnectionRepo.layer), + Layer.provide(ChatSyncChannelLinkRepo.layer), + Layer.provide(ChatSyncMessageLinkRepo.layer), + Layer.provide(ChatSyncEventReceiptRepo.layer), + Layer.provide(MessageRepo.layer), + Layer.provide(MessageOutboxRepo.layer), + Layer.provide(MessageReactionRepo.layer), + Layer.provide(ChannelRepo.layer), + Layer.provide(IntegrationConnectionRepo.layer), + Layer.provide(UserRepo.layer), + Layer.provide(OrganizationMemberRepo.layer), + Layer.provide(IntegrationBotService.layer), + Layer.provide(ChannelAccessSyncService.layer), + Layer.provide(ChatSyncProviderRegistry.layer), + Layer.provide(Discord.DiscordApiClient.layer), +) diff --git a/apps/backend/src/services/chat-sync/chat-sync-provider-registry.ts b/apps/backend/src/services/chat-sync/chat-sync-provider-registry.ts index fce185c27..40285c288 100644 --- a/apps/backend/src/services/chat-sync/chat-sync-provider-registry.ts +++ b/apps/backend/src/services/chat-sync/chat-sync-provider-registry.ts @@ -1,18 +1,18 @@ import { Discord } from "@hazel/integrations" -import { Config, Effect, Option, Redacted, Schema, Schedule } from "effect" +import { ServiceMap, Config, Effect, Layer, Option, Redacted, Schema, Schedule } from "effect" import { type ChatSyncOutboundAttachment, formatMessageContentWithAttachments, } from "./chat-sync-attachment-content" -export class ChatSyncProviderNotSupportedError extends Schema.TaggedError()( +export class ChatSyncProviderNotSupportedError extends Schema.TaggedErrorClass()( "ChatSyncProviderNotSupportedError", { provider: Schema.String, }, ) {} -export class ChatSyncProviderConfigurationError extends Schema.TaggedError()( +export class ChatSyncProviderConfigurationError extends Schema.TaggedErrorClass()( "ChatSyncProviderConfigurationError", { provider: Schema.String, @@ -20,7 +20,7 @@ export class ChatSyncProviderConfigurationError extends Schema.TaggedError()( +export class ChatSyncProviderApiError extends Schema.TaggedErrorClass()( "ChatSyncProviderApiError", { provider: Schema.String, @@ -75,7 +75,7 @@ const DISCORD_MAX_MESSAGE_LENGTH = 2000 const DISCORD_SNOWFLAKE_MIN_LENGTH = 17 const DISCORD_SNOWFLAKE_MAX_LENGTH = 30 const DISCORD_THREAD_NAME_MAX_LENGTH = 100 -const DISCORD_SYNC_RETRY_SCHEDULE = Schedule.intersect( +const DISCORD_SYNC_RETRY_SCHEDULE = Schedule.both( Schedule.exponential("250 millis").pipe(Schedule.jittered), Schedule.recurs(3), ) @@ -85,16 +85,17 @@ const isDiscordSnowflake = (value: string): boolean => value.length >= DISCORD_SNOWFLAKE_MIN_LENGTH && value.length <= DISCORD_SNOWFLAKE_MAX_LENGTH -export class ChatSyncProviderRegistry extends Effect.Service()( +export class ChatSyncProviderRegistry extends ServiceMap.Service()( "ChatSyncProviderRegistry", { - accessors: true, - effect: Effect.gen(function* () { + make: Effect.gen(function* () { const discordApiClient = yield* Discord.DiscordApiClient + // Read config once at service initialization to avoid ConfigError leaking into adapter methods + const discordBotTokenOption = yield* Config.redacted("DISCORD_BOT_TOKEN").pipe(Config.option) + const getDiscordToken = Effect.fn("ChatSyncProviderRegistry.getDiscordToken")(function* () { - const discordBotToken = yield* Config.redacted("DISCORD_BOT_TOKEN").pipe(Effect.option) - if (Option.isNone(discordBotToken)) { + if (Option.isNone(discordBotTokenOption)) { return yield* Effect.fail( new ChatSyncProviderConfigurationError({ provider: "discord", @@ -102,7 +103,7 @@ export class ChatSyncProviderRegistry extends Effect.Service { @@ -423,7 +424,7 @@ export class ChatSyncProviderRegistry extends Effect.Service const getAdapter = Effect.fn("ChatSyncProviderRegistry.getAdapter")(function* (provider: string) { - const adapter = Option.fromNullable(adapters[provider as keyof typeof adapters]) + const adapter = Option.fromNullishOr(adapters[provider as keyof typeof adapters]) return yield* Option.match(adapter, { onNone: () => Effect.fail( @@ -437,6 +438,7 @@ export class ChatSyncProviderRegistry extends Effect.Service(effect: Effect.Effect) => Effect.runPromise(effect as Effect.Effect) @@ -19,44 +28,56 @@ type GatewayLink = { direction: "both" | "hazel_to_external" | "external_to_hazel" } +type DispatchWorker = Pick< + ServiceMap.Service.Shape, + | "ingestMessageCreate" + | "ingestMessageUpdate" + | "ingestMessageDelete" + | "ingestReactionAdd" + | "ingestReactionRemove" + | "ingestThreadCreate" +> + const makeDispatchHarness = () => { const linksByChannel = new Map>() const calls = { - create: [] as Array>, - update: [] as Array>, - delete: [] as Array>, - reactionAdd: [] as Array>, - reactionRemove: [] as Array>, - threadCreate: [] as Array>, + create: [] as Array, + update: [] as Array, + delete: [] as Array, + reactionAdd: [] as Array, + reactionRemove: [] as Array, + threadCreate: [] as Array, + } + + const discordSyncWorker: DispatchWorker = { + ingestMessageCreate: (payload) => + Effect.sync(() => { + calls.create.push(payload) + }), + ingestMessageUpdate: (payload) => + Effect.sync(() => { + calls.update.push(payload) + }), + ingestMessageDelete: (payload) => + Effect.sync(() => { + calls.delete.push(payload) + }), + ingestReactionAdd: (payload) => + Effect.sync(() => { + calls.reactionAdd.push(payload) + }), + ingestReactionRemove: (payload) => + Effect.sync(() => { + calls.reactionRemove.push(payload) + }), + ingestThreadCreate: (payload) => + Effect.sync(() => { + calls.threadCreate.push(payload) + }), } const handlers = createDiscordGatewayDispatchHandlers({ - discordSyncWorker: { - ingestMessageCreate: (payload: any) => - Effect.sync(() => { - calls.create.push(payload as Record) - }), - ingestMessageUpdate: (payload: any) => - Effect.sync(() => { - calls.update.push(payload as Record) - }), - ingestMessageDelete: (payload: any) => - Effect.sync(() => { - calls.delete.push(payload as Record) - }), - ingestReactionAdd: (payload: any) => - Effect.sync(() => { - calls.reactionAdd.push(payload as Record) - }), - ingestReactionRemove: (payload: any) => - Effect.sync(() => { - calls.reactionRemove.push(payload as Record) - }), - ingestThreadCreate: (payload: any) => - Effect.sync(() => { - calls.threadCreate.push(payload as Record) - }), - } as any, + discordSyncWorker, findActiveLinksByExternalChannel: (externalChannelId) => Effect.succeed(linksByChannel.get(externalChannelId) ?? []), isCurrentBotAuthor: (authorId) => Effect.succeed(authorId === "bot-self"), @@ -77,11 +98,11 @@ describe("DiscordGatewayService dispatch handlers", () => { const channelId = "123456789012345678" as ExternalChannelId harness.setLinks(channelId, [ { - syncConnectionId: "00000000-0000-0000-0000-000000000001" as SyncConnectionId, + syncConnectionId: "00000000-0000-4000-8000-000000000001" as SyncConnectionId, direction: "both", }, { - syncConnectionId: "00000000-0000-0000-0000-000000000002" as SyncConnectionId, + syncConnectionId: "00000000-0000-4000-8000-000000000002" as SyncConnectionId, direction: "hazel_to_external", }, ]) @@ -109,7 +130,7 @@ describe("DiscordGatewayService dispatch handlers", () => { expected: "one inbound dispatch for both-direction link", actual: `${harness.calls.create.length} dispatches`, }) - expect(harness.calls.create[0]?.syncConnectionId).toBe("00000000-0000-0000-0000-000000000001") + expect(harness.calls.create[0]?.syncConnectionId).toBe("00000000-0000-4000-8000-000000000001") expect(harness.calls.create[0]?.dedupeKey).toBe("discord:gateway:create:223456789012345678") }) @@ -131,7 +152,7 @@ describe("DiscordGatewayService dispatch handlers", () => { const channelId = "123456789012345678" as ExternalChannelId harness.setLinks(channelId, [ { - syncConnectionId: "00000000-0000-0000-0000-000000000011" as SyncConnectionId, + syncConnectionId: "00000000-0000-4000-8000-000000000011" as SyncConnectionId, direction: "both", }, ]) @@ -161,7 +182,7 @@ describe("DiscordGatewayService dispatch handlers", () => { const channelId = "123456789012345678" as ExternalChannelId harness.setLinks(channelId, [ { - syncConnectionId: "00000000-0000-0000-0000-000000000021" as SyncConnectionId, + syncConnectionId: "00000000-0000-4000-8000-000000000021" as SyncConnectionId, direction: "external_to_hazel", }, ]) @@ -187,11 +208,11 @@ describe("DiscordGatewayService dispatch handlers", () => { const channelId = "123456789012345678" as ExternalChannelId harness.setLinks(channelId, [ { - syncConnectionId: "00000000-0000-0000-0000-000000000031" as SyncConnectionId, + syncConnectionId: "00000000-0000-4000-8000-000000000031" as SyncConnectionId, direction: "both", }, { - syncConnectionId: "00000000-0000-0000-0000-000000000032" as SyncConnectionId, + syncConnectionId: "00000000-0000-4000-8000-000000000032" as SyncConnectionId, direction: "hazel_to_external", }, ]) @@ -210,7 +231,7 @@ describe("DiscordGatewayService dispatch handlers", () => { await run( harness.handlers.ingestMessageReactionRemoveEvent({ ...reactionEvent, - } as any), + }), ) expect(harness.calls.reactionAdd).toHaveLength(1) @@ -226,7 +247,7 @@ describe("DiscordGatewayService dispatch handlers", () => { const parentChannelId = "123456789012345678" as ExternalChannelId harness.setLinks(parentChannelId, [ { - syncConnectionId: "00000000-0000-0000-0000-000000000041" as SyncConnectionId, + syncConnectionId: "00000000-0000-4000-8000-000000000041" as SyncConnectionId, direction: "both", }, ]) diff --git a/apps/backend/src/services/chat-sync/discord-gateway-service.test.ts b/apps/backend/src/services/chat-sync/discord-gateway-service.test.ts index 7421ea763..736074fbd 100644 --- a/apps/backend/src/services/chat-sync/discord-gateway-service.test.ts +++ b/apps/backend/src/services/chat-sync/discord-gateway-service.test.ts @@ -7,7 +7,7 @@ import { decodeRequiredExternalId, extractReactionAuthor, normalizeDiscordMessageAttachments, -} from "./discord-gateway-service" +} from "./discord-gateway-shared" describe("DiscordGatewayService reaction author extraction", () => { it("prefers member.user for reaction events", () => { diff --git a/apps/backend/src/services/chat-sync/discord-gateway-service.ts b/apps/backend/src/services/chat-sync/discord-gateway-service.ts index d2935c140..0f6976dff 100644 --- a/apps/backend/src/services/chat-sync/discord-gateway-service.ts +++ b/apps/backend/src/services/chat-sync/discord-gateway-service.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto" -import { FetchHttpClient } from "@effect/platform" +import { FetchHttpClient } from "effect/unstable/http" import { BunSocket } from "@effect/platform-bun" import { ChatSyncChannelLinkRepo } from "@hazel/backend-core" import { @@ -12,8 +12,8 @@ import { } from "@hazel/schema" import { DiscordConfig } from "dfx" import { DiscordGateway, DiscordLive } from "dfx/gateway" -import { Config, Effect, Layer, Option, Redacted, Ref, Schema } from "effect" -import { DiscordSyncWorker } from "./discord-sync-worker" +import { ServiceMap, Config, Effect, Layer, Option, Redacted, Ref, Schema } from "effect" +import { DiscordSyncWorker, DiscordSyncWorkerLayer } from "./discord-sync-worker" import type { ChatSyncIngressMessageAttachment } from "./chat-sync-core-worker" export interface DiscordMessageAuthor { @@ -196,7 +196,7 @@ type DiscordGatewayChannelLink = { } type DiscordGatewayDispatchWorker = Pick< - DiscordSyncWorker, + ServiceMap.Service.Shape, | "ingestMessageCreate" | "ingestMessageUpdate" | "ingestMessageDelete" @@ -571,178 +571,184 @@ export const createDiscordGatewayDispatchHandlers = (deps: { } } -export class DiscordGatewayService extends Effect.Service()("DiscordGatewayService", { - accessors: true, - effect: Effect.gen(function* () { - const discordSyncWorker = yield* DiscordSyncWorker - const channelLinkRepo = yield* ChatSyncChannelLinkRepo - - const gatewayEnabled = yield* Config.boolean("DISCORD_GATEWAY_ENABLED").pipe( - Config.withDefault(true), - Effect.orDie, - ) - const configuredIntents = yield* Config.number("DISCORD_GATEWAY_INTENTS").pipe( - // GUILDS + GUILD_MESSAGES + GUILD_MESSAGE_REACTIONS + MESSAGE_CONTENT - Config.withDefault(DISCORD_REQUIRED_GATEWAY_INTENTS), - Effect.orDie, - ) - const intents = configuredIntents | DISCORD_REQUIRED_GATEWAY_INTENTS - if (intents !== configuredIntents) { - yield* Effect.logWarning( - "DISCORD_GATEWAY_INTENTS missing required bits; forcing minimum intents", - { - configuredIntents, - effectiveIntents: intents, - }, +export class DiscordGatewayService extends ServiceMap.Service()( + "DiscordGatewayService", + { + make: Effect.gen(function* () { + const discordSyncWorker = yield* DiscordSyncWorker + const channelLinkRepo = yield* ChatSyncChannelLinkRepo + + const gatewayEnabled = yield* Config.boolean("DISCORD_GATEWAY_ENABLED").pipe( + Config.withDefault(true), ) - } - const botTokenOption = yield* Config.redacted("DISCORD_BOT_TOKEN").pipe(Effect.option) + const configuredIntents = yield* Config.number("DISCORD_GATEWAY_INTENTS").pipe( + // GUILDS + GUILD_MESSAGES + GUILD_MESSAGE_REACTIONS + MESSAGE_CONTENT + Config.withDefault(DISCORD_REQUIRED_GATEWAY_INTENTS), + ) + const intents = configuredIntents | DISCORD_REQUIRED_GATEWAY_INTENTS + if (intents !== configuredIntents) { + yield* Effect.logWarning( + "DISCORD_GATEWAY_INTENTS missing required bits; forcing minimum intents", + { + configuredIntents, + effectiveIntents: intents, + }, + ) + } + const botTokenOption = yield* Config.redacted("DISCORD_BOT_TOKEN").pipe(Config.option) - if (!gatewayEnabled) { - yield* Effect.logInfo("Discord gateway disabled via DISCORD_GATEWAY_ENABLED=false") - return { - start: Effect.void, + if (!gatewayEnabled) { + yield* Effect.logInfo("Discord gateway disabled via DISCORD_GATEWAY_ENABLED=false") + return { + start: Effect.void, + } } - } - if (Option.isNone(botTokenOption)) { - yield* Effect.logWarning("Discord gateway disabled: DISCORD_BOT_TOKEN is not configured") - return { - start: Effect.void, + if (Option.isNone(botTokenOption)) { + yield* Effect.logWarning("Discord gateway disabled: DISCORD_BOT_TOKEN is not configured") + return { + start: Effect.void, + } } - } - const botToken = Redacted.value(botTokenOption.value) - const botUserIdRef = yield* Ref.make>(Option.none()) - - const DiscordLayer = DiscordLive.pipe( - Layer.provide( - DiscordConfig.layer({ - token: Redacted.make(botToken), - gateway: { intents }, - }), - ), - Layer.provide(BunSocket.layerWebSocketConstructor), - Layer.provide(FetchHttpClient.layer), - ) - - const isCurrentBotAuthor = (authorId?: string) => - Effect.gen(function* () { - if (!authorId) return false - const botUserId = yield* Ref.get(botUserIdRef) - return Option.isSome(botUserId) && botUserId.value === authorId - }) + const botToken = Redacted.value(botTokenOption.value) + const botUserIdRef = yield* Ref.make>(Option.none()) - const dispatchHandlers = createDiscordGatewayDispatchHandlers({ - discordSyncWorker, - findActiveLinksByExternalChannel: (externalChannelId) => - channelLinkRepo.findActiveByExternalChannel(externalChannelId), - isCurrentBotAuthor, - }) + const DiscordLayer = DiscordLive.pipe( + Layer.provide( + DiscordConfig.layer({ + token: Redacted.make(botToken), + gateway: { intents }, + }), + ), + Layer.provide(BunSocket.layerWebSocketConstructor), + Layer.provide(FetchHttpClient.layer), + ) - const onReady = Effect.fn("DiscordGatewayService.onReady")(function* (event: DiscordReadyEvent) { - if (!event.user?.id) { - yield* Effect.logWarning("Discord gateway READY payload missing bot user id") - return - } - const botUserId = decodeRequiredExternalId(event.user.id, decodeExternalUserId) - if (Option.isNone(botUserId)) { - yield* Effect.logWarning("Discord gateway READY payload has invalid bot user id", { - eventType: "READY", - field: "user.id", - valueType: getValueType(event.user.id), + const isCurrentBotAuthor = (authorId?: string) => + Effect.gen(function* () { + if (!authorId) return false + const botUserId = yield* Ref.get(botUserIdRef) + return Option.isSome(botUserId) && botUserId.value === authorId }) - return - } - yield* Ref.set(botUserIdRef, Option.some(botUserId.value)) - yield* Effect.logInfo("Discord gateway READY", { - botUserId: botUserId.value, + const dispatchHandlers = createDiscordGatewayDispatchHandlers({ + discordSyncWorker, + findActiveLinksByExternalChannel: (externalChannelId) => + channelLinkRepo.findActiveByExternalChannel(externalChannelId), + isCurrentBotAuthor, }) - }) - const onDispatchError = (eventType: string, error: unknown) => - Effect.logWarning("Discord gateway dispatch handler failed", { - eventType, - error: String(error), + const onReady = Effect.fn("DiscordGatewayService.onReady")(function* (event: DiscordReadyEvent) { + if (!event.user?.id) { + yield* Effect.logWarning("Discord gateway READY payload missing bot user id") + return + } + const botUserId = decodeRequiredExternalId(event.user.id, decodeExternalUserId) + if (Option.isNone(botUserId)) { + yield* Effect.logWarning("Discord gateway READY payload has invalid bot user id", { + eventType: "READY", + field: "user.id", + valueType: getValueType(event.user.id), + }) + return + } + + yield* Ref.set(botUserIdRef, Option.some(botUserId.value)) + yield* Effect.logInfo("Discord gateway READY", { + botUserId: botUserId.value, + }) }) - const start = Effect.gen(function* () { - yield* Effect.logInfo("Starting Discord gateway background worker with dfx", { - intents, - }) + const onDispatchError = (eventType: string, error: unknown) => + Effect.logWarning("Discord gateway dispatch handler failed", { + eventType, + error: String(error), + }) - yield* Effect.gen(function* () { - const gateway = yield* DiscordGateway + const start = Effect.gen(function* () { + yield* Effect.logInfo("Starting Discord gateway background worker with dfx", { + intents, + }) + + yield* Effect.gen(function* () { + const gateway = yield* DiscordGateway - yield* Effect.all( - [ - gateway.handleDispatch("READY", (event) => - onReady(event as DiscordReadyEvent).pipe( - Effect.catchAll((error) => onDispatchError("READY", error)), + yield* Effect.all( + [ + gateway.handleDispatch("READY", (event) => + onReady(event as DiscordReadyEvent).pipe( + Effect.catch((error) => onDispatchError("READY", error)), + ), + ), + gateway.handleDispatch("MESSAGE_CREATE", (event) => + dispatchHandlers + .ingestMessageCreateEvent(event as DiscordMessageCreateEvent) + .pipe(Effect.catch((error) => onDispatchError("MESSAGE_CREATE", error))), + ), + gateway.handleDispatch("MESSAGE_UPDATE", (event) => + dispatchHandlers + .ingestMessageUpdateEvent(event as DiscordMessageUpdateEvent) + .pipe(Effect.catch((error) => onDispatchError("MESSAGE_UPDATE", error))), + ), + gateway.handleDispatch("MESSAGE_DELETE", (event) => + dispatchHandlers + .ingestMessageDeleteEvent(event as DiscordMessageDeleteEvent) + .pipe(Effect.catch((error) => onDispatchError("MESSAGE_DELETE", error))), ), - ), - gateway.handleDispatch("MESSAGE_CREATE", (event) => - dispatchHandlers - .ingestMessageCreateEvent(event as DiscordMessageCreateEvent) - .pipe(Effect.catchAll((error) => onDispatchError("MESSAGE_CREATE", error))), - ), - gateway.handleDispatch("MESSAGE_UPDATE", (event) => - dispatchHandlers - .ingestMessageUpdateEvent(event as DiscordMessageUpdateEvent) - .pipe(Effect.catchAll((error) => onDispatchError("MESSAGE_UPDATE", error))), - ), - gateway.handleDispatch("MESSAGE_DELETE", (event) => - dispatchHandlers - .ingestMessageDeleteEvent(event as DiscordMessageDeleteEvent) - .pipe(Effect.catchAll((error) => onDispatchError("MESSAGE_DELETE", error))), - ), - gateway.handleDispatch("MESSAGE_REACTION_ADD", (event) => - dispatchHandlers - .ingestMessageReactionAddEvent(event as DiscordMessageReactionAddEvent) - .pipe( - Effect.catchAll((error) => - onDispatchError("MESSAGE_REACTION_ADD", error), + gateway.handleDispatch("MESSAGE_REACTION_ADD", (event) => + dispatchHandlers + .ingestMessageReactionAddEvent(event as DiscordMessageReactionAddEvent) + .pipe( + Effect.catch((error) => + onDispatchError("MESSAGE_REACTION_ADD", error), + ), ), - ), - ), - gateway.handleDispatch("MESSAGE_REACTION_REMOVE", (event) => - dispatchHandlers - .ingestMessageReactionRemoveEvent(event as DiscordMessageReactionRemoveEvent) - .pipe( - Effect.catchAll((error) => - onDispatchError("MESSAGE_REACTION_REMOVE", error), + ), + gateway.handleDispatch("MESSAGE_REACTION_REMOVE", (event) => + dispatchHandlers + .ingestMessageReactionRemoveEvent( + event as DiscordMessageReactionRemoveEvent, + ) + .pipe( + Effect.catch((error) => + onDispatchError("MESSAGE_REACTION_REMOVE", error), + ), ), - ), - ), - gateway.handleDispatch("THREAD_CREATE", (event) => - dispatchHandlers - .ingestThreadCreateEvent(event as DiscordThreadCreateEvent) - .pipe(Effect.catchAll((error) => onDispatchError("THREAD_CREATE", error))), - ), - ], - { - concurrency: "unbounded", - discard: true, - }, + ), + gateway.handleDispatch("THREAD_CREATE", (event) => + dispatchHandlers + .ingestThreadCreateEvent(event as DiscordThreadCreateEvent) + .pipe(Effect.catch((error) => onDispatchError("THREAD_CREATE", error))), + ), + ], + { + concurrency: "unbounded", + discard: true, + }, + ) + }).pipe( + Effect.provide(DiscordLayer), + Effect.catchCause((cause) => + Effect.logError("Discord gateway background worker stopped", { + cause: String(cause), + }), + ), + Effect.forkScoped, + Effect.asVoid, ) - }).pipe( - Effect.provide(DiscordLayer), - Effect.catchAllCause((cause) => - Effect.logError("Discord gateway background worker stopped", { - cause: String(cause), - }), - ), - Effect.forkScoped, - Effect.asVoid, - ) - }) + }) - yield* start + yield* start - return { - start: Effect.void, - } - }), - dependencies: [DiscordSyncWorker.Default, ChatSyncChannelLinkRepo.Default], -}) {} + return { + start: Effect.void, + } + }), + }, +) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(DiscordSyncWorkerLayer), + Layer.provide(ChatSyncChannelLinkRepo.layer), + ) +} diff --git a/apps/backend/src/services/chat-sync/discord-gateway-shared.ts b/apps/backend/src/services/chat-sync/discord-gateway-shared.ts new file mode 100644 index 000000000..90fc65fb5 --- /dev/null +++ b/apps/backend/src/services/chat-sync/discord-gateway-shared.ts @@ -0,0 +1,547 @@ +import { createHash } from "node:crypto" +import { + ExternalChannelId, + ExternalMessageId, + ExternalThreadId, + ExternalUserId, + ExternalWebhookId, + SyncConnectionId, +} from "@hazel/schema" +import { ServiceMap, Effect, Option, Schema } from "effect" +import { DiscordSyncWorker } from "./discord-sync-worker" +import type { ChatSyncIngressMessageAttachment } from "./chat-sync-core-worker" + +export interface DiscordMessageAuthor { + id?: string + username?: string + global_name?: string | null + discriminator?: string + avatar?: string | null + bot?: boolean +} + +export interface DiscordReadyEvent { + user?: { id?: string } +} + +export interface DiscordMessageCreateEvent { + id?: string + channel_id?: string + content?: string + webhook_id?: string + attachments?: ReadonlyArray + author?: DiscordMessageAuthor + message_reference?: { + message_id?: string + channel_id?: string + } +} + +interface DiscordMessageAttachment { + id?: string + filename?: string + size?: number + url?: string +} + +export interface DiscordMessageUpdateEvent { + id?: string + channel_id?: string + content?: string + webhook_id?: string + author?: DiscordMessageAuthor + edited_timestamp?: string | null +} + +export interface DiscordMessageDeleteEvent { + id?: string + channel_id?: string + webhook_id?: string +} + +interface DiscordReactionEmoji { + id?: string | null + name?: string | null +} + +export interface DiscordMessageReactionAddEvent { + channel_id?: string + message_id?: string + user_id?: string + user?: DiscordMessageAuthor + member?: { + user?: DiscordMessageAuthor + } + emoji?: DiscordReactionEmoji +} + +export interface DiscordMessageReactionRemoveEvent { + channel_id?: string + message_id?: string + user_id?: string + user?: DiscordMessageAuthor + member?: { + user?: DiscordMessageAuthor + } + emoji?: DiscordReactionEmoji +} + +export interface DiscordThreadCreateEvent { + id?: string + parent_id?: string + name?: string + type?: number +} + +const formatDiscordDisplayName = (author?: DiscordMessageAuthor): string => { + if (!author) return "Discord User" + if (author.global_name) return author.global_name + if (author.discriminator && author.discriminator !== "0") { + return `${author.username ?? "discord-user"}#${author.discriminator}` + } + return author.username ?? "Discord User" +} + +const buildAuthorAvatarUrl = (author?: DiscordMessageAuthor): string | null => { + if (!author?.id || !author.avatar) return null + return `https://cdn.discordapp.com/avatars/${author.id}/${author.avatar}.png` +} + +export const normalizeDiscordMessageAttachments = ( + attachments: ReadonlyArray | undefined, +): ReadonlyArray => { + if (!attachments || attachments.length === 0) { + return [] + } + + const normalized: Array = [] + for (const attachment of attachments) { + const fileName = typeof attachment.filename === "string" ? attachment.filename.trim() : "" + const publicUrl = typeof attachment.url === "string" ? attachment.url.trim() : "" + if (!fileName || !publicUrl) { + continue + } + + normalized.push({ + externalAttachmentId: + typeof attachment.id === "string" && attachment.id.trim().length > 0 + ? attachment.id + : undefined, + fileName, + fileSize: + typeof attachment.size === "number" && + Number.isFinite(attachment.size) && + attachment.size >= 0 + ? attachment.size + : 0, + publicUrl, + }) + } + + return normalized +} + +export const extractReactionAuthor = (event: { + member?: { user?: DiscordMessageAuthor } + user?: DiscordMessageAuthor +}) => { + const author = event.member?.user ?? event.user + return { + externalAuthorDisplayName: author ? formatDiscordDisplayName(author) : undefined, + externalAuthorAvatarUrl: author ? buildAuthorAvatarUrl(author) : undefined, + } +} + +const formatDiscordEmoji = (emoji?: DiscordReactionEmoji): string | null => { + if (!emoji?.name) return null + if (emoji.id) return `${emoji.name}:${emoji.id}` + return emoji.name +} + +type ExternalIdDecoder = (value: unknown) => Option.Option + +const decodeExternalChannelId: ExternalIdDecoder = (value) => + Schema.decodeUnknownOption(ExternalChannelId)(value) +const decodeExternalMessageId: ExternalIdDecoder = (value) => + Schema.decodeUnknownOption(ExternalMessageId)(value) +const decodeExternalThreadId: ExternalIdDecoder = (value) => + Schema.decodeUnknownOption(ExternalThreadId)(value) +const decodeExternalUserId: ExternalIdDecoder = (value) => + Schema.decodeUnknownOption(ExternalUserId)(value) +const decodeExternalWebhookId: ExternalIdDecoder = (value) => + Schema.decodeUnknownOption(ExternalWebhookId)(value) + +export const decodeRequiredExternalId = (value: unknown, decode: ExternalIdDecoder): Option.Option => + decode(value) + +export const decodeOptionalExternalId = (value: unknown, decode: ExternalIdDecoder): A | undefined => { + if (value === undefined) return undefined + const decoded = decode(value) + return Option.isSome(decoded) ? decoded.value : undefined +} + +const getValueType = (value: unknown): string => (value === null ? "null" : typeof value) + +type GatewayDirection = "both" | "hazel_to_external" | "external_to_hazel" + +type DiscordGatewayChannelLink = { + readonly syncConnectionId: SyncConnectionId + readonly direction: GatewayDirection +} + +type DiscordGatewayDispatchWorker = Pick< + ServiceMap.Service.Shape, + | "ingestMessageCreate" + | "ingestMessageUpdate" + | "ingestMessageDelete" + | "ingestReactionAdd" + | "ingestReactionRemove" + | "ingestThreadCreate" +> + +const decodeRequiredExternalIdOrWarn = (params: { + eventType: string + field: string + value: unknown + decode: ExternalIdDecoder +}) => + Effect.gen(function* () { + const decoded = decodeRequiredExternalId(params.value, params.decode) + if (Option.isNone(decoded)) { + yield* Effect.logWarning("Discord gateway dropped event: invalid external id", { + eventType: params.eventType, + field: params.field, + valueType: getValueType(params.value), + }) + } + return decoded + }) + +const decodeOptionalExternalIdOrWarn = (params: { + eventType: string + field: string + value: unknown + decode: ExternalIdDecoder +}) => + Effect.gen(function* () { + const decoded = decodeOptionalExternalId(params.value, params.decode) + if (params.value !== undefined && decoded === undefined) { + yield* Effect.logWarning("Discord gateway ignored optional invalid external id", { + eventType: params.eventType, + field: params.field, + valueType: getValueType(params.value), + }) + } + return decoded + }) + +export const createDiscordGatewayDispatchHandlers = (deps: { + discordSyncWorker: DiscordGatewayDispatchWorker + findActiveLinksByExternalChannel: ( + externalChannelId: ExternalChannelId, + ) => Effect.Effect, unknown, never> + isCurrentBotAuthor: (authorId?: string) => Effect.Effect +}) => { + const ingestMessageCreateEvent = Effect.fn("DiscordGatewayService.ingestMessageCreateEvent")(function* ( + event: DiscordMessageCreateEvent, + ) { + if (!event.id || !event.channel_id || typeof event.content !== "string") return + if (event.author?.bot) return + if (yield* deps.isCurrentBotAuthor(event.author?.id)) return + + const externalChannelIdOption = yield* decodeRequiredExternalIdOrWarn({ + eventType: "MESSAGE_CREATE", + field: "channel_id", + value: event.channel_id, + decode: decodeExternalChannelId, + }) + if (Option.isNone(externalChannelIdOption)) return + + const externalMessageIdOption = yield* decodeRequiredExternalIdOrWarn({ + eventType: "MESSAGE_CREATE", + field: "id", + value: event.id, + decode: decodeExternalMessageId, + }) + if (Option.isNone(externalMessageIdOption)) return + + const externalAuthorId = yield* decodeOptionalExternalIdOrWarn({ + eventType: "MESSAGE_CREATE", + field: "author.id", + value: event.author?.id, + decode: decodeExternalUserId, + }) + + const externalReplyToMessageId = yield* decodeOptionalExternalIdOrWarn({ + eventType: "MESSAGE_CREATE", + field: "message_reference.message_id", + value: event.message_reference?.message_id, + decode: decodeExternalMessageId, + }) + + const externalWebhookId = yield* decodeOptionalExternalIdOrWarn({ + eventType: "MESSAGE_CREATE", + field: "webhook_id", + value: event.webhook_id, + decode: decodeExternalWebhookId, + }) + + const externalAttachments = normalizeDiscordMessageAttachments(event.attachments) + const links = yield* deps.findActiveLinksByExternalChannel(externalChannelIdOption.value) + const inboundLinks = links.filter((link) => link.direction !== "hazel_to_external") + + yield* Effect.forEach(inboundLinks, (link) => + deps.discordSyncWorker.ingestMessageCreate({ + syncConnectionId: link.syncConnectionId, + externalChannelId: externalChannelIdOption.value, + externalMessageId: externalMessageIdOption.value, + externalWebhookId, + content: event.content ?? "", + externalAuthorId, + externalAuthorDisplayName: formatDiscordDisplayName(event.author), + externalAuthorAvatarUrl: buildAuthorAvatarUrl(event.author), + externalReplyToMessageId: externalReplyToMessageId ?? null, + externalAttachments, + dedupeKey: `discord:gateway:create:${externalMessageIdOption.value}`, + }), + ) + }) + + const ingestMessageUpdateEvent = Effect.fn("DiscordGatewayService.ingestMessageUpdateEvent")(function* ( + event: DiscordMessageUpdateEvent, + ) { + if (!event.id || !event.channel_id || typeof event.content !== "string") return + if (event.author?.bot) return + if (yield* deps.isCurrentBotAuthor(event.author?.id)) return + + const externalChannelIdOption = yield* decodeRequiredExternalIdOrWarn({ + eventType: "MESSAGE_UPDATE", + field: "channel_id", + value: event.channel_id, + decode: decodeExternalChannelId, + }) + if (Option.isNone(externalChannelIdOption)) return + + const externalMessageIdOption = yield* decodeRequiredExternalIdOrWarn({ + eventType: "MESSAGE_UPDATE", + field: "id", + value: event.id, + decode: decodeExternalMessageId, + }) + if (Option.isNone(externalMessageIdOption)) return + + const externalWebhookId = yield* decodeOptionalExternalIdOrWarn({ + eventType: "MESSAGE_UPDATE", + field: "webhook_id", + value: event.webhook_id, + decode: decodeExternalWebhookId, + }) + + const links = yield* deps.findActiveLinksByExternalChannel(externalChannelIdOption.value) + const inboundLinks = links.filter((link) => link.direction !== "hazel_to_external") + const content = event.content + const dedupeSuffix = + event.edited_timestamp ?? + createHash("sha256") + .update(`${externalMessageIdOption.value}:${event.content ?? ""}`) + .digest("hex") + .slice(0, 16) + + yield* Effect.forEach(inboundLinks, (link) => + deps.discordSyncWorker.ingestMessageUpdate({ + syncConnectionId: link.syncConnectionId, + externalChannelId: externalChannelIdOption.value, + externalMessageId: externalMessageIdOption.value, + externalWebhookId, + content, + dedupeKey: `discord:gateway:update:${externalMessageIdOption.value}:${dedupeSuffix}`, + }), + ) + }) + + const ingestMessageDeleteEvent = Effect.fn("DiscordGatewayService.ingestMessageDeleteEvent")(function* ( + event: DiscordMessageDeleteEvent, + ) { + if (!event.id || !event.channel_id) return + + const externalChannelIdOption = yield* decodeRequiredExternalIdOrWarn({ + eventType: "MESSAGE_DELETE", + field: "channel_id", + value: event.channel_id, + decode: decodeExternalChannelId, + }) + if (Option.isNone(externalChannelIdOption)) return + + const externalMessageIdOption = yield* decodeRequiredExternalIdOrWarn({ + eventType: "MESSAGE_DELETE", + field: "id", + value: event.id, + decode: decodeExternalMessageId, + }) + if (Option.isNone(externalMessageIdOption)) return + + const externalWebhookId = yield* decodeOptionalExternalIdOrWarn({ + eventType: "MESSAGE_DELETE", + field: "webhook_id", + value: event.webhook_id, + decode: decodeExternalWebhookId, + }) + + const links = yield* deps.findActiveLinksByExternalChannel(externalChannelIdOption.value) + const inboundLinks = links.filter((link) => link.direction !== "hazel_to_external") + + yield* Effect.forEach(inboundLinks, (link) => + deps.discordSyncWorker.ingestMessageDelete({ + syncConnectionId: link.syncConnectionId, + externalChannelId: externalChannelIdOption.value, + externalMessageId: externalMessageIdOption.value, + externalWebhookId, + dedupeKey: `discord:gateway:delete:${externalMessageIdOption.value}`, + }), + ) + }) + + const ingestMessageReactionAddEvent = Effect.fn("DiscordGatewayService.ingestMessageReactionAddEvent")( + function* (event: DiscordMessageReactionAddEvent) { + if (!event.channel_id || !event.message_id || !event.user_id) return + + const emoji = formatDiscordEmoji(event.emoji) + if (!emoji) return + + const { externalAuthorDisplayName, externalAuthorAvatarUrl } = extractReactionAuthor(event) + const externalChannelIdOption = yield* decodeRequiredExternalIdOrWarn({ + eventType: "MESSAGE_REACTION_ADD", + field: "channel_id", + value: event.channel_id, + decode: decodeExternalChannelId, + }) + if (Option.isNone(externalChannelIdOption)) return + + const externalMessageIdOption = yield* decodeRequiredExternalIdOrWarn({ + eventType: "MESSAGE_REACTION_ADD", + field: "message_id", + value: event.message_id, + decode: decodeExternalMessageId, + }) + if (Option.isNone(externalMessageIdOption)) return + + const externalUserIdOption = yield* decodeRequiredExternalIdOrWarn({ + eventType: "MESSAGE_REACTION_ADD", + field: "user_id", + value: event.user_id, + decode: decodeExternalUserId, + }) + if (Option.isNone(externalUserIdOption)) return + + const links = yield* deps.findActiveLinksByExternalChannel(externalChannelIdOption.value) + const inboundLinks = links.filter((link) => link.direction !== "hazel_to_external") + + yield* Effect.forEach(inboundLinks, (link) => + deps.discordSyncWorker.ingestReactionAdd({ + syncConnectionId: link.syncConnectionId, + externalChannelId: externalChannelIdOption.value, + externalMessageId: externalMessageIdOption.value, + externalUserId: externalUserIdOption.value, + emoji, + externalAuthorDisplayName, + externalAuthorAvatarUrl, + dedupeKey: `discord:gateway:reaction:add:${externalChannelIdOption.value}:${externalMessageIdOption.value}:${externalUserIdOption.value}:${emoji}`, + }), + ) + }, + ) + + const ingestMessageReactionRemoveEvent = Effect.fn( + "DiscordGatewayService.ingestMessageReactionRemoveEvent", + )(function* (event: DiscordMessageReactionRemoveEvent) { + if (!event.channel_id || !event.message_id || !event.user_id) return + + const emoji = formatDiscordEmoji(event.emoji) + if (!emoji) return + + const { externalAuthorDisplayName, externalAuthorAvatarUrl } = extractReactionAuthor(event) + const externalChannelIdOption = yield* decodeRequiredExternalIdOrWarn({ + eventType: "MESSAGE_REACTION_REMOVE", + field: "channel_id", + value: event.channel_id, + decode: decodeExternalChannelId, + }) + if (Option.isNone(externalChannelIdOption)) return + + const externalMessageIdOption = yield* decodeRequiredExternalIdOrWarn({ + eventType: "MESSAGE_REACTION_REMOVE", + field: "message_id", + value: event.message_id, + decode: decodeExternalMessageId, + }) + if (Option.isNone(externalMessageIdOption)) return + + const externalUserIdOption = yield* decodeRequiredExternalIdOrWarn({ + eventType: "MESSAGE_REACTION_REMOVE", + field: "user_id", + value: event.user_id, + decode: decodeExternalUserId, + }) + if (Option.isNone(externalUserIdOption)) return + + const links = yield* deps.findActiveLinksByExternalChannel(externalChannelIdOption.value) + const inboundLinks = links.filter((link) => link.direction !== "hazel_to_external") + + yield* Effect.forEach(inboundLinks, (link) => + deps.discordSyncWorker.ingestReactionRemove({ + syncConnectionId: link.syncConnectionId, + externalChannelId: externalChannelIdOption.value, + externalMessageId: externalMessageIdOption.value, + externalUserId: externalUserIdOption.value, + emoji, + externalAuthorDisplayName, + externalAuthorAvatarUrl, + dedupeKey: `discord:gateway:reaction:remove:${externalChannelIdOption.value}:${externalMessageIdOption.value}:${externalUserIdOption.value}:${emoji}`, + }), + ) + }) + + const ingestThreadCreateEvent = Effect.fn("DiscordGatewayService.ingestThreadCreateEvent")(function* ( + event: DiscordThreadCreateEvent, + ) { + if (!event.id || !event.parent_id || event.type !== 11) return + + const externalParentChannelIdOption = yield* decodeRequiredExternalIdOrWarn({ + eventType: "THREAD_CREATE", + field: "parent_id", + value: event.parent_id, + decode: decodeExternalChannelId, + }) + if (Option.isNone(externalParentChannelIdOption)) return + + const externalThreadIdOption = yield* decodeRequiredExternalIdOrWarn({ + eventType: "THREAD_CREATE", + field: "id", + value: event.id, + decode: decodeExternalThreadId, + }) + if (Option.isNone(externalThreadIdOption)) return + + const links = yield* deps.findActiveLinksByExternalChannel(externalParentChannelIdOption.value) + const inboundLinks = links.filter((link) => link.direction !== "hazel_to_external") + + yield* Effect.forEach(inboundLinks, (link) => + deps.discordSyncWorker.ingestThreadCreate({ + syncConnectionId: link.syncConnectionId, + externalParentChannelId: externalParentChannelIdOption.value, + externalThreadId: externalThreadIdOption.value, + name: event.name, + dedupeKey: `discord:gateway:thread:create:${externalThreadIdOption.value}`, + }), + ) + }) + + return { + ingestMessageCreateEvent, + ingestMessageUpdateEvent, + ingestMessageDeleteEvent, + ingestMessageReactionAddEvent, + ingestMessageReactionRemoveEvent, + ingestThreadCreateEvent, + } +} diff --git a/apps/backend/src/services/chat-sync/discord-sync-worker.test.ts b/apps/backend/src/services/chat-sync/discord-sync-worker.test.ts index e20f44859..0f036ecdf 100644 --- a/apps/backend/src/services/chat-sync/discord-sync-worker.test.ts +++ b/apps/backend/src/services/chat-sync/discord-sync-worker.test.ts @@ -17,6 +17,7 @@ import type { ChannelId, ExternalChannelId, ExternalMessageId, + ExternalThreadId, ExternalUserId, ExternalWebhookId, MessageId, @@ -26,13 +27,14 @@ import type { UserId, } from "@hazel/schema" import { Discord } from "@hazel/integrations" -import { Effect, Layer, Option } from "effect" +import { Effect, Layer, Option, ServiceMap } from "effect" import { ChannelAccessSyncService } from "../channel-access-sync.ts" import { IntegrationBotService } from "../integrations/integration-bot-service.ts" -import { ChatSyncCoreWorker } from "./chat-sync-core-worker.ts" +import { configLayer, serviceShape } from "../../test/effect-helpers.ts" +import { ChatSyncCoreWorker, ChatSyncCoreWorkerMake } from "./chat-sync-core-worker.ts" import { ChatSyncProviderRegistry } from "./chat-sync-provider-registry.ts" import { - DiscordSyncWorker, + DiscordSyncWorker as DiscordSyncWorkerTag, type DiscordIngressMessageCreate, type DiscordIngressMessageUpdate, type DiscordIngressMessageDelete, @@ -40,27 +42,28 @@ import { type DiscordIngressReactionRemove, } from "./discord-sync-worker.ts" -const SYNC_CONNECTION_ID = "00000000-0000-0000-0000-000000000001" as SyncConnectionId -const CHANNEL_LINK_ID = "00000000-0000-0000-0000-000000000002" as SyncChannelLinkId -const HAZEL_CHANNEL_ID = "00000000-0000-0000-0000-000000000003" as ChannelId -const ORGANIZATION_ID = "00000000-0000-0000-0000-000000000004" as OrganizationId -const HAZEL_MESSAGE_ID = "00000000-0000-0000-0000-000000000005" as MessageId -const BOT_USER_ID = "00000000-0000-0000-0000-000000000006" as UserId -const REACTION_USER_ID = "00000000-0000-0000-0000-000000000007" as UserId +const SYNC_CONNECTION_ID = "00000000-0000-4000-8000-000000000001" as SyncConnectionId +const CHANNEL_LINK_ID = "00000000-0000-4000-8000-000000000002" as SyncChannelLinkId +const HAZEL_CHANNEL_ID = "00000000-0000-4000-8000-000000000003" as ChannelId +const ORGANIZATION_ID = "00000000-0000-4000-8000-000000000004" as OrganizationId +const HAZEL_MESSAGE_ID = "00000000-0000-4000-8000-000000000005" as MessageId +const BOT_USER_ID = "00000000-0000-4000-8000-000000000006" as UserId +const REACTION_USER_ID = "00000000-0000-4000-8000-000000000007" as UserId const DISCORD_CHANNEL_ID = "discord-channel-1" as ExternalChannelId const DISCORD_CHANNEL_ID_SNOWFLAKE = "123456789012345678" as ExternalChannelId const DISCORD_MESSAGE_ID = "discord-message-1" as ExternalMessageId const DISCORD_WEBHOOK_ID = "123456789012345678" as ExternalWebhookId const DISCORD_WEBHOOK_TOKEN = "webhook-test-token" const DISCORD_WEBHOOK_MESSAGE_ID = "987654321098765432" as ExternalMessageId +const ATTACHMENT_PUBLIC_URL = "https://cdn.example.com" const DISCORD_USER_ID_1 = "discord-user-1" as ExternalUserId const DISCORD_USER_ID_2 = "discord-user-2" as ExternalUserId const DISCORD_USER_ID_3 = "discord-user-3" as ExternalUserId -const SYNCED_MENTION_USER_ID = "00000000-0000-0000-0000-000000000011" as UserId -const UNSYNCED_MENTION_USER_ID = "00000000-0000-0000-0000-000000000012" as UserId -const INACTIVE_MENTION_USER_ID = "00000000-0000-0000-0000-000000000013" as UserId -const MISSING_EXTERNAL_MENTION_USER_ID = "00000000-0000-0000-0000-000000000014" as UserId -const UNKNOWN_MENTION_USER_ID = "00000000-0000-0000-0000-000000000015" as UserId +const SYNCED_MENTION_USER_ID = "00000000-0000-4000-8000-000000000011" as UserId +const UNSYNCED_MENTION_USER_ID = "00000000-0000-4000-8000-000000000012" as UserId +const INACTIVE_MENTION_USER_ID = "00000000-0000-4000-8000-000000000013" as UserId +const MISSING_EXTERNAL_MENTION_USER_ID = "00000000-0000-4000-8000-000000000014" as UserId +const UNKNOWN_MENTION_USER_ID = "00000000-0000-4000-8000-000000000015" as UserId const DISCORD_WEBHOOK_EMPTY_SETTINGS = { outboundIdentity: { enabled: true, @@ -123,6 +126,8 @@ type WorkerLayerDeps = { databaseExecute?: (query: unknown) => Effect.Effect } +type DiscordSyncWorkerShape = ServiceMap.Service.Shape + const PAYLOAD: DiscordIngressMessageCreate = { syncConnectionId: SYNC_CONNECTION_ID, externalChannelId: DISCORD_CHANNEL_ID, @@ -131,27 +136,61 @@ const PAYLOAD: DiscordIngressMessageCreate = { } const runWorkerEffect = (effect: Effect.Effect) => - Effect.runPromise(effect as Effect.Effect) + Effect.runPromise(Effect.scoped(effect as Effect.Effect)) + +const defaultProviderRegistry = serviceShape({ + getAdapter: () => + Effect.succeed({ + provider: "discord", + createMessage: () => Effect.succeed(DISCORD_MESSAGE_ID), + createMessageWithAttachments: () => Effect.succeed(DISCORD_MESSAGE_ID), + updateMessage: () => Effect.succeed(undefined), + deleteMessage: () => Effect.succeed(undefined), + addReaction: () => Effect.succeed(undefined), + removeReaction: () => Effect.succeed(undefined), + createThread: () => Effect.succeed("123456789012345679" as ExternalThreadId), + }), +}) + +const defaultDiscordApiClient = serviceShape({ + getAccountInfo: () => Effect.succeed({ id: "discord-bot" }), + listGuilds: () => Effect.succeed([]), + listGuildChannels: () => Effect.succeed([]), + createMessage: () => Effect.succeed(DISCORD_MESSAGE_ID), + updateMessage: () => Effect.succeed(undefined), + deleteMessage: () => Effect.succeed(undefined), + createWebhook: () => + Effect.succeed({ + id: DISCORD_WEBHOOK_ID, + token: DISCORD_WEBHOOK_TOKEN, + }), + executeWebhookMessage: () => Effect.succeed(DISCORD_WEBHOOK_MESSAGE_ID), + updateWebhookMessage: () => Effect.succeed(undefined), + deleteWebhookMessage: () => Effect.succeed(undefined), + addReaction: () => Effect.succeed(undefined), + removeReaction: () => Effect.succeed(undefined), + createThread: () => Effect.succeed("123456789012345680"), +}) const makeWorkerLayer = (deps: WorkerLayerDeps) => - DiscordSyncWorker.DefaultWithoutDependencies.pipe( + Layer.effect(DiscordSyncWorkerTag, DiscordSyncWorkerTag.make).pipe( Layer.provide( - ChatSyncCoreWorker.DefaultWithoutDependencies.pipe( + Layer.effect(ChatSyncCoreWorker, ChatSyncCoreWorkerMake).pipe( Layer.provide( deps.providerRegistry ? Layer.succeed( ChatSyncProviderRegistry, - deps.providerRegistry as ChatSyncProviderRegistry, + serviceShape(deps.providerRegistry), ) - : ChatSyncProviderRegistry.Default, + : Layer.succeed(ChatSyncProviderRegistry, defaultProviderRegistry), ), Layer.provide( deps.discordApiClient ? Layer.succeed( Discord.DiscordApiClient, - deps.discordApiClient as Discord.DiscordApiClient, + serviceShape(deps.discordApiClient), ) - : Discord.DiscordApiClient.Default, + : Layer.succeed(Discord.DiscordApiClient, defaultDiscordApiClient), ), ), ), @@ -168,44 +207,71 @@ const makeWorkerLayer = (deps: WorkerLayerDeps) => makeQueryWithSchema: () => Effect.die("not used in this test"), } as any), ), - Layer.provide(Layer.succeed(ChatSyncConnectionRepo, deps.connectionRepo as ChatSyncConnectionRepo)), Layer.provide( - Layer.succeed(ChatSyncChannelLinkRepo, deps.channelLinkRepo as ChatSyncChannelLinkRepo), + Layer.succeed( + ChatSyncConnectionRepo, + serviceShape(deps.connectionRepo), + ), + ), + Layer.provide( + Layer.succeed( + ChatSyncChannelLinkRepo, + serviceShape(deps.channelLinkRepo), + ), ), Layer.provide( - Layer.succeed(ChatSyncMessageLinkRepo, deps.messageLinkRepo as ChatSyncMessageLinkRepo), + Layer.succeed( + ChatSyncMessageLinkRepo, + serviceShape(deps.messageLinkRepo), + ), ), Layer.provide( - Layer.succeed(ChatSyncEventReceiptRepo, deps.eventReceiptRepo as ChatSyncEventReceiptRepo), + Layer.succeed( + ChatSyncEventReceiptRepo, + serviceShape(deps.eventReceiptRepo), + ), ), - Layer.provide(Layer.succeed(MessageRepo, deps.messageRepo as MessageRepo)), + Layer.provide(Layer.succeed(MessageRepo, serviceShape(deps.messageRepo))), Layer.provide( Layer.succeed( MessageOutboxRepo, - (deps.messageOutboxRepo ?? { - insert: () => Effect.succeed([{ id: "outbox-id" } as any]), - }) as MessageOutboxRepo, + serviceShape( + deps.messageOutboxRepo ?? { + insert: () => Effect.succeed([{ id: "outbox-id" }]), + }, + ), + ), + ), + Layer.provide( + Layer.succeed( + MessageReactionRepo, + serviceShape(deps.messageReactionRepo), ), ), - Layer.provide(Layer.succeed(MessageReactionRepo, deps.messageReactionRepo as MessageReactionRepo)), - Layer.provide(Layer.succeed(ChannelRepo, deps.channelRepo as ChannelRepo)), + Layer.provide(Layer.succeed(ChannelRepo, serviceShape(deps.channelRepo))), Layer.provide( Layer.succeed( IntegrationConnectionRepo, - deps.integrationConnectionRepo as IntegrationConnectionRepo, + serviceShape(deps.integrationConnectionRepo), ), ), - Layer.provide(Layer.succeed(UserRepo, deps.userRepo as UserRepo)), + Layer.provide(Layer.succeed(UserRepo, serviceShape(deps.userRepo))), Layer.provide( - Layer.succeed(OrganizationMemberRepo, deps.organizationMemberRepo as OrganizationMemberRepo), + Layer.succeed( + OrganizationMemberRepo, + serviceShape(deps.organizationMemberRepo), + ), ), Layer.provide( - Layer.succeed(IntegrationBotService, deps.integrationBotService as IntegrationBotService), + Layer.succeed( + IntegrationBotService, + serviceShape(deps.integrationBotService), + ), ), Layer.provide( Layer.succeed( ChannelAccessSyncService, - deps.channelAccessSyncService as ChannelAccessSyncService, + serviceShape(deps.channelAccessSyncService), ), ), ) @@ -215,6 +281,46 @@ const makeWorkerLayerWithOverrides = (deps: WorkerLayerDeps) => { return layer } +const useWorker = (fn: (worker: DiscordSyncWorkerShape) => Effect.Effect) => + DiscordSyncWorkerTag.use(fn) + +const DiscordSyncWorker = { + ingestMessageCreate: (payload: DiscordIngressMessageCreate) => + useWorker((worker) => worker.ingestMessageCreate(payload)), + ingestMessageUpdate: (payload: DiscordIngressMessageUpdate) => + useWorker((worker) => worker.ingestMessageUpdate(payload)), + ingestMessageDelete: (payload: DiscordIngressMessageDelete) => + useWorker((worker) => worker.ingestMessageDelete(payload)), + ingestReactionAdd: (payload: DiscordIngressReactionAdd) => + useWorker((worker) => worker.ingestReactionAdd(payload)), + ingestReactionRemove: (payload: DiscordIngressReactionRemove) => + useWorker((worker) => worker.ingestReactionRemove(payload)), + syncHazelMessageToDiscord: ( + syncConnectionId: SyncConnectionId, + hazelMessageId: MessageId, + dedupeKeyOverride?: string, + ) => + useWorker((worker) => + worker.syncHazelMessageToDiscord(syncConnectionId, hazelMessageId, dedupeKeyOverride), + ), + syncHazelMessageUpdateToDiscord: ( + syncConnectionId: SyncConnectionId, + hazelMessageId: MessageId, + dedupeKeyOverride?: string, + ) => + useWorker((worker) => + worker.syncHazelMessageUpdateToDiscord(syncConnectionId, hazelMessageId, dedupeKeyOverride), + ), + syncHazelMessageDeleteToDiscord: ( + syncConnectionId: SyncConnectionId, + hazelMessageId: MessageId, + dedupeKeyOverride?: string, + ) => + useWorker((worker) => + worker.syncHazelMessageDeleteToDiscord(syncConnectionId, hazelMessageId, dedupeKeyOverride), + ), +} + describe("DiscordSyncWorker dedupe claim", () => { it("returns deduped and exits before side effects when claim fails", async () => { let connectionLookupCalled = false @@ -390,7 +496,7 @@ describe("DiscordSyncWorker inbound attachment records", () => { } as unknown as ChatSyncChannelLinkRepo, messageLinkRepo: { findByExternalMessage: () => Effect.succeed(Option.none()), - insert: () => Effect.succeed([{ id: "message-link-id" } as any]), + insert: () => Effect.succeed([{ id: "message-link-id" }]), } as unknown as ChatSyncMessageLinkRepo, eventReceiptRepo: { claimByDedupeKey: () => Effect.succeed(true), @@ -399,7 +505,7 @@ describe("DiscordSyncWorker inbound attachment records", () => { messageRepo: { insert: (payload: { content: string }) => { insertedContent = payload.content - return Effect.succeed([{ id: HAZEL_MESSAGE_ID, threadChannelId: null } as any]) + return Effect.succeed([{ id: HAZEL_MESSAGE_ID, threadChannelId: null }]) }, } as unknown as MessageRepo, messageReactionRepo: {} as unknown as MessageReactionRepo, @@ -493,7 +599,7 @@ describe("DiscordSyncWorker inbound attachment records", () => { } as unknown as ChatSyncChannelLinkRepo, messageLinkRepo: { findByExternalMessage: () => Effect.succeed(Option.none()), - insert: () => Effect.succeed([{ id: "message-link-id" } as any]), + insert: () => Effect.succeed([{ id: "message-link-id" }]), } as unknown as ChatSyncMessageLinkRepo, eventReceiptRepo: { claimByDedupeKey: () => Effect.succeed(true), @@ -502,7 +608,7 @@ describe("DiscordSyncWorker inbound attachment records", () => { messageRepo: { insert: (payload: { content: string }) => { insertedContent = payload.content - return Effect.succeed([{ id: HAZEL_MESSAGE_ID, threadChannelId: null } as any]) + return Effect.succeed([{ id: HAZEL_MESSAGE_ID, threadChannelId: null }]) }, } as unknown as MessageRepo, messageReactionRepo: {} as unknown as MessageReactionRepo, @@ -576,7 +682,7 @@ describe("DiscordSyncWorker inbound attachment records", () => { } as unknown as ChatSyncChannelLinkRepo, messageLinkRepo: { findByExternalMessage: () => Effect.succeed(Option.none()), - insert: () => Effect.succeed([{ id: "message-link-id" } as any]), + insert: () => Effect.succeed([{ id: "message-link-id" }]), } as unknown as ChatSyncMessageLinkRepo, eventReceiptRepo: { claimByDedupeKey: () => Effect.succeed(true), @@ -585,7 +691,7 @@ describe("DiscordSyncWorker inbound attachment records", () => { messageRepo: { insert: (payload: { content: string }) => { insertedContent = payload.content - return Effect.succeed([{ id: HAZEL_MESSAGE_ID, threadChannelId: null } as any]) + return Effect.succeed([{ id: HAZEL_MESSAGE_ID, threadChannelId: null }]) }, } as unknown as MessageRepo, messageReactionRepo: {} as unknown as MessageReactionRepo, @@ -712,7 +818,7 @@ describe("DiscordSyncWorker inbound attachment records", () => { messageRepo: { update: (payload: { content?: string }) => { updatedContent = payload.content ?? null - return Effect.succeed([{ id: HAZEL_MESSAGE_ID } as any]) + return Effect.succeed([{ id: HAZEL_MESSAGE_ID }]) }, } as unknown as MessageRepo, messageReactionRepo: {} as unknown as MessageReactionRepo, @@ -780,7 +886,7 @@ describe("DiscordSyncWorker reaction author enrichment", () => { status: "active", }), ), - updateLastSyncedAt: () => Effect.succeed({ id: SYNC_CONNECTION_ID } as any), + updateLastSyncedAt: () => Effect.succeed({ id: SYNC_CONNECTION_ID }), } as unknown as ChatSyncConnectionRepo, channelLinkRepo: { findByExternalChannel: () => @@ -790,7 +896,7 @@ describe("DiscordSyncWorker reaction author enrichment", () => { hazelChannelId: HAZEL_CHANNEL_ID, }), ), - updateLastSyncedAt: () => Effect.succeed({ id: CHANNEL_LINK_ID } as any), + updateLastSyncedAt: () => Effect.succeed({ id: CHANNEL_LINK_ID }), } as unknown as ChatSyncChannelLinkRepo, messageLinkRepo: { findByExternalMessage: () => @@ -817,7 +923,7 @@ describe("DiscordSyncWorker reaction author enrichment", () => { id: REACTION_USER_ID, messageId: HAZEL_MESSAGE_ID, channelId: HAZEL_CHANNEL_ID, - userId: "00000000-0000-0000-0000-000000000008", + userId: "00000000-0000-4000-8000-000000000008", emoji: "🚀", }, ]), @@ -892,7 +998,7 @@ describe("DiscordSyncWorker reaction author enrichment", () => { status: "active", }), ), - updateLastSyncedAt: () => Effect.succeed({ id: SYNC_CONNECTION_ID } as any), + updateLastSyncedAt: () => Effect.succeed({ id: SYNC_CONNECTION_ID }), } as unknown as ChatSyncConnectionRepo, channelLinkRepo: { findByExternalChannel: () => @@ -902,7 +1008,7 @@ describe("DiscordSyncWorker reaction author enrichment", () => { hazelChannelId: HAZEL_CHANNEL_ID, }), ), - updateLastSyncedAt: () => Effect.succeed({ id: CHANNEL_LINK_ID } as any), + updateLastSyncedAt: () => Effect.succeed({ id: CHANNEL_LINK_ID }), } as unknown as ChatSyncChannelLinkRepo, messageLinkRepo: { findByExternalMessage: () => @@ -929,7 +1035,7 @@ describe("DiscordSyncWorker reaction author enrichment", () => { id: REACTION_USER_ID, messageId: HAZEL_MESSAGE_ID, channelId: HAZEL_CHANNEL_ID, - userId: "00000000-0000-0000-0000-000000000008", + userId: "00000000-0000-4000-8000-000000000008", emoji: "🚀", }, ]), @@ -1000,7 +1106,7 @@ describe("DiscordSyncWorker reaction author enrichment", () => { status: "active", }), ), - updateLastSyncedAt: () => Effect.succeed({ id: SYNC_CONNECTION_ID } as any), + updateLastSyncedAt: () => Effect.succeed({ id: SYNC_CONNECTION_ID }), } as unknown as ChatSyncConnectionRepo, channelLinkRepo: { findByExternalChannel: () => @@ -1010,7 +1116,7 @@ describe("DiscordSyncWorker reaction author enrichment", () => { hazelChannelId: HAZEL_CHANNEL_ID, }), ), - updateLastSyncedAt: () => Effect.succeed({ id: CHANNEL_LINK_ID } as any), + updateLastSyncedAt: () => Effect.succeed({ id: CHANNEL_LINK_ID }), } as unknown as ChatSyncChannelLinkRepo, messageLinkRepo: { findByExternalMessage: () => @@ -1033,7 +1139,7 @@ describe("DiscordSyncWorker reaction author enrichment", () => { findByMessageUserEmoji: () => Effect.succeed( Option.some({ - id: "00000000-0000-0000-0000-000000000008", + id: "00000000-0000-4000-8000-000000000008", messageId: HAZEL_MESSAGE_ID, }), ), @@ -1328,9 +1434,7 @@ describe("DiscordSyncWorker outbound webhook dispatch", () => { externalMessageId: ExternalMessageId }) => { insertedExternalMessageId = payload.externalMessageId - return Effect.succeed([ - { id: "message-link-id", channelLinkId: payload.channelLinkId } as any, - ]) + return Effect.succeed([{ id: "message-link-id", channelLinkId: payload.channelLinkId }]) }, } as unknown as ChatSyncMessageLinkRepo, eventReceiptRepo: { @@ -1423,7 +1527,7 @@ describe("DiscordSyncWorker outbound webhook dispatch", () => { } as unknown as ChatSyncChannelLinkRepo, messageLinkRepo: { findByHazelMessage: () => Effect.succeed(Option.none()), - insert: () => Effect.succeed([{ id: "message-link-id" } as any]), + insert: () => Effect.succeed([{ id: "message-link-id" }]), } as unknown as ChatSyncMessageLinkRepo, eventReceiptRepo: { claimByDedupeKey: () => Effect.succeed(true), @@ -1585,7 +1689,7 @@ describe("DiscordSyncWorker outbound webhook dispatch", () => { } as unknown as ChatSyncChannelLinkRepo, messageLinkRepo: { findByHazelMessage: () => Effect.succeed(Option.none()), - insert: () => Effect.succeed([{ id: "message-link-id" } as any]), + insert: () => Effect.succeed([{ id: "message-link-id" }]), } as unknown as ChatSyncMessageLinkRepo, eventReceiptRepo: { claimByDedupeKey: () => Effect.succeed(true), @@ -2107,7 +2211,7 @@ describe("DiscordSyncWorker outbound webhook dispatch", () => { externalMessageId: DISCORD_WEBHOOK_MESSAGE_ID, }), ), - softDelete: () => Effect.succeed([{ id: "message-link-id" } as any]), + softDelete: () => Effect.succeed([{ id: "message-link-id" }]), updateLastSyncedAt: () => Effect.succeed([]), } as unknown as ChatSyncMessageLinkRepo, eventReceiptRepo: { @@ -2232,12 +2336,12 @@ describe("DiscordSyncWorker outbound webhook dispatch", () => { updateSettings: (id: SyncChannelLinkId, settings: Record | null) => { persistedLinkId = id updatedSettings = settings - return Effect.succeed([{ id }] as any) + return Effect.succeed([{ id }]) }, } as unknown as ChatSyncChannelLinkRepo, messageLinkRepo: { findByHazelMessage: () => Effect.succeed(Option.none()), - insert: () => Effect.succeed([{ id: "message-link-id" } as any]), + insert: () => Effect.succeed([{ id: "message-link-id" }]), } as unknown as ChatSyncMessageLinkRepo, eventReceiptRepo: { claimByDedupeKey: () => Effect.succeed(true), @@ -2370,12 +2474,12 @@ describe("DiscordSyncWorker outbound webhook dispatch", () => { ), updateLastSyncedAt: () => Effect.succeed([]), updateSettings: () => { - return Effect.succeed([{ id: CHANNEL_LINK_ID }] as any) + return Effect.succeed([{ id: CHANNEL_LINK_ID }]) }, } as unknown as ChatSyncChannelLinkRepo, messageLinkRepo: { findByHazelMessage: () => Effect.succeed(Option.none()), - insert: () => Effect.succeed([{ id: "message-link-id" } as any]), + insert: () => Effect.succeed([{ id: "message-link-id" }]), } as unknown as ChatSyncMessageLinkRepo, eventReceiptRepo: { claimByDedupeKey: () => Effect.succeed(true), @@ -2497,12 +2601,12 @@ describe("DiscordSyncWorker outbound webhook dispatch", () => { updateLastSyncedAt: () => Effect.succeed([]), updateSettings: (id: SyncChannelLinkId, settings: Record | null) => { updatedSettings = settings - return Effect.succeed([{ id }] as any) + return Effect.succeed([{ id }]) }, } as unknown as ChatSyncChannelLinkRepo, messageLinkRepo: { findByHazelMessage: () => Effect.succeed(Option.none()), - insert: () => Effect.succeed([{ id: "message-link-id" } as any]), + insert: () => Effect.succeed([{ id: "message-link-id" }]), } as unknown as ChatSyncMessageLinkRepo, eventReceiptRepo: { claimByDedupeKey: () => Effect.succeed(true), @@ -2646,12 +2750,12 @@ describe("DiscordSyncWorker outbound webhook dispatch", () => { updateLastSyncedAt: () => Effect.succeed([]), updateSettings: (id: SyncChannelLinkId, settings: Record | null) => { updatedSettings = settings - return Effect.succeed([{ id }] as any) + return Effect.succeed([{ id }]) }, } as unknown as ChatSyncChannelLinkRepo, messageLinkRepo: { findByHazelMessage: () => Effect.succeed(Option.none()), - insert: () => Effect.succeed([{ id: "message-link-id" } as any]), + insert: () => Effect.succeed([{ id: "message-link-id" }]), } as unknown as ChatSyncMessageLinkRepo, eventReceiptRepo: { claimByDedupeKey: () => Effect.succeed(true), @@ -2851,33 +2955,24 @@ describe("DiscordSyncWorker outbound attachments primitive", () => { databaseExecute: () => Effect.succeed([ { - id: "00000000-0000-0000-0000-000000000101", + id: "00000000-0000-4000-8000-000000000101", fileName: "diagram.png", fileSize: 2048, }, ]), }) - const originalS3PublicUrl = process.env.S3_PUBLIC_URL - process.env.S3_PUBLIC_URL = "https://cdn.example.com" - try { - const result = await runWorkerEffect( - DiscordSyncWorker.syncHazelMessageToDiscord(SYNC_CONNECTION_ID, HAZEL_MESSAGE_ID).pipe( - Effect.provide(layer), - ), - ) + const result = await runWorkerEffect( + DiscordSyncWorker.syncHazelMessageToDiscord(SYNC_CONNECTION_ID, HAZEL_MESSAGE_ID).pipe( + Effect.provide(layer), + Effect.provide(configLayer({ S3_PUBLIC_URL: ATTACHMENT_PUBLIC_URL })), + ), + ) - expect(result.status).toBe("synced") - expect(createMessageWithAttachmentsCalled).toBe(true) - expect(createMessageCalled).toBe(false) - expect(receivedAttachmentCount).toBe(1) - } finally { - if (originalS3PublicUrl === undefined) { - delete process.env.S3_PUBLIC_URL - } else { - process.env.S3_PUBLIC_URL = originalS3PublicUrl - } - } + expect(result.status).toBe("synced") + expect(createMessageWithAttachmentsCalled).toBe(true) + expect(createMessageCalled).toBe(false) + expect(receivedAttachmentCount).toBe(1) }) it("keeps using createMessage when no attachments are present", async () => { @@ -2911,8 +3006,7 @@ describe("DiscordSyncWorker outbound attachments primitive", () => { } as unknown as ChatSyncChannelLinkRepo, messageLinkRepo: { findByHazelMessage: () => Effect.succeed(Option.none()), - insert: () => - Effect.succeed([{ id: "message-link-id", channelLinkId: CHANNEL_LINK_ID } as any]), + insert: () => Effect.succeed([{ id: "message-link-id", channelLinkId: CHANNEL_LINK_ID }]), } as unknown as ChatSyncMessageLinkRepo, eventReceiptRepo: { claimByDedupeKey: () => Effect.succeed(true), @@ -3079,32 +3173,23 @@ describe("DiscordSyncWorker outbound attachments primitive", () => { databaseExecute: () => Effect.succeed([ { - id: "00000000-0000-0000-0000-000000000201", + id: "00000000-0000-4000-8000-000000000201", fileName: "capture.jpg", fileSize: 5120, }, ]), }) - const originalS3PublicUrl = process.env.S3_PUBLIC_URL - process.env.S3_PUBLIC_URL = "https://cdn.example.com" - try { - const result = await runWorkerEffect( - DiscordSyncWorker.syncHazelMessageToDiscord(SYNC_CONNECTION_ID, HAZEL_MESSAGE_ID).pipe( - Effect.provide(layer), - ), - ) + const result = await runWorkerEffect( + DiscordSyncWorker.syncHazelMessageToDiscord(SYNC_CONNECTION_ID, HAZEL_MESSAGE_ID).pipe( + Effect.provide(layer), + Effect.provide(configLayer({ S3_PUBLIC_URL: ATTACHMENT_PUBLIC_URL })), + ), + ) - expect(result.status).toBe("synced") - expect(createMessageWithAttachmentsCalled).toBe(true) - expect(firstAttachmentUrl).toBe("https://cdn.example.com/00000000-0000-0000-0000-000000000201") - } finally { - if (originalS3PublicUrl === undefined) { - delete process.env.S3_PUBLIC_URL - } else { - process.env.S3_PUBLIC_URL = originalS3PublicUrl - } - } + expect(result.status).toBe("synced") + expect(createMessageWithAttachmentsCalled).toBe(true) + expect(firstAttachmentUrl).toBe(`${ATTACHMENT_PUBLIC_URL}/00000000-0000-4000-8000-000000000201`) }) it("fails with configuration error when attachments exist and S3_PUBLIC_URL is missing", async () => { @@ -3176,7 +3261,7 @@ describe("DiscordSyncWorker outbound attachments primitive", () => { databaseExecute: () => Effect.succeed([ { - id: "00000000-0000-0000-0000-000000000301", + id: "00000000-0000-4000-8000-000000000301", fileName: "missing-env.txt", fileSize: 10, }, @@ -3189,13 +3274,13 @@ describe("DiscordSyncWorker outbound attachments primitive", () => { const result = await runWorkerEffect( DiscordSyncWorker.syncHazelMessageToDiscord(SYNC_CONNECTION_ID, HAZEL_MESSAGE_ID).pipe( Effect.provide(layer), - Effect.either, + Effect.result, ), ) - expect(result._tag).toBe("Left") - if (result._tag === "Left") { - expect((result.left as { _tag?: string })._tag).toBe("DiscordSyncConfigurationError") + expect(result._tag).toBe("Failure") + if (result._tag === "Failure") { + expect((result.failure as { _tag?: string })._tag).toBe("DiscordSyncConfigurationError") } } finally { if (originalS3PublicUrl === undefined) { diff --git a/apps/backend/src/services/chat-sync/discord-sync-worker.ts b/apps/backend/src/services/chat-sync/discord-sync-worker.ts index 7ba0cd40a..f5599d404 100644 --- a/apps/backend/src/services/chat-sync/discord-sync-worker.ts +++ b/apps/backend/src/services/chat-sync/discord-sync-worker.ts @@ -1,8 +1,9 @@ -import { Effect } from "effect" +import { ServiceMap, Effect, Layer } from "effect" import { ChannelId, MessageId, MessageReactionId, SyncConnectionId, UserId } from "@hazel/schema" import { DEFAULT_MAX_MESSAGES_PER_CHANNEL, ChatSyncCoreWorker, + ChatSyncCoreWorkerLayer, type ChatSyncIngressMessageCreate, type ChatSyncIngressMessageDelete, type ChatSyncIngressMessageUpdate, @@ -31,39 +32,36 @@ export { DiscordSyncMessageNotFoundError, } -export class DiscordSyncWorker extends Effect.Service()("DiscordSyncWorker", { - accessors: true, - effect: Effect.gen(function* () { - const coreWorker = yield* ChatSyncCoreWorker - - const syncConnection = Effect.fn("DiscordSyncWorker.syncConnection")(function* ( - syncConnectionId: SyncConnectionId, - maxMessagesPerChannel = DEFAULT_MAX_MESSAGES_PER_CHANNEL, - ) { - return yield* coreWorker.syncConnection(syncConnectionId, maxMessagesPerChannel) - }) - - const syncAllActiveConnections = Effect.fn("DiscordSyncWorker.syncAllActiveConnections")(function* ( - maxMessagesPerChannel = DEFAULT_MAX_MESSAGES_PER_CHANNEL, - ) { - return yield* coreWorker.syncAllActiveConnections("discord", maxMessagesPerChannel) - }) - - const syncHazelMessageToDiscord = Effect.fn("DiscordSyncWorker.syncHazelMessageToDiscord")(function* ( - syncConnectionId: SyncConnectionId, - hazelMessageId: MessageId, - dedupeKeyOverride?: string, - ) { - return yield* coreWorker.syncHazelMessageToProvider( - syncConnectionId, - hazelMessageId, - dedupeKeyOverride, - ) - }) - - const syncHazelMessageUpdateToDiscord = Effect.fn( - "DiscordSyncWorker.syncHazelMessageUpdateToDiscord", - )(function* ( +const DiscordSyncWorkerMake = Effect.gen(function* () { + const coreWorker = yield* ChatSyncCoreWorker + + const syncConnection = Effect.fn("DiscordSyncWorker.syncConnection")(function* ( + syncConnectionId: SyncConnectionId, + maxMessagesPerChannel = DEFAULT_MAX_MESSAGES_PER_CHANNEL, + ) { + return yield* coreWorker.syncConnection(syncConnectionId, maxMessagesPerChannel) + }) + + const syncAllActiveConnections = Effect.fn("DiscordSyncWorker.syncAllActiveConnections")(function* ( + maxMessagesPerChannel = DEFAULT_MAX_MESSAGES_PER_CHANNEL, + ) { + return yield* coreWorker.syncAllActiveConnections("discord", maxMessagesPerChannel) + }) + + const syncHazelMessageToDiscord = Effect.fn("DiscordSyncWorker.syncHazelMessageToDiscord")(function* ( + syncConnectionId: SyncConnectionId, + hazelMessageId: MessageId, + dedupeKeyOverride?: string, + ) { + return yield* coreWorker.syncHazelMessageToProvider( + syncConnectionId, + hazelMessageId, + dedupeKeyOverride, + ) + }) + + const syncHazelMessageUpdateToDiscord = Effect.fn("discordSyncWorker.syncHazelMessageUpdateToDiscord")( + function* ( syncConnectionId: SyncConnectionId, hazelMessageId: MessageId, dedupeKeyOverride?: string, @@ -73,11 +71,11 @@ export class DiscordSyncWorker extends Effect.Service()("Disc hazelMessageId, dedupeKeyOverride, ) - }) + }, + ) - const syncHazelMessageDeleteToDiscord = Effect.fn( - "DiscordSyncWorker.syncHazelMessageDeleteToDiscord", - )(function* ( + const syncHazelMessageDeleteToDiscord = Effect.fn("discordSyncWorker.syncHazelMessageDeleteToDiscord")( + function* ( syncConnectionId: SyncConnectionId, hazelMessageId: MessageId, dedupeKeyOverride?: string, @@ -87,11 +85,11 @@ export class DiscordSyncWorker extends Effect.Service()("Disc hazelMessageId, dedupeKeyOverride, ) - }) + }, + ) - const syncHazelReactionCreateToDiscord = Effect.fn( - "DiscordSyncWorker.syncHazelReactionCreateToDiscord", - )(function* ( + const syncHazelReactionCreateToDiscord = Effect.fn("discordSyncWorker.syncHazelReactionCreateToDiscord")( + function* ( syncConnectionId: SyncConnectionId, hazelReactionId: MessageReactionId, dedupeKeyOverride?: string, @@ -101,11 +99,11 @@ export class DiscordSyncWorker extends Effect.Service()("Disc hazelReactionId, dedupeKeyOverride, ) - }) + }, + ) - const syncHazelReactionDeleteToDiscord = Effect.fn( - "DiscordSyncWorker.syncHazelReactionDeleteToDiscord", - )(function* ( + const syncHazelReactionDeleteToDiscord = Effect.fn("discordSyncWorker.syncHazelReactionDeleteToDiscord")( + function* ( syncConnectionId: SyncConnectionId, payload: { hazelChannelId: ChannelId @@ -120,118 +118,113 @@ export class DiscordSyncWorker extends Effect.Service()("Disc payload, dedupeKeyOverride, ) - }) - - const syncHazelMessageCreateToAllConnections = Effect.fn( - "DiscordSyncWorker.syncHazelMessageCreateToAllConnections", - )(function* (hazelMessageId: MessageId, dedupeKey?: string) { - return yield* coreWorker.syncHazelMessageCreateToAllConnections( - "discord", - hazelMessageId, - dedupeKey, - ) - }) - - const syncHazelMessageUpdateToAllConnections = Effect.fn( - "DiscordSyncWorker.syncHazelMessageUpdateToAllConnections", - )(function* (hazelMessageId: MessageId, dedupeKey?: string) { - return yield* coreWorker.syncHazelMessageUpdateToAllConnections( - "discord", - hazelMessageId, - dedupeKey, - ) - }) - - const syncHazelMessageDeleteToAllConnections = Effect.fn( - "DiscordSyncWorker.syncHazelMessageDeleteToAllConnections", - )(function* (hazelMessageId: MessageId, dedupeKey?: string) { - return yield* coreWorker.syncHazelMessageDeleteToAllConnections( - "discord", - hazelMessageId, - dedupeKey, - ) - }) - - const syncHazelReactionCreateToAllConnections = Effect.fn( - "DiscordSyncWorker.syncHazelReactionCreateToAllConnections", - )(function* (hazelReactionId: MessageReactionId, dedupeKey?: string) { - return yield* coreWorker.syncHazelReactionCreateToAllConnections( - "discord", - hazelReactionId, - dedupeKey, - ) - }) - - const syncHazelReactionDeleteToAllConnections = Effect.fn( - "DiscordSyncWorker.syncHazelReactionDeleteToAllConnections", - )(function* ( - payload: { - hazelChannelId: ChannelId - hazelMessageId: MessageId - emoji: string - userId?: UserId - }, - dedupeKey?: string, - ) { - return yield* coreWorker.syncHazelReactionDeleteToAllConnections("discord", payload, dedupeKey) - }) - - const ingestMessageCreate = Effect.fn("DiscordSyncWorker.ingestMessageCreate")(function* ( - payload: DiscordIngressMessageCreate, - ) { - return yield* coreWorker.ingestMessageCreate(payload) - }) - - const ingestMessageUpdate = Effect.fn("DiscordSyncWorker.ingestMessageUpdate")(function* ( - payload: DiscordIngressMessageUpdate, - ) { - return yield* coreWorker.ingestMessageUpdate(payload) - }) - - const ingestMessageDelete = Effect.fn("DiscordSyncWorker.ingestMessageDelete")(function* ( - payload: DiscordIngressMessageDelete, - ) { - return yield* coreWorker.ingestMessageDelete(payload) - }) - - const ingestReactionAdd = Effect.fn("DiscordSyncWorker.ingestReactionAdd")(function* ( - payload: DiscordIngressReactionAdd, - ) { - return yield* coreWorker.ingestReactionAdd(payload) - }) - - const ingestReactionRemove = Effect.fn("DiscordSyncWorker.ingestReactionRemove")(function* ( - payload: DiscordIngressReactionRemove, - ) { - return yield* coreWorker.ingestReactionRemove(payload) - }) - - const ingestThreadCreate = Effect.fn("DiscordSyncWorker.ingestThreadCreate")(function* ( - payload: DiscordIngressThreadCreate, - ) { - return yield* coreWorker.ingestThreadCreate(payload) - }) - - return { - syncConnection, - syncAllActiveConnections, - syncHazelMessageToDiscord, - syncHazelMessageUpdateToDiscord, - syncHazelMessageDeleteToDiscord, - syncHazelReactionCreateToDiscord, - syncHazelReactionDeleteToDiscord, - syncHazelMessageCreateToAllConnections, - syncHazelMessageUpdateToAllConnections, - syncHazelMessageDeleteToAllConnections, - syncHazelReactionCreateToAllConnections, - syncHazelReactionDeleteToAllConnections, - ingestMessageCreate, - ingestMessageUpdate, - ingestMessageDelete, - ingestReactionAdd, - ingestReactionRemove, - ingestThreadCreate, - } - }), - dependencies: [ChatSyncCoreWorker.Default], + }, + ) + + const syncHazelMessageCreateToAllConnections = Effect.fn( + "discordSyncWorker.syncHazelMessageCreateToAllConnections", + )(function* (hazelMessageId: MessageId, dedupeKey?: string) { + return yield* coreWorker.syncHazelMessageCreateToAllConnections("discord", hazelMessageId, dedupeKey) + }) + + const syncHazelMessageUpdateToAllConnections = Effect.fn( + "discordSyncWorker.syncHazelMessageUpdateToAllConnections", + )(function* (hazelMessageId: MessageId, dedupeKey?: string) { + return yield* coreWorker.syncHazelMessageUpdateToAllConnections("discord", hazelMessageId, dedupeKey) + }) + + const syncHazelMessageDeleteToAllConnections = Effect.fn( + "discordSyncWorker.syncHazelMessageDeleteToAllConnections", + )(function* (hazelMessageId: MessageId, dedupeKey?: string) { + return yield* coreWorker.syncHazelMessageDeleteToAllConnections("discord", hazelMessageId, dedupeKey) + }) + + const syncHazelReactionCreateToAllConnections = Effect.fn( + "discordSyncWorker.syncHazelReactionCreateToAllConnections", + )(function* (hazelReactionId: MessageReactionId, dedupeKey?: string) { + return yield* coreWorker.syncHazelReactionCreateToAllConnections( + "discord", + hazelReactionId, + dedupeKey, + ) + }) + + const syncHazelReactionDeleteToAllConnections = Effect.fn( + "discordSyncWorker.syncHazelReactionDeleteToAllConnections", + )(function* ( + payload: { + hazelChannelId: ChannelId + hazelMessageId: MessageId + emoji: string + userId?: UserId + }, + dedupeKey?: string, + ) { + return yield* coreWorker.syncHazelReactionDeleteToAllConnections("discord", payload, dedupeKey) + }) + + const ingestMessageCreate = Effect.fn("DiscordSyncWorker.ingestMessageCreate")(function* ( + payload: DiscordIngressMessageCreate, + ) { + return yield* coreWorker.ingestMessageCreate(payload) + }) + + const ingestMessageUpdate = Effect.fn("DiscordSyncWorker.ingestMessageUpdate")(function* ( + payload: DiscordIngressMessageUpdate, + ) { + return yield* coreWorker.ingestMessageUpdate(payload) + }) + + const ingestMessageDelete = Effect.fn("DiscordSyncWorker.ingestMessageDelete")(function* ( + payload: DiscordIngressMessageDelete, + ) { + return yield* coreWorker.ingestMessageDelete(payload) + }) + + const ingestReactionAdd = Effect.fn("DiscordSyncWorker.ingestReactionAdd")(function* ( + payload: DiscordIngressReactionAdd, + ) { + return yield* coreWorker.ingestReactionAdd(payload) + }) + + const ingestReactionRemove = Effect.fn("DiscordSyncWorker.ingestReactionRemove")(function* ( + payload: DiscordIngressReactionRemove, + ) { + return yield* coreWorker.ingestReactionRemove(payload) + }) + + const ingestThreadCreate = Effect.fn("DiscordSyncWorker.ingestThreadCreate")(function* ( + payload: DiscordIngressThreadCreate, + ) { + return yield* coreWorker.ingestThreadCreate(payload) + }) + + return { + syncConnection, + syncAllActiveConnections, + syncHazelMessageToDiscord, + syncHazelMessageUpdateToDiscord, + syncHazelMessageDeleteToDiscord, + syncHazelReactionCreateToDiscord, + syncHazelReactionDeleteToDiscord, + syncHazelMessageCreateToAllConnections, + syncHazelMessageUpdateToAllConnections, + syncHazelMessageDeleteToAllConnections, + syncHazelReactionCreateToAllConnections, + syncHazelReactionDeleteToAllConnections, + ingestMessageCreate, + ingestMessageUpdate, + ingestMessageDelete, + ingestReactionAdd, + ingestReactionRemove, + ingestThreadCreate, + } +}) + +export class DiscordSyncWorker extends ServiceMap.Service()("DiscordSyncWorker", { + make: DiscordSyncWorkerMake, }) {} + +export const DiscordSyncWorkerLayer = Layer.effect(DiscordSyncWorker, DiscordSyncWorker.make).pipe( + Layer.provide(ChatSyncCoreWorkerLayer), +) diff --git a/apps/backend/src/services/connect-conversation-service.test.ts b/apps/backend/src/services/connect-conversation-service.test.ts index 5b519f855..4b85b47df 100644 --- a/apps/backend/src/services/connect-conversation-service.test.ts +++ b/apps/backend/src/services/connect-conversation-service.test.ts @@ -16,20 +16,21 @@ import type { OrganizationId, UserId, } from "@hazel/schema" -import { Effect, Layer, Option } from "effect" +import { Effect, Layer, Option, ServiceMap } from "effect" +import { serviceShape } from "../test/effect-helpers" import { ChannelAccessSyncService } from "./channel-access-sync" import { ConnectConversationService } from "./connect-conversation-service" import { OrgResolver } from "./org-resolver" -const HOST_ORG_ID = "00000000-0000-0000-0000-000000000101" as OrganizationId -const GUEST_ORG_ID = "00000000-0000-0000-0000-000000000102" as OrganizationId -const OTHER_GUEST_ORG_ID = "00000000-0000-0000-0000-000000000103" as OrganizationId -const CONVERSATION_ID = "00000000-0000-0000-0000-000000000201" as ConnectConversationId -const HOST_CHANNEL_ID = "00000000-0000-0000-0000-000000000301" as ChannelId -const GUEST_CHANNEL_ID = "00000000-0000-0000-0000-000000000302" as ChannelId -const OTHER_GUEST_CHANNEL_ID = "00000000-0000-0000-0000-000000000303" as ChannelId -const HOST_USER_ID = "00000000-0000-0000-0000-000000000401" as UserId -const GUEST_USER_ID = "00000000-0000-0000-0000-000000000402" as UserId +const HOST_ORG_ID = "00000000-0000-4000-8000-000000000101" as OrganizationId +const GUEST_ORG_ID = "00000000-0000-4000-8000-000000000102" as OrganizationId +const OTHER_GUEST_ORG_ID = "00000000-0000-4000-8000-000000000103" as OrganizationId +const CONVERSATION_ID = "00000000-0000-4000-8000-000000000201" as ConnectConversationId +const HOST_CHANNEL_ID = "00000000-0000-4000-8000-000000000301" as ChannelId +const GUEST_CHANNEL_ID = "00000000-0000-4000-8000-000000000302" as ChannelId +const OTHER_GUEST_CHANNEL_ID = "00000000-0000-4000-8000-000000000303" as ChannelId +const HOST_USER_ID = "00000000-0000-4000-8000-000000000401" as UserId +const GUEST_USER_ID = "00000000-0000-4000-8000-000000000402" as UserId type MutableConversation = { id: ConnectConversationId @@ -69,88 +70,119 @@ type MutableParticipant = { deletedAt: Date | null } +type ConnectConversationServiceShape = ServiceMap.Service.Shape +type ConnectParticipantUpsertInput = Parameters< + ServiceMap.Service.Shape["upsertByChannelAndUser"] +>[0] + const makeChannelRepoLayer = () => - Layer.succeed(ChannelRepo, { - findById: () => Effect.succeed(Option.none()), - } as unknown as ChannelRepo) + Layer.succeed( + ChannelRepo, + serviceShape({ + findById: () => Effect.succeed(Option.none()), + }), + ) const makeMessageRepoLayer = () => - Layer.succeed(MessageRepo, { - backfillConversationIdForChannel: () => Effect.succeed(undefined), - } as unknown as MessageRepo) + Layer.succeed( + MessageRepo, + serviceShape({ + backfillConversationIdForChannel: () => Effect.succeed(undefined), + }), + ) const makeMessageReactionRepoLayer = () => - Layer.succeed(MessageReactionRepo, { - backfillConversationIdForChannel: () => Effect.succeed(undefined), - } as unknown as MessageReactionRepo) + Layer.succeed( + MessageReactionRepo, + serviceShape({ + backfillConversationIdForChannel: () => Effect.succeed(undefined), + }), + ) const makeOrgResolverLayer = () => - Layer.succeed(OrgResolver, { - fromChannelWithAccess: () => Effect.succeed(undefined), - } as unknown as OrgResolver) + Layer.succeed( + OrgResolver, + serviceShape({ + fromChannelWithAccess: () => Effect.succeed(undefined), + }), + ) const makeConversationRepoLayer = (conversation: MutableConversation) => - Layer.succeed(ConnectConversationRepo, { - findById: (id: ConnectConversationId) => - Effect.succeed(id === conversation.id ? Option.some(conversation) : Option.none()), - update: (patch: Partial & { id: ConnectConversationId }) => - Effect.sync(() => { - Object.assign(conversation, patch) - return conversation - }), - insert: () => Effect.die("not implemented"), - } as unknown as ConnectConversationRepo) + Layer.succeed( + ConnectConversationRepo, + serviceShape({ + findById: (id: ConnectConversationId) => + Effect.succeed(id === conversation.id ? Option.some(conversation) : Option.none()), + update: (patch: Partial & { id: ConnectConversationId }) => + Effect.sync(() => { + Object.assign(conversation, patch) + return conversation + }), + insert: () => Effect.die("not implemented"), + }), + ) const makeMountRepoLayer = (mounts: MutableMount[]) => - Layer.succeed(ConnectConversationChannelRepo, { - findByChannelId: (channelId: ChannelId) => - Effect.succeed( - Option.fromNullable( - mounts.find((mount) => mount.channelId === channelId && mount.deletedAt === null), + Layer.succeed( + ConnectConversationChannelRepo, + serviceShape({ + findByChannelId: (channelId: ChannelId) => + Effect.succeed( + Option.fromNullishOr( + mounts.find((mount) => mount.channelId === channelId && mount.deletedAt === null), + ), ), - ), - findByConversationId: (conversationId: ConnectConversationId) => - Effect.succeed( - mounts.filter((mount) => mount.conversationId === conversationId && mount.deletedAt === null), - ), - update: (patch: Partial & { id: ConnectConversationChannelId }) => - Effect.sync(() => { - const mount = mounts.find((candidate) => candidate.id === patch.id) - if (!mount) throw new Error(`Missing mount ${patch.id}`) - Object.assign(mount, patch) - return mount - }), - insert: () => Effect.die("not implemented"), - } as unknown as ConnectConversationChannelRepo) + findByConversationId: (conversationId: ConnectConversationId) => + Effect.succeed( + mounts.filter( + (mount) => mount.conversationId === conversationId && mount.deletedAt === null, + ), + ), + update: (patch: Partial & { id: ConnectConversationChannelId }) => + Effect.sync(() => { + const mount = mounts.find((candidate) => candidate.id === patch.id) + if (!mount) throw new Error(`Missing mount ${patch.id}`) + Object.assign(mount, patch) + return mount + }), + insert: () => Effect.die("not implemented"), + }), + ) const makeParticipantRepoLayer = (participants: MutableParticipant[]) => - Layer.succeed(ConnectParticipantRepo, { - listByConversation: (conversationId: ConnectConversationId) => - Effect.succeed( - participants.filter( - (participant) => - participant.conversationId === conversationId && participant.deletedAt === null, + Layer.succeed( + ConnectParticipantRepo, + serviceShape({ + listByConversation: (conversationId: ConnectConversationId) => + Effect.succeed( + participants.filter( + (participant) => + participant.conversationId === conversationId && participant.deletedAt === null, + ), ), - ), - update: (patch: Partial & { id: ConnectParticipantId }) => - Effect.sync(() => { - const participant = participants.find((candidate) => candidate.id === patch.id) - if (!participant) throw new Error(`Missing participant ${patch.id}`) - Object.assign(participant, patch) - return participant - }), - findByChannelAndUser: () => Effect.succeed(Option.none()), - insert: () => Effect.die("not implemented"), - upsertByChannelAndUser: () => Effect.die("not implemented"), - } as unknown as ConnectParticipantRepo) + update: (patch: Partial & { id: ConnectParticipantId }) => + Effect.sync(() => { + const participant = participants.find((candidate) => candidate.id === patch.id) + if (!participant) throw new Error(`Missing participant ${patch.id}`) + Object.assign(participant, patch) + return participant + }), + findByChannelAndUser: () => Effect.succeed(Option.none()), + insert: () => Effect.die("not implemented"), + upsertByChannelAndUser: () => Effect.die("not implemented"), + }), + ) const makeChannelAccessSyncLayer = (syncedChannels: ChannelId[]) => - Layer.succeed(ChannelAccessSyncService, { - syncChannel: (channelId: ChannelId) => - Effect.sync(() => { - syncedChannels.push(channelId) - }), - } as unknown as ChannelAccessSyncService) + Layer.succeed( + ChannelAccessSyncService, + serviceShape({ + syncChannel: (channelId: ChannelId) => + Effect.sync(() => { + syncedChannels.push(channelId) + }), + }), + ) const makeServiceLayer = (params: { conversation: MutableConversation @@ -158,7 +190,7 @@ const makeServiceLayer = (params: { participants: MutableParticipant[] syncedChannels: ChannelId[] }) => - ConnectConversationService.DefaultWithoutDependencies.pipe( + Layer.effect(ConnectConversationService, ConnectConversationService.make).pipe( Layer.provide(makeChannelRepoLayer()), Layer.provide(makeConversationRepoLayer(params.conversation)), Layer.provide(makeMountRepoLayer(params.mounts)), @@ -169,11 +201,8 @@ const makeServiceLayer = (params: { Layer.provide(makeOrgResolverLayer()), ) -const useService = (fn: (service: ConnectConversationService) => Effect.Effect) => - Effect.gen(function* () { - const service = yield* ConnectConversationService - return yield* fn(service) - }) +const useService = (fn: (service: ConnectConversationServiceShape) => Effect.Effect) => + ConnectConversationService.use(fn) describe("ConnectConversationService", () => { it("returns the existing mount when conversation creation races on unique constraints", async () => { @@ -190,7 +219,7 @@ describe("ConnectConversationService", () => { deletedAt: null, } const existingMount: MutableMount = { - id: "00000000-0000-0000-0000-000000000411" as ConnectConversationChannelId, + id: "00000000-0000-4000-8000-000000000411" as ConnectConversationChannelId, conversationId: CONVERSATION_ID, organizationId: HOST_ORG_ID, channelId: HOST_CHANNEL_ID, @@ -204,69 +233,84 @@ describe("ConnectConversationService", () => { const backfills: Array<{ kind: "message" | "reaction"; conversationId: ConnectConversationId }> = [] let findByChannelCalls = 0 - const layer = ConnectConversationService.DefaultWithoutDependencies.pipe( + const layer = Layer.effect(ConnectConversationService, ConnectConversationService.make).pipe( Layer.provide( - Layer.succeed(ChannelRepo, { - findById: () => - Effect.succeed( - Option.some({ - id: HOST_CHANNEL_ID, - organizationId: HOST_ORG_ID, - }), - ), - } as unknown as ChannelRepo), + Layer.succeed( + ChannelRepo, + serviceShape({ + findById: () => + Effect.succeed( + Option.some({ + id: HOST_CHANNEL_ID, + organizationId: HOST_ORG_ID, + }), + ), + }), + ), ), Layer.provide( - Layer.succeed(ConnectConversationRepo, { - insert: () => - Effect.fail( - new Database.DatabaseError({ - type: "unique_violation", - cause: { constraint_name: "connect_conversations_host_channel_unique" }, - }), - ), - findByHostChannel: () => Effect.succeed(Option.some(existingConversation)), - } as unknown as ConnectConversationRepo), + Layer.succeed( + ConnectConversationRepo, + serviceShape({ + insert: () => + Effect.fail( + new Database.DatabaseError({ + type: "unique_violation", + cause: { constraint_name: "connect_conversations_host_channel_unique" }, + }), + ), + findByHostChannel: () => Effect.succeed(Option.some(existingConversation)), + }), + ), ), Layer.provide( - Layer.succeed(ConnectConversationChannelRepo, { - findByChannelId: () => - Effect.sync(() => { - findByChannelCalls += 1 - return findByChannelCalls === 1 ? Option.none() : Option.some(existingMount) - }), - insert: () => - Effect.fail( - new Database.DatabaseError({ - type: "unique_violation", - cause: { constraint_name: "connect_conv_channels_channel_unique" }, + Layer.succeed( + ConnectConversationChannelRepo, + serviceShape({ + findByChannelId: () => + Effect.sync(() => { + findByChannelCalls += 1 + return findByChannelCalls === 1 ? Option.none() : Option.some(existingMount) }), - ), - findByConversationId: () => Effect.succeed([existingMount]), - } as unknown as ConnectConversationChannelRepo), + insert: () => + Effect.fail( + new Database.DatabaseError({ + type: "unique_violation", + cause: { constraint_name: "connect_conv_channels_channel_unique" }, + }), + ), + findByConversationId: () => Effect.succeed([existingMount]), + }), + ), ), Layer.provide(makeParticipantRepoLayer([])), Layer.provide( - Layer.succeed(MessageRepo, { - backfillConversationIdForChannel: ( - _channelId: ChannelId, - conversationId: ConnectConversationId, - ) => - Effect.sync(() => { - backfills.push({ kind: "message", conversationId }) - }), - } as unknown as MessageRepo), + Layer.succeed( + MessageRepo, + serviceShape({ + backfillConversationIdForChannel: ( + _channelId: ChannelId, + conversationId: ConnectConversationId, + ) => + Effect.sync(() => { + backfills.push({ kind: "message", conversationId }) + }), + }), + ), ), Layer.provide( - Layer.succeed(MessageReactionRepo, { - backfillConversationIdForChannel: ( - _channelId: ChannelId, - conversationId: ConnectConversationId, - ) => - Effect.sync(() => { - backfills.push({ kind: "reaction", conversationId }) - }), - } as unknown as MessageReactionRepo), + Layer.succeed( + MessageReactionRepo, + serviceShape({ + backfillConversationIdForChannel: ( + _channelId: ChannelId, + conversationId: ConnectConversationId, + ) => + Effect.sync(() => { + backfills.push({ kind: "reaction", conversationId }) + }), + }), + ), ), Layer.provide(makeChannelAccessSyncLayer([])), Layer.provide(makeOrgResolverLayer()), @@ -290,82 +334,97 @@ describe("ConnectConversationService", () => { const now = new Date("2026-03-13T12:00:00.000Z") const transactionChecks: boolean[] = [] - const layer = ConnectConversationService.DefaultWithoutDependencies.pipe( + const layer = Layer.effect(ConnectConversationService, ConnectConversationService.make).pipe( Layer.provide( - Layer.succeed(ChannelRepo, { - findById: () => - Effect.succeed( - Option.some({ - id: HOST_CHANNEL_ID, - organizationId: HOST_ORG_ID, - }), - ), - } as unknown as ChannelRepo), + Layer.succeed( + ChannelRepo, + serviceShape({ + findById: () => + Effect.succeed( + Option.some({ + id: HOST_CHANNEL_ID, + organizationId: HOST_ORG_ID, + }), + ), + }), + ), ), Layer.provide( - Layer.succeed(ConnectConversationRepo, { - insert: () => - Effect.succeed([ - { - id: CONVERSATION_ID, - hostOrganizationId: HOST_ORG_ID, - hostChannelId: HOST_CHANNEL_ID, - status: "active", - settings: null, - createdBy: HOST_USER_ID, - createdAt: now, - updatedAt: now, - deletedAt: null, - }, - ]), - } as unknown as ConnectConversationRepo), + Layer.succeed( + ConnectConversationRepo, + serviceShape({ + insert: () => + Effect.succeed([ + { + id: CONVERSATION_ID, + hostOrganizationId: HOST_ORG_ID, + hostChannelId: HOST_CHANNEL_ID, + status: "active", + settings: null, + createdBy: HOST_USER_ID, + createdAt: now, + updatedAt: now, + deletedAt: null, + }, + ]), + }), + ), ), Layer.provide( - Layer.succeed(ConnectConversationChannelRepo, { - findByChannelId: () => Effect.succeed(Option.none()), - insert: () => - Effect.succeed([ - { - id: "00000000-0000-0000-0000-000000000412" as ConnectConversationChannelId, - conversationId: CONVERSATION_ID, - organizationId: HOST_ORG_ID, - channelId: HOST_CHANNEL_ID, - role: "host", - allowGuestMemberAdds: false, - isActive: true, - createdAt: now, - updatedAt: now, - deletedAt: null, - }, - ]), - } as unknown as ConnectConversationChannelRepo), + Layer.succeed( + ConnectConversationChannelRepo, + serviceShape({ + findByChannelId: () => Effect.succeed(Option.none()), + insert: () => + Effect.succeed([ + { + id: "00000000-0000-4000-8000-000000000412" as ConnectConversationChannelId, + conversationId: CONVERSATION_ID, + organizationId: HOST_ORG_ID, + channelId: HOST_CHANNEL_ID, + role: "host", + allowGuestMemberAdds: false, + isActive: true, + createdAt: now, + updatedAt: now, + deletedAt: null, + }, + ]), + }), + ), ), Layer.provide(makeParticipantRepoLayer([])), Layer.provide( - Layer.succeed(MessageRepo, { - backfillConversationIdForChannel: () => - Effect.serviceOption(Database.TransactionContext).pipe( - Effect.tap((maybeTx) => - Effect.sync(() => { - transactionChecks.push(Option.isSome(maybeTx)) - }), + Layer.succeed( + MessageRepo, + serviceShape({ + backfillConversationIdForChannel: () => + Effect.serviceOption(Database.TransactionContext).pipe( + Effect.tap((maybeTx) => + Effect.sync(() => { + transactionChecks.push(Option.isSome(maybeTx)) + }), + ), + Effect.as(undefined), ), - Effect.as(undefined), - ), - } as unknown as MessageRepo), + }), + ), ), Layer.provide( - Layer.succeed(MessageReactionRepo, { - backfillConversationIdForChannel: () => - Effect.serviceOption(Database.TransactionContext).pipe( - Effect.tap((maybeTx) => - Effect.sync(() => { - transactionChecks.push(Option.isSome(maybeTx)) - }), + Layer.succeed( + MessageReactionRepo, + serviceShape({ + backfillConversationIdForChannel: () => + Effect.serviceOption(Database.TransactionContext).pipe( + Effect.tap((maybeTx) => + Effect.sync(() => { + transactionChecks.push(Option.isSome(maybeTx)) + }), + ), + Effect.as(undefined), ), - Effect.as(undefined), - ), - } as unknown as MessageReactionRepo), + }), + ), ), Layer.provide(makeChannelAccessSyncLayer([])), Layer.provide(makeOrgResolverLayer()), @@ -395,7 +454,7 @@ describe("ConnectConversationService", () => { let mountFetchCount = 0 const mounts: MutableMount[] = [ { - id: "00000000-0000-0000-0000-000000000921" as ConnectConversationChannelId, + id: "00000000-0000-4000-8000-000000000921" as ConnectConversationChannelId, conversationId: CONVERSATION_ID, organizationId: HOST_ORG_ID, channelId: HOST_CHANNEL_ID, @@ -407,7 +466,7 @@ describe("ConnectConversationService", () => { deletedAt: null, }, { - id: "00000000-0000-0000-0000-000000000922" as ConnectConversationChannelId, + id: "00000000-0000-4000-8000-000000000922" as ConnectConversationChannelId, conversationId: CONVERSATION_ID, organizationId: GUEST_ORG_ID, channelId: GUEST_CHANNEL_ID, @@ -420,26 +479,34 @@ describe("ConnectConversationService", () => { }, ] - const layer = ConnectConversationService.DefaultWithoutDependencies.pipe( + const layer = Layer.effect(ConnectConversationService, ConnectConversationService.make).pipe( Layer.provide(makeChannelRepoLayer()), - Layer.provide(Layer.succeed(ConnectConversationRepo, {} as unknown as ConnectConversationRepo)), Layer.provide( - Layer.succeed(ConnectConversationChannelRepo, { - findByConversationId: () => - Effect.sync(() => { - mountFetchCount += 1 - return mounts - }), - } as unknown as ConnectConversationChannelRepo), + Layer.succeed(ConnectConversationRepo, serviceShape({})), ), Layer.provide( - Layer.succeed(ConnectParticipantRepo, { - upsertByChannelAndUser: (row: any) => - Effect.sync(() => { - upserts.push(row) - return row - }), - } as unknown as ConnectParticipantRepo), + Layer.succeed( + ConnectConversationChannelRepo, + serviceShape({ + findByConversationId: () => + Effect.sync(() => { + mountFetchCount += 1 + return mounts + }), + }), + ), + ), + Layer.provide( + Layer.succeed( + ConnectParticipantRepo, + serviceShape({ + upsertByChannelAndUser: (row: ConnectParticipantUpsertInput) => + Effect.sync(() => { + upserts.push(row) + return row + }), + }), + ), ), Layer.provide(makeMessageRepoLayer()), Layer.provide(makeMessageReactionRepoLayer()), @@ -508,7 +575,7 @@ describe("ConnectConversationService", () => { } const mounts: MutableMount[] = [ { - id: "00000000-0000-0000-0000-000000000501" as ConnectConversationChannelId, + id: "00000000-0000-4000-8000-000000000501" as ConnectConversationChannelId, conversationId: CONVERSATION_ID, organizationId: HOST_ORG_ID, channelId: HOST_CHANNEL_ID, @@ -520,7 +587,7 @@ describe("ConnectConversationService", () => { deletedAt: null, }, { - id: "00000000-0000-0000-0000-000000000502" as ConnectConversationChannelId, + id: "00000000-0000-4000-8000-000000000502" as ConnectConversationChannelId, conversationId: CONVERSATION_ID, organizationId: GUEST_ORG_ID, channelId: GUEST_CHANNEL_ID, @@ -534,7 +601,7 @@ describe("ConnectConversationService", () => { ] const participants: MutableParticipant[] = [ { - id: "00000000-0000-0000-0000-000000000601" as ConnectParticipantId, + id: "00000000-0000-4000-8000-000000000601" as ConnectParticipantId, conversationId: CONVERSATION_ID, channelId: HOST_CHANNEL_ID, userId: GUEST_USER_ID, @@ -546,7 +613,7 @@ describe("ConnectConversationService", () => { deletedAt: null, }, { - id: "00000000-0000-0000-0000-000000000602" as ConnectParticipantId, + id: "00000000-0000-4000-8000-000000000602" as ConnectParticipantId, conversationId: CONVERSATION_ID, channelId: GUEST_CHANNEL_ID, userId: GUEST_USER_ID, @@ -588,7 +655,7 @@ describe("ConnectConversationService", () => { } const mounts: MutableMount[] = [ { - id: "00000000-0000-0000-0000-000000000701" as ConnectConversationChannelId, + id: "00000000-0000-4000-8000-000000000701" as ConnectConversationChannelId, conversationId: CONVERSATION_ID, organizationId: HOST_ORG_ID, channelId: HOST_CHANNEL_ID, @@ -600,7 +667,7 @@ describe("ConnectConversationService", () => { deletedAt: null, }, { - id: "00000000-0000-0000-0000-000000000702" as ConnectConversationChannelId, + id: "00000000-0000-4000-8000-000000000702" as ConnectConversationChannelId, conversationId: CONVERSATION_ID, organizationId: GUEST_ORG_ID, channelId: GUEST_CHANNEL_ID, @@ -612,7 +679,7 @@ describe("ConnectConversationService", () => { deletedAt: null, }, { - id: "00000000-0000-0000-0000-000000000703" as ConnectConversationChannelId, + id: "00000000-0000-4000-8000-000000000703" as ConnectConversationChannelId, conversationId: CONVERSATION_ID, organizationId: OTHER_GUEST_ORG_ID, channelId: OTHER_GUEST_CHANNEL_ID, @@ -626,7 +693,7 @@ describe("ConnectConversationService", () => { ] const participants: MutableParticipant[] = [ { - id: "00000000-0000-0000-0000-000000000801" as ConnectParticipantId, + id: "00000000-0000-4000-8000-000000000801" as ConnectParticipantId, conversationId: CONVERSATION_ID, channelId: HOST_CHANNEL_ID, userId: GUEST_USER_ID, @@ -638,7 +705,7 @@ describe("ConnectConversationService", () => { deletedAt: null, }, { - id: "00000000-0000-0000-0000-000000000802" as ConnectParticipantId, + id: "00000000-0000-4000-8000-000000000802" as ConnectParticipantId, conversationId: CONVERSATION_ID, channelId: OTHER_GUEST_CHANNEL_ID, userId: HOST_USER_ID, @@ -684,7 +751,7 @@ describe("ConnectConversationService", () => { } const mounts: MutableMount[] = [ { - id: "00000000-0000-0000-0000-000000000901" as ConnectConversationChannelId, + id: "00000000-0000-4000-8000-000000000901" as ConnectConversationChannelId, conversationId: CONVERSATION_ID, organizationId: HOST_ORG_ID, channelId: HOST_CHANNEL_ID, @@ -696,7 +763,7 @@ describe("ConnectConversationService", () => { deletedAt: null, }, { - id: "00000000-0000-0000-0000-000000000902" as ConnectConversationChannelId, + id: "00000000-0000-4000-8000-000000000902" as ConnectConversationChannelId, conversationId: CONVERSATION_ID, organizationId: GUEST_ORG_ID, channelId: GUEST_CHANNEL_ID, @@ -710,7 +777,7 @@ describe("ConnectConversationService", () => { ] const participants: MutableParticipant[] = [ { - id: "00000000-0000-0000-0000-000000001001" as ConnectParticipantId, + id: "00000000-0000-4000-8000-000000001001" as ConnectParticipantId, conversationId: CONVERSATION_ID, channelId: HOST_CHANNEL_ID, userId: HOST_USER_ID, @@ -722,7 +789,7 @@ describe("ConnectConversationService", () => { deletedAt: null, }, { - id: "00000000-0000-0000-0000-000000001002" as ConnectParticipantId, + id: "00000000-0000-4000-8000-000000001002" as ConnectParticipantId, conversationId: CONVERSATION_ID, channelId: GUEST_CHANNEL_ID, userId: GUEST_USER_ID, diff --git a/apps/backend/src/services/connect-conversation-service.ts b/apps/backend/src/services/connect-conversation-service.ts index cf8f8f44f..76c3b1c2f 100644 --- a/apps/backend/src/services/connect-conversation-service.ts +++ b/apps/backend/src/services/connect-conversation-service.ts @@ -8,25 +8,15 @@ import { } from "@hazel/backend-core" import { InternalServerError } from "@hazel/domain" import type { ChannelId, ConnectConversationId, OrganizationId, UserId } from "@hazel/schema" -import { Effect, Option } from "effect" +import { ServiceMap, Effect, Layer, Option } from "effect" import { ChannelAccessSyncService } from "./channel-access-sync" +import { DatabaseLive } from "./database" import { OrgResolver } from "./org-resolver" -export class ConnectConversationService extends Effect.Service()( +export class ConnectConversationService extends ServiceMap.Service()( "ConnectConversationService", { - accessors: true, - dependencies: [ - ChannelRepo.Default, - ConnectParticipantRepo.Default, - ConnectConversationRepo.Default, - ConnectConversationChannelRepo.Default, - MessageRepo.Default, - MessageReactionRepo.Default, - ChannelAccessSyncService.Default, - OrgResolver.Default, - ], - effect: Effect.gen(function* () { + make: Effect.gen(function* () { const channelRepo = yield* ChannelRepo const connectParticipantRepo = yield* ConnectParticipantRepo const connectConversationRepo = yield* ConnectConversationRepo @@ -72,7 +62,7 @@ export class ConnectConversationService extends Effect.Service - Database.layer({ - url: envVars.DATABASE_URL, - ssl: !envVars.IS_DEV, - }), - ), - ), -).pipe(Layer.provide(EnvVars.Default)) +export const DatabaseLive = Layer.unwrap( + Effect.gen(function* () { + const envVars = yield* EnvVars + return Database.layer({ + url: envVars.DATABASE_URL, + ssl: !envVars.IS_DEV, + }) + }), +).pipe(Layer.provide(EnvVars.layer)) diff --git a/apps/backend/src/services/integration-encryption.ts b/apps/backend/src/services/integration-encryption.ts index dbfde45a0..453ae8422 100644 --- a/apps/backend/src/services/integration-encryption.ts +++ b/apps/backend/src/services/integration-encryption.ts @@ -1,4 +1,4 @@ -import { Config, Effect, Option, Redacted, Schema } from "effect" +import { ServiceMap, Config, Effect, Layer, Option, Redacted, Schema } from "effect" export interface EncryptedToken { ciphertext: string // Base64 encoded @@ -6,9 +6,9 @@ export interface EncryptedToken { keyVersion: number } -const EncryptionOperation = Schema.Literal("encrypt", "decrypt", "importKey") +const EncryptionOperation = Schema.Literals(["encrypt", "decrypt", "importKey"]) -export class IntegrationEncryptionError extends Schema.TaggedError()( +export class IntegrationEncryptionError extends Schema.TaggedErrorClass()( "IntegrationEncryptionError", { cause: Schema.Unknown, @@ -16,134 +16,140 @@ export class IntegrationEncryptionError extends Schema.TaggedError()( +export class KeyVersionNotFoundError extends Schema.TaggedErrorClass()( "KeyVersionNotFoundError", { keyVersion: Schema.Number, }, ) {} -export class IntegrationEncryption extends Effect.Service()("IntegrationEncryption", { - accessors: true, - effect: Effect.gen(function* () { - // Load encryption keys from config (support key rotation) - const currentKey = yield* Config.redacted("INTEGRATION_ENCRYPTION_KEY") - const currentKeyVersion = yield* Config.number("INTEGRATION_ENCRYPTION_KEY_VERSION").pipe( - Config.withDefault(1), - ) - - // Optional: Previous key for decryption during rotation - const previousKey = yield* Config.redacted("INTEGRATION_ENCRYPTION_KEY_PREV").pipe( - Config.option, - Effect.map(Option.getOrUndefined), - ) - const previousKeyVersion = yield* Config.number("INTEGRATION_ENCRYPTION_KEY_VERSION_PREV").pipe( - Config.withDefault(0), - ) - - // Cache for imported crypto keys - const keyCache = new Map() - - const importKey = (keyData: Redacted.Redacted, version: number) => - Effect.gen(function* () { - // Check cache first - const cachedKey = keyCache.get(version) - if (cachedKey) { - return cachedKey - } +export class IntegrationEncryption extends ServiceMap.Service()( + "IntegrationEncryption", + { + make: Effect.gen(function* () { + // Load encryption keys from config (support key rotation) + const currentKey = yield* Config.redacted("INTEGRATION_ENCRYPTION_KEY") + const currentKeyVersion = yield* Config.number("INTEGRATION_ENCRYPTION_KEY_VERSION").pipe( + Config.withDefault(1), + ) + + // Optional: Previous key for decryption during rotation + const previousKeyOption = yield* Config.redacted("INTEGRATION_ENCRYPTION_KEY_PREV").pipe( + Config.option, + ) + const previousKey = Option.getOrUndefined(previousKeyOption) + const previousKeyVersion = yield* Config.number("INTEGRATION_ENCRYPTION_KEY_VERSION_PREV").pipe( + Config.withDefault(0), + ) + + // Cache for imported crypto keys + const keyCache = new Map() + + const importKey = (keyData: Redacted.Redacted, version: number) => + Effect.gen(function* () { + // Check cache first + const cachedKey = keyCache.get(version) + if (cachedKey) { + return cachedKey + } + + const rawKey = Buffer.from(Redacted.value(keyData), "base64") + + // Validate key length (256 bits = 32 bytes) + if (rawKey.length !== 32) { + return yield* Effect.fail( + new IntegrationEncryptionError({ + cause: `Invalid key length: expected 32 bytes, got ${rawKey.length}`, + operation: "importKey", + }), + ) + } + + const cryptoKey = yield* Effect.tryPromise({ + try: () => + crypto.subtle.importKey("raw", rawKey, { name: "AES-GCM", length: 256 }, false, [ + "encrypt", + "decrypt", + ]), + catch: (cause) => new IntegrationEncryptionError({ cause, operation: "importKey" }), + }) + + // Cache the imported key + keyCache.set(version, cryptoKey) + return cryptoKey + }) - const rawKey = Buffer.from(Redacted.value(keyData), "base64") + const encrypt = Effect.fn("IntegrationEncryption.encrypt")(function* (plaintext: string) { + const key = yield* importKey(currentKey, currentKeyVersion) - // Validate key length (256 bits = 32 bytes) - if (rawKey.length !== 32) { - return yield* Effect.fail( - new IntegrationEncryptionError({ - cause: `Invalid key length: expected 32 bytes, got ${rawKey.length}`, - operation: "importKey", - }), - ) - } + // Generate random 12-byte IV for AES-GCM + const iv = crypto.getRandomValues(new Uint8Array(12)) + const encoded = new TextEncoder().encode(plaintext) - const cryptoKey = yield* Effect.tryPromise({ + const ciphertext = yield* Effect.tryPromise({ try: () => - crypto.subtle.importKey("raw", rawKey, { name: "AES-GCM", length: 256 }, false, [ - "encrypt", - "decrypt", - ]), - catch: (cause) => new IntegrationEncryptionError({ cause, operation: "importKey" }), + crypto.subtle.encrypt( + { + name: "AES-GCM", + iv, + tagLength: 128, + }, + key, + encoded, + ), + catch: (cause) => new IntegrationEncryptionError({ cause, operation: "encrypt" }), }) - // Cache the imported key - keyCache.set(version, cryptoKey) - return cryptoKey + return { + ciphertext: Buffer.from(ciphertext).toString("base64"), + iv: Buffer.from(iv).toString("base64"), + keyVersion: currentKeyVersion, + } satisfies EncryptedToken }) - const encrypt = Effect.fn("IntegrationEncryption.encrypt")(function* (plaintext: string) { - const key = yield* importKey(currentKey, currentKeyVersion) - - // Generate random 12-byte IV for AES-GCM - const iv = crypto.getRandomValues(new Uint8Array(12)) - const encoded = new TextEncoder().encode(plaintext) - - const ciphertext = yield* Effect.tryPromise({ - try: () => - crypto.subtle.encrypt( - { - name: "AES-GCM", - iv, - tagLength: 128, - }, - key, - encoded, - ), - catch: (cause) => new IntegrationEncryptionError({ cause, operation: "encrypt" }), - }) + const decrypt = Effect.fn("IntegrationEncryption.decrypt")(function* (encrypted: EncryptedToken) { + // Select key based on version + let keyData: Redacted.Redacted | undefined + if (encrypted.keyVersion === currentKeyVersion) { + keyData = currentKey + } else if (previousKey && encrypted.keyVersion === previousKeyVersion) { + keyData = previousKey + } - return { - ciphertext: Buffer.from(ciphertext).toString("base64"), - iv: Buffer.from(iv).toString("base64"), - keyVersion: currentKeyVersion, - } satisfies EncryptedToken - }) - - const decrypt = Effect.fn("IntegrationEncryption.decrypt")(function* (encrypted: EncryptedToken) { - // Select key based on version - let keyData: Redacted.Redacted | undefined - if (encrypted.keyVersion === currentKeyVersion) { - keyData = currentKey - } else if (previousKey && encrypted.keyVersion === previousKeyVersion) { - keyData = previousKey - } + if (!keyData) { + return yield* Effect.fail( + new KeyVersionNotFoundError({ keyVersion: encrypted.keyVersion }), + ) + } - if (!keyData) { - return yield* Effect.fail(new KeyVersionNotFoundError({ keyVersion: encrypted.keyVersion })) - } + const key = yield* importKey(keyData, encrypted.keyVersion) + const iv = Buffer.from(encrypted.iv, "base64") + const ciphertext = Buffer.from(encrypted.ciphertext, "base64") - const key = yield* importKey(keyData, encrypted.keyVersion) - const iv = Buffer.from(encrypted.iv, "base64") - const ciphertext = Buffer.from(encrypted.ciphertext, "base64") - - const plaintext = yield* Effect.tryPromise({ - try: () => - crypto.subtle.decrypt( - { - name: "AES-GCM", - iv, - tagLength: 128, - }, - key, - ciphertext, - ), - catch: (cause) => new IntegrationEncryptionError({ cause, operation: "decrypt" }), - }) + const plaintext = yield* Effect.tryPromise({ + try: () => + crypto.subtle.decrypt( + { + name: "AES-GCM", + iv, + tagLength: 128, + }, + key, + ciphertext, + ), + catch: (cause) => new IntegrationEncryptionError({ cause, operation: "decrypt" }), + }) - return new TextDecoder().decode(plaintext) - }) + return new TextDecoder().decode(plaintext) + }) - return { - encrypt, - decrypt, - currentKeyVersion, - } - }), -}) {} + return { + encrypt, + decrypt, + currentKeyVersion, + } + }), + }, +) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/apps/backend/src/services/integration-token-service.ts b/apps/backend/src/services/integration-token-service.ts index cae9aedbf..abc0f24c5 100644 --- a/apps/backend/src/services/integration-token-service.ts +++ b/apps/backend/src/services/integration-token-service.ts @@ -3,22 +3,21 @@ import type { IntegrationConnectionId, IntegrationTokenId } from "@hazel/schema" import { IntegrationConnection } from "@hazel/domain/models" import { GitHub } from "@hazel/integrations" import { IntegrationConnectionId as IntegrationConnectionIdSchema } from "@hazel/schema" -import { Effect, Option, PartitionedSemaphore, Redacted, Schema } from "effect" -import { DatabaseLive } from "./database" +import { ServiceMap, Effect, Layer, Option, PartitionedSemaphore, Redacted, Schema } from "effect" import { type EncryptedToken, IntegrationEncryption } from "./integration-encryption" import { OAuthHttpClient } from "./oauth/oauth-http-client" import { type OAuthIntegrationProvider, loadProviderConfig } from "./oauth/provider-config" -export class TokenNotFoundError extends Schema.TaggedError()("TokenNotFoundError", { +export class TokenNotFoundError extends Schema.TaggedErrorClass()("TokenNotFoundError", { connectionId: IntegrationConnectionIdSchema, }) {} -export class TokenRefreshError extends Schema.TaggedError()("TokenRefreshError", { +export class TokenRefreshError extends Schema.TaggedErrorClass()("TokenRefreshError", { provider: IntegrationConnection.IntegrationProvider, cause: Schema.Unknown, }) {} -export class ConnectionNotFoundError extends Schema.TaggedError()( +export class ConnectionNotFoundError extends Schema.TaggedErrorClass()( "ConnectionNotFoundError", { connectionId: IntegrationConnectionIdSchema, @@ -60,15 +59,14 @@ const refreshOAuthToken = ( scope: result.scope, } }).pipe( - Effect.provide(OAuthHttpClient.Default), + Effect.provide(OAuthHttpClient.layer), Effect.mapError((cause) => new TokenRefreshError({ provider, cause })), ) -export class IntegrationTokenService extends Effect.Service()( +export class IntegrationTokenService extends ServiceMap.Service()( "IntegrationTokenService", { - accessors: true, - effect: Effect.gen(function* () { + make: Effect.gen(function* () { const encryption = yield* IntegrationEncryption const tokenRepo = yield* IntegrationTokenRepo const connectionRepo = yield* IntegrationConnectionRepo @@ -226,15 +224,17 @@ export class IntegrationTokenService extends Effect.Service - new TokenRefreshError({ - provider: connection.provider, - cause: `Failed to load provider config: ${cause}`, - }), - ), ) + .asEffect() + .pipe( + Effect.mapError( + (cause) => + new TokenRefreshError({ + provider: connection.provider, + cause: `Failed to load provider config: ${cause}`, + }), + ), + ) yield* Effect.logDebug("Refreshing OAuth token", { provider: connection.provider }) @@ -438,12 +438,12 @@ export class IntegrationTokenService extends Effect.Service()("IntegrationBotService", { - accessors: true, - effect: Effect.gen(function* () { - const userRepo = yield* UserRepo - const orgMemberRepo = yield* OrganizationMemberRepo - const botRepo = yield* BotRepo - const botInstallationRepo = yield* BotInstallationRepo - - /** - * Get or create a global bot user for an OAuth integration provider. - * Bot users are machine users with predictable external IDs. - * Also ensures the bot is a member of the given organization so it appears in Electric sync. - */ - const getOrCreateBotUser = ( - provider: IntegrationConnection.IntegrationProvider, - organizationId: OrganizationId, - ) => - Effect.gen(function* () { - const externalId = `integration-bot-${provider}` - - // Try to find existing bot user - const existing = yield* userRepo.findByExternalId(externalId) - const botUser = Option.isSome(existing) - ? existing.value - : yield* Effect.gen(function* () { - // Create new machine user for this integration - const botConfig = Integrations.getBotConfig(provider) - const newUser = yield* userRepo.insert({ - externalId, - email: `${provider}-bot@integrations.internal`, - firstName: botConfig.name, - lastName: "", - avatarUrl: botConfig.avatarUrl, - userType: "machine", - settings: null, - isOnboarded: true, - timezone: null, - deletedAt: null, +export class IntegrationBotService extends ServiceMap.Service()( + "IntegrationBotService", + { + make: Effect.gen(function* () { + const userRepo = yield* UserRepo + const orgMemberRepo = yield* OrganizationMemberRepo + const botRepo = yield* BotRepo + const botInstallationRepo = yield* BotInstallationRepo + + /** + * Get or create a global bot user for an OAuth integration provider. + * Bot users are machine users with predictable external IDs. + * Also ensures the bot is a member of the given organization so it appears in Electric sync. + */ + const getOrCreateBotUser = ( + provider: IntegrationConnection.IntegrationProvider, + organizationId: OrganizationId, + ) => + Effect.gen(function* () { + const externalId = `integration-bot-${provider}` + + // Try to find existing bot user + const existing = yield* userRepo.findByExternalId(externalId) + const botUser = Option.isSome(existing) + ? existing.value + : yield* Effect.gen(function* () { + // Create new machine user for this integration + const botConfig = Integrations.getBotConfig(provider) + const newUser = yield* userRepo.insert({ + externalId, + email: `${provider}-bot@integrations.internal`, + firstName: botConfig.name, + lastName: "", + avatarUrl: botConfig.avatarUrl, + userType: "machine", + settings: null, + isOnboarded: true, + timezone: null, + deletedAt: null, + }) + + return newUser[0] }) - return newUser[0] - }) + // Ensure bot is a member of this organization (so it shows in Electric sync) + yield* orgMemberRepo.upsertByOrgAndUser({ + organizationId, + userId: botUser.id, + role: "member", + nickname: null, + joinedAt: new Date(), + invitedBy: null, + deletedAt: null, + }) - // Ensure bot is a member of this organization (so it shows in Electric sync) - yield* orgMemberRepo.upsertByOrgAndUser({ - organizationId, - userId: botUser.id, - role: "member", - nickname: null, - joinedAt: new Date(), - invitedBy: null, - deletedAt: null, + return botUser }) - return botUser - }) - - /** - * Get or create a global bot user for a webhook-based integration provider. - * Similar to OAuth providers but uses WEBHOOK_BOT_CONFIGS. - */ - const getOrCreateWebhookBotUser = ( - provider: Integrations.WebhookProvider, - organizationId: OrganizationId, - ) => - Effect.gen(function* () { - const externalId = `integration-bot-${provider}` - - // Try to find existing bot user - const existing = yield* userRepo.findByExternalId(externalId) - const botUser = Option.isSome(existing) - ? existing.value - : yield* Effect.gen(function* () { - // Create new machine user for this webhook integration - const botConfig = Integrations.getWebhookBotConfig(provider) - const newUser = yield* userRepo.insert({ - externalId, - email: `${provider}-bot@webhooks.internal`, - firstName: botConfig.name, - lastName: "", - avatarUrl: botConfig.avatarUrl, - userType: "machine", - settings: null, - isOnboarded: true, - timezone: null, - deletedAt: null, + /** + * Get or create a global bot user for a webhook-based integration provider. + * Similar to OAuth providers but uses WEBHOOK_BOT_CONFIGS. + */ + const getOrCreateWebhookBotUser = ( + provider: Integrations.WebhookProvider, + organizationId: OrganizationId, + ) => + Effect.gen(function* () { + const externalId = `integration-bot-${provider}` + + // Try to find existing bot user + const existing = yield* userRepo.findByExternalId(externalId) + const botUser = Option.isSome(existing) + ? existing.value + : yield* Effect.gen(function* () { + // Create new machine user for this webhook integration + const botConfig = Integrations.getWebhookBotConfig(provider) + const newUser = yield* userRepo.insert({ + externalId, + email: `${provider}-bot@webhooks.internal`, + firstName: botConfig.name, + lastName: "", + avatarUrl: botConfig.avatarUrl, + userType: "machine", + settings: null, + isOnboarded: true, + timezone: null, + deletedAt: null, + }) + + return newUser[0] }) - return newUser[0] - }) + // Ensure bot is a member of this organization (so it shows in Electric sync) + yield* orgMemberRepo.upsertByOrgAndUser({ + organizationId, + userId: botUser.id, + role: "member", + nickname: null, + joinedAt: new Date(), + invitedBy: null, + deletedAt: null, + }) - // Ensure bot is a member of this organization (so it shows in Electric sync) - yield* orgMemberRepo.upsertByOrgAndUser({ - organizationId, - userId: botUser.id, - role: "member", - nickname: null, - joinedAt: new Date(), - invitedBy: null, - deletedAt: null, + return botUser }) - return botUser - }) - - /** - * Add an existing seeded bot to an organization. - * Unlike getOrCreateBotUser, this does NOT create the bot user - it must already exist from seeding. - * Creates org membership and bot installation. - * Returns Option.some(botUser) if found and added, Option.none() if bot not found. - */ - const addBotToOrg = ( - provider: IntegrationConnection.IntegrationProvider, - organizationId: OrganizationId, - ) => - Effect.gen(function* () { - const externalId = `internal-bot-${provider}` - - // Find existing bot user (must already exist from seed script) - const existingUser = yield* userRepo.findByExternalId(externalId) - if (Option.isNone(existingUser)) { - yield* Effect.logWarning("Bot user not found - has seed script been run?", { - provider, - externalId, + /** + * Add an existing seeded bot to an organization. + * Unlike getOrCreateBotUser, this does NOT create the bot user - it must already exist from seeding. + * Creates org membership and bot installation. + * Returns Option.some(botUser) if found and added, Option.none() if bot not found. + */ + const addBotToOrg = ( + provider: IntegrationConnection.IntegrationProvider, + organizationId: OrganizationId, + ) => + Effect.gen(function* () { + const externalId = `internal-bot-${provider}` + + // Find existing bot user (must already exist from seed script) + const existingUser = yield* userRepo.findByExternalId(externalId) + if (Option.isNone(existingUser)) { + yield* Effect.logWarning("Bot user not found - has seed script been run?", { + provider, + externalId, + }) + return Option.none() + } + + const botUser = existingUser.value + + // Find the bot record + const existingBot = yield* botRepo.findByUserId(botUser.id) + if (Option.isNone(existingBot)) { + yield* Effect.logWarning( + "Bot record not found for user - has seed script been run?", + { + provider, + userId: botUser.id, + }, + ) + return Option.none() + } + + const bot = existingBot.value + + // Add bot to org membership (so it shows in Electric sync) + yield* orgMemberRepo.upsertByOrgAndUser({ + organizationId, + userId: botUser.id, + role: "member", + nickname: null, + joinedAt: new Date(), + invitedBy: null, + deletedAt: null, }) - return Option.none() - } - const botUser = existingUser.value + // Create bot installation (idempotent - check if exists first) + const existingInstallation = yield* botInstallationRepo.findByBotAndOrg( + bot.id, + organizationId, + ) - // Find the bot record - const existingBot = yield* botRepo.findByUserId(botUser.id) - if (Option.isNone(existingBot)) { - yield* Effect.logWarning("Bot record not found for user - has seed script been run?", { - provider, - userId: botUser.id, - }) - return Option.none() - } - - const bot = existingBot.value - - // Add bot to org membership (so it shows in Electric sync) - yield* orgMemberRepo.upsertByOrgAndUser({ - organizationId, - userId: botUser.id, - role: "member", - nickname: null, - joinedAt: new Date(), - invitedBy: null, - deletedAt: null, - }) + if (Option.isNone(existingInstallation)) { + yield* botInstallationRepo.insert({ + botId: bot.id, + organizationId, + installedBy: botUser.id, + }) + } - // Create bot installation (idempotent - check if exists first) - const existingInstallation = yield* botInstallationRepo.findByBotAndOrg( - bot.id, - organizationId, - ) + return Option.some(botUser) + }) - if (Option.isNone(existingInstallation)) { - yield* botInstallationRepo.insert({ - botId: bot.id, - organizationId, - installedBy: botUser.id, - }) - } - - return Option.some(botUser) - }) - - return { getOrCreateBotUser, getOrCreateWebhookBotUser, addBotToOrg } - }), - dependencies: [ - UserRepo.Default, - OrganizationMemberRepo.Default, - BotRepo.Default, - BotInstallationRepo.Default, - ], -}) {} + return { getOrCreateBotUser, getOrCreateWebhookBotUser, addBotToOrg } + }), + }, +) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(UserRepo.layer), + Layer.provide(OrganizationMemberRepo.layer), + Layer.provide(BotRepo.layer), + Layer.provide(BotInstallationRepo.layer), + ) +} diff --git a/apps/backend/src/services/integrations/linear-resource-provider.ts b/apps/backend/src/services/integrations/linear-resource-provider.ts index 938b3e963..d6e25ab54 100644 --- a/apps/backend/src/services/integrations/linear-resource-provider.ts +++ b/apps/backend/src/services/integrations/linear-resource-provider.ts @@ -35,4 +35,4 @@ export const fetchLinearIssue = (issueKey: string, accessToken: string) => Effect.gen(function* () { const client = yield* Linear.LinearApiClient return yield* client.fetchIssue(issueKey, accessToken) - }).pipe(Effect.provide(Linear.LinearApiClient.Default)) + }).pipe(Effect.provide(Linear.LinearApiClient.layer)) diff --git a/apps/backend/src/services/message-outbox-dispatcher.test.ts b/apps/backend/src/services/message-outbox-dispatcher.test.ts index 6865ec7b1..44cd0dee3 100644 --- a/apps/backend/src/services/message-outbox-dispatcher.test.ts +++ b/apps/backend/src/services/message-outbox-dispatcher.test.ts @@ -9,16 +9,17 @@ import { } from "@hazel/backend-core" import { Database, asc, eq, inArray, schema } from "@hazel/db" import type { ChannelId, MessageId, MessageOutboxEventId, UserId } from "@hazel/schema" -import { Effect, Layer, Redacted } from "effect" +import { Effect, Layer, Redacted, ServiceMap } from "effect" import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { EnvVars } from "../lib/env-vars" import { createChatSyncDbHarness, type ChatSyncDbHarness } from "../test/chat-sync-db-harness" +import { serviceShape } from "../test/effect-helpers" import { MessageOutboxDispatcher } from "./message-outbox-dispatcher" import { MessageSideEffectService } from "./message-side-effect-service" -const CHANNEL_ID = "00000000-0000-0000-0000-000000000001" as ChannelId -const MESSAGE_ID = "00000000-0000-0000-0000-000000000002" as MessageId -const AUTHOR_ID = "00000000-0000-0000-0000-000000000003" as UserId +const CHANNEL_ID = "00000000-0000-4000-8000-000000000001" as ChannelId +const MESSAGE_ID = "00000000-0000-4000-8000-000000000002" as MessageId +const AUTHOR_ID = "00000000-0000-4000-8000-000000000003" as UserId type SideEffectCall = | { eventType: "message_created"; payload: MessageCreatedPayload; dedupeKey: string } @@ -33,24 +34,27 @@ type SideEffectOptions = { } const runRepoEffect = (harness: ChatSyncDbHarness, effect: Effect.Effect) => - harness.run(effect.pipe(Effect.provide(MessageOutboxRepo.Default))) + harness.run(effect.pipe(Effect.provide(MessageOutboxRepo.layer))) const runDispatcherEffect = ( harness: ChatSyncDbHarness, - sideEffects: MessageSideEffectService, - effect: Effect.Effect, + sideEffects: ServiceMap.Service.Shape, + make: Effect.Effect, ) => Effect.runPromise( Effect.scoped( - effect.pipe( - Effect.provide(MessageOutboxDispatcher.DefaultWithoutDependencies), + make.pipe( + Effect.provide(Layer.effect(MessageOutboxDispatcher, MessageOutboxDispatcher.make)), Effect.provide(Layer.succeed(MessageSideEffectService, sideEffects)), - Effect.provide(MessageOutboxRepo.Default), + Effect.provide(MessageOutboxRepo.layer), Effect.provide( - Layer.succeed(EnvVars, { - IS_DEV: true, - DATABASE_URL: Redacted.make(harness.container.getConnectionUri()), - } as EnvVars), + Layer.succeed( + EnvVars, + serviceShape({ + IS_DEV: true, + DATABASE_URL: Redacted.make(harness.container.getConnectionUri()), + }), + ), ), Effect.provide(harness.dbLayer), ), @@ -61,7 +65,7 @@ const makeSideEffectService = (calls: SideEffectCall[], options: SideEffectOptio let messageUpdatedFailures = 0 let messageDeletedFailures = 0 - return { + return serviceShape({ handleMessageCreated: (payload: MessageCreatedPayload, dedupeKey: string) => Effect.sync(() => { calls.push({ eventType: "message_created", payload, dedupeKey }) @@ -92,7 +96,7 @@ const makeSideEffectService = (calls: SideEffectCall[], options: SideEffectOptio Effect.sync(() => { calls.push({ eventType: "reaction_deleted", payload, dedupeKey }) }), - } as unknown as MessageSideEffectService + }) } const waitFor = async (predicate: () => Promise, timeoutMs = 8_000) => { diff --git a/apps/backend/src/services/message-outbox-dispatcher.ts b/apps/backend/src/services/message-outbox-dispatcher.ts index a491b3acc..ef4b4e06b 100644 --- a/apps/backend/src/services/message-outbox-dispatcher.ts +++ b/apps/backend/src/services/message-outbox-dispatcher.ts @@ -9,8 +9,9 @@ import { ReactionDeletedPayloadSchema, } from "@hazel/backend-core/repositories" import { Database } from "@hazel/db" -import { Effect, Redacted, Schema } from "effect" +import { ServiceMap, Effect, Layer, Redacted, Schema } from "effect" import { EnvVars } from "../lib/env-vars" +import { DatabaseLive } from "./database" import { MessageSideEffectService } from "./message-side-effect-service" const OUTBOX_BATCH_SIZE = 100 @@ -24,12 +25,10 @@ const OUTBOX_DISPATCHER_LOCK_KEY = 1_046_277_921 const computeRetryDelayMs = (attempt: number): number => Math.min(5_000 * 3 ** Math.max(0, attempt - 1), 300_000) -export class MessageOutboxDispatcher extends Effect.Service()( +export class MessageOutboxDispatcher extends ServiceMap.Service()( "MessageOutboxDispatcher", { - accessors: true, - dependencies: [EnvVars.Default, MessageOutboxRepo.Default, MessageSideEffectService.Default], - effect: Effect.gen(function* () { + make: Effect.gen(function* () { const envVars = yield* EnvVars const database = yield* Database.Database const outboxRepo = yield* MessageOutboxRepo @@ -113,14 +112,14 @@ export class MessageOutboxDispatcher extends Effect.Service= OUTBOX_FAILURE_LIMIT) { yield* outboxRepo.markFailed(event.id, { @@ -157,7 +156,7 @@ export class MessageOutboxDispatcher extends Effect.Service + Effect.catch((error) => Effect.gen(function* () { yield* Effect.logError("Message outbox batch failed", { workerId, @@ -171,21 +170,21 @@ export class MessageOutboxDispatcher extends Effect.Service => + const campaignForLeadership = (): Effect.Effect => Effect.gen(function* () { const reservedResult = yield* Effect.tryPromise({ try: (): Promise => pool.connect(), catch: (cause) => new Error(String(cause)), - }).pipe(Effect.either) + }).pipe(Effect.result) - if (reservedResult._tag === "Left") { + if (reservedResult._tag === "Failure") { yield* Effect.logError("Failed to reserve outbox advisory lock connection", { - error: String(reservedResult.left), + error: String(reservedResult.failure), }) yield* Effect.sleep(OUTBOX_LOCK_RETRY_INTERVAL) return yield* campaignForLeadership() } - const reserved = reservedResult.right + const reserved = reservedResult.success const lockResult = yield* Effect.tryPromise({ try: () => @@ -193,18 +192,18 @@ export class MessageOutboxDispatcher extends Effect.Service new Error(String(cause)), - }).pipe(Effect.either) + }).pipe(Effect.result) - if (lockResult._tag === "Left") { + if (lockResult._tag === "Failure") { yield* Effect.logError("Failed to acquire outbox advisory lock", { - error: String(lockResult.left), + error: String(lockResult.failure), }) yield* Effect.sync(() => reserved.release()) yield* Effect.sleep(OUTBOX_LOCK_RETRY_INTERVAL) return yield* campaignForLeadership() } - const lockRows = lockResult.right as { rows: Array<{ locked: boolean }> } + const lockRows = lockResult.success as { rows: Array<{ locked: boolean }> } if (!lockRows.rows[0]?.locked) { yield* Effect.sync(() => reserved.release()) yield* Effect.sleep(OUTBOX_LOCK_RETRY_INTERVAL) @@ -217,7 +216,7 @@ export class MessageOutboxDispatcher extends Effect.Service + Effect.catchCause((cause) => Effect.logError("Message outbox dispatcher leader loop stopped", { workerId, cause: String(cause), @@ -236,4 +235,11 @@ export class MessageOutboxDispatcher extends Effect.Service -const NEW_THREAD_MESSAGE_ID = "00000000-0000-0000-0000-000000000010" as MessageId +const NEW_THREAD_MESSAGE_ID = "00000000-0000-4000-8000-000000000010" as MessageId type DiscordCall = | { method: "message_create"; id: string; dedupeKey?: string } @@ -35,25 +36,24 @@ type WorkerOptions = { const runServiceEffect = ( harness: ChatSyncDbHarness, - worker: DiscordSyncWorker, - effect: Effect.Effect, + worker: ServiceMap.Service.Shape, + make: Effect.Effect, + httpClientLayer: Layer.Layer = FetchHttpClient.layer, ) => Effect.runPromise( Effect.scoped( - effect.pipe( - Effect.provide(MessageSideEffectService.DefaultWithoutDependencies), + make.pipe( + Effect.provide(Layer.effect(MessageSideEffectService, MessageSideEffectService.make)), Effect.provide(Layer.succeed(DiscordSyncWorker, worker)), - Effect.provide( - Layer.setConfigProvider(ConfigProvider.fromMap(new Map([["CLUSTER_URL", CLUSTER_URL]]))), - ), - Effect.provide(FetchHttpClient.layer), + Effect.provide(configLayer({ CLUSTER_URL })), + Effect.provide(httpClientLayer), Effect.provide(harness.dbLayer), ), ) as Effect.Effect, ) const makeDiscordWorker = (calls: DiscordCall[], options: WorkerOptions = {}) => - ({ + serviceShape({ syncHazelMessageCreateToAllConnections: (messageId: string, dedupeKey?: string) => options.failCreate ? Effect.fail(new Error("discord create failed")) @@ -89,7 +89,18 @@ const makeDiscordWorker = (calls: DiscordCall[], options: WorkerOptions = {}) => calls.push({ method: "reaction_delete", payload, dedupeKey }) return { synced: 1, failed: 0 } }), - }) as unknown as DiscordSyncWorker + }) + +const workflowClientLayer = (requests: Array<{ url: string }>) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request, url) => + Effect.sync(() => { + requests.push({ url: String(url) }) + return HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })) + }), + ), + ) const seedMessageSideEffectState = (harness: ChatSyncDbHarness) => harness.run( @@ -216,38 +227,26 @@ describe("MessageSideEffectService", () => { it("routes message creates through Discord sync, notifications, and thread naming", async () => { const calls: DiscordCall[] = [] - const requests: Array<{ url: string; body: string | null }> = [] - const originalFetch = globalThis.fetch - - globalThis.fetch = (async (input, init) => { - requests.push({ - url: String(input), - body: typeof init?.body === "string" ? init.body : null, - }) - return new Response(null, { status: 204 }) - }) as typeof fetch + const requests: Array<{ url: string }> = [] - try { - await runServiceEffect( - harness, - makeDiscordWorker(calls), - Effect.gen(function* () { - const service = yield* MessageSideEffectService - yield* service.handleMessageCreated( - { - messageId: NEW_THREAD_MESSAGE_ID, - channelId: THREAD_CHANNEL_ID, - authorId: AUTHOR_ID, - content: "Newest thread message", - replyToMessageId: null, - }, - "dedupe-1", - ) - }), - ) - } finally { - globalThis.fetch = originalFetch - } + await runServiceEffect( + harness, + makeDiscordWorker(calls), + Effect.gen(function* () { + const service = yield* MessageSideEffectService + yield* service.handleMessageCreated( + { + messageId: NEW_THREAD_MESSAGE_ID, + channelId: THREAD_CHANNEL_ID, + authorId: AUTHOR_ID, + content: "Newest thread message", + replyToMessageId: null, + }, + "dedupe-1", + ) + }), + workflowClientLayer(requests), + ) expect(calls).toEqual([ { @@ -256,85 +255,65 @@ describe("MessageSideEffectService", () => { dedupeKey: "dedupe-1", }, ]) - expect(requests).toHaveLength(2) expect(requests.every((request) => request.url.startsWith(CLUSTER_URL))).toBe(true) - expect(requests.map((request) => request.url).join("\n")).toContain("message-notification-workflow") - expect(requests.map((request) => request.url).join("\n")).toContain("thread-naming-workflow") + const workflowUrls = requests.map((request) => request.url).join("\n") + expect(workflowUrls).toContain("messagenotificationworkflow") + expect(workflowUrls).toContain("threadnamingworkflow") }) it("skips Discord loopback for the integration bot but still runs workflows", async () => { const calls: DiscordCall[] = [] - const requests: Array<{ url: string; body: string | null }> = [] - const originalFetch = globalThis.fetch - - globalThis.fetch = (async (input, init) => { - requests.push({ - url: String(input), - body: typeof init?.body === "string" ? init.body : null, - }) - return new Response(null, { status: 204 }) - }) as typeof fetch + const requests: Array<{ url: string }> = [] - try { - await runServiceEffect( - harness, - makeDiscordWorker(calls), - Effect.gen(function* () { - const service = yield* MessageSideEffectService - yield* service.handleMessageCreated( - { - messageId: randomUUID() as MessageId, - channelId: CHANNEL_ID, - authorId: INTEGRATION_BOT_ID, - content: "integration message", - replyToMessageId: null, - }, - "dedupe-2", - ) - }), - ) - } finally { - globalThis.fetch = originalFetch - } + await runServiceEffect( + harness, + makeDiscordWorker(calls), + Effect.gen(function* () { + const service = yield* MessageSideEffectService + yield* service.handleMessageCreated( + { + messageId: randomUUID() as MessageId, + channelId: CHANNEL_ID, + authorId: INTEGRATION_BOT_ID, + content: "integration message", + replyToMessageId: null, + }, + "dedupe-2", + ) + }), + workflowClientLayer(requests), + ) expect(calls).toHaveLength(0) expect(requests).toHaveLength(1) - expect(requests[0]?.url).toContain("message-notification-workflow") + expect(requests[0]?.url).toContain("messagenotificationworkflow") }) it("continues to workflows when Discord create sync fails", async () => { - const requests: Array = [] - const originalFetch = globalThis.fetch - - globalThis.fetch = (async (input) => { - requests.push(String(input)) - return new Response(null, { status: 204 }) - }) as typeof fetch + const requests: Array<{ url: string }> = [] - try { - await runServiceEffect( - harness, - makeDiscordWorker([], { failCreate: true }), - Effect.gen(function* () { - const service = yield* MessageSideEffectService - yield* service.handleMessageCreated( - { - messageId: randomUUID() as MessageId, - channelId: CHANNEL_ID, - authorId: AUTHOR_ID, - content: "still notifies", - replyToMessageId: null, - }, - "dedupe-3", - ) - }), - ) - } finally { - globalThis.fetch = originalFetch - } + await runServiceEffect( + harness, + makeDiscordWorker([], { failCreate: true }), + Effect.gen(function* () { + const service = yield* MessageSideEffectService + yield* service.handleMessageCreated( + { + messageId: randomUUID() as MessageId, + channelId: CHANNEL_ID, + authorId: AUTHOR_ID, + content: "still notifies", + replyToMessageId: null, + }, + "dedupe-3", + ) + }), + workflowClientLayer(requests), + ) expect(requests).toHaveLength(1) + expect(requests[0]?.url).toContain("messagenotificationworkflow") }) it("routes message updates, deletes, and reactions to the correct Discord worker methods", async () => { @@ -360,7 +339,7 @@ describe("MessageSideEffectService", () => { ) yield* service.handleReactionCreated( { - reactionId: "00000000-0000-0000-0000-000000000011" as MessageReactionId, + reactionId: "00000000-0000-4000-8000-000000000011" as MessageReactionId, }, "dedupe-reaction-create", ) @@ -389,7 +368,7 @@ describe("MessageSideEffectService", () => { }, { method: "reaction_create", - id: "00000000-0000-0000-0000-000000000011" as MessageReactionId, + id: "00000000-0000-4000-8000-000000000011" as MessageReactionId, dedupeKey: "dedupe-reaction-create", }, { diff --git a/apps/backend/src/services/message-side-effect-service.ts b/apps/backend/src/services/message-side-effect-service.ts index 8fc02a326..373a8cbf7 100644 --- a/apps/backend/src/services/message-side-effect-service.ts +++ b/apps/backend/src/services/message-side-effect-service.ts @@ -1,8 +1,7 @@ -import { HttpApiClient } from "@effect/platform" +import { HttpApiClient } from "effect/unstable/httpapi" import { and, Database, eq, isNull, schema, sql } from "@hazel/db" import { Cluster, WorkflowInitializationError } from "@hazel/domain" -import { Array, Config, Effect, Option } from "effect" -import { TreeFormatter } from "effect/ParseResult" +import { ServiceMap, Array, Config, Effect, Layer, Option } from "effect" import type { MessageCreatedPayload, MessageDeletedPayload, @@ -10,17 +9,15 @@ import type { ReactionCreatedPayload, ReactionDeletedPayload, } from "@hazel/backend-core" -import { DiscordSyncWorker } from "./chat-sync/discord-sync-worker" +import { DiscordSyncWorker, DiscordSyncWorkerLayer } from "./chat-sync/discord-sync-worker" -export class MessageSideEffectService extends Effect.Service()( +export class MessageSideEffectService extends ServiceMap.Service()( "MessageSideEffectService", { - accessors: true, - dependencies: [DiscordSyncWorker.Default], - effect: Effect.gen(function* () { + make: Effect.gen(function* () { const db = yield* Database.Database const discordSyncWorker = yield* DiscordSyncWorker - const clusterUrl = yield* Config.string("CLUSTER_URL").pipe(Effect.orDie) + const clusterUrl = yield* Config.string("CLUSTER_URL") const client = yield* HttpApiClient.make(Cluster.WorkflowApi, { baseUrl: clusterUrl, }) @@ -56,7 +53,7 @@ export class MessageSideEffectService extends Effect.Service + Effect.catch((error) => Effect.logWarning("Failed to sync outbox message create to Discord", { messageId: payload.messageId, channelId: payload.channelId, @@ -110,36 +107,14 @@ export class MessageSideEffectService extends Effect.Service - Effect.fail( - new WorkflowInitializationError({ - message: "Failed to execute notification workflow", - cause: err.message, - }), - ), - ParseError: (err) => - Effect.fail( - new WorkflowInitializationError({ - message: "Failed to execute notification workflow", - cause: TreeFormatter.formatErrorSync(err), - }), - ), - RequestError: (err) => - Effect.fail( - new WorkflowInitializationError({ - message: "Failed to execute notification workflow", - cause: err.message, - }), - ), - ResponseError: (err) => - Effect.fail( - new WorkflowInitializationError({ - message: "Failed to execute notification workflow", - cause: err.message, - }), - ), - }), + Effect.catch((err) => + Effect.fail( + new WorkflowInitializationError({ + message: "Failed to execute notification workflow", + cause: String(err), + }), + ), + ), ) if (channelType !== "thread") { @@ -201,12 +176,7 @@ export class MessageSideEffectService extends Effect.Service Effect.void, - ParseError: () => Effect.void, - RequestError: () => Effect.void, - ResponseError: () => Effect.void, - }), + Effect.catch(() => Effect.void), ) }, ) @@ -216,7 +186,7 @@ export class MessageSideEffectService extends Effect.Service + Effect.catch((error) => Effect.logWarning("Failed to sync outbox message update to Discord", { messageId: payload.messageId, error: String(error), @@ -231,7 +201,7 @@ export class MessageSideEffectService extends Effect.Service + Effect.catch((error) => Effect.logWarning("Failed to sync outbox message delete to Discord", { messageId: payload.messageId, error: String(error), @@ -246,7 +216,7 @@ export class MessageSideEffectService extends Effect.Service + Effect.catch((error) => Effect.logWarning("Failed to sync outbox reaction create to Discord", { reactionId: payload.reactionId, error: String(error), @@ -269,7 +239,7 @@ export class MessageSideEffectService extends Effect.Service + Effect.catch((error) => Effect.logWarning("Failed to sync outbox reaction delete to Discord", { hazelMessageId: payload.hazelMessageId, error: String(error), @@ -288,4 +258,6 @@ export class MessageSideEffectService extends Effect.Service()("MockDataGenerator", { - effect: Effect.gen(function* () { +export class MockDataGenerator extends ServiceMap.Service()("MockDataGenerator", { + make: Effect.gen(function* () { const generateForMarketingScreenshots = (config: MockDataConfig) => Effect.gen(function* () { const userRepo = yield* UserRepo @@ -487,5 +487,6 @@ export class MockDataGenerator extends Effect.Service()("Mock generateForMarketingScreenshots, } }), - dependencies: [DatabaseLive], -}) {} +}) { + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(DatabaseLive)) +} diff --git a/apps/backend/src/services/oauth/oauth-http-client.ts b/apps/backend/src/services/oauth/oauth-http-client.ts index 39b80e258..1c267331c 100644 --- a/apps/backend/src/services/oauth/oauth-http-client.ts +++ b/apps/backend/src/services/oauth/oauth-http-client.ts @@ -5,9 +5,8 @@ * Uses HttpClient with proper schema validation and error handling. */ -import { FetchHttpClient, HttpBody, HttpClient } from "@effect/platform" -import { Duration, Effect, Schema } from "effect" -import { TreeFormatter } from "effect/ParseResult" +import { FetchHttpClient, HttpBody, HttpClient } from "effect/unstable/http" +import { ServiceMap, Duration, Effect, Layer, Predicate, Schema, SchemaGetter, SchemaIssue } from "effect" import type { OAuthIntegrationProvider } from "./provider-config" // ============================================================================ @@ -22,17 +21,22 @@ const DEFAULT_TIMEOUT = Duration.seconds(30) const OAuthTokenApiResponse = Schema.Struct({ access_token: Schema.String, - refresh_token: Schema.optional(Schema.String), - expires_in: Schema.optional(Schema.Number), - scope: Schema.optional(Schema.String), - token_type: Schema.optionalWith(Schema.String, { default: () => "Bearer" }), + refresh_token: Schema.optionalKey(Schema.String), + expires_in: Schema.optionalKey(Schema.Number), + scope: Schema.optionalKey(Schema.String), + token_type: Schema.optional(Schema.String).pipe( + Schema.decodeTo(Schema.toType(Schema.String), { + decode: SchemaGetter.withDefault(() => "Bearer"), + encode: SchemaGetter.required(), + }), + ), }) // ============================================================================ // Error Types // ============================================================================ -export class OAuthHttpError extends Schema.TaggedError()("OAuthHttpError", { +export class OAuthHttpError extends Schema.TaggedErrorClass()("OAuthHttpError", { message: Schema.String, status: Schema.optional(Schema.Number), cause: Schema.optional(Schema.Unknown), @@ -81,9 +85,8 @@ const encodeFormData = (params: Record): string => * Provides Effect-based HTTP methods for OAuth token operations using HttpClient * with proper schema validation and error handling. */ -export class OAuthHttpClient extends Effect.Service()("OAuthHttpClient", { - accessors: true, - effect: Effect.gen(function* () { +export class OAuthHttpClient extends ServiceMap.Service()("OAuthHttpClient", { + make: Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient /** @@ -127,19 +130,15 @@ export class OAuthHttpClient extends Effect.Service()("OAuthHtt } const data = yield* response.json.pipe( - Effect.flatMap(Schema.decodeUnknown(OAuthTokenApiResponse)), - Effect.catchTags({ - ParseError: (error) => - new OAuthHttpError({ - message: `Failed to parse token response: ${TreeFormatter.formatErrorSync(error)}`, - cause: error, - }), - ResponseError: (error) => + Effect.flatMap(Schema.decodeUnknownEffect(OAuthTokenApiResponse)), + Effect.catch((error) => + Effect.fail( new OAuthHttpError({ - message: `Failed to read response body: ${error.message}`, + message: `Failed to parse token response: ${String(error)}`, cause: error, }), - }), + ), + ), ) return { @@ -192,19 +191,15 @@ export class OAuthHttpClient extends Effect.Service()("OAuthHtt } const data = yield* response.json.pipe( - Effect.flatMap(Schema.decodeUnknown(OAuthTokenApiResponse)), - Effect.catchTags({ - ParseError: (error) => - new OAuthHttpError({ - message: `Failed to parse token response: ${TreeFormatter.formatErrorSync(error)}`, - cause: error, - }), - ResponseError: (error) => + Effect.flatMap(Schema.decodeUnknownEffect(OAuthTokenApiResponse)), + Effect.catch((error) => + Effect.fail( new OAuthHttpError({ - message: `Failed to read response body: ${error.message}`, + message: `Failed to parse token response: ${String(error)}`, cause: error, }), - }), + ), + ), ) return { @@ -222,10 +217,10 @@ export class OAuthHttpClient extends Effect.Service()("OAuthHtt params: { code: string; redirectUri: string; clientId: string; clientSecret: string }, ) => exchangeCode(tokenUrl, params).pipe( - Effect.catchTag("TimeoutException", () => + Effect.catchTag("TimeoutError", () => Effect.fail(new OAuthHttpError({ message: "Request timed out" })), ), - Effect.catchTag("RequestError", (error) => + Effect.catchTag("HttpClientError", (error) => Effect.fail( new OAuthHttpError({ message: `Network error: ${String(error)}`, @@ -233,15 +228,6 @@ export class OAuthHttpClient extends Effect.Service()("OAuthHtt }), ), ), - Effect.catchTag("ResponseError", (error) => - Effect.fail( - new OAuthHttpError({ - message: `Response error: ${String(error)}`, - status: error.response.status, - cause: error, - }), - ), - ), Effect.withSpan("OAuthHttpClient.exchangeCode"), ) @@ -250,10 +236,10 @@ export class OAuthHttpClient extends Effect.Service()("OAuthHtt params: { refreshToken: string; clientId: string; clientSecret: string }, ) => refreshToken(provider, params).pipe( - Effect.catchTag("TimeoutException", () => + Effect.catchTag("TimeoutError", () => Effect.fail(new OAuthHttpError({ message: "Request timed out" })), ), - Effect.catchTag("RequestError", (error) => + Effect.catchTag("HttpClientError", (error) => Effect.fail( new OAuthHttpError({ message: `Network error: ${String(error)}`, @@ -261,15 +247,6 @@ export class OAuthHttpClient extends Effect.Service()("OAuthHtt }), ), ), - Effect.catchTag("ResponseError", (error) => - Effect.fail( - new OAuthHttpError({ - message: `Response error: ${String(error)}`, - status: error.response.status, - cause: error, - }), - ), - ), Effect.withSpan("OAuthHttpClient.refreshToken", { attributes: { provider } }), ) @@ -278,5 +255,6 @@ export class OAuthHttpClient extends Effect.Service()("OAuthHtt refreshToken: wrappedRefreshToken, } }), - dependencies: [FetchHttpClient.layer], -}) {} +}) { + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(FetchHttpClient.layer)) +} diff --git a/apps/backend/src/services/oauth/oauth-provider-registry.ts b/apps/backend/src/services/oauth/oauth-provider-registry.ts index c6e6536f8..d3e687943 100644 --- a/apps/backend/src/services/oauth/oauth-provider-registry.ts +++ b/apps/backend/src/services/oauth/oauth-provider-registry.ts @@ -1,6 +1,6 @@ import { UnsupportedProviderError } from "@hazel/domain/http" import { GitHub } from "@hazel/integrations" -import { Effect } from "effect" +import { ServiceMap, Effect, Layer } from "effect" import type { OAuthProvider } from "./oauth-provider" import { ProviderNotConfiguredError } from "./oauth-provider" import type { IntegrationProvider, OAuthIntegrationProvider, OAuthProviderConfig } from "./provider-config" @@ -64,99 +64,107 @@ const SUPPORTED_PROVIDERS: readonly OAuthIntegrationProvider[] = ["linear", "git * 3. Add provider to SUPPORTED_PROVIDERS array * 4. Set environment variables: {PROVIDER}_CLIENT_ID, {PROVIDER}_CLIENT_SECRET, {PROVIDER}_REDIRECT_URI */ -export class OAuthProviderRegistry extends Effect.Service()("OAuthProviderRegistry", { - accessors: true, - effect: Effect.gen(function* () { - // Cache for loaded providers - const providerCache = new Map() - - // Get the GitHub services for creating GitHub provider - const gitHubJwtService = yield* GitHub.GitHubAppJWTService - const gitHubApiClient = yield* GitHub.GitHubApiClient - - /** - * Get an OAuth provider instance. - * Loads configuration and creates provider on first access, then caches. - */ - const getProvider = ( - provider: IntegrationProvider, - ): Effect.Effect => - Effect.gen(function* () { - // Check cache first - const cached = providerCache.get(provider as OAuthIntegrationProvider) - if (cached) { - return cached - } - - // Check if provider is supported (cast needed since provider may include non-OAuth providers like "craft") - if (!(SUPPORTED_PROVIDERS as readonly string[]).includes(provider)) { - return yield* Effect.fail( - new UnsupportedProviderError({ - provider, - }), - ) - } - - // After the check above, we know the provider is a valid OAuth provider - const oauthProvider_ = provider as OAuthIntegrationProvider - - // Handle GitHub App separately (uses JWT service, not standard OAuth) - if (oauthProvider_ === "github") { - // Create provider with JWT service and API client - // Uses minimal AppProviderConfig since GitHub Apps manage their own auth via JWT - const oauthProvider = createGitHubAppProvider( - { provider: "github" }, - gitHubJwtService, - gitHubApiClient, - ) +export class OAuthProviderRegistry extends ServiceMap.Service()( + "OAuthProviderRegistry", + { + make: Effect.gen(function* () { + // Cache for loaded providers + const providerCache = new Map() + + // Get the GitHub services for creating GitHub provider + const gitHubJwtService = yield* GitHub.GitHubAppJWTService + const gitHubApiClient = yield* GitHub.GitHubApiClient + + /** + * Get an OAuth provider instance. + * Loads configuration and creates provider on first access, then caches. + */ + const getProvider = ( + provider: IntegrationProvider, + ): Effect.Effect => + Effect.gen(function* () { + // Check cache first + const cached = providerCache.get(provider as OAuthIntegrationProvider) + if (cached) { + return cached + } + + // Check if provider is supported (cast needed since provider may include non-OAuth providers like "craft") + if (!(SUPPORTED_PROVIDERS as readonly string[]).includes(provider)) { + return yield* Effect.fail( + new UnsupportedProviderError({ + provider, + }), + ) + } + + // After the check above, we know the provider is a valid OAuth provider + const oauthProvider_ = provider as OAuthIntegrationProvider + + // Handle GitHub App separately (uses JWT service, not standard OAuth) + if (oauthProvider_ === "github") { + // Create provider with JWT service and API client + // Uses minimal AppProviderConfig since GitHub Apps manage their own auth via JWT + const oauthProvider = createGitHubAppProvider( + { provider: "github" }, + gitHubJwtService, + gitHubApiClient, + ) + providerCache.set(oauthProvider_, oauthProvider) + return oauthProvider + } + + // Get factory function for standard OAuth providers + const factory = PROVIDER_FACTORIES[oauthProvider_] + if (!factory) { + return yield* Effect.fail( + new UnsupportedProviderError({ + provider, + }), + ) + } + + // Load configuration from environment for standard OAuth providers + const config = yield* loadProviderConfig(oauthProvider_) + .asEffect() + .pipe( + Effect.mapError( + (error) => + new ProviderNotConfiguredError({ + provider: oauthProvider_, + message: `Missing configuration for ${provider}: ${String(error)}`, + }), + ), + ) + + // Create and cache provider + const oauthProvider = factory(config) providerCache.set(oauthProvider_, oauthProvider) + return oauthProvider - } - - // Get factory function for standard OAuth providers - const factory = PROVIDER_FACTORIES[oauthProvider_] - if (!factory) { - return yield* Effect.fail( - new UnsupportedProviderError({ - provider, - }), - ) - } - - // Load configuration from environment for standard OAuth providers - const config = yield* loadProviderConfig(oauthProvider_).pipe( - Effect.mapError( - (error) => - new ProviderNotConfiguredError({ - provider: oauthProvider_, - message: `Missing configuration for ${provider}: ${String(error)}`, - }), - ), - ) - - // Create and cache provider - const oauthProvider = factory(config) - providerCache.set(oauthProvider_, oauthProvider) - - return oauthProvider - }) - - /** - * List all supported/implemented providers. - */ - const listSupportedProviders = (): readonly OAuthIntegrationProvider[] => SUPPORTED_PROVIDERS - - /** - * Check if a provider is supported. - */ - const isProviderSupported = (provider: string): provider is OAuthIntegrationProvider => - (SUPPORTED_PROVIDERS as readonly string[]).includes(provider) - - return { - getProvider, - listSupportedProviders, - isProviderSupported, - } - }), - dependencies: [GitHub.GitHubAppJWTService.Default, GitHub.GitHubApiClient.Default], -}) {} + }) + + /** + * List all supported/implemented providers. + */ + const listSupportedProviders = (): readonly OAuthIntegrationProvider[] => SUPPORTED_PROVIDERS + + /** + * Check if a provider is supported. + */ + const isProviderSupported = (provider: string): provider is OAuthIntegrationProvider => + (SUPPORTED_PROVIDERS as readonly string[]).includes(provider) + + return { + getProvider, + listSupportedProviders, + isProviderSupported, + } + }), + }, +) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(GitHub.GitHubAppJWTService.layer), + Layer.provide(GitHub.GitHubApiClient.layer), + ) +} diff --git a/apps/backend/src/services/oauth/oauth-provider.ts b/apps/backend/src/services/oauth/oauth-provider.ts index 0ce97fd57..af34d060e 100644 --- a/apps/backend/src/services/oauth/oauth-provider.ts +++ b/apps/backend/src/services/oauth/oauth-provider.ts @@ -8,12 +8,12 @@ import type { OAuthTokens, } from "./provider-config" -const IntegrationProviderSchema = Schema.Literal("linear", "github", "figma", "notion", "discord", "craft") +const IntegrationProviderSchema = Schema.Literals(["linear", "github", "figma", "notion", "discord", "craft"]) /** * Error when exchanging authorization code for tokens fails. */ -export class TokenExchangeError extends Schema.TaggedError()("TokenExchangeError", { +export class TokenExchangeError extends Schema.TaggedErrorClass()("TokenExchangeError", { provider: IntegrationProviderSchema, message: Schema.String, cause: Schema.optional(Schema.Unknown), @@ -22,7 +22,7 @@ export class TokenExchangeError extends Schema.TaggedError() /** * Error when fetching account info from provider fails. */ -export class AccountInfoError extends Schema.TaggedError()("AccountInfoError", { +export class AccountInfoError extends Schema.TaggedErrorClass()("AccountInfoError", { provider: IntegrationProviderSchema, message: Schema.String, cause: Schema.optional(Schema.Unknown), @@ -31,7 +31,7 @@ export class AccountInfoError extends Schema.TaggedError()("Ac /** * Error when refreshing access token fails. */ -export class TokenRefreshError extends Schema.TaggedError()("TokenRefreshError", { +export class TokenRefreshError extends Schema.TaggedErrorClass()("TokenRefreshError", { provider: IntegrationProviderSchema, message: Schema.String, cause: Schema.optional(Schema.Unknown), @@ -40,7 +40,7 @@ export class TokenRefreshError extends Schema.TaggedError()(" /** * Error when provider is not supported or not configured. */ -export class ProviderNotConfiguredError extends Schema.TaggedError()( +export class ProviderNotConfiguredError extends Schema.TaggedErrorClass()( "ProviderNotConfiguredError", { provider: IntegrationProviderSchema, @@ -167,7 +167,7 @@ export const makeTokenExchangeRequest = ( tokenType: result.tokenType, } satisfies OAuthTokens }).pipe( - Effect.provide(OAuthHttpClient.Default), + Effect.provide(OAuthHttpClient.layer), Effect.mapError( (error) => new TokenExchangeError({ diff --git a/apps/backend/src/services/oauth/provider-config.ts b/apps/backend/src/services/oauth/provider-config.ts index 5059d028a..fc6b0b9db 100644 --- a/apps/backend/src/services/oauth/provider-config.ts +++ b/apps/backend/src/services/oauth/provider-config.ts @@ -124,12 +124,12 @@ export const loadProviderConfig = (provider: OAuthIntegrationProvider) => { const prefix = provider.toUpperCase() const staticConfig = PROVIDER_CONFIGS[provider] - return Effect.all({ + return Config.all({ apiBaseUrl: Config.string("API_BASE_URL"), clientId: Config.string(`${prefix}_CLIENT_ID`), clientSecret: Config.redacted(`${prefix}_CLIENT_SECRET`), }).pipe( - Effect.map( + Config.map( (envConfig): OAuthProviderConfig => ({ ...staticConfig, clientId: envConfig.clientId, diff --git a/apps/backend/src/services/oauth/providers/discord-oauth-provider.ts b/apps/backend/src/services/oauth/providers/discord-oauth-provider.ts index 9c4b4bf29..fffc59fb4 100644 --- a/apps/backend/src/services/oauth/providers/discord-oauth-provider.ts +++ b/apps/backend/src/services/oauth/providers/discord-oauth-provider.ts @@ -18,13 +18,16 @@ export const createDiscordOAuthProvider = (config: OAuthProviderConfig): OAuthPr makeTokenExchangeRequest(config, code, Redacted.value(config.clientSecret)), getAccountInfo: (accessToken: string) => - Discord.DiscordApiClient.getAccountInfo(accessToken).pipe( - Effect.provide(Discord.DiscordApiClient.Default), + Effect.gen(function* () { + const discordApiClient = yield* Discord.DiscordApiClient + return yield* discordApiClient.getAccountInfo(accessToken) + }).pipe( + Effect.provide(Discord.DiscordApiClient.layer), Effect.mapError( (error) => new AccountInfoError({ provider: "discord", - message: `Failed to get Discord account info: ${"message" in error ? String(error.message) : String(error)}`, + message: `Failed to get Discord account info: ${error instanceof Error ? error.message : String(error)}`, cause: error, }), ), diff --git a/apps/backend/src/services/oauth/providers/linear-oauth-provider.ts b/apps/backend/src/services/oauth/providers/linear-oauth-provider.ts index f2a777ce2..f41043158 100644 --- a/apps/backend/src/services/oauth/providers/linear-oauth-provider.ts +++ b/apps/backend/src/services/oauth/providers/linear-oauth-provider.ts @@ -34,7 +34,7 @@ export const createLinearOAuthProvider = (config: OAuthProviderConfig): OAuthPro const client = yield* Linear.LinearApiClient return yield* client.getAccountInfo(accessToken) }).pipe( - Effect.provide(Linear.LinearApiClient.Default), + Effect.provide(Linear.LinearApiClient.layer), Effect.mapError( (error) => new AccountInfoError({ diff --git a/apps/backend/src/services/org-resolver.test.ts b/apps/backend/src/services/org-resolver.test.ts index 3b3e06204..2dbf5dc6e 100644 --- a/apps/backend/src/services/org-resolver.test.ts +++ b/apps/backend/src/services/org-resolver.test.ts @@ -2,24 +2,28 @@ import { describe, expect, it } from "@effect/vitest" import { ChannelMemberRepo, ChannelRepo, MessageRepo, OrganizationMemberRepo } from "@hazel/backend-core" import { PermissionError } from "@hazel/domain" import type { ChannelId, ChannelMemberId, MessageId, OrganizationId, UserId } from "@hazel/schema" -import { Effect, Either, Layer, Option } from "effect" +import { Effect, Result, Layer, Option, ServiceMap } from "effect" import { OrgResolver } from "./org-resolver" import { makeActor, TEST_ORG_ID } from "../policies/policy-test-helpers" import { CurrentUser } from "@hazel/domain" +import { serviceShape } from "../test/effect-helpers" type Role = "admin" | "member" | "owner" -const CHANNEL_ID = "00000000-0000-0000-0000-000000000501" as ChannelId -const MESSAGE_ID = "00000000-0000-0000-0000-000000000601" as MessageId -const CHANNEL_MEMBER_ID = "00000000-0000-0000-0000-000000000701" as ChannelMemberId +const CHANNEL_ID = "00000000-0000-4000-8000-000000000501" as ChannelId +const MESSAGE_ID = "00000000-0000-4000-8000-000000000601" as MessageId +const CHANNEL_MEMBER_ID = "00000000-0000-4000-8000-000000000701" as ChannelMemberId const makeOrgMemberRepoLayer = (members: Record) => - Layer.succeed(OrganizationMemberRepo, { - findByOrgAndUser: (organizationId: OrganizationId, userId: UserId) => { - const role = members[`${organizationId}:${userId}`] - return Effect.succeed(role ? Option.some({ organizationId, userId, role }) : Option.none()) - }, - } as unknown as OrganizationMemberRepo) + Layer.succeed( + OrganizationMemberRepo, + serviceShape({ + findByOrgAndUser: (organizationId: OrganizationId, userId: UserId) => { + const role = members[`${organizationId}:${userId}`] + return Effect.succeed(role ? Option.some({ organizationId, userId, role }) : Option.none()) + }, + }), + ) const makeChannelRepoLayer = ( channels: Record< @@ -27,30 +31,41 @@ const makeChannelRepoLayer = ( { organizationId: OrganizationId; type: string; parentChannelId?: string | null; id: string } >, ) => - Layer.succeed(ChannelRepo, { - findById: (id: ChannelId) => { - const channel = channels[id] - return Effect.succeed(channel ? Option.some(channel) : Option.none()) - }, - } as unknown as ChannelRepo) + Layer.succeed( + ChannelRepo, + serviceShape({ + findById: (id: ChannelId) => { + const channel = channels[id] + return Effect.succeed(channel ? Option.some(channel) : Option.none()) + }, + }), + ) const makeChannelMemberRepoLayer = (memberships: Record) => - Layer.succeed(ChannelMemberRepo, { - findByChannelAndUser: (channelId: ChannelId, userId: UserId) => { - const key = `${channelId}:${userId}` - return Effect.succeed( - memberships[key] ? Option.some({ id: CHANNEL_MEMBER_ID, channelId, userId }) : Option.none(), - ) - }, - } as unknown as ChannelMemberRepo) + Layer.succeed( + ChannelMemberRepo, + serviceShape({ + findByChannelAndUser: (channelId: ChannelId, userId: UserId) => { + const key = `${channelId}:${userId}` + return Effect.succeed( + memberships[key] + ? Option.some({ id: CHANNEL_MEMBER_ID, channelId, userId }) + : Option.none(), + ) + }, + }), + ) const makeMessageRepoLayer = (messages: Record) => - Layer.succeed(MessageRepo, { - findById: (id: MessageId) => { - const message = messages[id] - return Effect.succeed(message ? Option.some(message) : Option.none()) - }, - } as unknown as MessageRepo) + Layer.succeed( + MessageRepo, + serviceShape({ + findById: (id: MessageId) => { + const message = messages[id] + return Effect.succeed(message ? Option.some(message) : Option.none()) + }, + }), + ) const makeResolverLayer = (opts: { members?: Record @@ -61,7 +76,7 @@ const makeResolverLayer = (opts: { channelMembers?: Record messages?: Record }) => - OrgResolver.DefaultWithoutDependencies.pipe( + Layer.effect(OrgResolver, OrgResolver.make).pipe( Layer.provide(makeOrgMemberRepoLayer(opts.members ?? {})), Layer.provide(makeChannelRepoLayer(opts.channels ?? {})), Layer.provide(makeChannelMemberRepoLayer(opts.channelMembers ?? {})), @@ -69,19 +84,21 @@ const makeResolverLayer = (opts: { ) const runEither = ( - effect: Effect.Effect, - layer: Layer.Layer, + make: Effect.Effect, + layer: Layer.Layer, actor: CurrentUser.Schema = makeActor(), ) => Effect.runPromise( - effect.pipe(Effect.provide(layer), Effect.provideService(CurrentUser.Context, actor), Effect.either), + make.pipe( + Effect.provide(layer), + Effect.provideService(CurrentUser.Context, actor), + Effect.result, + ) as Effect.Effect, ) -const use = (fn: (resolver: OrgResolver) => Effect.Effect) => - Effect.gen(function* () { - const resolver = yield* OrgResolver - return yield* fn(resolver) - }) +const use = ( + fn: (resolver: ServiceMap.Service.Shape) => Effect.Effect, +) => OrgResolver.use(fn) describe("OrgResolver", () => { describe("requireScope", () => { @@ -96,7 +113,7 @@ describe("OrgResolver", () => { layer, actor, ) - expect(Either.isRight(result)).toBe(true) + expect(Result.isSuccess(result)).toBe(true) }) it("denies access for non-members", async () => { @@ -108,9 +125,9 @@ describe("OrgResolver", () => { layer, actor, ) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(PermissionError.is(result.left)).toBe(true) + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + expect(PermissionError.is(result.failure)).toBe(true) } }) }) @@ -129,7 +146,7 @@ describe("OrgResolver", () => { layer, actor, ) - expect(Either.isRight(result)).toBe(true) + expect(Result.isSuccess(result)).toBe(true) }) it("grants access for owner", async () => { @@ -145,7 +162,7 @@ describe("OrgResolver", () => { layer, actor, ) - expect(Either.isRight(result)).toBe(true) + expect(Result.isSuccess(result)).toBe(true) }) it("denies access for regular member", async () => { @@ -161,9 +178,9 @@ describe("OrgResolver", () => { layer, actor, ) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(PermissionError.is(result.left)).toBe(true) + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + expect(PermissionError.is(result.failure)).toBe(true) } }) }) @@ -171,7 +188,7 @@ describe("OrgResolver", () => { describe("requireOwner", () => { it("grants access for owner only", async () => { const actor = makeActor() - const adminActor = makeActor({ id: "00000000-0000-0000-0000-000000000502" as UserId }) + const adminActor = makeActor({ id: "00000000-0000-4000-8000-000000000502" as UserId }) const layer = makeResolverLayer({ members: { @@ -191,8 +208,8 @@ describe("OrgResolver", () => { adminActor, ) - expect(Either.isRight(ownerResult)).toBe(true) - expect(Either.isLeft(adminResult)).toBe(true) + expect(Result.isSuccess(ownerResult)).toBe(true) + expect(Result.isFailure(adminResult)).toBe(true) }) }) @@ -215,7 +232,7 @@ describe("OrgResolver", () => { layer, actor, ) - expect(Either.isRight(result)).toBe(true) + expect(Result.isSuccess(result)).toBe(true) }) it("fails for missing channel", async () => { @@ -224,13 +241,13 @@ describe("OrgResolver", () => { members: { [`${TEST_ORG_ID}:${actor.id}`]: "member" }, }) - const missingChannelId = "00000000-0000-0000-0000-000000000599" as ChannelId + const missingChannelId = "00000000-0000-4000-8000-000000000599" as ChannelId const result = await runEither( use((r) => r.fromChannel(missingChannelId, "channels:read", "Channel", "read")), layer, actor, ) - expect(Either.isLeft(result)).toBe(true) + expect(Result.isFailure(result)).toBe(true) }) }) @@ -253,7 +270,7 @@ describe("OrgResolver", () => { layer, actor, ) - expect(Either.isRight(result)).toBe(true) + expect(Result.isSuccess(result)).toBe(true) }) it("allows private channel for admin without membership", async () => { @@ -274,7 +291,7 @@ describe("OrgResolver", () => { layer, actor, ) - expect(Either.isRight(result)).toBe(true) + expect(Result.isSuccess(result)).toBe(true) }) it("denies private channel for non-admin without channel membership", async () => { @@ -296,7 +313,7 @@ describe("OrgResolver", () => { layer, actor, ) - expect(Either.isLeft(result)).toBe(true) + expect(Result.isFailure(result)).toBe(true) }) it("allows private channel for member with channel membership", async () => { @@ -318,12 +335,12 @@ describe("OrgResolver", () => { layer, actor, ) - expect(Either.isRight(result)).toBe(true) + expect(Result.isSuccess(result)).toBe(true) }) it("allows direct channel only for channel members", async () => { const actor = makeActor() - const outsider = makeActor({ id: "00000000-0000-0000-0000-000000000503" as UserId }) + const outsider = makeActor({ id: "00000000-0000-4000-8000-000000000503" as UserId }) const layer = makeResolverLayer({ members: { @@ -351,14 +368,14 @@ describe("OrgResolver", () => { outsider, ) - expect(Either.isRight(memberResult)).toBe(true) - expect(Either.isLeft(outsiderResult)).toBe(true) + expect(Result.isSuccess(memberResult)).toBe(true) + expect(Result.isFailure(outsiderResult)).toBe(true) }) it("checks parent channel access for threads", async () => { const actor = makeActor() - const parentChannelId = "00000000-0000-0000-0000-000000000502" as ChannelId - const threadId = "00000000-0000-0000-0000-000000000503" as ChannelId + const parentChannelId = "00000000-0000-4000-8000-000000000502" as ChannelId + const threadId = "00000000-0000-4000-8000-000000000503" as ChannelId const layer = makeResolverLayer({ members: { [`${TEST_ORG_ID}:${actor.id}`]: "member" }, @@ -382,7 +399,7 @@ describe("OrgResolver", () => { layer, actor, ) - expect(Either.isRight(result)).toBe(true) + expect(Result.isSuccess(result)).toBe(true) }) }) @@ -408,7 +425,7 @@ describe("OrgResolver", () => { layer, actor, ) - expect(Either.isRight(result)).toBe(true) + expect(Result.isSuccess(result)).toBe(true) }) it("fails for missing message", async () => { @@ -417,13 +434,13 @@ describe("OrgResolver", () => { members: { [`${TEST_ORG_ID}:${actor.id}`]: "member" }, }) - const missingId = "00000000-0000-0000-0000-000000000699" as MessageId + const missingId = "00000000-0000-4000-8000-000000000699" as MessageId const result = await runEither( use((r) => r.fromMessage(missingId, "messages:read", "Message", "read")), layer, actor, ) - expect(Either.isLeft(result)).toBe(true) + expect(Result.isFailure(result)).toBe(true) }) }) }) diff --git a/apps/backend/src/services/org-resolver.ts b/apps/backend/src/services/org-resolver.ts index 085b6e079..d2d58fc9b 100644 --- a/apps/backend/src/services/org-resolver.ts +++ b/apps/backend/src/services/org-resolver.ts @@ -3,7 +3,7 @@ import { PermissionError } from "@hazel/domain" import * as CurrentUser from "@hazel/domain/current-user" import { type ApiScope, CurrentBotScopes, scopesForRole } from "@hazel/domain/scopes" import type { ChannelId, MessageId, OrganizationId } from "@hazel/schema" -import { Effect, FiberRef, Option } from "effect" +import { ServiceMap, Effect, Layer, Option } from "effect" import { isAdminOrOwner, type OrganizationRole } from "../lib/policy-utils" /** @@ -11,8 +11,8 @@ import { isAdminOrOwner, type OrganizationRole } from "../lib/policy-utils" * It replaces ad-hoc role checks across individual policy services with a single * point of scope resolution. */ -export class OrgResolver extends Effect.Service()("OrgResolver", { - effect: Effect.gen(function* () { +export class OrgResolver extends ServiceMap.Service()("OrgResolver", { + make: Effect.gen(function* () { const organizationMemberRepo = yield* OrganizationMemberRepo const channelRepo = yield* ChannelRepo const channelMemberRepo = yield* ChannelMemberRepo @@ -25,9 +25,9 @@ export class OrgResolver extends Effect.Service()("OrgResolver", { */ const resolveGrantedScopes = (role: "owner" | "admin" | "member") => Effect.gen(function* () { - const botScopes = yield* FiberRef.get(CurrentBotScopes) - if (Option.isSome(botScopes)) { - return botScopes.value + const botScopes = yield* Effect.serviceOption(CurrentBotScopes) + if (Option.isSome(botScopes) && Option.isSome(botScopes.value)) { + return botScopes.value.value } return scopesForRole(role) }) @@ -247,11 +247,11 @@ export class OrgResolver extends Effect.Service()("OrgResolver", { checkChannelAccess, } as const }), - dependencies: [ - OrganizationMemberRepo.Default, - ChannelRepo.Default, - ChannelMemberRepo.Default, - MessageRepo.Default, - ], - accessors: true, -}) {} +}) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(OrganizationMemberRepo.layer), + Layer.provide(ChannelRepo.layer), + Layer.provide(ChannelMemberRepo.layer), + Layer.provide(MessageRepo.layer), + ) +} diff --git a/apps/backend/src/services/rate-limiter.ts b/apps/backend/src/services/rate-limiter.ts index 7be8810b4..8fa6b5d06 100644 --- a/apps/backend/src/services/rate-limiter.ts +++ b/apps/backend/src/services/rate-limiter.ts @@ -1,5 +1,5 @@ import { Redis, type RedisErrors } from "@hazel/effect-bun" -import { Effect, Layer, Schema } from "effect" +import { ServiceMap, Effect, Layer, Schema } from "effect" /** * Result of a rate limit check @@ -15,7 +15,7 @@ export interface RateLimitResult { readonly limit: number } -export class RateLimiterError extends Schema.TaggedError()("RateLimiterError", { +export class RateLimiterError extends Schema.TaggedErrorClass()("RateLimiterError", { message: Schema.String, cause: Schema.optional(Schema.Unknown), }) {} @@ -58,9 +58,8 @@ end /** * Rate limiter service backed by Redis via @hazel/effect-bun */ -export class RateLimiter extends Effect.Service()("RateLimiter", { - dependencies: [Redis.Default], - effect: Effect.gen(function* () { +export class RateLimiter extends ServiceMap.Service()("RateLimiter", { + make: Effect.gen(function* () { const redis = yield* Redis return { @@ -98,7 +97,9 @@ export class RateLimiter extends Effect.Service()("RateLimiter", { ), } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(Redis.Default)) +} /** * In-memory rate limiter for testing (no Redis required) @@ -107,7 +108,7 @@ const memoryStore = new Map() export const RateLimiterMemoryLive = Layer.succeed( RateLimiter, - RateLimiter.make({ + RateLimiter.of({ consume: (key: string, limit: number, windowMs: number) => Effect.sync(() => { // Simple in-memory implementation using a Map diff --git a/apps/backend/src/services/session-manager.ts b/apps/backend/src/services/session-manager.ts index ba7ab5097..19687a240 100644 --- a/apps/backend/src/services/session-manager.ts +++ b/apps/backend/src/services/session-manager.ts @@ -1,4 +1,4 @@ -import { BackendAuth } from "@hazel/auth/backend" +import { BackendAuth, type UserRepoLike } from "@hazel/auth/backend" import { CurrentUser, InvalidBearerTokenError, @@ -6,7 +6,7 @@ import { WorkOSUserFetchError, } from "@hazel/domain" import { UserRepo } from "@hazel/backend-core" -import { Effect } from "effect" +import { ServiceMap, Effect, Layer } from "effect" /** * Session management service that handles authentication via WorkOS. @@ -14,19 +14,22 @@ import { Effect } from "effect" * * This service delegates to @hazel/auth/backend for the actual authentication logic. */ -export class SessionManager extends Effect.Service()("SessionManager", { - accessors: true, - dependencies: [BackendAuth.Default, UserRepo.Default], - effect: Effect.gen(function* () { +export class SessionManager extends ServiceMap.Service()("SessionManager", { + make: Effect.gen(function* () { const auth = yield* BackendAuth const userRepo = yield* UserRepo + const userRepoLike: UserRepoLike = { + findByWorkOSUserId: userRepo.findByWorkOSUserId, + upsertWorkOSUser: userRepo.upsertWorkOSUser, + update: userRepo.update, + } /** * Authenticate with a WorkOS bearer token (JWT). * Verifies the JWT signature and syncs the user to the database. */ const authenticateWithBearer = (bearerToken: string) => - auth.authenticateWithBearer(bearerToken, userRepo) + auth.authenticateWithBearer(bearerToken, userRepoLike) return { authenticateWithBearer: authenticateWithBearer as ( @@ -38,4 +41,9 @@ export class SessionManager extends Effect.Service()("SessionMan >, } as const }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(BackendAuth.layer), + Layer.provide(UserRepo.layer), + ) +} diff --git a/apps/backend/src/services/typing-indicator-cleanup.ts b/apps/backend/src/services/typing-indicator-cleanup.ts index 233a14063..d7694f6b6 100644 --- a/apps/backend/src/services/typing-indicator-cleanup.ts +++ b/apps/backend/src/services/typing-indicator-cleanup.ts @@ -25,5 +25,5 @@ export const startTypingIndicatorCleanup = Effect.gen(function* () { yield* Effect.logDebug("Starting typing indicator cleanup job") // Run cleanup every 5 seconds - yield* cleanupStaleIndicators.pipe(Effect.repeat(Schedule.fixed(5000)), Effect.fork) + yield* cleanupStaleIndicators.pipe(Effect.repeat(Schedule.fixed(5000)), Effect.forkChild) }) diff --git a/apps/backend/src/services/webhook-bot-service.ts b/apps/backend/src/services/webhook-bot-service.ts index 32e499471..f7a84e513 100644 --- a/apps/backend/src/services/webhook-bot-service.ts +++ b/apps/backend/src/services/webhook-bot-service.ts @@ -1,6 +1,6 @@ import { OrganizationMemberRepo, UserRepo } from "@hazel/backend-core" import type { ChannelWebhookId, OrganizationId, UserId } from "@hazel/schema" -import { Effect } from "effect" +import { ServiceMap, Effect, Layer } from "effect" /** * Webhook Bot Service @@ -8,9 +8,8 @@ import { Effect } from "effect" * Manages machine users for channel webhooks. * Each webhook has its own unique bot user identity. */ -export class WebhookBotService extends Effect.Service()("WebhookBotService", { - accessors: true, - effect: Effect.gen(function* () { +export class WebhookBotService extends ServiceMap.Service()("WebhookBotService", { + make: Effect.gen(function* () { const userRepo = yield* UserRepo const orgMemberRepo = yield* OrganizationMemberRepo @@ -67,5 +66,9 @@ export class WebhookBotService extends Effect.Service()("Webh return { createWebhookBot, updateWebhookBot } }), - dependencies: [UserRepo.Default, OrganizationMemberRepo.Default], -}) {} +}) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(UserRepo.layer), + Layer.provide(OrganizationMemberRepo.layer), + ) +} diff --git a/apps/backend/src/services/workos-auth.ts b/apps/backend/src/services/workos-auth.ts index 8c02431c7..b9fdd0e5a 100644 --- a/apps/backend/src/services/workos-auth.ts +++ b/apps/backend/src/services/workos-auth.ts @@ -1,13 +1,12 @@ import { WorkOS as WorkOSNodeAPI } from "@workos-inc/node" -import { Config, Effect, Redacted, Schema } from "effect" +import { ServiceMap, Config, Effect, Layer, Redacted, Schema } from "effect" -export class WorkOSAuthError extends Schema.TaggedError()("WorkOSAuthError", { +export class WorkOSAuthError extends Schema.TaggedErrorClass()("WorkOSAuthError", { cause: Schema.Unknown, }) {} -export class WorkOSAuth extends Effect.Service()("WorkOSAuth", { - accessors: true, - effect: Effect.gen(function* () { +export class WorkOSAuth extends ServiceMap.Service()("WorkOSAuth", { + make: Effect.gen(function* () { const apiKey = yield* Config.redacted("WORKOS_API_KEY") const clientId = yield* Config.string("WORKOS_CLIENT_ID") @@ -25,4 +24,6 @@ export class WorkOSAuth extends Effect.Service()("WorkOSAuth", { call, } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/apps/backend/src/services/workos-webhook.ts b/apps/backend/src/services/workos-webhook.ts index 76a6b281f..de1598397 100644 --- a/apps/backend/src/services/workos-webhook.ts +++ b/apps/backend/src/services/workos-webhook.ts @@ -1,21 +1,20 @@ import * as crypto from "node:crypto" -import { Config, DateTime, Duration, Effect, Schema } from "effect" +import { ServiceMap, Config, DateTime, Duration, Effect, Layer, Schema } from "effect" // Error types -export class WebhookVerificationError extends Schema.TaggedError( +export class WebhookVerificationError extends Schema.TaggedErrorClass( "WebhookVerificationError", )("WebhookVerificationError", { message: Schema.String, }) {} -export class WebhookTimestampError extends Schema.TaggedError("WebhookTimestampError")( +export class WebhookTimestampError extends Schema.TaggedErrorClass( "WebhookTimestampError", - { - message: Schema.String, - timestamp: Schema.Number, - currentTime: Schema.Number, - }, -) {} +)("WebhookTimestampError", { + message: Schema.String, + timestamp: Schema.Number, + currentTime: Schema.Number, +}) {} export interface WorkOSWebhookSignature { timestamp: number @@ -24,139 +23,146 @@ export interface WorkOSWebhookSignature { const DEFAULT_TIMESTAMP_TOLERANCE = Duration.minutes(5) -export class WorkOSWebhookVerifier extends Effect.Service()("WorkOSWebhookVerifier", { - accessors: true, - effect: Effect.gen(function* () { - // Get webhook secret from config - const webhookSecret = yield* Config.string("WORKOS_WEBHOOK_SECRET") - - /** - * Parse the WorkOS-Signature header - * Format: "t=,v1=" - */ - const parseSignatureHeader = ( - header: string, - ): Effect.Effect => - Effect.gen(function* () { - const parts = header.split(",").map((p) => p.trim()) - if (parts.length !== 2) { - return yield* Effect.fail( - new WebhookVerificationError({ - message: `Invalid signature header format: expected 2 parts (t=...,v1=...), got ${parts.length}. Header: '${header.slice(0, 50)}${header.length > 50 ? "..." : ""}'`, - }), - ) - } - - const timestampPart = parts[0] - const signaturePart = parts[1] - - if (!timestampPart.startsWith("t=") || !signaturePart.startsWith("v1=")) { - return yield* Effect.fail( - new WebhookVerificationError({ - message: `Invalid signature header format: expected 't=,v1=', got '${timestampPart.slice(0, 15)}...,${signaturePart.slice(0, 15)}...'`, - }), - ) - } - - const timestamp = parseInt(timestampPart.slice(2), 10) - const signature = signaturePart.slice(3) - - if (Number.isNaN(timestamp)) { - return yield* Effect.fail( - new WebhookVerificationError({ - message: `Invalid timestamp in signature header: '${timestampPart}' is not a valid number`, - }), - ) - } - - return { timestamp, signature } - }) - - /** - * Validate timestamp to prevent replay attacks - * Default tolerance is 5 minutes - */ - const validateTimestamp = ( - timestamp: number, - tolerance: Duration.Duration = DEFAULT_TIMESTAMP_TOLERANCE, - ): Effect.Effect => - Effect.gen(function* () { - const webhookTime = DateTime.unsafeMake(timestamp) - const now = DateTime.unsafeNow() - const difference = DateTime.distanceDuration(webhookTime, now) - - if (Duration.greaterThan(difference, tolerance)) { - return yield* Effect.fail( - new WebhookTimestampError({ - message: `Webhook timestamp is too old. Difference: ${Duration.format(difference)}, Tolerance: ${Duration.format(tolerance)}`, - timestamp, - currentTime: Date.now(), - }), - ) - } - }) - - /** - * Compute the expected signature using HMAC SHA256 - */ - const computeSignature = (timestamp: number, payload: string): string => { - const signedPayload = `${timestamp}.${payload}` - const hmac = crypto.createHmac("sha256", webhookSecret) - hmac.update(signedPayload) - return hmac.digest("hex") - } - - /** - * Verify the webhook signature - */ - const verifyWebhook = ( - signatureHeader: string, - payload: string, - options?: { - timestampTolerance?: Duration.Duration - }, - ): Effect.Effect => - Effect.gen(function* () { - // Parse the signature header - const { timestamp, signature } = yield* parseSignatureHeader(signatureHeader) - - // Validate timestamp - yield* validateTimestamp(timestamp, options?.timestampTolerance) - - // Compute expected signature - const expectedSignature = computeSignature(timestamp, payload) - - // Compare signatures using timing-safe comparison - const signatureBuffer = Buffer.from(signature, "hex") - const expectedBuffer = Buffer.from(expectedSignature, "hex") - - if (signatureBuffer.length !== expectedBuffer.length) { - return yield* Effect.fail( - new WebhookVerificationError({ - message: `Signature length mismatch: received ${signatureBuffer.length} bytes, expected ${expectedBuffer.length} bytes`, - }), - ) - } - - if ( - !crypto.timingSafeEqual(new Uint8Array(signatureBuffer), new Uint8Array(expectedBuffer)) - ) { - return yield* Effect.fail( - new WebhookVerificationError({ - message: - "Webhook signature does not match. This could indicate the webhook secret is incorrect or the payload was modified.", - }), - ) - } - - yield* Effect.logInfo("WorkOS webhook signature verified successfully") - }) - - return { - verifyWebhook, - parseSignatureHeader, - validateTimestamp, - computeSignature, - } - }), -}) {} +export class WorkOSWebhookVerifier extends ServiceMap.Service()( + "WorkOSWebhookVerifier", + { + make: Effect.gen(function* () { + // Get webhook secret from config + const webhookSecret = yield* Config.string("WORKOS_WEBHOOK_SECRET") + + /** + * Parse the WorkOS-Signature header + * Format: "t=,v1=" + */ + const parseSignatureHeader = ( + header: string, + ): Effect.Effect => + Effect.gen(function* () { + const parts = header.split(",").map((p) => p.trim()) + if (parts.length !== 2) { + return yield* Effect.fail( + new WebhookVerificationError({ + message: `Invalid signature header format: expected 2 parts (t=...,v1=...), got ${parts.length}. Header: '${header.slice(0, 50)}${header.length > 50 ? "..." : ""}'`, + }), + ) + } + + const timestampPart = parts[0] + const signaturePart = parts[1] + + if (!timestampPart.startsWith("t=") || !signaturePart.startsWith("v1=")) { + return yield* Effect.fail( + new WebhookVerificationError({ + message: `Invalid signature header format: expected 't=,v1=', got '${timestampPart.slice(0, 15)}...,${signaturePart.slice(0, 15)}...'`, + }), + ) + } + + const timestamp = parseInt(timestampPart.slice(2), 10) + const signature = signaturePart.slice(3) + + if (Number.isNaN(timestamp)) { + return yield* Effect.fail( + new WebhookVerificationError({ + message: `Invalid timestamp in signature header: '${timestampPart}' is not a valid number`, + }), + ) + } + + return { timestamp, signature } + }) + + /** + * Validate timestamp to prevent replay attacks + * Default tolerance is 5 minutes + */ + const validateTimestamp = ( + timestamp: number, + tolerance: Duration.Duration = DEFAULT_TIMESTAMP_TOLERANCE, + ): Effect.Effect => + Effect.gen(function* () { + const webhookTime = DateTime.makeUnsafe(timestamp) + const now = DateTime.nowUnsafe() + const difference = DateTime.distance(webhookTime, now) + + if (Duration.isGreaterThan(difference, tolerance)) { + return yield* Effect.fail( + new WebhookTimestampError({ + message: `Webhook timestamp is too old. Difference: ${Duration.format(difference)}, Tolerance: ${Duration.format(tolerance)}`, + timestamp, + currentTime: Date.now(), + }), + ) + } + }) + + /** + * Compute the expected signature using HMAC SHA256 + */ + const computeSignature = (timestamp: number, payload: string): string => { + const signedPayload = `${timestamp}.${payload}` + const hmac = crypto.createHmac("sha256", webhookSecret) + hmac.update(signedPayload) + return hmac.digest("hex") + } + + /** + * Verify the webhook signature + */ + const verifyWebhook = ( + signatureHeader: string, + payload: string, + options?: { + timestampTolerance?: Duration.Duration + }, + ): Effect.Effect => + Effect.gen(function* () { + // Parse the signature header + const { timestamp, signature } = yield* parseSignatureHeader(signatureHeader) + + // Validate timestamp + yield* validateTimestamp(timestamp, options?.timestampTolerance) + + // Compute expected signature + const expectedSignature = computeSignature(timestamp, payload) + + // Compare signatures using timing-safe comparison + const signatureBuffer = Buffer.from(signature, "hex") + const expectedBuffer = Buffer.from(expectedSignature, "hex") + + if (signatureBuffer.length !== expectedBuffer.length) { + return yield* Effect.fail( + new WebhookVerificationError({ + message: `Signature length mismatch: received ${signatureBuffer.length} bytes, expected ${expectedBuffer.length} bytes`, + }), + ) + } + + if ( + !crypto.timingSafeEqual( + new Uint8Array(signatureBuffer), + new Uint8Array(expectedBuffer), + ) + ) { + return yield* Effect.fail( + new WebhookVerificationError({ + message: + "Webhook signature does not match. This could indicate the webhook secret is incorrect or the payload was modified.", + }), + ) + } + + yield* Effect.logInfo("WorkOS webhook signature verified successfully") + }) + + return { + verifyWebhook, + parseSignatureHeader, + validateTimestamp, + computeSignature, + } + }), + }, +) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/apps/backend/src/test/chat-sync-db-harness.ts b/apps/backend/src/test/chat-sync-db-harness.ts index c7908799b..b554bf947 100644 --- a/apps/backend/src/test/chat-sync-db-harness.ts +++ b/apps/backend/src/test/chat-sync-db-harness.ts @@ -1,10 +1,22 @@ -import { execSync } from "node:child_process" +import { execFileSync } from "node:child_process" import { fileURLToPath } from "node:url" -import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql" import { Database } from "@hazel/db" import { Effect, Layer, Redacted } from "effect" const DB_PACKAGE_DIR = fileURLToPath(new URL("../../../../packages/db", import.meta.url)) +const POSTGRES_IMAGE = "postgres:17-alpine" +const POSTGRES_USER = "user" +const POSTGRES_PASSWORD = "password" +const POSTGRES_DB = "app" +const STARTUP_TIMEOUT_MS = 60_000 +const POLL_INTERVAL_MS = 500 +const REQUIRED_TABLES = [ + "chat_sync_event_receipts", + "chat_sync_message_links", + "chat_sync_channel_links", + "chat_sync_connections", + "message_outbox_events", +] as const const TRUNCATE_SQL = ` TRUNCATE TABLE @@ -24,7 +36,9 @@ RESTART IDENTITY CASCADE; ` export interface ChatSyncDbHarness { - readonly container: StartedPostgreSqlContainer + readonly container: { + getConnectionUri: () => string + } readonly dbLayer: Layer.Layer run: (effect: Effect.Effect) => Promise reset: () => Promise @@ -32,7 +46,7 @@ export interface ChatSyncDbHarness { } const runDbPush = (databaseUrl: string) => { - execSync("bun run db:push", { + execFileSync("bun", ["run", "db:push"], { cwd: DB_PACKAGE_DIR, stdio: "pipe", env: { @@ -42,37 +56,156 @@ const runDbPush = (databaseUrl: string) => { }) } -export const createChatSyncDbHarness = async (): Promise => { - const container = await new PostgreSqlContainer("postgres:alpine").start() - const databaseUrl = container.getConnectionUri() +const runDocker = (args: ReadonlyArray, options?: { stdio?: "pipe" | "ignore" }) => + execFileSync("docker", [...args], { + encoding: "utf8", + stdio: options?.stdio ?? "pipe", + }) - runDbPush(databaseUrl) +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) +const waitForPostgres = async (containerId: string) => { + const deadline = Date.now() + STARTUP_TIMEOUT_MS + + while (Date.now() < deadline) { + try { + runDocker(["exec", containerId, "pg_isready", "-U", POSTGRES_USER, "-d", POSTGRES_DB], { + stdio: "ignore", + }) + return + } catch { + await sleep(POLL_INTERVAL_MS) + } + } + + throw new Error(`Timed out waiting for postgres container ${containerId} to become ready`) +} + +const getPublishedPort = (containerId: string) => { + const portBinding = runDocker(["port", containerId, "5432/tcp"]).trim() + const hostPort = portBinding.split(":").at(-1) + + if (!hostPort) { + throw new Error(`Could not determine published port for postgres container ${containerId}`) + } + + return hostPort +} + +const ensureSchema = async (databaseUrl: string) => { const dbLayer = Database.layer({ url: Redacted.make(databaseUrl), ssl: false, }) - const run = (effect: Effect.Effect) => - Effect.runPromise((effect as Effect.Effect).pipe(Effect.provide(dbLayer), Effect.scoped)) + const existingTables = await Effect.runPromise( + Effect.gen(function* () { + const db = yield* Database.Database + return yield* db.execute((client) => + client.$client.unsafe( + "select tablename from pg_tables where schemaname = 'public' order by tablename", + ), + ) + }).pipe(Effect.provide(dbLayer), Effect.scoped), + ) - const reset = () => - run( - Effect.gen(function* () { - const db = yield* Database.Database - yield* db.execute((client) => client.$client.unsafe(TRUNCATE_SQL)) - }), - ) + const tableSet = new Set( + existingTables.flatMap((row) => + typeof row === "object" && row !== null && "tablename" in row + ? [String((row as unknown as { tablename: unknown }).tablename)] + : [], + ), + ) - const stop = async () => { - await container.stop() + const missingTables = REQUIRED_TABLES.filter((table) => !tableSet.has(table)) + if (missingTables.length === 0) { + return } - return { - container, - dbLayer, - run, - reset, - stop, + runDbPush(databaseUrl) + + const recheckedTables = await Effect.runPromise( + Effect.gen(function* () { + const db = yield* Database.Database + return yield* db.execute((client) => + client.$client.unsafe( + "select tablename from pg_tables where schemaname = 'public' order by tablename", + ), + ) + }).pipe(Effect.provide(dbLayer), Effect.scoped), + ) + + const recheckedSet = new Set( + recheckedTables.flatMap((row) => + typeof row === "object" && row !== null && "tablename" in row + ? [String((row as unknown as { tablename: unknown }).tablename)] + : [], + ), + ) + + const stillMissing = REQUIRED_TABLES.filter((table) => !recheckedSet.has(table)) + if (stillMissing.length > 0) { + throw new Error(`Chat sync test schema is incomplete: missing ${stillMissing.join(", ")}`) + } +} + +export const createChatSyncDbHarness = async (): Promise => { + const containerId = runDocker([ + "run", + "-d", + "--rm", + "-e", + `POSTGRES_USER=${POSTGRES_USER}`, + "-e", + `POSTGRES_PASSWORD=${POSTGRES_PASSWORD}`, + "-e", + `POSTGRES_DB=${POSTGRES_DB}`, + "-p", + "127.0.0.1::5432", + POSTGRES_IMAGE, + ]).trim() + + try { + await waitForPostgres(containerId) + const hostPort = getPublishedPort(containerId) + const databaseUrl = `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:${hostPort}/${POSTGRES_DB}?sslmode=disable` + + runDbPush(databaseUrl) + await ensureSchema(databaseUrl) + + const dbLayer = Database.layer({ + url: Redacted.make(databaseUrl), + ssl: false, + }) + + const run = (effect: Effect.Effect) => + Effect.runPromise( + (effect as Effect.Effect).pipe(Effect.provide(dbLayer), Effect.scoped), + ) + + const reset = () => + run( + Effect.gen(function* () { + const db = yield* Database.Database + yield* db.execute((client) => client.$client.unsafe(TRUNCATE_SQL)) + }), + ) + + const stop = async () => { + runDocker(["rm", "-f", containerId], { stdio: "ignore" }) + } + + return { + container: { + getConnectionUri: () => databaseUrl, + }, + dbLayer, + run, + reset, + stop, + } + } catch (error) { + runDocker(["rm", "-f", containerId], { stdio: "ignore" }) + throw error } } diff --git a/apps/backend/src/test/effect-helpers.ts b/apps/backend/src/test/effect-helpers.ts new file mode 100644 index 000000000..b834d29b4 --- /dev/null +++ b/apps/backend/src/test/effect-helpers.ts @@ -0,0 +1,7 @@ +import { ConfigProvider, ServiceMap } from "effect" + +export const serviceShape = (shape: unknown) => + shape as ServiceMap.Service.Shape + +export const configLayer = (values: Record) => + ConfigProvider.layer(ConfigProvider.fromUnknown(values)) diff --git a/apps/backend/src/test/message-outbox-repo.test.ts b/apps/backend/src/test/message-outbox-repo.test.ts index 61a47c67c..8b67609a5 100644 --- a/apps/backend/src/test/message-outbox-repo.test.ts +++ b/apps/backend/src/test/message-outbox-repo.test.ts @@ -6,22 +6,22 @@ import { Effect } from "effect" import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { createChatSyncDbHarness, type ChatSyncDbHarness } from "./chat-sync-db-harness" -const CHANNEL_ID = "00000000-0000-0000-0000-000000000001" as ChannelId -const AUTHOR_ID = "00000000-0000-0000-0000-000000000102" as UserId -const MESSAGE_ID_1 = "00000000-0000-0000-0000-000000000101" as MessageId -const MESSAGE_ID_2 = "00000000-0000-0000-0000-000000000103" as MessageId -const MESSAGE_ID_3 = "00000000-0000-0000-0000-000000000105" as MessageId -const MESSAGE_ID_4 = "00000000-0000-0000-0000-000000000106" as MessageId -const MESSAGE_ID_5 = "00000000-0000-0000-0000-000000000107" as MessageId -const MESSAGE_ID_6 = "00000000-0000-0000-0000-000000000109" as MessageId -const REACTION_ID = "00000000-0000-0000-0000-000000000104" as MessageReactionId -const REACTION_USER_ID = "00000000-0000-0000-0000-000000000108" as UserId -const SECOND_AUTHOR_ID = "00000000-0000-0000-0000-000000000110" as UserId +const CHANNEL_ID = "00000000-0000-4000-8000-000000000001" as ChannelId +const AUTHOR_ID = "00000000-0000-4000-8000-000000000102" as UserId +const MESSAGE_ID_1 = "00000000-0000-4000-8000-000000000101" as MessageId +const MESSAGE_ID_2 = "00000000-0000-4000-8000-000000000103" as MessageId +const MESSAGE_ID_3 = "00000000-0000-4000-8000-000000000105" as MessageId +const MESSAGE_ID_4 = "00000000-0000-4000-8000-000000000106" as MessageId +const MESSAGE_ID_5 = "00000000-0000-4000-8000-000000000107" as MessageId +const MESSAGE_ID_6 = "00000000-0000-4000-8000-000000000109" as MessageId +const REACTION_ID = "00000000-0000-4000-8000-000000000104" as MessageReactionId +const REACTION_USER_ID = "00000000-0000-4000-8000-000000000108" as UserId +const SECOND_AUTHOR_ID = "00000000-0000-4000-8000-000000000110" as UserId const uuid = () => randomUUID() const runRepoEffect = (harness: ChatSyncDbHarness, effect: Effect.Effect) => - harness.run(effect.pipe(Effect.provide(MessageOutboxRepo.Default))) + harness.run(effect.pipe(Effect.provide(MessageOutboxRepo.layer))) describe("MessageOutboxRepo", () => { let harness: ChatSyncDbHarness diff --git a/apps/bot-gateway/package.json b/apps/bot-gateway/package.json index d86cc6f4d..359640f88 100644 --- a/apps/bot-gateway/package.json +++ b/apps/bot-gateway/package.json @@ -18,7 +18,6 @@ "effect": "catalog:effect" }, "devDependencies": { - "@effect/language-service": "catalog:effect", "@types/bun": "1.3.9", "typescript": "^5.9.3" } diff --git a/apps/bot-gateway/src/index.test.ts b/apps/bot-gateway/src/index.test.ts index 9cc9f9efd..5a3e97e1d 100644 --- a/apps/bot-gateway/src/index.test.ts +++ b/apps/bot-gateway/src/index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test" -import { ConfigProvider, Effect, Either, Layer, Option, Redacted, Runtime } from "effect" +import { ConfigProvider, Effect, Layer, Option, Redacted, Result, ServiceMap } from "effect" import { createGatewayServer, GatewayStartupError, @@ -40,8 +40,8 @@ const makeHub = () => ), }) as any -const makeTestRuntime = async () => - (await Effect.runPromise(Effect.runtime())) as Runtime.Runtime +const makeTestServices = async () => + (await Effect.runPromise(Effect.services())) as ServiceMap.ServiceMap describe("bot-gateway startup", () => { it("maps config failures to GatewayStartupError", async () => { @@ -49,16 +49,16 @@ describe("bot-gateway startup", () => { Effect.scoped( Layer.build( InstrumentedConfigLive.pipe( - Layer.provide(Layer.setConfigProvider(ConfigProvider.fromMap(new Map()))), + Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({}))), ), - ).pipe(Effect.either), + ).pipe(Effect.result), ), ) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(result.left).toBeInstanceOf(GatewayStartupError) - expect(result.left.dependency).toBe("config") + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + expect(result.failure).toBeInstanceOf(GatewayStartupError) + expect((result.failure as GatewayStartupError).dependency).toBe("config") } }) @@ -66,21 +66,21 @@ describe("bot-gateway startup", () => { const result = await Effect.runPromise( Effect.scoped( Layer.build( - instrumentStartupLayer(Layer.fail(new Error("db unavailable")), { + instrumentStartupLayer(Layer.effectDiscard(Effect.fail(new Error("db unavailable"))), { dependency: "database", startMessage: "db start", successMessage: "db ok", failureMessage: "db failed", }), - ).pipe(Effect.either), + ).pipe(Effect.result), ), ) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(result.left).toBeInstanceOf(GatewayStartupError) - expect(result.left.dependency).toBe("database") - expect(result.left.message).toBe("db failed") + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + expect(result.failure).toBeInstanceOf(GatewayStartupError) + expect((result.failure as GatewayStartupError).dependency).toBe("database") + expect((result.failure as GatewayStartupError).message).toBe("db failed") } }) @@ -88,20 +88,20 @@ describe("bot-gateway startup", () => { const result = await Effect.runPromise( Effect.scoped( Layer.build( - instrumentStartupLayer(Layer.fail(new Error("redis unavailable")), { + instrumentStartupLayer(Layer.effectDiscard(Effect.fail(new Error("redis unavailable"))), { dependency: "redis", startMessage: "redis start", successMessage: "redis ok", failureMessage: "redis failed", }), - ).pipe(Effect.either), + ).pipe(Effect.result), ), ) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(result.left).toBeInstanceOf(GatewayStartupError) - expect(result.left.dependency).toBe("redis") + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + expect(result.failure).toBeInstanceOf(GatewayStartupError) + expect((result.failure as GatewayStartupError).dependency).toBe("redis") } }) @@ -109,47 +109,50 @@ describe("bot-gateway startup", () => { const result = await Effect.runPromise( Effect.scoped( Layer.build( - instrumentStartupLayer(Layer.fail(new Error("tracer unavailable")), { - dependency: "tracer", - startMessage: "tracer start", - successMessage: "tracer ok", - failureMessage: "tracer failed", - }), - ).pipe(Effect.either), + instrumentStartupLayer( + Layer.effectDiscard(Effect.fail(new Error("tracer unavailable"))), + { + dependency: "tracer", + startMessage: "tracer start", + successMessage: "tracer ok", + failureMessage: "tracer failed", + }, + ), + ).pipe(Effect.result), ), ) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(result.left).toBeInstanceOf(GatewayStartupError) - expect(result.left.dependency).toBe("tracer") + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + expect(result.failure).toBeInstanceOf(GatewayStartupError) + expect((result.failure as GatewayStartupError).dependency).toBe("tracer") } }) it("maps server bind failures to GatewayStartupError", async () => { - const runtime = await makeTestRuntime() + const services = await makeTestServices() const result = await Effect.runPromise( Effect.scoped( createGatewayServer({ config: TEST_CONFIG as any, hub: makeHub(), - runtime, + runtime: services, serve: () => { throw new Error("bind failed") }, - }).pipe(Effect.either), + }).pipe(Effect.result), ), ) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(result.left).toBeInstanceOf(GatewayStartupError) - expect(result.left.dependency).toBe("server") + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + expect(result.failure).toBeInstanceOf(GatewayStartupError) + expect((result.failure as GatewayStartupError).dependency).toBe("server") } }) it("starts the server with a fake serve function and wires handlers", async () => { - const runtime = await makeTestRuntime() + const services = await makeTestServices() let stopCalls = 0 let servedOptions: any = null @@ -158,7 +161,7 @@ describe("bot-gateway startup", () => { createGatewayServer({ config: TEST_CONFIG as any, hub: makeHub(), - runtime, + runtime: services, serve: (options: any) => { servedOptions = options return { diff --git a/apps/bot-gateway/src/index.ts b/apps/bot-gateway/src/index.ts index 8de33d00f..18e29f4ea 100644 --- a/apps/bot-gateway/src/index.ts +++ b/apps/bot-gateway/src/index.ts @@ -16,14 +16,13 @@ import { Cause, Config, ConfigProvider, - Context, Deferred, Effect, Layer, Option, Ref, - Runtime, Schema, + ServiceMap, } from "effect" import { TracerLive } from "./observability/tracer" @@ -120,24 +119,23 @@ const extractGatewayOp = (payload: string | BufferSource): string | undefined => } } -export class GatewayConfig extends Effect.Service()("GatewayConfig", { - accessors: true, - effect: Effect.gen(function* () { +export class GatewayConfig extends ServiceMap.Service()("GatewayConfig", { + make: Effect.gen(function* () { const config = { - port: yield* Config.integer("PORT").pipe(Config.withDefault(DEFAULT_PORT)), + port: yield* Config.int("PORT").pipe(Config.withDefault(DEFAULT_PORT)), isDev: yield* Config.boolean("IS_DEV").pipe(Config.withDefault(false)), databaseUrl: yield* Config.redacted("DATABASE_URL"), durableStreamsUrl: yield* Config.string("DURABLE_STREAMS_URL").pipe( Config.withDefault(DEFAULT_DURABLE_STREAMS_URL), ), durableStreamsToken: yield* Config.option(Config.string("DURABLE_STREAMS_TOKEN")), - heartbeatIntervalMs: yield* Config.integer("GATEWAY_HEARTBEAT_INTERVAL_MS").pipe( + heartbeatIntervalMs: yield* Config.int("GATEWAY_HEARTBEAT_INTERVAL_MS").pipe( Config.withDefault(DEFAULT_HEARTBEAT_INTERVAL_MS), ), - leaseTtlSeconds: yield* Config.integer("GATEWAY_LEASE_TTL_SECONDS").pipe( + leaseTtlSeconds: yield* Config.int("GATEWAY_LEASE_TTL_SECONDS").pipe( Config.withDefault(DEFAULT_LEASE_TTL_SECONDS), ), - batchAckTimeoutMs: yield* Config.integer("GATEWAY_BATCH_ACK_TIMEOUT_MS").pipe( + batchAckTimeoutMs: yield* Config.int("GATEWAY_BATCH_ACK_TIMEOUT_MS").pipe( Config.withDefault(DEFAULT_BATCH_ACK_TIMEOUT_MS), ), } as const @@ -147,23 +145,28 @@ export class GatewayConfig extends Effect.Service()("GatewayConfi }) return config }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} -class GatewayAuthError extends Schema.TaggedError()("GatewayAuthError", { +class GatewayAuthError extends Schema.TaggedErrorClass()("GatewayAuthError", { message: Schema.String, }) {} -class GatewayProtocolError extends Schema.TaggedError()("GatewayProtocolError", { +class GatewayProtocolError extends Schema.TaggedErrorClass()("GatewayProtocolError", { message: Schema.String, }) {} -export class GatewayStartupError extends Schema.TaggedError()("GatewayStartupError", { - dependency: Schema.Literal("config", "database", "redis", "tracer", "server"), - message: Schema.String, - cause: Schema.optional(Schema.Unknown), -}) {} +export class GatewayStartupError extends Schema.TaggedErrorClass()( + "GatewayStartupError", + { + dependency: Schema.Literals(["config", "database", "redis", "tracer", "server"]), + message: Schema.String, + cause: Schema.optional(Schema.Unknown), + }, +) {} -class DurableStreamGatewayError extends Schema.TaggedError()( +class DurableStreamGatewayError extends Schema.TaggedErrorClass()( "DurableStreamGatewayError", { message: Schema.String, @@ -191,9 +194,8 @@ interface GatewaySession { closed: boolean } -class DurableStreamClient extends Effect.Service()("DurableStreamClient", { - accessors: true, - effect: Effect.gen(function* () { +class DurableStreamClient extends ServiceMap.Service()("DurableStreamClient", { + make: Effect.gen(function* () { const config = yield* GatewayConfig const ensuredStreamsRef = yield* Ref.make(new Set()) const authHeaders: Record = Option.isSome(config.durableStreamsToken) @@ -300,7 +302,7 @@ class DurableStreamClient extends Effect.Service()("Durable cause, }), }) - const events = yield* Schema.decodeUnknown(Schema.Array(BotGatewayEnvelope))(raw).pipe( + const events = yield* Schema.decodeUnknownEffect(Schema.Array(BotGatewayEnvelope))(raw).pipe( Effect.mapError( (cause) => new DurableStreamGatewayError({ @@ -322,16 +324,17 @@ class DurableStreamClient extends Effect.Service()("Durable readBatch, } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} -class BotGatewayHub extends Effect.Service()("BotGatewayHub", { - accessors: true, - effect: Effect.gen(function* () { +class BotGatewayHub extends ServiceMap.Service()("BotGatewayHub", { + make: Effect.gen(function* () { const botRepo = yield* BotRepo const redis = yield* Redis const durableStreams = yield* DurableStreamClient const config = yield* GatewayConfig - const runtime = (yield* Effect.runtime()) as Runtime.Runtime + const runtime = (yield* Effect.services()) as ServiceMap.ServiceMap const sessionsRef = yield* Ref.make(new Map()) const sendFrame = ( @@ -473,11 +476,13 @@ class BotGatewayHub extends Effect.Service()("BotGatewayHub", { }) const ackResult = yield* Deferred.await(ackDeferred).pipe( - Effect.timeoutFail({ + Effect.timeoutOrElse({ onTimeout: () => - new GatewayProtocolError({ - message: `Timed out waiting for ACK from session ${id}`, - }), + Effect.fail( + new GatewayProtocolError({ + message: `Timed out waiting for ACK from session ${id}`, + }), + ), duration: config.batchAckTimeoutMs, }), ) @@ -492,7 +497,7 @@ class BotGatewayHub extends Effect.Service()("BotGatewayHub", { yield* annotateReconnectReason("durable_stream_unavailable") yield* Effect.logWarning("Gateway delivery loop failed", { error, sessionId: id }) }).pipe( - Effect.zipRight( + Effect.andThen( Effect.gen(function* () { const session = yield* getSession(id) if (session) { @@ -521,7 +526,7 @@ class BotGatewayHub extends Effect.Service()("BotGatewayHub", { sessionId: id, }) }).pipe( - Effect.zipRight( + Effect.andThen( Effect.gen(function* () { const session = yield* getSession(id) if (session) { @@ -606,7 +611,7 @@ class BotGatewayHub extends Effect.Service()("BotGatewayHub", { resumeOffset: frame.resumeOffset, }) - Runtime.runFork(runtime)(startDeliveryLoop(id)) + Effect.runForkWith(runtime)(startDeliveryLoop(id)) }) const onOpen = (socket: ServerWebSocket<{ sessionId: string | null }>) => @@ -747,9 +752,9 @@ class BotGatewayHub extends Effect.Service()("BotGatewayHub", { new GatewayProtocolError({ message: `Session ${id} closed before ACK`, }), - ).pipe(Effect.catchAll(() => Effect.void)) + ).pipe(Effect.catch(() => Effect.void)) } - yield* releaseLease(session.botId, id).pipe(Effect.catchAll(() => Effect.void)) + yield* releaseLease(session.botId, id).pipe(Effect.catch(() => Effect.void)) yield* deleteSession(id) }) @@ -761,9 +766,11 @@ class BotGatewayHub extends Effect.Service()("BotGatewayHub", { proxyRead: durableStreams.proxyRead, } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} -const DatabaseLive = Layer.unwrapEffect( +const DatabaseLive = Layer.unwrap( Effect.gen(function* () { const config = yield* GatewayConfig return Database.layer({ @@ -773,7 +780,7 @@ const DatabaseLive = Layer.unwrapEffect( }), ) -const ConfigProviderLive = Layer.setConfigProvider(ConfigProvider.fromEnv()) +const ConfigProviderLive = ConfigProvider.layer(ConfigProvider.fromEnv()) type StartupDependency = "config" | "database" | "redis" | "tracer" | "server" @@ -790,11 +797,11 @@ export const instrumentStartupLayer = ( readonly dependency: StartupDependency readonly startMessage: string readonly successMessage: string - readonly successLogs?: (context: Context.Context) => Record + readonly successLogs?: (context: ServiceMap.ServiceMap) => Record readonly failureMessage: string }, ) => - Layer.unwrapEffect( + Layer.unwrap( Effect.logInfo(options.startMessage).pipe( Effect.as( layer.pipe( @@ -804,26 +811,34 @@ export const instrumentStartupLayer = ( ? Effect.logInfo(options.successMessage, logs) : Effect.logInfo(options.successMessage) }), - Layer.tapErrorCause((cause) => + Layer.tapCause((cause) => Effect.logError(options.failureMessage, { dependency: options.dependency, cause: Cause.pretty(cause), }), ), - Layer.mapError((error) => - makeStartupError(options.dependency, options.failureMessage, error), + Layer.catchCause((cause) => + Layer.effectDiscard( + Effect.fail( + makeStartupError( + options.dependency, + options.failureMessage, + Cause.squash(cause), + ), + ), + ), ), ), ), ), ) -export const InstrumentedConfigLive = instrumentStartupLayer(GatewayConfig.Default, { +export const InstrumentedConfigLive = instrumentStartupLayer(GatewayConfig.layer, { dependency: "config", startMessage: "Loading gateway startup config...", successMessage: "Gateway startup config loaded", successLogs: (context) => { - const config = Context.get(context, GatewayConfig) + const config = ServiceMap.get(context, GatewayConfig) return { port: config.port, durableStreamsUrl: config.durableStreamsUrl, @@ -857,9 +872,9 @@ export const InstrumentedTracerLive = instrumentStartupLayer(TracerLive, { failureMessage: "Bot gateway tracer layer initialization failed", }) -const RepoLive = Layer.mergeAll(BotRepo.Default).pipe(Layer.provide(InstrumentedDatabaseLive)) -const DurableStreamClientLive = DurableStreamClient.Default.pipe(Layer.provide(InstrumentedConfigLive)) -const BotGatewayHubLive = BotGatewayHub.Default.pipe( +const RepoLive = Layer.mergeAll(BotRepo.layer).pipe(Layer.provide(InstrumentedDatabaseLive)) +const DurableStreamClientLive = DurableStreamClient.layer.pipe(Layer.provide(InstrumentedConfigLive)) +const BotGatewayHubLive = BotGatewayHub.layer.pipe( Layer.provideMerge(InstrumentedConfigLive), Layer.provideMerge(InstrumentedRedisLive), Layer.provideMerge(RepoLive), @@ -882,9 +897,9 @@ type GatewayServe = (options: any) => { } export const createGatewayServer = (options: { - readonly config: Context.Tag.Service - readonly hub: Context.Tag.Service - readonly runtime: Runtime.Runtime + readonly config: (typeof GatewayConfig)["Service"] + readonly hub: (typeof BotGatewayHub)["Service"] + readonly runtime: ServiceMap.ServiceMap readonly serve?: GatewayServe }) => Effect.gen(function* () { @@ -955,7 +970,7 @@ export const createGatewayServer = (options: { } if (request.method === "GET" && url.pathname === "/bot-gateway/ws") { - return Runtime.runPromise(runtime)( + return Effect.runPromiseWith(runtime)( Effect.gen(function* () { yield* annotateHttpRequest("/bot-gateway/ws", request.method) yield* Effect.logInfo( @@ -984,14 +999,14 @@ export const createGatewayServer = (options: { } if (request.method === "GET" && url.pathname === "/bot-gateway/stream") { - return Runtime.runPromise(runtime)(handleStreamProxyRequest(request, url)) + return Effect.runPromiseWith(runtime)(handleStreamProxyRequest(request, url)) } return new Response("Not found", { status: 404 }) }, websocket: { open(socket: ServerWebSocket<{ sessionId: string | null }>) { - Runtime.runFork(runtime)( + Effect.runForkWith(runtime)( Effect.gen(function* () { yield* Effect.annotateCurrentSpan("http.route", "/bot-gateway/ws") yield* hub.onOpen(socket) @@ -1006,7 +1021,7 @@ export const createGatewayServer = (options: { message: string | BufferSource, ) { const op = extractGatewayOp(message) - Runtime.runFork(runtime)( + Effect.runForkWith(runtime)( Effect.gen(function* () { yield* Effect.annotateCurrentSpan("http.route", "/bot-gateway/ws") yield* annotateSessionContext({ @@ -1024,7 +1039,7 @@ export const createGatewayServer = (options: { ) }, close(socket: ServerWebSocket<{ sessionId: string | null }>) { - Runtime.runFork(runtime)( + Effect.runForkWith(runtime)( Effect.gen(function* () { yield* Effect.annotateCurrentSpan("http.route", "/bot-gateway/ws") yield* annotateSessionContext({ @@ -1059,7 +1074,7 @@ export const makeProgram = (options?: { Effect.gen(function* () { const config = yield* GatewayConfig const hub = yield* BotGatewayHub - const runtime = (yield* Effect.runtime()) as Runtime.Runtime + const runtime = (yield* Effect.services()) as ServiceMap.ServiceMap yield* createGatewayServer({ config, diff --git a/apps/cluster/package.json b/apps/cluster/package.json index 3d3bbbd31..51bbb568c 100644 --- a/apps/cluster/package.json +++ b/apps/cluster/package.json @@ -9,14 +9,9 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@effect/ai": "catalog:effect", - "@effect/cluster": "catalog:effect", - "@effect/experimental": "catalog:effect", - "@effect/platform": "catalog:effect", + "@effect/ai-openrouter": "catalog:effect", "@effect/platform-bun": "catalog:effect", "@effect/sql-pg": "catalog:effect", - "@effect/workflow": "catalog:effect", - "@hazel/ai-openrouter": "workspace:*", "@hazel/backend-core": "workspace:*", "@hazel/db": "workspace:*", "@hazel/domain": "workspace:*", diff --git a/apps/cluster/src/cron/presence-cleanup-cron.ts b/apps/cluster/src/cron/presence-cleanup-cron.ts index bce73cb49..3aba0bdcc 100644 --- a/apps/cluster/src/cron/presence-cleanup-cron.ts +++ b/apps/cluster/src/cron/presence-cleanup-cron.ts @@ -1,11 +1,11 @@ -import * as ClusterCron from "@effect/cluster/ClusterCron" +import * as ClusterCron from "effect/unstable/cluster/ClusterCron" import { and, Database, inArray, lt, ne, schema } from "@hazel/db" import * as Cron from "effect/Cron" import * as Duration from "effect/Duration" import * as Effect from "effect/Effect" // Run every 15 seconds (6-field cron: seconds minutes hours day month weekday) -const every15Seconds = Cron.unsafeParse("*/15 * * * * *") +const every15Seconds = Cron.parseUnsafe("*/15 * * * * *") // Timeout: 45 seconds (3x heartbeat interval of 15s) const HEARTBEAT_TIMEOUT_MS = 45_000 diff --git a/apps/cluster/src/cron/rss-poll-cron.ts b/apps/cluster/src/cron/rss-poll-cron.ts index 070af1a32..bf261b646 100644 --- a/apps/cluster/src/cron/rss-poll-cron.ts +++ b/apps/cluster/src/cron/rss-poll-cron.ts @@ -1,5 +1,5 @@ -import * as ClusterCron from "@effect/cluster/ClusterCron" -import { WorkflowEngine } from "@effect/workflow" +import * as ClusterCron from "effect/unstable/cluster/ClusterCron" +import { WorkflowEngine } from "effect/unstable/workflow" import { and, Database, eq, isNull, lt, schema, sql } from "@hazel/db" import { Cluster } from "@hazel/domain" import * as Cron from "effect/Cron" @@ -8,7 +8,7 @@ import * as Duration from "effect/Duration" import * as Effect from "effect/Effect" // Run every 5 minutes -const everyFiveMinutes = Cron.unsafeParse("*/5 * * * *") +const everyFiveMinutes = Cron.parseUnsafe("*/5 * * * *") /** * Cron job that polls RSS feeds due for a refresh. @@ -81,7 +81,7 @@ export const RssPollCronLayer = ClusterCron.make({ discard: true, }) .pipe( - Effect.catchAll((err) => + Effect.catch((err) => Effect.gen(function* () { yield* Effect.logWarning( `Failed to execute RssFeedPollWorkflow for subscription ${sub.id}`, @@ -104,7 +104,7 @@ export const RssPollCronLayer = ClusterCron.make({ .where(eq(schema.rssSubscriptionsTable.id, sub.id)), ) .pipe( - Effect.catchAll((dbErr) => + Effect.catch((dbErr) => Effect.logWarning("Failed to increment RSS error counter", { subscriptionId: sub.id, error: String(dbErr), diff --git a/apps/cluster/src/cron/status-expiration-cron.ts b/apps/cluster/src/cron/status-expiration-cron.ts index 95258847b..4d34ba3bd 100644 --- a/apps/cluster/src/cron/status-expiration-cron.ts +++ b/apps/cluster/src/cron/status-expiration-cron.ts @@ -1,11 +1,11 @@ -import * as ClusterCron from "@effect/cluster/ClusterCron" +import * as ClusterCron from "effect/unstable/cluster/ClusterCron" import { and, Database, isNotNull, lt, schema } from "@hazel/db" import * as Cron from "effect/Cron" import * as Duration from "effect/Duration" import * as Effect from "effect/Effect" // Run every minute -const everyMinute = Cron.unsafeParse("* * * * *") +const everyMinute = Cron.parseUnsafe("* * * * *") /** * Cron job that clears expired custom statuses. diff --git a/apps/cluster/src/cron/typing-indicator-cleanup-cron.ts b/apps/cluster/src/cron/typing-indicator-cleanup-cron.ts index a68a3e7fb..1037c6bb2 100644 --- a/apps/cluster/src/cron/typing-indicator-cleanup-cron.ts +++ b/apps/cluster/src/cron/typing-indicator-cleanup-cron.ts @@ -1,10 +1,10 @@ -import * as ClusterCron from "@effect/cluster/ClusterCron" +import * as ClusterCron from "effect/unstable/cluster/ClusterCron" import { Database, lt, schema } from "@hazel/db" import * as Cron from "effect/Cron" import * as Duration from "effect/Duration" import * as Effect from "effect/Effect" -const every5Seconds = Cron.unsafeParse("*/5 * * * * *") +const every5Seconds = Cron.parseUnsafe("*/5 * * * * *") const TYPING_INDICATOR_STALE_MS = 12_000 /** diff --git a/apps/cluster/src/cron/upload-cleanup-cron.ts b/apps/cluster/src/cron/upload-cleanup-cron.ts index 3b8558e69..4ebfca675 100644 --- a/apps/cluster/src/cron/upload-cleanup-cron.ts +++ b/apps/cluster/src/cron/upload-cleanup-cron.ts @@ -1,11 +1,11 @@ -import * as ClusterCron from "@effect/cluster/ClusterCron" +import * as ClusterCron from "effect/unstable/cluster/ClusterCron" import { and, Database, eq, isNull, lt, schema } from "@hazel/db" import * as Cron from "effect/Cron" import * as Duration from "effect/Duration" import * as Effect from "effect/Effect" // Run daily at 3 AM -const dailyAt3AM = Cron.unsafeParse("0 3 * * *") +const dailyAt3AM = Cron.parseUnsafe("0 3 * * *") // Max age for uploads to be considered stale (1 hour) const MAX_AGE_MINUTES = 60 diff --git a/apps/cluster/src/cron/workos-sync-cron.ts b/apps/cluster/src/cron/workos-sync-cron.ts index 74bcdf6f4..a35b5507b 100644 --- a/apps/cluster/src/cron/workos-sync-cron.ts +++ b/apps/cluster/src/cron/workos-sync-cron.ts @@ -1,17 +1,18 @@ -import * as ClusterCron from "@effect/cluster/ClusterCron" +import * as ClusterCron from "effect/unstable/cluster/ClusterCron" import { WorkOSSync } from "@hazel/backend-core/services" import * as Cron from "effect/Cron" import * as Duration from "effect/Duration" import * as Effect from "effect/Effect" -const workOsCron = Cron.unsafeParse("0 */12 * * *") +const workOsCron = Cron.parseUnsafe("0 */12 * * *") export const WorkOSSyncCronLayer = ClusterCron.make({ name: "WorkOSSync", cron: workOsCron, execute: Effect.gen(function* () { yield* Effect.logDebug("Starting scheduled WorkOS sync...") - const result = yield* WorkOSSync.syncAll + const workOSSync = yield* WorkOSSync + const result = yield* workOSSync.syncAll yield* Effect.annotateCurrentSpan("cron.duration_ms", result.endTime - result.startTime) yield* Effect.annotateCurrentSpan("cron.total_errors", result.totalErrors) yield* Effect.logDebug("WorkOS sync completed", { diff --git a/apps/cluster/src/index.ts b/apps/cluster/src/index.ts index 57f2f7613..a2ee0b555 100644 --- a/apps/cluster/src/index.ts +++ b/apps/cluster/src/index.ts @@ -1,8 +1,9 @@ -import { ClusterWorkflowEngine } from "@effect/cluster" -import { HttpApiBuilder, HttpMiddleware, HttpServer } from "@effect/platform" +import { ClusterWorkflowEngine } from "effect/unstable/cluster" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http" import { BunClusterSocket, BunHttpServer, BunRuntime } from "@effect/platform-bun" import { PgClient } from "@effect/sql-pg" -import { WorkflowProxyServer } from "@effect/workflow" +import { WorkflowProxyServer } from "effect/unstable/workflow" import { InvitationRepo, OrganizationMemberRepo, @@ -67,12 +68,12 @@ const AllWorkflows = Layer.mergeAll( // WorkOSSync dependencies layer for cron job // Build the layer manually to ensure Database is provided to all deps -const WorkOSSyncLive = WorkOSSync.Default.pipe( - Layer.provide(WorkOSClient.Default), - Layer.provide(UserRepo.Default), - Layer.provide(OrganizationRepo.Default), - Layer.provide(OrganizationMemberRepo.Default), - Layer.provide(InvitationRepo.Default), +const WorkOSSyncLive = WorkOSSync.layer.pipe( + Layer.provide(WorkOSClient.layer), + Layer.provide(UserRepo.layer), + Layer.provide(OrganizationRepo.layer), + Layer.provide(OrganizationMemberRepo.layer), + Layer.provide(InvitationRepo.layer), Layer.provide(DatabaseLayer), ) @@ -87,23 +88,26 @@ const AllCronJobs = Layer.mergeAll( ).pipe(Layer.provide(WorkflowEngineLayer)) // Workflow API implementation -const WorkflowApiLive = HttpApiBuilder.api(Cluster.WorkflowApi).pipe( +const WorkflowApiLive = HttpApiBuilder.layer(Cluster.WorkflowApi).pipe( Layer.provide(WorkflowProxyServer.layerHttpApi(Cluster.WorkflowApi, "workflows", Cluster.workflows)), Layer.provide(HealthLive), - HttpServer.withLogAddress, ) -// Main server layer with CORS enabled -const ServerLayer = HttpApiBuilder.serve( - HttpMiddleware.cors({ - allowedOrigins: ["http://localhost:3000", "https://app.hazel.sh"], - credentials: true, - }), -).pipe( - Layer.provide(WorkflowApiLive), +// All routes with CORS +const AllRoutes = Layer.mergeAll(WorkflowApiLive).pipe( + Layer.provide( + HttpRouter.cors({ + allowedOrigins: ["http://localhost:3000", "https://app.hazel.sh"], + credentials: true, + }), + ), +) + +// Main server layer +const ServerLayer = HttpRouter.serve(AllRoutes).pipe( Layer.provide(AllWorkflows), Layer.provide(AllCronJobs), - Layer.provide(Logger.pretty), + Layer.provide(Logger.layer([Logger.consolePretty()])), Layer.provide( BunHttpServer.layerConfig( Config.all({ @@ -115,6 +119,6 @@ const ServerLayer = HttpApiBuilder.serve( ), ) -Layer.launch(ServerLayer.pipe(Layer.provide(WorkflowEngineLayer), Layer.provide(TracerLive))).pipe( +ServerLayer.pipe(Layer.provide(WorkflowEngineLayer), Layer.provide(TracerLive), Layer.launch).pipe( BunRuntime.runMain, ) diff --git a/apps/cluster/src/services/bot-user-service.ts b/apps/cluster/src/services/bot-user-service.ts index 1dd4eb2bd..3a54b11b2 100644 --- a/apps/cluster/src/services/bot-user-service.ts +++ b/apps/cluster/src/services/bot-user-service.ts @@ -1,7 +1,7 @@ import { Database, eq, schema } from "@hazel/db" import { Cluster, Integrations } from "@hazel/domain" import type { UserId } from "@hazel/schema" -import { Array, Effect, Layer, Option } from "effect" +import { ServiceMap, Array, Effect, Layer, Option } from "effect" /** * Service for cached bot user lookups. @@ -10,9 +10,8 @@ import { Array, Effect, Layer, Option } from "effect" * This service caches the bot user ID at initialization to avoid * repeated database queries on every webhook. */ -export class BotUserService extends Effect.Service()("BotUserService", { - accessors: true, - effect: Effect.gen(function* () { +export class BotUserService extends ServiceMap.Service()("BotUserService", { + make: Effect.gen(function* () { const db = yield* Database.Database // Cache for bot user IDs by external ID @@ -117,9 +116,11 @@ export class BotUserService extends Effect.Service()("BotUserSer warmCache, } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} /** * Layer that provides BotUserService with Database dependency. */ -export const BotUserServiceLive = BotUserService.Default +export const BotUserServiceLive = BotUserService.layer diff --git a/apps/cluster/src/services/openrouter-service.ts b/apps/cluster/src/services/openrouter-service.ts index 4f5988beb..7bc29a295 100644 --- a/apps/cluster/src/services/openrouter-service.ts +++ b/apps/cluster/src/services/openrouter-service.ts @@ -1,12 +1,12 @@ -import { OpenRouterClient, OpenRouterLanguageModel } from "@hazel/ai-openrouter" -import { FetchHttpClient } from "@effect/platform" +import { OpenRouterClient, OpenRouterLanguageModel } from "@effect/ai-openrouter" +import { FetchHttpClient } from "effect/unstable/http" import { Config, Layer } from "effect" // OpenRouter configuration from environment const OpenRouterClientLayer = OpenRouterClient.layerConfig({ apiKey: Config.redacted("OPENROUTER_API_KEY"), - referrer: Config.string("APP_URL").pipe(Config.withDefault("https://app.hazel.sh")), - title: Config.string("APP_NAME").pipe(Config.withDefault("Hazel")), + siteReferrer: Config.string("APP_URL").pipe(Config.withDefault("https://app.hazel.sh")), + siteTitle: Config.string("APP_NAME").pipe(Config.withDefault("Hazel")), }).pipe(Layer.provide(FetchHttpClient.layer)) const MODEL = "anthropic/claude-3.5-haiku" diff --git a/apps/cluster/src/workflows/cleanup-uploads-handler.ts b/apps/cluster/src/workflows/cleanup-uploads-handler.ts index 09998c42e..15e20bc1e 100644 --- a/apps/cluster/src/workflows/cleanup-uploads-handler.ts +++ b/apps/cluster/src/workflows/cleanup-uploads-handler.ts @@ -1,4 +1,4 @@ -import { Activity } from "@effect/workflow" +import { Activity } from "effect/unstable/workflow" import { and, Database, eq, isNull, lt, schema } from "@hazel/db" import { Cluster } from "@hazel/domain" import type { AttachmentId } from "@hazel/schema" @@ -7,105 +7,44 @@ import { Effect } from "effect" const DEFAULT_MAX_AGE_MINUTES = 10 export const CleanupUploadsWorkflowLayer = Cluster.CleanupUploadsWorkflow.toLayer( - Effect.fn("workflow.CleanupUploads")(function* (payload: Cluster.CleanupUploadsWorkflowPayload) { + Effect.fn("workflow.CleanupUploads")(function* ( + payload: Cluster.CleanupUploadsWorkflowPayload, + _executionId: string, + ) { const maxAgeMinutes = payload.maxAgeMinutes ?? DEFAULT_MAX_AGE_MINUTES yield* Effect.annotateCurrentSpan("workflow.max_age_minutes", maxAgeMinutes) yield* Effect.logDebug(`Starting CleanupUploadsWorkflow (maxAgeMinutes: ${maxAgeMinutes})`) - const staleUploadsResult = yield* Activity.make({ - name: "FindStaleUploads", - success: Cluster.FindStaleUploadsResult, - error: Cluster.FindStaleUploadsError, - execute: Effect.gen(function* () { - const db = yield* Database.Database - - yield* Effect.logDebug( - `Finding attachments in 'uploading' status older than ${maxAgeMinutes} minutes`, - ) - - const cutoffTime = new Date(Date.now() - maxAgeMinutes * 60 * 1000) - - const staleUploads = yield* db - .execute((client) => - client - .select({ - id: schema.attachmentsTable.id, - fileName: schema.attachmentsTable.fileName, - uploadedAt: schema.attachmentsTable.uploadedAt, - }) - .from(schema.attachmentsTable) - .where( - and( - eq(schema.attachmentsTable.status, "uploading"), - lt(schema.attachmentsTable.uploadedAt, cutoffTime), - isNull(schema.attachmentsTable.deletedAt), - ), - ), - ) - .pipe( - Effect.catchTags({ - DatabaseError: (err) => - Effect.fail( - new Cluster.FindStaleUploadsError({ - message: "Failed to find stale uploads", - cause: err, - }), - ), - }), - ) + const staleUploadsResult = yield* Effect.gen(function* () { + return yield* Activity.make({ + name: "FindStaleUploads", + success: Cluster.FindStaleUploadsResult, + error: Cluster.FindStaleUploadsError, + execute: Effect.gen(function* () { + const db = yield* Database.Database - const uploads = staleUploads.map((upload) => ({ - id: upload.id, - fileName: upload.fileName, - uploadedAt: upload.uploadedAt, - ageMinutes: Math.floor((Date.now() - upload.uploadedAt.getTime()) / (60 * 1000)), - })) - - yield* Effect.annotateCurrentSpan("activity.stale_count", uploads.length) - yield* Effect.logDebug(`Found ${uploads.length} stale uploads`) - - return { - uploads, - totalCount: uploads.length, - } - }), - }).pipe( - Effect.tapError((err) => - Effect.logError("FindStaleUploads activity failed", { - errorTag: err._tag, - retryable: err.retryable, - }), - ), - ) - - // If no stale uploads found, we're done - if (staleUploadsResult.totalCount === 0) { - yield* Effect.logDebug("No stale uploads found, workflow complete") - return - } - - const markFailedResult = yield* Activity.make({ - name: "MarkUploadsFailed", - success: Cluster.MarkUploadsFailedResult, - error: Cluster.MarkUploadsFailedError, - execute: Effect.gen(function* () { - const db = yield* Database.Database - const failedIds: AttachmentId[] = [] + yield* Effect.logDebug( + `Finding attachments in 'uploading' status older than ${maxAgeMinutes} minutes`, + ) - yield* Effect.logDebug(`Marking ${staleUploadsResult.uploads.length} uploads as failed`) + const cutoffTime = new Date(Date.now() - maxAgeMinutes * 60 * 1000) - for (const upload of staleUploadsResult.uploads) { - yield* db + const staleUploads = yield* db .execute((client) => client - .update(schema.attachmentsTable) - .set({ status: "failed" }) + .select({ + id: schema.attachmentsTable.id, + fileName: schema.attachmentsTable.fileName, + uploadedAt: schema.attachmentsTable.uploadedAt, + }) + .from(schema.attachmentsTable) .where( and( - eq(schema.attachmentsTable.id, upload.id), eq(schema.attachmentsTable.status, "uploading"), + lt(schema.attachmentsTable.uploadedAt, cutoffTime), + isNull(schema.attachmentsTable.deletedAt), ), ), ) @@ -113,29 +52,97 @@ export const CleanupUploadsWorkflowLayer = Cluster.CleanupUploadsWorkflow.toLaye Effect.catchTags({ DatabaseError: (err) => Effect.fail( - new Cluster.MarkUploadsFailedError({ - message: `Failed to mark upload ${upload.id} as failed`, + new Cluster.FindStaleUploadsError({ + message: "Failed to find stale uploads", cause: err, }), ), }), ) - failedIds.push(upload.id) - yield* Effect.logDebug( - `Marked attachment ${upload.id} (${upload.fileName}) as failed (age: ${upload.ageMinutes}min)`, - ) - } + const uploads = staleUploads.map((upload) => ({ + id: upload.id, + fileName: upload.fileName, + uploadedAt: upload.uploadedAt, + ageMinutes: Math.floor((Date.now() - upload.uploadedAt.getTime()) / (60 * 1000)), + })) - yield* Effect.annotateCurrentSpan("activity.marked_count", failedIds.length) + yield* Effect.annotateCurrentSpan("activity.stale_count", uploads.length) + yield* Effect.logDebug(`Found ${uploads.length} stale uploads`) - return { - markedCount: failedIds.length, - failedIds, - } - }), + return { + uploads, + totalCount: uploads.length, + } + }), + }) + }).pipe( + Effect.tapError((err: { readonly _tag: string; readonly retryable?: boolean }) => + Effect.logError("FindStaleUploads activity failed", { + errorTag: err._tag, + retryable: err.retryable, + }), + ), + ) + + // If no stale uploads found, we're done + if (staleUploadsResult.totalCount === 0) { + yield* Effect.logDebug("No stale uploads found, workflow complete") + return + } + + const markFailedResult = yield* Effect.gen(function* () { + return yield* Activity.make({ + name: "MarkUploadsFailed", + success: Cluster.MarkUploadsFailedResult, + error: Cluster.MarkUploadsFailedError, + execute: Effect.gen(function* () { + const db = yield* Database.Database + const failedIds: AttachmentId[] = [] + + yield* Effect.logDebug(`Marking ${staleUploadsResult.uploads.length} uploads as failed`) + + for (const upload of staleUploadsResult.uploads) { + yield* db + .execute((client) => + client + .update(schema.attachmentsTable) + .set({ status: "failed" }) + .where( + and( + eq(schema.attachmentsTable.id, upload.id), + eq(schema.attachmentsTable.status, "uploading"), + ), + ), + ) + .pipe( + Effect.catchTags({ + DatabaseError: (err) => + Effect.fail( + new Cluster.MarkUploadsFailedError({ + message: `Failed to mark upload ${upload.id} as failed`, + cause: err, + }), + ), + }), + ) + + failedIds.push(upload.id) + yield* Effect.logDebug( + `Marked attachment ${upload.id} (${upload.fileName}) as failed (age: ${upload.ageMinutes}min)`, + ) + } + + yield* Effect.annotateCurrentSpan("activity.marked_count", failedIds.length) + + return { + markedCount: failedIds.length, + failedIds, + } + }), + }) }).pipe( - Effect.tapError((err) => + Effect.tapError((err: { readonly _tag: string; readonly retryable?: boolean }) => Effect.logError("MarkUploadsFailed activity failed", { errorTag: err._tag, retryable: err.retryable, diff --git a/apps/cluster/src/workflows/github-installation-handler.ts b/apps/cluster/src/workflows/github-installation-handler.ts index 4a2051d02..0894622a6 100644 --- a/apps/cluster/src/workflows/github-installation-handler.ts +++ b/apps/cluster/src/workflows/github-installation-handler.ts @@ -1,10 +1,13 @@ -import { Activity } from "@effect/workflow" +import { Activity } from "effect/unstable/workflow" import { and, Database, eq, isNull, schema, sql } from "@hazel/db" import { Cluster } from "@hazel/domain" import { Effect } from "effect" export const GitHubInstallationWorkflowLayer = Cluster.GitHubInstallationWorkflow.toLayer( - Effect.fn("workflow.GitHubInstallation")(function* (payload: Cluster.GitHubInstallationWorkflowPayload) { + Effect.fn("workflow.GitHubInstallation")(function* ( + payload: Cluster.GitHubInstallationWorkflowPayload, + _executionId: string, + ) { yield* Effect.annotateCurrentSpan("workflow.action", payload.action) yield* Effect.annotateCurrentSpan("workflow.installation_id", payload.installationId) yield* Effect.annotateCurrentSpan("workflow.account_login", payload.accountLogin) @@ -22,72 +25,77 @@ export const GitHubInstallationWorkflowLayer = Cluster.GitHubInstallationWorkflo } // Activity 1: Find the connection by installation ID - const connectionResult = yield* Activity.make({ - name: "FindConnectionByInstallationId", - success: Cluster.FindConnectionByInstallationResult, - error: Cluster.FindConnectionByInstallationError, - execute: Effect.gen(function* () { - const db = yield* Database.Database - - yield* Effect.logDebug(`Querying connection for installation ID ${payload.installationId}`) - - // Query for a connection with matching installationId in metadata - const connections = yield* db - .execute((client) => - client - .select({ - id: schema.integrationConnectionsTable.id, - organizationId: schema.integrationConnectionsTable.organizationId, - status: schema.integrationConnectionsTable.status, - externalAccountName: schema.integrationConnectionsTable.externalAccountName, - }) - .from(schema.integrationConnectionsTable) - .where( - and( - eq(schema.integrationConnectionsTable.provider, "github"), - sql`${schema.integrationConnectionsTable.metadata}->>'installationId' = ${String(payload.installationId)}`, - isNull(schema.integrationConnectionsTable.deletedAt), - ), - ), + const connectionResult = yield* Effect.gen(function* () { + return yield* Activity.make({ + name: "FindConnectionByInstallationId", + success: Cluster.FindConnectionByInstallationResult, + error: Cluster.FindConnectionByInstallationError, + execute: Effect.gen(function* () { + const db = yield* Database.Database + + yield* Effect.logDebug( + `Querying connection for installation ID ${payload.installationId}`, ) - .pipe( - Effect.catchTags({ - DatabaseError: (err) => - Effect.fail( - new Cluster.FindConnectionByInstallationError({ - installationId: payload.installationId, - message: "Failed to query GitHub connection", - cause: err, - }), + + // Query for a connection with matching installationId in metadata + const connections = yield* db + .execute((client) => + client + .select({ + id: schema.integrationConnectionsTable.id, + organizationId: schema.integrationConnectionsTable.organizationId, + status: schema.integrationConnectionsTable.status, + externalAccountName: + schema.integrationConnectionsTable.externalAccountName, + }) + .from(schema.integrationConnectionsTable) + .where( + and( + eq(schema.integrationConnectionsTable.provider, "github"), + sql`${schema.integrationConnectionsTable.metadata}->>'installationId' = ${String(payload.installationId)}`, + isNull(schema.integrationConnectionsTable.deletedAt), + ), ), - }), - ) + ) + .pipe( + Effect.catchTags({ + DatabaseError: (err) => + Effect.fail( + new Cluster.FindConnectionByInstallationError({ + installationId: payload.installationId, + message: "Failed to query GitHub connection", + cause: err, + }), + ), + }), + ) + + if (connections.length === 0) { + yield* Effect.logDebug( + `No connection found for installation ID ${payload.installationId}`, + ) + return { connections: [], totalCount: 0 } + } + + yield* Effect.annotateCurrentSpan("activity.connection_count", connections.length) - if (connections.length === 0) { yield* Effect.logDebug( - `No connection found for installation ID ${payload.installationId}`, + `Found ${connections.length} connection(s) for installation ${payload.installationId}`, ) - return { connections: [], totalCount: 0 } - } - - yield* Effect.annotateCurrentSpan("activity.connection_count", connections.length) - - yield* Effect.logDebug( - `Found ${connections.length} connection(s) for installation ${payload.installationId}`, - ) - - return { - connections: connections.map((connection) => ({ - id: connection.id, - organizationId: connection.organizationId, - status: connection.status, - externalAccountName: connection.externalAccountName, - })), - totalCount: connections.length, - } - }), + + return { + connections: connections.map((connection) => ({ + id: connection.id, + organizationId: connection.organizationId, + status: connection.status, + externalAccountName: connection.externalAccountName, + })), + totalCount: connections.length, + } + }), + }) }).pipe( - Effect.tapError((err) => + Effect.tapError((err: { readonly _tag: string; readonly retryable?: boolean }) => Effect.logError("FindConnectionByInstallationId activity failed", { errorTag: err._tag, retryable: err.retryable, @@ -108,72 +116,74 @@ export const GitHubInstallationWorkflowLayer = Cluster.GitHubInstallationWorkflo payload.action === "deleted" ? "revoked" : payload.action === "suspend" ? "suspended" : "active" // unsuspend // Activity 2: Update the connection status - const updateResult = yield* Activity.make({ - name: "UpdateConnectionStatus", - success: Cluster.UpdateConnectionStatusResult, - error: Cluster.UpdateConnectionStatusError, - execute: Effect.gen(function* () { - const db = yield* Database.Database - - yield* Effect.logDebug( - `Updating ${connectionResult.totalCount} connection(s) status to '${newStatus}' for installation ${payload.installationId}`, - ) - - // For "deleted" action, also set deletedAt - const updateValues = - payload.action === "deleted" - ? { - status: newStatus as "revoked", - deletedAt: new Date(), - updatedAt: new Date(), - } - : { - status: newStatus as "active" | "suspended", - updatedAt: new Date(), - } - - const updated = yield* db - .execute((client) => - client - .update(schema.integrationConnectionsTable) - .set(updateValues) - .where( - and( - eq(schema.integrationConnectionsTable.provider, "github"), - sql`${schema.integrationConnectionsTable.metadata}->>'installationId' = ${String(payload.installationId)}`, - isNull(schema.integrationConnectionsTable.deletedAt), - ), - ) - .returning({ id: schema.integrationConnectionsTable.id }), - ) - .pipe( - Effect.catchTags({ - DatabaseError: (err) => - Effect.fail( - new Cluster.UpdateConnectionStatusError({ - installationId: payload.installationId, - message: "Failed to update connection status", - cause: err, - }), - ), - }), + const updateResult = yield* Effect.gen(function* () { + return yield* Activity.make({ + name: "UpdateConnectionStatus", + success: Cluster.UpdateConnectionStatusResult, + error: Cluster.UpdateConnectionStatusError, + execute: Effect.gen(function* () { + const db = yield* Database.Database + + yield* Effect.logDebug( + `Updating ${connectionResult.totalCount} connection(s) status to '${newStatus}' for installation ${payload.installationId}`, ) - yield* Effect.annotateCurrentSpan("activity.new_status", newStatus) - yield* Effect.annotateCurrentSpan("activity.updated_count", updated.length) + // For "deleted" action, also set deletedAt + const updateValues = + payload.action === "deleted" + ? { + status: newStatus as "revoked", + deletedAt: new Date(), + updatedAt: new Date(), + } + : { + status: newStatus as "active" | "suspended", + updatedAt: new Date(), + } + + const updated = yield* db + .execute((client) => + client + .update(schema.integrationConnectionsTable) + .set(updateValues) + .where( + and( + eq(schema.integrationConnectionsTable.provider, "github"), + sql`${schema.integrationConnectionsTable.metadata}->>'installationId' = ${String(payload.installationId)}`, + isNull(schema.integrationConnectionsTable.deletedAt), + ), + ) + .returning({ id: schema.integrationConnectionsTable.id }), + ) + .pipe( + Effect.catchTags({ + DatabaseError: (err) => + Effect.fail( + new Cluster.UpdateConnectionStatusError({ + installationId: payload.installationId, + message: "Failed to update connection status", + cause: err, + }), + ), + }), + ) + + yield* Effect.annotateCurrentSpan("activity.new_status", newStatus) + yield* Effect.annotateCurrentSpan("activity.updated_count", updated.length) - yield* Effect.logDebug( - `Successfully updated ${updated.length} connection(s) for installation ${payload.installationId} to '${newStatus}'`, - ) + yield* Effect.logDebug( + `Successfully updated ${updated.length} connection(s) for installation ${payload.installationId} to '${newStatus}'`, + ) - return { - updatedCount: updated.length, - connectionIds: updated.map((u) => u.id), - newStatus, - } - }), + return { + updatedCount: updated.length, + connectionIds: updated.map((u) => u.id), + newStatus, + } + }), + }) }).pipe( - Effect.tapError((err) => + Effect.tapError((err: { readonly _tag: string; readonly retryable?: boolean }) => Effect.logError("UpdateConnectionStatus activity failed", { errorTag: err._tag, retryable: err.retryable, diff --git a/apps/cluster/src/workflows/github-webhook-handler.ts b/apps/cluster/src/workflows/github-webhook-handler.ts index 9dba2e7ab..756d30696 100644 --- a/apps/cluster/src/workflows/github-webhook-handler.ts +++ b/apps/cluster/src/workflows/github-webhook-handler.ts @@ -1,4 +1,4 @@ -import { Activity } from "@effect/workflow" +import { Activity } from "effect/unstable/workflow" import { and, Database, eq, isNull, schema } from "@hazel/db" import { Cluster } from "@hazel/domain" import type { MessageId } from "@hazel/schema" @@ -7,7 +7,10 @@ import { Effect, Option, Schema } from "effect" import { BotUserService } from "../services/bot-user-service.ts" export const GitHubWebhookWorkflowLayer = Cluster.GitHubWebhookWorkflow.toLayer( - Effect.fn("workflow.GitHubWebhook")(function* (payload: Cluster.GitHubWebhookWorkflowPayload) { + Effect.fn("workflow.GitHubWebhook")(function* ( + payload: Cluster.GitHubWebhookWorkflowPayload, + _executionId: string, + ) { yield* Effect.annotateCurrentSpan("workflow.event_type", payload.eventType) yield* Effect.annotateCurrentSpan("workflow.repository", payload.repositoryFullName) yield* Effect.annotateCurrentSpan("workflow.repository_id", payload.repositoryId) @@ -24,64 +27,69 @@ export const GitHubWebhookWorkflowLayer = Cluster.GitHubWebhookWorkflow.toLayer( } // Activity 1: Get all subscriptions for this repository - const subscriptionsResult = yield* Activity.make({ - name: "GetGitHubSubscriptions", - success: Cluster.GetGitHubSubscriptionsResult, - error: Cluster.GetGitHubSubscriptionsError, - execute: Effect.gen(function* () { - const db = yield* Database.Database - - yield* Effect.logDebug(`Querying subscriptions for repository ${payload.repositoryId}`) - - const subscriptions = yield* db - .execute((client) => - client - .select({ - id: schema.githubSubscriptionsTable.id, - channelId: schema.githubSubscriptionsTable.channelId, - enabledEvents: schema.githubSubscriptionsTable.enabledEvents, - branchFilter: schema.githubSubscriptionsTable.branchFilter, - }) - .from(schema.githubSubscriptionsTable) - .where( - and( - eq(schema.githubSubscriptionsTable.repositoryId, payload.repositoryId), - eq(schema.githubSubscriptionsTable.isEnabled, true), - isNull(schema.githubSubscriptionsTable.deletedAt), - ), - ), - ) - .pipe( - Effect.catchTags({ - DatabaseError: (err) => - Effect.fail( - new Cluster.GetGitHubSubscriptionsError({ - repositoryId: payload.repositoryId, - message: "Failed to query GitHub subscriptions", - cause: err, - }), + const subscriptionsResult = yield* Effect.gen(function* () { + return yield* Activity.make({ + name: "GetGitHubSubscriptions", + success: Cluster.GetGitHubSubscriptionsResult, + error: Cluster.GetGitHubSubscriptionsError, + execute: Effect.gen(function* () { + const db = yield* Database.Database + + yield* Effect.logDebug(`Querying subscriptions for repository ${payload.repositoryId}`) + + const subscriptions = yield* db + .execute((client) => + client + .select({ + id: schema.githubSubscriptionsTable.id, + channelId: schema.githubSubscriptionsTable.channelId, + enabledEvents: schema.githubSubscriptionsTable.enabledEvents, + branchFilter: schema.githubSubscriptionsTable.branchFilter, + }) + .from(schema.githubSubscriptionsTable) + .where( + and( + eq( + schema.githubSubscriptionsTable.repositoryId, + payload.repositoryId, + ), + eq(schema.githubSubscriptionsTable.isEnabled, true), + isNull(schema.githubSubscriptionsTable.deletedAt), + ), ), - }), - ) + ) + .pipe( + Effect.catchTags({ + DatabaseError: (err) => + Effect.fail( + new Cluster.GetGitHubSubscriptionsError({ + repositoryId: payload.repositoryId, + message: "Failed to query GitHub subscriptions", + cause: err, + }), + ), + }), + ) - yield* Effect.annotateCurrentSpan("activity.subscription_count", subscriptions.length) + yield* Effect.annotateCurrentSpan("activity.subscription_count", subscriptions.length) - yield* Effect.logDebug( - `Found ${subscriptions.length} subscriptions for repository ${payload.repositoryId}`, - ) + yield* Effect.logDebug( + `Found ${subscriptions.length} subscriptions for repository ${payload.repositoryId}`, + ) - return { - subscriptions: subscriptions.map((s) => ({ - id: s.id, - channelId: s.channelId, - enabledEvents: s.enabledEvents as Cluster.GitHubEventType[], - branchFilter: s.branchFilter, - })), - totalCount: subscriptions.length, - } - }), + return { + subscriptions: subscriptions.map((s) => ({ + id: s.id, + channelId: s.channelId, + enabledEvents: s.enabledEvents as Cluster.GitHubEventType[], + branchFilter: s.branchFilter, + })), + totalCount: subscriptions.length, + } + }), + }) }).pipe( - Effect.tapError((err) => + Effect.tapError((err: { readonly _tag: string; readonly retryable?: boolean }) => Effect.logError("GetGitHubSubscriptions activity failed", { errorTag: err._tag, retryable: err.retryable, @@ -94,19 +102,21 @@ export const GitHubWebhookWorkflowLayer = Cluster.GitHubWebhookWorkflow.toLayer( const ref = eventPayload.ref // Filter subscriptions by event type and branch filter - const eligibleSubscriptions = subscriptionsResult.subscriptions.filter((sub) => { - // Check if event type is enabled - if (!sub.enabledEvents.includes(internalEventType)) { - return false - } + const eligibleSubscriptions = subscriptionsResult.subscriptions.filter( + (sub: Cluster.GitHubSubscriptionForWorkflow) => { + // Check if event type is enabled + if (!sub.enabledEvents.includes(internalEventType)) { + return false + } - // For push events, check branch filter - if (payload.eventType === "push" && !GitHub.matchesBranchFilter(sub.branchFilter, ref)) { - return false - } + // For push events, check branch filter + if (payload.eventType === "push" && !GitHub.matchesBranchFilter(sub.branchFilter, ref)) { + return false + } - return true - }) + return true + }, + ) if (eligibleSubscriptions.length === 0) { yield* Effect.logDebug("No eligible subscriptions after filtering, workflow complete") @@ -124,74 +134,76 @@ export const GitHubWebhookWorkflowLayer = Cluster.GitHubWebhookWorkflow.toLayer( } // Activity 2: Create messages in subscribed channels - const messagesResult = yield* Activity.make({ - name: "CreateGitHubMessages", - success: Cluster.CreateGitHubMessagesResult, - error: Schema.Union(Cluster.CreateGitHubMessageError, Cluster.BotUserQueryError), - execute: Effect.gen(function* () { - const db = yield* Database.Database - const botUserService = yield* BotUserService - const messageIds: MessageId[] = [] - - yield* Effect.logDebug(`Creating messages in ${eligibleSubscriptions.length} channels`) - - // Get the GitHub bot user ID from cache - const botUserOption = yield* botUserService.getGitHubBotUserId() - - if (Option.isNone(botUserOption)) { - yield* Effect.logWarning("GitHub bot user not found, cannot create messages") - return { messageIds: [], messagesCreated: 0 } - } - - const botUserId = botUserOption.value - - // Create a message in each eligible channel - for (const subscription of eligibleSubscriptions) { - const messageResult = yield* db - .execute((client) => - client - .insert(schema.messagesTable) - .values({ - channelId: subscription.channelId, - authorId: botUserId, - content: "", - embeds: [embed], - replyToMessageId: null, - threadChannelId: null, - deletedAt: null, - }) - .returning({ id: schema.messagesTable.id }), - ) - .pipe( - Effect.catchTags({ - DatabaseError: (err) => - Effect.fail( - new Cluster.CreateGitHubMessageError({ - channelId: subscription.channelId, - message: "Failed to create GitHub message", - cause: err, - }), - ), - }), - ) + const messagesResult = yield* Effect.gen(function* () { + return yield* Activity.make({ + name: "CreateGitHubMessages", + success: Cluster.CreateGitHubMessagesResult, + error: Schema.Union([Cluster.CreateGitHubMessageError, Cluster.BotUserQueryError]), + execute: Effect.gen(function* () { + const db = yield* Database.Database + const botUserService = yield* BotUserService + const messageIds: MessageId[] = [] + + yield* Effect.logDebug(`Creating messages in ${eligibleSubscriptions.length} channels`) + + // Get the GitHub bot user ID from cache + const botUserOption = yield* botUserService.getGitHubBotUserId() + + if (Option.isNone(botUserOption)) { + yield* Effect.logWarning("GitHub bot user not found, cannot create messages") + return { messageIds: [], messagesCreated: 0 } + } - if (messageResult.length > 0) { - messageIds.push(messageResult[0]!.id) - yield* Effect.logDebug( - `Created message ${messageResult[0]!.id} in channel ${subscription.channelId}`, - ) + const botUserId = botUserOption.value + + // Create a message in each eligible channel + for (const subscription of eligibleSubscriptions) { + const messageResult = yield* db + .execute((client) => + client + .insert(schema.messagesTable) + .values({ + channelId: subscription.channelId, + authorId: botUserId, + content: "", + embeds: [embed], + replyToMessageId: null, + threadChannelId: null, + deletedAt: null, + }) + .returning({ id: schema.messagesTable.id }), + ) + .pipe( + Effect.catchTags({ + DatabaseError: (err) => + Effect.fail( + new Cluster.CreateGitHubMessageError({ + channelId: subscription.channelId, + message: "Failed to create GitHub message", + cause: err, + }), + ), + }), + ) + + if (messageResult.length > 0) { + messageIds.push(messageResult[0]!.id) + yield* Effect.logDebug( + `Created message ${messageResult[0]!.id} in channel ${subscription.channelId}`, + ) + } } - } - yield* Effect.annotateCurrentSpan("activity.messages_created", messageIds.length) + yield* Effect.annotateCurrentSpan("activity.messages_created", messageIds.length) - return { - messageIds, - messagesCreated: messageIds.length, - } - }), + return { + messageIds, + messagesCreated: messageIds.length, + } + }), + }) }).pipe( - Effect.tapError((err) => + Effect.tapError((err: { readonly _tag: string; readonly retryable?: boolean }) => Effect.logError("CreateGitHubMessages activity failed", { errorTag: err._tag, retryable: err.retryable, diff --git a/apps/cluster/src/workflows/message-notification-handler.ts b/apps/cluster/src/workflows/message-notification-handler.ts index 4bae8ad1d..237d4d5c1 100644 --- a/apps/cluster/src/workflows/message-notification-handler.ts +++ b/apps/cluster/src/workflows/message-notification-handler.ts @@ -1,8 +1,8 @@ -import { Activity } from "@effect/workflow" +import { Activity } from "effect/unstable/workflow" import { and, Database, eq, inArray, isNull, ne, or, schema, sql } from "@hazel/db" import { Cluster } from "@hazel/domain" import type { ChannelMemberId, NotificationId, OrganizationMemberId, UserId } from "@hazel/schema" -import { Array, Effect, Option, Schema } from "effect" +import { Array, Effect, Option, Result } from "effect" interface OrgMemberLookupRow { orgMemberId: OrganizationMemberId @@ -57,6 +57,7 @@ export const buildNotificationInsertRows = ( export const MessageNotificationWorkflowLayer = Cluster.MessageNotificationWorkflow.toLayer( Effect.fn("workflow.MessageNotification")(function* ( payload: Cluster.MessageNotificationWorkflowPayload, + _executionId: string, ) { yield* Effect.annotateCurrentSpan("workflow.message_id", payload.messageId) yield* Effect.annotateCurrentSpan("workflow.channel_id", payload.channelId) @@ -82,17 +83,148 @@ export const MessageNotificationWorkflowLayer = Cluster.MessageNotificationWorkf ) // Activity 1: Get notification targets based on channel type and mentions - const membersResult = yield* Activity.make({ - name: "GetChannelMembers", - success: Cluster.GetChannelMembersResult, - error: Cluster.GetChannelMembersError, - execute: Effect.gen(function* () { - const db = yield* Database.Database + const membersResult = yield* Effect.gen(function* () { + return yield* Activity.make({ + name: "GetChannelMembers", + success: Cluster.GetChannelMembersResult, + error: Cluster.GetChannelMembersError, + execute: Effect.gen(function* () { + const db = yield* Database.Database + + if (shouldNotifyAll) { + // DM/group or broadcast mention - notify all members (existing logic) + yield* Effect.logDebug( + `Querying all channel members for channel ${payload.channelId}`, + ) + + const channelMembers = yield* db + .execute((client) => + client + .select({ + id: schema.channelMembersTable.id, + channelId: schema.channelMembersTable.channelId, + userId: schema.channelMembersTable.userId, + isMuted: schema.channelMembersTable.isMuted, + notificationCount: schema.channelMembersTable.notificationCount, + }) + .from(schema.channelMembersTable) + .leftJoin( + schema.userPresenceStatusTable, + eq( + schema.channelMembersTable.userId, + schema.userPresenceStatusTable.userId, + ), + ) + .where( + and( + eq(schema.channelMembersTable.channelId, payload.channelId), + eq(schema.channelMembersTable.isMuted, false), + ne(schema.channelMembersTable.userId, payload.authorId), + isNull(schema.channelMembersTable.deletedAt), + or( + // No presence record - send notification + isNull(schema.userPresenceStatusTable.userId), + // Has presence record - check suppressNotifications and activeChannel/status + and( + eq( + schema.userPresenceStatusTable.suppressNotifications, + false, + ), + or( + isNull( + schema.userPresenceStatusTable.activeChannelId, + ), + ne( + schema.userPresenceStatusTable.activeChannelId, + payload.channelId, + ), + ne(schema.userPresenceStatusTable.status, "online"), + ), + ), + ), + ), + ), + ) + .pipe( + Effect.catchTags({ + DatabaseError: (err) => + Effect.fail( + new Cluster.GetChannelMembersError({ + channelId: payload.channelId, + message: "Failed to query channel members", + cause: err, + }), + ), + }), + ) + + yield* Effect.annotateCurrentSpan("activity.members_count", channelMembers.length) + + yield* Effect.logDebug( + `Found ${channelMembers.length} members to notify (all members mode)`, + ) + + return { + members: channelMembers, + totalCount: channelMembers.length, + } + } + + // Regular channel - only notify mentioned users and reply-to author + yield* Effect.logDebug( + `Smart notification mode: ${mentions.userMentions.length} user mentions, reply to: ${payload.replyToMessageId ?? "none"}`, + ) + + const usersToNotify: UserId[] = [...mentions.userMentions] + + // If this is a reply, get the original message author + if (payload.replyToMessageId) { + const replyToMessage = yield* db + .execute((client) => + client + .select({ authorId: schema.messagesTable.authorId }) + .from(schema.messagesTable) + .where(eq(schema.messagesTable.id, payload.replyToMessageId!)) + .limit(1), + ) + .pipe( + Effect.catchTags({ + DatabaseError: (err) => + Effect.fail( + new Cluster.GetChannelMembersError({ + channelId: payload.channelId, + message: "Failed to query reply-to message author", + cause: err, + }), + ), + }), + ) + + const replyAuthor = Array.head(replyToMessage).pipe( + Option.map((msg) => msg.authorId), + Option.filter((authorId) => authorId !== payload.authorId), + ) + if (Option.isSome(replyAuthor)) { + yield* Effect.logDebug( + `Adding reply-to author ${replyAuthor.value} to notification list`, + ) + usersToNotify.push(replyAuthor.value) + } + } + + // Remove duplicates and the author + const uniqueUsersToNotify = Array.dedupe(usersToNotify).filter( + (userId) => userId !== payload.authorId, + ) + + yield* Effect.logDebug(`Unique users to notify: ${uniqueUsersToNotify.length}`) - if (shouldNotifyAll) { - // DM/group or broadcast mention - notify all members (existing logic) - yield* Effect.logDebug(`Querying all channel members for channel ${payload.channelId}`) + if (uniqueUsersToNotify.length === 0) { + yield* Effect.logDebug("No users to notify in smart mode") + return { members: [], totalCount: 0 } + } + // Query only the members who should be notified const channelMembers = yield* db .execute((client) => client @@ -114,8 +246,8 @@ export const MessageNotificationWorkflowLayer = Cluster.MessageNotificationWorkf .where( and( eq(schema.channelMembersTable.channelId, payload.channelId), + inArray(schema.channelMembersTable.userId, uniqueUsersToNotify), eq(schema.channelMembersTable.isMuted, false), - ne(schema.channelMembersTable.userId, payload.authorId), isNull(schema.channelMembersTable.deletedAt), or( // No presence record - send notification @@ -145,7 +277,7 @@ export const MessageNotificationWorkflowLayer = Cluster.MessageNotificationWorkf Effect.fail( new Cluster.GetChannelMembersError({ channelId: payload.channelId, - message: "Failed to query channel members", + message: "Failed to query channel members for mentions", cause: err, }), ), @@ -154,135 +286,16 @@ export const MessageNotificationWorkflowLayer = Cluster.MessageNotificationWorkf yield* Effect.annotateCurrentSpan("activity.members_count", channelMembers.length) - yield* Effect.logDebug( - `Found ${channelMembers.length} members to notify (all members mode)`, - ) + yield* Effect.logDebug(`Found ${channelMembers.length} members to notify (smart mode)`) return { members: channelMembers, totalCount: channelMembers.length, } - } - - // Regular channel - only notify mentioned users and reply-to author - yield* Effect.logDebug( - `Smart notification mode: ${mentions.userMentions.length} user mentions, reply to: ${payload.replyToMessageId ?? "none"}`, - ) - - const usersToNotify: UserId[] = [...mentions.userMentions] - - // If this is a reply, get the original message author - if (payload.replyToMessageId) { - const replyToMessage = yield* db - .execute((client) => - client - .select({ authorId: schema.messagesTable.authorId }) - .from(schema.messagesTable) - .where(eq(schema.messagesTable.id, payload.replyToMessageId!)) - .limit(1), - ) - .pipe( - Effect.catchTags({ - DatabaseError: (err) => - Effect.fail( - new Cluster.GetChannelMembersError({ - channelId: payload.channelId, - message: "Failed to query reply-to message author", - cause: err, - }), - ), - }), - ) - - const replyAuthor = Array.head(replyToMessage).pipe( - Option.map((msg) => msg.authorId), - Option.filter((authorId) => authorId !== payload.authorId), - ) - if (Option.isSome(replyAuthor)) { - yield* Effect.logDebug( - `Adding reply-to author ${replyAuthor.value} to notification list`, - ) - usersToNotify.push(replyAuthor.value) - } - } - - // Remove duplicates and the author - const uniqueUsersToNotify = Array.dedupe(usersToNotify).filter( - (userId) => userId !== payload.authorId, - ) - - yield* Effect.logDebug(`Unique users to notify: ${uniqueUsersToNotify.length}`) - - if (uniqueUsersToNotify.length === 0) { - yield* Effect.logDebug("No users to notify in smart mode") - return { members: [], totalCount: 0 } - } - - // Query only the members who should be notified - const channelMembers = yield* db - .execute((client) => - client - .select({ - id: schema.channelMembersTable.id, - channelId: schema.channelMembersTable.channelId, - userId: schema.channelMembersTable.userId, - isMuted: schema.channelMembersTable.isMuted, - notificationCount: schema.channelMembersTable.notificationCount, - }) - .from(schema.channelMembersTable) - .leftJoin( - schema.userPresenceStatusTable, - eq(schema.channelMembersTable.userId, schema.userPresenceStatusTable.userId), - ) - .where( - and( - eq(schema.channelMembersTable.channelId, payload.channelId), - inArray(schema.channelMembersTable.userId, uniqueUsersToNotify), - eq(schema.channelMembersTable.isMuted, false), - isNull(schema.channelMembersTable.deletedAt), - or( - // No presence record - send notification - isNull(schema.userPresenceStatusTable.userId), - // Has presence record - check suppressNotifications and activeChannel/status - and( - eq(schema.userPresenceStatusTable.suppressNotifications, false), - or( - isNull(schema.userPresenceStatusTable.activeChannelId), - ne( - schema.userPresenceStatusTable.activeChannelId, - payload.channelId, - ), - ne(schema.userPresenceStatusTable.status, "online"), - ), - ), - ), - ), - ), - ) - .pipe( - Effect.catchTags({ - DatabaseError: (err) => - Effect.fail( - new Cluster.GetChannelMembersError({ - channelId: payload.channelId, - message: "Failed to query channel members for mentions", - cause: err, - }), - ), - }), - ) - - yield* Effect.annotateCurrentSpan("activity.members_count", channelMembers.length) - - yield* Effect.logDebug(`Found ${channelMembers.length} members to notify (smart mode)`) - - return { - members: channelMembers, - totalCount: channelMembers.length, - } - }), + }), + }) }).pipe( - Effect.tapError((err) => + Effect.tapError((err: { readonly _tag: string; readonly retryable?: boolean }) => Effect.logError("GetChannelMembers activity failed", { errorTag: err._tag, retryable: err.retryable, @@ -297,102 +310,83 @@ export const MessageNotificationWorkflowLayer = Cluster.MessageNotificationWorkf } // Activity 2: Create notifications for all members - const notificationsResult = yield* Activity.make({ - name: "CreateNotifications", - success: Cluster.CreateNotificationsResult, - error: Schema.Union(Cluster.CreateNotificationError), - execute: Effect.gen(function* () { - const db = yield* Database.Database - const startedAt = Date.now() - yield* Effect.annotateCurrentSpan("activity.candidate_count", membersResult.members.length) - yield* Effect.logDebug(`Creating notifications for ${membersResult.members.length} members`) - - const userIds = membersResult.members.map((member) => member.userId) - const orgMembers = yield* db - .execute((client) => - client - .select({ - orgMemberId: schema.organizationMembersTable.id, - userId: schema.organizationMembersTable.userId, - }) - .from(schema.organizationMembersTable) - .innerJoin( - schema.channelsTable, - eq( - schema.channelsTable.organizationId, - schema.organizationMembersTable.organizationId, - ), - ) - .where( - and( - eq(schema.channelsTable.id, payload.channelId), - inArray(schema.organizationMembersTable.userId, userIds), - isNull(schema.organizationMembersTable.deletedAt), - ), - ), + const notificationsResult = yield* Effect.gen(function* () { + return yield* Activity.make({ + name: "CreateNotifications", + success: Cluster.CreateNotificationsResult, + error: Cluster.CreateNotificationError, + execute: Effect.gen(function* () { + const db = yield* Database.Database + const startedAt = Date.now() + yield* Effect.annotateCurrentSpan( + "activity.candidate_count", + membersResult.members.length, ) - .pipe( - Effect.catchTags({ - DatabaseError: (err) => - Effect.fail( - new Cluster.CreateNotificationError({ - messageId: payload.messageId, - message: "Failed to query organization members", - cause: err, - }), - ), - }), + yield* Effect.logDebug( + `Creating notifications for ${membersResult.members.length} members`, ) - const orgMemberLookup = buildOrgMemberLookup(orgMembers) - const { values, channelMemberByOrgMember } = buildNotificationInsertRows( - membersResult.members, - orgMemberLookup, - payload, - ) - - if (values.length === 0) { - yield* Effect.logDebug("No valid organization members to notify") - return { notificationIds: [], notifiedCount: 0 } - } - - const insertedNotifications = yield* db - .execute((client) => - client - .insert(schema.notificationsTable) - .values(values) - .onConflictDoNothing() - .returning({ - id: schema.notificationsTable.id, - memberId: schema.notificationsTable.memberId, - }), + const userIds = membersResult.members.map( + (member: Cluster.ChannelMemberForNotification) => member.userId, ) - .pipe( - Effect.catchTags({ - DatabaseError: (err) => - Effect.fail( - new Cluster.CreateNotificationError({ - messageId: payload.messageId, - message: "Failed to insert notification batch", - cause: err, - }), + const orgMembers = yield* db + .execute((client) => + client + .select({ + orgMemberId: schema.organizationMembersTable.id, + userId: schema.organizationMembersTable.userId, + }) + .from(schema.organizationMembersTable) + .innerJoin( + schema.channelsTable, + eq( + schema.channelsTable.organizationId, + schema.organizationMembersTable.organizationId, + ), + ) + .where( + and( + eq(schema.channelsTable.id, payload.channelId), + inArray(schema.organizationMembersTable.userId, userIds), + isNull(schema.organizationMembersTable.deletedAt), + ), ), - }), + ) + .pipe( + Effect.catchTags({ + DatabaseError: (err) => + Effect.fail( + new Cluster.CreateNotificationError({ + messageId: payload.messageId, + message: "Failed to query organization members", + cause: err, + }), + ), + }), + ) + + const orgMemberLookup = buildOrgMemberLookup(orgMembers) + const { values, channelMemberByOrgMember } = buildNotificationInsertRows( + membersResult.members, + orgMemberLookup, + payload, ) - const insertedChannelMemberIds = Array.filterMap(insertedNotifications, (row) => - Option.fromNullable(channelMemberByOrgMember.get(row.memberId)), - ) + if (values.length === 0) { + yield* Effect.logDebug("No valid organization members to notify") + return { notificationIds: [], notifiedCount: 0 } + } - if (insertedChannelMemberIds.length > 0) { - yield* db + const insertedNotifications = yield* db .execute((client) => client - .update(schema.channelMembersTable) - .set({ - notificationCount: sql`${schema.channelMembersTable.notificationCount} + 1`, - }) - .where(inArray(schema.channelMembersTable.id, insertedChannelMemberIds)), + .insert(schema.notificationsTable) + .values(values) + .onConflictDoNothing() + .returning({ + id: schema.notificationsTable.id, + memberId: schema.notificationsTable.memberId, + }), ) .pipe( Effect.catchTags({ @@ -400,31 +394,60 @@ export const MessageNotificationWorkflowLayer = Cluster.MessageNotificationWorkf Effect.fail( new Cluster.CreateNotificationError({ messageId: payload.messageId, - message: "Failed to increment notification counts", + message: "Failed to insert notification batch", cause: err, }), ), }), ) - } - - const notificationIds = insertedNotifications.map((row) => row.id) as NotificationId[] - yield* Effect.annotateCurrentSpan("activity.eligible_count", values.length) - yield* Effect.annotateCurrentSpan("activity.inserted_count", insertedNotifications.length) - yield* Effect.logDebug("Notification batch completed", { - candidates: membersResult.members.length, - eligible: values.length, - inserted: insertedNotifications.length, - durationMs: Date.now() - startedAt, - }) - - return { - notificationIds, - notifiedCount: notificationIds.length, - } - }), + + const insertedChannelMemberIds = Array.filterMap(insertedNotifications, (row) => { + const memberId = channelMemberByOrgMember.get(row.memberId) + return memberId != null ? Result.succeed(memberId) : Result.failVoid + }) + + if (insertedChannelMemberIds.length > 0) { + yield* db + .execute((client) => + client + .update(schema.channelMembersTable) + .set({ + notificationCount: sql`${schema.channelMembersTable.notificationCount} + 1`, + }) + .where(inArray(schema.channelMembersTable.id, insertedChannelMemberIds)), + ) + .pipe( + Effect.catchTags({ + DatabaseError: (err) => + Effect.fail( + new Cluster.CreateNotificationError({ + messageId: payload.messageId, + message: "Failed to increment notification counts", + cause: err, + }), + ), + }), + ) + } + + const notificationIds = insertedNotifications.map((row) => row.id) as NotificationId[] + yield* Effect.annotateCurrentSpan("activity.eligible_count", values.length) + yield* Effect.annotateCurrentSpan("activity.inserted_count", insertedNotifications.length) + yield* Effect.logDebug("Notification batch completed", { + candidates: membersResult.members.length, + eligible: values.length, + inserted: insertedNotifications.length, + durationMs: Date.now() - startedAt, + }) + + return { + notificationIds, + notifiedCount: notificationIds.length, + } + }), + }) }).pipe( - Effect.tapError((err) => + Effect.tapError((err: { readonly _tag: string; readonly retryable?: boolean }) => Effect.logError("CreateNotifications activity failed", { errorTag: err._tag, retryable: err.retryable, diff --git a/apps/cluster/src/workflows/rss-feed-poll-handler.ts b/apps/cluster/src/workflows/rss-feed-poll-handler.ts index e68a24029..706c5541d 100644 --- a/apps/cluster/src/workflows/rss-feed-poll-handler.ts +++ b/apps/cluster/src/workflows/rss-feed-poll-handler.ts @@ -1,4 +1,4 @@ -import { Activity } from "@effect/workflow" +import { Activity } from "effect/unstable/workflow" import { and, Database, eq, isNull, schema } from "@hazel/db" import { Cluster } from "@hazel/domain" import type { MessageId } from "@hazel/schema" @@ -7,7 +7,10 @@ import { Effect, Option, Schema } from "effect" import { BotUserService } from "../services/bot-user-service.ts" export const RssFeedPollWorkflowLayer = Cluster.RssFeedPollWorkflow.toLayer( - Effect.fn("workflow.RssFeedPoll")(function* (payload: Cluster.RssFeedPollWorkflowPayload) { + Effect.fn("workflow.RssFeedPoll")(function* ( + payload: Cluster.RssFeedPollWorkflowPayload, + _executionId: string, + ) { yield* Effect.annotateCurrentSpan("workflow.subscription_id", payload.subscriptionId) yield* Effect.annotateCurrentSpan("workflow.feed_url", payload.feedUrl) yield* Effect.annotateCurrentSpan("workflow.channel_id", payload.channelId) @@ -17,42 +20,44 @@ export const RssFeedPollWorkflowLayer = Cluster.RssFeedPollWorkflow.toLayer( ) // Activity 1: Fetch and parse the RSS feed - const feedResult = yield* Activity.make({ - name: "FetchAndParseFeed", - success: Cluster.FetchRssFeedResult, - error: Cluster.FetchRssFeedError, - execute: Effect.gen(function* () { - yield* Effect.logDebug(`Fetching RSS feed: ${payload.feedUrl}`) + const feedResult = yield* Effect.gen(function* () { + return yield* Activity.make({ + name: "FetchAndParseFeed", + success: Cluster.FetchRssFeedResult, + error: Cluster.FetchRssFeedError, + execute: Effect.gen(function* () { + yield* Effect.logDebug(`Fetching RSS feed: ${payload.feedUrl}`) - const parsedFeed = yield* Effect.tryPromise({ - try: () => Rss.fetchAndParseFeed(payload.feedUrl), - catch: (error) => - new Cluster.FetchRssFeedError({ - subscriptionId: payload.subscriptionId, - feedUrl: payload.feedUrl, - message: error instanceof Error ? error.message : "Failed to fetch RSS feed", - cause: error, - }), - }) + const parsedFeed = yield* Effect.tryPromise({ + try: () => Rss.fetchAndParseFeed(payload.feedUrl), + catch: (error) => + new Cluster.FetchRssFeedError({ + subscriptionId: payload.subscriptionId, + feedUrl: payload.feedUrl, + message: error instanceof Error ? error.message : "Failed to fetch RSS feed", + cause: error, + }), + }) - yield* Effect.annotateCurrentSpan("activity.item_count", parsedFeed.items.length) - yield* Effect.logDebug(`Fetched ${parsedFeed.items.length} items from ${payload.feedUrl}`) + yield* Effect.annotateCurrentSpan("activity.item_count", parsedFeed.items.length) + yield* Effect.logDebug(`Fetched ${parsedFeed.items.length} items from ${payload.feedUrl}`) - return { - feedTitle: parsedFeed.metadata.title, - feedIconUrl: parsedFeed.metadata.iconUrl, - items: parsedFeed.items.map((item) => ({ - guid: item.guid, - title: item.title, - description: item.description, - link: item.link, - pubDate: item.pubDate, - author: item.author, - })), - } - }), + return { + feedTitle: parsedFeed.metadata.title, + feedIconUrl: parsedFeed.metadata.iconUrl, + items: parsedFeed.items.map((item) => ({ + guid: item.guid, + title: item.title, + description: item.description, + link: item.link, + pubDate: item.pubDate, + author: item.author, + })), + } + }), + }) }).pipe( - Effect.tapError((err) => + Effect.tapError((err: { readonly _tag: string }) => Effect.logError("FetchAndParseFeed activity failed", { errorTag: err._tag, feedUrl: payload.feedUrl, @@ -108,63 +113,163 @@ export const RssFeedPollWorkflowLayer = Cluster.RssFeedPollWorkflow.toLayer( } // Activity 2: Filter new items and post messages - const postResult = yield* Activity.make({ - name: "FilterAndPostItems", - success: Cluster.PostRssItemsResult, - error: Schema.Union(Cluster.PostRssItemsError, Cluster.BotUserQueryError), - execute: Effect.gen(function* () { - const db = yield* Database.Database - const botUserService = yield* BotUserService + const postResult = yield* Effect.gen(function* () { + return yield* Activity.make({ + name: "FilterAndPostItems", + success: Cluster.PostRssItemsResult, + error: Schema.Union([Cluster.PostRssItemsError, Cluster.BotUserQueryError]), + execute: Effect.gen(function* () { + const db = yield* Database.Database + const botUserService = yield* BotUserService + + // Get the RSS bot user ID + const botUserOption = yield* botUserService.getRssBotUserId() + if (Option.isNone(botUserOption)) { + yield* Effect.logWarning("RSS bot user not found, cannot create messages") + return { messageIds: [], messagesCreated: 0, itemGuidsPosted: [] } + } + const botUserId = botUserOption.value + + // Get already posted items for deduplication + const postedItems = yield* db + .execute((client) => + client + .select({ itemGuid: schema.rssPostedItemsTable.itemGuid }) + .from(schema.rssPostedItemsTable) + .where(eq(schema.rssPostedItemsTable.subscriptionId, payload.subscriptionId)), + ) + .pipe( + Effect.catchTags({ + DatabaseError: (err) => + Effect.fail( + new Cluster.PostRssItemsError({ + channelId: payload.channelId, + message: "Failed to query posted items", + cause: err, + }), + ), + }), + ) + + const postedGuids = new Set(postedItems.map((p) => p.itemGuid)) + + // Filter to only new items (GUID dedup + date filter) + const newItems = feedResult.items.filter((item) => { + if (postedGuids.has(item.guid)) return false + // Skip items published before the subscription was created + if (item.pubDate && new Date(item.pubDate).getTime() < payload.subscribedAt) + return false + return true + }) + + if (newItems.length === 0) { + yield* Effect.logDebug("No new items to post") + + // Still update last fetched time (non-critical, use orDie) + yield* db + .execute((client) => + client + .update(schema.rssSubscriptionsTable) + .set({ + lastFetchedAt: new Date(), + consecutiveErrors: 0, + lastErrorMessage: null, + lastErrorAt: null, + ...(feedResult.feedTitle && { feedTitle: feedResult.feedTitle }), + ...(feedResult.feedIconUrl && { + feedIconUrl: feedResult.feedIconUrl, + }), + updatedAt: new Date(), + }) + .where(eq(schema.rssSubscriptionsTable.id, payload.subscriptionId)), + ) + .pipe(Effect.orDie) + + return { messageIds: [], messagesCreated: 0, itemGuidsPosted: [] } + } + + // Post up to 5 items per poll cycle (flood protection) + const itemsToPost = newItems.slice(0, 5) + const messageIds: MessageId[] = [] + const itemGuidsPosted: string[] = [] - // Get the RSS bot user ID - const botUserOption = yield* botUserService.getRssBotUserId() - if (Option.isNone(botUserOption)) { - yield* Effect.logWarning("RSS bot user not found, cannot create messages") - return { messageIds: [], messagesCreated: 0, itemGuidsPosted: [] } - } - const botUserId = botUserOption.value + yield* Effect.annotateCurrentSpan("activity.new_item_count", newItems.length) - // Get already posted items for deduplication - const postedItems = yield* db - .execute((client) => - client - .select({ itemGuid: schema.rssPostedItemsTable.itemGuid }) - .from(schema.rssPostedItemsTable) - .where(eq(schema.rssPostedItemsTable.subscriptionId, payload.subscriptionId)), + yield* Effect.logDebug( + `Posting ${itemsToPost.length} new items (of ${newItems.length} total new)`, ) - .pipe( - Effect.catchTags({ - DatabaseError: (err) => - Effect.fail( - new Cluster.PostRssItemsError({ + + for (const item of itemsToPost) { + const embed = Rss.buildRssEmbed(item, feedResult.feedTitle) + + const messageResult = yield* db + .execute((client) => + client + .insert(schema.messagesTable) + .values({ channelId: payload.channelId, - message: "Failed to query posted items", - cause: err, - }), - ), - }), - ) + authorId: botUserId, + content: "", + embeds: [embed], + replyToMessageId: null, + threadChannelId: null, + deletedAt: null, + }) + .returning({ id: schema.messagesTable.id }), + ) + .pipe( + Effect.catchTags({ + DatabaseError: (err) => + Effect.fail( + new Cluster.PostRssItemsError({ + channelId: payload.channelId, + message: "Failed to create RSS message", + cause: err, + }), + ), + }), + ) - const postedGuids = new Set(postedItems.map((p) => p.itemGuid)) + if (messageResult.length > 0) { + const messageId = messageResult[0]!.id + messageIds.push(messageId) + itemGuidsPosted.push(item.guid) - // Filter to only new items (GUID dedup + date filter) - const newItems = feedResult.items.filter((item) => { - if (postedGuids.has(item.guid)) return false - // Skip items published before the subscription was created - if (item.pubDate && new Date(item.pubDate).getTime() < payload.subscribedAt) return false - return true - }) + // Record posted item for deduplication (non-critical) + yield* db + .execute((client) => + client.insert(schema.rssPostedItemsTable).values({ + subscriptionId: payload.subscriptionId, + itemGuid: item.guid, + itemUrl: item.link || null, + messageId, + }), + ) + .pipe( + Effect.catch((err) => + Effect.logWarning( + "Failed to record posted item (may cause duplicate on next poll)", + { error: err, itemGuid: item.guid }, + ), + ), + ) - if (newItems.length === 0) { - yield* Effect.logDebug("No new items to post") + yield* Effect.logDebug(`Posted RSS item "${item.title}" as message ${messageId}`) + } + } - // Still update last fetched time (non-critical, use orDie) + // Update subscription state (non-critical, use orDie) + const lastItem = itemsToPost[0] yield* db .execute((client) => client .update(schema.rssSubscriptionsTable) .set({ lastFetchedAt: new Date(), + lastItemGuid: lastItem?.guid ?? null, + lastItemPublishedAt: lastItem?.pubDate + ? new Date(lastItem.pubDate) + : null, consecutiveErrors: 0, lastErrorMessage: null, lastErrorAt: null, @@ -178,112 +283,17 @@ export const RssFeedPollWorkflowLayer = Cluster.RssFeedPollWorkflow.toLayer( ) .pipe(Effect.orDie) - return { messageIds: [], messagesCreated: 0, itemGuidsPosted: [] } - } - - // Post up to 5 items per poll cycle (flood protection) - const itemsToPost = newItems.slice(0, 5) - const messageIds: MessageId[] = [] - const itemGuidsPosted: string[] = [] - - yield* Effect.annotateCurrentSpan("activity.new_item_count", newItems.length) - - yield* Effect.logDebug( - `Posting ${itemsToPost.length} new items (of ${newItems.length} total new)`, - ) - - for (const item of itemsToPost) { - const embed = Rss.buildRssEmbed(item, feedResult.feedTitle) - - const messageResult = yield* db - .execute((client) => - client - .insert(schema.messagesTable) - .values({ - channelId: payload.channelId, - authorId: botUserId, - content: "", - embeds: [embed], - replyToMessageId: null, - threadChannelId: null, - deletedAt: null, - }) - .returning({ id: schema.messagesTable.id }), - ) - .pipe( - Effect.catchTags({ - DatabaseError: (err) => - Effect.fail( - new Cluster.PostRssItemsError({ - channelId: payload.channelId, - message: "Failed to create RSS message", - cause: err, - }), - ), - }), - ) - - if (messageResult.length > 0) { - const messageId = messageResult[0]!.id - messageIds.push(messageId) - itemGuidsPosted.push(item.guid) - - // Record posted item for deduplication (non-critical) - yield* db - .execute((client) => - client.insert(schema.rssPostedItemsTable).values({ - subscriptionId: payload.subscriptionId, - itemGuid: item.guid, - itemUrl: item.link || null, - messageId, - }), - ) - .pipe( - Effect.catchAll((err) => - Effect.logWarning( - "Failed to record posted item (may cause duplicate on next poll)", - { error: err, itemGuid: item.guid }, - ), - ), - ) + yield* Effect.annotateCurrentSpan("activity.messages_created", messageIds.length) - yield* Effect.logDebug(`Posted RSS item "${item.title}" as message ${messageId}`) + return { + messageIds, + messagesCreated: messageIds.length, + itemGuidsPosted, } - } - - // Update subscription state (non-critical, use orDie) - const lastItem = itemsToPost[0] - yield* db - .execute((client) => - client - .update(schema.rssSubscriptionsTable) - .set({ - lastFetchedAt: new Date(), - lastItemGuid: lastItem?.guid ?? null, - lastItemPublishedAt: lastItem?.pubDate ? new Date(lastItem.pubDate) : null, - consecutiveErrors: 0, - lastErrorMessage: null, - lastErrorAt: null, - ...(feedResult.feedTitle && { feedTitle: feedResult.feedTitle }), - ...(feedResult.feedIconUrl && { - feedIconUrl: feedResult.feedIconUrl, - }), - updatedAt: new Date(), - }) - .where(eq(schema.rssSubscriptionsTable.id, payload.subscriptionId)), - ) - .pipe(Effect.orDie) - - yield* Effect.annotateCurrentSpan("activity.messages_created", messageIds.length) - - return { - messageIds, - messagesCreated: messageIds.length, - itemGuidsPosted, - } - }), + }), + }) }).pipe( - Effect.tapError((err) => + Effect.tapError((err: { readonly _tag: string }) => Effect.logError("FilterAndPostItems activity failed", { errorTag: err._tag, }), diff --git a/apps/cluster/src/workflows/thread-naming-handler.ts b/apps/cluster/src/workflows/thread-naming-handler.ts index 8df040a30..5a40f1807 100644 --- a/apps/cluster/src/workflows/thread-naming-handler.ts +++ b/apps/cluster/src/workflows/thread-naming-handler.ts @@ -1,5 +1,5 @@ -import { LanguageModel } from "@effect/ai" -import { Activity } from "@effect/workflow" +import { AiError, LanguageModel } from "effect/unstable/ai" +import { Activity } from "effect/unstable/workflow" import { and, Database, eq, isNull, schema } from "@hazel/db" import { Cluster } from "@hazel/domain" import { Effect } from "effect" @@ -19,326 +19,322 @@ Thread replies: Generate a concise thread name:` -// Define the workflow error type union for type safety -type ThreadNamingError = - | Cluster.ThreadChannelNotFoundError - | Cluster.OriginalMessageNotFoundError - | Cluster.ThreadContextQueryError - | Cluster.AIProviderUnavailableError - | Cluster.AIRateLimitError - | Cluster.AIResponseParseError - | Cluster.ThreadNameUpdateError - export const ThreadNamingWorkflowLayer = Cluster.ThreadNamingWorkflow.toLayer( - Effect.fn("workflow.ThreadNaming")(function* (payload: Cluster.ThreadNamingWorkflowPayload) { + Effect.fn("workflow.ThreadNaming")(function* ( + payload: Cluster.ThreadNamingWorkflowPayload, + _executionId: string, + ) { yield* Effect.annotateCurrentSpan("workflow.thread_channel_id", payload.threadChannelId) yield* Effect.annotateCurrentSpan("workflow.original_message_id", payload.originalMessageId) yield* Effect.logDebug(`Starting ThreadNamingWorkflow for thread ${payload.threadChannelId}`) // Activity 1: Get thread context from database - const contextResult: Cluster.GetThreadContextResult = yield* Activity.make({ - name: "GetThreadContext", - success: Cluster.GetThreadContextResult, - error: Cluster.ThreadNamingWorkflowError, - execute: Effect.gen(function* () { - const db = yield* Database.Database + const contextResult: Cluster.GetThreadContextResult = yield* Effect.gen(function* () { + return yield* Activity.make({ + name: "GetThreadContext", + success: Cluster.GetThreadContextResult, + error: Cluster.ThreadNamingWorkflowError, + execute: Effect.gen(function* () { + const db = yield* Database.Database - const threadChannel = yield* db - .execute((client) => - client - .select({ - id: schema.channelsTable.id, - name: schema.channelsTable.name, - parentChannelId: schema.channelsTable.parentChannelId, - }) - .from(schema.channelsTable) - .where(eq(schema.channelsTable.id, payload.threadChannelId)) - .limit(1), - ) - .pipe( - Effect.catchTag("DatabaseError", (err) => - Effect.fail( - new Cluster.ThreadContextQueryError({ - threadChannelId: payload.threadChannelId, - operation: "thread", - cause: err, - }), + const threadChannel = yield* db + .execute((client) => + client + .select({ + id: schema.channelsTable.id, + name: schema.channelsTable.name, + parentChannelId: schema.channelsTable.parentChannelId, + }) + .from(schema.channelsTable) + .where(eq(schema.channelsTable.id, payload.threadChannelId)) + .limit(1), + ) + .pipe( + Effect.catchTag("DatabaseError", (err) => + Effect.fail( + new Cluster.ThreadContextQueryError({ + threadChannelId: payload.threadChannelId, + operation: "thread", + cause: err, + }), + ), ), - ), - ) + ) - if (threadChannel.length === 0) { - return yield* Effect.fail( - new Cluster.ThreadChannelNotFoundError({ - threadChannelId: payload.threadChannelId, - }), - ) - } + if (threadChannel.length === 0) { + return yield* Effect.fail( + new Cluster.ThreadChannelNotFoundError({ + threadChannelId: payload.threadChannelId, + }), + ) + } - const thread = threadChannel[0]! + const thread = threadChannel[0]! - // Get original message (the one with threadChannelId pointing to this thread) - const originalMessage = yield* db - .execute((client) => - client - .select({ - id: schema.messagesTable.id, - content: schema.messagesTable.content, - authorId: schema.messagesTable.authorId, - createdAt: schema.messagesTable.createdAt, - firstName: schema.usersTable.firstName, - lastName: schema.usersTable.lastName, - }) - .from(schema.messagesTable) - .innerJoin( - schema.usersTable, - eq(schema.messagesTable.authorId, schema.usersTable.id), - ) - .where(eq(schema.messagesTable.id, payload.originalMessageId)) - .limit(1), - ) - .pipe( - Effect.catchTag("DatabaseError", (err) => - Effect.fail( - new Cluster.ThreadContextQueryError({ - threadChannelId: payload.threadChannelId, - operation: "originalMessage", - cause: err, - }), + // Get original message (the one with threadChannelId pointing to this thread) + const originalMessage = yield* db + .execute((client) => + client + .select({ + id: schema.messagesTable.id, + content: schema.messagesTable.content, + authorId: schema.messagesTable.authorId, + createdAt: schema.messagesTable.createdAt, + firstName: schema.usersTable.firstName, + lastName: schema.usersTable.lastName, + }) + .from(schema.messagesTable) + .innerJoin( + schema.usersTable, + eq(schema.messagesTable.authorId, schema.usersTable.id), + ) + .where(eq(schema.messagesTable.id, payload.originalMessageId)) + .limit(1), + ) + .pipe( + Effect.catchTag("DatabaseError", (err) => + Effect.fail( + new Cluster.ThreadContextQueryError({ + threadChannelId: payload.threadChannelId, + operation: "originalMessage", + cause: err, + }), + ), ), - ), - ) + ) - if (originalMessage.length === 0) { - return yield* Effect.fail( - new Cluster.OriginalMessageNotFoundError({ - threadChannelId: payload.threadChannelId, - messageId: payload.originalMessageId, - }), - ) - } + if (originalMessage.length === 0) { + return yield* Effect.fail( + new Cluster.OriginalMessageNotFoundError({ + threadChannelId: payload.threadChannelId, + messageId: payload.originalMessageId, + }), + ) + } - const orig = originalMessage[0]! + const orig = originalMessage[0]! - // Get thread messages (messages in the thread channel) - const threadMessages = yield* db - .execute((client) => - client - .select({ - id: schema.messagesTable.id, - content: schema.messagesTable.content, - authorId: schema.messagesTable.authorId, - createdAt: schema.messagesTable.createdAt, - firstName: schema.usersTable.firstName, - lastName: schema.usersTable.lastName, - }) - .from(schema.messagesTable) - .innerJoin( - schema.usersTable, - eq(schema.messagesTable.authorId, schema.usersTable.id), - ) - .where( - and( - eq(schema.messagesTable.channelId, payload.threadChannelId), - isNull(schema.messagesTable.deletedAt), + // Get thread messages (messages in the thread channel) + const threadMessages = yield* db + .execute((client) => + client + .select({ + id: schema.messagesTable.id, + content: schema.messagesTable.content, + authorId: schema.messagesTable.authorId, + createdAt: schema.messagesTable.createdAt, + firstName: schema.usersTable.firstName, + lastName: schema.usersTable.lastName, + }) + .from(schema.messagesTable) + .innerJoin( + schema.usersTable, + eq(schema.messagesTable.authorId, schema.usersTable.id), + ) + .where( + and( + eq(schema.messagesTable.channelId, payload.threadChannelId), + isNull(schema.messagesTable.deletedAt), + ), + ) + .orderBy(schema.messagesTable.createdAt) + .limit(10), + ) + .pipe( + Effect.catchTag("DatabaseError", (err) => + Effect.fail( + new Cluster.ThreadContextQueryError({ + threadChannelId: payload.threadChannelId, + operation: "threadMessages", + cause: err, + }), ), - ) - .orderBy(schema.messagesTable.createdAt) - .limit(10), - ) - .pipe( - Effect.catchTag("DatabaseError", (err) => - Effect.fail( - new Cluster.ThreadContextQueryError({ - threadChannelId: payload.threadChannelId, - operation: "threadMessages", - cause: err, - }), ), - ), - ) + ) - yield* Effect.annotateCurrentSpan("activity.thread_message_count", threadMessages.length) + yield* Effect.annotateCurrentSpan("activity.thread_message_count", threadMessages.length) - return { - threadChannelId: payload.threadChannelId, - currentName: thread.name, - originalMessage: { - id: orig.id, - content: orig.content ?? "", - authorId: orig.authorId, - authorName: `${orig.firstName} ${orig.lastName}`.trim(), - createdAt: orig.createdAt.toISOString(), - }, - threadMessages: threadMessages.map((m) => ({ - id: m.id, - content: m.content ?? "", - authorId: m.authorId, - authorName: `${m.firstName} ${m.lastName}`.trim(), - createdAt: m.createdAt.toISOString(), - })), - } - }), + return { + threadChannelId: payload.threadChannelId, + currentName: thread.name, + originalMessage: { + id: orig.id, + content: orig.content ?? "", + authorId: orig.authorId, + authorName: `${orig.firstName} ${orig.lastName}`.trim(), + createdAt: orig.createdAt.toISOString(), + }, + threadMessages: threadMessages.map((m) => ({ + id: m.id, + content: m.content ?? "", + authorId: m.authorId, + authorName: `${m.firstName} ${m.lastName}`.trim(), + createdAt: m.createdAt.toISOString(), + })), + } + }), + }) }).pipe( - Effect.tapError((err) => + Effect.tapError((err: { readonly _tag: string; readonly cause?: unknown }) => Effect.logError("GetThreadContext activity failed", { threadChannelId: payload.threadChannelId, errorTag: err._tag, - cause: "cause" in err ? String(err.cause) : undefined, + cause: err.cause != null ? String(err.cause) : undefined, }), ), ) // Activity 2: Generate thread name using AI - const nameResult: Cluster.GenerateThreadNameResult = yield* Activity.make({ - name: "GenerateThreadName", - success: Cluster.GenerateThreadNameResult, - error: Cluster.ThreadNamingWorkflowError, - execute: Effect.gen(function* () { - // Build the prompt - const threadMessagesText = contextResult.threadMessages - .map((m: Cluster.ThreadMessageContext) => `${m.authorName}: ${m.content}`) - .join("\n") + const nameResult: Cluster.GenerateThreadNameResult = yield* Effect.gen(function* () { + return yield* Activity.make({ + name: "GenerateThreadName", + success: Cluster.GenerateThreadNameResult, + error: Cluster.ThreadNamingWorkflowError, + execute: Effect.gen(function* () { + // Build the prompt + const threadMessagesText = contextResult.threadMessages + .map((m: Cluster.ThreadMessageContext) => `${m.authorName}: ${m.content}`) + .join("\n") - const prompt = NAMING_PROMPT.replace( - "{originalAuthor}", - contextResult.originalMessage.authorName, - ) - .replace("{originalContent}", contextResult.originalMessage.content) - .replace("{threadMessages}", threadMessagesText || "(no replies yet)") + const prompt = NAMING_PROMPT.replace( + "{originalAuthor}", + contextResult.originalMessage.authorName, + ) + .replace("{originalContent}", contextResult.originalMessage.content) + .replace("{threadMessages}", threadMessagesText || "(no replies yet)") - // Call the AI model - const response = yield* LanguageModel.generateText({ - prompt, - }).pipe( - Effect.catchTags({ - HttpRequestError: (err) => - Effect.fail( - new Cluster.AIProviderUnavailableError({ - provider: "openrouter", - cause: err, - }), - ), - HttpResponseError: (err) => - err.response.status === 429 - ? Effect.fail( + type AiMappedError = + | Cluster.AIProviderUnavailableError + | Cluster.AIRateLimitError + | Cluster.AIResponseParseError + + // Call the AI model + const response = yield* LanguageModel.generateText({ + prompt, + }).pipe( + Effect.catchTag( + "AiError", + (err: AiError.AiError): Effect.Effect => { + const reason = err.reason + if (reason._tag === "RateLimitError") { + return Effect.fail( new Cluster.AIRateLimitError({ provider: "openrouter", }), ) - : Effect.fail( - new Cluster.AIProviderUnavailableError({ - provider: "openrouter", - cause: err, + } + if ( + reason._tag === "InvalidOutputError" || + reason._tag === "StructuredOutputError" + ) { + return Effect.fail( + new Cluster.AIResponseParseError({ + threadChannelId: payload.threadChannelId, + rawResponse: reason.message, }), - ), - MalformedInput: (err) => - Effect.fail( - new Cluster.AIResponseParseError({ - threadChannelId: payload.threadChannelId, - rawResponse: err.description, - }), - ), - MalformedOutput: (err) => - Effect.fail( - new Cluster.AIResponseParseError({ - threadChannelId: payload.threadChannelId, - rawResponse: err.description, - }), - ), - UnknownError: (err) => - Effect.fail( - new Cluster.AIProviderUnavailableError({ - provider: "openrouter", - cause: err, - }), - ), - }), - ) + ) + } + return Effect.fail( + new Cluster.AIProviderUnavailableError({ + provider: "openrouter", + cause: err, + }), + ) + }, + ), + ) - // Clean up the response - let threadName = response.text.trim() - // Remove quotes if present - threadName = threadName.replace(/^["']|["']$/g, "") - // Truncate if too long (max 50 chars) - if (threadName.length > 50) { - threadName = threadName.substring(0, 47) + "..." - } - // Fallback if empty - if (!threadName) { - threadName = "Discussion" - } + // Clean up the response + let threadName = response.text.trim() + // Remove quotes if present + threadName = threadName.replace(/^["']|["']$/g, "") + // Truncate if too long (max 50 chars) + if (threadName.length > 50) { + threadName = threadName.substring(0, 47) + "..." + } + // Fallback if empty + if (!threadName) { + threadName = "Discussion" + } - yield* Effect.annotateCurrentSpan("activity.ai_provider", "openrouter") - yield* Effect.annotateCurrentSpan("activity.generated_name", threadName) + yield* Effect.annotateCurrentSpan("activity.ai_provider", "openrouter") + yield* Effect.annotateCurrentSpan("activity.generated_name", threadName) - yield* Effect.logDebug(`Generated thread name: ${threadName}`) + yield* Effect.logDebug(`Generated thread name: ${threadName}`) - return { threadName } - }), - }).pipe( - Effect.tapError((err) => - Effect.logError("GenerateThreadName activity failed", { - threadChannelId: payload.threadChannelId, - errorTag: err._tag, - provider: "provider" in err ? err.provider : undefined, - cause: "cause" in err ? String(err.cause) : undefined, + return { threadName } }), + }) + }).pipe( + Effect.tapError( + (err: { readonly _tag: string; readonly provider?: string; readonly cause?: unknown }) => + Effect.logError("GenerateThreadName activity failed", { + threadChannelId: payload.threadChannelId, + errorTag: err._tag, + provider: err.provider, + cause: err.cause != null ? String(err.cause) : undefined, + }), ), ) // Activity 3: Update thread name in database - yield* Activity.make({ - name: "UpdateThreadName", - success: Cluster.UpdateThreadNameResult, - error: Cluster.ThreadNamingWorkflowError, - execute: Effect.gen(function* () { - const db = yield* Database.Database + yield* Effect.gen(function* () { + return yield* Activity.make({ + name: "UpdateThreadName", + success: Cluster.UpdateThreadNameResult, + error: Cluster.ThreadNamingWorkflowError, + execute: Effect.gen(function* () { + const db = yield* Database.Database - yield* db - .execute((client) => - client - .update(schema.channelsTable) - .set({ - name: nameResult.threadName, - updatedAt: new Date(), - }) - .where(eq(schema.channelsTable.id, payload.threadChannelId)), - ) - .pipe( - Effect.catchTag("DatabaseError", (err) => - Effect.fail( - new Cluster.ThreadNameUpdateError({ - threadChannelId: payload.threadChannelId, - newName: nameResult.threadName, - cause: err, - }), + yield* db + .execute((client) => + client + .update(schema.channelsTable) + .set({ + name: nameResult.threadName, + updatedAt: new Date(), + }) + .where(eq(schema.channelsTable.id, payload.threadChannelId)), + ) + .pipe( + Effect.catchTag("DatabaseError", (err) => + Effect.fail( + new Cluster.ThreadNameUpdateError({ + threadChannelId: payload.threadChannelId, + newName: nameResult.threadName, + cause: err, + }), + ), ), - ), - ) + ) - yield* Effect.annotateCurrentSpan("activity.previous_name", contextResult.currentName ?? "") - yield* Effect.annotateCurrentSpan("activity.new_name", nameResult.threadName) + yield* Effect.annotateCurrentSpan( + "activity.previous_name", + contextResult.currentName ?? "", + ) + yield* Effect.annotateCurrentSpan("activity.new_name", nameResult.threadName) - yield* Effect.logDebug( - `Updated thread ${payload.threadChannelId} name from "${contextResult.currentName}" to "${nameResult.threadName}"`, - ) + yield* Effect.logDebug( + `Updated thread ${payload.threadChannelId} name from "${contextResult.currentName}" to "${nameResult.threadName}"`, + ) - return { - success: true, - previousName: contextResult.currentName, - newName: nameResult.threadName, - } - }), - }).pipe( - Effect.tapError((err) => - Effect.logError("UpdateThreadName activity failed", { - threadChannelId: payload.threadChannelId, - errorTag: err._tag, - newName: "newName" in err ? err.newName : undefined, - cause: "cause" in err ? String(err.cause) : undefined, + return { + success: true, + previousName: contextResult.currentName, + newName: nameResult.threadName, + } }), + }) + }).pipe( + Effect.tapError( + (err: { readonly _tag: string; readonly newName?: string; readonly cause?: unknown }) => + Effect.logError("UpdateThreadName activity failed", { + threadChannelId: payload.threadChannelId, + errorTag: err._tag, + newName: err.newName, + cause: err.cause != null ? String(err.cause) : undefined, + }), ), ) diff --git a/apps/cluster/vitest.config.ts b/apps/cluster/vitest.config.ts new file mode 100644 index 000000000..94a64e2f7 --- /dev/null +++ b/apps/cluster/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({}) diff --git a/apps/docs/src/routeTree.gen.ts b/apps/docs/src/routeTree.gen.ts index f759073a6..f9984f5d6 100644 --- a/apps/docs/src/routeTree.gen.ts +++ b/apps/docs/src/routeTree.gen.ts @@ -8,79 +8,77 @@ // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. -import { Route as rootRouteImport } from './routes/__root' -import { Route as SplatRouteImport } from './routes/$' -import { Route as ApiSearchRouteImport } from './routes/api/search' +import { Route as rootRouteImport } from "./routes/__root" +import { Route as SplatRouteImport } from "./routes/$" +import { Route as ApiSearchRouteImport } from "./routes/api/search" const SplatRoute = SplatRouteImport.update({ - id: '/$', - path: '/$', - getParentRoute: () => rootRouteImport, + id: "/$", + path: "/$", + getParentRoute: () => rootRouteImport, } as any) const ApiSearchRoute = ApiSearchRouteImport.update({ - id: '/api/search', - path: '/api/search', - getParentRoute: () => rootRouteImport, + id: "/api/search", + path: "/api/search", + getParentRoute: () => rootRouteImport, } as any) export interface FileRoutesByFullPath { - '/$': typeof SplatRoute - '/api/search': typeof ApiSearchRoute + "/$": typeof SplatRoute + "/api/search": typeof ApiSearchRoute } export interface FileRoutesByTo { - '/$': typeof SplatRoute - '/api/search': typeof ApiSearchRoute + "/$": typeof SplatRoute + "/api/search": typeof ApiSearchRoute } export interface FileRoutesById { - __root__: typeof rootRouteImport - '/$': typeof SplatRoute - '/api/search': typeof ApiSearchRoute + __root__: typeof rootRouteImport + "/$": typeof SplatRoute + "/api/search": typeof ApiSearchRoute } export interface FileRouteTypes { - fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/$' | '/api/search' - fileRoutesByTo: FileRoutesByTo - to: '/$' | '/api/search' - id: '__root__' | '/$' | '/api/search' - fileRoutesById: FileRoutesById + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: "/$" | "/api/search" + fileRoutesByTo: FileRoutesByTo + to: "/$" | "/api/search" + id: "__root__" | "/$" | "/api/search" + fileRoutesById: FileRoutesById } export interface RootRouteChildren { - SplatRoute: typeof SplatRoute - ApiSearchRoute: typeof ApiSearchRoute + SplatRoute: typeof SplatRoute + ApiSearchRoute: typeof ApiSearchRoute } -declare module '@tanstack/react-router' { - interface FileRoutesByPath { - '/$': { - id: '/$' - path: '/$' - fullPath: '/$' - preLoaderRoute: typeof SplatRouteImport - parentRoute: typeof rootRouteImport - } - '/api/search': { - id: '/api/search' - path: '/api/search' - fullPath: '/api/search' - preLoaderRoute: typeof ApiSearchRouteImport - parentRoute: typeof rootRouteImport - } - } +declare module "@tanstack/react-router" { + interface FileRoutesByPath { + "/$": { + id: "/$" + path: "/$" + fullPath: "/$" + preLoaderRoute: typeof SplatRouteImport + parentRoute: typeof rootRouteImport + } + "/api/search": { + id: "/api/search" + path: "/api/search" + fullPath: "/api/search" + preLoaderRoute: typeof ApiSearchRouteImport + parentRoute: typeof rootRouteImport + } + } } const rootRouteChildren: RootRouteChildren = { - SplatRoute: SplatRoute, - ApiSearchRoute: ApiSearchRoute, + SplatRoute: SplatRoute, + ApiSearchRoute: ApiSearchRoute, } -export const routeTree = rootRouteImport - ._addFileChildren(rootRouteChildren) - ._addFileTypes() +export const routeTree = rootRouteImport._addFileChildren(rootRouteChildren)._addFileTypes() -import type { getRouter } from './router.tsx' -import type { createStart } from '@tanstack/react-start' -declare module '@tanstack/react-start' { - interface Register { - ssr: true - router: Awaited> - } +import type { getRouter } from "./router.tsx" +import type { createStart } from "@tanstack/react-start" +declare module "@tanstack/react-start" { + interface Register { + ssr: true + router: Awaited> + } } diff --git a/apps/electric-proxy/package.json b/apps/electric-proxy/package.json index 174af38a4..7f146453d 100644 --- a/apps/electric-proxy/package.json +++ b/apps/electric-proxy/package.json @@ -9,8 +9,6 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@effect/experimental": "catalog:effect", - "@effect/platform": "catalog:effect", "@effect/platform-bun": "catalog:effect", "@electric-sql/client": "1.5.12", "@hazel/auth": "workspace:*", diff --git a/apps/electric-proxy/src/auth/bot-auth.ts b/apps/electric-proxy/src/auth/bot-auth.ts index 2e9a0bd88..8e65916f3 100644 --- a/apps/electric-proxy/src/auth/bot-auth.ts +++ b/apps/electric-proxy/src/auth/bot-auth.ts @@ -18,7 +18,7 @@ export interface AuthenticatedBot { /** * Bot authentication error */ -export class BotAuthenticationError extends Schema.TaggedError( +export class BotAuthenticationError extends Schema.TaggedErrorClass( "BotAuthenticationError", )("BotAuthenticationError", { message: Schema.String, @@ -78,7 +78,7 @@ export const validateBotToken = Effect.fn("ElectricProxy.validateBotToken")(func .where(and(eq(schema.botsTable.apiTokenHash, tokenHash), isNull(schema.botsTable.deletedAt))) .limit(1), ) - const botOption = Option.fromNullable(botResult[0]) + const botOption = Option.fromNullishOr(botResult[0]) if (Option.isNone(botOption)) { yield* Effect.annotateCurrentSpan("auth.token.valid", false) diff --git a/apps/electric-proxy/src/cache/access-context-cache.ts b/apps/electric-proxy/src/cache/access-context-cache.ts index 535ef353a..06b152e78 100644 --- a/apps/electric-proxy/src/cache/access-context-cache.ts +++ b/apps/electric-proxy/src/cache/access-context-cache.ts @@ -1,5 +1,6 @@ import type { ChannelId } from "@hazel/schema" -import { Duration, PrimaryKey, Schema } from "effect" +import { Duration, Schema } from "effect" +import { Persistable } from "effect/unstable/persistence" /** * Cache configuration constants @@ -23,32 +24,27 @@ export type BotAccessContext = { /** * Cache lookup error - when we fail to fetch from database */ -export class AccessContextLookupError extends Schema.TaggedError()( +export class AccessContextLookupError extends Schema.TaggedErrorClass()( "AccessContextLookupError", { message: Schema.String, detail: Schema.optional(Schema.String), entityId: Schema.String, - entityType: Schema.Literal("user", "bot"), + entityType: Schema.Literals(["user", "bot"]), }, ) {} /** * Cache request for bot access context. - * Implements Schema.TaggedRequest (provides WithResult) and PrimaryKey. + * Implements Persistable.Class (provides persistence key and schemas) and PrimaryKey. */ -export class BotAccessContextRequest extends Schema.TaggedRequest()( - "BotAccessContextRequest", - { - failure: AccessContextLookupError, - success: BotAccessContextSchema, - payload: { - botId: Schema.String, - userId: Schema.String, - }, - }, -) { - [PrimaryKey.symbol]() { - return `bot:${this.botId}` +export class BotAccessContextRequest extends Persistable.Class<{ + payload: { + botId: string + userId: string } -} +}>()("BotAccessContextRequest", { + primaryKey: (payload) => `bot:${payload.botId}`, + success: BotAccessContextSchema, + error: AccessContextLookupError, +}) {} diff --git a/apps/electric-proxy/src/cache/access-context-service.ts b/apps/electric-proxy/src/cache/access-context-service.ts index 1827dc3b3..5473fd91b 100644 --- a/apps/electric-proxy/src/cache/access-context-service.ts +++ b/apps/electric-proxy/src/cache/access-context-service.ts @@ -1,7 +1,7 @@ -import { PersistedCache, type Persistence } from "@effect/experimental" +import { PersistedCache, Persistence } from "effect/unstable/persistence" import { and, Database, eq, isNull, schema } from "@hazel/db" import type { BotId, ChannelId, UserId } from "@hazel/schema" -import { Effect } from "effect" +import { ServiceMap, Effect, Layer, Schema } from "effect" import { AccessContextLookupError, type BotAccessContext, @@ -20,7 +20,10 @@ export interface AccessContextCache { readonly getBotContext: ( botId: BotId, userId: UserId, - ) => Effect.Effect + ) => Effect.Effect< + BotAccessContext, + AccessContextLookupError | Persistence.PersistenceError | Schema.SchemaError + > readonly invalidateBot: (botId: BotId) => Effect.Effect } @@ -32,11 +35,10 @@ export interface AccessContextCache { * Note: Database.Database is intentionally NOT included in dependencies * as it's a global infrastructure layer provided at the application root. */ -export class AccessContextCacheService extends Effect.Service()( +export class AccessContextCacheService extends ServiceMap.Service()( "AccessContextCacheService", { - accessors: true, - scoped: Effect.gen(function* () { + make: Effect.gen(function* () { const db = yield* Database.Database // Create bot access context cache @@ -70,27 +72,27 @@ export class AccessContextCacheService extends Effect.Service + Effect.catchTag("DatabaseError", (error) => + Effect.fail( new AccessContextLookupError({ message: "Failed to query bot's channels", detail: error.message, entityId: request.botId, entityType: "bot", }), + ), ), ) - const channelIds = channels.map((c) => c.channelId) + const channelIds = channels.map((c: { channelId: ChannelId }) => c.channelId) yield* Effect.annotateCurrentSpan("cache.result_size", channelIds.length) return { channelIds } }), - timeToLive: () => CACHE_TTL, + timeToLive: (_exit, _request) => CACHE_TTL, inMemoryCapacity: IN_MEMORY_CAPACITY, - inMemoryTTL: IN_MEMORY_TTL, + inMemoryTTL: (_exit, _request) => IN_MEMORY_TTL, }) return { @@ -117,4 +119,6 @@ export class AccessContextCacheService extends Effect.Service()("ProxyConfigService", { - accessors: true, - effect: Effect.gen(function* () { +export class ProxyConfigService extends ServiceMap.Service()("ProxyConfigService", { + make: Effect.gen(function* () { const electricUrl = yield* Config.string("ELECTRIC_URL") const electricSourceId = yield* Config.string("ELECTRIC_SOURCE_ID").pipe( Config.option, - Effect.map(Option.getOrUndefined), + Config.map(Option.getOrUndefined), ) const electricSourceSecret = yield* Config.string("ELECTRIC_SOURCE_SECRET").pipe( Config.option, - Effect.map(Option.getOrUndefined), + Config.map(Option.getOrUndefined), ) const workosApiKey = yield* Config.string("WORKOS_API_KEY") const workosClientId = yield* Config.string("WORKOS_CLIENT_ID") @@ -43,7 +42,7 @@ export class ProxyConfigService extends Effect.Service()("Pr const port = yield* Config.number("PORT").pipe(Config.withDefault(8184)) const otlpEndpoint = yield* Config.string("OTLP_ENDPOINT").pipe( Config.option, - Effect.map(Option.getOrUndefined), + Config.map(Option.getOrUndefined), ) const redisUrl = yield* Config.redacted("REDIS_URL").pipe( Config.withDefault(Redacted.make("redis://localhost:6380")), @@ -63,4 +62,6 @@ export class ProxyConfigService extends Effect.Service()("Pr redisUrl, } satisfies ProxyConfig }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/apps/electric-proxy/src/index.ts b/apps/electric-proxy/src/index.ts index 1b2ed276d..15a337a69 100644 --- a/apps/electric-proxy/src/index.ts +++ b/apps/electric-proxy/src/index.ts @@ -1,7 +1,7 @@ import { BunRuntime } from "@effect/platform-bun" import { ProxyAuth } from "@hazel/auth/proxy" import { Database } from "@hazel/db" -import { Effect, Layer, Logger, Metric, Runtime } from "effect" +import { Effect, Layer, Logger, Metric } from "effect" import { validateBotToken } from "./auth/bot-auth" import { validateSession } from "./auth/user-auth" import { @@ -137,8 +137,12 @@ const handleUserRequest = (request: Request) => { Effect.gen(function* () { yield* annotateHandledError(401, "ProxyAuthenticationError") yield* Effect.logInfo("Authentication failed", { detail: error.detail }) - yield* Metric.increment(proxyAuthFailures).pipe( - Effect.tagMetrics({ auth_type: "user", error_tag: "ProxyAuthenticationError" }), + yield* Metric.update( + Metric.withAttributes(proxyAuthFailures, { + auth_type: "user", + error_tag: "ProxyAuthenticationError", + }), + 1, ) return new Response( JSON.stringify({ @@ -180,7 +184,7 @@ const handleUserRequest = (request: Request) => { }), ), // Fallback for any unhandled errors - returns error details to client for debugging - Effect.catchAll((error) => + Effect.catch((error: unknown) => Effect.gen(function* () { const errorTag = (error as { _tag?: string })?._tag ?? "UnknownError" yield* annotateHandledError(500, errorTag) @@ -206,12 +210,13 @@ const handleUserRequest = (request: Request) => { const duration = Date.now() - start yield* Effect.annotateCurrentSpan("http.status_code", response.status) yield* Effect.annotateCurrentSpan("http.response.status_code", response.status) - yield* Metric.increment(proxyRequestsTotal).pipe( - Effect.tagMetrics({ + yield* Metric.update( + Metric.withAttributes(proxyRequestsTotal, { route: "/v1/shape", auth_type: "user", status_code: String(response.status), }), + 1, ) yield* Metric.update(proxyRequestDuration, duration) }), @@ -317,8 +322,12 @@ const handleBotRequest = (request: Request) => { yield* Effect.logInfo("Bot authentication failed", { detail: error.detail, }) - yield* Metric.increment(proxyAuthFailures).pipe( - Effect.tagMetrics({ auth_type: "bot", error_tag: "BotAuthenticationError" }), + yield* Metric.update( + Metric.withAttributes(proxyAuthFailures, { + auth_type: "bot", + error_tag: "BotAuthenticationError", + }), + 1, ) return new Response( JSON.stringify({ @@ -373,7 +382,7 @@ const handleBotRequest = (request: Request) => { }), ), // Fallback for any unhandled errors - returns error details to client for debugging - Effect.catchAll((error) => + Effect.catch((error: unknown) => Effect.gen(function* () { const errorTag = (error as { _tag?: string })?._tag ?? "UnknownError" yield* annotateHandledError(500, errorTag) @@ -396,12 +405,13 @@ const handleBotRequest = (request: Request) => { const duration = Date.now() - start yield* Effect.annotateCurrentSpan("http.status_code", response.status) yield* Effect.annotateCurrentSpan("http.response.status_code", response.status) - yield* Metric.increment(proxyRequestsTotal).pipe( - Effect.tagMetrics({ + yield* Metric.update( + Metric.withAttributes(proxyRequestsTotal, { route: "/bot/v1/shape", auth_type: "bot", status_code: String(response.status), }), + 1, ) yield* Metric.update(proxyRequestDuration, duration) }), @@ -419,7 +429,7 @@ const handleBotRequest = (request: Request) => { // LAYERS // ============================================================================= -const DatabaseLive = Layer.unwrapEffect( +const DatabaseLive = Layer.unwrap( Effect.gen(function* () { const config = yield* ProxyConfigService yield* Effect.log("Connecting to database", { isDev: config.isDev }) @@ -430,30 +440,32 @@ const DatabaseLive = Layer.unwrapEffect( }), ) -const LoggerLive = Layer.unwrapEffect( +const LoggerLive = Layer.unwrap( Effect.gen(function* () { const config = yield* ProxyConfigService - return config.isDev ? Logger.pretty : Logger.structured + return config.isDev + ? Logger.layer([Logger.consolePretty()]) + : Logger.layer([Logger.withConsoleLog(Logger.formatStructured)]) }), -).pipe(Layer.provide(ProxyConfigService.Default)) +).pipe(Layer.provide(ProxyConfigService.layer)) // Cache layer: AccessContextCache requires ResultPersistence and Database -const CacheLive = AccessContextCache.Default.pipe( +const CacheLive = AccessContextCache.layer.pipe( Layer.provide(RedisPersistenceLive), Layer.provide(DatabaseLive), - Layer.provide(ProxyConfigService.Default), + Layer.provide(ProxyConfigService.layer), ) // ProxyAuth layer requires ResultPersistence for session caching and Database for user lookup -// ProxyAuth.Default includes SessionValidator.Default via dependencies -const ProxyAuthLive = ProxyAuth.Default.pipe( +// ProxyAuth.layer includes SessionValidator.layer via dependencies +const ProxyAuthLive = ProxyAuth.layer.pipe( Layer.provide(RedisPersistenceLive), Layer.provide(DatabaseLive), - Layer.provide(ProxyConfigService.Default), + Layer.provide(ProxyConfigService.layer), ) const MainLive = DatabaseLive.pipe( - Layer.provideMerge(ProxyConfigService.Default), + Layer.provideMerge(ProxyConfigService.layer), Layer.provideMerge(LoggerLive), Layer.provideMerge(CacheLive), Layer.provideMerge(TracerLive), @@ -464,7 +476,7 @@ const MainLive = DatabaseLive.pipe( // SERVER // ============================================================================= -const ServerLive = Layer.scopedDiscard( +const ServerLive = Layer.effectDiscard( Effect.gen(function* () { const config = yield* ProxyConfigService @@ -480,9 +492,10 @@ const ServerLive = Layer.scopedDiscard( }) } - const runtime = yield* Effect.runtime< + const serviceMap = yield* Effect.services< ProxyConfigService | Database.Database | AccessContextCacheService | ProxyAuth >() + const run = Effect.runPromiseWith(serviceMap) yield* Effect.acquireRelease( Effect.sync(() => @@ -492,8 +505,8 @@ const ServerLive = Layer.scopedDiscard( idleTimeout: 120, routes: { "/health": new Response("OK"), // Static response - zero allocation - "/v1/shape": (req) => Runtime.runPromise(runtime)(handleUserRequest(req)), - "/bot/v1/shape": (req) => Runtime.runPromise(runtime)(handleBotRequest(req)), + "/v1/shape": (req) => run(handleUserRequest(req)), + "/bot/v1/shape": (req) => run(handleBotRequest(req)), }, fetch() { return new Response("Not Found", { status: 404 }) diff --git a/apps/electric-proxy/src/observability/metrics.ts b/apps/electric-proxy/src/observability/metrics.ts index 108da3465..1a1016d6c 100644 --- a/apps/electric-proxy/src/observability/metrics.ts +++ b/apps/electric-proxy/src/observability/metrics.ts @@ -3,9 +3,9 @@ * * Provides counters and histograms for monitoring proxy performance. */ -import { Metric, MetricBoundaries } from "effect" +import { Metric } from "effect" -const latencyBoundaries = MetricBoundaries.fromIterable([5, 10, 25, 50, 100, 250, 500, 1000, 2500]) +const latencyBoundaries = [5, 10, 25, 50, 100, 250, 500, 1000, 2500] as const // ============================================================================ // Counters @@ -25,7 +25,11 @@ export const proxyElectricErrors = Metric.counter("proxy.electric.errors") // ============================================================================ /** End-to-end proxy request duration */ -export const proxyRequestDuration = Metric.histogram("proxy.request.duration_ms", latencyBoundaries) +export const proxyRequestDuration = Metric.histogram("proxy.request.duration_ms", { + boundaries: latencyBoundaries, +}) /** Upstream Electric fetch latency */ -export const proxyElectricDuration = Metric.histogram("proxy.electric.duration_ms", latencyBoundaries) +export const proxyElectricDuration = Metric.histogram("proxy.electric.duration_ms", { + boundaries: latencyBoundaries, +}) diff --git a/apps/electric-proxy/src/proxy/electric-client.ts b/apps/electric-proxy/src/proxy/electric-client.ts index 81e1242d3..c3e568416 100644 --- a/apps/electric-proxy/src/proxy/electric-client.ts +++ b/apps/electric-proxy/src/proxy/electric-client.ts @@ -6,7 +6,7 @@ import { proxyElectricDuration, proxyElectricErrors } from "../observability/met /** * Error thrown when Electric proxy request fails */ -export class ElectricProxyError extends Schema.TaggedError()("ElectricProxyError", { +export class ElectricProxyError extends Schema.TaggedErrorClass()("ElectricProxyError", { message: Schema.String, detail: Schema.optional(Schema.String), }) {} @@ -83,8 +83,9 @@ export const proxyElectricRequest = Effect.fn("ElectricClient.proxyElectricReque if (upstreamRequestId) { yield* Effect.annotateCurrentSpan("electric.upstream_request_id", upstreamRequestId) } - yield* Metric.increment(proxyElectricErrors).pipe( - Effect.tagMetrics({ status_code: String(response.status) }), + yield* Metric.update( + Metric.withAttributes(proxyElectricErrors, { status_code: String(response.status) }), + 1, ) const errorBody = yield* Effect.promise(() => response.text()) yield* Effect.logWarning("Electric returned non-2xx", { diff --git a/apps/electric-proxy/src/tables/bot-tables.ts b/apps/electric-proxy/src/tables/bot-tables.ts index 0e203c0f7..53b8b3ba9 100644 --- a/apps/electric-proxy/src/tables/bot-tables.ts +++ b/apps/electric-proxy/src/tables/bot-tables.ts @@ -6,11 +6,14 @@ import type { WhereClauseResult } from "./where-clause-builder" /** * Error thrown when bot table access is denied or where clause cannot be generated */ -export class BotTableAccessError extends Schema.TaggedError()("BotTableAccessError", { - message: Schema.String, - detail: Schema.optional(Schema.String), - table: Schema.String, -}) {} +export class BotTableAccessError extends Schema.TaggedErrorClass()( + "BotTableAccessError", + { + message: Schema.String, + detail: Schema.optional(Schema.String), + table: Schema.String, + }, +) {} /** * Tables that bots can access through the Electric proxy. diff --git a/apps/electric-proxy/src/tables/user-tables.test.ts b/apps/electric-proxy/src/tables/user-tables.test.ts index 62a3a9013..816151145 100644 --- a/apps/electric-proxy/src/tables/user-tables.test.ts +++ b/apps/electric-proxy/src/tables/user-tables.test.ts @@ -17,8 +17,10 @@ describe("user table where clauses", () => { expect(result.params).toEqual([testUser.internalUserId]) expect(result.whereClause).toContain(`"deletedAt" IS NULL`) - expect(result.whereClause).toContain(`IN (SELECT "conversationId" FROM connect_conversation_channels`) - expect(result.whereClause).toContain(`"channelId" IN (SELECT "channelId" FROM channel_access`) + expect(result.whereClause).toMatch( + /IN \(SELECT "conversationId" FROM "?connect_conversation_channels"?/, + ) + expect(result.whereClause).toMatch(/"channelId" IN \(SELECT "channelId" FROM "?channel_access"?/) }) it("filters connect conversation channels by channel access", async () => { @@ -26,8 +28,8 @@ describe("user table where clauses", () => { expect(result.params).toEqual([testUser.internalUserId]) expect(result.whereClause).toContain(`"deletedAt" IS NULL AND`) - expect(result.whereClause).toContain( - `"channelId" IN (SELECT "channelId" FROM channel_access WHERE "userId" = $1)`, + expect(result.whereClause).toMatch( + /"channelId" IN \(SELECT "channelId" FROM "?channel_access"? WHERE "userId" = \$1\)/, ) }) @@ -36,8 +38,8 @@ describe("user table where clauses", () => { expect(result.params).toEqual([testUser.internalUserId]) expect(result.whereClause).toContain(`"deletedAt" IS NULL AND`) - expect(result.whereClause).toContain( - `"channelId" IN (SELECT "channelId" FROM channel_access WHERE "userId" = $1)`, + expect(result.whereClause).toMatch( + /"channelId" IN \(SELECT "channelId" FROM "?channel_access"? WHERE "userId" = \$1\)/, ) }) @@ -46,23 +48,27 @@ describe("user table where clauses", () => { expect(result.params).toEqual([testUser.internalUserId]) expect(result.whereClause).toContain(`"deletedAt" IS NULL`) - expect(result.whereClause).toContain( - `"channelId" IN (SELECT "channelId" FROM channel_access WHERE "userId" = $1)`, + expect(result.whereClause).toMatch( + /"channelId" IN \(SELECT "channelId" FROM "?channel_access"? WHERE "userId" = \$1\)/, ) expect(result.whereClause).toContain(`"conversationId" IS NULL`) expect(result.whereClause).toContain(`"conversationId" IS NOT NULL`) - expect(result.whereClause).toContain(`IN (SELECT "conversationId" FROM connect_conversation_channels`) + expect(result.whereClause).toMatch( + /IN \(SELECT "conversationId" FROM "?connect_conversation_channels"?/, + ) }) it("filters message reactions by channel access and conversation access", async () => { const result = await run(getWhereClauseForTable("message_reactions", testUser)) expect(result.params).toEqual([testUser.internalUserId]) - expect(result.whereClause).toContain( - `"channelId" IN (SELECT "channelId" FROM channel_access WHERE "userId" = $1)`, + expect(result.whereClause).toMatch( + /"channelId" IN \(SELECT "channelId" FROM "?channel_access"? WHERE "userId" = \$1\)/, ) expect(result.whereClause).toContain(`"conversationId" IS NULL`) expect(result.whereClause).toContain(`"conversationId" IS NOT NULL`) - expect(result.whereClause).toContain(`IN (SELECT "conversationId" FROM connect_conversation_channels`) + expect(result.whereClause).toMatch( + /IN \(SELECT "conversationId" FROM "?connect_conversation_channels"?/, + ) }) }) diff --git a/apps/electric-proxy/src/tables/user-tables.ts b/apps/electric-proxy/src/tables/user-tables.ts index b5b25c9ea..5362a3819 100644 --- a/apps/electric-proxy/src/tables/user-tables.ts +++ b/apps/electric-proxy/src/tables/user-tables.ts @@ -1,4 +1,4 @@ -import { schema } from "@hazel/db" +import { schema, sql } from "@hazel/db" import { Effect, Match, Schema } from "effect" import type { AuthenticatedUser } from "../auth/user-auth" import { @@ -9,13 +9,18 @@ import { buildNoFilterClause, buildOrgMembershipClause, buildUserMembershipClause, + col, + eqCol, + inSubquery, + isNullCol, + sqlToWhereClause, type WhereClauseResult, } from "./where-clause-builder" /** * Error thrown when table access is denied or where clause cannot be generated */ -export class TableAccessError extends Schema.TaggedError()("TableAccessError", { +export class TableAccessError extends Schema.TaggedErrorClass()("TableAccessError", { message: Schema.String, detail: Schema.optional(Schema.String), table: Schema.String, @@ -127,6 +132,9 @@ export function getWhereClauseForTable( table: AllowedTable, user: AuthenticatedUser, ): Effect.Effect { + const channelAccessSubquery = sql`(SELECT ${col(schema.channelAccessTable.channelId)} FROM ${schema.channelAccessTable} WHERE ${eqCol(schema.channelAccessTable.userId, user.internalUserId)})` + const connectConversationAccessSubquery = sql`(SELECT ${col(schema.connectConversationChannelsTable.conversationId)} FROM ${schema.connectConversationChannelsTable} WHERE ${isNullCol(schema.connectConversationChannelsTable.deletedAt)} AND ${col(schema.connectConversationChannelsTable.channelId)} IN ${channelAccessSubquery})` + // Chat Sync tables — handled before Match.pipe to stay within its 20-arg type limit switch (table) { case "chat_sync_connections": @@ -145,15 +153,26 @@ export function getWhereClauseForTable( schema.chatSyncChannelLinksTable.deletedAt, ), ) - case "chat_sync_message_links": { - const deletedAtClause = `"${schema.chatSyncMessageLinksTable.deletedAt.name}" IS NULL AND ` - const whereClause = `${deletedAtClause}"${schema.chatSyncMessageLinksTable.channelLinkId.name}" IN (SELECT "id" FROM chat_sync_channel_links WHERE "deletedAt" IS NULL AND "hazelChannelId" IN (SELECT "channelId" FROM channel_access WHERE "userId" = $1))` - return Effect.succeed({ whereClause, params: [user.internalUserId] }) - } - case "connect_conversations": { - const whereClause = `"${schema.connectConversationsTable.deletedAt.name}" IS NULL AND "${schema.connectConversationsTable.id.name}" IN (SELECT "conversationId" FROM connect_conversation_channels WHERE "deletedAt" IS NULL AND "channelId" IN (SELECT "channelId" FROM channel_access WHERE "userId" = $1))` - return Effect.succeed({ whereClause, params: [user.internalUserId] }) - } + case "chat_sync_message_links": + return Effect.succeed( + sqlToWhereClause( + schema.chatSyncMessageLinksTable, + sql`${isNullCol(schema.chatSyncMessageLinksTable.deletedAt)} AND ${inSubquery( + schema.chatSyncMessageLinksTable.channelLinkId, + sql`(SELECT ${col(schema.chatSyncChannelLinksTable.id)} FROM ${schema.chatSyncChannelLinksTable} WHERE ${isNullCol(schema.chatSyncChannelLinksTable.deletedAt)} AND ${col(schema.chatSyncChannelLinksTable.hazelChannelId)} IN ${channelAccessSubquery})`, + )}`, + ), + ) + case "connect_conversations": + return Effect.succeed( + sqlToWhereClause( + schema.connectConversationsTable, + sql`${isNullCol(schema.connectConversationsTable.deletedAt)} AND ${inSubquery( + schema.connectConversationsTable.id, + connectConversationAccessSubquery, + )}`, + ), + ) case "connect_conversation_channels": return Effect.succeed( buildChannelAccessClause( @@ -250,17 +269,21 @@ export function getWhereClauseForTable( // =========================================== Match.when("messages", () => - Effect.succeed({ - whereClause: `"${schema.messagesTable.deletedAt.name}" IS NULL AND (("${schema.messagesTable.conversationId.name}" IS NULL AND "${schema.messagesTable.channelId.name}" IN (SELECT "channelId" FROM channel_access WHERE "userId" = $1)) OR ("${schema.messagesTable.conversationId.name}" IS NOT NULL AND "${schema.messagesTable.conversationId.name}" IN (SELECT "conversationId" FROM connect_conversation_channels WHERE "deletedAt" IS NULL AND "channelId" IN (SELECT "channelId" FROM channel_access WHERE "userId" = $1))))`, - params: [user.internalUserId], - }), + Effect.succeed( + sqlToWhereClause( + schema.messagesTable, + sql`${isNullCol(schema.messagesTable.deletedAt)} AND ((${col(schema.messagesTable.conversationId)} IS NULL AND ${inSubquery(schema.messagesTable.channelId, channelAccessSubquery)}) OR (${col(schema.messagesTable.conversationId)} IS NOT NULL AND ${inSubquery(schema.messagesTable.conversationId, connectConversationAccessSubquery)}))`, + ), + ), ), Match.when("message_reactions", () => - Effect.succeed({ - whereClause: `("${schema.messageReactionsTable.conversationId.name}" IS NULL AND "${schema.messageReactionsTable.channelId.name}" IN (SELECT "channelId" FROM channel_access WHERE "userId" = $1)) OR ("${schema.messageReactionsTable.conversationId.name}" IS NOT NULL AND "${schema.messageReactionsTable.conversationId.name}" IN (SELECT "conversationId" FROM connect_conversation_channels WHERE "deletedAt" IS NULL AND "channelId" IN (SELECT "channelId" FROM channel_access WHERE "userId" = $1)))`, - params: [user.internalUserId], - }), + Effect.succeed( + sqlToWhereClause( + schema.messageReactionsTable, + sql`((${col(schema.messageReactionsTable.conversationId)} IS NULL AND ${inSubquery(schema.messageReactionsTable.channelId, channelAccessSubquery)}) OR (${col(schema.messageReactionsTable.conversationId)} IS NOT NULL AND ${inSubquery(schema.messageReactionsTable.conversationId, connectConversationAccessSubquery)}))`, + ), + ), ), Match.when("attachments", () => diff --git a/apps/electric-proxy/src/tables/where-clause-builder.test.ts b/apps/electric-proxy/src/tables/where-clause-builder.test.ts index 35c5411da..b13d413fe 100644 --- a/apps/electric-proxy/src/tables/where-clause-builder.test.ts +++ b/apps/electric-proxy/src/tables/where-clause-builder.test.ts @@ -1,3 +1,4 @@ +import { sql } from "@hazel/db" import { schema } from "@hazel/db" import type { UserId } from "@hazel/schema" import { describe, expect, it } from "vitest" @@ -5,6 +6,11 @@ import { assertWhereClauseParamsAreSequential, buildChannelAccessClause, buildChannelVisibilityClause, + col, + eqCol, + inSubquery, + isNullCol, + sqlToWhereClause, WhereClauseParamMismatchError, } from "./where-clause-builder" @@ -14,10 +20,11 @@ describe("where-clause-builder channel access", () => { expect(result.params).toEqual(["user-1"]) expect(result.whereClause).toContain(`"deletedAt" IS NULL`) - expect(result.whereClause).toContain( - `"id" IN (SELECT "channelId" FROM channel_access WHERE "userId" = $1)`, + expect(result.whereClause).toMatch( + /"id" IN \(SELECT "channelId" FROM "?channel_access"? WHERE "userId" = \$1\)/, ) expect(result.whereClause).not.toContain("COALESCE") + expect(result.whereClause).not.toContain(`"channels".`) }) it("buildChannelAccessClause includes optional deletedAt and single subquery", () => { @@ -29,10 +36,28 @@ describe("where-clause-builder channel access", () => { expect(result.params).toEqual(["user-1"]) expect(result.whereClause).toContain(`"deletedAt" IS NULL AND`) - expect(result.whereClause).toContain( - `"channelId" IN (SELECT "channelId" FROM channel_access WHERE "userId" = $1)`, + expect(result.whereClause).toMatch( + /"channelId" IN \(SELECT "channelId" FROM "?channel_access"? WHERE "userId" = \$1\)/, ) expect(result.whereClause).not.toContain("COALESCE") + expect(result.whereClause).not.toContain(`"messages".`) + }) +}) + +describe("where-clause-builder sql compiler", () => { + it("compiles drizzle sql to an Electric-compatible where clause", () => { + const channelAccessSubquery = sql`(SELECT ${col(schema.channelAccessTable.channelId)} FROM ${schema.channelAccessTable} WHERE ${eqCol(schema.channelAccessTable.userId, "user-1" as UserId)})` + const result = sqlToWhereClause( + schema.channelsTable, + sql`${isNullCol(schema.channelsTable.deletedAt)} AND ${inSubquery(schema.channelsTable.id, channelAccessSubquery)}`, + ) + + expect(result.params).toEqual(["user-1"]) + expect(result.whereClause).toContain(`"deletedAt" IS NULL`) + expect(result.whereClause).toMatch( + /"id" IN \(SELECT "channelId" FROM "?channel_access"? WHERE "userId" = \$1\)/, + ) + expect(result.whereClause).not.toContain(`"channels".`) }) }) diff --git a/apps/electric-proxy/src/tables/where-clause-builder.ts b/apps/electric-proxy/src/tables/where-clause-builder.ts index cb9fc347b..2a7486cf7 100644 --- a/apps/electric-proxy/src/tables/where-clause-builder.ts +++ b/apps/electric-proxy/src/tables/where-clause-builder.ts @@ -1,5 +1,6 @@ -import type { PgColumn } from "drizzle-orm/pg-core" +import { schema, sql, type SQL, type SQLWrapper } from "@hazel/db" import type { UserId } from "@hazel/schema" +import { QueryBuilder, type PgColumn, type PgTable } from "drizzle-orm/pg-core" /** * Result of building a WHERE clause with parameterized values @@ -9,6 +10,8 @@ export interface WhereClauseResult { params: unknown[] } +const queryBuilder = new QueryBuilder() + /** * Summary of placeholder/param usage in a WHERE clause. */ @@ -82,6 +85,79 @@ export function assertWhereClauseParamsAreSequential(result: WhereClauseResult): } } +const getRootTable = (column: PgColumn): PgTable => column.table as PgTable + +export const col = (column: PgColumn) => sql.identifier(column.name) + +export const eqCol = (column: PgColumn, value: T | SQLWrapper): SQL => sql`${col(column)} = ${value}` + +export const isNullCol = (column: PgColumn): SQL => sql`${col(column)} IS NULL` + +export const inSubquery = (column: PgColumn, subquerySql: SQL): SQL => sql`${col(column)} IN ${subquerySql}` + +const comma = sql.raw(", ") + +const buildParamList = (values: readonly unknown[]): SQL => + sql`(${sql.join( + values.map((value) => sql`${value}`), + comma, + )})` + +const buildSubquery = (selectedColumn: PgColumn, table: PgTable, whereExpr: SQL): SQL => + sql`(SELECT ${col(selectedColumn)} FROM ${table} WHERE ${whereExpr})` + +const extractWhereClause = (compiledSql: string): string => { + const match = /\bwhere\b\s+([\s\S]*)$/i.exec(compiledSql) + if (!match?.[1]) { + throw new Error(`Failed to extract WHERE clause from compiled SQL: ${compiledSql}`) + } + return match[1].trim() +} + +const dedupeParams = (whereClause: string, params: unknown[]): WhereClauseResult => { + const dedupedParams: unknown[] = [] + const placeholderMap = new Map() + + params.forEach((param, index) => { + const existingIndex = dedupedParams.findIndex((existingParam) => Object.is(existingParam, param)) + if (existingIndex === -1) { + dedupedParams.push(param) + placeholderMap.set(index + 1, dedupedParams.length) + return + } + + placeholderMap.set(index + 1, existingIndex + 1) + }) + + const dedupedWhereClause = whereClause.replace(/\$(\d+)\b/g, (_, rawIndex: string) => { + const nextIndex = placeholderMap.get(Number(rawIndex)) + return `$${nextIndex ?? Number(rawIndex)}` + }) + + return { + whereClause: dedupedWhereClause, + params: dedupedParams, + } +} + +export function sqlToWhereClause( + rootTable: PgTable, + whereExpr: SQL, + overrideParams?: unknown[], +): WhereClauseResult { + const compiled = queryBuilder + .select({ __electric_where__: sql`1` }) + .from(rootTable) + .where(whereExpr) + .toSQL() + const result = { + whereClause: extractWhereClause(compiled.sql), + params: overrideParams ?? compiled.params, + } + + return dedupeParams(result.whereClause, result.params) +} + /** * Build IN clause with sorted IDs using unqualified column name. * Uses column.name for Electric SQL compatibility (Electric requires unqualified column names). @@ -95,11 +171,7 @@ export function buildInClause(column: PgColumn, values: readon return { whereClause: "false", params: [] } } const sorted = [...values].sort() - const placeholders = sorted.map((_, i) => `$${i + 1}`).join(", ") - return { - whereClause: `"${column.name}" IN (${placeholders})`, - params: sorted, - } + return sqlToWhereClause(getRootTable(column), sql`${col(column)} IN ${buildParamList(sorted)}`) } /** @@ -119,11 +191,10 @@ export function buildInClauseWithDeletedAt( return { whereClause: "false", params: [] } } const sorted = [...values].sort() - const placeholders = sorted.map((_, i) => `$${i + 1}`).join(", ") - return { - whereClause: `"${column.name}" IN (${placeholders}) AND "${deletedAtColumn.name}" IS NULL`, - params: sorted, - } + return sqlToWhereClause( + getRootTable(column), + sql`${col(column)} IN ${buildParamList(sorted)} AND ${isNullCol(deletedAtColumn)}`, + ) } /** @@ -135,9 +206,14 @@ export function buildInClauseWithDeletedAt( * @returns WhereClauseResult with parameterized WHERE clause */ export function buildEqClause(column: PgColumn, value: T, paramIndex = 1): WhereClauseResult { + const result = sqlToWhereClause(getRootTable(column), eqCol(column, value)) + if (paramIndex === 1) { + return result + } + return { - whereClause: `"${column.name}" = $${paramIndex}`, - params: [value], + whereClause: result.whereClause.replace(/\$1\b/g, `$${paramIndex}`), + params: result.params, } } @@ -148,10 +224,7 @@ export function buildEqClause(column: PgColumn, value: T, paramIndex = 1): Wh * @returns WhereClauseResult with no parameters */ export function buildDeletedAtNullClause(deletedAtColumn: PgColumn): WhereClauseResult { - return { - whereClause: `"${deletedAtColumn.name}" IS NULL`, - params: [], - } + return sqlToWhereClause(getRootTable(deletedAtColumn), isNullCol(deletedAtColumn)) } /** @@ -177,12 +250,16 @@ export function buildNoFilterClause(): WhereClauseResult { * @returns WhereClauseResult with parameterized WHERE clause and subquery */ export function buildChannelVisibilityClause(userId: UserId, deletedAtColumn: PgColumn): WhereClauseResult { - const whereClause = `"${deletedAtColumn.name}" IS NULL AND "id" IN (SELECT "channelId" FROM channel_access WHERE "userId" = $1)` + const channelAccessSubquery = buildSubquery( + schema.channelAccessTable.channelId, + schema.channelAccessTable, + eqCol(schema.channelAccessTable.userId, userId), + ) - return { - whereClause, - params: [userId], - } + return sqlToWhereClause( + getRootTable(deletedAtColumn), + sql`${isNullCol(deletedAtColumn)} AND ${inSubquery(schema.channelsTable.id, channelAccessSubquery)}`, + ) } /** @@ -201,9 +278,18 @@ export function buildOrgMembershipClause( orgIdColumn: PgColumn, deletedAtColumn?: PgColumn, ): WhereClauseResult { - const deletedAtClause = deletedAtColumn ? `"${deletedAtColumn.name}" IS NULL AND ` : "" - const whereClause = `${deletedAtClause}"${orgIdColumn.name}" IN (SELECT "organizationId" FROM organization_members WHERE "userId" = $1 AND "deletedAt" IS NULL)` - return { whereClause, params: [userId] } + const orgMembershipSubquery = buildSubquery( + schema.organizationMembersTable.organizationId, + schema.organizationMembersTable, + sql`${eqCol(schema.organizationMembersTable.userId, userId)} AND ${isNullCol(schema.organizationMembersTable.deletedAt)}`, + ) + + const baseCondition = inSubquery(orgIdColumn, orgMembershipSubquery) + const whereExpr = deletedAtColumn + ? sql`${isNullCol(deletedAtColumn)} AND ${baseCondition}` + : baseCondition + + return sqlToWhereClause(getRootTable(orgIdColumn), whereExpr) } /** @@ -222,9 +308,21 @@ export function buildUserOrgMembershipClause( userIdColumn: PgColumn, deletedAtColumn: PgColumn, ): WhereClauseResult { - // Filter users to those in same orgs as the current user - const whereClause = `"${deletedAtColumn.name}" IS NULL AND "${userIdColumn.name}" IN (SELECT "userId" FROM organization_members WHERE "organizationId" IN (SELECT "organizationId" FROM organization_members WHERE "userId" = $1 AND "deletedAt" IS NULL) AND "deletedAt" IS NULL)` - return { whereClause, params: [userId] } + const currentUserOrgIds = buildSubquery( + schema.organizationMembersTable.organizationId, + schema.organizationMembersTable, + sql`${eqCol(schema.organizationMembersTable.userId, userId)} AND ${isNullCol(schema.organizationMembersTable.deletedAt)}`, + ) + const sharedOrgUsers = buildSubquery( + schema.organizationMembersTable.userId, + schema.organizationMembersTable, + sql`${col(schema.organizationMembersTable.organizationId)} IN ${currentUserOrgIds} AND ${isNullCol(schema.organizationMembersTable.deletedAt)}`, + ) + + return sqlToWhereClause( + getRootTable(userIdColumn), + sql`${isNullCol(deletedAtColumn)} AND ${inSubquery(userIdColumn, sharedOrgUsers)}`, + ) } /** @@ -238,8 +336,13 @@ export function buildUserOrgMembershipClause( * @returns WhereClauseResult with parameterized WHERE clause and subquery */ export function buildUserMembershipClause(userId: UserId, memberIdColumn: PgColumn): WhereClauseResult { - const whereClause = `"${memberIdColumn.name}" IN (SELECT "id" FROM organization_members WHERE "userId" = $1 AND "deletedAt" IS NULL)` - return { whereClause, params: [userId] } + const membershipSubquery = buildSubquery( + schema.organizationMembersTable.id, + schema.organizationMembersTable, + sql`${eqCol(schema.organizationMembersTable.userId, userId)} AND ${isNullCol(schema.organizationMembersTable.deletedAt)}`, + ) + + return sqlToWhereClause(getRootTable(memberIdColumn), inSubquery(memberIdColumn, membershipSubquery)) } /** @@ -258,9 +361,17 @@ export function buildChannelAccessClause( channelIdColumn: PgColumn, deletedAtColumn?: PgColumn, ): WhereClauseResult { - const deletedAtClause = deletedAtColumn ? `"${deletedAtColumn.name}" IS NULL AND ` : "" - const whereClause = `${deletedAtClause}"${channelIdColumn.name}" IN (SELECT "channelId" FROM channel_access WHERE "userId" = $1)` - return { whereClause, params: [userId] } + const channelAccessSubquery = buildSubquery( + schema.channelAccessTable.channelId, + schema.channelAccessTable, + eqCol(schema.channelAccessTable.userId, userId), + ) + const baseCondition = inSubquery(channelIdColumn, channelAccessSubquery) + const whereExpr = deletedAtColumn + ? sql`${isNullCol(deletedAtColumn)} AND ${baseCondition}` + : baseCondition + + return sqlToWhereClause(getRootTable(channelIdColumn), whereExpr) } /** @@ -277,9 +388,16 @@ export function buildIntegrationConnectionClause( userId: UserId, deletedAtColumn: PgColumn, ): WhereClauseResult { - // Org-level connections (userId IS NULL) in user's orgs OR user's own connections - const whereClause = `"${deletedAtColumn.name}" IS NULL AND (("userId" IS NULL AND "organizationId" IN (SELECT "organizationId" FROM organization_members WHERE "userId" = $1 AND "deletedAt" IS NULL)) OR "userId" = $1)` - return { whereClause, params: [userId] } + const orgMembershipSubquery = buildSubquery( + schema.organizationMembersTable.organizationId, + schema.organizationMembersTable, + sql`${eqCol(schema.organizationMembersTable.userId, userId)} AND ${isNullCol(schema.organizationMembersTable.deletedAt)}`, + ) + + return sqlToWhereClause( + getRootTable(deletedAtColumn), + sql`${isNullCol(deletedAtColumn)} AND ((${sql.identifier("userId")} IS NULL AND ${sql.identifier("organizationId")} IN ${orgMembershipSubquery}) OR ${sql.identifier("userId")} = ${userId})`, + ) } /** diff --git a/apps/link-preview-worker/package.json b/apps/link-preview-worker/package.json index 6ae893f7a..752c59768 100644 --- a/apps/link-preview-worker/package.json +++ b/apps/link-preview-worker/package.json @@ -12,7 +12,6 @@ "cf-typegen": "wrangler types" }, "dependencies": { - "@effect/platform": "catalog:effect", "effect": "catalog:effect", "metascraper": "^5.49.5", "metascraper-description": "^5.49.5", @@ -25,7 +24,6 @@ }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.8.19", - "@effect/language-service": "catalog:effect", "typescript": "^5.9.3", "vitest": "^4.1.0", "wrangler": "^4.73.0" diff --git a/apps/link-preview-worker/src/api.ts b/apps/link-preview-worker/src/api.ts index 2d61684ab..101a5b9e7 100644 --- a/apps/link-preview-worker/src/api.ts +++ b/apps/link-preview-worker/src/api.ts @@ -1,11 +1,11 @@ -import { HttpApi, OpenApi } from "@effect/platform" +import { HttpApi, OpenApi } from "effect/unstable/httpapi" import { AppApi, LinkPreviewGroup, TweetGroup } from "./declare" export class LinkPreviewApi extends HttpApi.make("api") .add(AppApi) .add(LinkPreviewGroup) .add(TweetGroup) - .annotateContext( + .annotateMerge( OpenApi.annotations({ title: "Link Preview Worker API", description: "API for fetching link previews and tweet data", diff --git a/apps/link-preview-worker/src/cache.ts b/apps/link-preview-worker/src/cache.ts index 06dac7e92..6c9302cf9 100644 --- a/apps/link-preview-worker/src/cache.ts +++ b/apps/link-preview-worker/src/cache.ts @@ -1,4 +1,4 @@ -import { Context, Effect, Layer } from "effect" +import { Effect, Layer, ServiceMap } from "effect" /** * Cache TTL in seconds (1 hour) @@ -9,13 +9,13 @@ const CACHE_TTL = 3600 * KV Cache Service * Provides caching functionality using Cloudflare KV */ -export class KVCache extends Context.Tag("KVCache")< +export class KVCache extends ServiceMap.Service< KVCache, { get: (key: string) => Effect.Effect set: (key: string, value: T) => Effect.Effect } ->() {} +>()("KVCache") {} /** * Create a KV Cache Layer from a KV namespace binding diff --git a/apps/link-preview-worker/src/declare.ts b/apps/link-preview-worker/src/declare.ts index fb28437ae..708a3379b 100644 --- a/apps/link-preview-worker/src/declare.ts +++ b/apps/link-preview-worker/src/declare.ts @@ -1,11 +1,14 @@ -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "@effect/platform" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" // Health check API export class AppApi extends HttpApiGroup.make("app") - - .add(HttpApiEndpoint.get("health", "/health").addSuccess(Schema.String)) - .annotateContext( + .add( + HttpApiEndpoint.get("health", "/health", { + success: Schema.String, + }), + ) + .annotateMerge( OpenApi.annotations({ title: "App Api", description: "App Api", @@ -22,63 +25,53 @@ export class LinkPreviewData extends Schema.Class("LinkPreviewD publisher: Schema.optional(Schema.String), }) {} -export class LinkPreviewError extends Schema.TaggedError("LinkPreviewError")( +export class LinkPreviewError extends Schema.TaggedErrorClass()( "LinkPreviewError", { message: Schema.String, }, - HttpApiSchema.annotations({ - status: 500, - }), + { httpApiStatus: 500 }, ) {} export class LinkPreviewGroup extends HttpApiGroup.make("linkPreview") .add( - HttpApiEndpoint.get("get")`/` - .addSuccess(LinkPreviewData) - .addError(LinkPreviewError) - .setUrlParams( - Schema.Struct({ - url: Schema.String, - }), - ) - .annotateContext( - OpenApi.annotations({ - title: "Get Link Preview", - description: "Fetch metadata for a given URL", - summary: "Get link preview metadata", - }), - ), + HttpApiEndpoint.get("get", "/", { + payload: { + url: Schema.String, + }, + success: LinkPreviewData, + error: LinkPreviewError, + }).annotateMerge( + OpenApi.annotations({ + title: "Get Link Preview", + description: "Fetch metadata for a given URL", + }), + ), ) .prefix("/link-preview") {} // Tweet Schemas -export class TweetError extends Schema.TaggedError("TweetError")( +export class TweetError extends Schema.TaggedErrorClass()( "TweetError", { message: Schema.String, }, - HttpApiSchema.annotations({ - status: 500, - }), + { httpApiStatus: 500 }, ) {} export class TweetGroup extends HttpApiGroup.make("tweet") .add( - HttpApiEndpoint.get("get")`/` - .addSuccess(Schema.Any) - .addError(TweetError) - .setUrlParams( - Schema.Struct({ - id: Schema.String, - }), - ) - .annotateContext( - OpenApi.annotations({ - title: "Get Tweet", - description: "Fetch tweet data by ID", - summary: "Get tweet metadata", - }), - ), + HttpApiEndpoint.get("get", "/", { + payload: { + id: Schema.String, + }, + success: Schema.Any, + error: TweetError, + }).annotateMerge( + OpenApi.annotations({ + title: "Get Tweet", + description: "Fetch tweet data by ID", + }), + ), ) .prefix("/tweet") {} diff --git a/apps/link-preview-worker/src/handle.ts b/apps/link-preview-worker/src/handle.ts index c0b911493..82127ec68 100644 --- a/apps/link-preview-worker/src/handle.ts +++ b/apps/link-preview-worker/src/handle.ts @@ -1,15 +1,11 @@ -import { HttpApiBuilder } from "@effect/platform" +import { HttpApiBuilder } from "effect/unstable/httpapi" import { Effect } from "effect" import { LinkPreviewApi } from "./api" import { HttpLinkPreviewLive } from "./handlers/link-preview" import { HttpTweetLive } from "./handlers/tweet" export const HttpAppLive = HttpApiBuilder.group(LinkPreviewApi, "app", (handles) => - Effect.gen(function* () { - yield* Effect.logInfo("Link Preview Worker started") - - return handles.handle("health", () => Effect.succeed("ok")) - }), + handles.handle("health", () => Effect.succeed("ok")), ) export { HttpLinkPreviewLive, HttpTweetLive } diff --git a/apps/link-preview-worker/src/handlers/link-preview.ts b/apps/link-preview-worker/src/handlers/link-preview.ts index 4a09b1534..8f6e6f2c1 100644 --- a/apps/link-preview-worker/src/handlers/link-preview.ts +++ b/apps/link-preview-worker/src/handlers/link-preview.ts @@ -1,4 +1,5 @@ -import { FetchHttpClient, HttpApiBuilder, HttpClient } from "@effect/platform" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { Effect } from "effect" import metascraper from "metascraper" import metascraperDescription from "metascraper-description" @@ -22,14 +23,14 @@ const scraper = metascraper([ ]) // Validate if an image URL is accessible using HttpClient -function validateImageUrl(url: string): Effect.Effect { +function validateImageUrl(url: string) { return HttpClient.head(url).pipe( - Effect.andThen((response) => { + Effect.map((response) => { const contentType = response.headers["content-type"] return contentType ? contentType.startsWith("image/") : false }), Effect.timeout("1 seconds"), - Effect.catchAll(() => Effect.succeed(false)), + Effect.orElseSucceed(() => false as boolean), Effect.provide(FetchHttpClient.layer), ) } @@ -71,8 +72,8 @@ function extractMetaTag(html: string, property: string): string | null { export const HttpLinkPreviewLive = HttpApiBuilder.group(LinkPreviewApi, "linkPreview", (handlers) => handlers.handle( "get", - Effect.fn(function* ({ urlParams }) { - const targetUrl = urlParams.url + Effect.fn(function* ({ payload }) { + const targetUrl = payload.url const cacheKey = `link-preview:${targetUrl}` const cache = yield* KVCache @@ -86,7 +87,7 @@ export const HttpLinkPreviewLive = HttpApiBuilder.group(LinkPreviewApi, "linkPre logo?: { url: string } publisher?: string }>(cacheKey) - .pipe(Effect.catchAll(() => Effect.succeed(null))) + .pipe(Effect.orElseSucceed(() => null)) if (cachedData) { yield* Effect.logDebug(`Cache hit for: ${targetUrl}`) @@ -166,15 +167,7 @@ export const HttpLinkPreviewLive = HttpApiBuilder.group(LinkPreviewApi, "linkPre } // Store in cache (don't fail request if caching fails) - yield* cache - .set(cacheKey, result) - .pipe( - Effect.catchAll((error) => - Effect.logDebug(`Failed to cache result: ${error.message}`).pipe( - Effect.andThen(Effect.succeed(undefined)), - ), - ), - ) + yield* cache.set(cacheKey, result).pipe(Effect.orElseSucceed(() => undefined)) return result }), diff --git a/apps/link-preview-worker/src/handlers/tweet.ts b/apps/link-preview-worker/src/handlers/tweet.ts index 6858b3de6..c448d18bc 100644 --- a/apps/link-preview-worker/src/handlers/tweet.ts +++ b/apps/link-preview-worker/src/handlers/tweet.ts @@ -1,4 +1,4 @@ -import { HttpApiBuilder } from "@effect/platform" +import { HttpApiBuilder } from "effect/unstable/httpapi" import { Effect } from "effect" import { LinkPreviewApi } from "../api" import { KVCache } from "../cache" @@ -8,16 +8,14 @@ import { TwitterApi, TwitterApiError } from "../services/twitter" export const HttpTweetLive = HttpApiBuilder.group(LinkPreviewApi, "tweet", (handlers) => handlers.handle( "get", - Effect.fn(function* ({ urlParams }) { - const tweetId = urlParams.id + Effect.fn(function* ({ payload }) { + const tweetId = payload.id const cacheKey = `tweet:${tweetId}` const cache = yield* KVCache const twitterApi = yield* TwitterApi // Check cache first - const cachedData = yield* cache - .get(cacheKey) - .pipe(Effect.catchAll(() => Effect.succeed(null))) + const cachedData = yield* cache.get(cacheKey).pipe(Effect.orElseSucceed(() => null)) if (cachedData) { yield* Effect.logDebug(`Cache hit for tweet: ${tweetId}`) @@ -44,14 +42,7 @@ export const HttpTweetLive = HttpApiBuilder.group(LinkPreviewApi, "tweet", (hand yield* Effect.logDebug(`Successfully fetched tweet: ${tweetId}`) // Store in cache (don't fail request if caching fails) - yield* cache.set(cacheKey, tweet).pipe( - Effect.catchAll((error) => { - const errorMessage = error instanceof Error ? error.message : String(error) - return Effect.logDebug(`Failed to cache tweet: ${errorMessage}`).pipe( - Effect.andThen(Effect.succeed(undefined)), - ) - }), - ) + yield* cache.set(cacheKey, tweet).pipe(Effect.orElseSucceed(() => undefined)) // Return the tweet data return tweet diff --git a/apps/link-preview-worker/src/index.ts b/apps/link-preview-worker/src/index.ts index 0ad1224c1..ec6762447 100644 --- a/apps/link-preview-worker/src/index.ts +++ b/apps/link-preview-worker/src/index.ts @@ -1,29 +1,23 @@ -import { HttpApiBuilder, HttpServer } from "@effect/platform" -import { Layer, Logger, pipe } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { HttpRouter, HttpServer } from "effect/unstable/http" +import { Layer, Logger } from "effect" import { LinkPreviewApi } from "./api" import { makeKVCacheLayer } from "./cache" import { HttpAppLive, HttpLinkPreviewLive, HttpTweetLive } from "./handle" import { TwitterApi } from "./services/twitter" -const HttpLive = HttpApiBuilder.api(LinkPreviewApi).pipe( - Layer.provide([HttpAppLive, HttpLinkPreviewLive, HttpTweetLive]), -) +const makeAppLayer = (env: Env) => { + const ServiceLayers = Layer.mergeAll(makeKVCacheLayer(env.LINK_CACHE), TwitterApi.layer) -const makeHttpLiveWithKV = (env: Env) => - pipe( - HttpApiBuilder.Router.Live, - Layer.provideMerge(HttpLive), - Layer.provideMerge( - HttpApiBuilder.middlewareCors({ - allowedOrigins: ["http://localhost:3000", "https://app.hazel.sh", "tauri://localhost"], - credentials: true, - }), - ), - Layer.provideMerge(HttpServer.layerContext), - Layer.provide(makeKVCacheLayer(env.LINK_CACHE)), - Layer.provide(TwitterApi.Default), - Layer.provide(Logger.pretty), + const HandlerLayers = Layer.mergeAll(HttpAppLive, HttpLinkPreviewLive, HttpTweetLive) + + return HttpApiBuilder.layer(LinkPreviewApi).pipe( + Layer.provide(HandlerLayers), + HttpRouter.provideRequest(ServiceLayers), + Layer.provide(HttpServer.layerServices), + Layer.provide(Logger.layer([Logger.consolePretty()])), ) +} export default { async fetch(request, env, _ctx): Promise { @@ -31,9 +25,13 @@ export default { env, }) - const Live = makeHttpLiveWithKV(env) - const handler = HttpApiBuilder.toWebHandler(Live, {}) + const Live = makeAppLayer(env) + const { handler, dispose } = HttpRouter.toWebHandler(Live) - return handler.handler(request) + try { + return await handler(request) + } finally { + await dispose() + } }, } satisfies ExportedHandler diff --git a/apps/link-preview-worker/src/services/twitter.ts b/apps/link-preview-worker/src/services/twitter.ts index d53916b1a..f2dda2869 100644 --- a/apps/link-preview-worker/src/services/twitter.ts +++ b/apps/link-preview-worker/src/services/twitter.ts @@ -1,10 +1,10 @@ -import { FetchHttpClient, HttpClient } from "@effect/platform" -import { Effect, Schema } from "effect" +import { FetchHttpClient, HttpClient } from "effect/unstable/http" +import { ServiceMap, Effect, Layer, Schema } from "effect" const SYNDICATION_URL = "https://cdn.syndication.twimg.com" const TWEET_ID_REGEX = /^[0-9]+$/ -export class TwitterApiError extends Schema.TaggedError("TwitterApiError")( +export class TwitterApiError extends Schema.TaggedErrorClass("TwitterApiError")( "TwitterApiError", { message: Schema.String, @@ -72,8 +72,8 @@ function buildTweetUrl(id: string): string { * Twitter API Service * Provides methods to interact with Twitter's syndication API */ -export class TwitterApi extends Effect.Service()("TwitterApi", { - effect: Effect.gen(function* () { +export class TwitterApi extends ServiceMap.Service()("TwitterApi", { + make: Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient return { @@ -108,9 +108,7 @@ export class TwitterApi extends Effect.Service()("TwitterApi", { ) // Parse JSON response - const data: any = yield* response.json.pipe( - Effect.catchAll(() => Effect.succeed(undefined)), - ) + const data: any = yield* response.json.pipe(Effect.catch(() => Effect.succeed(undefined))) // Handle successful response if (response.status >= 200 && response.status < 300) { @@ -167,5 +165,6 @@ export class TwitterApi extends Effect.Service()("TwitterApi", { }), } }), - dependencies: [FetchHttpClient.layer], -}) {} +}) { + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(FetchHttpClient.layer)) +} diff --git a/apps/web/package.json b/apps/web/package.json index 4d06e6dba..f3e0fa45f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,12 +14,9 @@ "dependencies": { "@cloudflare/realtimekit-react": "^1.2.4", "@cloudflare/realtimekit-react-ui": "^1.1.0", - "@effect-atom/atom-react": "catalog:effect", - "@effect/experimental": "catalog:effect", + "@effect/atom-react": "catalog:effect", "@effect/opentelemetry": "catalog:effect", - "@effect/platform": "catalog:effect", "@effect/platform-browser": "catalog:effect", - "@effect/rpc": "catalog:effect", "@electric-sql/client": "1.5.12", "@fontsource/inter": "^5.2.8", "@hazel/actors": "workspace:*", @@ -96,7 +93,6 @@ "workbox-window": "^7.4.0" }, "devDependencies": { - "@effect/language-service": "catalog:effect", "@react-types/overlays": "^3.9.4", "@react-types/shared": "^3.33.1", "@tailwindcss/postcss": "^4.2.1", diff --git a/apps/web/src/atoms/bot-atoms.ts b/apps/web/src/atoms/bot-atoms.ts index 8970a2e4e..d9023bf1c 100644 --- a/apps/web/src/atoms/bot-atoms.ts +++ b/apps/web/src/atoms/bot-atoms.ts @@ -7,12 +7,12 @@ import { HazelRpcClient } from "~/lib/services/common/rpc-atom-client" * Type for bot data returned from RPC. * Inferred from the domain model's JSON schema to stay in sync automatically. */ -export type BotData = Schema.Schema.Type +export type BotData = Schema.Schema.Type /** * Type for bot command data returned from RPC. */ -export type BotCommandData = Schema.Schema.Type +export type BotCommandData = Schema.Schema.Type /** * Type for public bot data with install status. diff --git a/apps/web/src/atoms/channel-webhook-atoms.ts b/apps/web/src/atoms/channel-webhook-atoms.ts index 290a7e596..e335888b1 100644 --- a/apps/web/src/atoms/channel-webhook-atoms.ts +++ b/apps/web/src/atoms/channel-webhook-atoms.ts @@ -6,7 +6,7 @@ import { HazelRpcClient } from "~/lib/services/common/rpc-atom-client" * Type for webhook data returned from RPC (without sensitive tokenHash field). * Inferred from the domain model's JSON schema to stay in sync automatically. */ -export type WebhookData = Schema.Schema.Type +export type WebhookData = Schema.Schema.Type /** * Mutation atom for creating a channel webhook. diff --git a/apps/web/src/atoms/chat-atoms.ts b/apps/web/src/atoms/chat-atoms.ts index 0e2c2a9bf..804ad80ec 100644 --- a/apps/web/src/atoms/chat-atoms.ts +++ b/apps/web/src/atoms/chat-atoms.ts @@ -1,4 +1,4 @@ -import { Atom } from "@effect-atom/atom-react" +import { Atom } from "effect/unstable/reactivity" import type { AttachmentId, ChannelId, MessageId } from "@hazel/schema" /** diff --git a/apps/web/src/atoms/chat-query-atoms.ts b/apps/web/src/atoms/chat-query-atoms.ts index e41061a24..3915be2ea 100644 --- a/apps/web/src/atoms/chat-query-atoms.ts +++ b/apps/web/src/atoms/chat-query-atoms.ts @@ -1,13 +1,13 @@ -import { Atom } from "@effect-atom/atom-react" +import { Atom } from "effect/unstable/reactivity" import type { Message, PinnedMessage, User } from "@hazel/domain/models" import type { ChannelId } from "@hazel/schema" import { eq } from "@tanstack/db" import { channelCollection } from "~/db/collections" import { makeQuery } from "../../../../libs/tanstack-db-atom/src" -export type MessageWithPinned = typeof Message.Model.Type & { - pinnedMessage: typeof PinnedMessage.Model.Type | null | undefined - author: typeof User.Model.Type | null | undefined +export type MessageWithPinned = Message.Type & { + pinnedMessage: PinnedMessage.Type | null | undefined + author: User.Type | null | undefined isSyncedFromDiscord?: boolean } diff --git a/apps/web/src/atoms/command-palette-state.ts b/apps/web/src/atoms/command-palette-state.ts index f119e24db..48f51b736 100644 --- a/apps/web/src/atoms/command-palette-state.ts +++ b/apps/web/src/atoms/command-palette-state.ts @@ -1,4 +1,4 @@ -import { Atom } from "@effect-atom/atom-react" +import { Atom } from "effect/unstable/reactivity" import type { FilterType, SearchFilter } from "~/lib/search-filter-parser" /** diff --git a/apps/web/src/atoms/custom-emoji-atoms.ts b/apps/web/src/atoms/custom-emoji-atoms.ts index a9705896c..5c64473a5 100644 --- a/apps/web/src/atoms/custom-emoji-atoms.ts +++ b/apps/web/src/atoms/custom-emoji-atoms.ts @@ -1,4 +1,4 @@ -import { Atom, Result } from "@effect-atom/atom-react" +import { Atom, AsyncResult } from "effect/unstable/reactivity" import type { OrganizationId } from "@hazel/schema" import { and, eq, isNull } from "@tanstack/db" import { customEmojiCollection } from "~/db/collections" @@ -24,7 +24,7 @@ export const customEmojisForOrgAtomFamily = Atom.family((orgId: OrganizationId) export const customEmojiMapAtomFamily = Atom.family((orgId: OrganizationId) => Atom.make((get) => { const emojisResult = get(customEmojisForOrgAtomFamily(orgId)) - const emojis = Result.getOrElse(emojisResult, () => []) + const emojis = AsyncResult.getOrElse(emojisResult, () => []) const map = new Map() for (const emoji of emojis) { diff --git a/apps/web/src/atoms/desktop-auth.test.ts b/apps/web/src/atoms/desktop-auth.test.ts index 17319f692..11a224bc0 100644 --- a/apps/web/src/atoms/desktop-auth.test.ts +++ b/apps/web/src/atoms/desktop-auth.test.ts @@ -35,7 +35,7 @@ describe("isFatalRefreshError", () => { it("returns false for timeout errors", () => { const error = { - _tag: "TimeoutException", + _tag: "TimeoutError", message: "Request timed out", } expect(isFatalRefreshError(error)).toBe(false) @@ -43,7 +43,7 @@ describe("isFatalRefreshError", () => { it("returns false for network errors", () => { const error = { - _tag: "RequestError", + _tag: "HttpClientError", message: "Network error during token refresh", } expect(isFatalRefreshError(error)).toBe(false) @@ -65,7 +65,7 @@ describe("isFatalRefreshError", () => { describe("isTransientError", () => { it("returns true for TimeoutException tag", () => { const error = { - _tag: "TimeoutException", + _tag: "TimeoutError", message: "Operation timed out", } expect(isTransientError(error)).toBe(true) @@ -73,7 +73,7 @@ describe("isTransientError", () => { it("returns true for RequestError tag", () => { const error = { - _tag: "RequestError", + _tag: "HttpClientError", message: "Request failed", } expect(isTransientError(error)).toBe(true) diff --git a/apps/web/src/atoms/desktop-auth.ts b/apps/web/src/atoms/desktop-auth.ts index 37cf7bd7c..398d3156c 100644 --- a/apps/web/src/atoms/desktop-auth.ts +++ b/apps/web/src/atoms/desktop-auth.ts @@ -7,10 +7,10 @@ * This module owns atom definitions, init, login, logout, and the scheduler. */ -import { Atom } from "@effect-atom/atom-react" +import { Atom } from "effect/unstable/reactivity" import { Clipboard } from "@effect/platform-browser" import type { OrganizationId } from "@hazel/schema" -import { Duration, Effect, Layer, Option, Schema } from "effect" +import { Duration, Effect, Layer, Option, Schema, type ServiceMap } from "effect" import { appRegistry } from "~/lib/registry" import { runtime } from "~/lib/services/common/runtime" import { TauriAuth } from "~/lib/services/desktop/tauri-auth" @@ -56,11 +56,15 @@ const REFRESH_BUFFER_MS = 5 * 60 * 1000 // Layers // ============================================================================ -const TokenStorageLive = TokenStorage.Default -const TokenExchangeLive = TokenExchange.Default -const TauriAuthLive = TauriAuth.Default +const TokenStorageLive = TokenStorage.layer +const TokenExchangeLive = TokenExchange.layer +const TauriAuthLive = TauriAuth.layer const ClipboardLive = Clipboard.layer +type TauriAuthService = ServiceMap.Service.Shape +type TokenStorageService = ServiceMap.Service.Shape +type TokenExchangeService = ServiceMap.Service.Shape + // ============================================================================ // Core State Atoms // ============================================================================ @@ -75,6 +79,18 @@ export const desktopAuthErrorAtom = Atom.make(null).pip export const isDesktopAuthenticatedAtom = Atom.make((get) => get(desktopTokensAtom) !== null) +// ============================================================================ +// Helper to extract error info +// ============================================================================ + +function toErrorInfo(error: unknown): { _tag: string; message: string } { + const e = error as { _tag?: string; message?: string } | undefined + return { + _tag: e?._tag ?? "UnknownError", + message: e?.message ?? "Unknown error", + } +} + // ============================================================================ // Action Atoms // ============================================================================ @@ -83,7 +99,7 @@ export const isDesktopAuthenticatedAtom = Atom.make((get) => get(desktopTokensAt * Action atom that initiates the desktop OAuth flow */ export const desktopLoginAtom = Atom.fn( - Effect.fnUntraced(function* (options: DesktopLoginOptions, get) { + Effect.fnUntraced(function* (options: DesktopLoginOptions | undefined, get) { if (!isTauri()) { yield* Effect.log("[desktop-auth] Not in Tauri environment, skipping desktop login") return @@ -92,13 +108,14 @@ export const desktopLoginAtom = Atom.fn( get.set(desktopAuthStatusAtom, "loading") get.set(desktopAuthErrorAtom, null) - const result = yield* Effect.gen(function* () { - const auth = yield* TauriAuth + const loginEffect = Effect.gen(function* () { + const auth: TauriAuthService = yield* TauriAuth const authResult = yield* auth.initiateAuth(options) - const accessTokenOpt = yield* TokenStorage.getAccessToken - const refreshTokenOpt = yield* TokenStorage.getRefreshToken - const expiresAtOpt = yield* TokenStorage.getExpiresAt + const tokenStorage: TokenStorageService = yield* TokenStorage + const accessTokenOpt = yield* tokenStorage.getAccessToken + const refreshTokenOpt = yield* tokenStorage.getRefreshToken + const expiresAtOpt = yield* tokenStorage.getExpiresAt if ( Option.isSome(accessTokenOpt) && @@ -114,15 +131,14 @@ export const desktopLoginAtom = Atom.fn( } return authResult - }).pipe( - Effect.provide(Layer.mergeAll(TauriAuthLive, TokenStorageLive)), - Effect.catchAll((error) => { + }).pipe(Effect.provide(Layer.mergeAll(TauriAuthLive, TokenStorageLive))) + + const result = yield* loginEffect.pipe( + Effect.catch((error) => { console.error("[desktop-auth] Login failed:", error) + const info = toErrorInfo(error) get.set(desktopAuthStatusAtom, "error") - get.set(desktopAuthErrorAtom, { - _tag: error._tag ?? "UnknownError", - message: error.message ?? "Login failed", - }) + get.set(desktopAuthErrorAtom, info) return Effect.fail(error) }), ) @@ -136,23 +152,26 @@ export const desktopLoginAtom = Atom.fn( * Action atom that performs desktop logout */ export const desktopLogoutAtom = Atom.fn( - Effect.fnUntraced(function* (options?: DesktopLogoutOptions, get?) { + Effect.fnUntraced(function* (options: DesktopLogoutOptions | undefined, get) { if (!isTauri()) { yield* Effect.log("[desktop-auth] Not in Tauri environment, skipping desktop logout") return } - yield* TokenStorage.clearTokens.pipe( + yield* Effect.gen(function* () { + const tokenStorage: TokenStorageService = yield* TokenStorage + yield* tokenStorage.clearTokens + }).pipe( Effect.provide(TokenStorageLive), - Effect.catchAll((error) => { - console.error("[desktop-auth] Failed to clear tokens:", error) + Effect.catch(() => { + console.error("[desktop-auth] Failed to clear tokens") return Effect.void }), ) - get?.set(desktopTokensAtom, null) - get?.set(desktopAuthStatusAtom, "idle") - get?.set(desktopAuthErrorAtom, null) + get.set(desktopTokensAtom, null) + get.set(desktopAuthStatusAtom, "idle") + get.set(desktopAuthErrorAtom, null) const redirectTo = options?.redirectTo || "/" yield* Effect.log(`[desktop-auth] Logout complete, redirecting to: ${redirectTo}`) @@ -178,6 +197,12 @@ const ClipboardAuthPayload = Schema.Struct({ state: Schema.Unknown, }) +interface ExchangeResult { + accessToken: string + refreshToken: string + expiresIn: number +} + /** * Action atom that authenticates using clipboard data */ @@ -188,7 +213,7 @@ export const desktopLoginFromClipboardAtom = Atom.fn( get.set(desktopAuthStatusAtom, "loading") get.set(desktopAuthErrorAtom, null) - const result = yield* Effect.gen(function* () { + const clipboardEffect = Effect.gen(function* () { const clipboard = yield* Clipboard.Clipboard const clipboardText = yield* clipboard.readString @@ -197,21 +222,20 @@ export const desktopLoginFromClipboardAtom = Atom.fn( catch: () => new Error("Invalid clipboard data - not valid JSON"), }) - const parsed = yield* Schema.decodeUnknown(ClipboardAuthPayload)(rawJson).pipe( + const parsed = yield* Schema.decodeUnknownEffect(ClipboardAuthPayload)(rawJson).pipe( Effect.mapError(() => new Error("Invalid clipboard data - missing code or state")), ) const stateString = typeof parsed.state === "string" ? parsed.state : JSON.stringify(parsed.state) - const tokenExchange = yield* TokenExchange + const tokenExchange: TokenExchangeService = yield* TokenExchange const tokens = yield* tokenExchange.exchangeCode(parsed.code, stateString) - yield* TokenStorage.storeTokens(tokens.accessToken, tokens.refreshToken, tokens.expiresIn) + return tokens as ExchangeResult + }).pipe(Effect.provide(Layer.mergeAll(ClipboardLive, TokenExchangeLive, TokenStorageLive))) - return tokens - }).pipe( - Effect.provide(Layer.mergeAll(ClipboardLive, TokenExchangeLive, TokenStorageLive)), - Effect.catchAll((error) => { + const result: ExchangeResult = yield* clipboardEffect.pipe( + Effect.catch((error) => { console.error("[desktop-auth] Clipboard login failed:", error) get.set(desktopAuthStatusAtom, "error") get.set(desktopAuthErrorAtom, { @@ -242,9 +266,10 @@ export const desktopInitAtom = Atom.make((get) => { if (!isTauri()) return null const loadTokens = Effect.gen(function* () { - const accessTokenOpt = yield* TokenStorage.getAccessToken - const refreshTokenOpt = yield* TokenStorage.getRefreshToken - const expiresAtOpt = yield* TokenStorage.getExpiresAt + const tokenStorage: TokenStorageService = yield* TokenStorage + const accessTokenOpt = yield* tokenStorage.getAccessToken + const refreshTokenOpt = yield* tokenStorage.getRefreshToken + const expiresAtOpt = yield* tokenStorage.getExpiresAt if (Option.isSome(accessTokenOpt) && Option.isSome(refreshTokenOpt) && Option.isSome(expiresAtOpt)) { get.set(desktopTokensAtom, { @@ -260,13 +285,11 @@ export const desktopInitAtom = Atom.make((get) => { } }).pipe( Effect.provide(TokenStorageLive), - Effect.catchAll((error) => { + Effect.catch((error) => { console.error("[desktop-auth] Failed to load tokens:", error) + const info = toErrorInfo(error) get.set(desktopAuthStatusAtom, "error") - get.set(desktopAuthErrorAtom, { - _tag: error._tag ?? "UnknownError", - message: error.message ?? "Failed to load tokens", - }) + get.set(desktopAuthErrorAtom, info) return Effect.void }), ) @@ -274,7 +297,7 @@ export const desktopInitAtom = Atom.make((get) => { const fiber = runtime.runFork(loadTokens) get.addFinalizer(() => { - fiber.unsafeInterruptAsFork(fiber.id()) + fiber.interruptUnsafe() }) return null @@ -314,7 +337,7 @@ export const desktopTokenSchedulerAtom = Atom.make((get) => { const fiber = runtime.runFork(refreshSchedule) get.addFinalizer(() => { - fiber.unsafeInterruptAsFork(fiber.id()) + fiber.interruptUnsafe() }) return { scheduledFor, immediate: false } @@ -333,10 +356,13 @@ export const clearDesktopTokens = (): Promise => { if (!isTauri()) return Promise.resolve() return runtime.runPromise( - TokenStorage.clearTokens.pipe( + Effect.gen(function* () { + const tokenStorage: TokenStorageService = yield* TokenStorage + yield* tokenStorage.clearTokens + }).pipe( Effect.provide(TokenStorageLive), - Effect.catchAll((error) => { - console.error("[desktop-auth] Failed to clear tokens during recovery:", error) + Effect.catch(() => { + console.error("[desktop-auth] Failed to clear tokens during recovery") return Effect.void }), Effect.ensuring( @@ -347,5 +373,5 @@ export const clearDesktopTokens = (): Promise => { }), ), ), - ) + ) as Promise } diff --git a/apps/web/src/atoms/desktop-callback-atoms.ts b/apps/web/src/atoms/desktop-callback-atoms.ts index 9dc5a00ae..eae50056b 100644 --- a/apps/web/src/atoms/desktop-callback-atoms.ts +++ b/apps/web/src/atoms/desktop-callback-atoms.ts @@ -4,7 +4,7 @@ * @description Effect Atom-based state management for desktop OAuth callback handling */ -import { Atom } from "@effect-atom/atom-react" +import { Atom } from "effect/unstable/reactivity" import { DesktopConnectionError, InvalidDesktopStateError, @@ -215,7 +215,7 @@ const handleCallback = (params: DesktopCallbackParams, get: AtomGetter) => schedule: Schedule.exponential("500 millis"), }), Effect.map(() => ({ success: true as const })), - Effect.catchAll((e) => { + Effect.catch((e) => { const error = new DesktopConnectionError({ message: "Could not connect to Hazel", port, @@ -249,7 +249,7 @@ export const createCallbackInitAtom = (params: DesktopCallbackParams) => const fiber = runtime.runFork(callbackEffect) get.addFinalizer(() => { - fiber.unsafeInterruptAsFork(fiber.id()) + fiber.interruptUnsafe() }) return null diff --git a/apps/web/src/atoms/emoji-atoms.ts b/apps/web/src/atoms/emoji-atoms.ts index b31b125a9..4d1e6f4fa 100644 --- a/apps/web/src/atoms/emoji-atoms.ts +++ b/apps/web/src/atoms/emoji-atoms.ts @@ -1,4 +1,4 @@ -import { Atom } from "@effect-atom/atom-react" +import { Atom } from "effect/unstable/reactivity" import { Schema } from "effect" import { platformStorageRuntime } from "~/lib/platform-storage" @@ -11,10 +11,7 @@ const DEFAULT_EMOJIS = ["👍", "❤️", "😂", "🔥", "👀", "🎉"] as con * Schema for emoji usage data * Maps emoji string to usage count */ -const EmojiUsageSchema = Schema.Record({ - key: Schema.String, - value: Schema.Number, -}) +const EmojiUsageSchema = Schema.Record(Schema.String, Schema.Number) export type EmojiUsage = typeof EmojiUsageSchema.Type @@ -24,7 +21,7 @@ export type EmojiUsage = typeof EmojiUsageSchema.Type export const emojiUsageAtom = Atom.kvs({ runtime: platformStorageRuntime, key: "hazel-emoji-usage", - schema: EmojiUsageSchema, + schema: Schema.toCodecIso(EmojiUsageSchema), defaultValue: () => ({}) as EmojiUsage, }) diff --git a/apps/web/src/atoms/feature-discovery-atoms.ts b/apps/web/src/atoms/feature-discovery-atoms.ts index 8f599aa3e..df5d1dcab 100644 --- a/apps/web/src/atoms/feature-discovery-atoms.ts +++ b/apps/web/src/atoms/feature-discovery-atoms.ts @@ -1,4 +1,5 @@ -import { Atom, useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { Atom } from "effect/unstable/reactivity" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import { Schema } from "effect" import { useCallback } from "react" import { platformStorageRuntime } from "~/lib/platform-storage" @@ -7,15 +8,12 @@ const HINT_IDS = ["command-palette", "create-channel"] as const export type HintId = (typeof HINT_IDS)[number] -const DismissedHintsSchema = Schema.Record({ - key: Schema.String, - value: Schema.Boolean, -}) +const DismissedHintsSchema = Schema.Record(Schema.String, Schema.Boolean) export const dismissedHintsAtom = Atom.kvs({ runtime: platformStorageRuntime, key: "hazel-dismissed-hints", - schema: DismissedHintsSchema, + schema: Schema.toCodecIso(DismissedHintsSchema), defaultValue: () => ({}) as Record, }).pipe(Atom.keepAlive) diff --git a/apps/web/src/atoms/github-subscription-atoms.ts b/apps/web/src/atoms/github-subscription-atoms.ts index 417199e97..489c63b68 100644 --- a/apps/web/src/atoms/github-subscription-atoms.ts +++ b/apps/web/src/atoms/github-subscription-atoms.ts @@ -6,7 +6,7 @@ import { HazelRpcClient } from "~/lib/services/common/rpc-atom-client" * Type for GitHub subscription data returned from RPC. * Inferred from the domain model's JSON schema to stay in sync automatically. */ -export type GitHubSubscriptionData = Schema.Schema.Type +export type GitHubSubscriptionData = Schema.Schema.Type /** * Mutation atom for creating a GitHub subscription. diff --git a/apps/web/src/atoms/hotkey-atoms.ts b/apps/web/src/atoms/hotkey-atoms.ts index 616f69791..5ea8fa66d 100644 --- a/apps/web/src/atoms/hotkey-atoms.ts +++ b/apps/web/src/atoms/hotkey-atoms.ts @@ -1,4 +1,5 @@ -import { Atom, useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { Atom } from "effect/unstable/reactivity" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import { normalizeHotkey, validateHotkey, type Hotkey } from "@tanstack/react-hotkeys" import { Schema } from "effect" import { useCallback } from "react" @@ -11,10 +12,7 @@ import { } from "~/lib/hotkeys/hotkey-registry" import { platformStorageRuntime } from "~/lib/platform-storage" -const HotkeyOverridesSchema = Schema.Record({ - key: Schema.String, - value: Schema.String, -}) +const HotkeyOverridesSchema = Schema.Record(Schema.String, Schema.String) type HotkeyOverrides = typeof HotkeyOverridesSchema.Type @@ -35,7 +33,7 @@ export interface ResolvedHotkeyDefinition extends AppHotkeyDefinition { export const hotkeyOverridesAtom = Atom.kvs({ runtime: platformStorageRuntime, key: "hazel-hotkey-overrides", - schema: HotkeyOverridesSchema, + schema: Schema.toCodecIso(HotkeyOverridesSchema), defaultValue: () => ({}) as HotkeyOverrides, }).pipe(Atom.keepAlive) diff --git a/apps/web/src/atoms/loading-state-atoms.ts b/apps/web/src/atoms/loading-state-atoms.ts index 288bdf3b6..eb53115b5 100644 --- a/apps/web/src/atoms/loading-state-atoms.ts +++ b/apps/web/src/atoms/loading-state-atoms.ts @@ -1,4 +1,4 @@ -import { Atom } from "@effect-atom/atom-react" +import { Atom } from "effect/unstable/reactivity" /** * Loading state enum diff --git a/apps/web/src/atoms/message-atoms.ts b/apps/web/src/atoms/message-atoms.ts index e5eecad25..b7bb2cc01 100644 --- a/apps/web/src/atoms/message-atoms.ts +++ b/apps/web/src/atoms/message-atoms.ts @@ -1,4 +1,4 @@ -import { Atom, Result } from "@effect-atom/atom-react" +import { Atom, AsyncResult } from "effect/unstable/reactivity" import type { ChannelId, MessageId, UserId } from "@hazel/schema" import { and, count, eq, isNull } from "@tanstack/db" import { @@ -35,7 +35,7 @@ export const processedReactionsAtomFamily = Atom.family((key: `${MessageId}:${st const [messageId] = key.split(":") as [MessageId, string] const currentUserId = key.slice(messageId.length + 1) // Handle userId that may contain ":" const reactionsResult = get(messageReactionsAtomFamily(messageId)) - const reactions = Result.getOrElse(reactionsResult, () => []) + const reactions = AsyncResult.getOrElse(reactionsResult, () => []) // Aggregate reactions by emoji return Object.entries( diff --git a/apps/web/src/atoms/modal-atoms.ts b/apps/web/src/atoms/modal-atoms.ts index 48fe58ec0..b88edcb20 100644 --- a/apps/web/src/atoms/modal-atoms.ts +++ b/apps/web/src/atoms/modal-atoms.ts @@ -1,4 +1,5 @@ -import { Atom, useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { Atom } from "effect/unstable/reactivity" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import { useCallback } from "react" /** diff --git a/apps/web/src/atoms/notification-sound-atoms.ts b/apps/web/src/atoms/notification-sound-atoms.ts index 1df4d9ef4..ee0df0ed7 100644 --- a/apps/web/src/atoms/notification-sound-atoms.ts +++ b/apps/web/src/atoms/notification-sound-atoms.ts @@ -3,7 +3,7 @@ * @description Atoms for notification sound system state management */ -import { Atom } from "@effect-atom/atom-react" +import { Atom } from "effect/unstable/reactivity" import { Schema } from "effect" import { platformStorageRuntime } from "~/lib/platform-storage" @@ -26,7 +26,7 @@ export interface NotificationSoundSettings { const NotificationSoundSettingsSchema = Schema.Struct({ enabled: Schema.Boolean, volume: Schema.Number, - soundFile: Schema.Literal("notification01", "notification03"), + soundFile: Schema.Literals(["notification01", "notification03"]), cooldownMs: Schema.Number, }) @@ -40,7 +40,7 @@ const DEFAULT_SETTINGS: NotificationSoundSettings = { export const notificationSoundSettingsAtom = Atom.kvs({ runtime: platformStorageRuntime, key: "notification-sound-settings", - schema: Schema.NullOr(NotificationSoundSettingsSchema), + schema: Schema.toCodecIso(Schema.NullOr(NotificationSoundSettingsSchema)), defaultValue: () => DEFAULT_SETTINGS, }) diff --git a/apps/web/src/atoms/onboarding-atoms.ts b/apps/web/src/atoms/onboarding-atoms.ts index 28a57c43f..52646743e 100644 --- a/apps/web/src/atoms/onboarding-atoms.ts +++ b/apps/web/src/atoms/onboarding-atoms.ts @@ -1,4 +1,4 @@ -import { Atom } from "@effect-atom/atom-react" +import { Atom } from "effect/unstable/reactivity" import type { OrganizationId } from "@hazel/schema" // Step identifiers diff --git a/apps/web/src/atoms/organization-atoms.ts b/apps/web/src/atoms/organization-atoms.ts index 62e9ce2e2..198e063d2 100644 --- a/apps/web/src/atoms/organization-atoms.ts +++ b/apps/web/src/atoms/organization-atoms.ts @@ -1,4 +1,4 @@ -import { Atom } from "@effect-atom/atom-react" +import { Atom } from "effect/unstable/reactivity" import type { OrganizationId } from "@hazel/schema" import { HazelRpcClient } from "~/lib/services/common/rpc-atom-client" diff --git a/apps/web/src/atoms/panel-atoms.ts b/apps/web/src/atoms/panel-atoms.ts index 2682ef20b..bc800eaf8 100644 --- a/apps/web/src/atoms/panel-atoms.ts +++ b/apps/web/src/atoms/panel-atoms.ts @@ -1,4 +1,5 @@ -import { Atom, useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { Atom } from "effect/unstable/reactivity" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import { Schema } from "effect" import { useCallback } from "react" import { platformStorageRuntime } from "~/lib/platform-storage" @@ -35,7 +36,7 @@ export const panelWidthAtomFamily = Atom.family((panelType: PanelType) => Atom.kvs({ runtime: platformStorageRuntime, key: `panel_width_${panelType}`, - schema: Schema.NullOr(Schema.Number), + schema: Schema.toCodecIso(Schema.NullOr(Schema.Number)), defaultValue: () => DEFAULT_PANEL_WIDTHS[panelType], }), ) diff --git a/apps/web/src/atoms/presence-atoms.ts b/apps/web/src/atoms/presence-atoms.ts index 9231fa6e8..ca06b224e 100644 --- a/apps/web/src/atoms/presence-atoms.ts +++ b/apps/web/src/atoms/presence-atoms.ts @@ -1,4 +1,4 @@ -import { Atom } from "@effect-atom/atom-react" +import { Atom } from "effect/unstable/reactivity" /** * Shared "now" signal used to periodically re-render presence UI. diff --git a/apps/web/src/atoms/react-scan-atoms.ts b/apps/web/src/atoms/react-scan-atoms.ts index 65d5b7965..635cf2039 100644 --- a/apps/web/src/atoms/react-scan-atoms.ts +++ b/apps/web/src/atoms/react-scan-atoms.ts @@ -1,10 +1,10 @@ -import { Atom } from "@effect-atom/atom-react" +import { Atom } from "effect/unstable/reactivity" import { Schema } from "effect" import { platformStorageRuntime } from "~/lib/platform-storage" export const reactScanEnabledAtom = Atom.kvs({ runtime: platformStorageRuntime, key: "react-scan-enabled", - schema: Schema.NullOr(Schema.Boolean), + schema: Schema.toCodecIso(Schema.NullOr(Schema.Boolean)), defaultValue: () => false, }) diff --git a/apps/web/src/atoms/recent-channels-atom.ts b/apps/web/src/atoms/recent-channels-atom.ts index 8d9770df5..f438e1094 100644 --- a/apps/web/src/atoms/recent-channels-atom.ts +++ b/apps/web/src/atoms/recent-channels-atom.ts @@ -1,4 +1,4 @@ -import { Atom } from "@effect-atom/atom-react" +import { Atom } from "effect/unstable/reactivity" import { Schema } from "effect" import { platformStorageRuntime } from "~/lib/platform-storage" @@ -26,6 +26,6 @@ const RecentChannelsSchema = Schema.Array(RecentChannelSchema) export const recentChannelsAtom = Atom.kvs({ runtime: platformStorageRuntime, key: "recentChannels", - schema: RecentChannelsSchema, + schema: Schema.toCodecIso(RecentChannelsSchema), defaultValue: () => [] as RecentChannel[], }).pipe(Atom.keepAlive) diff --git a/apps/web/src/atoms/rss-subscription-atoms.ts b/apps/web/src/atoms/rss-subscription-atoms.ts index eb8dc39d4..c47877a5b 100644 --- a/apps/web/src/atoms/rss-subscription-atoms.ts +++ b/apps/web/src/atoms/rss-subscription-atoms.ts @@ -6,7 +6,7 @@ import { HazelRpcClient } from "~/lib/services/common/rpc-atom-client" * Type for RSS subscription data returned from RPC. * Inferred from the domain model's JSON schema to stay in sync automatically. */ -export type RssSubscriptionData = Schema.Schema.Type +export type RssSubscriptionData = Schema.Schema.Type /** * Mutation atom for creating an RSS subscription. diff --git a/apps/web/src/atoms/search-atoms.ts b/apps/web/src/atoms/search-atoms.ts index fabf79e98..53e652687 100644 --- a/apps/web/src/atoms/search-atoms.ts +++ b/apps/web/src/atoms/search-atoms.ts @@ -1,4 +1,4 @@ -import { Atom } from "@effect-atom/atom-react" +import { Atom } from "effect/unstable/reactivity" import { Schema } from "effect" import { platformStorageRuntime } from "~/lib/platform-storage" @@ -8,7 +8,7 @@ export const MAX_RECENT_SEARCHES = 10 * Schema for a resolved search filter */ const SearchFilterSchema = Schema.Struct({ - type: Schema.Literal("from", "in", "has", "before", "after"), + type: Schema.Literals(["from", "in", "has", "before", "after"]), value: Schema.String, displayValue: Schema.String, id: Schema.String, @@ -38,6 +38,6 @@ const RecentSearchesSchema = Schema.Array(RecentSearchSchema) export const recentSearchesAtom = Atom.kvs({ runtime: platformStorageRuntime, key: "recentSearches", - schema: RecentSearchesSchema, + schema: Schema.toCodecIso(RecentSearchesSchema), defaultValue: () => [] as RecentSearch[], }).pipe(Atom.keepAlive) diff --git a/apps/web/src/atoms/section-collapse-atoms.ts b/apps/web/src/atoms/section-collapse-atoms.ts index d1ecebab3..b7be15370 100644 --- a/apps/web/src/atoms/section-collapse-atoms.ts +++ b/apps/web/src/atoms/section-collapse-atoms.ts @@ -1,4 +1,4 @@ -import { Atom } from "@effect-atom/atom-react" +import { Atom } from "effect/unstable/reactivity" import type { ChannelSectionId } from "@hazel/schema" import { Schema } from "effect" import { platformStorageRuntime } from "~/lib/platform-storage" @@ -7,10 +7,7 @@ import { platformStorageRuntime } from "~/lib/platform-storage" * Schema for collapsed sections state * Maps section IDs to their collapsed state */ -const CollapsedSectionsSchema = Schema.Record({ - key: Schema.String, - value: Schema.Boolean, -}) +const CollapsedSectionsSchema = Schema.Record(Schema.String, Schema.Boolean) /** * Atom that stores collapsed state for all sections @@ -19,7 +16,7 @@ const CollapsedSectionsSchema = Schema.Record({ export const collapsedSectionsAtom = Atom.kvs({ runtime: platformStorageRuntime, key: "section_collapsed_state", - schema: CollapsedSectionsSchema, + schema: Schema.toCodecIso(CollapsedSectionsSchema), defaultValue: () => ({}) as Record, }) diff --git a/apps/web/src/atoms/sidebar-atoms.ts b/apps/web/src/atoms/sidebar-atoms.ts index e781e06e3..01f5009fa 100644 --- a/apps/web/src/atoms/sidebar-atoms.ts +++ b/apps/web/src/atoms/sidebar-atoms.ts @@ -1,4 +1,4 @@ -import { Atom } from "@effect-atom/atom-react" +import { Atom } from "effect/unstable/reactivity" import { Schema } from "effect" import { platformStorageRuntime } from "~/lib/platform-storage" @@ -14,7 +14,7 @@ export type SidebarState = "expanded" | "collapsed" export const sidebarOpenAtom = Atom.kvs({ runtime: platformStorageRuntime, key: "sidebar_state", - schema: Schema.NullOr(Schema.Boolean), + schema: Schema.toCodecIso(Schema.NullOr(Schema.Boolean)), defaultValue: () => true, }) diff --git a/apps/web/src/atoms/tauri-update-atoms.ts b/apps/web/src/atoms/tauri-update-atoms.ts index bfe8e5b88..f2a054427 100644 --- a/apps/web/src/atoms/tauri-update-atoms.ts +++ b/apps/web/src/atoms/tauri-update-atoms.ts @@ -4,7 +4,7 @@ * @description Effect Atom-based state management for Tauri app updates */ -import { Atom } from "@effect-atom/atom-react" +import { Atom } from "effect/unstable/reactivity" import { getTauriProcess, getTauriUpdater, diff --git a/apps/web/src/atoms/web-auth.ts b/apps/web/src/atoms/web-auth.ts index 4aab6a3b1..e1a2f8e39 100644 --- a/apps/web/src/atoms/web-auth.ts +++ b/apps/web/src/atoms/web-auth.ts @@ -7,7 +7,7 @@ * This module owns atom definitions, init, logout, and the scheduler. */ -import { Atom } from "@effect-atom/atom-react" +import { Atom } from "effect/unstable/reactivity" import { Duration, Effect, Option, Schema } from "effect" import { runtime } from "~/lib/services/common/runtime" import { WebTokenStorage } from "~/lib/services/web/token-storage" @@ -36,7 +36,7 @@ export interface WebAuthError { // Errors // ============================================================================ -class JwtDecodeError extends Schema.TaggedError()("JwtDecodeError", { +class JwtDecodeError extends Schema.TaggedErrorClass()("JwtDecodeError", { message: Schema.String, }) {} @@ -79,7 +79,7 @@ const REFRESH_BUFFER_MS = 5 * 60 * 1000 // Layers // ============================================================================ -const WebTokenStorageLive = WebTokenStorage.Default +const WebTokenStorageLive = WebTokenStorage.layer // ============================================================================ // Core State Atoms @@ -116,7 +116,7 @@ export const webLogoutAtom = Atom.fn( const accessTokenOption = yield* tokenStorage.getAccessToken yield* tokenStorage.clearTokens.pipe( - Effect.catchAll((error) => Effect.logError("[web-auth] Failed to clear tokens", error)), + Effect.catch((error) => Effect.logError("[web-auth] Failed to clear tokens", error)), ) get?.set(webTokensAtom, null) @@ -199,7 +199,7 @@ export const webInitAtom = Atom.make((get) => { } }).pipe( Effect.provide(WebTokenStorageLive), - Effect.catchAll((error) => { + Effect.catch((error) => { console.error("[web-auth] Failed to load tokens:", error) get.set(webAuthStatusAtom, "error") get.set(webAuthErrorAtom, { @@ -213,7 +213,7 @@ export const webInitAtom = Atom.make((get) => { const fiber = runtime.runFork(loadTokens) get.addFinalizer(() => { - fiber.unsafeInterruptAsFork(fiber.id()) + fiber.interruptUnsafe() }) return null @@ -253,7 +253,7 @@ export const webTokenSchedulerAtom = Atom.make((get) => { const fiber = runtime.runFork(refreshSchedule) get.addFinalizer(() => { - fiber.unsafeInterruptAsFork(fiber.id()) + fiber.interruptUnsafe() }) return { scheduledFor, immediate: false } diff --git a/apps/web/src/atoms/web-callback-atoms.ts b/apps/web/src/atoms/web-callback-atoms.ts index cba456068..5b72461cb 100644 --- a/apps/web/src/atoms/web-callback-atoms.ts +++ b/apps/web/src/atoms/web-callback-atoms.ts @@ -4,16 +4,26 @@ * @description Effect Atom-based state management for web OAuth callback handling (JWT flow) */ -import { Atom } from "@effect-atom/atom-react" +import { Atom } from "effect/unstable/reactivity" import { MissingAuthCodeError, OAuthCallbackError, OAuthCodeExpiredError, + OAuthRedemptionPendingError, + OAuthStateMismatchError, + TokenDecodeError, TokenExchangeError, } from "@hazel/domain/errors" -import { Effect, Layer } from "effect" +import { Effect, Layer, type ServiceMap } from "effect" import { appRegistry } from "~/lib/registry" -import { runtime } from "~/lib/services/common/runtime" +import { getWebAuthErrorInfo, type WebAuthError } from "~/lib/auth-errors" +import { + getWebCallbackAttemptKey as buildWebCallbackAttemptKey, + resetAllWebCallbackAttempts, + resetWebCallbackAttempt, + runWebCallbackAttemptOnce, + type WebCallbackKeyParams, +} from "~/lib/web-callback-single-flight" import { TokenExchange } from "~/lib/services/desktop/token-exchange" import { WebTokenStorage } from "~/lib/services/web/token-storage" import { webAuthStatusAtom, webTokensAtom } from "./web-auth" @@ -62,217 +72,265 @@ export const webCallbackStatusAtom = Atom.make({ _tag: "idle" // Layers // ============================================================================ -const WebTokenStorageLive = WebTokenStorage.Default -const TokenExchangeLive = TokenExchange.Default +const WebTokenStorageLive = WebTokenStorage.layer +const TokenExchangeLive = TokenExchange.layer + +type TokenExchangeService = ServiceMap.Service.Shape +type WebTokenStorageService = ServiceMap.Service.Shape // ============================================================================ -// Error Handling +// Attempt Context // ============================================================================ -type CallbackError = OAuthCallbackError | MissingAuthCodeError | TokenExchangeError | OAuthCodeExpiredError +const callbackAttemptIds = new Map() -/** - * Get user-friendly error info from typed error - */ -function getErrorInfo(error: CallbackError): { - message: string - isRetryable: boolean -} { - switch (error._tag) { - case "OAuthCallbackError": - return { - message: error.errorDescription || error.error, - isRetryable: true, - } - case "MissingAuthCodeError": - return { - message: "No authorization code received. Please try again.", - isRetryable: true, - } - case "OAuthCodeExpiredError": - // Code expired or already used - user must restart the login flow - return { - message: "Login session expired. Please try logging in again.", - isRetryable: false, - } - case "TokenExchangeError": - return { - message: error.message || "Failed to exchange authorization code.", - isRetryable: true, - } +const getOrCreateCallbackAttemptId = (attemptKey: string): string => { + const existing = callbackAttemptIds.get(attemptKey) + if (existing) { + return existing } + + const attemptId = `web_callback_${crypto.randomUUID()}` + callbackAttemptIds.set(attemptKey, attemptId) + return attemptId } -// ============================================================================ -// Core Callback Logic -// ============================================================================ +const logWebCallback = (level: "Info" | "Error", message: string, fields: Record): void => { + const effect = level === "Error" ? Effect.logError(message, fields) : Effect.logInfo(message, fields) + void Effect.runFork(effect) +} -/** - * Module-level Set to track codes that have been processed - * Prevents double-execution from React StrictMode or hot reload - */ -const processedCodes = new Set() +type WebCallbackResult = { success: true; returnTo: string } | { success: false; error: WebAuthError } /** * Effect that handles the web callback - exchanges code for tokens and stores them */ -const handleCallback = (params: WebCallbackParams) => +const exchangeAndStoreTokens = (code: string, stateString: string, returnTo: string, attemptId: string) => Effect.gen(function* () { - // Guard against double-execution (React StrictMode, hot reload) - // OAuth codes are one-time use, so we track processed codes - if (params.code && processedCodes.has(params.code)) { - yield* Effect.log("[web-callback] Code already processed, skipping") - return - } - - // Mark code as being processed - if (params.code) { - processedCodes.add(params.code) - } - - appRegistry.set(webCallbackStatusAtom, { _tag: "exchanging" }) - - // Check for OAuth errors from WorkOS - if (params.error) { - const error = new OAuthCallbackError({ - message: params.error_description || params.error, - error: params.error, - errorDescription: params.error_description, - }) - const errorInfo = getErrorInfo(error) - console.error("[web-callback] OAuth error:", error) - appRegistry.set(webCallbackStatusAtom, { _tag: "error", ...errorInfo }) - return - } + const tokenExchange: TokenExchangeService = yield* TokenExchange + const tokenStorage: WebTokenStorageService = yield* WebTokenStorage + + yield* Effect.logInfo("[web-callback] Exchanging code for tokens", { + attemptId, + returnTo, + }) + + const tokens = yield* tokenExchange.exchangeCode(code, stateString, attemptId) + + yield* Effect.logInfo("[web-callback] Storing tokens", { + attemptId, + }) + yield* tokenStorage.storeTokens(tokens.accessToken, tokens.refreshToken, tokens.expiresIn) + + const expiresAt = Date.now() + tokens.expiresIn * 1000 + appRegistry.set(webTokensAtom, { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresAt, + }) + appRegistry.set(webAuthStatusAtom, "authenticated") + + yield* Effect.logInfo("[web-callback] Token exchange successful", { + attemptId, + returnTo, + }) + + return { success: true as const, returnTo } + }) - // Validate required code - if (!params.code) { - const error = new MissingAuthCodeError({ message: "Missing authorization code" }) - const errorInfo = getErrorInfo(error) - console.error("[web-callback] Missing code:", error) - appRegistry.set(webCallbackStatusAtom, { _tag: "error", ...errorInfo }) - return - } - const code = params.code - - // Validate state - if (!params.state) { - const error = new MissingAuthCodeError({ message: "Missing state parameter" }) - const errorInfo = getErrorInfo(error) - console.error("[web-callback] Missing state:", error) - appRegistry.set(webCallbackStatusAtom, { _tag: "error", ...errorInfo }) - return - } - - // Parse the state to extract returnTo - // State can be either a string (raw JSON) or already-parsed object (TanStack Router auto-parses JSON) - let authState: AuthState - let stateString: string - if (typeof params.state === "string") { - stateString = params.state - try { - authState = JSON.parse(params.state) - } catch { - const error = new MissingAuthCodeError({ message: "Invalid state parameter" }) - const errorInfo = getErrorInfo(error) - console.error("[web-callback] Invalid state JSON:", params.state) - appRegistry.set(webCallbackStatusAtom, { _tag: "error", ...errorInfo }) - return - } - } else { - // State is already parsed by TanStack Router - authState = params.state - stateString = JSON.stringify(params.state) +const parseWebCallbackState = (state: string | AuthState): { authState: AuthState; stateString: string } => { + if (typeof state === "string") { + return { + authState: JSON.parse(state) as AuthState, + stateString: state, } + } - const returnTo = authState.returnTo || "/" + return { + authState: state, + stateString: JSON.stringify(state), + } +} - // Exchange code for tokens - const result = yield* Effect.gen(function* () { - const tokenExchange = yield* TokenExchange - const tokenStorage = yield* WebTokenStorage +const executeWebCallback = async ( + params: WebCallbackParams, + attemptKey: string, + attemptId: string, +): Promise => { + if (params.error) { + const error = new OAuthCallbackError({ + message: params.error_description || params.error, + error: params.error, + errorDescription: params.error_description, + }) + logWebCallback("Error", "[web-callback] OAuth provider returned an error", { + attemptId, + attemptKey, + errorTag: error._tag, + error: error.error, + errorDescription: error.errorDescription, + }) + return { success: false, error } + } - yield* Effect.log("[web-callback] Exchanging code for tokens...") + if (!params.code) { + const error = new MissingAuthCodeError({ message: "Missing authorization code" }) + logWebCallback("Error", "[web-callback] Missing authorization code", { + attemptId, + attemptKey, + errorTag: error._tag, + }) + return { success: false, error } + } - const tokens = yield* tokenExchange.exchangeCode(code, stateString) + if (!params.state) { + const error = new MissingAuthCodeError({ message: "Missing state parameter" }) + logWebCallback("Error", "[web-callback] Missing state parameter", { + attemptId, + attemptKey, + errorTag: error._tag, + }) + return { success: false, error } + } - yield* Effect.log("[web-callback] Storing tokens...") + let authState: AuthState + let stateString: string + try { + ;({ authState, stateString } = parseWebCallbackState(params.state)) + } catch { + const error = new MissingAuthCodeError({ message: "Invalid state parameter" }) + logWebCallback("Error", "[web-callback] Invalid state parameter", { + attemptId, + attemptKey, + errorTag: error._tag, + }) + return { success: false, error } + } - // Store tokens in localStorage - yield* tokenStorage.storeTokens(tokens.accessToken, tokens.refreshToken, tokens.expiresIn) + const returnTo = authState.returnTo || "/" + return webCallbackExecutor({ + attemptId, + code: params.code, + stateString, + returnTo, + }) +} - // Update atom state via global registry (not `get.set()` which can be - // stale if React StrictMode unmounted the atom that forked this fiber) - const expiresAt = Date.now() + tokens.expiresIn * 1000 - appRegistry.set(webTokensAtom, { - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - expiresAt, - }) - appRegistry.set(webAuthStatusAtom, "authenticated") +type WebCallbackExecutorArgs = { + attemptId: string + code: string + stateString: string + returnTo: string +} - yield* Effect.log("[web-callback] Token exchange successful") +type WebCallbackExecutor = (args: WebCallbackExecutorArgs) => Promise - return { success: true as const, returnTo } - }).pipe( +const defaultWebCallbackExecutor: WebCallbackExecutor = async ({ attemptId, code, stateString, returnTo }) => + await exchangeAndStoreTokens(code, stateString, returnTo, attemptId) + .pipe( Effect.provide(Layer.mergeAll(TokenExchangeLive, WebTokenStorageLive)), - // Preserve typed errors (OAuthCodeExpiredError, TokenExchangeError, etc.) - Effect.catchTag("OAuthCodeExpiredError", (error) => { - console.error("[web-callback] OAuth code expired:", error) - return Effect.succeed({ - success: false as const, - error, - }) - }), - Effect.catchTag("TokenExchangeError", (error) => { - console.error("[web-callback] Token exchange failed:", error) - return Effect.succeed({ - success: false as const, - error, - }) + Effect.catchTags({ + OAuthCodeExpiredError: (error) => + Effect.succeed({ + success: false as const, + error, + }), + OAuthStateMismatchError: (error) => + Effect.succeed({ + success: false as const, + error, + }), + OAuthRedemptionPendingError: (error) => + Effect.succeed({ + success: false as const, + error, + }), + TokenExchangeError: (error) => + Effect.succeed({ + success: false as const, + error, + }), + TokenDecodeError: (error) => + Effect.succeed({ + success: false as const, + error, + }), }), - Effect.catchAll((error) => { - console.error("[web-callback] Token exchange failed:", error) + Effect.catch((error: unknown) => { + const msg = + error && typeof error === "object" && "message" in error + ? (error as { message?: string }).message + : undefined return Effect.succeed({ success: false as const, error: new TokenExchangeError({ - message: error.message || "Failed to exchange authorization code", + message: msg || "Failed to exchange authorization code", detail: String(error), }), }) }), ) + .pipe(Effect.runPromise) - if (result.success) { - appRegistry.set(webCallbackStatusAtom, { _tag: "success", returnTo: result.returnTo }) - } else { - const errorInfo = getErrorInfo(result.error) - // Allow retry for retryable errors by clearing the processed code - if (errorInfo.isRetryable && code) { - processedCodes.delete(code) - } - appRegistry.set(webCallbackStatusAtom, { _tag: "error", ...errorInfo }) - } - }) +let webCallbackExecutor: WebCallbackExecutor = defaultWebCallbackExecutor -// ============================================================================ -// Init Atom Factory -// ============================================================================ +export const setWebCallbackExecutorForTest = (executor: WebCallbackExecutor | null): void => { + webCallbackExecutor = executor ?? defaultWebCallbackExecutor +} -/** - * Factory that creates an init atom for handling the callback - * The atom runs the callback effect when mounted via useAtomValue - */ -export const createWebCallbackInitAtom = (params: WebCallbackParams) => - Atom.make(() => { - // No finalizer — let the OAuth exchange complete even if Strict Mode - // unmounts/remounts. processedCodes prevents re-execution on remount. - // All atom updates use appRegistry.set() so they survive unmount. - runtime.runFork(handleCallback(params)) - - return null +export const getWebCallbackAttemptKey = (params: WebCallbackKeyParams): string => + buildWebCallbackAttemptKey(params) + +export const startWebCallback = async (params: WebCallbackParams): Promise => { + const attemptKey = getWebCallbackAttemptKey(params) + const attemptId = getOrCreateCallbackAttemptId(attemptKey) + logWebCallback("Info", "[web-callback] Starting callback handling", { + attemptId, + attemptKey, + hasCode: Boolean(params.code), + hasState: Boolean(params.state), + hasProviderError: Boolean(params.error), }) + await runWebCallbackAttemptOnce( + attemptKey, + async () => { + appRegistry.set(webCallbackStatusAtom, { _tag: "exchanging" }) + const result = await executeWebCallback(params, attemptKey, attemptId) + + if (result.success) { + appRegistry.set(webCallbackStatusAtom, { _tag: "success", returnTo: result.returnTo }) + logWebCallback("Info", "[web-callback] Callback completed", { + attemptId, + attemptKey, + outcome: "success", + returnTo: result.returnTo, + }) + } else { + const errorInfo = getWebAuthErrorInfo(result.error) + appRegistry.set(webCallbackStatusAtom, { _tag: "error", ...errorInfo }) + logWebCallback("Error", "[web-callback] Callback failed", { + attemptId, + attemptKey, + outcome: "error", + errorTag: result.error._tag, + message: errorInfo.message, + isRetryable: errorInfo.isRetryable, + }) + } + + return result + }, + (result) => result.success || !getWebAuthErrorInfo(result.error).isRetryable, + ) +} + +export const retryWebCallback = async (params: WebCallbackParams): Promise => { + const attemptKey = getWebCallbackAttemptKey(params) + resetWebCallbackAttempt(attemptKey) + await startWebCallback(params) +} + // ============================================================================ // State Reset // ============================================================================ @@ -282,20 +340,7 @@ export const createWebCallbackInitAtom = (params: WebCallbackParams) => * Called during logout to clear stale state that survives client-side navigation. */ export const resetCallbackState = () => { - processedCodes.clear() + resetAllWebCallbackAttempts() + callbackAttemptIds.clear() appRegistry.set(webCallbackStatusAtom, { _tag: "idle" }) } - -// ============================================================================ -// Action Atoms -// ============================================================================ - -/** - * Action atom for retry functionality - * Takes params directly instead of reading from a params atom - */ -export const retryWebCallbackAtom = Atom.fn( - Effect.fnUntraced(function* (params: WebCallbackParams) { - yield* handleCallback(params) - }), -) diff --git a/apps/web/src/components/bots/bot-avatar.tsx b/apps/web/src/components/bots/bot-avatar.tsx index 38c6cae2f..07b49eee2 100644 --- a/apps/web/src/components/bots/bot-avatar.tsx +++ b/apps/web/src/components/bots/bot-avatar.tsx @@ -1,4 +1,4 @@ -import { useAtomValue } from "@effect-atom/atom-react" +import { useAtomValue } from "@effect/atom-react" import type { IntegrationConnection } from "@hazel/domain/models" import { Avatar, type AvatarProps } from "~/components/ui/avatar" import { resolveBotAvatarUrl } from "~/lib/bot-avatar" diff --git a/apps/web/src/components/bots/bot-card.tsx b/apps/web/src/components/bots/bot-card.tsx index 902db68f8..84c9259ae 100644 --- a/apps/web/src/components/bots/bot-card.tsx +++ b/apps/web/src/components/bots/bot-card.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { IntegrationConnection } from "@hazel/domain/models" import { useState } from "react" diff --git a/apps/web/src/components/channel-settings/add-github-repo-modal.tsx b/apps/web/src/components/channel-settings/add-github-repo-modal.tsx index 3fa20e9a6..4091cad2b 100644 --- a/apps/web/src/components/channel-settings/add-github-repo-modal.tsx +++ b/apps/web/src/components/channel-settings/add-github-repo-modal.tsx @@ -1,4 +1,5 @@ -import { Result, useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { AsyncResult } from "effect/unstable/reactivity" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import type { OrganizationId } from "@hazel/schema" import type { GitHubSubscription } from "@hazel/domain/models" import type { ChannelId } from "@hazel/schema" @@ -60,8 +61,8 @@ export function AddGitHubRepoModal({ // Fetch repositories const repositoriesResult = useAtomValue( HazelApiClient.query("integration-resources", "getGitHubRepositories", { - path: { orgId: organizationId }, - urlParams: { page: 1, perPage: 100 }, + params: { orgId: organizationId }, + query: { page: 1, perPage: 100 }, }), ) @@ -123,7 +124,7 @@ export function AddGitHubRepoModal({ } // Get repositories from result - const repositories = Result.builder(repositoriesResult) + const repositories = AsyncResult.builder(repositoriesResult) .onSuccess((data) => data?.repositories ?? []) .orElse(() => []) @@ -144,7 +145,7 @@ export function AddGitHubRepoModal({ {/* Repository selector */}
- {Result.builder(repositoriesResult) + {AsyncResult.builder(repositoriesResult) .onInitial(() => (
diff --git a/apps/web/src/components/channel-settings/add-rss-feed-modal.tsx b/apps/web/src/components/channel-settings/add-rss-feed-modal.tsx index 749c5db0e..461f3eabf 100644 --- a/apps/web/src/components/channel-settings/add-rss-feed-modal.tsx +++ b/apps/web/src/components/channel-settings/add-rss-feed-modal.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { ChannelId } from "@hazel/schema" import { useState } from "react" import { createRssSubscriptionMutation } from "~/atoms/rss-subscription-atoms" diff --git a/apps/web/src/components/channel-settings/create-webhook-form.tsx b/apps/web/src/components/channel-settings/create-webhook-form.tsx index 13142e4bd..f4242b5c1 100644 --- a/apps/web/src/components/channel-settings/create-webhook-form.tsx +++ b/apps/web/src/components/channel-settings/create-webhook-form.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { ChannelId } from "@hazel/schema" import { type } from "arktype" import { useState } from "react" diff --git a/apps/web/src/components/channel-settings/github-integration-card.tsx b/apps/web/src/components/channel-settings/github-integration-card.tsx index 0be990ea2..aaab50ae8 100644 --- a/apps/web/src/components/channel-settings/github-integration-card.tsx +++ b/apps/web/src/components/channel-settings/github-integration-card.tsx @@ -1,4 +1,4 @@ -import { useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import type { ChannelId, OrganizationId } from "@hazel/schema" import { useCallback, useEffect, useRef, useState } from "react" import { diff --git a/apps/web/src/components/channel-settings/github-subscription-card.tsx b/apps/web/src/components/channel-settings/github-subscription-card.tsx index d9cb2f8eb..a08229653 100644 --- a/apps/web/src/components/channel-settings/github-subscription-card.tsx +++ b/apps/web/src/components/channel-settings/github-subscription-card.tsx @@ -1,4 +1,4 @@ -import { useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import type { GitHubSubscriptionId } from "@hazel/schema" import { useState } from "react" import { diff --git a/apps/web/src/components/channel-settings/integration-card.tsx b/apps/web/src/components/channel-settings/integration-card.tsx index fdc039c9c..28fe27edc 100644 --- a/apps/web/src/components/channel-settings/integration-card.tsx +++ b/apps/web/src/components/channel-settings/integration-card.tsx @@ -1,8 +1,9 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { ChannelId, ChannelWebhookId } from "@hazel/schema" import { formatDistanceToNow } from "date-fns" import { useState } from "react" import { toast } from "sonner" +import { toDate } from "~/lib/utils" import { createChannelWebhookMutation, deleteChannelWebhookMutation, @@ -266,7 +267,7 @@ export function IntegrationCard({ provider, channelId, webhook, onWebhookChange {webhook.lastUsedAt && (

Last alert{" "} - {formatDistanceToNow(new Date(webhook.lastUsedAt), { addSuffix: true })} + {formatDistanceToNow(toDate(webhook.lastUsedAt), { addSuffix: true })}

)}
diff --git a/apps/web/src/components/channel-settings/openstatus-section.tsx b/apps/web/src/components/channel-settings/openstatus-section.tsx index 185670555..0e4d770cc 100644 --- a/apps/web/src/components/channel-settings/openstatus-section.tsx +++ b/apps/web/src/components/channel-settings/openstatus-section.tsx @@ -1,8 +1,9 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { ChannelId, ChannelWebhookId } from "@hazel/schema" import { formatDistanceToNow } from "date-fns" import { Exit } from "effect" import { useState } from "react" +import { toDate } from "~/lib/utils" import { toast } from "sonner" import { createChannelWebhookMutation, @@ -272,7 +273,7 @@ export function OpenStatusSection({ {webhook.lastUsedAt && (

Last alert{" "} - {formatDistanceToNow(new Date(webhook.lastUsedAt), { addSuffix: true })} + {formatDistanceToNow(toDate(webhook.lastUsedAt), { addSuffix: true })}

)}
diff --git a/apps/web/src/components/channel-settings/railway-section.tsx b/apps/web/src/components/channel-settings/railway-section.tsx index a5b8ecf12..bd2664676 100644 --- a/apps/web/src/components/channel-settings/railway-section.tsx +++ b/apps/web/src/components/channel-settings/railway-section.tsx @@ -1,8 +1,9 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { ChannelId, ChannelWebhookId } from "@hazel/schema" import { formatDistanceToNow } from "date-fns" import { Exit } from "effect" import { useState } from "react" +import { toDate } from "~/lib/utils" import { toast } from "sonner" import { createChannelWebhookMutation, @@ -272,7 +273,7 @@ export function RailwaySection({ {webhook.lastUsedAt && (

Last alert{" "} - {formatDistanceToNow(new Date(webhook.lastUsedAt), { addSuffix: true })} + {formatDistanceToNow(toDate(webhook.lastUsedAt), { addSuffix: true })}

)}
diff --git a/apps/web/src/components/channel-settings/rss-integration-card.tsx b/apps/web/src/components/channel-settings/rss-integration-card.tsx index 5aa633e7b..5ce723142 100644 --- a/apps/web/src/components/channel-settings/rss-integration-card.tsx +++ b/apps/web/src/components/channel-settings/rss-integration-card.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { ChannelId, RssSubscriptionId } from "@hazel/schema" import { useCallback, useEffect, useRef, useState } from "react" import { diff --git a/apps/web/src/components/chat-sync/add-channel-link-modal.tsx b/apps/web/src/components/chat-sync/add-channel-link-modal.tsx index 70f9c5367..398ea9caa 100644 --- a/apps/web/src/components/chat-sync/add-channel-link-modal.tsx +++ b/apps/web/src/components/chat-sync/add-channel-link-modal.tsx @@ -1,4 +1,5 @@ -import { Result, useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { AsyncResult } from "effect/unstable/reactivity" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import type { Channel } from "@hazel/domain/models" import type { ChannelId, ExternalChannelId, OrganizationId, SyncConnectionId } from "@hazel/schema" import { eq, or, useLiveQuery } from "@tanstack/react-db" @@ -21,7 +22,7 @@ import { HazelApiClient } from "~/lib/services/common/atom-client" import { HazelRpcClient } from "~/lib/services/common/rpc-atom-client" import { exitToast } from "~/lib/toast-exit" -type ChannelData = typeof Channel.Model.Type +type ChannelData = Channel.Type type SyncDirection = "both" | "hazel_to_external" | "external_to_hazel" interface DiscordChannel { @@ -111,7 +112,7 @@ export function AddChannelLinkModal({ const discordChannelsResult = useAtomValue( HazelApiClient.query("integration-resources", "getDiscordGuildChannels", { - path: { orgId: organizationId, guildId: externalWorkspaceId }, + params: { orgId: organizationId, guildId: externalWorkspaceId }, }), ) @@ -123,7 +124,7 @@ export function AddChannelLinkModal({ const discordChannels = useMemo( () => - Result.builder(discordChannelsResult) + AsyncResult.builder(discordChannelsResult) .onSuccess((data) => data?.channels ?? []) .orElse(() => []), [discordChannelsResult], @@ -251,7 +252,7 @@ export function AddChannelLinkModal({
- {Result.isInitial(discordChannelsResult) && ( + {AsyncResult.isInitial(discordChannelsResult) && (
@@ -259,7 +260,7 @@ export function AddChannelLinkModal({
)} - {Result.isFailure(discordChannelsResult) && ( + {AsyncResult.isFailure(discordChannelsResult) && (

Could not load Discord channels

@@ -267,7 +268,7 @@ export function AddChannelLinkModal({

)} - {Result.isSuccess(discordChannelsResult) && ( + {AsyncResult.isSuccess(discordChannelsResult) && ( <> {selectedDiscordChannel ? (
@@ -367,7 +368,7 @@ export function AddChannelLinkModal({
)} - {Result.isSuccess(guildsResult) && ( + {AsyncResult.isSuccess(guildsResult) && (
{selectedGuild ? ( @@ -222,7 +223,7 @@ export function AddConnectionModal({
diff --git a/apps/web/src/components/chat/channel-files/channel-files-media-gallery-view.tsx b/apps/web/src/components/chat/channel-files/channel-files-media-gallery-view.tsx index 5e8447a95..c827b5045 100644 --- a/apps/web/src/components/chat/channel-files/channel-files-media-gallery-view.tsx +++ b/apps/web/src/components/chat/channel-files/channel-files-media-gallery-view.tsx @@ -5,12 +5,13 @@ import { useState } from "react" import { IconDownload } from "~/components/icons/icon-download" import { Button } from "~/components/ui/button" import { useChannelAttachments } from "~/db/hooks" +import { toEpochMs } from "~/lib/utils" import { getAttachmentUrl } from "~/utils/attachment-url" import { getFileCategory, getFileTypeFromName } from "~/utils/file-utils" import { ImageViewerModal, type ViewerImage } from "../image-viewer-modal" -type AttachmentWithUser = typeof Attachment.Model.Type & { - user: typeof User.Model.Type | null +type AttachmentWithUser = Attachment.Type & { + user: User.Type | null } interface MediaGalleryViewProps { @@ -181,7 +182,7 @@ export function MediaGalleryView({ channelId }: MediaGalleryViewProps) { images={viewerImages} initialIndex={selectedImageIndex} author={selectedImage?.user ?? undefined} - createdAt={selectedImage?.uploadedAt.getTime() ?? 0} + createdAt={selectedImage ? toEpochMs(selectedImage.uploadedAt) : 0} /> )} diff --git a/apps/web/src/components/chat/channel-files/channel-files-media-grid.tsx b/apps/web/src/components/chat/channel-files/channel-files-media-grid.tsx index 8ae884403..a873e0e4e 100644 --- a/apps/web/src/components/chat/channel-files/channel-files-media-grid.tsx +++ b/apps/web/src/components/chat/channel-files/channel-files-media-grid.tsx @@ -6,12 +6,13 @@ import { useMemo, useState } from "react" import { IconDownload } from "~/components/icons/icon-download" import { Button } from "~/components/ui/button" import { useBreakpoint } from "~/hooks/use-breakpoint" +import { toEpochMs } from "~/lib/utils" import { getAttachmentUrl } from "~/utils/attachment-url" import { getFileTypeFromName } from "~/utils/file-utils" import { ImageViewerModal, type ViewerImage } from "../image-viewer-modal" -type AttachmentWithUser = typeof Attachment.Model.Type & { - user: typeof User.Model.Type | null +type AttachmentWithUser = Attachment.Type & { + user: User.Type | null } interface ChannelFilesMediaGridProps { @@ -217,7 +218,7 @@ export function ChannelFilesMediaGrid({ attachments, channelId }: ChannelFilesMe images={viewerImages} initialIndex={selectedIndex} author={selectedImage?.user ?? undefined} - createdAt={selectedImage?.uploadedAt.getTime() ?? 0} + createdAt={selectedImage ? toEpochMs(selectedImage.uploadedAt) : 0} /> )} diff --git a/apps/web/src/components/chat/channel-join-banner.tsx b/apps/web/src/components/chat/channel-join-banner.tsx index 877d698b2..8338f5466 100644 --- a/apps/web/src/components/chat/channel-join-banner.tsx +++ b/apps/web/src/components/chat/channel-join-banner.tsx @@ -1,6 +1,6 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { ChannelId } from "@hazel/schema" -import { UserId } from "@hazel/schema" +import type { UserId } from "@hazel/schema" import { eq, useLiveQuery } from "@tanstack/react-db" import IconHashtag from "~/components/icons/icon-hashtag" import { Button } from "~/components/ui/button" @@ -33,7 +33,7 @@ export function ChannelJoinBanner({ channelId }: ChannelJoinBannerProps) { const exit = await joinChannel({ channelId, - userId: UserId.make(user.id), + userId: user.id as UserId, }) exitToast(exit) diff --git a/apps/web/src/components/chat/chat-header.tsx b/apps/web/src/components/chat/chat-header.tsx index 4dde52bc2..68a2032f1 100644 --- a/apps/web/src/components/chat/chat-header.tsx +++ b/apps/web/src/components/chat/chat-header.tsx @@ -24,7 +24,7 @@ import { PinnedMessagesModal } from "./pinned-messages-modal" interface OtherMemberAvatarProps { member: { userId: UserId - user: Pick + user: Pick } } diff --git a/apps/web/src/components/chat/image-viewer-modal.tsx b/apps/web/src/components/chat/image-viewer-modal.tsx index 6b6073713..e7943fccb 100644 --- a/apps/web/src/components/chat/image-viewer-modal.tsx +++ b/apps/web/src/components/chat/image-viewer-modal.tsx @@ -20,7 +20,7 @@ import { IconExternalLink } from "../icons/icon-link-external" export type ViewerImage = | { type: "attachment" - attachment: typeof Attachment.Model.Type + attachment: Attachment.Type } | { type: "url" @@ -33,7 +33,7 @@ interface ImageViewerModalProps { onOpenChange: (open: boolean) => void images: ViewerImage[] initialIndex: number - author?: typeof User.Model.Type + author?: User.Type createdAt: number } diff --git a/apps/web/src/components/chat/inline-thread-preview.tsx b/apps/web/src/components/chat/inline-thread-preview.tsx index 3252d045b..baed425a1 100644 --- a/apps/web/src/components/chat/inline-thread-preview.tsx +++ b/apps/web/src/components/chat/inline-thread-preview.tsx @@ -1,8 +1,10 @@ -import { Result, useAtomValue } from "@effect-atom/atom-react" +import { AsyncResult } from "effect/unstable/reactivity" +import { useAtomValue } from "@effect/atom-react" import type { ChannelId, MessageId, UserId } from "@hazel/schema" import { eq, useLiveQuery } from "@tanstack/react-db" import { formatDistanceToNow } from "date-fns" import { useMemo } from "react" +import { toDate } from "~/lib/utils" import { threadMessageCountAtomFamily, userWithPresenceAtomFamily } from "~/atoms/message-atoms" import { channelCollection, messageCollection } from "~/db/collections" import { useChatStable } from "~/hooks/use-chat" @@ -33,7 +35,7 @@ export function InlineThreadPreview({ // Get total thread message count using atom const countResult = useAtomValue(threadMessageCountAtomFamily(threadChannelId)) - const countData = Result.getOrElse(countResult, () => []) + const countData = AsyncResult.getOrElse(countResult, () => []) const totalCount = countData?.[0]?.count ?? 0 // Get last message timestamp and unique authors for avatar stack @@ -105,7 +107,7 @@ export function InlineThreadPreview({ {lastReplyAt && ( {totalCount > 1 ? "Last reply " : ""} - {formatDistanceToNow(lastReplyAt, { addSuffix: false })} ago + {formatDistanceToNow(toDate(lastReplyAt), { addSuffix: false })} ago )} {/* View thread - absolutely positioned on hover */} @@ -135,7 +137,7 @@ function AvatarStack({ authorIds }: { authorIds: UserId[] }) { function AvatarStackItem({ authorId, index }: { authorId: UserId; index: number }) { const userPresenceResult = useAtomValue(userWithPresenceAtomFamily(authorId)) - const data = Result.getOrElse(userPresenceResult, () => []) + const data = AsyncResult.getOrElse(userPresenceResult, () => []) const user = data[0]?.user const authorIdentity = useChatAuthorIdentity(authorId, user) diff --git a/apps/web/src/components/chat/message-attachments.tsx b/apps/web/src/components/chat/message-attachments.tsx index 9dc976db8..803d5c07d 100644 --- a/apps/web/src/components/chat/message-attachments.tsx +++ b/apps/web/src/components/chat/message-attachments.tsx @@ -4,6 +4,7 @@ import { FileIcon } from "@untitledui/file-icons" import { useState } from "react" import { useAttachments, useMessage } from "~/db/hooks" +import { toEpochMs } from "~/lib/utils" import { getAttachmentUrl } from "~/utils/attachment-url" import { formatFileSize, getFileTypeFromName } from "~/utils/file-utils" import { IconDownload } from "../icons/icon-download" @@ -16,7 +17,7 @@ interface MessageAttachmentsProps { } interface ImageAttachmentItemProps { - attachment: typeof Attachment.Model.Type + attachment: Attachment.Type imageCount: number index: number onClick: () => void @@ -51,7 +52,7 @@ function ImageAttachmentItem({ attachment, imageCount, index, onClick }: ImageAt } interface AttachmentItemProps { - attachment: typeof Attachment.Model.Type + attachment: Attachment.Type } function AttachmentItem({ attachment }: AttachmentItemProps) { @@ -198,7 +199,7 @@ export function MessageAttachments({ messageId }: MessageAttachmentsProps) { images={viewerImages} initialIndex={selectedImageIndex} author={message.author} - createdAt={message.createdAt.getTime()} + createdAt={toEpochMs(message.createdAt)} /> ) })()} diff --git a/apps/web/src/components/chat/message-content.tsx b/apps/web/src/components/chat/message-content.tsx index e13d2e4e9..ccbe2b751 100644 --- a/apps/web/src/components/chat/message-content.tsx +++ b/apps/web/src/components/chat/message-content.tsx @@ -1,5 +1,6 @@ import type { OrganizationId } from "@hazel/schema" import { createContext, lazy, Suspense, useMemo } from "react" +import { toEpochMs } from "~/lib/utils" import { Node } from "slate" import type { MessageWithPinned } from "~/atoms/chat-query-atoms" import { GifEmbed } from "~/components/gif-embed" @@ -266,7 +267,7 @@ function Embeds() { ) : null @@ -284,7 +285,7 @@ function Embeds() { key={url} url={url} author={message.author ?? undefined} - createdAt={message.createdAt.getTime()} + createdAt={toEpochMs(message.createdAt)} /> ))} diff --git a/apps/web/src/components/chat/message-embeds.tsx b/apps/web/src/components/chat/message-embeds.tsx index f5f399fba..ebe524fcd 100644 --- a/apps/web/src/components/chat/message-embeds.tsx +++ b/apps/web/src/components/chat/message-embeds.tsx @@ -4,10 +4,10 @@ import { Embed } from "~/components/embeds" import { MessageLive } from "./message-live-state" // Extract embed type from the Message model -type MessageEmbedType = NonNullable[number] +type MessageEmbedType = NonNullable[number] interface MessageEmbedsProps { - embeds: typeof Message.Model.Type.embeds + embeds: Message.Type["embeds"] messageId?: MessageId organizationId?: OrganizationId } diff --git a/apps/web/src/components/chat/message-item.tsx b/apps/web/src/components/chat/message-item.tsx index 961850463..b8c67750f 100644 --- a/apps/web/src/components/chat/message-item.tsx +++ b/apps/web/src/components/chat/message-item.tsx @@ -69,6 +69,7 @@ export const MessageItem = memo(function MessageItem({ // It's now implemented inside Message.Header but we keep this export import { format } from "date-fns" import IconPin from "~/components/icons/icon-pin" +import { toDate, toEpochMs } from "~/lib/utils" import { StatusEmojiWithTooltip } from "~/components/status/user-status-badge" import { Badge } from "~/components/ui/badge" import { useUserStatus } from "~/hooks/use-presence" @@ -84,7 +85,7 @@ export const MessageAuthorHeader = ({ const user = message.author const { statusEmoji, customMessage, statusExpiresAt, quietHours } = useUserStatus(message.authorId) - const isEdited = message.updatedAt && message.updatedAt.getTime() > message.createdAt.getTime() + const isEdited = message.updatedAt && toEpochMs(message.updatedAt) > toEpochMs(message.createdAt) if (!user) return null @@ -101,7 +102,7 @@ export const MessageAuthorHeader = ({ /> {user.userType === "machine" && APP} - {format(message.createdAt, "HH:mm")} + {format(toDate(message.createdAt), "HH:mm")} {isEdited && " (edited)"} {isPinned && ( diff --git a/apps/web/src/components/chat/message-list.tsx b/apps/web/src/components/chat/message-list.tsx index e5a61ead1..cac03b325 100644 --- a/apps/web/src/components/chat/message-list.tsx +++ b/apps/web/src/components/chat/message-list.tsx @@ -1,5 +1,5 @@ import { LegendList, type LegendListRef, type ViewToken } from "@legendapp/list" -import { useAtomValue } from "@effect-atom/atom-react" +import { useAtomValue } from "@effect/atom-react" import type { ChannelId } from "@hazel/schema" import { useLiveInfiniteQuery } from "@tanstack/react-db" import { memo, useCallback, useImperativeHandle, useMemo, useRef, useState } from "react" @@ -8,6 +8,7 @@ import { editingMessageAtomFamily } from "~/atoms/chat-atoms" import type { MessageWithPinned, ProcessedMessage } from "~/atoms/chat-query-atoms" import { useVisibleMessageNotificationCleaner } from "~/hooks/use-visible-message-notification-cleaner" import { useMessageToolbarOverlay } from "~/hooks/use-message-toolbar-overlay" +import { toEpochMs, toDate } from "~/lib/utils" import { MessageHoverProvider, useMessageHover } from "~/providers/message-hover-provider" import { Route } from "~/routes/_app/$orgSlug/chat/$id" @@ -193,13 +194,13 @@ function MessageListContent({ const isGroupStart = !prevMessage || message.authorId !== prevMessage.authorId || - message.createdAt.getTime() - prevMessage.createdAt.getTime() > timeThreshold || + toEpochMs(message.createdAt) - toEpochMs(prevMessage.createdAt) > timeThreshold || !!prevMessage.replyToMessageId const isGroupEnd = !nextMessage || message.authorId !== nextMessage.authorId || - nextMessage.createdAt.getTime() - message.createdAt.getTime() > timeThreshold + toEpochMs(nextMessage.createdAt) - toEpochMs(message.createdAt) > timeThreshold const isFirstNewMessage = false const isPinned = !!message.pinnedMessage?.id @@ -222,7 +223,7 @@ function MessageListContent({ let lastDate = "" for (const processedMessage of processedMessages) { - const date = new Date(processedMessage.message.createdAt).toDateString() + const date = toDate(processedMessage.message.createdAt).toDateString() if (date !== lastDate) { rows.push({ id: `header-${date}`, type: "header", date }) sticky.push(idx) diff --git a/apps/web/src/components/chat/message-reply-section.tsx b/apps/web/src/components/chat/message-reply-section.tsx index 01673aac6..a79fb043d 100644 --- a/apps/web/src/components/chat/message-reply-section.tsx +++ b/apps/web/src/components/chat/message-reply-section.tsx @@ -1,4 +1,5 @@ -import { Result, useAtomValue } from "@effect-atom/atom-react" +import { AsyncResult } from "effect/unstable/reactivity" +import { useAtomValue } from "@effect/atom-react" import type { Message, User } from "@hazel/domain/models" import type { MessageId } from "@hazel/schema" import { messageWithAuthorAtomFamily } from "~/atoms/message-atoms" @@ -10,8 +11,8 @@ interface MessageReplySectionProps { onClick?: () => void } -type MessageWithAuthor = typeof Message.Model.Type & { - author: typeof User.Model.Type +type MessageWithAuthor = Message.Type & { + author: User.Type } export function MessageReplySection({ replyToMessageId, onClick }: MessageReplySectionProps) { @@ -44,7 +45,7 @@ export function MessageReplySection({ replyToMessageId, onClick }: MessageReplyS className="flex w-fit items-center gap-1 pl-12 text-left hover:bg-transparent" onClick={onClick} > - {Result.builder(messageResult) + {AsyncResult.builder(messageResult) .onInitial(() => ( <>
diff --git a/apps/web/src/components/chat/message.tsx b/apps/web/src/components/chat/message.tsx index 0ddd89a21..3516fa33f 100644 --- a/apps/web/src/components/chat/message.tsx +++ b/apps/web/src/components/chat/message.tsx @@ -1,4 +1,5 @@ -import { Result, useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { AsyncResult } from "effect/unstable/reactivity" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import type { MessageId, OrganizationId } from "@hazel/schema" import { format } from "date-fns" import { createContext, memo, useCallback, useMemo, useRef, type ReactNode, type RefObject } from "react" @@ -14,7 +15,7 @@ import { Badge } from "~/components/ui/badge" import { useChatStable } from "~/hooks/use-chat" import { useUserStatus } from "~/hooks/use-presence" import { useAuth } from "~/lib/auth" -import { cn } from "~/lib/utils" +import { cn, toDate, toEpochMs } from "~/lib/utils" import { useChatAuthorIdentity, type ChatAuthorIdentity } from "./author-identity" import { InlineThreadPreview } from "./inline-thread-preview" import { MessageAttachments } from "./message-attachments" @@ -247,7 +248,7 @@ function MessageAvatar() { return (
- {format(message.createdAt, "HH:mm")} + {format(toDate(message.createdAt), "HH:mm")}
) } @@ -260,9 +261,9 @@ const MessageHeader = memo(function MessageHeader() { const user = message.author const { statusEmoji, customMessage, statusExpiresAt, quietHours } = useUserStatus(message.authorId) const isDiscordSyncedResult = useAtomValue(isDiscordSyncedMessageAtomFamily(message.id)) - const isDiscordSynced = Result.getOrElse(isDiscordSyncedResult, () => []).length > 0 + const isDiscordSynced = AsyncResult.getOrElse(isDiscordSyncedResult, () => []).length > 0 - const isEdited = message.updatedAt && message.updatedAt.getTime() > message.createdAt.getTime() + const isEdited = message.updatedAt && toEpochMs(message.updatedAt) > toEpochMs(message.createdAt) if (!showAvatar || !user) return null @@ -288,7 +289,7 @@ const MessageHeader = memo(function MessageHeader() { Synced from Discord ) : null} - {format(message.createdAt, "HH:mm")} + {format(toDate(message.createdAt), "HH:mm")} {isEdited && " (edited)"} {isPinned && ( diff --git a/apps/web/src/components/chat/pinned-messages-modal.tsx b/apps/web/src/components/chat/pinned-messages-modal.tsx index 220d1a037..6c22f2d0c 100644 --- a/apps/web/src/components/chat/pinned-messages-modal.tsx +++ b/apps/web/src/components/chat/pinned-messages-modal.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "@tanstack/react-router" import { eq, useLiveQuery } from "@tanstack/react-db" import { format } from "date-fns" import { useCallback, useMemo } from "react" +import { toDate, toEpochMs } from "~/lib/utils" import { messageCollection, pinnedMessageCollection, userCollection } from "~/db/collections" import { useChatStable } from "~/hooks/use-chat" import IconClose from "../icons/icon-close" @@ -44,7 +45,7 @@ export function PinnedMessagesModal() { const sortedPins = useMemo( () => [...(pinnedMessages || [])].sort( - (a, b) => a.pinned.pinnedAt.getTime() - b.pinned.pinnedAt.getTime(), + (a, b) => toEpochMs(a.pinned.pinnedAt) - toEpochMs(b.pinned.pinnedAt), ), [pinnedMessages], ) @@ -103,8 +104,8 @@ export function PinnedMessagesModal() { const user = pinnedMessage.message.author const isEdited = pinnedMessage.message.updatedAt && - pinnedMessage.message.updatedAt.getTime() > - pinnedMessage.message.createdAt.getTime() + toEpochMs(pinnedMessage.message.updatedAt) > + toEpochMs(pinnedMessage.message.createdAt) return (
@@ -160,7 +164,10 @@ export function PinnedMessagesModal() { {/* Pinned Date */}
Pinned{" "} - {format(pinnedMessage.pinned.pinnedAt, "MMM d 'at' h:mm a")} + {format( + toDate(pinnedMessage.pinned.pinnedAt), + "MMM d 'at' h:mm a", + )}
) diff --git a/apps/web/src/components/chat/reaction-button.tsx b/apps/web/src/components/chat/reaction-button.tsx index 885de5d7d..f8a3a8655 100644 --- a/apps/web/src/components/chat/reaction-button.tsx +++ b/apps/web/src/components/chat/reaction-button.tsx @@ -1,6 +1,7 @@ "use client" -import { Result, useAtomValue } from "@effect-atom/atom-react" +import { AsyncResult } from "effect/unstable/reactivity" +import { useAtomValue } from "@effect/atom-react" import type { UserId } from "@hazel/schema" import { memo, useState } from "react" import { userWithPresenceAtomFamily } from "~/atoms/message-atoms" @@ -116,7 +117,7 @@ interface UserNameProps { function UserName({ userId, isCurrentUser }: UserNameProps) { const userPresenceResult = useAtomValue(userWithPresenceAtomFamily(userId)) - const data = Result.getOrElse(userPresenceResult, () => []) + const data = AsyncResult.getOrElse(userPresenceResult, () => []) const result = data[0] const user = result?.user diff --git a/apps/web/src/components/chat/slate-editor/autocomplete/triggers/emoji-trigger.tsx b/apps/web/src/components/chat/slate-editor/autocomplete/triggers/emoji-trigger.tsx index b3bd2a6cc..cee4ad301 100644 --- a/apps/web/src/components/chat/slate-editor/autocomplete/triggers/emoji-trigger.tsx +++ b/apps/web/src/components/chat/slate-editor/autocomplete/triggers/emoji-trigger.tsx @@ -1,6 +1,7 @@ "use client" -import { Result, useAtomValue } from "@effect-atom/atom-react" +import { AsyncResult } from "effect/unstable/reactivity" +import { useAtomValue } from "@effect/atom-react" import type { OrganizationId } from "@hazel/schema" import { useMemo } from "react" import { customEmojisForOrgAtomFamily } from "~/atoms/custom-emoji-atoms" @@ -76,7 +77,7 @@ function EmojiItem({ option }: { option: AutocompleteOption }) { */ function useCustomEmojiOptions(organizationId: OrganizationId | undefined): AutocompleteOption[] { const emojisResult = useAtomValue(customEmojisForOrgAtomFamily(organizationId ?? ("" as OrganizationId))) - const emojis = Result.getOrElse(emojisResult, () => []) + const emojis = AsyncResult.getOrElse(emojisResult, () => []) return useMemo(() => { if (!organizationId || emojis.length === 0) return [] diff --git a/apps/web/src/components/chat/slate-editor/mention-element.tsx b/apps/web/src/components/chat/slate-editor/mention-element.tsx index 97a3cfba6..4bc8f2533 100644 --- a/apps/web/src/components/chat/slate-editor/mention-element.tsx +++ b/apps/web/src/components/chat/slate-editor/mention-element.tsx @@ -1,6 +1,7 @@ "use client" -import { Result, useAtomValue } from "@effect-atom/atom-react" +import { AsyncResult } from "effect/unstable/reactivity" +import { useAtomValue } from "@effect/atom-react" import type { UserId } from "@hazel/schema" import { Button as PrimitiveButton } from "react-aria-components" import type { RenderElementProps } from "slate-react" @@ -43,7 +44,8 @@ export function MentionElement({ attributes, children, element, interactive = fa const userPresenceResult = useAtomValue( userWithPresenceAtomFamily((shouldFetchUser ? userId : "dummy-id") as UserId), ) - const data = shouldFetchUser && userPresenceResult ? Result.getOrElse(userPresenceResult, () => []) : [] + const data = + shouldFetchUser && userPresenceResult ? AsyncResult.getOrElse(userPresenceResult, () => []) : [] const result = data[0] const user = result?.user const presence = result?.presence diff --git a/apps/web/src/components/chat/slate-editor/slate-message-editor.tsx b/apps/web/src/components/chat/slate-editor/slate-message-editor.tsx index 5e5b076c8..19ae4b504 100644 --- a/apps/web/src/components/chat/slate-editor/slate-message-editor.tsx +++ b/apps/web/src/components/chat/slate-editor/slate-message-editor.tsx @@ -1,6 +1,6 @@ "use client" -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { BotId, ChannelId, OrganizationId } from "@hazel/schema" import { Exit, pipe } from "effect" import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react" @@ -466,7 +466,7 @@ export const SlateMessageEditor = forwardRef r._tag === "Fail") + const error = failReason ? (failReason as any).error : null let message = "Command failed" if (error && typeof error === "object" && "_tag" in error) { diff --git a/apps/web/src/components/chat/slate-editor/slate-message-viewer.tsx b/apps/web/src/components/chat/slate-editor/slate-message-viewer.tsx index 521c21a8a..5daebfc33 100644 --- a/apps/web/src/components/chat/slate-editor/slate-message-viewer.tsx +++ b/apps/web/src/components/chat/slate-editor/slate-message-viewer.tsx @@ -1,6 +1,6 @@ "use client" -import { useAtomValue } from "@effect-atom/atom-react" +import { useAtomValue } from "@effect/atom-react" import type { OrganizationId } from "@hazel/schema" import { memo, useCallback, useEffect, useMemo } from "react" import { customEmojiMapAtomFamily } from "~/atoms/custom-emoji-atoms" diff --git a/apps/web/src/components/chat/thread-message-list.tsx b/apps/web/src/components/chat/thread-message-list.tsx index 3ec874266..5859fd8f6 100644 --- a/apps/web/src/components/chat/thread-message-list.tsx +++ b/apps/web/src/components/chat/thread-message-list.tsx @@ -1,9 +1,11 @@ -import { Result, useAtomValue } from "@effect-atom/atom-react" +import { AsyncResult } from "effect/unstable/reactivity" +import { useAtomValue } from "@effect/atom-react" import type { ChannelId } from "@hazel/schema" import { useCallback, useMemo } from "react" import { createPortal } from "react-dom" import type { MessageWithPinned } from "~/atoms/chat-query-atoms" import { useMessageToolbarOverlay } from "~/hooks/use-message-toolbar-overlay" +import { toEpochMs } from "~/lib/utils" import { threadMessagesWithAuthorAtomFamily } from "~/atoms/message-atoms" import { MessageHoverProvider, useMessageHover } from "~/providers/message-hover-provider" import type { MessageGroupPosition } from "./message" @@ -44,7 +46,7 @@ function ThreadMessageListContent({ threadChannelId }: ThreadMessageListProps) { // Query thread messages with author data using the new atom const messagesResult = useAtomValue(threadMessagesWithAuthorAtomFamily({ threadChannelId })) - const messages = Result.getOrElse(messagesResult, () => []) as MessageWithPinned[] + const messages = AsyncResult.getOrElse(messagesResult, () => []) as MessageWithPinned[] // Find the hovered message for the toolbar const hoveredMessage = useMemo( @@ -66,14 +68,14 @@ function ThreadMessageListContent({ threadChannelId }: ThreadMessageListProps) { const isGroupStart = !prevMessage || message.authorId !== prevMessage.authorId || - message.createdAt.getTime() - prevMessage.createdAt.getTime() > timeThreshold || + toEpochMs(message.createdAt) - toEpochMs(prevMessage.createdAt) > timeThreshold || !!prevMessage.replyToMessageId const nextMessage = index < messages.length - 1 ? messages[index + 1] : null const isGroupEnd = !nextMessage || message.authorId !== nextMessage.authorId || - nextMessage.createdAt.getTime() - message.createdAt.getTime() > timeThreshold + toEpochMs(nextMessage.createdAt) - toEpochMs(message.createdAt) > timeThreshold // Determine group position let groupPosition: MessageGroupPosition diff --git a/apps/web/src/components/chat/thread-panel.tsx b/apps/web/src/components/chat/thread-panel.tsx index b99a81c1d..384380688 100644 --- a/apps/web/src/components/chat/thread-panel.tsx +++ b/apps/web/src/components/chat/thread-panel.tsx @@ -1,7 +1,8 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { ChannelId, MessageId, OrganizationId } from "@hazel/schema" import { eq, useLiveQuery } from "@tanstack/react-db" import { format } from "date-fns" +import { toDate } from "~/lib/utils" import { generateThreadNameMutation } from "~/atoms/channel-atoms" import { exitToast } from "~/lib/toast-exit" import { channelCollection } from "~/db/collections" @@ -182,7 +183,7 @@ function ThreadContent({ {authorIdentity.displayName} - {format(originalMessage.createdAt, "MMM d, HH:mm")} + {format(toDate(originalMessage.createdAt), "MMM d, HH:mm")}
diff --git a/apps/web/src/components/chat/user-profile-popover.tsx b/apps/web/src/components/chat/user-profile-popover.tsx index 6b3ab4940..4ff02ba76 100644 --- a/apps/web/src/components/chat/user-profile-popover.tsx +++ b/apps/web/src/components/chat/user-profile-popover.tsx @@ -1,6 +1,7 @@ import { IconChatBubble } from "~/components/icons/icon-chat-bubble" import { IconClock } from "~/components/icons/icon-clock" -import { Result, useAtomValue } from "@effect-atom/atom-react" +import { AsyncResult } from "effect/unstable/reactivity" +import { useAtomValue } from "@effect/atom-react" import type { UserId } from "@hazel/schema" import { useNavigate } from "@tanstack/react-router" import { useEffect, useRef, useState } from "react" @@ -64,7 +65,7 @@ function PopoverBody({ userId }: { userId: UserId }) { const nowMs = useAtomValue(presenceNowSignal) const userPresenceResult = useAtomValue(userWithPresenceAtomFamily(userId)) - const data = Result.getOrElse(userPresenceResult, () => []) + const data = AsyncResult.getOrElse(userPresenceResult, () => []) const result = data[0] const user = result?.user const presence = result?.presence diff --git a/apps/web/src/components/command-palette/pages/create-channel-view.tsx b/apps/web/src/components/command-palette/pages/create-channel-view.tsx index de8cd9f4d..9e58c82f8 100644 --- a/apps/web/src/components/command-palette/pages/create-channel-view.tsx +++ b/apps/web/src/components/command-palette/pages/create-channel-view.tsx @@ -1,6 +1,6 @@ "use client" -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import { useNavigate } from "@tanstack/react-router" import { type } from "arktype" import IconHashtag from "~/components/icons/icon-hashtag" diff --git a/apps/web/src/components/command-palette/pages/home-view.tsx b/apps/web/src/components/command-palette/pages/home-view.tsx index 7fa662382..37a8e1e56 100644 --- a/apps/web/src/components/command-palette/pages/home-view.tsx +++ b/apps/web/src/components/command-palette/pages/home-view.tsx @@ -1,6 +1,6 @@ "use client" -import { useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import { and, eq, inArray, or, useLiveQuery } from "@tanstack/react-db" import { useNavigate } from "@tanstack/react-router" import { useCallback, useMemo } from "react" diff --git a/apps/web/src/components/command-palette/pages/join-channel-view.tsx b/apps/web/src/components/command-palette/pages/join-channel-view.tsx index 8cc5e45c7..670d4084a 100644 --- a/apps/web/src/components/command-palette/pages/join-channel-view.tsx +++ b/apps/web/src/components/command-palette/pages/join-channel-view.tsx @@ -1,6 +1,6 @@ "use client" -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { ChannelId, UserId } from "@hazel/schema" import { eq, inArray, not, or, useLiveQuery } from "@tanstack/react-db" import { useMemo } from "react" diff --git a/apps/web/src/components/command-palette/search-result-item.tsx b/apps/web/src/components/command-palette/search-result-item.tsx index 1b558ae69..e9f928da9 100644 --- a/apps/web/src/components/command-palette/search-result-item.tsx +++ b/apps/web/src/components/command-palette/search-result-item.tsx @@ -4,13 +4,13 @@ import { useEffect, useRef } from "react" import IconHashtag from "~/components/icons/icon-hashtag" import IconPaperclip from "~/components/icons/icon-paperclip2" import { Avatar } from "~/components/ui/avatar" -import { cn } from "~/lib/utils" +import { cn, toDate } from "~/lib/utils" import { MarkdownText } from "./markdown-text" interface SearchResultItemProps { - message: typeof Message.Model.Type - author: typeof User.Model.Type | null - channel: typeof Channel.Model.Type | null + message: Message.Type + author: User.Type | null + channel: Channel.Type | null attachmentCount: number searchQuery?: string isSelected?: boolean @@ -92,7 +92,7 @@ export function SearchResultItem({ )} - {formatRelativeTime(message.createdAt)} + {formatRelativeTime(toDate(message.createdAt))}
diff --git a/apps/web/src/components/command-palette/search-view.tsx b/apps/web/src/components/command-palette/search-view.tsx index 5a1f92fda..369e6cecb 100644 --- a/apps/web/src/components/command-palette/search-view.tsx +++ b/apps/web/src/components/command-palette/search-view.tsx @@ -1,6 +1,6 @@ "use client" -import { useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import type { UserId } from "@hazel/schema" import { useNavigate } from "@tanstack/react-router" import { formatDistanceToNow } from "date-fns" diff --git a/apps/web/src/components/connect/share-channel-modal.test.tsx b/apps/web/src/components/connect/share-channel-modal.test.tsx index a52aa7eb2..a838bd892 100644 --- a/apps/web/src/components/connect/share-channel-modal.test.tsx +++ b/apps/web/src/components/connect/share-channel-modal.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -import type { OrganizationId } from "@hazel/schema" +import type { ChannelId, OrganizationId } from "@hazel/schema" import type { InputHTMLAttributes, ReactNode } from "react" const { workspaceSearchMutation, createConnectInviteMutation, searchWorkspacesMock, createInviteMock } = @@ -13,7 +13,7 @@ const { workspaceSearchMutation, createConnectInviteMutation, searchWorkspacesMo createInviteMock: vi.fn(), })) -vi.mock("@effect-atom/atom-react", () => ({ +vi.mock("@effect/atom-react", () => ({ useAtomSet: (mutation: symbol) => mutation === workspaceSearchMutation ? searchWorkspacesMock : createInviteMock, })) @@ -122,7 +122,9 @@ vi.mock("~/components/icons/icon-close", () => ({ import { ShareChannelModal } from "./share-channel-modal" -const ORG_ID = "00000000-0000-0000-0000-000000000101" as OrganizationId +const ORG_ID = "00000000-0000-4000-8000-000000000101" as OrganizationId +const GUEST_ORG_ID = "00000000-0000-4000-8000-000000000102" as OrganizationId +const CHANNEL_ID = "00000000-0000-4000-8000-000000000201" as ChannelId describe("ShareChannelModal", () => { beforeEach(() => { @@ -142,7 +144,7 @@ describe("ShareChannelModal", () => { logoUrl: null, }, { - id: "00000000-0000-0000-0000-000000000102" as OrganizationId, + id: GUEST_ORG_ID, name: "Guest Workspace", slug: "guest-workspace", logoUrl: null, @@ -156,7 +158,7 @@ describe("ShareChannelModal", () => { undefined} - channelId={"00000000-0000-0000-0000-000000000201" as any} + channelId={CHANNEL_ID} channelName="general" organizationId={ORG_ID} />, diff --git a/apps/web/src/components/connect/share-channel-modal.tsx b/apps/web/src/components/connect/share-channel-modal.tsx index 67fb9ba4f..62500f2b4 100644 --- a/apps/web/src/components/connect/share-channel-modal.tsx +++ b/apps/web/src/components/connect/share-channel-modal.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { ChannelId, OrganizationId } from "@hazel/schema" import { useCallback, useEffect, useRef, useState } from "react" import { createConnectInviteMutation, workspaceSearchMutation } from "~/atoms/connect-share-atoms" diff --git a/apps/web/src/components/embeds/use-embed-theme.ts b/apps/web/src/components/embeds/use-embed-theme.ts index d285bea5f..2b13dfbd8 100644 --- a/apps/web/src/components/embeds/use-embed-theme.ts +++ b/apps/web/src/components/embeds/use-embed-theme.ts @@ -1,4 +1,4 @@ -import { useAtomValue } from "@effect-atom/atom-react" +import { useAtomValue } from "@effect/atom-react" import { getBrandfetchIcon } from "~/lib/integrations/__data" import { resolvedThemeAtom } from "../theme-provider" diff --git a/apps/web/src/components/emoji-picker/custom-emoji-section.tsx b/apps/web/src/components/emoji-picker/custom-emoji-section.tsx index 3b4d0e311..aacbd371a 100644 --- a/apps/web/src/components/emoji-picker/custom-emoji-section.tsx +++ b/apps/web/src/components/emoji-picker/custom-emoji-section.tsx @@ -1,6 +1,7 @@ "use client" -import { Result, useAtomValue } from "@effect-atom/atom-react" +import { AsyncResult } from "effect/unstable/reactivity" +import { useAtomValue } from "@effect/atom-react" import type { OrganizationId } from "@hazel/schema" import { memo } from "react" import { customEmojisForOrgAtomFamily } from "~/atoms/custom-emoji-atoms" @@ -19,7 +20,7 @@ export const CustomEmojiSection = memo(function CustomEmojiSection({ onEmojiSelect, }: CustomEmojiSectionProps) { const emojisResult = useAtomValue(customEmojisForOrgAtomFamily(organizationId)) - const emojis = Result.getOrElse(emojisResult, () => []) + const emojis = AsyncResult.getOrElse(emojisResult, () => []) if (emojis.length === 0) return null diff --git a/apps/web/src/components/gif-embed.tsx b/apps/web/src/components/gif-embed.tsx index bd6a9ec80..0c632af23 100644 --- a/apps/web/src/components/gif-embed.tsx +++ b/apps/web/src/components/gif-embed.tsx @@ -5,7 +5,7 @@ import { extractGiphyMediaUrl, isKlipyUrl } from "~/components/link-preview" interface GifEmbedProps { url: string - author?: typeof User.Model.Type + author?: User.Type createdAt?: number } diff --git a/apps/web/src/components/gif-picker/use-klipy.ts b/apps/web/src/components/gif-picker/use-klipy.ts index d203744cf..902072764 100644 --- a/apps/web/src/components/gif-picker/use-klipy.ts +++ b/apps/web/src/components/gif-picker/use-klipy.ts @@ -1,4 +1,5 @@ -import { Result, useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { AsyncResult } from "effect/unstable/reactivity" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import type { KlipyCategory, KlipyGif, KlipySearchResponse } from "@hazel/domain/http" import { Exit } from "effect" import { useCallback, useMemo, useRef, useState } from "react" @@ -22,7 +23,7 @@ interface UseKlipyReturn { export function useKlipy({ perPage = 25 }: UseKlipyOptions = {}): UseKlipyReturn { // === Auto-fetching query atoms (no useEffect needed) === const trendingResult = useAtomValue( - HazelApiClient.query("klipy", "trending", { urlParams: { page: 1, per_page: perPage } }), + HazelApiClient.query("klipy", "trending", { query: { page: 1, per_page: perPage } }), ) const categoriesResult = useAtomValue(HazelApiClient.query("klipy", "categories", {})) @@ -53,7 +54,7 @@ export function useKlipy({ perPage = 25 }: UseKlipyOptions = {}): UseKlipyReturn // Initialize pagination from trending query result const trendingInitRef = useRef(false) - if (!trendingInitRef.current && Result.isSuccess(trendingResult) && overrideGifs === null) { + if (!trendingInitRef.current && AsyncResult.isSuccess(trendingResult) && overrideGifs === null) { trendingInitRef.current = true pageRef.current = trendingResult.value.current_page const more = trendingResult.value.has_next @@ -62,7 +63,7 @@ export function useKlipy({ perPage = 25 }: UseKlipyOptions = {}): UseKlipyReturn } // === Derived display values === - const categories = Result.isSuccess(categoriesResult) ? [...categoriesResult.value.categories] : [] + const categories = AsyncResult.isSuccess(categoriesResult) ? [...categoriesResult.value.categories] : [] let gifs: KlipyGif[] let isLoading: boolean @@ -70,7 +71,7 @@ export function useKlipy({ perPage = 25 }: UseKlipyOptions = {}): UseKlipyReturn if (overrideGifs !== null) { gifs = overrideGifs isLoading = isMutating - } else if (Result.isSuccess(trendingResult)) { + } else if (AsyncResult.isSuccess(trendingResult)) { gifs = [...trendingResult.value.data] isLoading = false } else { @@ -92,9 +93,9 @@ export function useKlipy({ perPage = 25 }: UseKlipyOptions = {}): UseKlipyReturn try { let exit: Exit.Exit if (query) { - exit = await searchRef.current({ urlParams: { q: query, page, per_page: perPage } }) + exit = await searchRef.current({ query: { q: query, page, per_page: perPage } }) } else { - exit = await trendingMutRef.current({ urlParams: { page, per_page: perPage } }) + exit = await trendingMutRef.current({ query: { page, per_page: perPage } }) } if (requestIdRef.current !== id) return diff --git a/apps/web/src/components/integrations/add-github-subscription-modal.tsx b/apps/web/src/components/integrations/add-github-subscription-modal.tsx index 37bbacaf3..cd295838f 100644 --- a/apps/web/src/components/integrations/add-github-subscription-modal.tsx +++ b/apps/web/src/components/integrations/add-github-subscription-modal.tsx @@ -1,4 +1,5 @@ -import { Result, useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { AsyncResult } from "effect/unstable/reactivity" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import type { Channel, GitHubSubscription } from "@hazel/domain/models" import type { ChannelId, OrganizationId } from "@hazel/schema" import { eq, or, useLiveQuery } from "@tanstack/react-db" @@ -22,7 +23,7 @@ import { HazelApiClient } from "~/lib/services/common/atom-client" import { exitToast } from "~/lib/toast-exit" type GitHubEventType = typeof GitHubSubscription.GitHubEventType.Type -type ChannelData = typeof Channel.Model.Type +type ChannelData = Channel.Type const EVENT_OPTIONS: { id: GitHubEventType; label: string; description: string }[] = [ { id: "push", label: "Push", description: "Commits pushed to branches" }, @@ -75,8 +76,8 @@ export function AddGitHubSubscriptionModal({ // Fetch repositories const repositoriesResult = useAtomValue( HazelApiClient.query("integration-resources", "getGitHubRepositories", { - path: { orgId: organizationId }, - urlParams: { page: 1, perPage: 100 }, + params: { orgId: organizationId }, + query: { page: 1, perPage: 100 }, }), ) @@ -140,7 +141,7 @@ export function AddGitHubSubscriptionModal({ } // Get repositories from result - const repositories = Result.builder(repositoriesResult) + const repositories = AsyncResult.builder(repositoriesResult) .onSuccess((data) => data?.repositories ?? []) .orElse(() => []) @@ -225,7 +226,7 @@ export function AddGitHubSubscriptionModal({ {selectedChannel && (
- {Result.builder(repositoriesResult) + {AsyncResult.builder(repositoriesResult) .onInitial(() => (
diff --git a/apps/web/src/components/integrations/add-rss-subscription-modal.tsx b/apps/web/src/components/integrations/add-rss-subscription-modal.tsx index 676ef05d7..8ec2e2678 100644 --- a/apps/web/src/components/integrations/add-rss-subscription-modal.tsx +++ b/apps/web/src/components/integrations/add-rss-subscription-modal.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { Channel } from "@hazel/domain/models" import type { ChannelId, OrganizationId } from "@hazel/schema" import { eq, or, useLiveQuery } from "@tanstack/react-db" @@ -19,7 +19,7 @@ import { import { channelCollection } from "~/db/collections" import { exitToast } from "~/lib/toast-exit" -type ChannelData = typeof Channel.Model.Type +type ChannelData = Channel.Type const POLLING_INTERVAL_OPTIONS = [ { value: 5, label: "Every 5 minutes" }, diff --git a/apps/web/src/components/integrations/configure-openstatus-modal.tsx b/apps/web/src/components/integrations/configure-openstatus-modal.tsx index 09f70ea0c..39a7520eb 100644 --- a/apps/web/src/components/integrations/configure-openstatus-modal.tsx +++ b/apps/web/src/components/integrations/configure-openstatus-modal.tsx @@ -10,7 +10,7 @@ import { Description } from "~/components/ui/field" import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ModalTitle } from "~/components/ui/modal" import { Select, SelectContent, SelectItem, SelectTrigger } from "~/components/ui/select" -type ChannelData = typeof Channel.Model.Type +type ChannelData = Channel.Type interface ConfigureOpenStatusModalProps { isOpen: boolean diff --git a/apps/web/src/components/integrations/configure-railway-modal.tsx b/apps/web/src/components/integrations/configure-railway-modal.tsx index 42db78c31..1c6da69f4 100644 --- a/apps/web/src/components/integrations/configure-railway-modal.tsx +++ b/apps/web/src/components/integrations/configure-railway-modal.tsx @@ -10,7 +10,7 @@ import { Description } from "~/components/ui/field" import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ModalTitle } from "~/components/ui/modal" import { Select, SelectContent, SelectItem, SelectTrigger } from "~/components/ui/select" -type ChannelData = typeof Channel.Model.Type +type ChannelData = Channel.Type interface ConfigureRailwayModalProps { isOpen: boolean diff --git a/apps/web/src/components/integrations/edit-github-subscription-modal.tsx b/apps/web/src/components/integrations/edit-github-subscription-modal.tsx index a56b33f9f..0b5093a96 100644 --- a/apps/web/src/components/integrations/edit-github-subscription-modal.tsx +++ b/apps/web/src/components/integrations/edit-github-subscription-modal.tsx @@ -1,4 +1,4 @@ -import { useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import type { GitHubSubscription } from "@hazel/domain/models" import type { GitHubSubscriptionId } from "@hazel/schema" import { useState } from "react" diff --git a/apps/web/src/components/integrations/github-pr-embed.tsx b/apps/web/src/components/integrations/github-pr-embed.tsx index 0c338c971..901503fc6 100644 --- a/apps/web/src/components/integrations/github-pr-embed.tsx +++ b/apps/web/src/components/integrations/github-pr-embed.tsx @@ -1,6 +1,7 @@ "use client" -import { Result, useAtomValue } from "@effect-atom/atom-react" +import { AsyncResult } from "effect/unstable/reactivity" +import { useAtomValue } from "@effect/atom-react" import type { OrganizationId } from "@hazel/schema" import { HazelApiClient } from "~/lib/services/common/atom-client" import { cn } from "~/lib/utils" @@ -220,13 +221,13 @@ export function GitHubPREmbed({ url, orgId }: GitHubPREmbedProps) { const resourceResult = useAtomValue( HazelApiClient.query("integration-resources", "fetchGitHubPR", { - path: { orgId }, - urlParams: { url }, + params: { orgId }, + query: { url }, timeToLive: "3 minutes", }), ) - return Result.builder(resourceResult) + return AsyncResult.builder(resourceResult) .onInitial(() => ) .onErrorTag("IntegrationNotConnectedForPreviewError", () => ( ) .onErrorTag("IntegrationNotConnectedForPreviewError", () => (

{webhook.lastUsedAt - ? `Last alert ${formatDistanceToNow(new Date(webhook.lastUsedAt), { addSuffix: true })}` + ? `Last alert ${formatDistanceToNow(toDate(webhook.lastUsedAt), { addSuffix: true })}` : "No alerts received yet"}

diff --git a/apps/web/src/components/integrations/railway-integration-content.tsx b/apps/web/src/components/integrations/railway-integration-content.tsx index 13dfce3e8..b44e92afa 100644 --- a/apps/web/src/components/integrations/railway-integration-content.tsx +++ b/apps/web/src/components/integrations/railway-integration-content.tsx @@ -1,8 +1,9 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { Channel } from "@hazel/domain/models" import type { ChannelId, OrganizationId } from "@hazel/schema" import { eq, or, useLiveQuery } from "@tanstack/react-db" import { formatDistanceToNow } from "date-fns" +import { toDate } from "~/lib/utils" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { toast } from "sonner" import { listOrganizationWebhooksMutation, type WebhookData } from "~/atoms/channel-webhook-atoms" @@ -17,7 +18,7 @@ import { channelCollection } from "~/db/collections" import { exitToast } from "~/lib/toast-exit" import { ConfigureRailwayModal } from "./configure-railway-modal" -type ChannelData = typeof Channel.Model.Type +type ChannelData = Channel.Type interface RailwayIntegrationContentProps { organizationId: OrganizationId @@ -285,7 +286,7 @@ export function RailwayIntegrationContent({ organizationId }: RailwayIntegration

{webhook.lastUsedAt - ? `Last alert ${formatDistanceToNow(new Date(webhook.lastUsedAt), { addSuffix: true })}` + ? `Last alert ${formatDistanceToNow(toDate(webhook.lastUsedAt), { addSuffix: true })}` : "No alerts received yet"}

diff --git a/apps/web/src/components/integrations/rss-subscriptions-section.tsx b/apps/web/src/components/integrations/rss-subscriptions-section.tsx index 38d23d816..418c825ed 100644 --- a/apps/web/src/components/integrations/rss-subscriptions-section.tsx +++ b/apps/web/src/components/integrations/rss-subscriptions-section.tsx @@ -1,8 +1,9 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { Channel } from "@hazel/domain/models" import type { OrganizationId, RssSubscriptionId } from "@hazel/schema" import { eq, or, useLiveQuery } from "@tanstack/react-db" import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { toDate } from "~/lib/utils" import { deleteRssSubscriptionMutation, listOrganizationRssSubscriptionsMutation, @@ -22,7 +23,7 @@ import { channelCollection } from "~/db/collections" import { exitToast } from "~/lib/toast-exit" import { AddRssSubscriptionModal } from "./add-rss-subscription-modal" -type ChannelData = typeof Channel.Model.Type +type ChannelData = Channel.Type interface RssSubscriptionsSectionProps { organizationId: OrganizationId @@ -367,7 +368,7 @@ function SubscriptionRow({ subscription, channelName, onToggle, onDelete }: Subs <> · - Last fetched {new Date(subscription.lastFetchedAt).toLocaleDateString()} + Last fetched {toDate(subscription.lastFetchedAt).toLocaleDateString()} )} diff --git a/apps/web/src/components/link-preview.tsx b/apps/web/src/components/link-preview.tsx index 45c7ec402..a40d20cd8 100644 --- a/apps/web/src/components/link-preview.tsx +++ b/apps/web/src/components/link-preview.tsx @@ -1,13 +1,14 @@ "use client" -import { Result, useAtomValue } from "@effect-atom/atom-react" +import { AsyncResult } from "effect/unstable/reactivity" +import { useAtomValue } from "@effect/atom-react" import { useMemo } from "react" import { LinkPreviewClient } from "~/lib/services/common/link-preview-client" export function LinkPreview({ url }: { url: string }) { - const previewResult = useAtomValue(LinkPreviewClient.query("linkPreview", "get", { urlParams: { url } })) - const og = Result.getOrElse(previewResult, () => null) - const isLoading = Result.isInitial(previewResult) + const previewResult = useAtomValue(LinkPreviewClient.query("linkPreview", "get", { payload: { url } })) + const og = AsyncResult.getOrElse(previewResult, () => null) + const isLoading = AsyncResult.isInitial(previewResult) const host = useMemo(() => { const resolvedUrl = og?.url || url diff --git a/apps/web/src/components/modals/create-bot-modal.tsx b/apps/web/src/components/modals/create-bot-modal.tsx index d16609211..9caf426b4 100644 --- a/apps/web/src/components/modals/create-bot-modal.tsx +++ b/apps/web/src/components/modals/create-bot-modal.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { ApiScope } from "@hazel/domain/scopes" import { type } from "arktype" import { useState } from "react" diff --git a/apps/web/src/components/modals/create-channel-modal.tsx b/apps/web/src/components/modals/create-channel-modal.tsx index eb998049a..1871049e3 100644 --- a/apps/web/src/components/modals/create-channel-modal.tsx +++ b/apps/web/src/components/modals/create-channel-modal.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import { useNavigate } from "@tanstack/react-router" import { type } from "arktype" import { useState } from "react" diff --git a/apps/web/src/components/modals/create-dm-modal.tsx b/apps/web/src/components/modals/create-dm-modal.tsx index c41d392f7..fec284bd7 100644 --- a/apps/web/src/components/modals/create-dm-modal.tsx +++ b/apps/web/src/components/modals/create-dm-modal.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { User } from "@hazel/domain/models" import type { UserId } from "@hazel/schema" import { eq, useLiveQuery } from "@tanstack/react-db" @@ -165,7 +165,7 @@ export function CreateDmModal({ isOpen, onOpenChange }: CreateDmModalProps) { // Stable callback for toggling user selection - only updates form state const toggleUserSelection = useCallback( - (targetUser: typeof User.Model.Type) => { + (targetUser: User.Type) => { const currentIds = form.state.values.userIds const isSelected = currentIds.includes(targetUser.id) const newIds = isSelected diff --git a/apps/web/src/components/modals/create-organization-modal.tsx b/apps/web/src/components/modals/create-organization-modal.tsx index b92fd2218..35de99aa5 100644 --- a/apps/web/src/components/modals/create-organization-modal.tsx +++ b/apps/web/src/components/modals/create-organization-modal.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import { type } from "arktype" import { useCallback } from "react" import { createOrganizationMutation } from "~/atoms/organization-atoms" @@ -65,9 +65,9 @@ export function CreateOrganizationModal({ isOpen, onOpenChange }: CreateOrganiza login({ organizationId: result.data.id, returnTo: returnUrl }) }) .successMessage("Server created successfully") - .onErrorTag("OrganizationSlugAlreadyExistsError", (error) => ({ + .onErrorTag("OrganizationSlugAlreadyExistsError", () => ({ title: "Slug already taken", - description: `The slug "${error.slug}" is already in use. Please choose a different one.`, + description: "That workspace URL is already in use. Please choose a different one.", isRetryable: false, })) .run() diff --git a/apps/web/src/components/modals/create-section-modal.tsx b/apps/web/src/components/modals/create-section-modal.tsx index f709cb44a..9895b0adf 100644 --- a/apps/web/src/components/modals/create-section-modal.tsx +++ b/apps/web/src/components/modals/create-section-modal.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import { type } from "arktype" import { Button } from "~/components/ui/button" import { FieldError, Label } from "~/components/ui/field" diff --git a/apps/web/src/components/modals/delete-channel-modal.tsx b/apps/web/src/components/modals/delete-channel-modal.tsx index 995670c36..b9272494d 100644 --- a/apps/web/src/components/modals/delete-channel-modal.tsx +++ b/apps/web/src/components/modals/delete-channel-modal.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { ChannelId } from "@hazel/schema" import { useMatchRoute, useNavigate, useParams } from "@tanstack/react-router" import { Button } from "~/components/ui/button" diff --git a/apps/web/src/components/modals/delete-workspace-modal.tsx b/apps/web/src/components/modals/delete-workspace-modal.tsx index e1713254c..cee8477bd 100644 --- a/apps/web/src/components/modals/delete-workspace-modal.tsx +++ b/apps/web/src/components/modals/delete-workspace-modal.tsx @@ -1,4 +1,4 @@ -import { useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import type { OrganizationId } from "@hazel/schema" import { useState } from "react" import { deleteOrganizationMutation } from "~/atoms/organization-atoms" diff --git a/apps/web/src/components/modals/edit-bot-modal.tsx b/apps/web/src/components/modals/edit-bot-modal.tsx index 939eaf1e6..ac023d597 100644 --- a/apps/web/src/components/modals/edit-bot-modal.tsx +++ b/apps/web/src/components/modals/edit-bot-modal.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { ApiScope } from "@hazel/domain/scopes" import { type } from "arktype" import { useEffect } from "react" diff --git a/apps/web/src/components/modals/email-invite-modal.tsx b/apps/web/src/components/modals/email-invite-modal.tsx index f31eb73ca..a0b38a467 100644 --- a/apps/web/src/components/modals/email-invite-modal.tsx +++ b/apps/web/src/components/modals/email-invite-modal.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { OrganizationId } from "@hazel/schema" import { createInvitationMutation } from "~/atoms/invitation-atoms" import IconClose from "~/components/icons/icon-close" diff --git a/apps/web/src/components/modals/join-channel-modal.tsx b/apps/web/src/components/modals/join-channel-modal.tsx index 84032dd67..88ab91fb2 100644 --- a/apps/web/src/components/modals/join-channel-modal.tsx +++ b/apps/web/src/components/modals/join-channel-modal.tsx @@ -1,6 +1,6 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { ChannelId } from "@hazel/schema" -import { UserId } from "@hazel/schema" +import type { UserId } from "@hazel/schema" import { eq, inArray, not, or, useLiveQuery } from "@tanstack/react-db" import { useState } from "react" import { toast } from "sonner" @@ -69,7 +69,7 @@ export function JoinChannelModal({ isOpen, onOpenChange }: JoinChannelModalProps const exit = await joinChannel({ channelId, - userId: UserId.make(user.id), + userId: user.id as UserId, }) exitToast(exit) diff --git a/apps/web/src/components/modals/rename-channel-modal.tsx b/apps/web/src/components/modals/rename-channel-modal.tsx index 482ffbf54..2bfadf4d5 100644 --- a/apps/web/src/components/modals/rename-channel-modal.tsx +++ b/apps/web/src/components/modals/rename-channel-modal.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { ChannelId } from "@hazel/schema" import { eq, useLiveQuery } from "@tanstack/react-db" import { type } from "arktype" diff --git a/apps/web/src/components/modals/rename-thread-modal.tsx b/apps/web/src/components/modals/rename-thread-modal.tsx index 8849bc5a9..46faedeb2 100644 --- a/apps/web/src/components/modals/rename-thread-modal.tsx +++ b/apps/web/src/components/modals/rename-thread-modal.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { ChannelId } from "@hazel/schema" import { eq, useLiveQuery } from "@tanstack/react-db" import { type } from "arktype" diff --git a/apps/web/src/components/modals/request-integration-modal.tsx b/apps/web/src/components/modals/request-integration-modal.tsx index e5c224e6c..e34a4f1a2 100644 --- a/apps/web/src/components/modals/request-integration-modal.tsx +++ b/apps/web/src/components/modals/request-integration-modal.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import { type } from "arktype" import { Exit } from "effect" import { useState } from "react" diff --git a/apps/web/src/components/notifications/notification-item.tsx b/apps/web/src/components/notifications/notification-item.tsx index 4fc4f0feb..e461c2ccd 100644 --- a/apps/web/src/components/notifications/notification-item.tsx +++ b/apps/web/src/components/notifications/notification-item.tsx @@ -1,6 +1,7 @@ import type { NotificationId } from "@hazel/schema" import { Link, useParams } from "@tanstack/react-router" import { formatDistanceToNow } from "date-fns" +import { toDate } from "~/lib/utils" import IconHashtag from "~/components/icons/icon-hashtag" import IconMsgs from "~/components/icons/icon-msgs" import { Avatar } from "~/components/ui/avatar" @@ -118,7 +119,7 @@ export function NotificationItem({ notification, onMarkAsRead }: NotificationIte {getMessagePreview(notification)}

- {formatDistanceToNow(new Date(n.createdAt), { addSuffix: true })} + {formatDistanceToNow(toDate(n.createdAt), { addSuffix: true })}

{getNotificationContext(notification)}

diff --git a/apps/web/src/components/onboarding/invite-team-step.tsx b/apps/web/src/components/onboarding/invite-team-step.tsx index 2ddebe26e..e4828508c 100644 --- a/apps/web/src/components/onboarding/invite-team-step.tsx +++ b/apps/web/src/components/onboarding/invite-team-step.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { OrganizationId } from "@hazel/schema" import IconClose from "~/components/icons/icon-close" import IconPlus from "~/components/icons/icon-plus" diff --git a/apps/web/src/components/onboarding/org-setup-step.tsx b/apps/web/src/components/onboarding/org-setup-step.tsx index deb58ae63..819692474 100644 --- a/apps/web/src/components/onboarding/org-setup-step.tsx +++ b/apps/web/src/components/onboarding/org-setup-step.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import { type } from "arktype" import { Exit } from "effect" import { createOrganizationMutation } from "~/atoms/organization-atoms" @@ -75,9 +75,9 @@ export function OrgSetupStep({ }, }) exitToast(exit) - .onErrorTag("OrganizationSlugAlreadyExistsError", (error) => ({ + .onErrorTag("OrganizationSlugAlreadyExistsError", () => ({ title: "Slug already taken", - description: `The slug "${error.slug}" is already in use. Please choose a different one.`, + description: "That workspace URL is already in use. Please choose a different one.", isRetryable: false, })) .run() diff --git a/apps/web/src/components/onboarding/profile-info-step.tsx b/apps/web/src/components/onboarding/profile-info-step.tsx index 8844e5f92..7e6a3c64a 100644 --- a/apps/web/src/components/onboarding/profile-info-step.tsx +++ b/apps/web/src/components/onboarding/profile-info-step.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import { type } from "arktype" import { Exit } from "effect" import { updateUserMutation } from "~/atoms/user-atoms" diff --git a/apps/web/src/components/onboarding/timezone-selection-step.tsx b/apps/web/src/components/onboarding/timezone-selection-step.tsx index 9771ca414..cd19c8d92 100644 --- a/apps/web/src/components/onboarding/timezone-selection-step.tsx +++ b/apps/web/src/components/onboarding/timezone-selection-step.tsx @@ -1,6 +1,6 @@ import IconMagnifier3 from "~/components/icons/icon-magnifier-3" import { IconMapPin } from "~/components/icons/icon-map-pin" -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import { useEffect, useMemo, useState } from "react" import { Exit } from "effect" import { updateUserMutation } from "~/atoms/user-atoms" diff --git a/apps/web/src/components/profile/profile-picture-upload.tsx b/apps/web/src/components/profile/profile-picture-upload.tsx index 4a04b09c6..42f74842f 100644 --- a/apps/web/src/components/profile/profile-picture-upload.tsx +++ b/apps/web/src/components/profile/profile-picture-upload.tsx @@ -1,4 +1,4 @@ -import { useAtomRefresh, useAtomSet } from "@effect-atom/atom-react" +import { useAtomRefresh, useAtomSet } from "@effect/atom-react" import { Exit } from "effect" import { useState } from "react" import { Button, type DropItem, DropZone, FileTrigger } from "react-aria-components" diff --git a/apps/web/src/components/sidebar/channel-item.tsx b/apps/web/src/components/sidebar/channel-item.tsx index 61ed10746..f78dd15af 100644 --- a/apps/web/src/components/sidebar/channel-item.tsx +++ b/apps/web/src/components/sidebar/channel-item.tsx @@ -1,6 +1,6 @@ -import { useAtomSet } from "@effect-atom/atom-react" -import type { Channel, ChannelMember } from "@hazel/db/schema" -import type { ChannelSectionId } from "@hazel/schema" +import { useAtomSet } from "@effect/atom-react" +import type { ChannelId, ChannelMemberId, ChannelSectionId, OrganizationId, UserId } from "@hazel/schema" +import type { DateTime } from "effect" import { useNavigate } from "@tanstack/react-router" import { memo } from "react" import { useModal } from "~/atoms/modal-atoms" @@ -25,13 +25,38 @@ import { usePermission } from "~/hooks/use-permission" import { useScrollIntoViewOnActive } from "~/hooks/use-scroll-into-view-on-active" import { exitToastAsync } from "~/lib/toast-exit" +type DateLike = Date | DateTime.Utc + +export interface SidebarChannelData { + readonly id: ChannelId + readonly name: string + readonly type: "private" | "public" | "thread" | "direct" | "single" + readonly icon: string | null + readonly organizationId: OrganizationId + readonly parentChannelId: ChannelId | null + readonly sectionId: ChannelSectionId | null + readonly updatedAt?: DateLike | null + readonly [key: string]: unknown +} + +export interface SidebarChannelMemberData { + readonly id: ChannelMemberId + readonly channelId: ChannelId + readonly userId: UserId + readonly isMuted: boolean + readonly isFavorite: boolean + readonly isHidden: boolean + readonly notificationCount: number + readonly [key: string]: unknown +} + interface ChannelItemProps { - channel: Omit & { updatedAt: Date | null } - member: ChannelMember + channel: SidebarChannelData + member: SidebarChannelMemberData notificationCount?: number threads?: Array<{ - channel: Omit & { updatedAt: Date | null } - member: ChannelMember + channel: SidebarChannelData + member: SidebarChannelMemberData }> /** Available sections for "move to section" menu */ sections?: Array<{ id: ChannelSectionId; name: string }> diff --git a/apps/web/src/components/sidebar/channels-sidebar.tsx b/apps/web/src/components/sidebar/channels-sidebar.tsx index 3b46bd60d..f2fa8601c 100644 --- a/apps/web/src/components/sidebar/channels-sidebar.tsx +++ b/apps/web/src/components/sidebar/channels-sidebar.tsx @@ -60,6 +60,8 @@ import IconClose from "../icons/icon-close" import IconLightbulb from "../icons/icon-lightbulb" import { Button } from "../ui/button" +import type { SidebarChannelData, SidebarChannelMemberData } from "~/components/sidebar/channel-item" + interface ChannelSectionProps { organizationId: OrganizationId sectionId: ChannelSectionId | null @@ -72,8 +74,8 @@ interface ChannelSectionProps { threadsByParent?: Map< string, Array<{ - channel: Omit & { updatedAt: Date | null } - member: import("@hazel/db/schema").ChannelMember + channel: SidebarChannelData + member: SidebarChannelMemberData }> > /** Available sections for "move to section" menu */ diff --git a/apps/web/src/components/sidebar/discoverable-channels.tsx b/apps/web/src/components/sidebar/discoverable-channels.tsx index d3962073c..c9156055b 100644 --- a/apps/web/src/components/sidebar/discoverable-channels.tsx +++ b/apps/web/src/components/sidebar/discoverable-channels.tsx @@ -1,6 +1,6 @@ "use client" -import { useAtomValue } from "@effect-atom/atom-react" +import { useAtomValue } from "@effect/atom-react" import type { OrganizationId } from "@hazel/schema" import { and, eq, inArray, isNull, not, or, useLiveQuery } from "@tanstack/react-db" import { sectionCollapsedAtomFamily } from "~/atoms/section-collapse-atoms" diff --git a/apps/web/src/components/sidebar/dm-channel-item.tsx b/apps/web/src/components/sidebar/dm-channel-item.tsx index 31204f34a..dd4ecdc28 100644 --- a/apps/web/src/components/sidebar/dm-channel-item.tsx +++ b/apps/web/src/components/sidebar/dm-channel-item.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { ChannelId, UserId } from "@hazel/schema" import { useRouter } from "@tanstack/react-router" import { memo, useCallback } from "react" diff --git a/apps/web/src/components/sidebar/section-group.tsx b/apps/web/src/components/sidebar/section-group.tsx index f11991408..25bc761b2 100644 --- a/apps/web/src/components/sidebar/section-group.tsx +++ b/apps/web/src/components/sidebar/section-group.tsx @@ -1,6 +1,6 @@ "use client" -import { useAtom, useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { useAtom, useAtomSet, useAtomValue } from "@effect/atom-react" import type { ChannelId, ChannelSectionId } from "@hazel/schema" import type { ReactNode } from "react" import { useDragAndDrop } from "react-aria-components" diff --git a/apps/web/src/components/sidebar/thread-item.tsx b/apps/web/src/components/sidebar/thread-item.tsx index cb09b32e5..334035da7 100644 --- a/apps/web/src/components/sidebar/thread-item.tsx +++ b/apps/web/src/components/sidebar/thread-item.tsx @@ -1,6 +1,5 @@ -import { useAtomSet } from "@effect-atom/atom-react" -import type { Channel, ChannelMember } from "@hazel/db/schema" -import type { ChannelId } from "@hazel/schema" +import { useAtomSet } from "@effect/atom-react" +import type { ChannelId, ChannelMemberId } from "@hazel/schema" import { Exit } from "effect" import { useState } from "react" import { toast } from "sonner" @@ -21,8 +20,16 @@ import { useOrganization } from "~/hooks/use-organization" import { useScrollIntoViewOnActive } from "~/hooks/use-scroll-into-view-on-active" interface ThreadItemProps { - thread: Omit & { updatedAt: Date | null } - member: ChannelMember + thread: { + readonly id: ChannelId + readonly name: string + } + member: { + readonly id: ChannelMemberId + readonly isMuted: boolean + readonly isFavorite: boolean + readonly isHidden: boolean + } } export function ThreadItem({ thread, member }: ThreadItemProps) { diff --git a/apps/web/src/components/status/user-status-badge.tsx b/apps/web/src/components/status/user-status-badge.tsx index 0e99905ba..10f118894 100644 --- a/apps/web/src/components/status/user-status-badge.tsx +++ b/apps/web/src/components/status/user-status-badge.tsx @@ -1,3 +1,4 @@ +import type { DateTime } from "effect" import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip" import { cn } from "~/lib/utils" import { formatStatusExpiration } from "~/utils/status" @@ -53,7 +54,7 @@ interface QuietHoursInfo { interface StatusEmojiWithTooltipProps { emoji: string | null | undefined message?: string | null - expiresAt?: Date | null + expiresAt?: Date | DateTime.Utc | null className?: string quietHours?: QuietHoursInfo /** Set to false when inside another interactive element (e.g., MenuTrigger) to avoid nested buttons */ diff --git a/apps/web/src/components/tauri-menu-listener.tsx b/apps/web/src/components/tauri-menu-listener.tsx index 513fc2a27..5ca57397a 100644 --- a/apps/web/src/components/tauri-menu-listener.tsx +++ b/apps/web/src/components/tauri-menu-listener.tsx @@ -1,5 +1,5 @@ import { useEffect } from "react" -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import { getTauriEvent, type TauriEventApi } from "@hazel/desktop/bridge" import { useNavigate } from "@tanstack/react-router" import { modalAtomFamily } from "~/atoms/modal-atoms" diff --git a/apps/web/src/components/tauri-update-check.tsx b/apps/web/src/components/tauri-update-check.tsx index 462c139b2..13ded3d5c 100644 --- a/apps/web/src/components/tauri-update-check.tsx +++ b/apps/web/src/components/tauri-update-check.tsx @@ -4,7 +4,7 @@ * @description Check for app updates and prompt user to install (no-op in browser) */ -import { useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import { useEffect, useRef } from "react" import { toast } from "sonner" import { diff --git a/apps/web/src/components/theme-provider.tsx b/apps/web/src/components/theme-provider.tsx index f425d140d..57c2972ff 100644 --- a/apps/web/src/components/theme-provider.tsx +++ b/apps/web/src/components/theme-provider.tsx @@ -1,4 +1,5 @@ -import { Atom, useAtomMount, useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { Atom } from "effect/unstable/reactivity" +import { useAtomMount, useAtomSet, useAtomValue } from "@effect/atom-react" import type { Theme as ThemeModel } from "@hazel/domain/models" import { Schema } from "effect" import { applyBrandColor, applyGrayPalette, applyRadius } from "~/lib/theme/apply" @@ -13,14 +14,11 @@ type ThemeProviderProps = { storageKey?: string } -const ThemeSchema = Schema.Literal("dark", "light", "system") +const ThemeSchema = Schema.Literals(["dark", "light", "system"]) -const HexColorSchema = Schema.String.pipe( - Schema.pattern(/^#[0-9A-Fa-f]{6}$/), - Schema.annotations({ message: () => "Must be a valid hex color (#RRGGBB)" }), -) +const HexColorSchema = Schema.String.pipe(Schema.check(Schema.isPattern(/^#[0-9A-Fa-f]{6}$/))) -const GrayPaletteSchema = Schema.Literal( +const GrayPaletteSchema = Schema.Literals([ "gray", "gray-blue", "gray-cool", @@ -29,9 +27,9 @@ const GrayPaletteSchema = Schema.Literal( "gray-iron", "gray-true", "gray-warm", -) +]) -const RadiusPresetSchema = Schema.Literal("tight", "normal", "round", "full") +const RadiusPresetSchema = Schema.Literals(["tight", "normal", "round", "full"]) const ThemeCustomizationSchema = Schema.Struct({ primary: HexColorSchema, @@ -39,28 +37,30 @@ const ThemeCustomizationSchema = Schema.Struct({ radius: RadiusPresetSchema, }) +type ThemeCustomization = typeof ThemeCustomizationSchema.Type + // Theme mode atom with automatic localStorage persistence export const themeAtom = Atom.kvs({ runtime: platformStorageRuntime, key: "hazel-ui-theme", - schema: Schema.NullOr(ThemeSchema), - defaultValue: () => "system" as const, + schema: Schema.toCodecJson(Schema.NullOr(ThemeSchema)), + defaultValue: () => "system" as Theme | null, }) // Brand color atom (legacy, kept for backward compatibility) export const brandColorAtom = Atom.kvs({ runtime: platformStorageRuntime, key: "brand-color", - schema: Schema.NullOr(HexColorSchema), - defaultValue: () => DEFAULT_BRAND_COLOR as string, + schema: Schema.toCodecJson(Schema.NullOr(HexColorSchema)), + defaultValue: () => DEFAULT_BRAND_COLOR as string | null, }) // Full theme customization atom export const themeCustomizationAtom = Atom.kvs({ runtime: platformStorageRuntime, key: "hazel-theme-customization", - schema: Schema.NullOr(ThemeCustomizationSchema), - defaultValue: () => getDefaultThemeCustomization() as ThemeModel.ThemeCustomization, + schema: Schema.toCodecJson(Schema.NullOr(ThemeCustomizationSchema)), + defaultValue: () => getDefaultThemeCustomization() as ThemeCustomization | null, }) // Resolved theme (system -> light/dark) @@ -68,7 +68,7 @@ export const resolvedThemeAtom = Atom.transform(themeAtom, (get) => { const theme = get(themeAtom) if (theme !== "system" && theme !== null) return theme - if (typeof window === "undefined") return "light" + if (typeof window === "undefined") return "light" as const // Listen to system theme changes const matcher = window.matchMedia("(prefers-color-scheme: dark)") @@ -76,10 +76,10 @@ export const resolvedThemeAtom = Atom.transform(themeAtom, (get) => { matcher.addEventListener("change", onChange) get.addFinalizer(() => matcher.removeEventListener("change", onChange)) - return matcher.matches ? "dark" : "light" + return matcher.matches ? ("dark" as const) : ("light" as const) function onChange() { - get.setSelf(matcher.matches ? "dark" : "light") + get.setSelf(matcher.matches ? ("dark" as const) : ("light" as const)) } }) @@ -114,7 +114,7 @@ export const applyThemeCustomizationAtom = Atom.make((get) => { const isDarkMode = resolvedTheme === "dark" // Apply all customization parts - applyBrandColor(customization.primary as ThemeModel.HexColor) + applyBrandColor(customization.primary as unknown as ThemeModel.HexColor) applyGrayPalette(customization.grayPalette as ThemeModel.GrayPalette, isDarkMode) applyRadius(customization.radius as ThemeModel.RadiusPreset) }) @@ -131,7 +131,7 @@ export const applyBrandColorAtom = Atom.make((get) => { if (typeof document === "undefined") return const hexColor = brandColor || DEFAULT_BRAND_COLOR - applyBrandColor(hexColor as ThemeModel.HexColor) + applyBrandColor(hexColor as unknown as ThemeModel.HexColor) }) export function ThemeProvider({ children }: ThemeProviderProps) { @@ -156,19 +156,19 @@ export const useTheme = () => { // When setting brand color, also update the customization const handleSetBrandColor = (color: string) => { - setBrandColor(color) + setBrandColor(color as string & null) if (customization) { setCustomization({ ...customization, primary: color, - }) + } as unknown as ThemeCustomization) } } return { - theme: theme || "system", - setTheme, - brandColor: customization?.primary || brandColor || DEFAULT_BRAND_COLOR, + theme: (theme || "system") as Theme, + setTheme: setTheme as (value: Theme | null) => void, + brandColor: (customization?.primary || brandColor || DEFAULT_BRAND_COLOR) as string, setBrandColor: handleSetBrandColor, } } @@ -187,29 +187,29 @@ export const useThemeCustomization = () => { setCustomization({ ...activeCustomization, primary: color, - }) + } as unknown as ThemeCustomization) } const setGrayPalette = (palette: ThemeModel.GrayPalette) => { setCustomization({ ...activeCustomization, grayPalette: palette, - }) + } as unknown as ThemeCustomization) } const setRadius = (radius: ThemeModel.RadiusPreset) => { setCustomization({ ...activeCustomization, radius: radius, - }) + } as unknown as ThemeCustomization) } const setFullCustomization = (newCustomization: ThemeModel.ThemeCustomization) => { - setCustomization(newCustomization) + setCustomization(newCustomization as unknown as ThemeCustomization) } return { - customization: activeCustomization, + customization: activeCustomization as ThemeModel.ThemeCustomization, isDarkMode: resolvedTheme === "dark", setPrimary, setGrayPalette, diff --git a/apps/web/src/components/tweet-embed.tsx b/apps/web/src/components/tweet-embed.tsx index 62dc2c790..d185059cb 100644 --- a/apps/web/src/components/tweet-embed.tsx +++ b/apps/web/src/components/tweet-embed.tsx @@ -1,4 +1,5 @@ -import { Result, useAtomValue } from "@effect-atom/atom-react" +import { AsyncResult } from "effect/unstable/reactivity" +import { useAtomValue } from "@effect/atom-react" import type { User } from "@hazel/domain/models" import { useState } from "react" import { type EnrichedTweet, enrichTweet } from "react-tweet" @@ -231,14 +232,14 @@ function TweetMetrics({ tweet }: { tweet: EnrichedTweet }) { interface TweetEmbedProps { id: string - author?: typeof User.Model.Type + author?: User.Type messageCreatedAt?: number } export function TweetEmbed({ id, author, messageCreatedAt }: TweetEmbedProps) { - const tweetResult = useAtomValue(LinkPreviewClient.query("tweet", "get", { urlParams: { id } })) - const tweet = Result.getOrElse(tweetResult, () => null) - const isLoading = Result.isInitial(tweetResult) + const tweetResult = useAtomValue(LinkPreviewClient.query("tweet", "get", { payload: { id } })) + const tweet = AsyncResult.getOrElse(tweetResult, () => null) + const isLoading = AsyncResult.isInitial(tweetResult) const [isModalOpen, setIsModalOpen] = useState(false) const [selectedImageIndex, setSelectedImageIndex] = useState(0) diff --git a/apps/web/src/db/actions.ts b/apps/web/src/db/actions.ts index 06c74a99d..a46602f61 100644 --- a/apps/web/src/db/actions.ts +++ b/apps/web/src/db/actions.ts @@ -1,7 +1,7 @@ import { type User } from "@hazel/domain/models" -import { - type AttachmentId, - type ChannelIcon, +import type { + AttachmentId, + ChannelIcon, ChannelId, ChannelMemberId, ChannelSectionId, @@ -10,7 +10,7 @@ import { MessageReactionId, OrganizationId, PinnedMessageId, - type UserId, + UserId, } from "@hazel/schema" import { Cause, Effect, Schedule, Duration } from "effect" import { isRetryableError } from "~/lib/error-messages" @@ -54,8 +54,8 @@ const getMountedConversationId = (channelId: ChannelId) => */ const MessageRetrySchedule = Schedule.exponential(Duration.seconds(1), 2).pipe( Schedule.jittered, - Schedule.whileInput(isErrorRetryable), - Schedule.intersect(Schedule.recurs(3)), + Schedule.while((metadata) => isErrorRetryable(metadata.input)), + Schedule.both(Schedule.recurs(3)), ) export const sendMessageAction = optimisticAction({ @@ -76,7 +76,7 @@ export const sendMessageAction = optimisticAction({ attachmentIds?: AttachmentId[] onRetryAttempt?: (attempt: number) => void }) => { - const messageId = props.messageId ?? MessageId.make(crypto.randomUUID()) + const messageId = props.messageId ?? (crypto.randomUUID() as MessageId) messageCollection.insert({ id: messageId, @@ -161,7 +161,7 @@ export const createChannelAction = optimisticAction({ currentUserId: UserId addAllMembers?: boolean }) => { - const channelId = ChannelId.make(crypto.randomUUID()) + const channelId = crypto.randomUUID() as ChannelId const now = new Date() // Optimistically insert the channel @@ -180,7 +180,7 @@ export const createChannelAction = optimisticAction({ // Add creator as member channelMemberCollection.insert({ - id: ChannelMemberId.make(crypto.randomUUID()), + id: crypto.randomUUID() as ChannelMemberId, channelId: channelId, userId: props.currentUserId, isHidden: false, @@ -227,7 +227,7 @@ export const createDmChannelAction = optimisticAction({ name?: string currentUserId: UserId }) => { - const channelId = ChannelId.make(crypto.randomUUID()) + const channelId = crypto.randomUUID() as ChannelId const now = new Date() let channelName = props.name @@ -251,7 +251,7 @@ export const createDmChannelAction = optimisticAction({ // Add current user as member channelMemberCollection.insert({ - id: ChannelMemberId.make(crypto.randomUUID()), + id: crypto.randomUUID() as ChannelMemberId, channelId: channelId, userId: props.currentUserId, isHidden: false, @@ -267,7 +267,7 @@ export const createDmChannelAction = optimisticAction({ // Add all participants as members for (const participantId of props.participantIds) { channelMemberCollection.insert({ - id: ChannelMemberId.make(crypto.randomUUID()), + id: crypto.randomUUID() as ChannelMemberId, channelId: channelId, userId: participantId, isHidden: false, @@ -308,7 +308,7 @@ export const createOrganizationAction = optimisticAction({ runtime: runtime, onMutate: (props: { name: string; slug: string; logoUrl?: string | null }) => { - const organizationId = OrganizationId.make(crypto.randomUUID()) + const organizationId = crypto.randomUUID() as OrganizationId const now = new Date() // Optimistically insert the organization @@ -369,7 +369,7 @@ export const toggleReactionAction = optimisticAction({ } // Toggle on: insert a new reaction - const reactionId = MessageReactionId.make(crypto.randomUUID()) + const reactionId = crypto.randomUUID() as MessageReactionId messageReactionCollection.insert({ id: reactionId, messageId: props.messageId, @@ -415,7 +415,7 @@ export const createThreadAction = optimisticAction({ organizationId: OrganizationId currentUserId: UserId }) => { - const threadChannelId = props.threadChannelId ?? ChannelId.make(crypto.randomUUID()) + const threadChannelId = props.threadChannelId ?? (crypto.randomUUID() as ChannelId) const now = new Date() // Create thread channel @@ -434,7 +434,7 @@ export const createThreadAction = optimisticAction({ // Add creator as member channelMemberCollection.insert({ - id: ChannelMemberId.make(crypto.randomUUID()), + id: crypto.randomUUID() as ChannelMemberId, channelId: threadChannelId, userId: props.currentUserId, isHidden: false, @@ -561,7 +561,7 @@ export const pinMessageAction = optimisticAction({ runtime: runtime, onMutate: (props: { messageId: MessageId; channelId: ChannelId; userId: UserId }) => { - const pinnedMessageId = PinnedMessageId.make(crypto.randomUUID()) + const pinnedMessageId = crypto.randomUUID() as PinnedMessageId pinnedMessageCollection.insert({ id: pinnedMessageId, channelId: props.channelId, @@ -644,7 +644,7 @@ export const joinChannelAction = optimisticAction({ runtime: runtime, onMutate: (props: { channelId: ChannelId; userId: UserId }) => { - const memberId = ChannelMemberId.make(crypto.randomUUID()) + const memberId = crypto.randomUUID() as ChannelMemberId const now = new Date() channelMemberCollection.insert({ id: memberId, @@ -710,7 +710,7 @@ export const createChannelSectionAction = optimisticAction({ runtime: runtime, onMutate: (props: { organizationId: OrganizationId; name: string; order?: number }) => { - const sectionId = ChannelSectionId.make(crypto.randomUUID()) + const sectionId = crypto.randomUUID() as ChannelSectionId const now = new Date() channelSectionCollection.insert({ @@ -900,7 +900,7 @@ export const createCustomEmojiAction = optimisticAction({ imageUrl: string createdBy: UserId }) => { - const emojiId = CustomEmojiId.make(crypto.randomUUID()) + const emojiId = crypto.randomUUID() as CustomEmojiId const now = new Date() customEmojiCollection.insert({ diff --git a/apps/web/src/db/collections.ts b/apps/web/src/db/collections.ts index 5828f3f74..f93223b8c 100644 --- a/apps/web/src/db/collections.ts +++ b/apps/web/src/db/collections.ts @@ -48,7 +48,7 @@ export const organizationCollection = createEffectCollection({ fetchClient: electricFetchClient, }, - schema: Organization.Model.json, + schema: Organization.Schema, getKey: (item) => item.id, }) @@ -67,7 +67,7 @@ export const invitationCollection = createEffectCollection({ }, fetchClient: electricFetchClient, }, - schema: Invitation.Model.json, + schema: Invitation.Schema, getKey: (item) => item.id, }) @@ -87,7 +87,7 @@ export const messageCollection = createEffectCollection({ } as any, fetchClient: electricFetchClient, }, - schema: Message.Model.json, + schema: Message.Schema, getKey: (item) => item.id, }) @@ -106,7 +106,7 @@ export const messageReactionCollection = createEffectCollection({ }, fetchClient: electricFetchClient, }, - schema: MessageReaction.Model.json, + schema: MessageReaction.Schema, getKey: (item) => item.id, }) @@ -125,7 +125,7 @@ export const pinnedMessageCollection = createEffectCollection({ }, fetchClient: electricFetchClient, }, - schema: PinnedMessage.Model.json, + schema: PinnedMessage.Schema, getKey: (item) => item.id, }) @@ -145,7 +145,7 @@ export const notificationCollection = createEffectCollection({ }, fetchClient: electricFetchClient, }, - schema: Notification.Model.json, + schema: Notification.Schema, getKey: (item) => item.id, }) @@ -165,7 +165,7 @@ export const userCollection = createEffectCollection({ }, fetchClient: electricFetchClient, }, - schema: User.Model.json, + schema: User.Schema, getKey: (item) => item.id, }) @@ -184,7 +184,7 @@ export const organizationMemberCollection = createEffectCollection({ }, fetchClient: electricFetchClient, }, - schema: OrganizationMember.Model.json, + schema: OrganizationMember.Schema, getKey: (item) => item.id, }) @@ -203,7 +203,7 @@ export const channelCollection = createEffectCollection({ }, fetchClient: electricFetchClient, }, - schema: Channel.Model.json, + schema: Channel.Schema, getKey: (item) => item.id, }) @@ -221,7 +221,7 @@ export const connectConversationCollection = createEffectCollection({ }, fetchClient: electricFetchClient, }, - schema: ConnectConversation.Model.json, + schema: ConnectConversation.Schema, getKey: (item) => item.id, }) @@ -239,7 +239,7 @@ export const connectConversationChannelCollection = createEffectCollection({ }, fetchClient: electricFetchClient, }, - schema: ConnectConversationChannel.Model.json, + schema: ConnectConversationChannel.Schema, getKey: (item) => item.id, }) @@ -257,7 +257,7 @@ export const connectParticipantCollection = createEffectCollection({ }, fetchClient: electricFetchClient, }, - schema: ConnectParticipant.Model.json, + schema: ConnectParticipant.Schema, getKey: (item) => item.id, }) @@ -277,7 +277,7 @@ export const channelMemberCollection = createEffectCollection({ }, fetchClient: electricFetchClient, }, - schema: ChannelMember.Model.json, + schema: ChannelMember.Schema, getKey: (item) => item.id, }) @@ -295,7 +295,7 @@ export const channelSectionCollection = createEffectCollection({ }, fetchClient: electricFetchClient, }, - schema: ChannelSection.Model.json, + schema: ChannelSection.Schema, getKey: (item) => item.id, }) @@ -314,7 +314,7 @@ export const attachmentCollection = createEffectCollection({ }, fetchClient: electricFetchClient, }, - schema: Attachment.Model.json, + schema: Attachment.Schema, getKey: (item) => item.id, }) @@ -331,7 +331,7 @@ export const typingIndicatorCollection = createEffectCollection({ }, fetchClient: electricFetchClient, }, - schema: TypingIndicator.Model.json, + schema: TypingIndicator.Schema, getKey: (item) => item.id, }) @@ -350,7 +350,7 @@ export const userPresenceStatusCollection = createEffectCollection({ }, fetchClient: electricFetchClient, }, - schema: UserPresenceStatus.Model.json, + schema: UserPresenceStatus.Schema, getKey: (item) => item.id, }) @@ -370,7 +370,7 @@ export const integrationConnectionCollection = createEffectCollection({ }, fetchClient: electricFetchClient, }, - schema: IntegrationConnection.Model.json, + schema: IntegrationConnection.Schema, getKey: (item) => item.id, }) @@ -388,7 +388,7 @@ export const chatSyncConnectionCollection = createEffectCollection({ }, fetchClient: electricFetchClient, }, - schema: ChatSyncConnection.Model.json, + schema: ChatSyncConnection.Schema, getKey: (item) => item.id, }) @@ -406,7 +406,7 @@ export const chatSyncChannelLinkCollection = createEffectCollection({ }, fetchClient: electricFetchClient, }, - schema: ChatSyncChannelLink.Model.json, + schema: ChatSyncChannelLink.Schema, getKey: (item) => item.id, }) @@ -424,7 +424,7 @@ export const chatSyncMessageLinkCollection = createEffectCollection({ }, fetchClient: electricFetchClient, }, - schema: ChatSyncMessageLink.Model.json, + schema: ChatSyncMessageLink.Schema, getKey: (item) => item.id, }) @@ -442,7 +442,7 @@ export const botCollection = createEffectCollection({ } as any, fetchClient: electricFetchClient, }, - schema: Bot.Model.json, + schema: Bot.Schema, getKey: (item) => item.id, }) @@ -460,7 +460,7 @@ export const botCommandCollection = createEffectCollection({ } as any, fetchClient: electricFetchClient, }, - schema: BotCommand.Model.json, + schema: BotCommand.Schema, getKey: (item) => item.id, }) @@ -478,7 +478,7 @@ export const botInstallationCollection = createEffectCollection({ } as any, fetchClient: electricFetchClient, }, - schema: BotInstallation.Model.json, + schema: BotInstallation.Schema, getKey: (item) => item.id, }) @@ -496,6 +496,6 @@ export const customEmojiCollection = createEffectCollection({ } as any, fetchClient: electricFetchClient, }, - schema: CustomEmoji.Model.json, + schema: CustomEmoji.Schema, getKey: (item) => item.id, }) diff --git a/apps/web/src/db/hooks.ts b/apps/web/src/db/hooks.ts index 30adaab4e..63b9d860c 100644 --- a/apps/web/src/db/hooks.ts +++ b/apps/web/src/db/hooks.ts @@ -35,9 +35,9 @@ export const useMessage = (messageId: MessageId) => { } } -type ChannelWithMembers = typeof Channel.Model.Type & { - members: (typeof ChannelMember.Model.Type & { - user: typeof User.Model.Type +type ChannelWithMembers = Channel.Type & { + members: (ChannelMember.Type & { + user: User.Type })[] } @@ -199,7 +199,7 @@ export const useIntegrationConnections = (organizationId: OrganizationId | null) // Build a map of provider -> connection for easy lookup const connectionsByProvider = new Map< IntegrationConnection.IntegrationProvider, - typeof IntegrationConnection.Model.Type + IntegrationConnection.Type >() if (organizationId && data) { @@ -417,8 +417,8 @@ export const useBotName = (userId: UserId | undefined, userType: string | undefi /** * Type for bot data with its machine user included. */ -export type BotWithUser = typeof Bot.Model.Type & { - user: typeof User.Model.Type +export type BotWithUser = Bot.Type & { + user: User.Type } /** diff --git a/apps/web/src/hooks/use-bot-avatar-upload.tsx b/apps/web/src/hooks/use-bot-avatar-upload.tsx index 6dfcfc30f..8fb116c0a 100644 --- a/apps/web/src/hooks/use-bot-avatar-upload.tsx +++ b/apps/web/src/hooks/use-bot-avatar-upload.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { BotId } from "@hazel/schema" import { Exit } from "effect" import { useCallback } from "react" diff --git a/apps/web/src/hooks/use-channel-member-actions.ts b/apps/web/src/hooks/use-channel-member-actions.ts index 055192dfe..aea8053bc 100644 --- a/apps/web/src/hooks/use-channel-member-actions.ts +++ b/apps/web/src/hooks/use-channel-member-actions.ts @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { ChannelMemberId } from "@hazel/schema" import { deleteChannelMemberMutation } from "~/atoms/channel-member-atoms" import { updateChannelMemberAction } from "~/db/actions" diff --git a/apps/web/src/hooks/use-command-palette.ts b/apps/web/src/hooks/use-command-palette.ts index b68d0ce4d..4509534d6 100644 --- a/apps/web/src/hooks/use-command-palette.ts +++ b/apps/web/src/hooks/use-command-palette.ts @@ -1,4 +1,4 @@ -import { useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import { useCallback } from "react" import { type CommandPalettePageType, diff --git a/apps/web/src/hooks/use-emoji-stats.tsx b/apps/web/src/hooks/use-emoji-stats.tsx index 2af999a61..fb887ae63 100644 --- a/apps/web/src/hooks/use-emoji-stats.tsx +++ b/apps/web/src/hooks/use-emoji-stats.tsx @@ -1,4 +1,4 @@ -import { useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import { useCallback } from "react" import { type EmojiUsage, emojiUsageAtom, topEmojisAtom } from "~/atoms/emoji-atoms" diff --git a/apps/web/src/hooks/use-file-upload.tsx b/apps/web/src/hooks/use-file-upload.tsx index 6307a920a..df8f97651 100644 --- a/apps/web/src/hooks/use-file-upload.tsx +++ b/apps/web/src/hooks/use-file-upload.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { AttachmentId, ChannelId, OrganizationId } from "@hazel/schema" import { Exit } from "effect" import { useCallback, useRef, useState } from "react" diff --git a/apps/web/src/hooks/use-grouped-notifications.ts b/apps/web/src/hooks/use-grouped-notifications.ts index 1711dc75b..d1969f5ba 100644 --- a/apps/web/src/hooks/use-grouped-notifications.ts +++ b/apps/web/src/hooks/use-grouped-notifications.ts @@ -1,5 +1,6 @@ import { isThisWeek, isToday, isYesterday } from "date-fns" import { useMemo } from "react" +import { toDate } from "~/lib/utils" import type { NotificationWithDetails } from "./use-notifications" import { useNotifications } from "./use-notifications" @@ -20,7 +21,7 @@ function groupNotificationsByTime(notifications: NotificationWithDetails[]): Not const older: NotificationWithDetails[] = [] for (const notification of notifications) { - const createdAt = new Date(notification.notification.createdAt) + const createdAt = toDate(notification.notification.createdAt) if (isToday(createdAt)) { today.push(notification) diff --git a/apps/web/src/hooks/use-loading-state.ts b/apps/web/src/hooks/use-loading-state.ts index 28295de58..75a654fdd 100644 --- a/apps/web/src/hooks/use-loading-state.ts +++ b/apps/web/src/hooks/use-loading-state.ts @@ -1,4 +1,4 @@ -import { useAtomValue } from "@effect-atom/atom-react" +import { useAtomValue } from "@effect/atom-react" import { useCallback, useMemo } from "react" import { canLoadBottomAtomFamily, diff --git a/apps/web/src/hooks/use-notification-settings.ts b/apps/web/src/hooks/use-notification-settings.ts index 56014c7b4..76e1c10ac 100644 --- a/apps/web/src/hooks/use-notification-settings.ts +++ b/apps/web/src/hooks/use-notification-settings.ts @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import { type User } from "@hazel/domain/models" import type { UserId } from "@hazel/schema" import { eq, useLiveQuery } from "@tanstack/react-db" diff --git a/apps/web/src/hooks/use-notification-sound.tsx b/apps/web/src/hooks/use-notification-sound.tsx index 63e05bcad..a6bdcc205 100644 --- a/apps/web/src/hooks/use-notification-sound.tsx +++ b/apps/web/src/hooks/use-notification-sound.tsx @@ -1,4 +1,4 @@ -import { useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import { useCallback } from "react" import { type NotificationSoundSettings, diff --git a/apps/web/src/hooks/use-notifications.ts b/apps/web/src/hooks/use-notifications.ts index 5df8796a2..8dfe011af 100644 --- a/apps/web/src/hooks/use-notifications.ts +++ b/apps/web/src/hooks/use-notifications.ts @@ -42,10 +42,10 @@ function useOrganizationMember() { } export interface NotificationWithDetails { - notification: typeof Notification.Model.Type - message?: typeof Message.Model.Type - channel?: typeof Channel.Model.Type - author?: typeof User.Model.Type + notification: Notification.Type + message?: Message.Type + channel?: Channel.Type + author?: User.Type } export function useNotifications() { @@ -76,10 +76,10 @@ export function useNotifications() { if (!notificationsData) return [] return notificationsData.map((row) => ({ - notification: row.notification as typeof Notification.Model.Type, - message: (row.message ?? undefined) as typeof Message.Model.Type | undefined, - channel: (row.channel ?? undefined) as typeof Channel.Model.Type | undefined, - author: (row.author ?? undefined) as typeof User.Model.Type | undefined, + notification: row.notification as Notification.Type, + message: (row.message ?? undefined) as Message.Type | undefined, + channel: (row.channel ?? undefined) as Channel.Type | undefined, + author: (row.author ?? undefined) as User.Type | undefined, })) }, [notificationsData]) diff --git a/apps/web/src/hooks/use-onboarding.ts b/apps/web/src/hooks/use-onboarding.ts index 509874e19..09b832f94 100644 --- a/apps/web/src/hooks/use-onboarding.ts +++ b/apps/web/src/hooks/use-onboarding.ts @@ -1,4 +1,4 @@ -import { useAtom, useAtomSet } from "@effect-atom/atom-react" +import { useAtom, useAtomSet } from "@effect/atom-react" import type { OrganizationId, OrganizationMemberId } from "@hazel/schema" import { Exit } from "effect" import { usePostHog } from "posthog-js/react" diff --git a/apps/web/src/hooks/use-organization-avatar-upload.tsx b/apps/web/src/hooks/use-organization-avatar-upload.tsx index e27e208ae..cce3f6c9c 100644 --- a/apps/web/src/hooks/use-organization-avatar-upload.tsx +++ b/apps/web/src/hooks/use-organization-avatar-upload.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { OrganizationId } from "@hazel/schema" import { Exit } from "effect" import { useCallback } from "react" diff --git a/apps/web/src/hooks/use-presence.ts b/apps/web/src/hooks/use-presence.ts index 85804378e..cab348263 100644 --- a/apps/web/src/hooks/use-presence.ts +++ b/apps/web/src/hooks/use-presence.ts @@ -1,4 +1,5 @@ -import { Atom, Result, useAtomMount, useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { Atom, AsyncResult } from "effect/unstable/reactivity" +import { useAtomMount, useAtomSet, useAtomValue } from "@effect/atom-react" import type { ChannelId, UserId } from "@hazel/schema" import { eq } from "@tanstack/db" import { DateTime, Duration, Effect } from "effect" @@ -25,7 +26,7 @@ const ACTIVITY_BROADCAST_THROTTLE_MS = 1_000 */ const lastActivityAtom = Atom.make((get) => { let lastActivityMs = Date.now() - let lastActivity = DateTime.unsafeMake(new Date(lastActivityMs)) + let lastActivity = DateTime.makeUnsafe(new Date(lastActivityMs)) let lastBroadcastAtMs = 0 const tabId = @@ -59,14 +60,14 @@ const lastActivityAtom = Atom.make((get) => { const applyActivity = (at: number) => { if (!Number.isFinite(at) || at <= lastActivityMs) return lastActivityMs = at - lastActivity = DateTime.unsafeMake(new Date(at)) + lastActivity = DateTime.makeUnsafe(new Date(at)) get.setSelf(lastActivity) } const handleLocalActivity = () => { const at = Date.now() lastActivityMs = at - lastActivity = DateTime.unsafeMake(new Date(at)) + lastActivity = DateTime.makeUnsafe(new Date(at)) get.setSelf(lastActivity) if (at - lastBroadcastAtMs >= ACTIVITY_BROADCAST_THROTTLE_MS) { @@ -221,7 +222,7 @@ export const currentChannelIdAtom = Atom.make((get) => { * within the timeout period, the server-side cron job marks the user offline. */ const heartbeatAtom = Atom.make((get) => { - const user = Result.getOrElse(get(userAtom), () => null) + const user = AsyncResult.getOrElse(get(userAtom), () => null) // Skip if no user if (!user?.id) return null @@ -294,8 +295,8 @@ const userSettingsAtomFamily = Atom.family((userId: UserId) => * Reads from userAtom and returns the presence data */ const currentUserPresenceAtom = Atom.make((get) => { - const user = Result.getOrElse(get(userAtom), () => null) - if (!user?.id) return Result.initial(false) + const user = AsyncResult.getOrElse(get(userAtom), () => null) + if (!user?.id) return AsyncResult.initial(false) return get(currentUserPresenceAtomFamily(user.id)) }) @@ -418,13 +419,13 @@ export function usePresence() { // Subscribe to atoms for return values (these cause re-renders, but that's expected for consumers) const nowMs = useAtomValue(presenceNowSignal) const presenceResult = useAtomValue(currentUserPresenceAtom) - const currentPresence = Result.getOrElse(presenceResult, () => undefined) + const currentPresence = AsyncResult.getOrElse(presenceResult, () => undefined) const computedStatus = useAtomValue(computedPresenceStatusAtom) const afkState = useAtomValue(afkStateAtom) // Query current user's settings for quiet hours const userSettingsResult = useAtomValue(userSettingsAtomFamily(user?.id as UserId)) - const userSettings = Result.getOrElse(userSettingsResult, () => undefined) + const userSettings = AsyncResult.getOrElse(userSettingsResult, () => undefined) // Compute quiet hours for current user const quietHours = useMemo((): QuietHoursInfo | undefined => { @@ -552,7 +553,7 @@ export interface QuietHoursInfo { */ export function useCurrentUserStatus() { const presenceResult = useAtomValue(currentUserPresenceAtom) - const currentPresence = Result.getOrElse(presenceResult, () => undefined) + const currentPresence = AsyncResult.getOrElse(presenceResult, () => undefined) return { statusEmoji: currentPresence?.statusEmoji ?? null, @@ -569,9 +570,9 @@ export function useCurrentUserStatus() { */ export function useUserStatus(userId: UserId) { const presenceResult = useAtomValue(currentUserPresenceAtomFamily(userId)) - const presence = Result.getOrElse(presenceResult, () => undefined) + const presence = AsyncResult.getOrElse(presenceResult, () => undefined) const userSettingsResult = useAtomValue(userSettingsAtomFamily(userId)) - const userSettings = Result.getOrElse(userSettingsResult, () => undefined) + const userSettings = AsyncResult.getOrElse(userSettingsResult, () => undefined) const quietHours = useMemo((): QuietHoursInfo | undefined => { if (userSettings?.settings?.showQuietHoursInStatus === false) return undefined @@ -603,9 +604,9 @@ export function useUserStatus(userId: UserId) { export function useUserPresence(userId: UserId) { const nowMs = useAtomValue(presenceNowSignal) const presenceResult = useAtomValue(currentUserPresenceAtomFamily(userId)) - const presence = Result.getOrElse(presenceResult, () => undefined) + const presence = AsyncResult.getOrElse(presenceResult, () => undefined) const userSettingsResult = useAtomValue(userSettingsAtomFamily(userId)) - const userSettings = Result.getOrElse(userSettingsResult, () => undefined) + const userSettings = AsyncResult.getOrElse(userSettingsResult, () => undefined) // Compute quiet hours state const quietHours = useMemo((): QuietHoursInfo | undefined => { diff --git a/apps/web/src/hooks/use-profile-picture-upload.tsx b/apps/web/src/hooks/use-profile-picture-upload.tsx index 74aab1323..2902889ab 100644 --- a/apps/web/src/hooks/use-profile-picture-upload.tsx +++ b/apps/web/src/hooks/use-profile-picture-upload.tsx @@ -1,4 +1,4 @@ -import { useAtomRefresh, useAtomSet } from "@effect-atom/atom-react" +import { useAtomRefresh, useAtomSet } from "@effect/atom-react" import type { UserId } from "@hazel/schema" import { Exit } from "effect" import { useCallback } from "react" diff --git a/apps/web/src/hooks/use-search-query.ts b/apps/web/src/hooks/use-search-query.ts index c473374e2..efc4c19c4 100644 --- a/apps/web/src/hooks/use-search-query.ts +++ b/apps/web/src/hooks/use-search-query.ts @@ -14,9 +14,9 @@ import { parseDateFilter, type SearchFilter } from "~/lib/search-filter-parser" import { getFileCategory } from "~/utils/file-utils" export interface SearchResult { - message: typeof Message.Model.Type - author: typeof User.Model.Type | null - channel: typeof Channel.Model.Type | null + message: Message.Type + author: User.Type | null + channel: Channel.Type | null attachmentCount: number } diff --git a/apps/web/src/hooks/use-theme-settings.ts b/apps/web/src/hooks/use-theme-settings.ts index c76e3ba26..c7003e163 100644 --- a/apps/web/src/hooks/use-theme-settings.ts +++ b/apps/web/src/hooks/use-theme-settings.ts @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import { type Theme } from "@hazel/domain/models" import type { UserId } from "@hazel/schema" import { eq, useLiveQuery } from "@tanstack/react-db" diff --git a/apps/web/src/hooks/use-typing-indicators.ts b/apps/web/src/hooks/use-typing-indicators.ts index 07678c8fe..b698194be 100644 --- a/apps/web/src/hooks/use-typing-indicators.ts +++ b/apps/web/src/hooks/use-typing-indicators.ts @@ -7,8 +7,8 @@ import { useAuth } from "~/lib/auth" import { pushTypingDiagnostics } from "~/lib/typing-diagnostics" type TypingUser = { - user: typeof User.Model.Type - member: typeof ChannelMember.Model.Type + user: User.Type + member: ChannelMember.Type } export type TypingUsers = TypingUser[] diff --git a/apps/web/src/hooks/use-typing.test.tsx b/apps/web/src/hooks/use-typing.test.tsx index e0abc47f1..4a075d0d6 100644 --- a/apps/web/src/hooks/use-typing.test.tsx +++ b/apps/web/src/hooks/use-typing.test.tsx @@ -1,4 +1,5 @@ import { renderHook, act } from "@testing-library/react" +import type { ChannelId, ChannelMemberId } from "@hazel/schema" import { Exit } from "effect" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { useTyping } from "./use-typing" @@ -19,7 +20,7 @@ vi.mock("~/lib/typing-diagnostics", () => ({ pushTypingDiagnostics: vi.fn(), })) -vi.mock("@effect-atom/atom-react", () => ({ +vi.mock("@effect/atom-react", () => ({ useAtomSet: vi.fn((mutation: unknown) => { if (mutation === upsertMutationSymbol) return upsertMock if (mutation === deleteMutationSymbol) return deleteMock @@ -34,6 +35,9 @@ const flushMicrotasks = async () => { } describe("useTyping", () => { + const channelId = "00000000-0000-4000-8000-000000000301" as ChannelId + const memberId = "00000000-0000-4000-8000-000000000302" as ChannelMemberId + beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")) @@ -43,13 +47,13 @@ describe("useTyping", () => { Exit.succeed({ data: { id: "typing-indicator-1" }, transactionId: 1, - }) as any, + }), ) deleteMock.mockResolvedValue( Exit.succeed({ data: { id: "typing-indicator-1" }, transactionId: 1, - }) as any, + }), ) }) @@ -60,8 +64,8 @@ describe("useTyping", () => { it("sends heartbeat immediately on first non-empty content and throttles subsequent heartbeats", async () => { const { result } = renderHook(() => useTyping({ - channelId: "channel-1" as any, - memberId: "member-1" as any, + channelId, + memberId, heartbeatInterval: 1500, }), ) @@ -91,8 +95,8 @@ describe("useTyping", () => { it("deletes typing indicator when content becomes empty", async () => { const { result } = renderHook(() => useTyping({ - channelId: "channel-1" as any, - memberId: "member-1" as any, + channelId, + memberId, }), ) @@ -117,8 +121,8 @@ describe("useTyping", () => { it("auto-stops after typing timeout and deletes indicator", async () => { const { result } = renderHook(() => useTyping({ - channelId: "channel-1" as any, - memberId: "member-1" as any, + channelId, + memberId, typingTimeout: 3000, }), ) @@ -140,15 +144,15 @@ describe("useTyping", () => { it("cleans up previous indicator when channel/member context changes", async () => { const { result, rerender } = renderHook( - (props: { channelId: string; memberId: string }) => + (props: { channelId: ChannelId; memberId: ChannelMemberId }) => useTyping({ - channelId: props.channelId as any, - memberId: props.memberId as any, + channelId: props.channelId, + memberId: props.memberId, }), { initialProps: { - channelId: "channel-1", - memberId: "member-1", + channelId, + memberId, }, }, ) @@ -159,8 +163,8 @@ describe("useTyping", () => { await flushMicrotasks() rerender({ - channelId: "channel-2", - memberId: "member-2", + channelId: "00000000-0000-4000-8000-000000000303" as ChannelId, + memberId: "00000000-0000-4000-8000-000000000304" as ChannelMemberId, }) await flushMicrotasks() diff --git a/apps/web/src/hooks/use-typing.ts b/apps/web/src/hooks/use-typing.ts index f76dec61d..2e55a32cd 100644 --- a/apps/web/src/hooks/use-typing.ts +++ b/apps/web/src/hooks/use-typing.ts @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { ChannelId, ChannelMemberId, TypingIndicatorId } from "@hazel/schema" import { Exit } from "effect" import { useCallback, useEffect, useRef, useState } from "react" diff --git a/apps/web/src/hooks/use-upload.ts b/apps/web/src/hooks/use-upload.ts index ec470644d..6e3fe68cc 100644 --- a/apps/web/src/hooks/use-upload.ts +++ b/apps/web/src/hooks/use-upload.ts @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { AttachmentUploadRequest, BotAvatarUploadRequest, diff --git a/apps/web/src/hooks/use-visible-message-notification-cleaner.ts b/apps/web/src/hooks/use-visible-message-notification-cleaner.ts index b051dba3a..8ce3bd8f9 100644 --- a/apps/web/src/hooks/use-visible-message-notification-cleaner.ts +++ b/apps/web/src/hooks/use-visible-message-notification-cleaner.ts @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { ChannelId, MessageId } from "@hazel/schema" import { and, eq, useLiveQuery } from "@tanstack/react-db" import { useCallback, useEffect, useMemo, useRef } from "react" diff --git a/apps/web/src/lib/auth-errors.ts b/apps/web/src/lib/auth-errors.ts new file mode 100644 index 000000000..50b721a71 --- /dev/null +++ b/apps/web/src/lib/auth-errors.ts @@ -0,0 +1,63 @@ +import { + MissingAuthCodeError, + OAuthCallbackError, + OAuthCodeExpiredError, + OAuthRedemptionPendingError, + OAuthStateMismatchError, + TokenDecodeError, + TokenExchangeError, +} from "@hazel/domain/errors" + +export type WebAuthError = + | OAuthCallbackError + | MissingAuthCodeError + | OAuthCodeExpiredError + | OAuthStateMismatchError + | OAuthRedemptionPendingError + | TokenExchangeError + | TokenDecodeError + +export type WebAuthErrorInfo = { + message: string + isRetryable: boolean +} + +export const getWebAuthErrorInfo = (error: WebAuthError): WebAuthErrorInfo => { + switch (error._tag) { + case "OAuthCallbackError": + return { + message: error.errorDescription || error.error, + isRetryable: true, + } + case "MissingAuthCodeError": + return { + message: "We did not receive a valid login callback. Please try again.", + isRetryable: true, + } + case "OAuthCodeExpiredError": + return { + message: "This login code is no longer valid. Please start login again.", + isRetryable: false, + } + case "OAuthStateMismatchError": + return { + message: "This login callback did not match the active session. Please start again.", + isRetryable: false, + } + case "OAuthRedemptionPendingError": + return { + message: "Login is still finishing in another request. Please try again in a moment.", + isRetryable: true, + } + case "TokenDecodeError": + return { + message: "The server returned an invalid auth response. Please try again.", + isRetryable: true, + } + case "TokenExchangeError": + return { + message: error.message || "Failed to exchange authorization code.", + isRetryable: true, + } + } +} diff --git a/apps/web/src/lib/auth-fetch.ts b/apps/web/src/lib/auth-fetch.ts index 32803323e..f34564461 100644 --- a/apps/web/src/lib/auth-fetch.ts +++ b/apps/web/src/lib/auth-fetch.ts @@ -15,19 +15,25 @@ import { WebTokenStorage } from "./services/web/token-storage" import { runtime } from "./services/common/runtime" import { isTauri } from "./tauri" -const DesktopTokenStorageLive = TokenStorage.Default -const WebTokenStorageLive = WebTokenStorage.Default +const DesktopTokenStorageLive = TokenStorage.layer +const WebTokenStorageLive = WebTokenStorage.layer /** * Clear tokens from appropriate storage (desktop or web) */ const clearTokens = async (): Promise => { const effect = isTauri() - ? TokenStorage.clearTokens.pipe(Effect.provide(DesktopTokenStorageLive)) - : WebTokenStorage.clearTokens.pipe(Effect.provide(WebTokenStorageLive)) + ? Effect.gen(function* () { + const storage = yield* TokenStorage + yield* storage.clearTokens + }).pipe(Effect.provide(DesktopTokenStorageLive)) + : Effect.gen(function* () { + const storage = yield* WebTokenStorage + yield* storage.clearTokens + }).pipe(Effect.provide(WebTokenStorageLive)) return runtime.runPromise( effect.pipe( - Effect.catchAll(() => Effect.void), + Effect.catch(() => Effect.void), Effect.withSpan("clearTokens"), ), ) diff --git a/apps/web/src/lib/auth-token.ts b/apps/web/src/lib/auth-token.ts index c31556f2e..b0e9b2500 100644 --- a/apps/web/src/lib/auth-token.ts +++ b/apps/web/src/lib/auth-token.ts @@ -5,7 +5,7 @@ * React/async consumers use the exported Promise wrappers. */ -import { Deferred, Duration, Effect, Option, Ref } from "effect" +import { Deferred, Duration, Effect, Option, Ref, type ServiceMap } from "effect" import { appRegistry } from "~/lib/registry" import { webTokensAtom, webAuthErrorAtom } from "~/atoms/web-auth" import { desktopTokensAtom, desktopAuthErrorAtom } from "~/atoms/desktop-auth" @@ -26,26 +26,36 @@ const BASE_BACKOFF_MS = 1000 // 1s, 2s, 4s // Shared Refs (prevents concurrent refreshes across all callers) // ============================================================================ -const isRefreshingRef = Ref.unsafeMake(false) -const refreshDeferredRef = Ref.unsafeMake | null>(null) +const isRefreshingRef = Effect.runSync(Ref.make(false)) +const refreshDeferredRef = Effect.runSync(Ref.make | null>(null)) // ============================================================================ // Platform-specific layers // ============================================================================ -const webStorageLive = WebTokenStorage.Default -const desktopStorageLive = TokenStorage.Default -const tokenExchangeLive = TokenExchange.Default +const webStorageLive = WebTokenStorage.layer +const desktopStorageLive = TokenStorage.layer +const tokenExchangeLive = TokenExchange.layer + +type TokenExchangeService = ServiceMap.Service.Shape +type WebTokenStorageService = ServiceMap.Service.Shape +type DesktopTokenStorageService = ServiceMap.Service.Shape // ============================================================================ // Error Classification // ============================================================================ +interface ErrorLike { + _tag?: string + message?: string + detail?: string +} + /** * Check if an error is a fatal error (refresh token revoked/invalid) * Fatal errors should not be retried */ -export const isFatalRefreshError = (error: { _tag?: string; message?: string; detail?: string }): boolean => { +export const isFatalRefreshError = (error: ErrorLike): boolean => { if (error.detail?.includes("HTTP 401")) return true if (error.detail?.includes("HTTP 403")) return true return false @@ -54,14 +64,14 @@ export const isFatalRefreshError = (error: { _tag?: string; message?: string; de /** * Check if an error is transient (timeout, network) and can be retried */ -export const isTransientError = (error: { _tag?: string; message?: string }): boolean => { +export const isTransientError = (error: ErrorLike): boolean => { const message = error.message?.toLowerCase() ?? "" return ( message.includes("timed out") || message.includes("timeout") || message.includes("network error") || - error._tag === "TimeoutException" || - error._tag === "RequestError" + error._tag === "TimeoutError" || + error._tag === "HttpClientError" ) } @@ -74,39 +84,49 @@ const errorAtom = () => (isTauri() ? desktopAuthErrorAtom : webAuthErrorAtom) const platformTag = () => (isTauri() ? "desktop" : "web") /** Read access token from the correct platform storage */ -const readAccessToken = Effect.fn("readAccessToken")(function* () { - if (isTauri()) { - return Option.getOrNull(yield* TokenStorage.getAccessToken.pipe(Effect.provide(desktopStorageLive))) - } - return Option.getOrNull(yield* WebTokenStorage.getAccessToken.pipe(Effect.provide(webStorageLive))) -}) +const readAccessTokenDesktop = Effect.gen(function* () { + const storage: DesktopTokenStorageService = yield* TokenStorage + return Option.getOrNull(yield* storage.getAccessToken) +}).pipe(Effect.provide(desktopStorageLive)) + +const readAccessTokenWeb = Effect.gen(function* () { + const storage: WebTokenStorageService = yield* WebTokenStorage + return Option.getOrNull(yield* storage.getAccessToken) +}).pipe(Effect.provide(webStorageLive)) + +const readAccessToken = Effect.suspend(() => (isTauri() ? readAccessTokenDesktop : readAccessTokenWeb)).pipe( + Effect.catch(() => Effect.succeed(null)), +) /** Read refresh token from the correct platform storage */ -const readRefreshToken = Effect.fn("readRefreshToken")(function* () { - if (isTauri()) { - return yield* TokenStorage.getRefreshToken.pipe(Effect.provide(desktopStorageLive)) - } - return yield* WebTokenStorage.getRefreshToken.pipe(Effect.provide(webStorageLive)) -}) +const readRefreshTokenDesktop = Effect.gen(function* () { + const storage: DesktopTokenStorageService = yield* TokenStorage + return yield* storage.getRefreshToken +}).pipe(Effect.provide(desktopStorageLive)) + +const readRefreshTokenWeb = Effect.gen(function* () { + const storage: WebTokenStorageService = yield* WebTokenStorage + return yield* storage.getRefreshToken +}).pipe(Effect.provide(webStorageLive)) + +const readRefreshToken = Effect.suspend(() => + isTauri() ? readRefreshTokenDesktop : readRefreshTokenWeb, +).pipe(Effect.catch(() => Effect.succeed(Option.none()))) /** Store tokens in the correct platform storage */ -const storeTokens = Effect.fn("storeTokens")(function* ( - accessToken: string, - refreshToken: string, - expiresIn: number, -) { - if (isTauri()) { - yield* TokenStorage.storeTokens(accessToken, refreshToken, expiresIn).pipe( - Effect.provide(desktopStorageLive), - Effect.orDie, - ) - } else { - yield* WebTokenStorage.storeTokens(accessToken, refreshToken, expiresIn).pipe( - Effect.provide(webStorageLive), - Effect.orDie, - ) - } -}) +const storeTokens = (accessToken: string, refreshToken: string, expiresIn: number) => + Effect.suspend(() => { + if (isTauri()) { + return Effect.gen(function* () { + const storage: DesktopTokenStorageService = yield* TokenStorage + yield* storage.storeTokens(accessToken, refreshToken, expiresIn) + }).pipe(Effect.provide(desktopStorageLive)) + } + return Effect.gen(function* () { + const storage: WebTokenStorageService = yield* WebTokenStorage + yield* storage.storeTokens(accessToken, refreshToken, expiresIn) + }).pipe(Effect.provide(webStorageLive)) + }).pipe(Effect.orDie) // ============================================================================ // Core Effects @@ -116,10 +136,7 @@ const storeTokens = Effect.fn("storeTokens")(function* ( * Get the current access token from the appropriate platform storage. * Returns null if not authenticated. */ -const getAccessTokenEffect: Effect.Effect = readAccessToken().pipe( - Effect.catchAll(() => Effect.succeed(null)), - Effect.withSpan("getAccessToken"), -) +const getAccessTokenEffect = readAccessToken.pipe(Effect.withSpan("getAccessToken")) /** * Wait for any in-progress token refresh to complete. @@ -133,7 +150,7 @@ const waitForRefreshEffect: Effect.Effect = Effect.gen(function* () { } return true }).pipe( - Effect.catchAll(() => Effect.succeed(true)), + Effect.catch(() => Effect.succeed(true)), Effect.withSpan("waitForRefresh"), ) @@ -156,8 +173,8 @@ const forceRefreshEffect: Effect.Effect = Effect.gen(function* () { } // Get refresh token to check if we can refresh - const refreshTokenOpt = yield* readRefreshToken().pipe( - Effect.catchAll(() => Effect.succeed(Option.none())), + const refreshTokenOpt = yield* readRefreshToken.pipe( + Effect.catch(() => Effect.succeed(Option.none())), ) if (Option.isNone(refreshTokenOpt)) { @@ -173,14 +190,22 @@ const forceRefreshEffect: Effect.Effect = Effect.gen(function* () { const resultRef = yield* Ref.make(false) + type RefreshTokens = { accessToken: string; refreshToken: string; expiresIn: number } + type RefreshResult = { success: true; tokens: RefreshTokens } | { success: false; error: ErrorLike } + const attemptRefresh = (attempt: number): Effect.Effect => Effect.gen(function* () { - const tokenExchange = yield* TokenExchange - - const refreshResult = yield* tokenExchange.refreshToken(refreshTokenOpt.value).pipe( - Effect.map((tokens) => ({ success: true as const, tokens })), - Effect.catchAll((error) => Effect.succeed({ success: false as const, error })), - ) + const tokenExchange: TokenExchangeService = yield* TokenExchange + + const refreshResult: RefreshResult = yield* tokenExchange + .refreshToken(refreshTokenOpt.value) + .pipe( + Effect.map((tokens): RefreshResult => ({ success: true, tokens })), + Effect.catch( + (error): Effect.Effect => + Effect.succeed({ success: false, error: error as ErrorLike }), + ), + ) if (refreshResult.success) { const { tokens } = refreshResult @@ -247,7 +272,7 @@ const forceRefreshEffect: Effect.Effect = Effect.gen(function* () { yield* attemptRefresh(1).pipe( Effect.provide(tokenExchangeLive), Effect.tap((result) => Ref.set(resultRef, result)), - Effect.catchAll((error) => { + Effect.catch((error) => { console.error(`[auth-token:${tag}] Unexpected error during refresh:`, error) return Effect.void }), @@ -263,7 +288,7 @@ const forceRefreshEffect: Effect.Effect = Effect.gen(function* () { return yield* Ref.get(resultRef) }).pipe( - Effect.catchAll(() => Effect.succeed(false)), + Effect.catch(() => Effect.succeed(false)), Effect.withSpan("forceRefresh"), ) diff --git a/apps/web/src/lib/auth.tsx b/apps/web/src/lib/auth.tsx index a564537a5..c22950924 100644 --- a/apps/web/src/lib/auth.tsx +++ b/apps/web/src/lib/auth.tsx @@ -1,4 +1,5 @@ -import { Atom, Result, useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { Atom, AsyncResult } from "effect/unstable/reactivity" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import type { OrganizationId } from "@hazel/schema" import { desktopInitAtom, @@ -138,8 +139,8 @@ export function useAuth() { } return { - user: Result.getOrElse(userResult, () => null), - error: Result.error(userResult), + user: AsyncResult.getOrElse(userResult, () => null), + error: AsyncResult.error(userResult), isLoading, login, logout, diff --git a/apps/web/src/lib/channels.ts b/apps/web/src/lib/channels.ts index 73f968454..8d9669ac0 100644 --- a/apps/web/src/lib/channels.ts +++ b/apps/web/src/lib/channels.ts @@ -31,7 +31,7 @@ export function findExistingDmChannel( currentUserId: UserId, targetUserIds: UserId[], organizationId: OrganizationId, -): typeof Channel.Model.Type | null { +): Channel.Type | null { const channels = dmChannelsCollection.toArray if (!channels || channels.length === 0 || targetUserIds.length === 0) { @@ -41,7 +41,7 @@ export function findExistingDmChannel( const allParticipants = [currentUserId, ...targetUserIds] // Group channels by channel ID to get all members per channel - const channelMembersMap = new Map() + const channelMembersMap = new Map() for (const item of channels) { // Skip channels from other organizations diff --git a/apps/web/src/lib/connect-shared-channels.ts b/apps/web/src/lib/connect-shared-channels.ts index 23f0e09f7..10d484ed5 100644 --- a/apps/web/src/lib/connect-shared-channels.ts +++ b/apps/web/src/lib/connect-shared-channels.ts @@ -1,10 +1,11 @@ import type { ChannelId, ConnectConversationId } from "@hazel/schema" +import type { DateTime } from "effect" type ConnectMountLike = { channelId: ChannelId conversationId: ConnectConversationId isActive: boolean - deletedAt: Date | null + deletedAt: Date | DateTime.Utc | null } const isActiveMount = (mount: ConnectMountLike) => mount.isActive && mount.deletedAt === null diff --git a/apps/web/src/lib/electric-fetch.ts b/apps/web/src/lib/electric-fetch.ts index 5110f74fc..cdea28f7b 100644 --- a/apps/web/src/lib/electric-fetch.ts +++ b/apps/web/src/lib/electric-fetch.ts @@ -33,7 +33,7 @@ const hasAuthToken = (): boolean => { const retrySchedule = Schedule.exponential("2 seconds").pipe( Schedule.jittered, Schedule.either(Schedule.spaced("60 seconds")), - Schedule.upTo(8), + Schedule.compose(Schedule.recurs(8)), ) /** @@ -43,7 +43,7 @@ const shouldRetry = (response: Response): boolean => response.status >= 500 && r /** * Electric fetch client with exponential backoff retry for server errors. - * - Retries 5xx errors with exponential backoff (2s → 4s → 8s... up to 60s) + * - Retries 5xx errors with exponential backoff (2s -> 4s -> 8s... up to 60s) * - Does NOT retry 4xx client errors (auth, validation, etc.) * - Uses jitter to prevent thundering herd */ @@ -76,8 +76,8 @@ export const electricFetchClient = async ( while: (error) => error instanceof Response && shouldRetry(error), }), // If all retries exhausted, return the last failed response - Effect.catchAll((error) => (error instanceof Response ? Effect.succeed(error) : Effect.fail(error))), + Effect.catch((error) => (error instanceof Response ? Effect.succeed(error) : Effect.fail(error))), ) - return runtime.runPromise(withRetry) + return runtime.runPromise(withRetry) as Promise } diff --git a/apps/web/src/lib/error-messages.ts b/apps/web/src/lib/error-messages.ts index 84b5466a3..19635e117 100644 --- a/apps/web/src/lib/error-messages.ts +++ b/apps/web/src/lib/error-messages.ts @@ -1,5 +1,5 @@ -import type { HttpClientError } from "@effect/platform/HttpClientError" -import { RpcClientError } from "@effect/rpc/RpcClientError" +import { HttpClientError } from "effect/unstable/http" +import { RpcClientError } from "effect/unstable/rpc" import { AIProviderUnavailableError, AIRateLimitError, @@ -32,8 +32,7 @@ import { WorkflowServiceUnavailableError, WorkOSUserFetchError, } from "@hazel/domain/errors" -import { Cause, Chunk, Match, Option, Schema } from "effect" -import type { ParseError } from "effect/ParseResult" +import { Cause, Match, Schema } from "effect" import { CollectionInErrorEffectError, CollectionSyncEffectError, @@ -60,7 +59,7 @@ export interface UserErrorMessage { * Schema union for Schema-based common errors. * Used for type-safe error matching and runtime validation. */ -export const CommonAppErrorSchema = Schema.Union( +export const CommonAppErrorSchema = Schema.Union([ // Auth errors (401) UnauthorizedError, SessionNotProvidedError, @@ -80,7 +79,6 @@ export const CommonAppErrorSchema = Schema.Union( // Infrastructure errors (appear in most RPC calls) OptimisticActionError, SyncError, - RpcClientError, // TanStack DB errors (permanent - non-retryable) DuplicateKeyEffectError, KeyUpdateNotAllowedEffectError, @@ -111,7 +109,7 @@ export const CommonAppErrorSchema = Schema.Union( AIRateLimitError, AIResponseParseError, ThreadNameUpdateError, -) +]) /** * Union of common application errors that have user-friendly messages. @@ -124,8 +122,8 @@ export const CommonAppErrorSchema = Schema.Union( export type CommonAppError = | typeof CommonAppErrorSchema.Type // Non-Schema errors (still have _tag but not Schema.TaggedError) - | ParseError - | HttpClientError + | Schema.SchemaError + | HttpClientError.HttpClientError /** * Static error messages for errors that don't need dynamic content @@ -450,7 +448,7 @@ const isSchemaCommonError = Schema.is(CommonAppErrorSchema) /** * Tags for non-Schema errors that are still common */ -const NON_SCHEMA_COMMON_TAGS = new Set(["ParseError", "RequestError", "ResponseError"]) +const NON_SCHEMA_COMMON_TAGS = new Set(["ParseError", "HttpClientError", "RpcClientError"]) /** * Type guard for CommonAppError. @@ -475,7 +473,7 @@ function isNetworkError(error: unknown): boolean { if (typeof error !== "object" || error === null) return false // Effect HttpClientError.RequestError with Transport reason - if ("_tag" in error && error._tag === "RequestError" && "reason" in error) { + if ("_tag" in error && error._tag === "HttpClientError" && "reason" in error) { return (error as { reason: string }).reason === "Transport" } @@ -493,7 +491,7 @@ function isNetworkError(error: unknown): boolean { function isTimeoutError(error: unknown): boolean { if (typeof error !== "object" || error === null) return false if ("_tag" in error) { - return (error as { _tag: string })._tag === "TimeoutException" + return (error as { _tag: string })._tag === "TimeoutError" } return false } @@ -503,11 +501,10 @@ function isTimeoutError(error: unknown): boolean { * Uses type-safe Match for common errors, falls back to message extraction. */ export function getUserFriendlyError(cause: Cause.Cause): UserErrorMessage { - const failures = Cause.failures(cause) - const firstFailureOption = Chunk.head(failures) + const firstFailure = cause.reasons.find(Cause.isFailReason)?.error - if (Option.isSome(firstFailureOption)) { - const error = firstFailureOption.value + if (firstFailure !== undefined) { + const error = firstFailure // Check for network errors first if (isNetworkError(error)) { @@ -542,11 +539,10 @@ export function getUserFriendlyError(cause: Cause.Cause): UserErrorMessage } // Check defects (unexpected errors) - const defects = Cause.defects(cause) - const firstDefectOption = Chunk.head(defects) + const firstDefect = cause.reasons.find(Cause.isDieReason)?.defect - if (Option.isSome(firstDefectOption)) { - const defect = firstDefectOption.value + if (firstDefect !== undefined) { + const defect = firstDefect if (defect instanceof Error) { return { title: defect.message, isRetryable: false } } diff --git a/apps/web/src/lib/native-notifications.ts b/apps/web/src/lib/native-notifications.ts index c66c53159..65f5d6d61 100644 --- a/apps/web/src/lib/native-notifications.ts +++ b/apps/web/src/lib/native-notifications.ts @@ -192,7 +192,7 @@ export function getMessagePreview(content: string | null | undefined, maxLength /** * Format author display name */ -export function formatAuthorName(user: typeof User.Model.Type | undefined): string { +export function formatAuthorName(user: User.Type | undefined): string { if (!user) return "Someone" return `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() || "Someone" } @@ -203,8 +203,8 @@ export function formatAuthorName(user: typeof User.Model.Type | undefined): stri * - Channel/thread: Author + channel ("John Doe in #general") */ export function formatNotificationTitle( - author: typeof User.Model.Type | undefined, - channel: typeof Channel.Model.Type | undefined, + author: User.Type | undefined, + channel: Channel.Type | undefined, ): string { const authorName = formatAuthorName(author) @@ -221,9 +221,9 @@ export function formatNotificationTitle( * Build complete notification content from message, author, and channel data */ export function buildNotificationContent( - message: typeof Message.Model.Type | undefined, - author: typeof User.Model.Type | undefined, - channel: typeof Channel.Model.Type | undefined, + message: Message.Type | undefined, + author: User.Type | undefined, + channel: Channel.Type | undefined, ): NativeNotificationOptions { const title = formatNotificationTitle(author, channel) const body = getMessagePreview(message?.content) diff --git a/apps/web/src/lib/notifications/orchestrator.ts b/apps/web/src/lib/notifications/orchestrator.ts index 040b545bc..d68cf6868 100644 --- a/apps/web/src/lib/notifications/orchestrator.ts +++ b/apps/web/src/lib/notifications/orchestrator.ts @@ -1,3 +1,4 @@ +import { toEpochMs } from "~/lib/utils" import { pushNotificationDiagnostics } from "./diagnostics-store" import type { NotificationDecision, @@ -37,8 +38,8 @@ export class NotificationOrchestrator { } this.queue.sort((a, b) => { - const aTime = a.notification.createdAt.getTime() - const bTime = b.notification.createdAt.getTime() + const aTime = toEpochMs(a.notification.createdAt) + const bTime = toEpochMs(b.notification.createdAt) if (aTime !== bTime) return aTime - bTime return a.id.localeCompare(b.id) }) diff --git a/apps/web/src/lib/notifications/selectors.ts b/apps/web/src/lib/notifications/selectors.ts index 1a082199e..6acc8858a 100644 --- a/apps/web/src/lib/notifications/selectors.ts +++ b/apps/web/src/lib/notifications/selectors.ts @@ -5,7 +5,7 @@ import { useMemo } from "react" import { notificationCollection } from "~/db/collections" export type NotificationLike = Pick< - typeof Notification.Model.Type, + Notification.Type, "id" | "readAt" | "targetedResourceId" | "targetedResourceType" > diff --git a/apps/web/src/lib/notifications/types.ts b/apps/web/src/lib/notifications/types.ts index 243c039df..220930259 100644 --- a/apps/web/src/lib/notifications/types.ts +++ b/apps/web/src/lib/notifications/types.ts @@ -26,10 +26,10 @@ export interface NotificationSinkResult { export interface NotificationEvent { id: string - notification: typeof Notification.Model.Type - message?: typeof Message.Model.Type - author?: typeof User.Model.Type - channel?: typeof Channel.Model.Type + notification: Notification.Type + message?: Message.Type + author?: User.Type + channel?: Channel.Type receivedAt: number } diff --git a/apps/web/src/lib/platform-storage/platform-key-value-store.ts b/apps/web/src/lib/platform-storage/platform-key-value-store.ts index d2f2185c5..03c0c5902 100644 --- a/apps/web/src/lib/platform-storage/platform-key-value-store.ts +++ b/apps/web/src/lib/platform-storage/platform-key-value-store.ts @@ -7,7 +7,7 @@ */ import { BrowserKeyValueStore } from "@effect/platform-browser" -import type * as KeyValueStore from "@effect/platform/KeyValueStore" +import type * as KeyValueStore from "effect/unstable/persistence/KeyValueStore" import type { Layer } from "effect" import { isTauri } from "~/lib/tauri" import { layerTauriStore } from "./tauri-key-value-store" diff --git a/apps/web/src/lib/platform-storage/platform-runtime.ts b/apps/web/src/lib/platform-storage/platform-runtime.ts index dc38e48c0..11ab94515 100644 --- a/apps/web/src/lib/platform-storage/platform-runtime.ts +++ b/apps/web/src/lib/platform-storage/platform-runtime.ts @@ -18,7 +18,7 @@ * // Then use platformStorageRuntime in Atom.kvs */ -import { Atom } from "@effect-atom/atom-react" +import { Atom } from "effect/unstable/reactivity" import { layer } from "./platform-key-value-store" -export const platformStorageRuntime = Atom.runtime(layer) +export const platformStorageRuntime = Atom.runtime(layer) as Atom.AtomRuntime diff --git a/apps/web/src/lib/platform-storage/tauri-key-value-store.ts b/apps/web/src/lib/platform-storage/tauri-key-value-store.ts index 3ff031503..25ba4bf24 100644 --- a/apps/web/src/lib/platform-storage/tauri-key-value-store.ts +++ b/apps/web/src/lib/platform-storage/tauri-key-value-store.ts @@ -7,10 +7,10 @@ * Store file: settings.json (separate from auth.json used for tokens) */ -import * as KeyValueStore from "@effect/platform/KeyValueStore" -import { SystemError } from "@effect/platform/Error" +import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore" +import { KeyValueStoreError as SystemError } from "effect/unstable/persistence/KeyValueStore" import { getTauriStore, type TauriStoreApi } from "@hazel/desktop/bridge" -import { Effect, Layer, Option } from "effect" +import { Effect, Layer } from "effect" const STORE_NAME = "settings.json" @@ -25,11 +25,10 @@ const loadStore: Effect.Effect = Effect.tryPromise({ }, catch: (error) => new SystemError({ - reason: "Unknown", - module: "KeyValueStore", + message: `Failed to load Tauri store: ${error}`, method: "getStore", - pathOrDescriptor: STORE_NAME, - description: `Failed to load Tauri store: ${error}`, + key: STORE_NAME, + cause: error, }), }) @@ -38,11 +37,10 @@ const getStore = Effect.cached(loadStore) const makeError = (method: string, key: string, error: unknown) => new SystemError({ - reason: "Unknown", - module: "KeyValueStore", + message: `Tauri store ${method} failed: ${error}`, method, - pathOrDescriptor: key, - description: `Tauri store ${method} failed: ${error}`, + key, + cause: error, }) /** @@ -57,10 +55,7 @@ export const layerTauriStore: Layer.Layer = Layer.e return KeyValueStore.makeStringOnly({ get: (key: string) => Effect.tryPromise({ - try: async () => { - const value = await store.get(key) - return Option.fromNullable(value) - }, + try: async () => await store.get(key), catch: (error) => makeError("get", key, error), }), diff --git a/apps/web/src/lib/registry.ts b/apps/web/src/lib/registry.ts index 0f0eb350b..59d51036a 100644 --- a/apps/web/src/lib/registry.ts +++ b/apps/web/src/lib/registry.ts @@ -1,7 +1,8 @@ -import { Atom, Registry, scheduleTask } from "@effect-atom/atom-react" +import { scheduleTask } from "@effect/atom-react" +import { Atom, AtomRegistry } from "effect/unstable/reactivity" import { runtimeLayer } from "./services/common/runtime" -export const appRegistry = Registry.make({ scheduleTask }) +export const appRegistry = AtomRegistry.make({ scheduleTask }) const sharedAtomRuntime = Atom.runtime(runtimeLayer) diff --git a/apps/web/src/lib/rpc-auth-middleware.ts b/apps/web/src/lib/rpc-auth-middleware.ts index 99a4b8d16..12da4f46c 100644 --- a/apps/web/src/lib/rpc-auth-middleware.ts +++ b/apps/web/src/lib/rpc-auth-middleware.ts @@ -4,13 +4,13 @@ * @description Client-side auth middleware that adds Bearer token from storage (Tauri store or localStorage) */ -import { Headers } from "@effect/platform" -import { RpcMiddleware } from "@effect/rpc" +import { Headers } from "effect/unstable/http" +import { RpcMiddleware } from "effect/unstable/rpc" import { AuthMiddleware } from "@hazel/domain/rpc" import { Effect } from "effect" import { waitForRefreshEffect, getAccessTokenEffect } from "~/lib/auth-token" -export const AuthMiddlewareClientLive = RpcMiddleware.layerClient(AuthMiddleware, ({ request }) => +export const AuthMiddlewareClientLive = RpcMiddleware.layerClient(AuthMiddleware, ({ request, next }) => Effect.gen(function* () { yield* waitForRefreshEffect @@ -18,9 +18,9 @@ export const AuthMiddlewareClientLive = RpcMiddleware.layerClient(AuthMiddleware if (token) { const newHeaders = Headers.set(request.headers, "authorization", `Bearer ${token}`) - return { ...request, headers: newHeaders } + return yield* next({ ...request, headers: newHeaders }) } - return request + return yield* next(request) }), ) diff --git a/apps/web/src/lib/services/common/api-client.ts b/apps/web/src/lib/services/common/api-client.ts index 88bf00884..b8a769bc3 100644 --- a/apps/web/src/lib/services/common/api-client.ts +++ b/apps/web/src/lib/services/common/api-client.ts @@ -4,11 +4,10 @@ * @description HTTP client that uses Bearer tokens for desktop and cookies for web */ -import * as FetchHttpClient from "@effect/platform/FetchHttpClient" -import * as HttpApiClient from "@effect/platform/HttpApiClient" -import * as HttpClient from "@effect/platform/HttpClient" +import { FetchHttpClient, HttpClient, HttpClientError } from "effect/unstable/http" +import { HttpApiClient } from "effect/unstable/httpapi" import { HazelApi } from "@hazel/domain/http" -import { Layer } from "effect" +import { ServiceMap, Layer } from "effect" import * as Effect from "effect/Effect" import { authenticatedFetch } from "../../auth-fetch" @@ -16,10 +15,8 @@ export const CustomFetchLive = FetchHttpClient.layer.pipe( Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, authenticatedFetch)), ) -export class ApiClient extends Effect.Service()("ApiClient", { - accessors: true, - dependencies: [CustomFetchLive], - effect: Effect.gen(function* () { +export class ApiClient extends ServiceMap.Service()("ApiClient", { + make: Effect.gen(function* () { return yield* HttpApiClient.make(HazelApi, { baseUrl: import.meta.env.VITE_BACKEND_URL, transformClient: (client) => @@ -28,17 +25,16 @@ export class ApiClient extends Effect.Service()("ApiClient", { times: 3, // Only retry server errors (5xx), not client errors (4xx) like 401/403 while: (error) => { - if (error._tag === "ResponseError") { - const status = error.response.status - // Only retry server errors (500-599) and network errors - // Don't retry client errors (400-499) including auth errors - return status >= 500 && status < 600 + if (HttpClientError.isHttpClientError(error)) { + const status = error.response?.status + return status === undefined || (status >= 500 && status < 600) } - // Retry other transient errors (network issues, etc.) - return error._tag === "RequestError" + return false }, }), ), }) }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(CustomFetchLive)) +} diff --git a/apps/web/src/lib/services/common/atom-client.ts b/apps/web/src/lib/services/common/atom-client.ts index 19c97bbc8..87d8ce1ef 100644 --- a/apps/web/src/lib/services/common/atom-client.ts +++ b/apps/web/src/lib/services/common/atom-client.ts @@ -1,8 +1,8 @@ -import { AtomHttpApi } from "@effect-atom/atom-react" +import { AtomHttpApi } from "effect/unstable/reactivity" import { HazelApi } from "@hazel/domain/http" import { CustomFetchLive } from "./api-client" -export class HazelApiClient extends AtomHttpApi.Tag()("HazelApiClient", { +export class HazelApiClient extends AtomHttpApi.Service()("HazelApiClient", { api: HazelApi, httpClient: CustomFetchLive, baseUrl: import.meta.env.VITE_BACKEND_URL || "http://localhost:3003", diff --git a/apps/web/src/lib/services/common/link-preview-client.ts b/apps/web/src/lib/services/common/link-preview-client.ts index b8187bbd9..e5f92e60b 100644 --- a/apps/web/src/lib/services/common/link-preview-client.ts +++ b/apps/web/src/lib/services/common/link-preview-client.ts @@ -1,9 +1,9 @@ -import { FetchHttpClient } from "@effect/platform" -import { AtomHttpApi } from "@effect-atom/atom-react" +import { FetchHttpClient } from "effect/unstable/http" +import { AtomHttpApi } from "effect/unstable/reactivity" import { LinkPreviewApi } from "@hazel/link-preview-worker" -export class LinkPreviewClient extends AtomHttpApi.Tag()("LinkPreviewClient", { +export class LinkPreviewClient extends AtomHttpApi.Service()("LinkPreviewClient", { api: LinkPreviewApi, httpClient: FetchHttpClient.layer, baseUrl: "https://link-preview.hazel.sh", diff --git a/apps/web/src/lib/services/common/network-mode.ts b/apps/web/src/lib/services/common/network-mode.ts index 6d3ab1cc1..d816a19fc 100644 --- a/apps/web/src/lib/services/common/network-mode.ts +++ b/apps/web/src/lib/services/common/network-mode.ts @@ -1,22 +1,40 @@ -import * as Chunk from "effect/Chunk" import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Latch from "effect/Latch" +import * as Queue from "effect/Queue" +import * as ServiceMap from "effect/ServiceMap" import * as Stream from "effect/Stream" import * as SubscriptionRef from "effect/SubscriptionRef" -export class NetworkMonitor extends Effect.Service()("NetworkMonitor", { - scoped: Effect.gen(function* () { - const latch = yield* Effect.makeLatch(true) +export class NetworkMonitor extends ServiceMap.Service()("NetworkMonitor", { + make: Effect.gen(function* () { + const latch = yield* Latch.make(true) const ref = yield* SubscriptionRef.make(window.navigator.onLine) - yield* Stream.async((emit) => { - const onlineHandler = () => emit(Effect.succeed(Chunk.of(true))) - const offlineHandler = () => emit(Effect.succeed(Chunk.of(false))) - window.addEventListener("online", onlineHandler) - window.addEventListener("offline", offlineHandler) - }).pipe( + yield* Stream.callback((queue) => + Effect.gen(function* () { + const onlineHandler = () => { + Effect.runFork(Queue.offer(queue, true)) + } + const offlineHandler = () => { + Effect.runFork(Queue.offer(queue, false)) + } + window.addEventListener("online", onlineHandler) + window.addEventListener("offline", offlineHandler) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + window.removeEventListener("online", onlineHandler) + window.removeEventListener("offline", offlineHandler) + }), + ) + // Keep scope alive + yield* Effect.never + }), + ).pipe( Stream.tap((isOnline) => - (isOnline ? latch.open : latch.close).pipe( - Effect.zipRight(SubscriptionRef.update(ref, () => isOnline)), + Effect.andThen( + isOnline ? latch.open : latch.close, + SubscriptionRef.update(ref, () => isOnline), ), ), Stream.runDrain, @@ -25,5 +43,6 @@ export class NetworkMonitor extends Effect.Service()("NetworkMon return { latch, ref } }), - accessors: true, -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/apps/web/src/lib/services/common/rpc-atom-client.ts b/apps/web/src/lib/services/common/rpc-atom-client.ts index d2b06bd46..c3d3f712e 100644 --- a/apps/web/src/lib/services/common/rpc-atom-client.ts +++ b/apps/web/src/lib/services/common/rpc-atom-client.ts @@ -1,7 +1,7 @@ -import { Reactivity } from "@effect/experimental" -import { FetchHttpClient } from "@effect/platform" -import { RpcClient as RpcClientBuilder, RpcSerialization } from "@effect/rpc" -import { AtomRpc } from "@effect-atom/atom-react" +import { Reactivity } from "effect/unstable/reactivity" +import { FetchHttpClient } from "effect/unstable/http" +import { RpcClient as RpcClientBuilder, RpcSerialization } from "effect/unstable/rpc" +import { AtomRpc } from "effect/unstable/reactivity" import { AuthMiddlewareClientLive } from "~/lib/rpc-auth-middleware" import { AttachmentRpcs, @@ -28,11 +28,6 @@ import { UserRpcs, } from "@hazel/domain/rpc" import { Layer } from "effect" -import { - createRpcTypeResolver, - DevtoolsProtocolLayer, - setRpcTypeResolver, -} from "effect-rpc-tanstack-devtools" const backendUrl = import.meta.env.VITE_BACKEND_URL const httpUrl = `${backendUrl}/rpc` @@ -47,7 +42,7 @@ export const RpcProtocolLive = BaseProtocolLive // Use Layer.mergeAll to make AuthMiddlewareClientLive available alongside the protocol const AtomRpcProtocolLive = Layer.mergeAll(RpcProtocolLive, AuthMiddlewareClientLive, Reactivity.layer) -const AllRpcs = MessageRpcs.merge( +const BaseRpcs = MessageRpcs.merge( NotificationRpcs, InvitationRpcs, IntegrationRequestRpcs, @@ -67,19 +62,13 @@ const AllRpcs = MessageRpcs.merge( AttachmentRpcs, UserPresenceStatusRpcs, BotRpcs, - ChatSyncRpcs, ConnectShareRpcs, ) -// Configure RPC type resolver for devtools (only in dev mode) -if (import.meta.env.DEV) { - setRpcTypeResolver(createRpcTypeResolver([AllRpcs])) -} - -export class HazelRpcClient extends AtomRpc.Tag()("HazelRpcClient", { +const AllRpcs = BaseRpcs.merge(ChatSyncRpcs) +export class HazelRpcClient extends AtomRpc.Service()("HazelRpcClient", { group: AllRpcs, - // @ts-expect-error protocol: AtomRpcProtocolLive, }) {} -export type { RpcClientError } from "@effect/rpc" +export type { RpcClientError } from "effect/unstable/rpc" diff --git a/apps/web/src/lib/services/common/runtime.ts b/apps/web/src/lib/services/common/runtime.ts index fbe2c43e8..56c0cc107 100644 --- a/apps/web/src/lib/services/common/runtime.ts +++ b/apps/web/src/lib/services/common/runtime.ts @@ -1,4 +1,4 @@ -import { Atom } from "@effect-atom/atom-react" +import { Atom } from "effect/unstable/reactivity" import { Layer, ManagedRuntime } from "effect" import { ApiClient } from "./api-client" import { HazelRpcClient } from "./rpc-atom-client" @@ -15,7 +15,7 @@ import { TracerLive } from "./telemetry" * * All RPC clients share the same WebSocket connection via RpcProtocolLive. */ -export const runtimeLayer = Layer.mergeAll(ApiClient.Default, HazelRpcClient.layer, TracerLive) +export const runtimeLayer = Layer.mergeAll(ApiClient.layer, HazelRpcClient.layer, TracerLive) /** * Managed runtime for imperative Effect execution @@ -28,4 +28,4 @@ export const runtimeLayer = Layer.mergeAll(ApiClient.Default, HazelRpcClient.lay * * Used by collections.ts and other imperative code that calls runtime.runPromise(). */ -export const runtime = ManagedRuntime.make(runtimeLayer, Atom.defaultMemoMap) +export const runtime = ManagedRuntime.make(runtimeLayer, { memoMap: Atom.defaultMemoMap }) diff --git a/apps/web/src/lib/services/desktop/tauri-auth.ts b/apps/web/src/lib/services/desktop/tauri-auth.ts index 0d386d489..91cffd5d0 100644 --- a/apps/web/src/lib/services/desktop/tauri-auth.ts +++ b/apps/web/src/lib/services/desktop/tauri-auth.ts @@ -22,7 +22,7 @@ import { TauriCommandError, TauriNotAvailableError, } from "@hazel/domain/errors" -import { Deferred, Duration, Effect, FiberId } from "effect" +import { ServiceMap, Deferred, Duration, Effect, Fiber, Layer } from "effect" import { TokenExchange } from "./token-exchange" import { TokenStorage } from "./token-storage" @@ -82,10 +82,8 @@ const getTauriEvent = Effect.gen(function* () { return event }) -export class TauriAuth extends Effect.Service()("TauriAuth", { - accessors: true, - dependencies: [TokenStorage.Default, TokenExchange.Default], - effect: Effect.gen(function* () { +export class TauriAuth extends ServiceMap.Service()("TauriAuth", { + make: Effect.gen(function* () { const tokenStorage = yield* TokenStorage const tokenExchange = yield* TokenExchange @@ -150,28 +148,24 @@ export class TauriAuth extends Effect.Service()("TauriAuth", { yield* Effect.log("[tauri-auth] Browser opened, waiting for web callback...") // Wait for OAuth callback with timeout and cleanup - // Uses Deferred to ensure cleanup works even if listener setup is async - const callbackUrl = yield* Effect.async((resume) => { - const unlistenDeferred = Deferred.unsafeMake<() => void, never>(FiberId.none) + const callbackUrl = yield* Effect.callback((resume) => { + let unlistenFn: (() => void) | null = null event - .listen("oauth-callback", (evt) => { + .listen("oauth-callback", (evt: { payload: string }) => { resume(Effect.succeed(evt.payload)) }) - .then((unlistenFn) => { - Deferred.unsafeDone(unlistenDeferred, Effect.succeed(unlistenFn)) + .then((fn: () => void) => { + unlistenFn = fn }) - // Return cleanup function that waits for unlisten to be available - return Deferred.await(unlistenDeferred).pipe( - Effect.flatMap((fn) => Effect.sync(() => fn())), - // If deferred isn't done yet, just skip cleanup - Effect.timeout(Duration.millis(100)), - Effect.ignore, - ) + // Return cleanup effect + return Effect.sync(() => { + if (unlistenFn) unlistenFn() + }) }).pipe( Effect.timeout(Duration.minutes(2)), - Effect.catchTag("TimeoutException", () => + Effect.catchTag("TimeoutError", () => Effect.fail( new OAuthTimeoutError({ message: "OAuth callback timeout after 2 minutes", @@ -206,4 +200,9 @@ export class TauriAuth extends Effect.Service()("TauriAuth", { }).pipe(Effect.withSpan("TauriAuth.initiateAuth")), } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(TokenStorage.layer), + Layer.provide(TokenExchange.layer), + ) +} diff --git a/apps/web/src/lib/services/desktop/token-exchange.test.ts b/apps/web/src/lib/services/desktop/token-exchange.test.ts new file mode 100644 index 000000000..9d11fa355 --- /dev/null +++ b/apps/web/src/lib/services/desktop/token-exchange.test.ts @@ -0,0 +1,154 @@ +import { FetchHttpClient } from "effect/unstable/http" +import { afterEach, describe, expect, it, vi } from "vitest" +import { Effect, Layer } from "effect" +import { OAuthRedemptionPendingError, OAuthStateMismatchError } from "@hazel/domain/errors" +import { TokenExchange } from "./token-exchange" + +const makeTokenExchangeLayer = (fetchImpl: typeof fetch) => + Layer.effect(TokenExchange, TokenExchange.make).pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchImpl)), + ) + +const runExchange = (fetchImpl: typeof fetch, attemptId = "attempt_test_123") => + Effect.runPromise( + Effect.gen(function* () { + const tokenExchange = yield* TokenExchange + return yield* tokenExchange.exchangeCode( + "code_123", + JSON.stringify({ returnTo: "/inbox" }), + attemptId, + ) + }).pipe(Effect.provide(makeTokenExchangeLayer(fetchImpl))), + ) + +const runRefresh = (fetchImpl: typeof fetch, attemptId = "attempt_refresh_123") => + Effect.runPromise( + Effect.gen(function* () { + const tokenExchange = yield* TokenExchange + return yield* tokenExchange.refreshToken("refresh_123", attemptId) + }).pipe(Effect.provide(makeTokenExchangeLayer(fetchImpl))), + ) + +describe("TokenExchange", () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it("uses the generated auth client and sends the typed attempt header for token exchange", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init) + expect(request.method).toBe("POST") + expect(new URL(request.url).pathname).toBe("/auth/token") + expect(request.headers.get("x-auth-attempt-id")).toBe("attempt_test_123") + expect(await request.json()).toEqual({ + code: "code_123", + state: JSON.stringify({ returnTo: "/inbox" }), + }) + + return new Response( + JSON.stringify({ + accessToken: "access_token", + refreshToken: "refresh_token", + expiresIn: 3600, + user: { + id: "user_123", + email: "test@example.com", + firstName: "Test", + lastName: "User", + }, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ) + }) + + const response = await runExchange(fetchMock as typeof fetch) + + expect(response.accessToken).toBe("access_token") + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it("decodes typed auth errors from the generated auth client", async () => { + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + _tag: "OAuthStateMismatchError", + message: "state mismatch", + }), + { + status: 400, + headers: { "content-type": "application/json" }, + }, + ), + ) + + const error = await runExchange(fetchMock as typeof fetch).catch((caught) => caught) + + expect(error).toBeInstanceOf(OAuthStateMismatchError) + expect(error).toEqual( + new OAuthStateMismatchError({ + message: "state mismatch", + }), + ) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it("sends the typed attempt header for refresh requests", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init) + expect(request.method).toBe("POST") + expect(new URL(request.url).pathname).toBe("/auth/refresh") + expect(request.headers.get("x-auth-attempt-id")).toBe("attempt_refresh_123") + expect(await request.json()).toEqual({ + refreshToken: "refresh_123", + }) + + return new Response( + JSON.stringify({ + accessToken: "access_token", + refreshToken: "refresh_token", + expiresIn: 3600, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ) + }) + + const response = await runRefresh(fetchMock as typeof fetch) + + expect(response.refreshToken).toBe("refresh_token") + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it("preserves pending-redemption errors as typed failures", async () => { + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + _tag: "OAuthRedemptionPendingError", + message: "try again shortly", + }), + { + status: 503, + headers: { "content-type": "application/json" }, + }, + ), + ) + + const error = await runExchange(fetchMock as typeof fetch).catch((caught) => caught) + + expect(error).toBeInstanceOf(OAuthRedemptionPendingError) + expect(error).toEqual( + new OAuthRedemptionPendingError({ + message: "try again shortly", + }), + ) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/web/src/lib/services/desktop/token-exchange.ts b/apps/web/src/lib/services/desktop/token-exchange.ts index 264e4b296..7695d0d53 100644 --- a/apps/web/src/lib/services/desktop/token-exchange.ts +++ b/apps/web/src/lib/services/desktop/token-exchange.ts @@ -1,190 +1,189 @@ /** - * @module Token exchange Effect service for desktop apps - * @platform desktop - * @description HTTP client for token exchange using Effect HttpClient with Schema validation + * @module Auth HTTP Effect service + * @platform web + * @description Type-safe auth client for token exchange and refresh */ -import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest } from "@effect/platform" -import { OAuthCodeExpiredError, TokenDecodeError, TokenExchangeError } from "@hazel/domain/errors" -import { RefreshTokenResponse, TokenResponse } from "@hazel/domain/http" -import { Duration, Effect, Schema } from "effect" +import { FetchHttpClient, HttpClient, HttpClientError } from "effect/unstable/http" +import { HttpApiClient } from "effect/unstable/httpapi" +import { + OAuthCodeExpiredError, + OAuthRedemptionPendingError, + OAuthStateMismatchError, + TokenDecodeError, + TokenExchangeError, +} from "@hazel/domain/errors" +import { + AuthRequestHeaders, + HazelApi, + RefreshTokenRequest, + RefreshTokenResponse, + TokenRequest, + TokenResponse, +} from "@hazel/domain/http" +import { Duration, Effect, Layer, Schema, ServiceMap } from "effect" const DEFAULT_TIMEOUT = Duration.seconds(60) +const createAttemptId = (scope: "callback" | "refresh"): string => `${scope}_${crypto.randomUUID()}` -export class TokenExchange extends Effect.Service()("TokenExchange", { - accessors: true, - dependencies: [FetchHttpClient.layer], - effect: Effect.gen(function* () { +const makeAttemptHeaders = (attemptId?: string) => + new AuthRequestHeaders({ + "x-auth-attempt-id": attemptId, + }) + +const mapExchangeError = ( + error: unknown, +): + | OAuthCodeExpiredError + | OAuthStateMismatchError + | OAuthRedemptionPendingError + | TokenExchangeError + | TokenDecodeError => { + if ( + error instanceof OAuthCodeExpiredError || + error instanceof OAuthStateMismatchError || + error instanceof OAuthRedemptionPendingError || + error instanceof TokenExchangeError + ) { + return error + } + + if (HttpClientError.isHttpClientError(error)) { + return new TokenExchangeError({ + message: + error.response?.status === undefined + ? "Network error during token exchange" + : "Server error during token exchange", + detail: error.response?.status === undefined ? String(error) : `HTTP ${error.response.status}`, + }) + } + + if (Schema.isSchemaError(error)) { + return new TokenDecodeError({ + message: "Invalid token response from server", + detail: String(error), + }) + } + + return new TokenExchangeError({ + message: "Failed to exchange code for token", + detail: String(error), + }) +} + +const mapRefreshError = (error: unknown): TokenExchangeError | TokenDecodeError => { + if (error instanceof TokenExchangeError) { + return error + } + + if (HttpClientError.isHttpClientError(error)) { + return new TokenExchangeError({ + message: + error.response?.status === undefined + ? "Network error during token refresh" + : "Server error during token refresh", + detail: error.response?.status === undefined ? String(error) : `HTTP ${error.response.status}`, + }) + } + + if (Schema.isSchemaError(error)) { + return new TokenDecodeError({ + message: "Invalid refresh response from server", + detail: String(error), + }) + } + + if (error instanceof OAuthCodeExpiredError) { + return new TokenExchangeError({ + message: error.message, + }) + } + + return new TokenExchangeError({ + message: "Failed to refresh token", + detail: String(error), + }) +} + +export class TokenExchange extends ServiceMap.Service()("TokenExchange", { + make: Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient const backendUrl = import.meta.env.VITE_BACKEND_URL - - /** - * Create a configured client for auth requests - */ - const makeAuthClient = () => - httpClient.pipe( - HttpClient.mapRequest( - HttpClientRequest.setHeaders({ - "Content-Type": "application/json", - }), - ), - ) + const authClient = yield* HttpApiClient.group(HazelApi, { + group: "auth", + httpClient, + baseUrl: backendUrl, + }) return { - /** - * Exchange authorization code for access/refresh tokens - */ - exchangeCode: (code: string, state: string) => - Effect.gen(function* () { - const client = makeAuthClient() - const body = JSON.stringify({ code, state }) - - const response = yield* client - .post(`${backendUrl}/auth/token`, { - body: HttpBody.text(body, "application/json"), - }) - .pipe(Effect.scoped, Effect.timeout(DEFAULT_TIMEOUT)) - - // Handle HTTP errors - if (response.status >= 400) { - const errorText = yield* response.text - // Try to parse the error response to detect specific error types - try { - const errorJson = JSON.parse(errorText) - if (errorJson._tag === "OAuthCodeExpiredError") { - return yield* Effect.fail( - new OAuthCodeExpiredError({ - message: - errorJson.message || "Authorization code expired or already used", - }), - ) - } - } catch { - // JSON parsing failed, fall through to generic error - } - return yield* Effect.fail( - new TokenExchangeError({ - message: "Failed to exchange code for token", - detail: `HTTP ${response.status}: ${errorText}`, - }), - ) - } - - // Parse and validate response - const rawJson = yield* response.json - return yield* Schema.decodeUnknown(TokenResponse)(rawJson).pipe( - Effect.mapError( - (parseError) => - new TokenDecodeError({ - message: "Invalid token response from server", - detail: String(parseError), + exchangeCode: ( + code: string, + state: string, + attemptId: string = createAttemptId("callback"), + ): Effect.Effect< + Schema.Schema.Type, + | OAuthCodeExpiredError + | OAuthStateMismatchError + | OAuthRedemptionPendingError + | TokenExchangeError + | TokenDecodeError, + never + > => + authClient + .token({ + headers: makeAttemptHeaders(attemptId), + payload: new TokenRequest({ code, state }), + }) + .pipe( + Effect.timeout(DEFAULT_TIMEOUT), + Effect.catchTag("TimeoutError", () => + Effect.fail( + new TokenExchangeError({ + message: "Token exchange timed out", }), + ), ), - ) - }).pipe( - // Map HTTP client errors to TokenExchangeError - Effect.catchTag("TimeoutException", () => - Effect.fail( - new TokenExchangeError({ - message: "Token exchange timed out", - }), - ), - ), - Effect.catchTag("RequestError", (error) => - Effect.fail( - new TokenExchangeError({ - message: "Network error during token exchange", - detail: String(error), - }), - ), - ), - Effect.catchTag("ResponseError", (error) => - Effect.fail( - new TokenExchangeError({ - message: "Server error during token exchange", - detail: `HTTP ${error.response.status}`, - }), - ), + Effect.catchTag("OAuthCodeExpiredError", (error) => Effect.fail(error)), + Effect.catchTag("OAuthStateMismatchError", (error) => Effect.fail(error)), + Effect.catchTag("OAuthRedemptionPendingError", (error) => Effect.fail(error)), + Effect.catch((error) => Effect.fail(mapExchangeError(error))), ), - ), - - /** - * Refresh tokens using a refresh token - */ - refreshToken: (refreshToken: string) => - Effect.gen(function* () { - const client = makeAuthClient() - const body = JSON.stringify({ refreshToken }) - - const response = yield* client - .post(`${backendUrl}/auth/refresh`, { - body: HttpBody.text(body, "application/json"), - }) - .pipe(Effect.scoped, Effect.timeout(DEFAULT_TIMEOUT)) - - // Handle HTTP errors - if (response.status >= 400) { - const errorText = yield* response.text - return yield* Effect.fail( - new TokenExchangeError({ - message: "Failed to refresh token", - detail: `HTTP ${response.status}: ${errorText}`, - }), - ) - } - - // Parse and validate response - const rawJson = yield* response.json - return yield* Schema.decodeUnknown(RefreshTokenResponse)(rawJson).pipe( - Effect.mapError( - (parseError) => - new TokenDecodeError({ - message: "Invalid refresh response from server", - detail: String(parseError), + + refreshToken: ( + refreshToken: string, + attemptId: string = createAttemptId("refresh"), + ): Effect.Effect< + Schema.Schema.Type, + TokenExchangeError | TokenDecodeError, + never + > => + authClient + .refresh({ + headers: makeAttemptHeaders(attemptId), + payload: new RefreshTokenRequest({ refreshToken }), + }) + .pipe( + Effect.timeout(DEFAULT_TIMEOUT), + Effect.catchTag("TimeoutError", () => + Effect.fail( + new TokenExchangeError({ + message: "Token refresh timed out", }), + ), ), - ) - }).pipe( - // Map HTTP client errors to TokenExchangeError - Effect.catchTag("TimeoutException", () => - Effect.fail( - new TokenExchangeError({ - message: "Token refresh timed out", - }), - ), - ), - Effect.catchTag("RequestError", (error) => - Effect.fail( - new TokenExchangeError({ - message: "Network error during token refresh", - detail: String(error), - }), - ), + Effect.catch((error) => Effect.fail(mapRefreshError(error))), ), - Effect.catchTag("ResponseError", (error) => - Effect.fail( - new TokenExchangeError({ - message: "Server error during token refresh", - detail: `HTTP ${error.response.status}`, - }), - ), - ), - ), } }), }) { - /** - * Mock token response for testing - */ + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(FetchHttpClient.layer)) + static mockTokenResponse = () => ({ accessToken: "new-access-token", refreshToken: "new-refresh-token", expiresIn: 3600, }) - /** - * Mock full token response with user data for testing - */ static mockFullTokenResponse = () => ({ accessToken: "new-access-token", refreshToken: "new-refresh-token", diff --git a/apps/web/src/lib/services/desktop/token-storage.ts b/apps/web/src/lib/services/desktop/token-storage.ts index 52f7888a6..3ba1d16b6 100644 --- a/apps/web/src/lib/services/desktop/token-storage.ts +++ b/apps/web/src/lib/services/desktop/token-storage.ts @@ -6,7 +6,7 @@ import { getTauriStore, type TauriStoreApi } from "@hazel/desktop/bridge" import { TokenNotFoundError, TokenStoreError, TauriNotAvailableError } from "@hazel/domain/errors" -import { Effect, Option } from "effect" +import { ServiceMap, Effect, Layer, Option } from "effect" type StoreInstance = Awaited> @@ -48,9 +48,8 @@ const getStore = Effect.gen(function* () { }) }) -export class TokenStorage extends Effect.Service()("TokenStorage", { - accessors: true, - effect: Effect.gen(function* () { +export class TokenStorage extends ServiceMap.Service()("TokenStorage", { + make: Effect.gen(function* () { return { /** * Store all auth tokens in Tauri store @@ -104,7 +103,7 @@ export class TokenStorage extends Effect.Service()("TokenStorage", detail: String(e), }), }) - return Option.fromNullable(token) + return Option.fromNullishOr(token) }), /** @@ -121,7 +120,7 @@ export class TokenStorage extends Effect.Service()("TokenStorage", detail: String(e), }), }) - return Option.fromNullable(token) + return Option.fromNullishOr(token) }), /** @@ -138,7 +137,7 @@ export class TokenStorage extends Effect.Service()("TokenStorage", detail: String(e), }), }) - return Option.fromNullable(expiresAt) + return Option.fromNullishOr(expiresAt) }), /** @@ -233,6 +232,8 @@ export class TokenStorage extends Effect.Service()("TokenStorage", } }), }) { + static readonly layer = Layer.effect(this, this.make) + /** * Mock token data for testing */ diff --git a/apps/web/src/lib/services/web/token-storage.ts b/apps/web/src/lib/services/web/token-storage.ts index 719ab8f4b..554891aab 100644 --- a/apps/web/src/lib/services/web/token-storage.ts +++ b/apps/web/src/lib/services/web/token-storage.ts @@ -5,7 +5,7 @@ */ import { TokenNotFoundError, TokenStoreError } from "@hazel/domain/errors" -import { Effect, Option } from "effect" +import { ServiceMap, Effect, Layer, Option } from "effect" const STORAGE_PREFIX = "hazel_auth_" const ACCESS_TOKEN_KEY = `${STORAGE_PREFIX}access_token` @@ -28,9 +28,8 @@ const checkStorage = Effect.gen(function* () { return window.localStorage }) -export class WebTokenStorage extends Effect.Service()("WebTokenStorage", { - accessors: true, - effect: Effect.gen(function* () { +export class WebTokenStorage extends ServiceMap.Service()("WebTokenStorage", { + make: Effect.gen(function* () { return { /** * Store all auth tokens in localStorage @@ -84,7 +83,7 @@ export class WebTokenStorage extends Effect.Service()("WebToken detail: String(e), }), }) - return Option.fromNullable(token) + return Option.fromNullishOr(token) }), /** @@ -101,7 +100,7 @@ export class WebTokenStorage extends Effect.Service()("WebToken detail: String(e), }), }) - return Option.fromNullable(token) + return Option.fromNullishOr(token) }), /** @@ -217,6 +216,8 @@ export class WebTokenStorage extends Effect.Service()("WebToken } }), }) { + static readonly layer = Layer.effect(this, this.make) + /** * Mock token data for testing */ diff --git a/apps/web/src/lib/toast-exit.tsx b/apps/web/src/lib/toast-exit.tsx index d63640404..ab0a25966 100644 --- a/apps/web/src/lib/toast-exit.tsx +++ b/apps/web/src/lib/toast-exit.tsx @@ -1,4 +1,4 @@ -import { Cause, Chunk, Exit, Option } from "effect" +import { Cause, Exit, Option } from "effect" import { type ExternalToast, toast } from "sonner" import { @@ -118,9 +118,8 @@ export type ExitToastBuilder = { /** * Execute the builder and show appropriate toast. - * Only available when all non-common errors are handled. */ - run: NonCommonErrorTags extends HandledErrors ? () => void : BuilderNotReady + run: () => void } /** @@ -187,9 +186,7 @@ export type ExitToastAsyncBuilder = { * Execute the builder, await the promise, and show appropriate toast. * Returns the Exit for further handling. */ - run: NonCommonErrorTags extends HandledErrors - ? () => Promise> - : BuilderNotReady + run: () => Promise> } /** @@ -233,13 +230,12 @@ function executeToast( toastOptions.id = loadingToastId } - const failures = Cause.failures(cause) - const firstFailure = Chunk.head(failures) + const firstFailure = Cause.findErrorOption(cause) let userError: UserErrorMessage if (Option.isSome(firstFailure)) { - userError = getErrorMessage(firstFailure.value, state.errorHandlers) + userError = getErrorMessage(firstFailure.value as { _tag: string }, state.errorHandlers) } else { userError = getUserFriendlyError(cause) } diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 5e1abdf42..f11bd9f0f 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -1,6 +1,23 @@ import { type ClassValue, clsx } from "clsx" +import { DateTime } from "effect" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +/** + * Convert a `Date | DateTime.Utc` to epoch milliseconds. + * Handles the Effect v4 change where Schema models may return `DateTime.Utc` instead of `Date`. + */ +export function toEpochMs(d: Date | DateTime.Utc): number { + return d instanceof Date ? d.getTime() : d.epochMilliseconds +} + +/** + * Convert a `Date | DateTime.Utc` to a plain `Date`. + * Useful when passing to `date-fns` or `new Date()`. + */ +export function toDate(d: Date | DateTime.Utc): Date { + return d instanceof Date ? d : new Date(d.epochMilliseconds) +} diff --git a/apps/web/src/lib/web-callback-single-flight.test.ts b/apps/web/src/lib/web-callback-single-flight.test.ts new file mode 100644 index 000000000..abd471826 --- /dev/null +++ b/apps/web/src/lib/web-callback-single-flight.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { + getWebCallbackAttemptKey, + resetAllWebCallbackAttempts, + runWebCallbackAttemptOnce, +} from "./web-callback-single-flight" + +describe("web-callback-single-flight", () => { + beforeEach(() => { + resetAllWebCallbackAttempts() + }) + + it("invokes the runner once for duplicate in-flight callback attempts", async () => { + const runner = vi.fn(async () => { + await Promise.resolve() + return { success: true as const } + }) + const key = getWebCallbackAttemptKey({ + code: "auth-code", + state: { returnTo: "/" }, + }) + + const [first, second] = await Promise.all([ + runWebCallbackAttemptOnce(key, runner, () => true), + runWebCallbackAttemptOnce(key, runner, () => true), + ]) + + expect(runner).toHaveBeenCalledTimes(1) + expect(first).toEqual({ success: true }) + expect(second).toEqual({ success: true }) + }) + + it("keeps successful terminal results for later callers", async () => { + const runner = vi.fn(async () => ({ success: true as const })) + const key = getWebCallbackAttemptKey({ + code: "auth-code", + state: { returnTo: "/dashboard" }, + }) + + await runWebCallbackAttemptOnce(key, runner, () => true) + await runWebCallbackAttemptOnce(key, runner, () => true) + + expect(runner).toHaveBeenCalledTimes(1) + }) + + it("clears retryable results so the next attempt can run again", async () => { + const runner = vi + .fn<() => Promise<{ success: false; retryable: boolean }>>() + .mockResolvedValue({ success: false, retryable: true }) + const key = getWebCallbackAttemptKey({ + code: "auth-code", + state: { returnTo: "/dashboard" }, + }) + + await runWebCallbackAttemptOnce(key, runner, () => false) + await runWebCallbackAttemptOnce(key, runner, () => false) + + expect(runner).toHaveBeenCalledTimes(2) + }) + + it("keeps non-retryable terminal failures", async () => { + const runner = vi + .fn<() => Promise<{ success: false; retryable: boolean }>>() + .mockResolvedValue({ success: false, retryable: false }) + const key = getWebCallbackAttemptKey({ + code: "auth-code", + state: { returnTo: "/dashboard" }, + }) + + await runWebCallbackAttemptOnce(key, runner, () => true) + await runWebCallbackAttemptOnce(key, runner, () => true) + + expect(runner).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/web/src/lib/web-callback-single-flight.ts b/apps/web/src/lib/web-callback-single-flight.ts new file mode 100644 index 000000000..eefc64594 --- /dev/null +++ b/apps/web/src/lib/web-callback-single-flight.ts @@ -0,0 +1,81 @@ +export interface WebCallbackKeyParams { + code?: string + state?: unknown + error?: string + error_description?: string +} + +const activeAttempts = new Map>() + +const normalizeAttemptValue = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(normalizeAttemptValue) + } + + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nested]) => [key, normalizeAttemptValue(nested)]), + ) + } + + return value +} + +const normalizeState = (state: WebCallbackKeyParams["state"]): string => { + if (state === undefined) return "" + + if (typeof state === "string") { + try { + return JSON.stringify(normalizeAttemptValue(JSON.parse(state))) + } catch { + return state + } + } + + return JSON.stringify(normalizeAttemptValue(state)) +} + +export const getWebCallbackAttemptKey = (params: WebCallbackKeyParams): string => + JSON.stringify({ + code: params.code ?? "", + state: normalizeState(params.state), + error: params.error ?? "", + errorDescription: params.error_description ?? "", + }) + +export const runWebCallbackAttemptOnce = async ( + key: string, + runner: () => Promise, + keepResult: (result: T) => boolean, +): Promise => { + const existing = activeAttempts.get(key) + if (existing) { + return existing as Promise + } + + const promise = runner().then( + (result) => { + if (!keepResult(result)) { + activeAttempts.delete(key) + } + return result + }, + (error) => { + activeAttempts.delete(key) + throw error + }, + ) + + activeAttempts.set(key, promise) + return promise +} + +export const resetWebCallbackAttempt = (key: string): void => { + activeAttempts.delete(key) +} + +export const resetAllWebCallbackAttempts = (): void => { + activeAttempts.clear() +} diff --git a/apps/web/src/lib/web-login-single-flight.test.ts b/apps/web/src/lib/web-login-single-flight.test.ts new file mode 100644 index 000000000..f7e216a44 --- /dev/null +++ b/apps/web/src/lib/web-login-single-flight.test.ts @@ -0,0 +1,36 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { + getWebLoginAttemptKey, + resetAllWebLoginRedirects, + startWebLoginRedirectOnce, +} from "./web-login-single-flight" + +describe("web-login-single-flight", () => { + beforeEach(() => { + vi.useFakeTimers() + resetAllWebLoginRedirects() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("starts a login redirect only once while the guard is active", () => { + const start = vi.fn() + const key = getWebLoginAttemptKey({ returnTo: "/" }) + + expect(startWebLoginRedirectOnce(key, start, 1000)).toBe(true) + expect(startWebLoginRedirectOnce(key, start, 1000)).toBe(false) + expect(start).toHaveBeenCalledTimes(1) + }) + + it("allows the same login redirect again after the guard timeout", () => { + const start = vi.fn() + const key = getWebLoginAttemptKey({ returnTo: "/" }) + + expect(startWebLoginRedirectOnce(key, start, 1000)).toBe(true) + vi.advanceTimersByTime(1000) + expect(startWebLoginRedirectOnce(key, start, 1000)).toBe(true) + expect(start).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/web/src/lib/web-login-single-flight.ts b/apps/web/src/lib/web-login-single-flight.ts new file mode 100644 index 000000000..62c5a0a99 --- /dev/null +++ b/apps/web/src/lib/web-login-single-flight.ts @@ -0,0 +1,49 @@ +const DEFAULT_LOGIN_GUARD_MS = 10_000 + +const activeLoginRedirects = new Map>() + +export interface WebLoginAttemptKeyParams { + returnTo?: string + organizationId?: string + invitationToken?: string +} + +export const getWebLoginAttemptKey = (params: WebLoginAttemptKeyParams): string => + JSON.stringify({ + returnTo: params.returnTo ?? "/", + organizationId: params.organizationId ?? "", + invitationToken: params.invitationToken ?? "", + }) + +export const startWebLoginRedirectOnce = ( + key: string, + start: () => void, + timeoutMs = DEFAULT_LOGIN_GUARD_MS, +): boolean => { + if (activeLoginRedirects.has(key)) { + return false + } + + const timeoutId = globalThis.setTimeout(() => { + activeLoginRedirects.delete(key) + }, timeoutMs) + + activeLoginRedirects.set(key, timeoutId) + start() + return true +} + +export const resetWebLoginRedirect = (key: string): void => { + const timeoutId = activeLoginRedirects.get(key) + if (timeoutId !== undefined) { + globalThis.clearTimeout(timeoutId) + } + activeLoginRedirects.delete(key) +} + +export const resetAllWebLoginRedirects = (): void => { + for (const timeoutId of activeLoginRedirects.values()) { + globalThis.clearTimeout(timeoutId) + } + activeLoginRedirects.clear() +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 9825518a0..b4b028a46 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -18,7 +18,7 @@ import "./styles/styles.css" // Initialize app registry and mount runtimes // Note: RPC devtools are now integrated via Effect layers in rpc-atom-client.ts -import { RegistryContext } from "@effect-atom/atom-react" +import { RegistryContext } from "@effect/atom-react" import { appRegistry } from "./lib/registry.ts" // Initialize Tauri-specific features (no-op in browser) diff --git a/apps/web/src/providers/chat-provider.tsx b/apps/web/src/providers/chat-provider.tsx index 01886c110..d05bbd632 100644 --- a/apps/web/src/providers/chat-provider.tsx +++ b/apps/web/src/providers/chat-provider.tsx @@ -1,12 +1,13 @@ -import { Result, useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { AsyncResult } from "effect/unstable/reactivity" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import type { Channel } from "@hazel/domain/models" -import { - type AttachmentId, +import type { + AttachmentId, ChannelId, - type MessageId, - type MessageReactionId, - type OrganizationId, - type PinnedMessageId, + MessageId, + MessageReactionId, + OrganizationId, + PinnedMessageId, UserId, } from "@hazel/schema" import { Exit } from "effect" @@ -49,7 +50,7 @@ interface SendMessageProps { export interface ChatStableValue { channelId: ChannelId organizationId: OrganizationId - channel: typeof Channel.Model.Type | undefined + channel: Channel.Type | undefined // All actions (sendMessage is stabilized via refs) sendMessage: (props: SendMessageProps) => void editMessage: (messageId: MessageId, content: string) => Promise @@ -99,7 +100,7 @@ export interface ChatThreadValue { export interface ChatState { channelId: ChannelId organizationId: OrganizationId - channel: typeof Channel.Model.Type | undefined + channel: Channel.Type | undefined replyToMessageId: MessageId | null attachmentIds: AttachmentId[] isUploading: boolean @@ -283,7 +284,7 @@ export function ChatProvider({ channelId, organizationId, children, onMessageSen const setUploadingFiles = useAtomSet(uploadingFilesAtomFamily(channelId)) const channelResult = useAtomValue(channelByIdAtomFamily(channelId)) - const channel = Result.getOrElse(channelResult, () => undefined) + const channel = AsyncResult.getOrElse(channelResult, () => undefined) // Track pending thread creation to disable composer until thread is created const [pendingThreadChannelId, setPendingThreadChannelId] = useState(null) @@ -395,7 +396,7 @@ export function ChatProvider({ channelId, organizationId, children, onMessageSen const tx = await sendMessageMutation({ channelId, - authorId: UserId.make(user.id), + authorId: user.id as UserId, content, replyToMessageId: savedReplyToMessageId, threadChannelId: null, @@ -502,7 +503,7 @@ export function ChatProvider({ channelId, organizationId, children, onMessageSen messageId, channelId, emoji, - userId: UserId.make(user.id), + userId: user.id as UserId, }) exitToast(tx) @@ -528,7 +529,7 @@ export function ChatProvider({ channelId, organizationId, children, onMessageSen const exit = await pinMessageMutation({ messageId, channelId, - userId: UserId.make(user.id), + userId: user.id as UserId, }) exitToast(exit) @@ -575,7 +576,7 @@ export function ChatProvider({ channelId, organizationId, children, onMessageSen if (!user?.id) return // Generate thread channel ID upfront for optimistic UI - const threadChannelId = ChannelId.make(crypto.randomUUID()) + const threadChannelId = crypto.randomUUID() as ChannelId // Open panel IMMEDIATELY with optimistic ID setActiveThreadChannelId(threadChannelId) @@ -590,7 +591,7 @@ export function ChatProvider({ channelId, organizationId, children, onMessageSen messageId, parentChannelId: channelId, organizationId, - currentUserId: UserId.make(user.id), + currentUserId: user.id as UserId, }) // Clear pending state diff --git a/apps/web/src/providers/notification-sound-provider.tsx b/apps/web/src/providers/notification-sound-provider.tsx index 781488127..74a3120cd 100644 --- a/apps/web/src/providers/notification-sound-provider.tsx +++ b/apps/web/src/providers/notification-sound-provider.tsx @@ -1,5 +1,5 @@ import type { ChannelId, MessageId } from "@hazel/schema" -import { useAtomValue } from "@effect-atom/atom-react" +import { useAtomValue } from "@effect/atom-react" import { eq, useLiveQuery } from "@tanstack/react-db" import { type ReactNode, useEffect, useMemo, useRef } from "react" import { diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 97e3cf499..d4eb0d2d4 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -8,1420 +8,1362 @@ // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. -import { Route as rootRouteImport } from './routes/__root' -import { Route as DevLayoutRouteImport } from './routes/_dev/layout' -import { Route as AppLayoutRouteImport } from './routes/_app/layout' -import { Route as AppIndexRouteImport } from './routes/_app/index' -import { Route as JoinSlugRouteImport } from './routes/join/$slug' -import { Route as AuthLoginRouteImport } from './routes/auth/login' -import { Route as AuthDesktopLoginRouteImport } from './routes/auth/desktop-login' -import { Route as AuthDesktopCallbackRouteImport } from './routes/auth/desktop-callback' -import { Route as AuthCallbackRouteImport } from './routes/auth/callback' -import { Route as DevUiLayoutRouteImport } from './routes/_dev/ui/layout' -import { Route as AppOrgSlugLayoutRouteImport } from './routes/_app/$orgSlug/layout' -import { Route as DevEmbedsIndexRouteImport } from './routes/dev/embeds/index' -import { Route as AppSelectOrganizationIndexRouteImport } from './routes/_app/select-organization/index' -import { Route as AppOnboardingIndexRouteImport } from './routes/_app/onboarding/index' -import { Route as AppOrgSlugIndexRouteImport } from './routes/_app/$orgSlug/index' -import { Route as DevEmbedsRailwayRouteImport } from './routes/dev/embeds/railway' -import { Route as DevEmbedsOpenstatusRouteImport } from './routes/dev/embeds/openstatus' -import { Route as DevEmbedsGithubRouteImport } from './routes/dev/embeds/github' -import { Route as DevEmbedsDemoRouteImport } from './routes/dev/embeds/demo' -import { Route as DevUiAgentStepsRouteImport } from './routes/_dev/ui/agent-steps' -import { Route as AppOnboardingSetupOrganizationRouteImport } from './routes/_app/onboarding/setup-organization' -import { Route as AppOrgSlugSettingsLayoutRouteImport } from './routes/_app/$orgSlug/settings/layout' -import { Route as AppOrgSlugNotificationsLayoutRouteImport } from './routes/_app/$orgSlug/notifications/layout' -import { Route as AppOrgSlugMySettingsLayoutRouteImport } from './routes/_app/$orgSlug/my-settings/layout' -import { Route as AppOrgSlugSettingsIndexRouteImport } from './routes/_app/$orgSlug/settings/index' -import { Route as AppOrgSlugNotificationsIndexRouteImport } from './routes/_app/$orgSlug/notifications/index' -import { Route as AppOrgSlugMySettingsIndexRouteImport } from './routes/_app/$orgSlug/my-settings/index' -import { Route as AppOrgSlugChatIndexRouteImport } from './routes/_app/$orgSlug/chat/index' -import { Route as AppOrgSlugSettingsTeamRouteImport } from './routes/_app/$orgSlug/settings/team' -import { Route as AppOrgSlugSettingsInvitationsRouteImport } from './routes/_app/$orgSlug/settings/invitations' -import { Route as AppOrgSlugSettingsDebugRouteImport } from './routes/_app/$orgSlug/settings/debug' -import { Route as AppOrgSlugSettingsCustomEmojisRouteImport } from './routes/_app/$orgSlug/settings/custom-emojis' -import { Route as AppOrgSlugSettingsConnectInvitesRouteImport } from './routes/_app/$orgSlug/settings/connect-invites' -import { Route as AppOrgSlugSettingsAuthenticationRouteImport } from './routes/_app/$orgSlug/settings/authentication' -import { Route as AppOrgSlugProfileUserIdRouteImport } from './routes/_app/$orgSlug/profile/$userId' -import { Route as AppOrgSlugNotificationsThreadsRouteImport } from './routes/_app/$orgSlug/notifications/threads' -import { Route as AppOrgSlugNotificationsGeneralRouteImport } from './routes/_app/$orgSlug/notifications/general' -import { Route as AppOrgSlugNotificationsDmsRouteImport } from './routes/_app/$orgSlug/notifications/dms' -import { Route as AppOrgSlugMySettingsProfileRouteImport } from './routes/_app/$orgSlug/my-settings/profile' -import { Route as AppOrgSlugMySettingsNotificationsRouteImport } from './routes/_app/$orgSlug/my-settings/notifications' -import { Route as AppOrgSlugMySettingsLinkedAccountsRouteImport } from './routes/_app/$orgSlug/my-settings/linked-accounts' -import { Route as AppOrgSlugMySettingsDesktopRouteImport } from './routes/_app/$orgSlug/my-settings/desktop' -import { Route as AppOrgSlugChatIdRouteImport } from './routes/_app/$orgSlug/chat/$id' -import { Route as AppOrgSlugSettingsIntegrationsLayoutRouteImport } from './routes/_app/$orgSlug/settings/integrations/layout' -import { Route as AppOrgSlugSettingsChatSyncLayoutRouteImport } from './routes/_app/$orgSlug/settings/chat-sync/layout' -import { Route as AppOrgSlugSettingsIntegrationsIndexRouteImport } from './routes/_app/$orgSlug/settings/integrations/index' -import { Route as AppOrgSlugSettingsChatSyncIndexRouteImport } from './routes/_app/$orgSlug/settings/chat-sync/index' -import { Route as AppOrgSlugChatIdIndexRouteImport } from './routes/_app/$orgSlug/chat/$id/index' -import { Route as AppOrgSlugSettingsIntegrationsYourAppsRouteImport } from './routes/_app/$orgSlug/settings/integrations/your-apps' -import { Route as AppOrgSlugSettingsIntegrationsMarketplaceRouteImport } from './routes/_app/$orgSlug/settings/integrations/marketplace' -import { Route as AppOrgSlugSettingsIntegrationsInstalledRouteImport } from './routes/_app/$orgSlug/settings/integrations/installed' -import { Route as AppOrgSlugSettingsIntegrationsIntegrationIdRouteImport } from './routes/_app/$orgSlug/settings/integrations/$integrationId' -import { Route as AppOrgSlugSettingsChatSyncConnectionIdRouteImport } from './routes/_app/$orgSlug/settings/chat-sync/$connectionId' -import { Route as AppOrgSlugChannelsChannelIdSettingsLayoutRouteImport } from './routes/_app/$orgSlug/channels/$channelId/settings/layout' -import { Route as AppOrgSlugChatIdFilesIndexRouteImport } from './routes/_app/$orgSlug/chat/$id/files/index' -import { Route as AppOrgSlugChannelsChannelIdSettingsIndexRouteImport } from './routes/_app/$orgSlug/channels/$channelId/settings/index' -import { Route as AppOrgSlugChatIdFilesMediaRouteImport } from './routes/_app/$orgSlug/chat/$id/files/media' -import { Route as AppOrgSlugChannelsChannelIdSettingsOverviewRouteImport } from './routes/_app/$orgSlug/channels/$channelId/settings/overview' -import { Route as AppOrgSlugChannelsChannelIdSettingsIntegrationsRouteImport } from './routes/_app/$orgSlug/channels/$channelId/settings/integrations' -import { Route as AppOrgSlugChannelsChannelIdSettingsConnectRouteImport } from './routes/_app/$orgSlug/channels/$channelId/settings/connect' +import { Route as rootRouteImport } from "./routes/__root" +import { Route as DevLayoutRouteImport } from "./routes/_dev/layout" +import { Route as AppLayoutRouteImport } from "./routes/_app/layout" +import { Route as AppIndexRouteImport } from "./routes/_app/index" +import { Route as JoinSlugRouteImport } from "./routes/join/$slug" +import { Route as AuthLoginRouteImport } from "./routes/auth/login" +import { Route as AuthDesktopLoginRouteImport } from "./routes/auth/desktop-login" +import { Route as AuthDesktopCallbackRouteImport } from "./routes/auth/desktop-callback" +import { Route as AuthCallbackRouteImport } from "./routes/auth/callback" +import { Route as DevUiLayoutRouteImport } from "./routes/_dev/ui/layout" +import { Route as AppOrgSlugLayoutRouteImport } from "./routes/_app/$orgSlug/layout" +import { Route as DevEmbedsIndexRouteImport } from "./routes/dev/embeds/index" +import { Route as AppSelectOrganizationIndexRouteImport } from "./routes/_app/select-organization/index" +import { Route as AppOnboardingIndexRouteImport } from "./routes/_app/onboarding/index" +import { Route as AppOrgSlugIndexRouteImport } from "./routes/_app/$orgSlug/index" +import { Route as DevEmbedsRailwayRouteImport } from "./routes/dev/embeds/railway" +import { Route as DevEmbedsOpenstatusRouteImport } from "./routes/dev/embeds/openstatus" +import { Route as DevEmbedsGithubRouteImport } from "./routes/dev/embeds/github" +import { Route as DevEmbedsDemoRouteImport } from "./routes/dev/embeds/demo" +import { Route as DevUiAgentStepsRouteImport } from "./routes/_dev/ui/agent-steps" +import { Route as AppOnboardingSetupOrganizationRouteImport } from "./routes/_app/onboarding/setup-organization" +import { Route as AppOrgSlugSettingsLayoutRouteImport } from "./routes/_app/$orgSlug/settings/layout" +import { Route as AppOrgSlugNotificationsLayoutRouteImport } from "./routes/_app/$orgSlug/notifications/layout" +import { Route as AppOrgSlugMySettingsLayoutRouteImport } from "./routes/_app/$orgSlug/my-settings/layout" +import { Route as AppOrgSlugSettingsIndexRouteImport } from "./routes/_app/$orgSlug/settings/index" +import { Route as AppOrgSlugNotificationsIndexRouteImport } from "./routes/_app/$orgSlug/notifications/index" +import { Route as AppOrgSlugMySettingsIndexRouteImport } from "./routes/_app/$orgSlug/my-settings/index" +import { Route as AppOrgSlugChatIndexRouteImport } from "./routes/_app/$orgSlug/chat/index" +import { Route as AppOrgSlugSettingsTeamRouteImport } from "./routes/_app/$orgSlug/settings/team" +import { Route as AppOrgSlugSettingsInvitationsRouteImport } from "./routes/_app/$orgSlug/settings/invitations" +import { Route as AppOrgSlugSettingsDebugRouteImport } from "./routes/_app/$orgSlug/settings/debug" +import { Route as AppOrgSlugSettingsCustomEmojisRouteImport } from "./routes/_app/$orgSlug/settings/custom-emojis" +import { Route as AppOrgSlugSettingsConnectInvitesRouteImport } from "./routes/_app/$orgSlug/settings/connect-invites" +import { Route as AppOrgSlugSettingsAuthenticationRouteImport } from "./routes/_app/$orgSlug/settings/authentication" +import { Route as AppOrgSlugProfileUserIdRouteImport } from "./routes/_app/$orgSlug/profile/$userId" +import { Route as AppOrgSlugNotificationsThreadsRouteImport } from "./routes/_app/$orgSlug/notifications/threads" +import { Route as AppOrgSlugNotificationsGeneralRouteImport } from "./routes/_app/$orgSlug/notifications/general" +import { Route as AppOrgSlugNotificationsDmsRouteImport } from "./routes/_app/$orgSlug/notifications/dms" +import { Route as AppOrgSlugMySettingsProfileRouteImport } from "./routes/_app/$orgSlug/my-settings/profile" +import { Route as AppOrgSlugMySettingsNotificationsRouteImport } from "./routes/_app/$orgSlug/my-settings/notifications" +import { Route as AppOrgSlugMySettingsLinkedAccountsRouteImport } from "./routes/_app/$orgSlug/my-settings/linked-accounts" +import { Route as AppOrgSlugMySettingsDesktopRouteImport } from "./routes/_app/$orgSlug/my-settings/desktop" +import { Route as AppOrgSlugChatIdRouteImport } from "./routes/_app/$orgSlug/chat/$id" +import { Route as AppOrgSlugSettingsIntegrationsLayoutRouteImport } from "./routes/_app/$orgSlug/settings/integrations/layout" +import { Route as AppOrgSlugSettingsChatSyncLayoutRouteImport } from "./routes/_app/$orgSlug/settings/chat-sync/layout" +import { Route as AppOrgSlugSettingsIntegrationsIndexRouteImport } from "./routes/_app/$orgSlug/settings/integrations/index" +import { Route as AppOrgSlugSettingsChatSyncIndexRouteImport } from "./routes/_app/$orgSlug/settings/chat-sync/index" +import { Route as AppOrgSlugChatIdIndexRouteImport } from "./routes/_app/$orgSlug/chat/$id/index" +import { Route as AppOrgSlugSettingsIntegrationsYourAppsRouteImport } from "./routes/_app/$orgSlug/settings/integrations/your-apps" +import { Route as AppOrgSlugSettingsIntegrationsMarketplaceRouteImport } from "./routes/_app/$orgSlug/settings/integrations/marketplace" +import { Route as AppOrgSlugSettingsIntegrationsInstalledRouteImport } from "./routes/_app/$orgSlug/settings/integrations/installed" +import { Route as AppOrgSlugSettingsIntegrationsIntegrationIdRouteImport } from "./routes/_app/$orgSlug/settings/integrations/$integrationId" +import { Route as AppOrgSlugSettingsChatSyncConnectionIdRouteImport } from "./routes/_app/$orgSlug/settings/chat-sync/$connectionId" +import { Route as AppOrgSlugChannelsChannelIdSettingsLayoutRouteImport } from "./routes/_app/$orgSlug/channels/$channelId/settings/layout" +import { Route as AppOrgSlugChatIdFilesIndexRouteImport } from "./routes/_app/$orgSlug/chat/$id/files/index" +import { Route as AppOrgSlugChannelsChannelIdSettingsIndexRouteImport } from "./routes/_app/$orgSlug/channels/$channelId/settings/index" +import { Route as AppOrgSlugChatIdFilesMediaRouteImport } from "./routes/_app/$orgSlug/chat/$id/files/media" +import { Route as AppOrgSlugChannelsChannelIdSettingsOverviewRouteImport } from "./routes/_app/$orgSlug/channels/$channelId/settings/overview" +import { Route as AppOrgSlugChannelsChannelIdSettingsIntegrationsRouteImport } from "./routes/_app/$orgSlug/channels/$channelId/settings/integrations" +import { Route as AppOrgSlugChannelsChannelIdSettingsConnectRouteImport } from "./routes/_app/$orgSlug/channels/$channelId/settings/connect" const DevLayoutRoute = DevLayoutRouteImport.update({ - id: '/_dev', - getParentRoute: () => rootRouteImport, + id: "/_dev", + getParentRoute: () => rootRouteImport, } as any) const AppLayoutRoute = AppLayoutRouteImport.update({ - id: '/_app', - getParentRoute: () => rootRouteImport, + id: "/_app", + getParentRoute: () => rootRouteImport, } as any) const AppIndexRoute = AppIndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => AppLayoutRoute, + id: "/", + path: "/", + getParentRoute: () => AppLayoutRoute, } as any) const JoinSlugRoute = JoinSlugRouteImport.update({ - id: '/join/$slug', - path: '/join/$slug', - getParentRoute: () => rootRouteImport, + id: "/join/$slug", + path: "/join/$slug", + getParentRoute: () => rootRouteImport, } as any) const AuthLoginRoute = AuthLoginRouteImport.update({ - id: '/auth/login', - path: '/auth/login', - getParentRoute: () => rootRouteImport, + id: "/auth/login", + path: "/auth/login", + getParentRoute: () => rootRouteImport, } as any) const AuthDesktopLoginRoute = AuthDesktopLoginRouteImport.update({ - id: '/auth/desktop-login', - path: '/auth/desktop-login', - getParentRoute: () => rootRouteImport, + id: "/auth/desktop-login", + path: "/auth/desktop-login", + getParentRoute: () => rootRouteImport, } as any) const AuthDesktopCallbackRoute = AuthDesktopCallbackRouteImport.update({ - id: '/auth/desktop-callback', - path: '/auth/desktop-callback', - getParentRoute: () => rootRouteImport, + id: "/auth/desktop-callback", + path: "/auth/desktop-callback", + getParentRoute: () => rootRouteImport, } as any) const AuthCallbackRoute = AuthCallbackRouteImport.update({ - id: '/auth/callback', - path: '/auth/callback', - getParentRoute: () => rootRouteImport, + id: "/auth/callback", + path: "/auth/callback", + getParentRoute: () => rootRouteImport, } as any) const DevUiLayoutRoute = DevUiLayoutRouteImport.update({ - id: '/ui', - path: '/ui', - getParentRoute: () => DevLayoutRoute, + id: "/ui", + path: "/ui", + getParentRoute: () => DevLayoutRoute, } as any) const AppOrgSlugLayoutRoute = AppOrgSlugLayoutRouteImport.update({ - id: '/$orgSlug', - path: '/$orgSlug', - getParentRoute: () => AppLayoutRoute, + id: "/$orgSlug", + path: "/$orgSlug", + getParentRoute: () => AppLayoutRoute, } as any) const DevEmbedsIndexRoute = DevEmbedsIndexRouteImport.update({ - id: '/dev/embeds/', - path: '/dev/embeds/', - getParentRoute: () => rootRouteImport, + id: "/dev/embeds/", + path: "/dev/embeds/", + getParentRoute: () => rootRouteImport, +} as any) +const AppSelectOrganizationIndexRoute = AppSelectOrganizationIndexRouteImport.update({ + id: "/select-organization/", + path: "/select-organization/", + getParentRoute: () => AppLayoutRoute, } as any) -const AppSelectOrganizationIndexRoute = - AppSelectOrganizationIndexRouteImport.update({ - id: '/select-organization/', - path: '/select-organization/', - getParentRoute: () => AppLayoutRoute, - } as any) const AppOnboardingIndexRoute = AppOnboardingIndexRouteImport.update({ - id: '/onboarding/', - path: '/onboarding/', - getParentRoute: () => AppLayoutRoute, + id: "/onboarding/", + path: "/onboarding/", + getParentRoute: () => AppLayoutRoute, } as any) const AppOrgSlugIndexRoute = AppOrgSlugIndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => AppOrgSlugLayoutRoute, + id: "/", + path: "/", + getParentRoute: () => AppOrgSlugLayoutRoute, } as any) const DevEmbedsRailwayRoute = DevEmbedsRailwayRouteImport.update({ - id: '/dev/embeds/railway', - path: '/dev/embeds/railway', - getParentRoute: () => rootRouteImport, + id: "/dev/embeds/railway", + path: "/dev/embeds/railway", + getParentRoute: () => rootRouteImport, } as any) const DevEmbedsOpenstatusRoute = DevEmbedsOpenstatusRouteImport.update({ - id: '/dev/embeds/openstatus', - path: '/dev/embeds/openstatus', - getParentRoute: () => rootRouteImport, + id: "/dev/embeds/openstatus", + path: "/dev/embeds/openstatus", + getParentRoute: () => rootRouteImport, } as any) const DevEmbedsGithubRoute = DevEmbedsGithubRouteImport.update({ - id: '/dev/embeds/github', - path: '/dev/embeds/github', - getParentRoute: () => rootRouteImport, + id: "/dev/embeds/github", + path: "/dev/embeds/github", + getParentRoute: () => rootRouteImport, } as any) const DevEmbedsDemoRoute = DevEmbedsDemoRouteImport.update({ - id: '/dev/embeds/demo', - path: '/dev/embeds/demo', - getParentRoute: () => rootRouteImport, + id: "/dev/embeds/demo", + path: "/dev/embeds/demo", + getParentRoute: () => rootRouteImport, } as any) const DevUiAgentStepsRoute = DevUiAgentStepsRouteImport.update({ - id: '/agent-steps', - path: '/agent-steps', - getParentRoute: () => DevUiLayoutRoute, + id: "/agent-steps", + path: "/agent-steps", + getParentRoute: () => DevUiLayoutRoute, +} as any) +const AppOnboardingSetupOrganizationRoute = AppOnboardingSetupOrganizationRouteImport.update({ + id: "/onboarding/setup-organization", + path: "/onboarding/setup-organization", + getParentRoute: () => AppLayoutRoute, +} as any) +const AppOrgSlugSettingsLayoutRoute = AppOrgSlugSettingsLayoutRouteImport.update({ + id: "/settings", + path: "/settings", + getParentRoute: () => AppOrgSlugLayoutRoute, +} as any) +const AppOrgSlugNotificationsLayoutRoute = AppOrgSlugNotificationsLayoutRouteImport.update({ + id: "/notifications", + path: "/notifications", + getParentRoute: () => AppOrgSlugLayoutRoute, +} as any) +const AppOrgSlugMySettingsLayoutRoute = AppOrgSlugMySettingsLayoutRouteImport.update({ + id: "/my-settings", + path: "/my-settings", + getParentRoute: () => AppOrgSlugLayoutRoute, } as any) -const AppOnboardingSetupOrganizationRoute = - AppOnboardingSetupOrganizationRouteImport.update({ - id: '/onboarding/setup-organization', - path: '/onboarding/setup-organization', - getParentRoute: () => AppLayoutRoute, - } as any) -const AppOrgSlugSettingsLayoutRoute = - AppOrgSlugSettingsLayoutRouteImport.update({ - id: '/settings', - path: '/settings', - getParentRoute: () => AppOrgSlugLayoutRoute, - } as any) -const AppOrgSlugNotificationsLayoutRoute = - AppOrgSlugNotificationsLayoutRouteImport.update({ - id: '/notifications', - path: '/notifications', - getParentRoute: () => AppOrgSlugLayoutRoute, - } as any) -const AppOrgSlugMySettingsLayoutRoute = - AppOrgSlugMySettingsLayoutRouteImport.update({ - id: '/my-settings', - path: '/my-settings', - getParentRoute: () => AppOrgSlugLayoutRoute, - } as any) const AppOrgSlugSettingsIndexRoute = AppOrgSlugSettingsIndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => AppOrgSlugSettingsLayoutRoute, + id: "/", + path: "/", + getParentRoute: () => AppOrgSlugSettingsLayoutRoute, +} as any) +const AppOrgSlugNotificationsIndexRoute = AppOrgSlugNotificationsIndexRouteImport.update({ + id: "/", + path: "/", + getParentRoute: () => AppOrgSlugNotificationsLayoutRoute, +} as any) +const AppOrgSlugMySettingsIndexRoute = AppOrgSlugMySettingsIndexRouteImport.update({ + id: "/", + path: "/", + getParentRoute: () => AppOrgSlugMySettingsLayoutRoute, } as any) -const AppOrgSlugNotificationsIndexRoute = - AppOrgSlugNotificationsIndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => AppOrgSlugNotificationsLayoutRoute, - } as any) -const AppOrgSlugMySettingsIndexRoute = - AppOrgSlugMySettingsIndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => AppOrgSlugMySettingsLayoutRoute, - } as any) const AppOrgSlugChatIndexRoute = AppOrgSlugChatIndexRouteImport.update({ - id: '/chat/', - path: '/chat/', - getParentRoute: () => AppOrgSlugLayoutRoute, + id: "/chat/", + path: "/chat/", + getParentRoute: () => AppOrgSlugLayoutRoute, } as any) const AppOrgSlugSettingsTeamRoute = AppOrgSlugSettingsTeamRouteImport.update({ - id: '/team', - path: '/team', - getParentRoute: () => AppOrgSlugSettingsLayoutRoute, + id: "/team", + path: "/team", + getParentRoute: () => AppOrgSlugSettingsLayoutRoute, +} as any) +const AppOrgSlugSettingsInvitationsRoute = AppOrgSlugSettingsInvitationsRouteImport.update({ + id: "/invitations", + path: "/invitations", + getParentRoute: () => AppOrgSlugSettingsLayoutRoute, } as any) -const AppOrgSlugSettingsInvitationsRoute = - AppOrgSlugSettingsInvitationsRouteImport.update({ - id: '/invitations', - path: '/invitations', - getParentRoute: () => AppOrgSlugSettingsLayoutRoute, - } as any) const AppOrgSlugSettingsDebugRoute = AppOrgSlugSettingsDebugRouteImport.update({ - id: '/debug', - path: '/debug', - getParentRoute: () => AppOrgSlugSettingsLayoutRoute, + id: "/debug", + path: "/debug", + getParentRoute: () => AppOrgSlugSettingsLayoutRoute, +} as any) +const AppOrgSlugSettingsCustomEmojisRoute = AppOrgSlugSettingsCustomEmojisRouteImport.update({ + id: "/custom-emojis", + path: "/custom-emojis", + getParentRoute: () => AppOrgSlugSettingsLayoutRoute, +} as any) +const AppOrgSlugSettingsConnectInvitesRoute = AppOrgSlugSettingsConnectInvitesRouteImport.update({ + id: "/connect-invites", + path: "/connect-invites", + getParentRoute: () => AppOrgSlugSettingsLayoutRoute, +} as any) +const AppOrgSlugSettingsAuthenticationRoute = AppOrgSlugSettingsAuthenticationRouteImport.update({ + id: "/authentication", + path: "/authentication", + getParentRoute: () => AppOrgSlugSettingsLayoutRoute, } as any) -const AppOrgSlugSettingsCustomEmojisRoute = - AppOrgSlugSettingsCustomEmojisRouteImport.update({ - id: '/custom-emojis', - path: '/custom-emojis', - getParentRoute: () => AppOrgSlugSettingsLayoutRoute, - } as any) -const AppOrgSlugSettingsConnectInvitesRoute = - AppOrgSlugSettingsConnectInvitesRouteImport.update({ - id: '/connect-invites', - path: '/connect-invites', - getParentRoute: () => AppOrgSlugSettingsLayoutRoute, - } as any) -const AppOrgSlugSettingsAuthenticationRoute = - AppOrgSlugSettingsAuthenticationRouteImport.update({ - id: '/authentication', - path: '/authentication', - getParentRoute: () => AppOrgSlugSettingsLayoutRoute, - } as any) const AppOrgSlugProfileUserIdRoute = AppOrgSlugProfileUserIdRouteImport.update({ - id: '/profile/$userId', - path: '/profile/$userId', - getParentRoute: () => AppOrgSlugLayoutRoute, + id: "/profile/$userId", + path: "/profile/$userId", + getParentRoute: () => AppOrgSlugLayoutRoute, +} as any) +const AppOrgSlugNotificationsThreadsRoute = AppOrgSlugNotificationsThreadsRouteImport.update({ + id: "/threads", + path: "/threads", + getParentRoute: () => AppOrgSlugNotificationsLayoutRoute, +} as any) +const AppOrgSlugNotificationsGeneralRoute = AppOrgSlugNotificationsGeneralRouteImport.update({ + id: "/general", + path: "/general", + getParentRoute: () => AppOrgSlugNotificationsLayoutRoute, +} as any) +const AppOrgSlugNotificationsDmsRoute = AppOrgSlugNotificationsDmsRouteImport.update({ + id: "/dms", + path: "/dms", + getParentRoute: () => AppOrgSlugNotificationsLayoutRoute, +} as any) +const AppOrgSlugMySettingsProfileRoute = AppOrgSlugMySettingsProfileRouteImport.update({ + id: "/profile", + path: "/profile", + getParentRoute: () => AppOrgSlugMySettingsLayoutRoute, +} as any) +const AppOrgSlugMySettingsNotificationsRoute = AppOrgSlugMySettingsNotificationsRouteImport.update({ + id: "/notifications", + path: "/notifications", + getParentRoute: () => AppOrgSlugMySettingsLayoutRoute, +} as any) +const AppOrgSlugMySettingsLinkedAccountsRoute = AppOrgSlugMySettingsLinkedAccountsRouteImport.update({ + id: "/linked-accounts", + path: "/linked-accounts", + getParentRoute: () => AppOrgSlugMySettingsLayoutRoute, +} as any) +const AppOrgSlugMySettingsDesktopRoute = AppOrgSlugMySettingsDesktopRouteImport.update({ + id: "/desktop", + path: "/desktop", + getParentRoute: () => AppOrgSlugMySettingsLayoutRoute, } as any) -const AppOrgSlugNotificationsThreadsRoute = - AppOrgSlugNotificationsThreadsRouteImport.update({ - id: '/threads', - path: '/threads', - getParentRoute: () => AppOrgSlugNotificationsLayoutRoute, - } as any) -const AppOrgSlugNotificationsGeneralRoute = - AppOrgSlugNotificationsGeneralRouteImport.update({ - id: '/general', - path: '/general', - getParentRoute: () => AppOrgSlugNotificationsLayoutRoute, - } as any) -const AppOrgSlugNotificationsDmsRoute = - AppOrgSlugNotificationsDmsRouteImport.update({ - id: '/dms', - path: '/dms', - getParentRoute: () => AppOrgSlugNotificationsLayoutRoute, - } as any) -const AppOrgSlugMySettingsProfileRoute = - AppOrgSlugMySettingsProfileRouteImport.update({ - id: '/profile', - path: '/profile', - getParentRoute: () => AppOrgSlugMySettingsLayoutRoute, - } as any) -const AppOrgSlugMySettingsNotificationsRoute = - AppOrgSlugMySettingsNotificationsRouteImport.update({ - id: '/notifications', - path: '/notifications', - getParentRoute: () => AppOrgSlugMySettingsLayoutRoute, - } as any) -const AppOrgSlugMySettingsLinkedAccountsRoute = - AppOrgSlugMySettingsLinkedAccountsRouteImport.update({ - id: '/linked-accounts', - path: '/linked-accounts', - getParentRoute: () => AppOrgSlugMySettingsLayoutRoute, - } as any) -const AppOrgSlugMySettingsDesktopRoute = - AppOrgSlugMySettingsDesktopRouteImport.update({ - id: '/desktop', - path: '/desktop', - getParentRoute: () => AppOrgSlugMySettingsLayoutRoute, - } as any) const AppOrgSlugChatIdRoute = AppOrgSlugChatIdRouteImport.update({ - id: '/chat/$id', - path: '/chat/$id', - getParentRoute: () => AppOrgSlugLayoutRoute, + id: "/chat/$id", + path: "/chat/$id", + getParentRoute: () => AppOrgSlugLayoutRoute, +} as any) +const AppOrgSlugSettingsIntegrationsLayoutRoute = AppOrgSlugSettingsIntegrationsLayoutRouteImport.update({ + id: "/integrations", + path: "/integrations", + getParentRoute: () => AppOrgSlugSettingsLayoutRoute, +} as any) +const AppOrgSlugSettingsChatSyncLayoutRoute = AppOrgSlugSettingsChatSyncLayoutRouteImport.update({ + id: "/chat-sync", + path: "/chat-sync", + getParentRoute: () => AppOrgSlugSettingsLayoutRoute, +} as any) +const AppOrgSlugSettingsIntegrationsIndexRoute = AppOrgSlugSettingsIntegrationsIndexRouteImport.update({ + id: "/", + path: "/", + getParentRoute: () => AppOrgSlugSettingsIntegrationsLayoutRoute, +} as any) +const AppOrgSlugSettingsChatSyncIndexRoute = AppOrgSlugSettingsChatSyncIndexRouteImport.update({ + id: "/", + path: "/", + getParentRoute: () => AppOrgSlugSettingsChatSyncLayoutRoute, } as any) -const AppOrgSlugSettingsIntegrationsLayoutRoute = - AppOrgSlugSettingsIntegrationsLayoutRouteImport.update({ - id: '/integrations', - path: '/integrations', - getParentRoute: () => AppOrgSlugSettingsLayoutRoute, - } as any) -const AppOrgSlugSettingsChatSyncLayoutRoute = - AppOrgSlugSettingsChatSyncLayoutRouteImport.update({ - id: '/chat-sync', - path: '/chat-sync', - getParentRoute: () => AppOrgSlugSettingsLayoutRoute, - } as any) -const AppOrgSlugSettingsIntegrationsIndexRoute = - AppOrgSlugSettingsIntegrationsIndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => AppOrgSlugSettingsIntegrationsLayoutRoute, - } as any) -const AppOrgSlugSettingsChatSyncIndexRoute = - AppOrgSlugSettingsChatSyncIndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => AppOrgSlugSettingsChatSyncLayoutRoute, - } as any) const AppOrgSlugChatIdIndexRoute = AppOrgSlugChatIdIndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => AppOrgSlugChatIdRoute, + id: "/", + path: "/", + getParentRoute: () => AppOrgSlugChatIdRoute, +} as any) +const AppOrgSlugSettingsIntegrationsYourAppsRoute = AppOrgSlugSettingsIntegrationsYourAppsRouteImport.update({ + id: "/your-apps", + path: "/your-apps", + getParentRoute: () => AppOrgSlugSettingsIntegrationsLayoutRoute, } as any) -const AppOrgSlugSettingsIntegrationsYourAppsRoute = - AppOrgSlugSettingsIntegrationsYourAppsRouteImport.update({ - id: '/your-apps', - path: '/your-apps', - getParentRoute: () => AppOrgSlugSettingsIntegrationsLayoutRoute, - } as any) const AppOrgSlugSettingsIntegrationsMarketplaceRoute = - AppOrgSlugSettingsIntegrationsMarketplaceRouteImport.update({ - id: '/marketplace', - path: '/marketplace', - getParentRoute: () => AppOrgSlugSettingsIntegrationsLayoutRoute, - } as any) + AppOrgSlugSettingsIntegrationsMarketplaceRouteImport.update({ + id: "/marketplace", + path: "/marketplace", + getParentRoute: () => AppOrgSlugSettingsIntegrationsLayoutRoute, + } as any) const AppOrgSlugSettingsIntegrationsInstalledRoute = - AppOrgSlugSettingsIntegrationsInstalledRouteImport.update({ - id: '/installed', - path: '/installed', - getParentRoute: () => AppOrgSlugSettingsIntegrationsLayoutRoute, - } as any) + AppOrgSlugSettingsIntegrationsInstalledRouteImport.update({ + id: "/installed", + path: "/installed", + getParentRoute: () => AppOrgSlugSettingsIntegrationsLayoutRoute, + } as any) const AppOrgSlugSettingsIntegrationsIntegrationIdRoute = - AppOrgSlugSettingsIntegrationsIntegrationIdRouteImport.update({ - id: '/$integrationId', - path: '/$integrationId', - getParentRoute: () => AppOrgSlugSettingsIntegrationsLayoutRoute, - } as any) -const AppOrgSlugSettingsChatSyncConnectionIdRoute = - AppOrgSlugSettingsChatSyncConnectionIdRouteImport.update({ - id: '/$connectionId', - path: '/$connectionId', - getParentRoute: () => AppOrgSlugSettingsChatSyncLayoutRoute, - } as any) + AppOrgSlugSettingsIntegrationsIntegrationIdRouteImport.update({ + id: "/$integrationId", + path: "/$integrationId", + getParentRoute: () => AppOrgSlugSettingsIntegrationsLayoutRoute, + } as any) +const AppOrgSlugSettingsChatSyncConnectionIdRoute = AppOrgSlugSettingsChatSyncConnectionIdRouteImport.update({ + id: "/$connectionId", + path: "/$connectionId", + getParentRoute: () => AppOrgSlugSettingsChatSyncLayoutRoute, +} as any) const AppOrgSlugChannelsChannelIdSettingsLayoutRoute = - AppOrgSlugChannelsChannelIdSettingsLayoutRouteImport.update({ - id: '/channels/$channelId/settings', - path: '/channels/$channelId/settings', - getParentRoute: () => AppOrgSlugLayoutRoute, - } as any) -const AppOrgSlugChatIdFilesIndexRoute = - AppOrgSlugChatIdFilesIndexRouteImport.update({ - id: '/files/', - path: '/files/', - getParentRoute: () => AppOrgSlugChatIdRoute, - } as any) + AppOrgSlugChannelsChannelIdSettingsLayoutRouteImport.update({ + id: "/channels/$channelId/settings", + path: "/channels/$channelId/settings", + getParentRoute: () => AppOrgSlugLayoutRoute, + } as any) +const AppOrgSlugChatIdFilesIndexRoute = AppOrgSlugChatIdFilesIndexRouteImport.update({ + id: "/files/", + path: "/files/", + getParentRoute: () => AppOrgSlugChatIdRoute, +} as any) const AppOrgSlugChannelsChannelIdSettingsIndexRoute = - AppOrgSlugChannelsChannelIdSettingsIndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => AppOrgSlugChannelsChannelIdSettingsLayoutRoute, - } as any) -const AppOrgSlugChatIdFilesMediaRoute = - AppOrgSlugChatIdFilesMediaRouteImport.update({ - id: '/files/media', - path: '/files/media', - getParentRoute: () => AppOrgSlugChatIdRoute, - } as any) + AppOrgSlugChannelsChannelIdSettingsIndexRouteImport.update({ + id: "/", + path: "/", + getParentRoute: () => AppOrgSlugChannelsChannelIdSettingsLayoutRoute, + } as any) +const AppOrgSlugChatIdFilesMediaRoute = AppOrgSlugChatIdFilesMediaRouteImport.update({ + id: "/files/media", + path: "/files/media", + getParentRoute: () => AppOrgSlugChatIdRoute, +} as any) const AppOrgSlugChannelsChannelIdSettingsOverviewRoute = - AppOrgSlugChannelsChannelIdSettingsOverviewRouteImport.update({ - id: '/overview', - path: '/overview', - getParentRoute: () => AppOrgSlugChannelsChannelIdSettingsLayoutRoute, - } as any) + AppOrgSlugChannelsChannelIdSettingsOverviewRouteImport.update({ + id: "/overview", + path: "/overview", + getParentRoute: () => AppOrgSlugChannelsChannelIdSettingsLayoutRoute, + } as any) const AppOrgSlugChannelsChannelIdSettingsIntegrationsRoute = - AppOrgSlugChannelsChannelIdSettingsIntegrationsRouteImport.update({ - id: '/integrations', - path: '/integrations', - getParentRoute: () => AppOrgSlugChannelsChannelIdSettingsLayoutRoute, - } as any) + AppOrgSlugChannelsChannelIdSettingsIntegrationsRouteImport.update({ + id: "/integrations", + path: "/integrations", + getParentRoute: () => AppOrgSlugChannelsChannelIdSettingsLayoutRoute, + } as any) const AppOrgSlugChannelsChannelIdSettingsConnectRoute = - AppOrgSlugChannelsChannelIdSettingsConnectRouteImport.update({ - id: '/connect', - path: '/connect', - getParentRoute: () => AppOrgSlugChannelsChannelIdSettingsLayoutRoute, - } as any) + AppOrgSlugChannelsChannelIdSettingsConnectRouteImport.update({ + id: "/connect", + path: "/connect", + getParentRoute: () => AppOrgSlugChannelsChannelIdSettingsLayoutRoute, + } as any) export interface FileRoutesByFullPath { - '/': typeof AppIndexRoute - '/$orgSlug': typeof AppOrgSlugLayoutRouteWithChildren - '/ui': typeof DevUiLayoutRouteWithChildren - '/auth/callback': typeof AuthCallbackRoute - '/auth/desktop-callback': typeof AuthDesktopCallbackRoute - '/auth/desktop-login': typeof AuthDesktopLoginRoute - '/auth/login': typeof AuthLoginRoute - '/join/$slug': typeof JoinSlugRoute - '/$orgSlug/my-settings': typeof AppOrgSlugMySettingsLayoutRouteWithChildren - '/$orgSlug/notifications': typeof AppOrgSlugNotificationsLayoutRouteWithChildren - '/$orgSlug/settings': typeof AppOrgSlugSettingsLayoutRouteWithChildren - '/onboarding/setup-organization': typeof AppOnboardingSetupOrganizationRoute - '/ui/agent-steps': typeof DevUiAgentStepsRoute - '/dev/embeds/demo': typeof DevEmbedsDemoRoute - '/dev/embeds/github': typeof DevEmbedsGithubRoute - '/dev/embeds/openstatus': typeof DevEmbedsOpenstatusRoute - '/dev/embeds/railway': typeof DevEmbedsRailwayRoute - '/$orgSlug/': typeof AppOrgSlugIndexRoute - '/onboarding/': typeof AppOnboardingIndexRoute - '/select-organization/': typeof AppSelectOrganizationIndexRoute - '/dev/embeds/': typeof DevEmbedsIndexRoute - '/$orgSlug/settings/chat-sync': typeof AppOrgSlugSettingsChatSyncLayoutRouteWithChildren - '/$orgSlug/settings/integrations': typeof AppOrgSlugSettingsIntegrationsLayoutRouteWithChildren - '/$orgSlug/chat/$id': typeof AppOrgSlugChatIdRouteWithChildren - '/$orgSlug/my-settings/desktop': typeof AppOrgSlugMySettingsDesktopRoute - '/$orgSlug/my-settings/linked-accounts': typeof AppOrgSlugMySettingsLinkedAccountsRoute - '/$orgSlug/my-settings/notifications': typeof AppOrgSlugMySettingsNotificationsRoute - '/$orgSlug/my-settings/profile': typeof AppOrgSlugMySettingsProfileRoute - '/$orgSlug/notifications/dms': typeof AppOrgSlugNotificationsDmsRoute - '/$orgSlug/notifications/general': typeof AppOrgSlugNotificationsGeneralRoute - '/$orgSlug/notifications/threads': typeof AppOrgSlugNotificationsThreadsRoute - '/$orgSlug/profile/$userId': typeof AppOrgSlugProfileUserIdRoute - '/$orgSlug/settings/authentication': typeof AppOrgSlugSettingsAuthenticationRoute - '/$orgSlug/settings/connect-invites': typeof AppOrgSlugSettingsConnectInvitesRoute - '/$orgSlug/settings/custom-emojis': typeof AppOrgSlugSettingsCustomEmojisRoute - '/$orgSlug/settings/debug': typeof AppOrgSlugSettingsDebugRoute - '/$orgSlug/settings/invitations': typeof AppOrgSlugSettingsInvitationsRoute - '/$orgSlug/settings/team': typeof AppOrgSlugSettingsTeamRoute - '/$orgSlug/chat/': typeof AppOrgSlugChatIndexRoute - '/$orgSlug/my-settings/': typeof AppOrgSlugMySettingsIndexRoute - '/$orgSlug/notifications/': typeof AppOrgSlugNotificationsIndexRoute - '/$orgSlug/settings/': typeof AppOrgSlugSettingsIndexRoute - '/$orgSlug/channels/$channelId/settings': typeof AppOrgSlugChannelsChannelIdSettingsLayoutRouteWithChildren - '/$orgSlug/settings/chat-sync/$connectionId': typeof AppOrgSlugSettingsChatSyncConnectionIdRoute - '/$orgSlug/settings/integrations/$integrationId': typeof AppOrgSlugSettingsIntegrationsIntegrationIdRoute - '/$orgSlug/settings/integrations/installed': typeof AppOrgSlugSettingsIntegrationsInstalledRoute - '/$orgSlug/settings/integrations/marketplace': typeof AppOrgSlugSettingsIntegrationsMarketplaceRoute - '/$orgSlug/settings/integrations/your-apps': typeof AppOrgSlugSettingsIntegrationsYourAppsRoute - '/$orgSlug/chat/$id/': typeof AppOrgSlugChatIdIndexRoute - '/$orgSlug/settings/chat-sync/': typeof AppOrgSlugSettingsChatSyncIndexRoute - '/$orgSlug/settings/integrations/': typeof AppOrgSlugSettingsIntegrationsIndexRoute - '/$orgSlug/channels/$channelId/settings/connect': typeof AppOrgSlugChannelsChannelIdSettingsConnectRoute - '/$orgSlug/channels/$channelId/settings/integrations': typeof AppOrgSlugChannelsChannelIdSettingsIntegrationsRoute - '/$orgSlug/channels/$channelId/settings/overview': typeof AppOrgSlugChannelsChannelIdSettingsOverviewRoute - '/$orgSlug/chat/$id/files/media': typeof AppOrgSlugChatIdFilesMediaRoute - '/$orgSlug/channels/$channelId/settings/': typeof AppOrgSlugChannelsChannelIdSettingsIndexRoute - '/$orgSlug/chat/$id/files/': typeof AppOrgSlugChatIdFilesIndexRoute + "/": typeof AppIndexRoute + "/$orgSlug": typeof AppOrgSlugLayoutRouteWithChildren + "/ui": typeof DevUiLayoutRouteWithChildren + "/auth/callback": typeof AuthCallbackRoute + "/auth/desktop-callback": typeof AuthDesktopCallbackRoute + "/auth/desktop-login": typeof AuthDesktopLoginRoute + "/auth/login": typeof AuthLoginRoute + "/join/$slug": typeof JoinSlugRoute + "/$orgSlug/my-settings": typeof AppOrgSlugMySettingsLayoutRouteWithChildren + "/$orgSlug/notifications": typeof AppOrgSlugNotificationsLayoutRouteWithChildren + "/$orgSlug/settings": typeof AppOrgSlugSettingsLayoutRouteWithChildren + "/onboarding/setup-organization": typeof AppOnboardingSetupOrganizationRoute + "/ui/agent-steps": typeof DevUiAgentStepsRoute + "/dev/embeds/demo": typeof DevEmbedsDemoRoute + "/dev/embeds/github": typeof DevEmbedsGithubRoute + "/dev/embeds/openstatus": typeof DevEmbedsOpenstatusRoute + "/dev/embeds/railway": typeof DevEmbedsRailwayRoute + "/$orgSlug/": typeof AppOrgSlugIndexRoute + "/onboarding/": typeof AppOnboardingIndexRoute + "/select-organization/": typeof AppSelectOrganizationIndexRoute + "/dev/embeds/": typeof DevEmbedsIndexRoute + "/$orgSlug/settings/chat-sync": typeof AppOrgSlugSettingsChatSyncLayoutRouteWithChildren + "/$orgSlug/settings/integrations": typeof AppOrgSlugSettingsIntegrationsLayoutRouteWithChildren + "/$orgSlug/chat/$id": typeof AppOrgSlugChatIdRouteWithChildren + "/$orgSlug/my-settings/desktop": typeof AppOrgSlugMySettingsDesktopRoute + "/$orgSlug/my-settings/linked-accounts": typeof AppOrgSlugMySettingsLinkedAccountsRoute + "/$orgSlug/my-settings/notifications": typeof AppOrgSlugMySettingsNotificationsRoute + "/$orgSlug/my-settings/profile": typeof AppOrgSlugMySettingsProfileRoute + "/$orgSlug/notifications/dms": typeof AppOrgSlugNotificationsDmsRoute + "/$orgSlug/notifications/general": typeof AppOrgSlugNotificationsGeneralRoute + "/$orgSlug/notifications/threads": typeof AppOrgSlugNotificationsThreadsRoute + "/$orgSlug/profile/$userId": typeof AppOrgSlugProfileUserIdRoute + "/$orgSlug/settings/authentication": typeof AppOrgSlugSettingsAuthenticationRoute + "/$orgSlug/settings/connect-invites": typeof AppOrgSlugSettingsConnectInvitesRoute + "/$orgSlug/settings/custom-emojis": typeof AppOrgSlugSettingsCustomEmojisRoute + "/$orgSlug/settings/debug": typeof AppOrgSlugSettingsDebugRoute + "/$orgSlug/settings/invitations": typeof AppOrgSlugSettingsInvitationsRoute + "/$orgSlug/settings/team": typeof AppOrgSlugSettingsTeamRoute + "/$orgSlug/chat/": typeof AppOrgSlugChatIndexRoute + "/$orgSlug/my-settings/": typeof AppOrgSlugMySettingsIndexRoute + "/$orgSlug/notifications/": typeof AppOrgSlugNotificationsIndexRoute + "/$orgSlug/settings/": typeof AppOrgSlugSettingsIndexRoute + "/$orgSlug/channels/$channelId/settings": typeof AppOrgSlugChannelsChannelIdSettingsLayoutRouteWithChildren + "/$orgSlug/settings/chat-sync/$connectionId": typeof AppOrgSlugSettingsChatSyncConnectionIdRoute + "/$orgSlug/settings/integrations/$integrationId": typeof AppOrgSlugSettingsIntegrationsIntegrationIdRoute + "/$orgSlug/settings/integrations/installed": typeof AppOrgSlugSettingsIntegrationsInstalledRoute + "/$orgSlug/settings/integrations/marketplace": typeof AppOrgSlugSettingsIntegrationsMarketplaceRoute + "/$orgSlug/settings/integrations/your-apps": typeof AppOrgSlugSettingsIntegrationsYourAppsRoute + "/$orgSlug/chat/$id/": typeof AppOrgSlugChatIdIndexRoute + "/$orgSlug/settings/chat-sync/": typeof AppOrgSlugSettingsChatSyncIndexRoute + "/$orgSlug/settings/integrations/": typeof AppOrgSlugSettingsIntegrationsIndexRoute + "/$orgSlug/channels/$channelId/settings/connect": typeof AppOrgSlugChannelsChannelIdSettingsConnectRoute + "/$orgSlug/channels/$channelId/settings/integrations": typeof AppOrgSlugChannelsChannelIdSettingsIntegrationsRoute + "/$orgSlug/channels/$channelId/settings/overview": typeof AppOrgSlugChannelsChannelIdSettingsOverviewRoute + "/$orgSlug/chat/$id/files/media": typeof AppOrgSlugChatIdFilesMediaRoute + "/$orgSlug/channels/$channelId/settings/": typeof AppOrgSlugChannelsChannelIdSettingsIndexRoute + "/$orgSlug/chat/$id/files/": typeof AppOrgSlugChatIdFilesIndexRoute } export interface FileRoutesByTo { - '/': typeof AppIndexRoute - '/ui': typeof DevUiLayoutRouteWithChildren - '/auth/callback': typeof AuthCallbackRoute - '/auth/desktop-callback': typeof AuthDesktopCallbackRoute - '/auth/desktop-login': typeof AuthDesktopLoginRoute - '/auth/login': typeof AuthLoginRoute - '/join/$slug': typeof JoinSlugRoute - '/onboarding/setup-organization': typeof AppOnboardingSetupOrganizationRoute - '/ui/agent-steps': typeof DevUiAgentStepsRoute - '/dev/embeds/demo': typeof DevEmbedsDemoRoute - '/dev/embeds/github': typeof DevEmbedsGithubRoute - '/dev/embeds/openstatus': typeof DevEmbedsOpenstatusRoute - '/dev/embeds/railway': typeof DevEmbedsRailwayRoute - '/$orgSlug': typeof AppOrgSlugIndexRoute - '/onboarding': typeof AppOnboardingIndexRoute - '/select-organization': typeof AppSelectOrganizationIndexRoute - '/dev/embeds': typeof DevEmbedsIndexRoute - '/$orgSlug/my-settings/desktop': typeof AppOrgSlugMySettingsDesktopRoute - '/$orgSlug/my-settings/linked-accounts': typeof AppOrgSlugMySettingsLinkedAccountsRoute - '/$orgSlug/my-settings/notifications': typeof AppOrgSlugMySettingsNotificationsRoute - '/$orgSlug/my-settings/profile': typeof AppOrgSlugMySettingsProfileRoute - '/$orgSlug/notifications/dms': typeof AppOrgSlugNotificationsDmsRoute - '/$orgSlug/notifications/general': typeof AppOrgSlugNotificationsGeneralRoute - '/$orgSlug/notifications/threads': typeof AppOrgSlugNotificationsThreadsRoute - '/$orgSlug/profile/$userId': typeof AppOrgSlugProfileUserIdRoute - '/$orgSlug/settings/authentication': typeof AppOrgSlugSettingsAuthenticationRoute - '/$orgSlug/settings/connect-invites': typeof AppOrgSlugSettingsConnectInvitesRoute - '/$orgSlug/settings/custom-emojis': typeof AppOrgSlugSettingsCustomEmojisRoute - '/$orgSlug/settings/debug': typeof AppOrgSlugSettingsDebugRoute - '/$orgSlug/settings/invitations': typeof AppOrgSlugSettingsInvitationsRoute - '/$orgSlug/settings/team': typeof AppOrgSlugSettingsTeamRoute - '/$orgSlug/chat': typeof AppOrgSlugChatIndexRoute - '/$orgSlug/my-settings': typeof AppOrgSlugMySettingsIndexRoute - '/$orgSlug/notifications': typeof AppOrgSlugNotificationsIndexRoute - '/$orgSlug/settings': typeof AppOrgSlugSettingsIndexRoute - '/$orgSlug/settings/chat-sync/$connectionId': typeof AppOrgSlugSettingsChatSyncConnectionIdRoute - '/$orgSlug/settings/integrations/$integrationId': typeof AppOrgSlugSettingsIntegrationsIntegrationIdRoute - '/$orgSlug/settings/integrations/installed': typeof AppOrgSlugSettingsIntegrationsInstalledRoute - '/$orgSlug/settings/integrations/marketplace': typeof AppOrgSlugSettingsIntegrationsMarketplaceRoute - '/$orgSlug/settings/integrations/your-apps': typeof AppOrgSlugSettingsIntegrationsYourAppsRoute - '/$orgSlug/chat/$id': typeof AppOrgSlugChatIdIndexRoute - '/$orgSlug/settings/chat-sync': typeof AppOrgSlugSettingsChatSyncIndexRoute - '/$orgSlug/settings/integrations': typeof AppOrgSlugSettingsIntegrationsIndexRoute - '/$orgSlug/channels/$channelId/settings/connect': typeof AppOrgSlugChannelsChannelIdSettingsConnectRoute - '/$orgSlug/channels/$channelId/settings/integrations': typeof AppOrgSlugChannelsChannelIdSettingsIntegrationsRoute - '/$orgSlug/channels/$channelId/settings/overview': typeof AppOrgSlugChannelsChannelIdSettingsOverviewRoute - '/$orgSlug/chat/$id/files/media': typeof AppOrgSlugChatIdFilesMediaRoute - '/$orgSlug/channels/$channelId/settings': typeof AppOrgSlugChannelsChannelIdSettingsIndexRoute - '/$orgSlug/chat/$id/files': typeof AppOrgSlugChatIdFilesIndexRoute + "/": typeof AppIndexRoute + "/ui": typeof DevUiLayoutRouteWithChildren + "/auth/callback": typeof AuthCallbackRoute + "/auth/desktop-callback": typeof AuthDesktopCallbackRoute + "/auth/desktop-login": typeof AuthDesktopLoginRoute + "/auth/login": typeof AuthLoginRoute + "/join/$slug": typeof JoinSlugRoute + "/onboarding/setup-organization": typeof AppOnboardingSetupOrganizationRoute + "/ui/agent-steps": typeof DevUiAgentStepsRoute + "/dev/embeds/demo": typeof DevEmbedsDemoRoute + "/dev/embeds/github": typeof DevEmbedsGithubRoute + "/dev/embeds/openstatus": typeof DevEmbedsOpenstatusRoute + "/dev/embeds/railway": typeof DevEmbedsRailwayRoute + "/$orgSlug": typeof AppOrgSlugIndexRoute + "/onboarding": typeof AppOnboardingIndexRoute + "/select-organization": typeof AppSelectOrganizationIndexRoute + "/dev/embeds": typeof DevEmbedsIndexRoute + "/$orgSlug/my-settings/desktop": typeof AppOrgSlugMySettingsDesktopRoute + "/$orgSlug/my-settings/linked-accounts": typeof AppOrgSlugMySettingsLinkedAccountsRoute + "/$orgSlug/my-settings/notifications": typeof AppOrgSlugMySettingsNotificationsRoute + "/$orgSlug/my-settings/profile": typeof AppOrgSlugMySettingsProfileRoute + "/$orgSlug/notifications/dms": typeof AppOrgSlugNotificationsDmsRoute + "/$orgSlug/notifications/general": typeof AppOrgSlugNotificationsGeneralRoute + "/$orgSlug/notifications/threads": typeof AppOrgSlugNotificationsThreadsRoute + "/$orgSlug/profile/$userId": typeof AppOrgSlugProfileUserIdRoute + "/$orgSlug/settings/authentication": typeof AppOrgSlugSettingsAuthenticationRoute + "/$orgSlug/settings/connect-invites": typeof AppOrgSlugSettingsConnectInvitesRoute + "/$orgSlug/settings/custom-emojis": typeof AppOrgSlugSettingsCustomEmojisRoute + "/$orgSlug/settings/debug": typeof AppOrgSlugSettingsDebugRoute + "/$orgSlug/settings/invitations": typeof AppOrgSlugSettingsInvitationsRoute + "/$orgSlug/settings/team": typeof AppOrgSlugSettingsTeamRoute + "/$orgSlug/chat": typeof AppOrgSlugChatIndexRoute + "/$orgSlug/my-settings": typeof AppOrgSlugMySettingsIndexRoute + "/$orgSlug/notifications": typeof AppOrgSlugNotificationsIndexRoute + "/$orgSlug/settings": typeof AppOrgSlugSettingsIndexRoute + "/$orgSlug/settings/chat-sync/$connectionId": typeof AppOrgSlugSettingsChatSyncConnectionIdRoute + "/$orgSlug/settings/integrations/$integrationId": typeof AppOrgSlugSettingsIntegrationsIntegrationIdRoute + "/$orgSlug/settings/integrations/installed": typeof AppOrgSlugSettingsIntegrationsInstalledRoute + "/$orgSlug/settings/integrations/marketplace": typeof AppOrgSlugSettingsIntegrationsMarketplaceRoute + "/$orgSlug/settings/integrations/your-apps": typeof AppOrgSlugSettingsIntegrationsYourAppsRoute + "/$orgSlug/chat/$id": typeof AppOrgSlugChatIdIndexRoute + "/$orgSlug/settings/chat-sync": typeof AppOrgSlugSettingsChatSyncIndexRoute + "/$orgSlug/settings/integrations": typeof AppOrgSlugSettingsIntegrationsIndexRoute + "/$orgSlug/channels/$channelId/settings/connect": typeof AppOrgSlugChannelsChannelIdSettingsConnectRoute + "/$orgSlug/channels/$channelId/settings/integrations": typeof AppOrgSlugChannelsChannelIdSettingsIntegrationsRoute + "/$orgSlug/channels/$channelId/settings/overview": typeof AppOrgSlugChannelsChannelIdSettingsOverviewRoute + "/$orgSlug/chat/$id/files/media": typeof AppOrgSlugChatIdFilesMediaRoute + "/$orgSlug/channels/$channelId/settings": typeof AppOrgSlugChannelsChannelIdSettingsIndexRoute + "/$orgSlug/chat/$id/files": typeof AppOrgSlugChatIdFilesIndexRoute } export interface FileRoutesById { - __root__: typeof rootRouteImport - '/_app': typeof AppLayoutRouteWithChildren - '/_dev': typeof DevLayoutRouteWithChildren - '/_app/$orgSlug': typeof AppOrgSlugLayoutRouteWithChildren - '/_dev/ui': typeof DevUiLayoutRouteWithChildren - '/auth/callback': typeof AuthCallbackRoute - '/auth/desktop-callback': typeof AuthDesktopCallbackRoute - '/auth/desktop-login': typeof AuthDesktopLoginRoute - '/auth/login': typeof AuthLoginRoute - '/join/$slug': typeof JoinSlugRoute - '/_app/': typeof AppIndexRoute - '/_app/$orgSlug/my-settings': typeof AppOrgSlugMySettingsLayoutRouteWithChildren - '/_app/$orgSlug/notifications': typeof AppOrgSlugNotificationsLayoutRouteWithChildren - '/_app/$orgSlug/settings': typeof AppOrgSlugSettingsLayoutRouteWithChildren - '/_app/onboarding/setup-organization': typeof AppOnboardingSetupOrganizationRoute - '/_dev/ui/agent-steps': typeof DevUiAgentStepsRoute - '/dev/embeds/demo': typeof DevEmbedsDemoRoute - '/dev/embeds/github': typeof DevEmbedsGithubRoute - '/dev/embeds/openstatus': typeof DevEmbedsOpenstatusRoute - '/dev/embeds/railway': typeof DevEmbedsRailwayRoute - '/_app/$orgSlug/': typeof AppOrgSlugIndexRoute - '/_app/onboarding/': typeof AppOnboardingIndexRoute - '/_app/select-organization/': typeof AppSelectOrganizationIndexRoute - '/dev/embeds/': typeof DevEmbedsIndexRoute - '/_app/$orgSlug/settings/chat-sync': typeof AppOrgSlugSettingsChatSyncLayoutRouteWithChildren - '/_app/$orgSlug/settings/integrations': typeof AppOrgSlugSettingsIntegrationsLayoutRouteWithChildren - '/_app/$orgSlug/chat/$id': typeof AppOrgSlugChatIdRouteWithChildren - '/_app/$orgSlug/my-settings/desktop': typeof AppOrgSlugMySettingsDesktopRoute - '/_app/$orgSlug/my-settings/linked-accounts': typeof AppOrgSlugMySettingsLinkedAccountsRoute - '/_app/$orgSlug/my-settings/notifications': typeof AppOrgSlugMySettingsNotificationsRoute - '/_app/$orgSlug/my-settings/profile': typeof AppOrgSlugMySettingsProfileRoute - '/_app/$orgSlug/notifications/dms': typeof AppOrgSlugNotificationsDmsRoute - '/_app/$orgSlug/notifications/general': typeof AppOrgSlugNotificationsGeneralRoute - '/_app/$orgSlug/notifications/threads': typeof AppOrgSlugNotificationsThreadsRoute - '/_app/$orgSlug/profile/$userId': typeof AppOrgSlugProfileUserIdRoute - '/_app/$orgSlug/settings/authentication': typeof AppOrgSlugSettingsAuthenticationRoute - '/_app/$orgSlug/settings/connect-invites': typeof AppOrgSlugSettingsConnectInvitesRoute - '/_app/$orgSlug/settings/custom-emojis': typeof AppOrgSlugSettingsCustomEmojisRoute - '/_app/$orgSlug/settings/debug': typeof AppOrgSlugSettingsDebugRoute - '/_app/$orgSlug/settings/invitations': typeof AppOrgSlugSettingsInvitationsRoute - '/_app/$orgSlug/settings/team': typeof AppOrgSlugSettingsTeamRoute - '/_app/$orgSlug/chat/': typeof AppOrgSlugChatIndexRoute - '/_app/$orgSlug/my-settings/': typeof AppOrgSlugMySettingsIndexRoute - '/_app/$orgSlug/notifications/': typeof AppOrgSlugNotificationsIndexRoute - '/_app/$orgSlug/settings/': typeof AppOrgSlugSettingsIndexRoute - '/_app/$orgSlug/channels/$channelId/settings': typeof AppOrgSlugChannelsChannelIdSettingsLayoutRouteWithChildren - '/_app/$orgSlug/settings/chat-sync/$connectionId': typeof AppOrgSlugSettingsChatSyncConnectionIdRoute - '/_app/$orgSlug/settings/integrations/$integrationId': typeof AppOrgSlugSettingsIntegrationsIntegrationIdRoute - '/_app/$orgSlug/settings/integrations/installed': typeof AppOrgSlugSettingsIntegrationsInstalledRoute - '/_app/$orgSlug/settings/integrations/marketplace': typeof AppOrgSlugSettingsIntegrationsMarketplaceRoute - '/_app/$orgSlug/settings/integrations/your-apps': typeof AppOrgSlugSettingsIntegrationsYourAppsRoute - '/_app/$orgSlug/chat/$id/': typeof AppOrgSlugChatIdIndexRoute - '/_app/$orgSlug/settings/chat-sync/': typeof AppOrgSlugSettingsChatSyncIndexRoute - '/_app/$orgSlug/settings/integrations/': typeof AppOrgSlugSettingsIntegrationsIndexRoute - '/_app/$orgSlug/channels/$channelId/settings/connect': typeof AppOrgSlugChannelsChannelIdSettingsConnectRoute - '/_app/$orgSlug/channels/$channelId/settings/integrations': typeof AppOrgSlugChannelsChannelIdSettingsIntegrationsRoute - '/_app/$orgSlug/channels/$channelId/settings/overview': typeof AppOrgSlugChannelsChannelIdSettingsOverviewRoute - '/_app/$orgSlug/chat/$id/files/media': typeof AppOrgSlugChatIdFilesMediaRoute - '/_app/$orgSlug/channels/$channelId/settings/': typeof AppOrgSlugChannelsChannelIdSettingsIndexRoute - '/_app/$orgSlug/chat/$id/files/': typeof AppOrgSlugChatIdFilesIndexRoute + __root__: typeof rootRouteImport + "/_app": typeof AppLayoutRouteWithChildren + "/_dev": typeof DevLayoutRouteWithChildren + "/_app/$orgSlug": typeof AppOrgSlugLayoutRouteWithChildren + "/_dev/ui": typeof DevUiLayoutRouteWithChildren + "/auth/callback": typeof AuthCallbackRoute + "/auth/desktop-callback": typeof AuthDesktopCallbackRoute + "/auth/desktop-login": typeof AuthDesktopLoginRoute + "/auth/login": typeof AuthLoginRoute + "/join/$slug": typeof JoinSlugRoute + "/_app/": typeof AppIndexRoute + "/_app/$orgSlug/my-settings": typeof AppOrgSlugMySettingsLayoutRouteWithChildren + "/_app/$orgSlug/notifications": typeof AppOrgSlugNotificationsLayoutRouteWithChildren + "/_app/$orgSlug/settings": typeof AppOrgSlugSettingsLayoutRouteWithChildren + "/_app/onboarding/setup-organization": typeof AppOnboardingSetupOrganizationRoute + "/_dev/ui/agent-steps": typeof DevUiAgentStepsRoute + "/dev/embeds/demo": typeof DevEmbedsDemoRoute + "/dev/embeds/github": typeof DevEmbedsGithubRoute + "/dev/embeds/openstatus": typeof DevEmbedsOpenstatusRoute + "/dev/embeds/railway": typeof DevEmbedsRailwayRoute + "/_app/$orgSlug/": typeof AppOrgSlugIndexRoute + "/_app/onboarding/": typeof AppOnboardingIndexRoute + "/_app/select-organization/": typeof AppSelectOrganizationIndexRoute + "/dev/embeds/": typeof DevEmbedsIndexRoute + "/_app/$orgSlug/settings/chat-sync": typeof AppOrgSlugSettingsChatSyncLayoutRouteWithChildren + "/_app/$orgSlug/settings/integrations": typeof AppOrgSlugSettingsIntegrationsLayoutRouteWithChildren + "/_app/$orgSlug/chat/$id": typeof AppOrgSlugChatIdRouteWithChildren + "/_app/$orgSlug/my-settings/desktop": typeof AppOrgSlugMySettingsDesktopRoute + "/_app/$orgSlug/my-settings/linked-accounts": typeof AppOrgSlugMySettingsLinkedAccountsRoute + "/_app/$orgSlug/my-settings/notifications": typeof AppOrgSlugMySettingsNotificationsRoute + "/_app/$orgSlug/my-settings/profile": typeof AppOrgSlugMySettingsProfileRoute + "/_app/$orgSlug/notifications/dms": typeof AppOrgSlugNotificationsDmsRoute + "/_app/$orgSlug/notifications/general": typeof AppOrgSlugNotificationsGeneralRoute + "/_app/$orgSlug/notifications/threads": typeof AppOrgSlugNotificationsThreadsRoute + "/_app/$orgSlug/profile/$userId": typeof AppOrgSlugProfileUserIdRoute + "/_app/$orgSlug/settings/authentication": typeof AppOrgSlugSettingsAuthenticationRoute + "/_app/$orgSlug/settings/connect-invites": typeof AppOrgSlugSettingsConnectInvitesRoute + "/_app/$orgSlug/settings/custom-emojis": typeof AppOrgSlugSettingsCustomEmojisRoute + "/_app/$orgSlug/settings/debug": typeof AppOrgSlugSettingsDebugRoute + "/_app/$orgSlug/settings/invitations": typeof AppOrgSlugSettingsInvitationsRoute + "/_app/$orgSlug/settings/team": typeof AppOrgSlugSettingsTeamRoute + "/_app/$orgSlug/chat/": typeof AppOrgSlugChatIndexRoute + "/_app/$orgSlug/my-settings/": typeof AppOrgSlugMySettingsIndexRoute + "/_app/$orgSlug/notifications/": typeof AppOrgSlugNotificationsIndexRoute + "/_app/$orgSlug/settings/": typeof AppOrgSlugSettingsIndexRoute + "/_app/$orgSlug/channels/$channelId/settings": typeof AppOrgSlugChannelsChannelIdSettingsLayoutRouteWithChildren + "/_app/$orgSlug/settings/chat-sync/$connectionId": typeof AppOrgSlugSettingsChatSyncConnectionIdRoute + "/_app/$orgSlug/settings/integrations/$integrationId": typeof AppOrgSlugSettingsIntegrationsIntegrationIdRoute + "/_app/$orgSlug/settings/integrations/installed": typeof AppOrgSlugSettingsIntegrationsInstalledRoute + "/_app/$orgSlug/settings/integrations/marketplace": typeof AppOrgSlugSettingsIntegrationsMarketplaceRoute + "/_app/$orgSlug/settings/integrations/your-apps": typeof AppOrgSlugSettingsIntegrationsYourAppsRoute + "/_app/$orgSlug/chat/$id/": typeof AppOrgSlugChatIdIndexRoute + "/_app/$orgSlug/settings/chat-sync/": typeof AppOrgSlugSettingsChatSyncIndexRoute + "/_app/$orgSlug/settings/integrations/": typeof AppOrgSlugSettingsIntegrationsIndexRoute + "/_app/$orgSlug/channels/$channelId/settings/connect": typeof AppOrgSlugChannelsChannelIdSettingsConnectRoute + "/_app/$orgSlug/channels/$channelId/settings/integrations": typeof AppOrgSlugChannelsChannelIdSettingsIntegrationsRoute + "/_app/$orgSlug/channels/$channelId/settings/overview": typeof AppOrgSlugChannelsChannelIdSettingsOverviewRoute + "/_app/$orgSlug/chat/$id/files/media": typeof AppOrgSlugChatIdFilesMediaRoute + "/_app/$orgSlug/channels/$channelId/settings/": typeof AppOrgSlugChannelsChannelIdSettingsIndexRoute + "/_app/$orgSlug/chat/$id/files/": typeof AppOrgSlugChatIdFilesIndexRoute } export interface FileRouteTypes { - fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: - | '/' - | '/$orgSlug' - | '/ui' - | '/auth/callback' - | '/auth/desktop-callback' - | '/auth/desktop-login' - | '/auth/login' - | '/join/$slug' - | '/$orgSlug/my-settings' - | '/$orgSlug/notifications' - | '/$orgSlug/settings' - | '/onboarding/setup-organization' - | '/ui/agent-steps' - | '/dev/embeds/demo' - | '/dev/embeds/github' - | '/dev/embeds/openstatus' - | '/dev/embeds/railway' - | '/$orgSlug/' - | '/onboarding/' - | '/select-organization/' - | '/dev/embeds/' - | '/$orgSlug/settings/chat-sync' - | '/$orgSlug/settings/integrations' - | '/$orgSlug/chat/$id' - | '/$orgSlug/my-settings/desktop' - | '/$orgSlug/my-settings/linked-accounts' - | '/$orgSlug/my-settings/notifications' - | '/$orgSlug/my-settings/profile' - | '/$orgSlug/notifications/dms' - | '/$orgSlug/notifications/general' - | '/$orgSlug/notifications/threads' - | '/$orgSlug/profile/$userId' - | '/$orgSlug/settings/authentication' - | '/$orgSlug/settings/connect-invites' - | '/$orgSlug/settings/custom-emojis' - | '/$orgSlug/settings/debug' - | '/$orgSlug/settings/invitations' - | '/$orgSlug/settings/team' - | '/$orgSlug/chat/' - | '/$orgSlug/my-settings/' - | '/$orgSlug/notifications/' - | '/$orgSlug/settings/' - | '/$orgSlug/channels/$channelId/settings' - | '/$orgSlug/settings/chat-sync/$connectionId' - | '/$orgSlug/settings/integrations/$integrationId' - | '/$orgSlug/settings/integrations/installed' - | '/$orgSlug/settings/integrations/marketplace' - | '/$orgSlug/settings/integrations/your-apps' - | '/$orgSlug/chat/$id/' - | '/$orgSlug/settings/chat-sync/' - | '/$orgSlug/settings/integrations/' - | '/$orgSlug/channels/$channelId/settings/connect' - | '/$orgSlug/channels/$channelId/settings/integrations' - | '/$orgSlug/channels/$channelId/settings/overview' - | '/$orgSlug/chat/$id/files/media' - | '/$orgSlug/channels/$channelId/settings/' - | '/$orgSlug/chat/$id/files/' - fileRoutesByTo: FileRoutesByTo - to: - | '/' - | '/ui' - | '/auth/callback' - | '/auth/desktop-callback' - | '/auth/desktop-login' - | '/auth/login' - | '/join/$slug' - | '/onboarding/setup-organization' - | '/ui/agent-steps' - | '/dev/embeds/demo' - | '/dev/embeds/github' - | '/dev/embeds/openstatus' - | '/dev/embeds/railway' - | '/$orgSlug' - | '/onboarding' - | '/select-organization' - | '/dev/embeds' - | '/$orgSlug/my-settings/desktop' - | '/$orgSlug/my-settings/linked-accounts' - | '/$orgSlug/my-settings/notifications' - | '/$orgSlug/my-settings/profile' - | '/$orgSlug/notifications/dms' - | '/$orgSlug/notifications/general' - | '/$orgSlug/notifications/threads' - | '/$orgSlug/profile/$userId' - | '/$orgSlug/settings/authentication' - | '/$orgSlug/settings/connect-invites' - | '/$orgSlug/settings/custom-emojis' - | '/$orgSlug/settings/debug' - | '/$orgSlug/settings/invitations' - | '/$orgSlug/settings/team' - | '/$orgSlug/chat' - | '/$orgSlug/my-settings' - | '/$orgSlug/notifications' - | '/$orgSlug/settings' - | '/$orgSlug/settings/chat-sync/$connectionId' - | '/$orgSlug/settings/integrations/$integrationId' - | '/$orgSlug/settings/integrations/installed' - | '/$orgSlug/settings/integrations/marketplace' - | '/$orgSlug/settings/integrations/your-apps' - | '/$orgSlug/chat/$id' - | '/$orgSlug/settings/chat-sync' - | '/$orgSlug/settings/integrations' - | '/$orgSlug/channels/$channelId/settings/connect' - | '/$orgSlug/channels/$channelId/settings/integrations' - | '/$orgSlug/channels/$channelId/settings/overview' - | '/$orgSlug/chat/$id/files/media' - | '/$orgSlug/channels/$channelId/settings' - | '/$orgSlug/chat/$id/files' - id: - | '__root__' - | '/_app' - | '/_dev' - | '/_app/$orgSlug' - | '/_dev/ui' - | '/auth/callback' - | '/auth/desktop-callback' - | '/auth/desktop-login' - | '/auth/login' - | '/join/$slug' - | '/_app/' - | '/_app/$orgSlug/my-settings' - | '/_app/$orgSlug/notifications' - | '/_app/$orgSlug/settings' - | '/_app/onboarding/setup-organization' - | '/_dev/ui/agent-steps' - | '/dev/embeds/demo' - | '/dev/embeds/github' - | '/dev/embeds/openstatus' - | '/dev/embeds/railway' - | '/_app/$orgSlug/' - | '/_app/onboarding/' - | '/_app/select-organization/' - | '/dev/embeds/' - | '/_app/$orgSlug/settings/chat-sync' - | '/_app/$orgSlug/settings/integrations' - | '/_app/$orgSlug/chat/$id' - | '/_app/$orgSlug/my-settings/desktop' - | '/_app/$orgSlug/my-settings/linked-accounts' - | '/_app/$orgSlug/my-settings/notifications' - | '/_app/$orgSlug/my-settings/profile' - | '/_app/$orgSlug/notifications/dms' - | '/_app/$orgSlug/notifications/general' - | '/_app/$orgSlug/notifications/threads' - | '/_app/$orgSlug/profile/$userId' - | '/_app/$orgSlug/settings/authentication' - | '/_app/$orgSlug/settings/connect-invites' - | '/_app/$orgSlug/settings/custom-emojis' - | '/_app/$orgSlug/settings/debug' - | '/_app/$orgSlug/settings/invitations' - | '/_app/$orgSlug/settings/team' - | '/_app/$orgSlug/chat/' - | '/_app/$orgSlug/my-settings/' - | '/_app/$orgSlug/notifications/' - | '/_app/$orgSlug/settings/' - | '/_app/$orgSlug/channels/$channelId/settings' - | '/_app/$orgSlug/settings/chat-sync/$connectionId' - | '/_app/$orgSlug/settings/integrations/$integrationId' - | '/_app/$orgSlug/settings/integrations/installed' - | '/_app/$orgSlug/settings/integrations/marketplace' - | '/_app/$orgSlug/settings/integrations/your-apps' - | '/_app/$orgSlug/chat/$id/' - | '/_app/$orgSlug/settings/chat-sync/' - | '/_app/$orgSlug/settings/integrations/' - | '/_app/$orgSlug/channels/$channelId/settings/connect' - | '/_app/$orgSlug/channels/$channelId/settings/integrations' - | '/_app/$orgSlug/channels/$channelId/settings/overview' - | '/_app/$orgSlug/chat/$id/files/media' - | '/_app/$orgSlug/channels/$channelId/settings/' - | '/_app/$orgSlug/chat/$id/files/' - fileRoutesById: FileRoutesById + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | "/" + | "/$orgSlug" + | "/ui" + | "/auth/callback" + | "/auth/desktop-callback" + | "/auth/desktop-login" + | "/auth/login" + | "/join/$slug" + | "/$orgSlug/my-settings" + | "/$orgSlug/notifications" + | "/$orgSlug/settings" + | "/onboarding/setup-organization" + | "/ui/agent-steps" + | "/dev/embeds/demo" + | "/dev/embeds/github" + | "/dev/embeds/openstatus" + | "/dev/embeds/railway" + | "/$orgSlug/" + | "/onboarding/" + | "/select-organization/" + | "/dev/embeds/" + | "/$orgSlug/settings/chat-sync" + | "/$orgSlug/settings/integrations" + | "/$orgSlug/chat/$id" + | "/$orgSlug/my-settings/desktop" + | "/$orgSlug/my-settings/linked-accounts" + | "/$orgSlug/my-settings/notifications" + | "/$orgSlug/my-settings/profile" + | "/$orgSlug/notifications/dms" + | "/$orgSlug/notifications/general" + | "/$orgSlug/notifications/threads" + | "/$orgSlug/profile/$userId" + | "/$orgSlug/settings/authentication" + | "/$orgSlug/settings/connect-invites" + | "/$orgSlug/settings/custom-emojis" + | "/$orgSlug/settings/debug" + | "/$orgSlug/settings/invitations" + | "/$orgSlug/settings/team" + | "/$orgSlug/chat/" + | "/$orgSlug/my-settings/" + | "/$orgSlug/notifications/" + | "/$orgSlug/settings/" + | "/$orgSlug/channels/$channelId/settings" + | "/$orgSlug/settings/chat-sync/$connectionId" + | "/$orgSlug/settings/integrations/$integrationId" + | "/$orgSlug/settings/integrations/installed" + | "/$orgSlug/settings/integrations/marketplace" + | "/$orgSlug/settings/integrations/your-apps" + | "/$orgSlug/chat/$id/" + | "/$orgSlug/settings/chat-sync/" + | "/$orgSlug/settings/integrations/" + | "/$orgSlug/channels/$channelId/settings/connect" + | "/$orgSlug/channels/$channelId/settings/integrations" + | "/$orgSlug/channels/$channelId/settings/overview" + | "/$orgSlug/chat/$id/files/media" + | "/$orgSlug/channels/$channelId/settings/" + | "/$orgSlug/chat/$id/files/" + fileRoutesByTo: FileRoutesByTo + to: + | "/" + | "/ui" + | "/auth/callback" + | "/auth/desktop-callback" + | "/auth/desktop-login" + | "/auth/login" + | "/join/$slug" + | "/onboarding/setup-organization" + | "/ui/agent-steps" + | "/dev/embeds/demo" + | "/dev/embeds/github" + | "/dev/embeds/openstatus" + | "/dev/embeds/railway" + | "/$orgSlug" + | "/onboarding" + | "/select-organization" + | "/dev/embeds" + | "/$orgSlug/my-settings/desktop" + | "/$orgSlug/my-settings/linked-accounts" + | "/$orgSlug/my-settings/notifications" + | "/$orgSlug/my-settings/profile" + | "/$orgSlug/notifications/dms" + | "/$orgSlug/notifications/general" + | "/$orgSlug/notifications/threads" + | "/$orgSlug/profile/$userId" + | "/$orgSlug/settings/authentication" + | "/$orgSlug/settings/connect-invites" + | "/$orgSlug/settings/custom-emojis" + | "/$orgSlug/settings/debug" + | "/$orgSlug/settings/invitations" + | "/$orgSlug/settings/team" + | "/$orgSlug/chat" + | "/$orgSlug/my-settings" + | "/$orgSlug/notifications" + | "/$orgSlug/settings" + | "/$orgSlug/settings/chat-sync/$connectionId" + | "/$orgSlug/settings/integrations/$integrationId" + | "/$orgSlug/settings/integrations/installed" + | "/$orgSlug/settings/integrations/marketplace" + | "/$orgSlug/settings/integrations/your-apps" + | "/$orgSlug/chat/$id" + | "/$orgSlug/settings/chat-sync" + | "/$orgSlug/settings/integrations" + | "/$orgSlug/channels/$channelId/settings/connect" + | "/$orgSlug/channels/$channelId/settings/integrations" + | "/$orgSlug/channels/$channelId/settings/overview" + | "/$orgSlug/chat/$id/files/media" + | "/$orgSlug/channels/$channelId/settings" + | "/$orgSlug/chat/$id/files" + id: + | "__root__" + | "/_app" + | "/_dev" + | "/_app/$orgSlug" + | "/_dev/ui" + | "/auth/callback" + | "/auth/desktop-callback" + | "/auth/desktop-login" + | "/auth/login" + | "/join/$slug" + | "/_app/" + | "/_app/$orgSlug/my-settings" + | "/_app/$orgSlug/notifications" + | "/_app/$orgSlug/settings" + | "/_app/onboarding/setup-organization" + | "/_dev/ui/agent-steps" + | "/dev/embeds/demo" + | "/dev/embeds/github" + | "/dev/embeds/openstatus" + | "/dev/embeds/railway" + | "/_app/$orgSlug/" + | "/_app/onboarding/" + | "/_app/select-organization/" + | "/dev/embeds/" + | "/_app/$orgSlug/settings/chat-sync" + | "/_app/$orgSlug/settings/integrations" + | "/_app/$orgSlug/chat/$id" + | "/_app/$orgSlug/my-settings/desktop" + | "/_app/$orgSlug/my-settings/linked-accounts" + | "/_app/$orgSlug/my-settings/notifications" + | "/_app/$orgSlug/my-settings/profile" + | "/_app/$orgSlug/notifications/dms" + | "/_app/$orgSlug/notifications/general" + | "/_app/$orgSlug/notifications/threads" + | "/_app/$orgSlug/profile/$userId" + | "/_app/$orgSlug/settings/authentication" + | "/_app/$orgSlug/settings/connect-invites" + | "/_app/$orgSlug/settings/custom-emojis" + | "/_app/$orgSlug/settings/debug" + | "/_app/$orgSlug/settings/invitations" + | "/_app/$orgSlug/settings/team" + | "/_app/$orgSlug/chat/" + | "/_app/$orgSlug/my-settings/" + | "/_app/$orgSlug/notifications/" + | "/_app/$orgSlug/settings/" + | "/_app/$orgSlug/channels/$channelId/settings" + | "/_app/$orgSlug/settings/chat-sync/$connectionId" + | "/_app/$orgSlug/settings/integrations/$integrationId" + | "/_app/$orgSlug/settings/integrations/installed" + | "/_app/$orgSlug/settings/integrations/marketplace" + | "/_app/$orgSlug/settings/integrations/your-apps" + | "/_app/$orgSlug/chat/$id/" + | "/_app/$orgSlug/settings/chat-sync/" + | "/_app/$orgSlug/settings/integrations/" + | "/_app/$orgSlug/channels/$channelId/settings/connect" + | "/_app/$orgSlug/channels/$channelId/settings/integrations" + | "/_app/$orgSlug/channels/$channelId/settings/overview" + | "/_app/$orgSlug/chat/$id/files/media" + | "/_app/$orgSlug/channels/$channelId/settings/" + | "/_app/$orgSlug/chat/$id/files/" + fileRoutesById: FileRoutesById } export interface RootRouteChildren { - AppLayoutRoute: typeof AppLayoutRouteWithChildren - DevLayoutRoute: typeof DevLayoutRouteWithChildren - AuthCallbackRoute: typeof AuthCallbackRoute - AuthDesktopCallbackRoute: typeof AuthDesktopCallbackRoute - AuthDesktopLoginRoute: typeof AuthDesktopLoginRoute - AuthLoginRoute: typeof AuthLoginRoute - JoinSlugRoute: typeof JoinSlugRoute - DevEmbedsDemoRoute: typeof DevEmbedsDemoRoute - DevEmbedsGithubRoute: typeof DevEmbedsGithubRoute - DevEmbedsOpenstatusRoute: typeof DevEmbedsOpenstatusRoute - DevEmbedsRailwayRoute: typeof DevEmbedsRailwayRoute - DevEmbedsIndexRoute: typeof DevEmbedsIndexRoute + AppLayoutRoute: typeof AppLayoutRouteWithChildren + DevLayoutRoute: typeof DevLayoutRouteWithChildren + AuthCallbackRoute: typeof AuthCallbackRoute + AuthDesktopCallbackRoute: typeof AuthDesktopCallbackRoute + AuthDesktopLoginRoute: typeof AuthDesktopLoginRoute + AuthLoginRoute: typeof AuthLoginRoute + JoinSlugRoute: typeof JoinSlugRoute + DevEmbedsDemoRoute: typeof DevEmbedsDemoRoute + DevEmbedsGithubRoute: typeof DevEmbedsGithubRoute + DevEmbedsOpenstatusRoute: typeof DevEmbedsOpenstatusRoute + DevEmbedsRailwayRoute: typeof DevEmbedsRailwayRoute + DevEmbedsIndexRoute: typeof DevEmbedsIndexRoute } -declare module '@tanstack/react-router' { - interface FileRoutesByPath { - '/_dev': { - id: '/_dev' - path: '' - fullPath: '/' - preLoaderRoute: typeof DevLayoutRouteImport - parentRoute: typeof rootRouteImport - } - '/_app': { - id: '/_app' - path: '' - fullPath: '/' - preLoaderRoute: typeof AppLayoutRouteImport - parentRoute: typeof rootRouteImport - } - '/_app/': { - id: '/_app/' - path: '/' - fullPath: '/' - preLoaderRoute: typeof AppIndexRouteImport - parentRoute: typeof AppLayoutRoute - } - '/join/$slug': { - id: '/join/$slug' - path: '/join/$slug' - fullPath: '/join/$slug' - preLoaderRoute: typeof JoinSlugRouteImport - parentRoute: typeof rootRouteImport - } - '/auth/login': { - id: '/auth/login' - path: '/auth/login' - fullPath: '/auth/login' - preLoaderRoute: typeof AuthLoginRouteImport - parentRoute: typeof rootRouteImport - } - '/auth/desktop-login': { - id: '/auth/desktop-login' - path: '/auth/desktop-login' - fullPath: '/auth/desktop-login' - preLoaderRoute: typeof AuthDesktopLoginRouteImport - parentRoute: typeof rootRouteImport - } - '/auth/desktop-callback': { - id: '/auth/desktop-callback' - path: '/auth/desktop-callback' - fullPath: '/auth/desktop-callback' - preLoaderRoute: typeof AuthDesktopCallbackRouteImport - parentRoute: typeof rootRouteImport - } - '/auth/callback': { - id: '/auth/callback' - path: '/auth/callback' - fullPath: '/auth/callback' - preLoaderRoute: typeof AuthCallbackRouteImport - parentRoute: typeof rootRouteImport - } - '/_dev/ui': { - id: '/_dev/ui' - path: '/ui' - fullPath: '/ui' - preLoaderRoute: typeof DevUiLayoutRouteImport - parentRoute: typeof DevLayoutRoute - } - '/_app/$orgSlug': { - id: '/_app/$orgSlug' - path: '/$orgSlug' - fullPath: '/$orgSlug' - preLoaderRoute: typeof AppOrgSlugLayoutRouteImport - parentRoute: typeof AppLayoutRoute - } - '/dev/embeds/': { - id: '/dev/embeds/' - path: '/dev/embeds' - fullPath: '/dev/embeds/' - preLoaderRoute: typeof DevEmbedsIndexRouteImport - parentRoute: typeof rootRouteImport - } - '/_app/select-organization/': { - id: '/_app/select-organization/' - path: '/select-organization' - fullPath: '/select-organization/' - preLoaderRoute: typeof AppSelectOrganizationIndexRouteImport - parentRoute: typeof AppLayoutRoute - } - '/_app/onboarding/': { - id: '/_app/onboarding/' - path: '/onboarding' - fullPath: '/onboarding/' - preLoaderRoute: typeof AppOnboardingIndexRouteImport - parentRoute: typeof AppLayoutRoute - } - '/_app/$orgSlug/': { - id: '/_app/$orgSlug/' - path: '/' - fullPath: '/$orgSlug/' - preLoaderRoute: typeof AppOrgSlugIndexRouteImport - parentRoute: typeof AppOrgSlugLayoutRoute - } - '/dev/embeds/railway': { - id: '/dev/embeds/railway' - path: '/dev/embeds/railway' - fullPath: '/dev/embeds/railway' - preLoaderRoute: typeof DevEmbedsRailwayRouteImport - parentRoute: typeof rootRouteImport - } - '/dev/embeds/openstatus': { - id: '/dev/embeds/openstatus' - path: '/dev/embeds/openstatus' - fullPath: '/dev/embeds/openstatus' - preLoaderRoute: typeof DevEmbedsOpenstatusRouteImport - parentRoute: typeof rootRouteImport - } - '/dev/embeds/github': { - id: '/dev/embeds/github' - path: '/dev/embeds/github' - fullPath: '/dev/embeds/github' - preLoaderRoute: typeof DevEmbedsGithubRouteImport - parentRoute: typeof rootRouteImport - } - '/dev/embeds/demo': { - id: '/dev/embeds/demo' - path: '/dev/embeds/demo' - fullPath: '/dev/embeds/demo' - preLoaderRoute: typeof DevEmbedsDemoRouteImport - parentRoute: typeof rootRouteImport - } - '/_dev/ui/agent-steps': { - id: '/_dev/ui/agent-steps' - path: '/agent-steps' - fullPath: '/ui/agent-steps' - preLoaderRoute: typeof DevUiAgentStepsRouteImport - parentRoute: typeof DevUiLayoutRoute - } - '/_app/onboarding/setup-organization': { - id: '/_app/onboarding/setup-organization' - path: '/onboarding/setup-organization' - fullPath: '/onboarding/setup-organization' - preLoaderRoute: typeof AppOnboardingSetupOrganizationRouteImport - parentRoute: typeof AppLayoutRoute - } - '/_app/$orgSlug/settings': { - id: '/_app/$orgSlug/settings' - path: '/settings' - fullPath: '/$orgSlug/settings' - preLoaderRoute: typeof AppOrgSlugSettingsLayoutRouteImport - parentRoute: typeof AppOrgSlugLayoutRoute - } - '/_app/$orgSlug/notifications': { - id: '/_app/$orgSlug/notifications' - path: '/notifications' - fullPath: '/$orgSlug/notifications' - preLoaderRoute: typeof AppOrgSlugNotificationsLayoutRouteImport - parentRoute: typeof AppOrgSlugLayoutRoute - } - '/_app/$orgSlug/my-settings': { - id: '/_app/$orgSlug/my-settings' - path: '/my-settings' - fullPath: '/$orgSlug/my-settings' - preLoaderRoute: typeof AppOrgSlugMySettingsLayoutRouteImport - parentRoute: typeof AppOrgSlugLayoutRoute - } - '/_app/$orgSlug/settings/': { - id: '/_app/$orgSlug/settings/' - path: '/' - fullPath: '/$orgSlug/settings/' - preLoaderRoute: typeof AppOrgSlugSettingsIndexRouteImport - parentRoute: typeof AppOrgSlugSettingsLayoutRoute - } - '/_app/$orgSlug/notifications/': { - id: '/_app/$orgSlug/notifications/' - path: '/' - fullPath: '/$orgSlug/notifications/' - preLoaderRoute: typeof AppOrgSlugNotificationsIndexRouteImport - parentRoute: typeof AppOrgSlugNotificationsLayoutRoute - } - '/_app/$orgSlug/my-settings/': { - id: '/_app/$orgSlug/my-settings/' - path: '/' - fullPath: '/$orgSlug/my-settings/' - preLoaderRoute: typeof AppOrgSlugMySettingsIndexRouteImport - parentRoute: typeof AppOrgSlugMySettingsLayoutRoute - } - '/_app/$orgSlug/chat/': { - id: '/_app/$orgSlug/chat/' - path: '/chat' - fullPath: '/$orgSlug/chat/' - preLoaderRoute: typeof AppOrgSlugChatIndexRouteImport - parentRoute: typeof AppOrgSlugLayoutRoute - } - '/_app/$orgSlug/settings/team': { - id: '/_app/$orgSlug/settings/team' - path: '/team' - fullPath: '/$orgSlug/settings/team' - preLoaderRoute: typeof AppOrgSlugSettingsTeamRouteImport - parentRoute: typeof AppOrgSlugSettingsLayoutRoute - } - '/_app/$orgSlug/settings/invitations': { - id: '/_app/$orgSlug/settings/invitations' - path: '/invitations' - fullPath: '/$orgSlug/settings/invitations' - preLoaderRoute: typeof AppOrgSlugSettingsInvitationsRouteImport - parentRoute: typeof AppOrgSlugSettingsLayoutRoute - } - '/_app/$orgSlug/settings/debug': { - id: '/_app/$orgSlug/settings/debug' - path: '/debug' - fullPath: '/$orgSlug/settings/debug' - preLoaderRoute: typeof AppOrgSlugSettingsDebugRouteImport - parentRoute: typeof AppOrgSlugSettingsLayoutRoute - } - '/_app/$orgSlug/settings/custom-emojis': { - id: '/_app/$orgSlug/settings/custom-emojis' - path: '/custom-emojis' - fullPath: '/$orgSlug/settings/custom-emojis' - preLoaderRoute: typeof AppOrgSlugSettingsCustomEmojisRouteImport - parentRoute: typeof AppOrgSlugSettingsLayoutRoute - } - '/_app/$orgSlug/settings/connect-invites': { - id: '/_app/$orgSlug/settings/connect-invites' - path: '/connect-invites' - fullPath: '/$orgSlug/settings/connect-invites' - preLoaderRoute: typeof AppOrgSlugSettingsConnectInvitesRouteImport - parentRoute: typeof AppOrgSlugSettingsLayoutRoute - } - '/_app/$orgSlug/settings/authentication': { - id: '/_app/$orgSlug/settings/authentication' - path: '/authentication' - fullPath: '/$orgSlug/settings/authentication' - preLoaderRoute: typeof AppOrgSlugSettingsAuthenticationRouteImport - parentRoute: typeof AppOrgSlugSettingsLayoutRoute - } - '/_app/$orgSlug/profile/$userId': { - id: '/_app/$orgSlug/profile/$userId' - path: '/profile/$userId' - fullPath: '/$orgSlug/profile/$userId' - preLoaderRoute: typeof AppOrgSlugProfileUserIdRouteImport - parentRoute: typeof AppOrgSlugLayoutRoute - } - '/_app/$orgSlug/notifications/threads': { - id: '/_app/$orgSlug/notifications/threads' - path: '/threads' - fullPath: '/$orgSlug/notifications/threads' - preLoaderRoute: typeof AppOrgSlugNotificationsThreadsRouteImport - parentRoute: typeof AppOrgSlugNotificationsLayoutRoute - } - '/_app/$orgSlug/notifications/general': { - id: '/_app/$orgSlug/notifications/general' - path: '/general' - fullPath: '/$orgSlug/notifications/general' - preLoaderRoute: typeof AppOrgSlugNotificationsGeneralRouteImport - parentRoute: typeof AppOrgSlugNotificationsLayoutRoute - } - '/_app/$orgSlug/notifications/dms': { - id: '/_app/$orgSlug/notifications/dms' - path: '/dms' - fullPath: '/$orgSlug/notifications/dms' - preLoaderRoute: typeof AppOrgSlugNotificationsDmsRouteImport - parentRoute: typeof AppOrgSlugNotificationsLayoutRoute - } - '/_app/$orgSlug/my-settings/profile': { - id: '/_app/$orgSlug/my-settings/profile' - path: '/profile' - fullPath: '/$orgSlug/my-settings/profile' - preLoaderRoute: typeof AppOrgSlugMySettingsProfileRouteImport - parentRoute: typeof AppOrgSlugMySettingsLayoutRoute - } - '/_app/$orgSlug/my-settings/notifications': { - id: '/_app/$orgSlug/my-settings/notifications' - path: '/notifications' - fullPath: '/$orgSlug/my-settings/notifications' - preLoaderRoute: typeof AppOrgSlugMySettingsNotificationsRouteImport - parentRoute: typeof AppOrgSlugMySettingsLayoutRoute - } - '/_app/$orgSlug/my-settings/linked-accounts': { - id: '/_app/$orgSlug/my-settings/linked-accounts' - path: '/linked-accounts' - fullPath: '/$orgSlug/my-settings/linked-accounts' - preLoaderRoute: typeof AppOrgSlugMySettingsLinkedAccountsRouteImport - parentRoute: typeof AppOrgSlugMySettingsLayoutRoute - } - '/_app/$orgSlug/my-settings/desktop': { - id: '/_app/$orgSlug/my-settings/desktop' - path: '/desktop' - fullPath: '/$orgSlug/my-settings/desktop' - preLoaderRoute: typeof AppOrgSlugMySettingsDesktopRouteImport - parentRoute: typeof AppOrgSlugMySettingsLayoutRoute - } - '/_app/$orgSlug/chat/$id': { - id: '/_app/$orgSlug/chat/$id' - path: '/chat/$id' - fullPath: '/$orgSlug/chat/$id' - preLoaderRoute: typeof AppOrgSlugChatIdRouteImport - parentRoute: typeof AppOrgSlugLayoutRoute - } - '/_app/$orgSlug/settings/integrations': { - id: '/_app/$orgSlug/settings/integrations' - path: '/integrations' - fullPath: '/$orgSlug/settings/integrations' - preLoaderRoute: typeof AppOrgSlugSettingsIntegrationsLayoutRouteImport - parentRoute: typeof AppOrgSlugSettingsLayoutRoute - } - '/_app/$orgSlug/settings/chat-sync': { - id: '/_app/$orgSlug/settings/chat-sync' - path: '/chat-sync' - fullPath: '/$orgSlug/settings/chat-sync' - preLoaderRoute: typeof AppOrgSlugSettingsChatSyncLayoutRouteImport - parentRoute: typeof AppOrgSlugSettingsLayoutRoute - } - '/_app/$orgSlug/settings/integrations/': { - id: '/_app/$orgSlug/settings/integrations/' - path: '/' - fullPath: '/$orgSlug/settings/integrations/' - preLoaderRoute: typeof AppOrgSlugSettingsIntegrationsIndexRouteImport - parentRoute: typeof AppOrgSlugSettingsIntegrationsLayoutRoute - } - '/_app/$orgSlug/settings/chat-sync/': { - id: '/_app/$orgSlug/settings/chat-sync/' - path: '/' - fullPath: '/$orgSlug/settings/chat-sync/' - preLoaderRoute: typeof AppOrgSlugSettingsChatSyncIndexRouteImport - parentRoute: typeof AppOrgSlugSettingsChatSyncLayoutRoute - } - '/_app/$orgSlug/chat/$id/': { - id: '/_app/$orgSlug/chat/$id/' - path: '/' - fullPath: '/$orgSlug/chat/$id/' - preLoaderRoute: typeof AppOrgSlugChatIdIndexRouteImport - parentRoute: typeof AppOrgSlugChatIdRoute - } - '/_app/$orgSlug/settings/integrations/your-apps': { - id: '/_app/$orgSlug/settings/integrations/your-apps' - path: '/your-apps' - fullPath: '/$orgSlug/settings/integrations/your-apps' - preLoaderRoute: typeof AppOrgSlugSettingsIntegrationsYourAppsRouteImport - parentRoute: typeof AppOrgSlugSettingsIntegrationsLayoutRoute - } - '/_app/$orgSlug/settings/integrations/marketplace': { - id: '/_app/$orgSlug/settings/integrations/marketplace' - path: '/marketplace' - fullPath: '/$orgSlug/settings/integrations/marketplace' - preLoaderRoute: typeof AppOrgSlugSettingsIntegrationsMarketplaceRouteImport - parentRoute: typeof AppOrgSlugSettingsIntegrationsLayoutRoute - } - '/_app/$orgSlug/settings/integrations/installed': { - id: '/_app/$orgSlug/settings/integrations/installed' - path: '/installed' - fullPath: '/$orgSlug/settings/integrations/installed' - preLoaderRoute: typeof AppOrgSlugSettingsIntegrationsInstalledRouteImport - parentRoute: typeof AppOrgSlugSettingsIntegrationsLayoutRoute - } - '/_app/$orgSlug/settings/integrations/$integrationId': { - id: '/_app/$orgSlug/settings/integrations/$integrationId' - path: '/$integrationId' - fullPath: '/$orgSlug/settings/integrations/$integrationId' - preLoaderRoute: typeof AppOrgSlugSettingsIntegrationsIntegrationIdRouteImport - parentRoute: typeof AppOrgSlugSettingsIntegrationsLayoutRoute - } - '/_app/$orgSlug/settings/chat-sync/$connectionId': { - id: '/_app/$orgSlug/settings/chat-sync/$connectionId' - path: '/$connectionId' - fullPath: '/$orgSlug/settings/chat-sync/$connectionId' - preLoaderRoute: typeof AppOrgSlugSettingsChatSyncConnectionIdRouteImport - parentRoute: typeof AppOrgSlugSettingsChatSyncLayoutRoute - } - '/_app/$orgSlug/channels/$channelId/settings': { - id: '/_app/$orgSlug/channels/$channelId/settings' - path: '/channels/$channelId/settings' - fullPath: '/$orgSlug/channels/$channelId/settings' - preLoaderRoute: typeof AppOrgSlugChannelsChannelIdSettingsLayoutRouteImport - parentRoute: typeof AppOrgSlugLayoutRoute - } - '/_app/$orgSlug/chat/$id/files/': { - id: '/_app/$orgSlug/chat/$id/files/' - path: '/files' - fullPath: '/$orgSlug/chat/$id/files/' - preLoaderRoute: typeof AppOrgSlugChatIdFilesIndexRouteImport - parentRoute: typeof AppOrgSlugChatIdRoute - } - '/_app/$orgSlug/channels/$channelId/settings/': { - id: '/_app/$orgSlug/channels/$channelId/settings/' - path: '/' - fullPath: '/$orgSlug/channels/$channelId/settings/' - preLoaderRoute: typeof AppOrgSlugChannelsChannelIdSettingsIndexRouteImport - parentRoute: typeof AppOrgSlugChannelsChannelIdSettingsLayoutRoute - } - '/_app/$orgSlug/chat/$id/files/media': { - id: '/_app/$orgSlug/chat/$id/files/media' - path: '/files/media' - fullPath: '/$orgSlug/chat/$id/files/media' - preLoaderRoute: typeof AppOrgSlugChatIdFilesMediaRouteImport - parentRoute: typeof AppOrgSlugChatIdRoute - } - '/_app/$orgSlug/channels/$channelId/settings/overview': { - id: '/_app/$orgSlug/channels/$channelId/settings/overview' - path: '/overview' - fullPath: '/$orgSlug/channels/$channelId/settings/overview' - preLoaderRoute: typeof AppOrgSlugChannelsChannelIdSettingsOverviewRouteImport - parentRoute: typeof AppOrgSlugChannelsChannelIdSettingsLayoutRoute - } - '/_app/$orgSlug/channels/$channelId/settings/integrations': { - id: '/_app/$orgSlug/channels/$channelId/settings/integrations' - path: '/integrations' - fullPath: '/$orgSlug/channels/$channelId/settings/integrations' - preLoaderRoute: typeof AppOrgSlugChannelsChannelIdSettingsIntegrationsRouteImport - parentRoute: typeof AppOrgSlugChannelsChannelIdSettingsLayoutRoute - } - '/_app/$orgSlug/channels/$channelId/settings/connect': { - id: '/_app/$orgSlug/channels/$channelId/settings/connect' - path: '/connect' - fullPath: '/$orgSlug/channels/$channelId/settings/connect' - preLoaderRoute: typeof AppOrgSlugChannelsChannelIdSettingsConnectRouteImport - parentRoute: typeof AppOrgSlugChannelsChannelIdSettingsLayoutRoute - } - } +declare module "@tanstack/react-router" { + interface FileRoutesByPath { + "/_dev": { + id: "/_dev" + path: "" + fullPath: "/" + preLoaderRoute: typeof DevLayoutRouteImport + parentRoute: typeof rootRouteImport + } + "/_app": { + id: "/_app" + path: "" + fullPath: "/" + preLoaderRoute: typeof AppLayoutRouteImport + parentRoute: typeof rootRouteImport + } + "/_app/": { + id: "/_app/" + path: "/" + fullPath: "/" + preLoaderRoute: typeof AppIndexRouteImport + parentRoute: typeof AppLayoutRoute + } + "/join/$slug": { + id: "/join/$slug" + path: "/join/$slug" + fullPath: "/join/$slug" + preLoaderRoute: typeof JoinSlugRouteImport + parentRoute: typeof rootRouteImport + } + "/auth/login": { + id: "/auth/login" + path: "/auth/login" + fullPath: "/auth/login" + preLoaderRoute: typeof AuthLoginRouteImport + parentRoute: typeof rootRouteImport + } + "/auth/desktop-login": { + id: "/auth/desktop-login" + path: "/auth/desktop-login" + fullPath: "/auth/desktop-login" + preLoaderRoute: typeof AuthDesktopLoginRouteImport + parentRoute: typeof rootRouteImport + } + "/auth/desktop-callback": { + id: "/auth/desktop-callback" + path: "/auth/desktop-callback" + fullPath: "/auth/desktop-callback" + preLoaderRoute: typeof AuthDesktopCallbackRouteImport + parentRoute: typeof rootRouteImport + } + "/auth/callback": { + id: "/auth/callback" + path: "/auth/callback" + fullPath: "/auth/callback" + preLoaderRoute: typeof AuthCallbackRouteImport + parentRoute: typeof rootRouteImport + } + "/_dev/ui": { + id: "/_dev/ui" + path: "/ui" + fullPath: "/ui" + preLoaderRoute: typeof DevUiLayoutRouteImport + parentRoute: typeof DevLayoutRoute + } + "/_app/$orgSlug": { + id: "/_app/$orgSlug" + path: "/$orgSlug" + fullPath: "/$orgSlug" + preLoaderRoute: typeof AppOrgSlugLayoutRouteImport + parentRoute: typeof AppLayoutRoute + } + "/dev/embeds/": { + id: "/dev/embeds/" + path: "/dev/embeds" + fullPath: "/dev/embeds/" + preLoaderRoute: typeof DevEmbedsIndexRouteImport + parentRoute: typeof rootRouteImport + } + "/_app/select-organization/": { + id: "/_app/select-organization/" + path: "/select-organization" + fullPath: "/select-organization/" + preLoaderRoute: typeof AppSelectOrganizationIndexRouteImport + parentRoute: typeof AppLayoutRoute + } + "/_app/onboarding/": { + id: "/_app/onboarding/" + path: "/onboarding" + fullPath: "/onboarding/" + preLoaderRoute: typeof AppOnboardingIndexRouteImport + parentRoute: typeof AppLayoutRoute + } + "/_app/$orgSlug/": { + id: "/_app/$orgSlug/" + path: "/" + fullPath: "/$orgSlug/" + preLoaderRoute: typeof AppOrgSlugIndexRouteImport + parentRoute: typeof AppOrgSlugLayoutRoute + } + "/dev/embeds/railway": { + id: "/dev/embeds/railway" + path: "/dev/embeds/railway" + fullPath: "/dev/embeds/railway" + preLoaderRoute: typeof DevEmbedsRailwayRouteImport + parentRoute: typeof rootRouteImport + } + "/dev/embeds/openstatus": { + id: "/dev/embeds/openstatus" + path: "/dev/embeds/openstatus" + fullPath: "/dev/embeds/openstatus" + preLoaderRoute: typeof DevEmbedsOpenstatusRouteImport + parentRoute: typeof rootRouteImport + } + "/dev/embeds/github": { + id: "/dev/embeds/github" + path: "/dev/embeds/github" + fullPath: "/dev/embeds/github" + preLoaderRoute: typeof DevEmbedsGithubRouteImport + parentRoute: typeof rootRouteImport + } + "/dev/embeds/demo": { + id: "/dev/embeds/demo" + path: "/dev/embeds/demo" + fullPath: "/dev/embeds/demo" + preLoaderRoute: typeof DevEmbedsDemoRouteImport + parentRoute: typeof rootRouteImport + } + "/_dev/ui/agent-steps": { + id: "/_dev/ui/agent-steps" + path: "/agent-steps" + fullPath: "/ui/agent-steps" + preLoaderRoute: typeof DevUiAgentStepsRouteImport + parentRoute: typeof DevUiLayoutRoute + } + "/_app/onboarding/setup-organization": { + id: "/_app/onboarding/setup-organization" + path: "/onboarding/setup-organization" + fullPath: "/onboarding/setup-organization" + preLoaderRoute: typeof AppOnboardingSetupOrganizationRouteImport + parentRoute: typeof AppLayoutRoute + } + "/_app/$orgSlug/settings": { + id: "/_app/$orgSlug/settings" + path: "/settings" + fullPath: "/$orgSlug/settings" + preLoaderRoute: typeof AppOrgSlugSettingsLayoutRouteImport + parentRoute: typeof AppOrgSlugLayoutRoute + } + "/_app/$orgSlug/notifications": { + id: "/_app/$orgSlug/notifications" + path: "/notifications" + fullPath: "/$orgSlug/notifications" + preLoaderRoute: typeof AppOrgSlugNotificationsLayoutRouteImport + parentRoute: typeof AppOrgSlugLayoutRoute + } + "/_app/$orgSlug/my-settings": { + id: "/_app/$orgSlug/my-settings" + path: "/my-settings" + fullPath: "/$orgSlug/my-settings" + preLoaderRoute: typeof AppOrgSlugMySettingsLayoutRouteImport + parentRoute: typeof AppOrgSlugLayoutRoute + } + "/_app/$orgSlug/settings/": { + id: "/_app/$orgSlug/settings/" + path: "/" + fullPath: "/$orgSlug/settings/" + preLoaderRoute: typeof AppOrgSlugSettingsIndexRouteImport + parentRoute: typeof AppOrgSlugSettingsLayoutRoute + } + "/_app/$orgSlug/notifications/": { + id: "/_app/$orgSlug/notifications/" + path: "/" + fullPath: "/$orgSlug/notifications/" + preLoaderRoute: typeof AppOrgSlugNotificationsIndexRouteImport + parentRoute: typeof AppOrgSlugNotificationsLayoutRoute + } + "/_app/$orgSlug/my-settings/": { + id: "/_app/$orgSlug/my-settings/" + path: "/" + fullPath: "/$orgSlug/my-settings/" + preLoaderRoute: typeof AppOrgSlugMySettingsIndexRouteImport + parentRoute: typeof AppOrgSlugMySettingsLayoutRoute + } + "/_app/$orgSlug/chat/": { + id: "/_app/$orgSlug/chat/" + path: "/chat" + fullPath: "/$orgSlug/chat/" + preLoaderRoute: typeof AppOrgSlugChatIndexRouteImport + parentRoute: typeof AppOrgSlugLayoutRoute + } + "/_app/$orgSlug/settings/team": { + id: "/_app/$orgSlug/settings/team" + path: "/team" + fullPath: "/$orgSlug/settings/team" + preLoaderRoute: typeof AppOrgSlugSettingsTeamRouteImport + parentRoute: typeof AppOrgSlugSettingsLayoutRoute + } + "/_app/$orgSlug/settings/invitations": { + id: "/_app/$orgSlug/settings/invitations" + path: "/invitations" + fullPath: "/$orgSlug/settings/invitations" + preLoaderRoute: typeof AppOrgSlugSettingsInvitationsRouteImport + parentRoute: typeof AppOrgSlugSettingsLayoutRoute + } + "/_app/$orgSlug/settings/debug": { + id: "/_app/$orgSlug/settings/debug" + path: "/debug" + fullPath: "/$orgSlug/settings/debug" + preLoaderRoute: typeof AppOrgSlugSettingsDebugRouteImport + parentRoute: typeof AppOrgSlugSettingsLayoutRoute + } + "/_app/$orgSlug/settings/custom-emojis": { + id: "/_app/$orgSlug/settings/custom-emojis" + path: "/custom-emojis" + fullPath: "/$orgSlug/settings/custom-emojis" + preLoaderRoute: typeof AppOrgSlugSettingsCustomEmojisRouteImport + parentRoute: typeof AppOrgSlugSettingsLayoutRoute + } + "/_app/$orgSlug/settings/connect-invites": { + id: "/_app/$orgSlug/settings/connect-invites" + path: "/connect-invites" + fullPath: "/$orgSlug/settings/connect-invites" + preLoaderRoute: typeof AppOrgSlugSettingsConnectInvitesRouteImport + parentRoute: typeof AppOrgSlugSettingsLayoutRoute + } + "/_app/$orgSlug/settings/authentication": { + id: "/_app/$orgSlug/settings/authentication" + path: "/authentication" + fullPath: "/$orgSlug/settings/authentication" + preLoaderRoute: typeof AppOrgSlugSettingsAuthenticationRouteImport + parentRoute: typeof AppOrgSlugSettingsLayoutRoute + } + "/_app/$orgSlug/profile/$userId": { + id: "/_app/$orgSlug/profile/$userId" + path: "/profile/$userId" + fullPath: "/$orgSlug/profile/$userId" + preLoaderRoute: typeof AppOrgSlugProfileUserIdRouteImport + parentRoute: typeof AppOrgSlugLayoutRoute + } + "/_app/$orgSlug/notifications/threads": { + id: "/_app/$orgSlug/notifications/threads" + path: "/threads" + fullPath: "/$orgSlug/notifications/threads" + preLoaderRoute: typeof AppOrgSlugNotificationsThreadsRouteImport + parentRoute: typeof AppOrgSlugNotificationsLayoutRoute + } + "/_app/$orgSlug/notifications/general": { + id: "/_app/$orgSlug/notifications/general" + path: "/general" + fullPath: "/$orgSlug/notifications/general" + preLoaderRoute: typeof AppOrgSlugNotificationsGeneralRouteImport + parentRoute: typeof AppOrgSlugNotificationsLayoutRoute + } + "/_app/$orgSlug/notifications/dms": { + id: "/_app/$orgSlug/notifications/dms" + path: "/dms" + fullPath: "/$orgSlug/notifications/dms" + preLoaderRoute: typeof AppOrgSlugNotificationsDmsRouteImport + parentRoute: typeof AppOrgSlugNotificationsLayoutRoute + } + "/_app/$orgSlug/my-settings/profile": { + id: "/_app/$orgSlug/my-settings/profile" + path: "/profile" + fullPath: "/$orgSlug/my-settings/profile" + preLoaderRoute: typeof AppOrgSlugMySettingsProfileRouteImport + parentRoute: typeof AppOrgSlugMySettingsLayoutRoute + } + "/_app/$orgSlug/my-settings/notifications": { + id: "/_app/$orgSlug/my-settings/notifications" + path: "/notifications" + fullPath: "/$orgSlug/my-settings/notifications" + preLoaderRoute: typeof AppOrgSlugMySettingsNotificationsRouteImport + parentRoute: typeof AppOrgSlugMySettingsLayoutRoute + } + "/_app/$orgSlug/my-settings/linked-accounts": { + id: "/_app/$orgSlug/my-settings/linked-accounts" + path: "/linked-accounts" + fullPath: "/$orgSlug/my-settings/linked-accounts" + preLoaderRoute: typeof AppOrgSlugMySettingsLinkedAccountsRouteImport + parentRoute: typeof AppOrgSlugMySettingsLayoutRoute + } + "/_app/$orgSlug/my-settings/desktop": { + id: "/_app/$orgSlug/my-settings/desktop" + path: "/desktop" + fullPath: "/$orgSlug/my-settings/desktop" + preLoaderRoute: typeof AppOrgSlugMySettingsDesktopRouteImport + parentRoute: typeof AppOrgSlugMySettingsLayoutRoute + } + "/_app/$orgSlug/chat/$id": { + id: "/_app/$orgSlug/chat/$id" + path: "/chat/$id" + fullPath: "/$orgSlug/chat/$id" + preLoaderRoute: typeof AppOrgSlugChatIdRouteImport + parentRoute: typeof AppOrgSlugLayoutRoute + } + "/_app/$orgSlug/settings/integrations": { + id: "/_app/$orgSlug/settings/integrations" + path: "/integrations" + fullPath: "/$orgSlug/settings/integrations" + preLoaderRoute: typeof AppOrgSlugSettingsIntegrationsLayoutRouteImport + parentRoute: typeof AppOrgSlugSettingsLayoutRoute + } + "/_app/$orgSlug/settings/chat-sync": { + id: "/_app/$orgSlug/settings/chat-sync" + path: "/chat-sync" + fullPath: "/$orgSlug/settings/chat-sync" + preLoaderRoute: typeof AppOrgSlugSettingsChatSyncLayoutRouteImport + parentRoute: typeof AppOrgSlugSettingsLayoutRoute + } + "/_app/$orgSlug/settings/integrations/": { + id: "/_app/$orgSlug/settings/integrations/" + path: "/" + fullPath: "/$orgSlug/settings/integrations/" + preLoaderRoute: typeof AppOrgSlugSettingsIntegrationsIndexRouteImport + parentRoute: typeof AppOrgSlugSettingsIntegrationsLayoutRoute + } + "/_app/$orgSlug/settings/chat-sync/": { + id: "/_app/$orgSlug/settings/chat-sync/" + path: "/" + fullPath: "/$orgSlug/settings/chat-sync/" + preLoaderRoute: typeof AppOrgSlugSettingsChatSyncIndexRouteImport + parentRoute: typeof AppOrgSlugSettingsChatSyncLayoutRoute + } + "/_app/$orgSlug/chat/$id/": { + id: "/_app/$orgSlug/chat/$id/" + path: "/" + fullPath: "/$orgSlug/chat/$id/" + preLoaderRoute: typeof AppOrgSlugChatIdIndexRouteImport + parentRoute: typeof AppOrgSlugChatIdRoute + } + "/_app/$orgSlug/settings/integrations/your-apps": { + id: "/_app/$orgSlug/settings/integrations/your-apps" + path: "/your-apps" + fullPath: "/$orgSlug/settings/integrations/your-apps" + preLoaderRoute: typeof AppOrgSlugSettingsIntegrationsYourAppsRouteImport + parentRoute: typeof AppOrgSlugSettingsIntegrationsLayoutRoute + } + "/_app/$orgSlug/settings/integrations/marketplace": { + id: "/_app/$orgSlug/settings/integrations/marketplace" + path: "/marketplace" + fullPath: "/$orgSlug/settings/integrations/marketplace" + preLoaderRoute: typeof AppOrgSlugSettingsIntegrationsMarketplaceRouteImport + parentRoute: typeof AppOrgSlugSettingsIntegrationsLayoutRoute + } + "/_app/$orgSlug/settings/integrations/installed": { + id: "/_app/$orgSlug/settings/integrations/installed" + path: "/installed" + fullPath: "/$orgSlug/settings/integrations/installed" + preLoaderRoute: typeof AppOrgSlugSettingsIntegrationsInstalledRouteImport + parentRoute: typeof AppOrgSlugSettingsIntegrationsLayoutRoute + } + "/_app/$orgSlug/settings/integrations/$integrationId": { + id: "/_app/$orgSlug/settings/integrations/$integrationId" + path: "/$integrationId" + fullPath: "/$orgSlug/settings/integrations/$integrationId" + preLoaderRoute: typeof AppOrgSlugSettingsIntegrationsIntegrationIdRouteImport + parentRoute: typeof AppOrgSlugSettingsIntegrationsLayoutRoute + } + "/_app/$orgSlug/settings/chat-sync/$connectionId": { + id: "/_app/$orgSlug/settings/chat-sync/$connectionId" + path: "/$connectionId" + fullPath: "/$orgSlug/settings/chat-sync/$connectionId" + preLoaderRoute: typeof AppOrgSlugSettingsChatSyncConnectionIdRouteImport + parentRoute: typeof AppOrgSlugSettingsChatSyncLayoutRoute + } + "/_app/$orgSlug/channels/$channelId/settings": { + id: "/_app/$orgSlug/channels/$channelId/settings" + path: "/channels/$channelId/settings" + fullPath: "/$orgSlug/channels/$channelId/settings" + preLoaderRoute: typeof AppOrgSlugChannelsChannelIdSettingsLayoutRouteImport + parentRoute: typeof AppOrgSlugLayoutRoute + } + "/_app/$orgSlug/chat/$id/files/": { + id: "/_app/$orgSlug/chat/$id/files/" + path: "/files" + fullPath: "/$orgSlug/chat/$id/files/" + preLoaderRoute: typeof AppOrgSlugChatIdFilesIndexRouteImport + parentRoute: typeof AppOrgSlugChatIdRoute + } + "/_app/$orgSlug/channels/$channelId/settings/": { + id: "/_app/$orgSlug/channels/$channelId/settings/" + path: "/" + fullPath: "/$orgSlug/channels/$channelId/settings/" + preLoaderRoute: typeof AppOrgSlugChannelsChannelIdSettingsIndexRouteImport + parentRoute: typeof AppOrgSlugChannelsChannelIdSettingsLayoutRoute + } + "/_app/$orgSlug/chat/$id/files/media": { + id: "/_app/$orgSlug/chat/$id/files/media" + path: "/files/media" + fullPath: "/$orgSlug/chat/$id/files/media" + preLoaderRoute: typeof AppOrgSlugChatIdFilesMediaRouteImport + parentRoute: typeof AppOrgSlugChatIdRoute + } + "/_app/$orgSlug/channels/$channelId/settings/overview": { + id: "/_app/$orgSlug/channels/$channelId/settings/overview" + path: "/overview" + fullPath: "/$orgSlug/channels/$channelId/settings/overview" + preLoaderRoute: typeof AppOrgSlugChannelsChannelIdSettingsOverviewRouteImport + parentRoute: typeof AppOrgSlugChannelsChannelIdSettingsLayoutRoute + } + "/_app/$orgSlug/channels/$channelId/settings/integrations": { + id: "/_app/$orgSlug/channels/$channelId/settings/integrations" + path: "/integrations" + fullPath: "/$orgSlug/channels/$channelId/settings/integrations" + preLoaderRoute: typeof AppOrgSlugChannelsChannelIdSettingsIntegrationsRouteImport + parentRoute: typeof AppOrgSlugChannelsChannelIdSettingsLayoutRoute + } + "/_app/$orgSlug/channels/$channelId/settings/connect": { + id: "/_app/$orgSlug/channels/$channelId/settings/connect" + path: "/connect" + fullPath: "/$orgSlug/channels/$channelId/settings/connect" + preLoaderRoute: typeof AppOrgSlugChannelsChannelIdSettingsConnectRouteImport + parentRoute: typeof AppOrgSlugChannelsChannelIdSettingsLayoutRoute + } + } } interface AppOrgSlugMySettingsLayoutRouteChildren { - AppOrgSlugMySettingsDesktopRoute: typeof AppOrgSlugMySettingsDesktopRoute - AppOrgSlugMySettingsLinkedAccountsRoute: typeof AppOrgSlugMySettingsLinkedAccountsRoute - AppOrgSlugMySettingsNotificationsRoute: typeof AppOrgSlugMySettingsNotificationsRoute - AppOrgSlugMySettingsProfileRoute: typeof AppOrgSlugMySettingsProfileRoute - AppOrgSlugMySettingsIndexRoute: typeof AppOrgSlugMySettingsIndexRoute + AppOrgSlugMySettingsDesktopRoute: typeof AppOrgSlugMySettingsDesktopRoute + AppOrgSlugMySettingsLinkedAccountsRoute: typeof AppOrgSlugMySettingsLinkedAccountsRoute + AppOrgSlugMySettingsNotificationsRoute: typeof AppOrgSlugMySettingsNotificationsRoute + AppOrgSlugMySettingsProfileRoute: typeof AppOrgSlugMySettingsProfileRoute + AppOrgSlugMySettingsIndexRoute: typeof AppOrgSlugMySettingsIndexRoute } -const AppOrgSlugMySettingsLayoutRouteChildren: AppOrgSlugMySettingsLayoutRouteChildren = - { - AppOrgSlugMySettingsDesktopRoute: AppOrgSlugMySettingsDesktopRoute, - AppOrgSlugMySettingsLinkedAccountsRoute: - AppOrgSlugMySettingsLinkedAccountsRoute, - AppOrgSlugMySettingsNotificationsRoute: - AppOrgSlugMySettingsNotificationsRoute, - AppOrgSlugMySettingsProfileRoute: AppOrgSlugMySettingsProfileRoute, - AppOrgSlugMySettingsIndexRoute: AppOrgSlugMySettingsIndexRoute, - } +const AppOrgSlugMySettingsLayoutRouteChildren: AppOrgSlugMySettingsLayoutRouteChildren = { + AppOrgSlugMySettingsDesktopRoute: AppOrgSlugMySettingsDesktopRoute, + AppOrgSlugMySettingsLinkedAccountsRoute: AppOrgSlugMySettingsLinkedAccountsRoute, + AppOrgSlugMySettingsNotificationsRoute: AppOrgSlugMySettingsNotificationsRoute, + AppOrgSlugMySettingsProfileRoute: AppOrgSlugMySettingsProfileRoute, + AppOrgSlugMySettingsIndexRoute: AppOrgSlugMySettingsIndexRoute, +} -const AppOrgSlugMySettingsLayoutRouteWithChildren = - AppOrgSlugMySettingsLayoutRoute._addFileChildren( - AppOrgSlugMySettingsLayoutRouteChildren, - ) +const AppOrgSlugMySettingsLayoutRouteWithChildren = AppOrgSlugMySettingsLayoutRoute._addFileChildren( + AppOrgSlugMySettingsLayoutRouteChildren, +) interface AppOrgSlugNotificationsLayoutRouteChildren { - AppOrgSlugNotificationsDmsRoute: typeof AppOrgSlugNotificationsDmsRoute - AppOrgSlugNotificationsGeneralRoute: typeof AppOrgSlugNotificationsGeneralRoute - AppOrgSlugNotificationsThreadsRoute: typeof AppOrgSlugNotificationsThreadsRoute - AppOrgSlugNotificationsIndexRoute: typeof AppOrgSlugNotificationsIndexRoute + AppOrgSlugNotificationsDmsRoute: typeof AppOrgSlugNotificationsDmsRoute + AppOrgSlugNotificationsGeneralRoute: typeof AppOrgSlugNotificationsGeneralRoute + AppOrgSlugNotificationsThreadsRoute: typeof AppOrgSlugNotificationsThreadsRoute + AppOrgSlugNotificationsIndexRoute: typeof AppOrgSlugNotificationsIndexRoute } -const AppOrgSlugNotificationsLayoutRouteChildren: AppOrgSlugNotificationsLayoutRouteChildren = - { - AppOrgSlugNotificationsDmsRoute: AppOrgSlugNotificationsDmsRoute, - AppOrgSlugNotificationsGeneralRoute: AppOrgSlugNotificationsGeneralRoute, - AppOrgSlugNotificationsThreadsRoute: AppOrgSlugNotificationsThreadsRoute, - AppOrgSlugNotificationsIndexRoute: AppOrgSlugNotificationsIndexRoute, - } +const AppOrgSlugNotificationsLayoutRouteChildren: AppOrgSlugNotificationsLayoutRouteChildren = { + AppOrgSlugNotificationsDmsRoute: AppOrgSlugNotificationsDmsRoute, + AppOrgSlugNotificationsGeneralRoute: AppOrgSlugNotificationsGeneralRoute, + AppOrgSlugNotificationsThreadsRoute: AppOrgSlugNotificationsThreadsRoute, + AppOrgSlugNotificationsIndexRoute: AppOrgSlugNotificationsIndexRoute, +} -const AppOrgSlugNotificationsLayoutRouteWithChildren = - AppOrgSlugNotificationsLayoutRoute._addFileChildren( - AppOrgSlugNotificationsLayoutRouteChildren, - ) +const AppOrgSlugNotificationsLayoutRouteWithChildren = AppOrgSlugNotificationsLayoutRoute._addFileChildren( + AppOrgSlugNotificationsLayoutRouteChildren, +) interface AppOrgSlugSettingsChatSyncLayoutRouteChildren { - AppOrgSlugSettingsChatSyncConnectionIdRoute: typeof AppOrgSlugSettingsChatSyncConnectionIdRoute - AppOrgSlugSettingsChatSyncIndexRoute: typeof AppOrgSlugSettingsChatSyncIndexRoute + AppOrgSlugSettingsChatSyncConnectionIdRoute: typeof AppOrgSlugSettingsChatSyncConnectionIdRoute + AppOrgSlugSettingsChatSyncIndexRoute: typeof AppOrgSlugSettingsChatSyncIndexRoute } -const AppOrgSlugSettingsChatSyncLayoutRouteChildren: AppOrgSlugSettingsChatSyncLayoutRouteChildren = - { - AppOrgSlugSettingsChatSyncConnectionIdRoute: - AppOrgSlugSettingsChatSyncConnectionIdRoute, - AppOrgSlugSettingsChatSyncIndexRoute: AppOrgSlugSettingsChatSyncIndexRoute, - } +const AppOrgSlugSettingsChatSyncLayoutRouteChildren: AppOrgSlugSettingsChatSyncLayoutRouteChildren = { + AppOrgSlugSettingsChatSyncConnectionIdRoute: AppOrgSlugSettingsChatSyncConnectionIdRoute, + AppOrgSlugSettingsChatSyncIndexRoute: AppOrgSlugSettingsChatSyncIndexRoute, +} const AppOrgSlugSettingsChatSyncLayoutRouteWithChildren = - AppOrgSlugSettingsChatSyncLayoutRoute._addFileChildren( - AppOrgSlugSettingsChatSyncLayoutRouteChildren, - ) + AppOrgSlugSettingsChatSyncLayoutRoute._addFileChildren(AppOrgSlugSettingsChatSyncLayoutRouteChildren) interface AppOrgSlugSettingsIntegrationsLayoutRouteChildren { - AppOrgSlugSettingsIntegrationsIntegrationIdRoute: typeof AppOrgSlugSettingsIntegrationsIntegrationIdRoute - AppOrgSlugSettingsIntegrationsInstalledRoute: typeof AppOrgSlugSettingsIntegrationsInstalledRoute - AppOrgSlugSettingsIntegrationsMarketplaceRoute: typeof AppOrgSlugSettingsIntegrationsMarketplaceRoute - AppOrgSlugSettingsIntegrationsYourAppsRoute: typeof AppOrgSlugSettingsIntegrationsYourAppsRoute - AppOrgSlugSettingsIntegrationsIndexRoute: typeof AppOrgSlugSettingsIntegrationsIndexRoute + AppOrgSlugSettingsIntegrationsIntegrationIdRoute: typeof AppOrgSlugSettingsIntegrationsIntegrationIdRoute + AppOrgSlugSettingsIntegrationsInstalledRoute: typeof AppOrgSlugSettingsIntegrationsInstalledRoute + AppOrgSlugSettingsIntegrationsMarketplaceRoute: typeof AppOrgSlugSettingsIntegrationsMarketplaceRoute + AppOrgSlugSettingsIntegrationsYourAppsRoute: typeof AppOrgSlugSettingsIntegrationsYourAppsRoute + AppOrgSlugSettingsIntegrationsIndexRoute: typeof AppOrgSlugSettingsIntegrationsIndexRoute } -const AppOrgSlugSettingsIntegrationsLayoutRouteChildren: AppOrgSlugSettingsIntegrationsLayoutRouteChildren = - { - AppOrgSlugSettingsIntegrationsIntegrationIdRoute: - AppOrgSlugSettingsIntegrationsIntegrationIdRoute, - AppOrgSlugSettingsIntegrationsInstalledRoute: - AppOrgSlugSettingsIntegrationsInstalledRoute, - AppOrgSlugSettingsIntegrationsMarketplaceRoute: - AppOrgSlugSettingsIntegrationsMarketplaceRoute, - AppOrgSlugSettingsIntegrationsYourAppsRoute: - AppOrgSlugSettingsIntegrationsYourAppsRoute, - AppOrgSlugSettingsIntegrationsIndexRoute: - AppOrgSlugSettingsIntegrationsIndexRoute, - } +const AppOrgSlugSettingsIntegrationsLayoutRouteChildren: AppOrgSlugSettingsIntegrationsLayoutRouteChildren = { + AppOrgSlugSettingsIntegrationsIntegrationIdRoute: AppOrgSlugSettingsIntegrationsIntegrationIdRoute, + AppOrgSlugSettingsIntegrationsInstalledRoute: AppOrgSlugSettingsIntegrationsInstalledRoute, + AppOrgSlugSettingsIntegrationsMarketplaceRoute: AppOrgSlugSettingsIntegrationsMarketplaceRoute, + AppOrgSlugSettingsIntegrationsYourAppsRoute: AppOrgSlugSettingsIntegrationsYourAppsRoute, + AppOrgSlugSettingsIntegrationsIndexRoute: AppOrgSlugSettingsIntegrationsIndexRoute, +} const AppOrgSlugSettingsIntegrationsLayoutRouteWithChildren = - AppOrgSlugSettingsIntegrationsLayoutRoute._addFileChildren( - AppOrgSlugSettingsIntegrationsLayoutRouteChildren, - ) + AppOrgSlugSettingsIntegrationsLayoutRoute._addFileChildren( + AppOrgSlugSettingsIntegrationsLayoutRouteChildren, + ) interface AppOrgSlugSettingsLayoutRouteChildren { - AppOrgSlugSettingsChatSyncLayoutRoute: typeof AppOrgSlugSettingsChatSyncLayoutRouteWithChildren - AppOrgSlugSettingsIntegrationsLayoutRoute: typeof AppOrgSlugSettingsIntegrationsLayoutRouteWithChildren - AppOrgSlugSettingsAuthenticationRoute: typeof AppOrgSlugSettingsAuthenticationRoute - AppOrgSlugSettingsConnectInvitesRoute: typeof AppOrgSlugSettingsConnectInvitesRoute - AppOrgSlugSettingsCustomEmojisRoute: typeof AppOrgSlugSettingsCustomEmojisRoute - AppOrgSlugSettingsDebugRoute: typeof AppOrgSlugSettingsDebugRoute - AppOrgSlugSettingsInvitationsRoute: typeof AppOrgSlugSettingsInvitationsRoute - AppOrgSlugSettingsTeamRoute: typeof AppOrgSlugSettingsTeamRoute - AppOrgSlugSettingsIndexRoute: typeof AppOrgSlugSettingsIndexRoute + AppOrgSlugSettingsChatSyncLayoutRoute: typeof AppOrgSlugSettingsChatSyncLayoutRouteWithChildren + AppOrgSlugSettingsIntegrationsLayoutRoute: typeof AppOrgSlugSettingsIntegrationsLayoutRouteWithChildren + AppOrgSlugSettingsAuthenticationRoute: typeof AppOrgSlugSettingsAuthenticationRoute + AppOrgSlugSettingsConnectInvitesRoute: typeof AppOrgSlugSettingsConnectInvitesRoute + AppOrgSlugSettingsCustomEmojisRoute: typeof AppOrgSlugSettingsCustomEmojisRoute + AppOrgSlugSettingsDebugRoute: typeof AppOrgSlugSettingsDebugRoute + AppOrgSlugSettingsInvitationsRoute: typeof AppOrgSlugSettingsInvitationsRoute + AppOrgSlugSettingsTeamRoute: typeof AppOrgSlugSettingsTeamRoute + AppOrgSlugSettingsIndexRoute: typeof AppOrgSlugSettingsIndexRoute } -const AppOrgSlugSettingsLayoutRouteChildren: AppOrgSlugSettingsLayoutRouteChildren = - { - AppOrgSlugSettingsChatSyncLayoutRoute: - AppOrgSlugSettingsChatSyncLayoutRouteWithChildren, - AppOrgSlugSettingsIntegrationsLayoutRoute: - AppOrgSlugSettingsIntegrationsLayoutRouteWithChildren, - AppOrgSlugSettingsAuthenticationRoute: - AppOrgSlugSettingsAuthenticationRoute, - AppOrgSlugSettingsConnectInvitesRoute: - AppOrgSlugSettingsConnectInvitesRoute, - AppOrgSlugSettingsCustomEmojisRoute: AppOrgSlugSettingsCustomEmojisRoute, - AppOrgSlugSettingsDebugRoute: AppOrgSlugSettingsDebugRoute, - AppOrgSlugSettingsInvitationsRoute: AppOrgSlugSettingsInvitationsRoute, - AppOrgSlugSettingsTeamRoute: AppOrgSlugSettingsTeamRoute, - AppOrgSlugSettingsIndexRoute: AppOrgSlugSettingsIndexRoute, - } +const AppOrgSlugSettingsLayoutRouteChildren: AppOrgSlugSettingsLayoutRouteChildren = { + AppOrgSlugSettingsChatSyncLayoutRoute: AppOrgSlugSettingsChatSyncLayoutRouteWithChildren, + AppOrgSlugSettingsIntegrationsLayoutRoute: AppOrgSlugSettingsIntegrationsLayoutRouteWithChildren, + AppOrgSlugSettingsAuthenticationRoute: AppOrgSlugSettingsAuthenticationRoute, + AppOrgSlugSettingsConnectInvitesRoute: AppOrgSlugSettingsConnectInvitesRoute, + AppOrgSlugSettingsCustomEmojisRoute: AppOrgSlugSettingsCustomEmojisRoute, + AppOrgSlugSettingsDebugRoute: AppOrgSlugSettingsDebugRoute, + AppOrgSlugSettingsInvitationsRoute: AppOrgSlugSettingsInvitationsRoute, + AppOrgSlugSettingsTeamRoute: AppOrgSlugSettingsTeamRoute, + AppOrgSlugSettingsIndexRoute: AppOrgSlugSettingsIndexRoute, +} -const AppOrgSlugSettingsLayoutRouteWithChildren = - AppOrgSlugSettingsLayoutRoute._addFileChildren( - AppOrgSlugSettingsLayoutRouteChildren, - ) +const AppOrgSlugSettingsLayoutRouteWithChildren = AppOrgSlugSettingsLayoutRoute._addFileChildren( + AppOrgSlugSettingsLayoutRouteChildren, +) interface AppOrgSlugChatIdRouteChildren { - AppOrgSlugChatIdIndexRoute: typeof AppOrgSlugChatIdIndexRoute - AppOrgSlugChatIdFilesMediaRoute: typeof AppOrgSlugChatIdFilesMediaRoute - AppOrgSlugChatIdFilesIndexRoute: typeof AppOrgSlugChatIdFilesIndexRoute + AppOrgSlugChatIdIndexRoute: typeof AppOrgSlugChatIdIndexRoute + AppOrgSlugChatIdFilesMediaRoute: typeof AppOrgSlugChatIdFilesMediaRoute + AppOrgSlugChatIdFilesIndexRoute: typeof AppOrgSlugChatIdFilesIndexRoute } const AppOrgSlugChatIdRouteChildren: AppOrgSlugChatIdRouteChildren = { - AppOrgSlugChatIdIndexRoute: AppOrgSlugChatIdIndexRoute, - AppOrgSlugChatIdFilesMediaRoute: AppOrgSlugChatIdFilesMediaRoute, - AppOrgSlugChatIdFilesIndexRoute: AppOrgSlugChatIdFilesIndexRoute, + AppOrgSlugChatIdIndexRoute: AppOrgSlugChatIdIndexRoute, + AppOrgSlugChatIdFilesMediaRoute: AppOrgSlugChatIdFilesMediaRoute, + AppOrgSlugChatIdFilesIndexRoute: AppOrgSlugChatIdFilesIndexRoute, } -const AppOrgSlugChatIdRouteWithChildren = - AppOrgSlugChatIdRoute._addFileChildren(AppOrgSlugChatIdRouteChildren) +const AppOrgSlugChatIdRouteWithChildren = AppOrgSlugChatIdRoute._addFileChildren( + AppOrgSlugChatIdRouteChildren, +) interface AppOrgSlugChannelsChannelIdSettingsLayoutRouteChildren { - AppOrgSlugChannelsChannelIdSettingsConnectRoute: typeof AppOrgSlugChannelsChannelIdSettingsConnectRoute - AppOrgSlugChannelsChannelIdSettingsIntegrationsRoute: typeof AppOrgSlugChannelsChannelIdSettingsIntegrationsRoute - AppOrgSlugChannelsChannelIdSettingsOverviewRoute: typeof AppOrgSlugChannelsChannelIdSettingsOverviewRoute - AppOrgSlugChannelsChannelIdSettingsIndexRoute: typeof AppOrgSlugChannelsChannelIdSettingsIndexRoute + AppOrgSlugChannelsChannelIdSettingsConnectRoute: typeof AppOrgSlugChannelsChannelIdSettingsConnectRoute + AppOrgSlugChannelsChannelIdSettingsIntegrationsRoute: typeof AppOrgSlugChannelsChannelIdSettingsIntegrationsRoute + AppOrgSlugChannelsChannelIdSettingsOverviewRoute: typeof AppOrgSlugChannelsChannelIdSettingsOverviewRoute + AppOrgSlugChannelsChannelIdSettingsIndexRoute: typeof AppOrgSlugChannelsChannelIdSettingsIndexRoute } const AppOrgSlugChannelsChannelIdSettingsLayoutRouteChildren: AppOrgSlugChannelsChannelIdSettingsLayoutRouteChildren = - { - AppOrgSlugChannelsChannelIdSettingsConnectRoute: - AppOrgSlugChannelsChannelIdSettingsConnectRoute, - AppOrgSlugChannelsChannelIdSettingsIntegrationsRoute: - AppOrgSlugChannelsChannelIdSettingsIntegrationsRoute, - AppOrgSlugChannelsChannelIdSettingsOverviewRoute: - AppOrgSlugChannelsChannelIdSettingsOverviewRoute, - AppOrgSlugChannelsChannelIdSettingsIndexRoute: - AppOrgSlugChannelsChannelIdSettingsIndexRoute, - } + { + AppOrgSlugChannelsChannelIdSettingsConnectRoute: AppOrgSlugChannelsChannelIdSettingsConnectRoute, + AppOrgSlugChannelsChannelIdSettingsIntegrationsRoute: + AppOrgSlugChannelsChannelIdSettingsIntegrationsRoute, + AppOrgSlugChannelsChannelIdSettingsOverviewRoute: AppOrgSlugChannelsChannelIdSettingsOverviewRoute, + AppOrgSlugChannelsChannelIdSettingsIndexRoute: AppOrgSlugChannelsChannelIdSettingsIndexRoute, + } const AppOrgSlugChannelsChannelIdSettingsLayoutRouteWithChildren = - AppOrgSlugChannelsChannelIdSettingsLayoutRoute._addFileChildren( - AppOrgSlugChannelsChannelIdSettingsLayoutRouteChildren, - ) + AppOrgSlugChannelsChannelIdSettingsLayoutRoute._addFileChildren( + AppOrgSlugChannelsChannelIdSettingsLayoutRouteChildren, + ) interface AppOrgSlugLayoutRouteChildren { - AppOrgSlugMySettingsLayoutRoute: typeof AppOrgSlugMySettingsLayoutRouteWithChildren - AppOrgSlugNotificationsLayoutRoute: typeof AppOrgSlugNotificationsLayoutRouteWithChildren - AppOrgSlugSettingsLayoutRoute: typeof AppOrgSlugSettingsLayoutRouteWithChildren - AppOrgSlugIndexRoute: typeof AppOrgSlugIndexRoute - AppOrgSlugChatIdRoute: typeof AppOrgSlugChatIdRouteWithChildren - AppOrgSlugProfileUserIdRoute: typeof AppOrgSlugProfileUserIdRoute - AppOrgSlugChatIndexRoute: typeof AppOrgSlugChatIndexRoute - AppOrgSlugChannelsChannelIdSettingsLayoutRoute: typeof AppOrgSlugChannelsChannelIdSettingsLayoutRouteWithChildren + AppOrgSlugMySettingsLayoutRoute: typeof AppOrgSlugMySettingsLayoutRouteWithChildren + AppOrgSlugNotificationsLayoutRoute: typeof AppOrgSlugNotificationsLayoutRouteWithChildren + AppOrgSlugSettingsLayoutRoute: typeof AppOrgSlugSettingsLayoutRouteWithChildren + AppOrgSlugIndexRoute: typeof AppOrgSlugIndexRoute + AppOrgSlugChatIdRoute: typeof AppOrgSlugChatIdRouteWithChildren + AppOrgSlugProfileUserIdRoute: typeof AppOrgSlugProfileUserIdRoute + AppOrgSlugChatIndexRoute: typeof AppOrgSlugChatIndexRoute + AppOrgSlugChannelsChannelIdSettingsLayoutRoute: typeof AppOrgSlugChannelsChannelIdSettingsLayoutRouteWithChildren } const AppOrgSlugLayoutRouteChildren: AppOrgSlugLayoutRouteChildren = { - AppOrgSlugMySettingsLayoutRoute: AppOrgSlugMySettingsLayoutRouteWithChildren, - AppOrgSlugNotificationsLayoutRoute: - AppOrgSlugNotificationsLayoutRouteWithChildren, - AppOrgSlugSettingsLayoutRoute: AppOrgSlugSettingsLayoutRouteWithChildren, - AppOrgSlugIndexRoute: AppOrgSlugIndexRoute, - AppOrgSlugChatIdRoute: AppOrgSlugChatIdRouteWithChildren, - AppOrgSlugProfileUserIdRoute: AppOrgSlugProfileUserIdRoute, - AppOrgSlugChatIndexRoute: AppOrgSlugChatIndexRoute, - AppOrgSlugChannelsChannelIdSettingsLayoutRoute: - AppOrgSlugChannelsChannelIdSettingsLayoutRouteWithChildren, + AppOrgSlugMySettingsLayoutRoute: AppOrgSlugMySettingsLayoutRouteWithChildren, + AppOrgSlugNotificationsLayoutRoute: AppOrgSlugNotificationsLayoutRouteWithChildren, + AppOrgSlugSettingsLayoutRoute: AppOrgSlugSettingsLayoutRouteWithChildren, + AppOrgSlugIndexRoute: AppOrgSlugIndexRoute, + AppOrgSlugChatIdRoute: AppOrgSlugChatIdRouteWithChildren, + AppOrgSlugProfileUserIdRoute: AppOrgSlugProfileUserIdRoute, + AppOrgSlugChatIndexRoute: AppOrgSlugChatIndexRoute, + AppOrgSlugChannelsChannelIdSettingsLayoutRoute: + AppOrgSlugChannelsChannelIdSettingsLayoutRouteWithChildren, } -const AppOrgSlugLayoutRouteWithChildren = - AppOrgSlugLayoutRoute._addFileChildren(AppOrgSlugLayoutRouteChildren) +const AppOrgSlugLayoutRouteWithChildren = AppOrgSlugLayoutRoute._addFileChildren( + AppOrgSlugLayoutRouteChildren, +) interface AppLayoutRouteChildren { - AppOrgSlugLayoutRoute: typeof AppOrgSlugLayoutRouteWithChildren - AppIndexRoute: typeof AppIndexRoute - AppOnboardingSetupOrganizationRoute: typeof AppOnboardingSetupOrganizationRoute - AppOnboardingIndexRoute: typeof AppOnboardingIndexRoute - AppSelectOrganizationIndexRoute: typeof AppSelectOrganizationIndexRoute + AppOrgSlugLayoutRoute: typeof AppOrgSlugLayoutRouteWithChildren + AppIndexRoute: typeof AppIndexRoute + AppOnboardingSetupOrganizationRoute: typeof AppOnboardingSetupOrganizationRoute + AppOnboardingIndexRoute: typeof AppOnboardingIndexRoute + AppSelectOrganizationIndexRoute: typeof AppSelectOrganizationIndexRoute } const AppLayoutRouteChildren: AppLayoutRouteChildren = { - AppOrgSlugLayoutRoute: AppOrgSlugLayoutRouteWithChildren, - AppIndexRoute: AppIndexRoute, - AppOnboardingSetupOrganizationRoute: AppOnboardingSetupOrganizationRoute, - AppOnboardingIndexRoute: AppOnboardingIndexRoute, - AppSelectOrganizationIndexRoute: AppSelectOrganizationIndexRoute, + AppOrgSlugLayoutRoute: AppOrgSlugLayoutRouteWithChildren, + AppIndexRoute: AppIndexRoute, + AppOnboardingSetupOrganizationRoute: AppOnboardingSetupOrganizationRoute, + AppOnboardingIndexRoute: AppOnboardingIndexRoute, + AppSelectOrganizationIndexRoute: AppSelectOrganizationIndexRoute, } -const AppLayoutRouteWithChildren = AppLayoutRoute._addFileChildren( - AppLayoutRouteChildren, -) +const AppLayoutRouteWithChildren = AppLayoutRoute._addFileChildren(AppLayoutRouteChildren) interface DevUiLayoutRouteChildren { - DevUiAgentStepsRoute: typeof DevUiAgentStepsRoute + DevUiAgentStepsRoute: typeof DevUiAgentStepsRoute } const DevUiLayoutRouteChildren: DevUiLayoutRouteChildren = { - DevUiAgentStepsRoute: DevUiAgentStepsRoute, + DevUiAgentStepsRoute: DevUiAgentStepsRoute, } -const DevUiLayoutRouteWithChildren = DevUiLayoutRoute._addFileChildren( - DevUiLayoutRouteChildren, -) +const DevUiLayoutRouteWithChildren = DevUiLayoutRoute._addFileChildren(DevUiLayoutRouteChildren) interface DevLayoutRouteChildren { - DevUiLayoutRoute: typeof DevUiLayoutRouteWithChildren + DevUiLayoutRoute: typeof DevUiLayoutRouteWithChildren } const DevLayoutRouteChildren: DevLayoutRouteChildren = { - DevUiLayoutRoute: DevUiLayoutRouteWithChildren, + DevUiLayoutRoute: DevUiLayoutRouteWithChildren, } -const DevLayoutRouteWithChildren = DevLayoutRoute._addFileChildren( - DevLayoutRouteChildren, -) +const DevLayoutRouteWithChildren = DevLayoutRoute._addFileChildren(DevLayoutRouteChildren) const rootRouteChildren: RootRouteChildren = { - AppLayoutRoute: AppLayoutRouteWithChildren, - DevLayoutRoute: DevLayoutRouteWithChildren, - AuthCallbackRoute: AuthCallbackRoute, - AuthDesktopCallbackRoute: AuthDesktopCallbackRoute, - AuthDesktopLoginRoute: AuthDesktopLoginRoute, - AuthLoginRoute: AuthLoginRoute, - JoinSlugRoute: JoinSlugRoute, - DevEmbedsDemoRoute: DevEmbedsDemoRoute, - DevEmbedsGithubRoute: DevEmbedsGithubRoute, - DevEmbedsOpenstatusRoute: DevEmbedsOpenstatusRoute, - DevEmbedsRailwayRoute: DevEmbedsRailwayRoute, - DevEmbedsIndexRoute: DevEmbedsIndexRoute, + AppLayoutRoute: AppLayoutRouteWithChildren, + DevLayoutRoute: DevLayoutRouteWithChildren, + AuthCallbackRoute: AuthCallbackRoute, + AuthDesktopCallbackRoute: AuthDesktopCallbackRoute, + AuthDesktopLoginRoute: AuthDesktopLoginRoute, + AuthLoginRoute: AuthLoginRoute, + JoinSlugRoute: JoinSlugRoute, + DevEmbedsDemoRoute: DevEmbedsDemoRoute, + DevEmbedsGithubRoute: DevEmbedsGithubRoute, + DevEmbedsOpenstatusRoute: DevEmbedsOpenstatusRoute, + DevEmbedsRailwayRoute: DevEmbedsRailwayRoute, + DevEmbedsIndexRoute: DevEmbedsIndexRoute, } -export const routeTree = rootRouteImport - ._addFileChildren(rootRouteChildren) - ._addFileTypes() +export const routeTree = rootRouteImport._addFileChildren(rootRouteChildren)._addFileTypes() diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 66224f265..3dc71e10d 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,4 +1,3 @@ -import { TanStackDevtools } from "@tanstack/react-devtools" import { createRootRouteWithContext, type NavigateOptions, @@ -6,7 +5,6 @@ import { type ToOptions, useRouter, } from "@tanstack/react-router" -import { RpcDevtoolsPanel } from "effect-rpc-tanstack-devtools/components" import { lazy, Suspense } from "react" import { RouterProvider } from "react-aria-components" diff --git a/apps/web/src/routes/_app/$orgSlug/channels/$channelId/settings/connect.tsx b/apps/web/src/routes/_app/$orgSlug/channels/$channelId/settings/connect.tsx index e7f5e0272..3d64766b2 100644 --- a/apps/web/src/routes/_app/$orgSlug/channels/$channelId/settings/connect.tsx +++ b/apps/web/src/routes/_app/$orgSlug/channels/$channelId/settings/connect.tsx @@ -1,7 +1,8 @@ -import { Result, useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { AsyncResult } from "effect/unstable/reactivity" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import type { ChannelId, ConnectConversationId, ConnectInviteId, OrganizationId } from "@hazel/schema" import { eq, useLiveQuery } from "@tanstack/react-db" -import { Option } from "effect" +import { type DateTime, Option } from "effect" import { createFileRoute } from "@tanstack/react-router" import { useMemo, useState } from "react" import { @@ -27,6 +28,7 @@ import { getConnectInviteStatusBadge, getSharedConversationMountsForChannel, } from "~/lib/connect-shared-channels" +import { toDate } from "~/lib/utils" import { exitToastAsync } from "~/lib/toast-exit" export const Route = createFileRoute("/_app/$orgSlug/channels/$channelId/settings/connect")({ @@ -61,13 +63,17 @@ function ConnectPage() { // Get outgoing invites for this channel (RPC returns all org invites, filter by channelId) const outgoingResult = useAtomValue(listOutgoingInvitesQuery(organizationId!)) const outgoingInvites = useMemo(() => { - if (!Result.isSuccess(outgoingResult)) return [] - const data = Result.value(outgoingResult) + if (!AsyncResult.isSuccess(outgoingResult)) return [] + const data = AsyncResult.value(outgoingResult) if (Option.isNone(data)) return [] return data.value.data.filter((inv) => inv.hostChannelId === channelId) }, [outgoingResult, channelId]) - const sharedConnections = getSharedConversationMountsForChannel(channelId as ChannelId, connections ?? []) + type SharedConnection = NonNullable[number] + const sharedConnections: SharedConnection[] = getSharedConversationMountsForChannel( + channelId as ChannelId, + connections ?? [], + ) const isConnected = sharedConnections.length > 0 const viewerMount = sharedConnections.find((connection) => connection.organizationId === organizationId) @@ -278,7 +284,7 @@ function InviteRow({ targetKind: string targetValue: string status: string - createdAt: Date + createdAt: Date | DateTime.Utc } organizationId: OrganizationId | undefined }) { @@ -328,7 +334,7 @@ function InviteRow({ - {invite.createdAt.toLocaleDateString()} + {toDate(invite.createdAt).toLocaleDateString()} {invite.status === "pending" && ( diff --git a/apps/web/src/routes/_app/$orgSlug/channels/$channelId/settings/integrations.tsx b/apps/web/src/routes/_app/$orgSlug/channels/$channelId/settings/integrations.tsx index 6a2640e0f..d8827c380 100644 --- a/apps/web/src/routes/_app/$orgSlug/channels/$channelId/settings/integrations.tsx +++ b/apps/web/src/routes/_app/$orgSlug/channels/$channelId/settings/integrations.tsx @@ -1,9 +1,10 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { ChannelId, ChannelWebhookId, OrganizationId } from "@hazel/schema" import { eq, useLiveQuery } from "@tanstack/react-db" import { createFileRoute } from "@tanstack/react-router" import { formatDistanceToNow } from "date-fns" import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { toDate } from "~/lib/utils" import { toast } from "sonner" import { deleteChannelWebhookMutation, @@ -279,7 +280,7 @@ function CompactWebhookItem({ webhook, onDelete }: { webhook: WebhookData; onDel <> · - {formatDistanceToNow(new Date(webhook.lastUsedAt), { addSuffix: true })} + {formatDistanceToNow(toDate(webhook.lastUsedAt), { addSuffix: true })} )} diff --git a/apps/web/src/routes/_app/$orgSlug/channels/$channelId/settings/overview.tsx b/apps/web/src/routes/_app/$orgSlug/channels/$channelId/settings/overview.tsx index a6daac594..a3bd3d0cb 100644 --- a/apps/web/src/routes/_app/$orgSlug/channels/$channelId/settings/overview.tsx +++ b/apps/web/src/routes/_app/$orgSlug/channels/$channelId/settings/overview.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { ChannelIcon as ChannelIconType, ChannelId } from "@hazel/schema" import { eq, useLiveQuery } from "@tanstack/react-db" import { createFileRoute } from "@tanstack/react-router" diff --git a/apps/web/src/routes/_app/$orgSlug/chat/$id.tsx b/apps/web/src/routes/_app/$orgSlug/chat/$id.tsx index 919b7f72e..0d5e8fba9 100644 --- a/apps/web/src/routes/_app/$orgSlug/chat/$id.tsx +++ b/apps/web/src/routes/_app/$orgSlug/chat/$id.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { ChannelId } from "@hazel/schema" import { createLiveQueryCollection, eq } from "@tanstack/db" import { createFileRoute, Outlet } from "@tanstack/react-router" diff --git a/apps/web/src/routes/_app/$orgSlug/index.tsx b/apps/web/src/routes/_app/$orgSlug/index.tsx index 5133a4485..64a52c491 100644 --- a/apps/web/src/routes/_app/$orgSlug/index.tsx +++ b/apps/web/src/routes/_app/$orgSlug/index.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { UserId } from "@hazel/schema" import { eq, useLiveQuery } from "@tanstack/react-db" import { createFileRoute, useNavigate, useParams } from "@tanstack/react-router" diff --git a/apps/web/src/routes/_app/$orgSlug/my-settings/desktop.tsx b/apps/web/src/routes/_app/$orgSlug/my-settings/desktop.tsx index 7c89f7fb9..498b50cef 100644 --- a/apps/web/src/routes/_app/$orgSlug/my-settings/desktop.tsx +++ b/apps/web/src/routes/_app/$orgSlug/my-settings/desktop.tsx @@ -4,7 +4,7 @@ * @description User settings specific to the desktop application (autostart, etc.) */ -import { useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import { createFileRoute } from "@tanstack/react-router" import { useEffect, useState } from "react" import { diff --git a/apps/web/src/routes/_app/$orgSlug/my-settings/linked-accounts.tsx b/apps/web/src/routes/_app/$orgSlug/my-settings/linked-accounts.tsx index 70073df70..6e3f6cee4 100644 --- a/apps/web/src/routes/_app/$orgSlug/my-settings/linked-accounts.tsx +++ b/apps/web/src/routes/_app/$orgSlug/my-settings/linked-accounts.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { UserId } from "@hazel/schema" import { createFileRoute, useNavigate } from "@tanstack/react-router" import { Exit } from "effect" @@ -68,8 +68,8 @@ function LinkedAccountsSettings() { if (!user?.organizationId) return setIsConnectingDiscord(true) const result = await getOAuthUrlMutation({ - path: { orgId: user.organizationId, provider: "discord" }, - urlParams: { level: "user" }, + params: { orgId: user.organizationId, provider: "discord" }, + query: { level: "user" }, }) if (Exit.isSuccess(result)) { @@ -85,8 +85,8 @@ function LinkedAccountsSettings() { if (!user?.organizationId) return setIsDisconnectingDiscord(true) const result = await disconnectMutation({ - path: { orgId: user.organizationId, provider: "discord" }, - urlParams: { level: "user" }, + params: { orgId: user.organizationId, provider: "discord" }, + query: { level: "user" }, }) if (Exit.isSuccess(result)) { diff --git a/apps/web/src/routes/_app/$orgSlug/my-settings/profile.tsx b/apps/web/src/routes/_app/$orgSlug/my-settings/profile.tsx index 7932aee16..769d9cb35 100644 --- a/apps/web/src/routes/_app/$orgSlug/my-settings/profile.tsx +++ b/apps/web/src/routes/_app/$orgSlug/my-settings/profile.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { UserId } from "@hazel/schema" import { createFileRoute } from "@tanstack/react-router" import { type } from "arktype" diff --git a/apps/web/src/routes/_app/$orgSlug/profile/$userId.tsx b/apps/web/src/routes/_app/$orgSlug/profile/$userId.tsx index a5705618d..a86aaac37 100644 --- a/apps/web/src/routes/_app/$orgSlug/profile/$userId.tsx +++ b/apps/web/src/routes/_app/$orgSlug/profile/$userId.tsx @@ -1,4 +1,5 @@ -import { Result, useAtomValue } from "@effect-atom/atom-react" +import { AsyncResult } from "effect/unstable/reactivity" +import { useAtomValue } from "@effect/atom-react" import type { UserId } from "@hazel/schema" import { createFileRoute, Link } from "@tanstack/react-router" import { userWithPresenceAtomFamily } from "~/atoms/message-atoms" @@ -26,7 +27,7 @@ function ProfilePage() { const nowMs = useAtomValue(presenceNowSignal) const userPresenceResult = useAtomValue(userWithPresenceAtomFamily(userId as UserId)) - const data = Result.getOrElse(userPresenceResult, () => []) + const data = AsyncResult.getOrElse(userPresenceResult, () => []) const result = data[0] const user = result?.user const presence = result?.presence diff --git a/apps/web/src/routes/_app/$orgSlug/settings/authentication.tsx b/apps/web/src/routes/_app/$orgSlug/settings/authentication.tsx index bde41928b..f480d75e7 100644 --- a/apps/web/src/routes/_app/$orgSlug/settings/authentication.tsx +++ b/apps/web/src/routes/_app/$orgSlug/settings/authentication.tsx @@ -1,4 +1,5 @@ -import { Result, useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { AsyncResult } from "effect/unstable/reactivity" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import type { OrganizationId } from "@hazel/schema" import { createFileRoute } from "@tanstack/react-router" import { useState } from "react" @@ -197,7 +198,7 @@ function DomainManagement({ const isAddingDomain = addDomainResult.waiting const isRemovingDomain = removeDomainResult.waiting - const domains: Domain[] = Result.getOrElse(domainsResult, () => []) as Domain[] + const domains: Domain[] = AsyncResult.getOrElse(domainsResult, () => []) as Domain[] // Only show loading on initial load, not during background refreshes (polling) const isLoadingDomains = domainsResult._tag === "Initial" diff --git a/apps/web/src/routes/_app/$orgSlug/settings/chat-sync/$connectionId.tsx b/apps/web/src/routes/_app/$orgSlug/settings/chat-sync/$connectionId.tsx index 96f5246f1..d407d1e24 100644 --- a/apps/web/src/routes/_app/$orgSlug/settings/chat-sync/$connectionId.tsx +++ b/apps/web/src/routes/_app/$orgSlug/settings/chat-sync/$connectionId.tsx @@ -1,9 +1,11 @@ -import { Result, useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { AsyncResult } from "effect/unstable/reactivity" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import type { SyncChannelLinkId, SyncConnectionId } from "@hazel/schema" import { createFileRoute, useNavigate } from "@tanstack/react-router" import { eq, useLiveQuery } from "@tanstack/react-db" import { Option } from "effect" import { useMemo, useState } from "react" +import { toDate } from "~/lib/utils" import { AddChannelLinkModal } from "~/components/chat-sync/add-channel-link-modal" import IconCirclePause from "~/components/icons/icon-circle-pause" import IconDotsVertical from "~/components/icons/icon-dots-vertical" @@ -181,16 +183,16 @@ function ChatSyncConnectionDetailPage() { // Find the current connection from the list const connection = useMemo(() => { - if (!Result.isSuccess(connectionsResult)) return null - const data = Result.value(connectionsResult) + if (!AsyncResult.isSuccess(connectionsResult)) return null + const data = AsyncResult.value(connectionsResult) if (Option.isNone(data)) return null return data.value.data.find((c) => c.id === connectionId) ?? null }, [connectionsResult, connectionId]) // Get channel links const channelLinks = useMemo(() => { - if (!Result.isSuccess(channelLinksResult)) return [] - const data = Result.value(channelLinksResult) + if (!AsyncResult.isSuccess(channelLinksResult)) return [] + const data = AsyncResult.value(channelLinksResult) if (Option.isNone(data)) return [] return data.value.data }, [channelLinksResult]) @@ -285,7 +287,7 @@ function ChatSyncConnectionDetailPage() { } // Loading state - if (Result.isInitial(connectionsResult)) { + if (AsyncResult.isInitial(connectionsResult)) { return (
@@ -453,7 +455,7 @@ function ChatSyncConnectionDetailPage() {

{connection.lastSyncedAt - ? `Last synced ${new Date( + ? `Last synced ${toDate( connection.lastSyncedAt, ).toLocaleDateString(undefined, { month: "short", @@ -494,7 +496,7 @@ function ChatSyncConnectionDetailPage() {

- {Result.isInitial(channelLinksResult) ? ( + {AsyncResult.isInitial(channelLinksResult) ? (
diff --git a/apps/web/src/routes/_app/$orgSlug/settings/chat-sync/index.tsx b/apps/web/src/routes/_app/$orgSlug/settings/chat-sync/index.tsx index 4ae17883f..a62946505 100644 --- a/apps/web/src/routes/_app/$orgSlug/settings/chat-sync/index.tsx +++ b/apps/web/src/routes/_app/$orgSlug/settings/chat-sync/index.tsx @@ -1,7 +1,9 @@ -import { Result, useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { AsyncResult } from "effect/unstable/reactivity" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import type { SyncConnectionId } from "@hazel/schema" import { createFileRoute, useNavigate } from "@tanstack/react-router" import { Option } from "effect" +import { toDate } from "~/lib/utils" import { useState } from "react" import { AddConnectionModal } from "~/components/chat-sync/add-connection-modal" import IconArrowPath from "~/components/icons/icon-arrow-path" @@ -141,7 +143,7 @@ function ChatSyncConnectionsPage() { } // Loading state - if (Result.isInitial(connectionsResult)) { + if (AsyncResult.isInitial(connectionsResult)) { return ( <> @@ -165,7 +167,7 @@ function ChatSyncConnectionsPage() { } // Error state - if (Result.isFailure(connectionsResult)) { + if (AsyncResult.isFailure(connectionsResult)) { return ( <> @@ -186,7 +188,7 @@ function ChatSyncConnectionsPage() { ) } - const data = Result.value(connectionsResult) + const data = AsyncResult.value(connectionsResult) const connections = Option.isSome(data) ? data.value.data : [] return ( @@ -263,7 +265,7 @@ function ChatSyncConnectionsPage() { {connection.lastSyncedAt && ( Last synced:{" "} - {new Date(connection.lastSyncedAt).toLocaleDateString( + {toDate(connection.lastSyncedAt).toLocaleDateString( undefined, { month: "short", diff --git a/apps/web/src/routes/_app/$orgSlug/settings/connect-invites.tsx b/apps/web/src/routes/_app/$orgSlug/settings/connect-invites.tsx index 438fb525f..d80df1026 100644 --- a/apps/web/src/routes/_app/$orgSlug/settings/connect-invites.tsx +++ b/apps/web/src/routes/_app/$orgSlug/settings/connect-invites.tsx @@ -1,7 +1,8 @@ -import { Result, useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { AsyncResult } from "effect/unstable/reactivity" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import type { ConnectInviteId, OrganizationId } from "@hazel/schema" import { eq, useLiveQuery } from "@tanstack/react-db" -import { Option } from "effect" +import { type DateTime, Option } from "effect" import { createFileRoute } from "@tanstack/react-router" import { useMemo, useState } from "react" import { @@ -17,6 +18,7 @@ import { EmptyState } from "~/components/ui/empty-state" import { organizationCollection } from "~/db/collections" import { useOrganization } from "~/hooks/use-organization" import { getConnectInviteStatusBadge } from "~/lib/connect-shared-channels" +import { toDate } from "~/lib/utils" import { exitToastAsync } from "~/lib/toast-exit" export const Route = createFileRoute("/_app/$orgSlug/settings/connect-invites")({ @@ -29,8 +31,8 @@ function ConnectInvitesPage() { const invitesResult = useAtomValue(listIncomingInvitesQuery(organizationId!)) const allInvites = useMemo(() => { - if (!Result.isSuccess(invitesResult)) return [] - const data = Result.value(invitesResult) + if (!AsyncResult.isSuccess(invitesResult)) return [] + const data = AsyncResult.value(invitesResult) if (Option.isNone(data)) return [] return data.value.data }, [invitesResult]) @@ -114,7 +116,7 @@ function IncomingInviteRow({ id: ConnectInviteId hostOrganizationId: OrganizationId status: string - createdAt: Date + createdAt: Date | DateTime.Utc } organizationId: OrganizationId | undefined }) { @@ -227,7 +229,7 @@ function IncomingInviteRow({ - {invite.createdAt.toLocaleDateString()} + {toDate(invite.createdAt).toLocaleDateString()} {invite.status === "pending" && ( diff --git a/apps/web/src/routes/_app/$orgSlug/settings/custom-emojis.tsx b/apps/web/src/routes/_app/$orgSlug/settings/custom-emojis.tsx index 9094549af..cfb2fb168 100644 --- a/apps/web/src/routes/_app/$orgSlug/settings/custom-emojis.tsx +++ b/apps/web/src/routes/_app/$orgSlug/settings/custom-emojis.tsx @@ -1,8 +1,10 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" +import { CustomEmojiDeletedExistsError } from "@hazel/domain/rpc" import { eq, isNull, useLiveQuery } from "@tanstack/react-db" import { createFileRoute } from "@tanstack/react-router" import { formatDistanceToNow } from "date-fns" -import { Cause, Chunk, Exit, Option } from "effect" +import { toDate } from "~/lib/utils" +import { Cause, Exit } from "effect" import { useCallback, useEffect, useState } from "react" import { type DropItem, DropZone, FileTrigger, Button as AriaButton } from "react-aria-components" import { toast } from "sonner" @@ -204,27 +206,22 @@ function CustomEmojisSettings() { toast.success(`Emoji :${emojiName}: created`) handleCancel() } else { - // Check if the error is a deleted emoji conflict - const failures = Cause.failures(createResult.cause) - const firstError = Chunk.head(failures) - - if (Option.isSome(firstError)) { - if ("_tag" in firstError.value) { - if (firstError.value._tag === "CustomEmojiDeletedExistsError") { - const err = firstError.value as { - customEmojiId: CustomEmojiId - name: string - imageUrl: string - } - setRestoreTarget({ - id: err.customEmojiId, - name: err.name, - imageUrl: err.imageUrl, - newImageUrl: publicUrl, - }) - return - } - } + const deletedExistsError = createResult.cause.reasons + .filter(Cause.isFailReason) + .map((reason) => reason.error) + .find( + (error): error is CustomEmojiDeletedExistsError => + error instanceof CustomEmojiDeletedExistsError, + ) + + if (deletedExistsError) { + setRestoreTarget({ + id: deletedExistsError.customEmojiId, + name: deletedExistsError.name, + imageUrl: deletedExistsError.imageUrl, + newImageUrl: publicUrl, + }) + return } toast.error("Failed to create emoji", { description: "The name may already be taken. Please try another.", @@ -528,7 +525,7 @@ function CustomEmojisSettings() { {emoji.createdAt - ? formatDistanceToNow(new Date(emoji.createdAt), { + ? formatDistanceToNow(toDate(emoji.createdAt), { addSuffix: true, }) : "—"} diff --git a/apps/web/src/routes/_app/$orgSlug/settings/debug.tsx b/apps/web/src/routes/_app/$orgSlug/settings/debug.tsx index 545b026e7..895ab37ef 100644 --- a/apps/web/src/routes/_app/$orgSlug/settings/debug.tsx +++ b/apps/web/src/routes/_app/$orgSlug/settings/debug.tsx @@ -1,4 +1,4 @@ -import { useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import { type Notification } from "@hazel/domain/models" import { IconWarning } from "~/components/icons/icon-warning" import { createFileRoute, redirect } from "@tanstack/react-router" @@ -101,7 +101,7 @@ function DebugSettings() { const handleSyntheticNotification = () => { const now = new Date() - const syntheticNotification: typeof Notification.Model.Type = { + const syntheticNotification: Notification.Type = { id: `synthetic-${Date.now()}` as any, memberId: `synthetic-member-${Date.now()}` as any, targetedResourceId: null, diff --git a/apps/web/src/routes/_app/$orgSlug/settings/index.tsx b/apps/web/src/routes/_app/$orgSlug/settings/index.tsx index 44990ea57..82f764ece 100644 --- a/apps/web/src/routes/_app/$orgSlug/settings/index.tsx +++ b/apps/web/src/routes/_app/$orgSlug/settings/index.tsx @@ -1,4 +1,4 @@ -import { useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import { createFileRoute, useNavigate, useParams } from "@tanstack/react-router" import { useRef, useState } from "react" import { toast } from "sonner" diff --git a/apps/web/src/routes/_app/$orgSlug/settings/integrations/$integrationId.tsx b/apps/web/src/routes/_app/$orgSlug/settings/integrations/$integrationId.tsx index d9c75f771..aa6bfbc4d 100644 --- a/apps/web/src/routes/_app/$orgSlug/settings/integrations/$integrationId.tsx +++ b/apps/web/src/routes/_app/$orgSlug/settings/integrations/$integrationId.tsx @@ -1,4 +1,5 @@ -import { Result, useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { AsyncResult } from "effect/unstable/reactivity" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import type { OrganizationId } from "@hazel/schema" import type { IntegrationConnection } from "@hazel/domain/models" import { createFileRoute, notFound, useNavigate } from "@tanstack/react-router" @@ -156,8 +157,8 @@ function IntegrationConfigPage() { // Make authenticated API call to get OAuth URL, then redirect // This ensures the bearer token auth is properly sent const exit = await getOAuthUrlMutation({ - path: { orgId: organizationId, provider: integrationId as IntegrationProvider }, - urlParams: { level: "organization" }, + params: { orgId: organizationId, provider: integrationId as IntegrationProvider }, + query: { level: "organization" }, }) if (Exit.isSuccess(exit)) { @@ -175,8 +176,8 @@ function IntegrationConfigPage() { if (!organizationId) return setIsDisconnecting(true) const exit = await disconnectMutation({ - path: { orgId: organizationId, provider: integrationId as IntegrationProvider }, - urlParams: { level: "organization" }, + params: { orgId: organizationId, provider: integrationId as IntegrationProvider }, + query: { level: "organization" }, }) exitToast(exit) @@ -185,9 +186,9 @@ function IntegrationConfigPage() { description: "This integration is already disconnected.", isRetryable: false, })) - .onErrorTag("UnsupportedProviderError", (error) => ({ + .onErrorTag("UnsupportedProviderError", () => ({ title: "Unsupported provider", - description: `The provider "${error.provider}" is not supported.`, + description: "This integration provider is not supported.", isRetryable: false, })) .run() @@ -200,7 +201,7 @@ function IntegrationConfigPage() { setIsConnecting(true) const exit = await connectApiKeyMutation({ - path: { orgId: organizationId, provider: integrationId as IntegrationProvider }, + params: { orgId: organizationId, provider: integrationId as IntegrationProvider }, payload: { token, baseUrl }, }) @@ -215,9 +216,9 @@ function IntegrationConfigPage() { description: error.message, isRetryable: true, })) - .onErrorTag("UnsupportedProviderError", (error) => ({ + .onErrorTag("UnsupportedProviderError", () => ({ title: "Unsupported provider", - description: `The provider "${error.provider}" does not support API key connections.`, + description: "This integration does not support API key connections.", isRetryable: false, })) .onError(() => ({ @@ -553,7 +554,7 @@ function ConnectedState({ isConfiguring, }: { integration: Integration - connection: typeof IntegrationConnection.Model.Type | null + connection: IntegrationConnection.Type | null externalAccountName: string | null isDisconnecting: boolean onDisconnect: () => void @@ -762,13 +763,13 @@ function GitHubRepositoryAccessSection({ organizationId }: { organizationId: Org const repositoriesResult = useAtomValue( HazelApiClient.query("integration-resources", "getGitHubRepositories", { - path: { orgId: organizationId }, - urlParams: { page, perPage }, + params: { orgId: organizationId }, + query: { page, perPage }, }), ) // Loading state - if (Result.isInitial(repositoriesResult)) { + if (AsyncResult.isInitial(repositoriesResult)) { return (
@@ -799,7 +800,7 @@ function GitHubRepositoryAccessSection({ organizationId }: { organizationId: Org } // Error state - if (Result.isFailure(repositoriesResult)) { + if (AsyncResult.isFailure(repositoriesResult)) { return (
@@ -812,7 +813,7 @@ function GitHubRepositoryAccessSection({ organizationId }: { organizationId: Org ) } - const data = Result.value(repositoriesResult) + const data = AsyncResult.value(repositoriesResult) if (Option.isNone(data)) { return null } diff --git a/apps/web/src/routes/_app/$orgSlug/settings/integrations/index.tsx b/apps/web/src/routes/_app/$orgSlug/settings/integrations/index.tsx index 96638611a..390e85fa7 100644 --- a/apps/web/src/routes/_app/$orgSlug/settings/integrations/index.tsx +++ b/apps/web/src/routes/_app/$orgSlug/settings/integrations/index.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { IntegrationConnection } from "@hazel/domain/models" import { createFileRoute, useNavigate } from "@tanstack/react-router" import { useEffect, useMemo, useState } from "react" diff --git a/apps/web/src/routes/_app/$orgSlug/settings/integrations/installed.tsx b/apps/web/src/routes/_app/$orgSlug/settings/integrations/installed.tsx index 2f1ffb102..b0d436dfc 100644 --- a/apps/web/src/routes/_app/$orgSlug/settings/integrations/installed.tsx +++ b/apps/web/src/routes/_app/$orgSlug/settings/integrations/installed.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { BotId } from "@hazel/schema" import { createFileRoute, useNavigate } from "@tanstack/react-router" import { useCallback, useState } from "react" diff --git a/apps/web/src/routes/_app/$orgSlug/settings/integrations/marketplace.tsx b/apps/web/src/routes/_app/$orgSlug/settings/integrations/marketplace.tsx index 79f5fbc90..4c4a5f1ad 100644 --- a/apps/web/src/routes/_app/$orgSlug/settings/integrations/marketplace.tsx +++ b/apps/web/src/routes/_app/$orgSlug/settings/integrations/marketplace.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { BotId } from "@hazel/schema" import { createFileRoute } from "@tanstack/react-router" import { useCallback, useDeferredValue, useMemo, useState } from "react" diff --git a/apps/web/src/routes/_app/$orgSlug/settings/invitations.tsx b/apps/web/src/routes/_app/$orgSlug/settings/invitations.tsx index 2f0424035..d71c79f70 100644 --- a/apps/web/src/routes/_app/$orgSlug/settings/invitations.tsx +++ b/apps/web/src/routes/_app/$orgSlug/settings/invitations.tsx @@ -1,5 +1,6 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import type { InvitationId } from "@hazel/schema" +import { toEpochMs } from "~/lib/utils" import { IconArrowPath } from "~/components/icons/icon-arrow-path" import { eq, useLiveQuery } from "@tanstack/react-db" import { createFileRoute } from "@tanstack/react-router" @@ -222,7 +223,7 @@ function InvitationsSettings() {

{formatTimeRemaining( - invitation.expiresAt.getTime() - now, + toEpochMs(invitation.expiresAt) - now, )}

diff --git a/apps/web/src/routes/_app/$orgSlug/settings/team.tsx b/apps/web/src/routes/_app/$orgSlug/settings/team.tsx index 27a8796b8..5bff457ca 100644 --- a/apps/web/src/routes/_app/$orgSlug/settings/team.tsx +++ b/apps/web/src/routes/_app/$orgSlug/settings/team.tsx @@ -1,4 +1,4 @@ -import { useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import type { UserId } from "@hazel/schema" import { eq, useLiveQuery } from "@tanstack/react-db" import { createFileRoute, useNavigate } from "@tanstack/react-router" diff --git a/apps/web/src/routes/_app/onboarding/setup-organization.tsx b/apps/web/src/routes/_app/onboarding/setup-organization.tsx index d9c2da572..b912adc0e 100644 --- a/apps/web/src/routes/_app/onboarding/setup-organization.tsx +++ b/apps/web/src/routes/_app/onboarding/setup-organization.tsx @@ -1,4 +1,4 @@ -import { useAtomSet } from "@effect-atom/atom-react" +import { useAtomSet } from "@effect/atom-react" import { eq, useLiveQuery } from "@tanstack/react-db" import { createFileRoute, Navigate, useNavigate } from "@tanstack/react-router" import { type } from "arktype" @@ -93,9 +93,9 @@ function RouteComponent() { }) exitToast(exit) - .onErrorTag("OrganizationSlugAlreadyExistsError", (error) => ({ + .onErrorTag("OrganizationSlugAlreadyExistsError", () => ({ title: "Slug already taken", - description: `The slug "${error.slug}" is already in use. Please choose a different one.`, + description: "That workspace URL is already in use. Please choose a different one.", isRetryable: false, })) .onErrorTag("OrganizationNotFoundError", () => ({ diff --git a/apps/web/src/routes/auth/callback.test.tsx b/apps/web/src/routes/auth/callback.test.tsx new file mode 100644 index 000000000..2a776a1f4 --- /dev/null +++ b/apps/web/src/routes/auth/callback.test.tsx @@ -0,0 +1,171 @@ +import { RegistryContext } from "@effect/atom-react" +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { StrictMode } from "react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { OAuthCodeExpiredError, OAuthStateMismatchError, TokenExchangeError } from "@hazel/domain/errors" + +const getMockSearch = () => + ( + globalThis as typeof globalThis & { + __authCallbackSearch: { + code?: string + state?: string + error?: string + error_description?: string + } + } + ).__authCallbackSearch + +const getMockNavigate = () => + ( + globalThis as typeof globalThis & { + __authCallbackNavigate: ReturnType + } + ).__authCallbackNavigate + +vi.mock("@tanstack/react-router", () => ({ + createFileRoute: () => (config: Record) => ({ + ...config, + useSearch: () => getMockSearch(), + }), + useNavigate: () => getMockNavigate(), +})) + +import { resetCallbackState, setWebCallbackExecutorForTest } from "~/atoms/web-callback-atoms" +import { appRegistry } from "~/lib/registry" +import { WebCallbackPage } from "./callback" + +describe("/auth/callback", () => { + beforeEach(() => { + ;( + globalThis as typeof globalThis & { + __authCallbackSearch: { + code?: string + state?: string + error?: string + error_description?: string + } + __authCallbackNavigate: ReturnType + } + ).__authCallbackSearch = { + code: "test-auth-code", + state: JSON.stringify({ returnTo: "/" }), + error: undefined, + error_description: undefined, + } + ;( + globalThis as typeof globalThis & { __authCallbackNavigate: ReturnType } + ).__authCallbackNavigate = vi.fn() + resetCallbackState() + setWebCallbackExecutorForTest(null) + }) + + it("redeems the callback once under StrictMode and lands in success", async () => { + const executor = vi.fn(async ({ returnTo }: { attemptId: string; returnTo: string }) => ({ + success: true as const, + returnTo, + })) + setWebCallbackExecutorForTest(executor) + + render( + + + + + , + ) + + expect(await screen.findByText("Authentication Successful")).toBeTruthy() + expect(executor).toHaveBeenCalledTimes(1) + }) + + it("retries once after a retryable failure and then succeeds", async () => { + const executor = vi + .fn< + (args: { + attemptId: string + returnTo: string + }) => Promise< + | { success: true; returnTo: string } + | { success: false; error: TokenExchangeError | OAuthCodeExpiredError } + > + >() + .mockResolvedValueOnce({ + success: false, + error: new TokenExchangeError({ message: "Temporary exchange failure" }), + }) + .mockImplementationOnce(async ({ returnTo }) => ({ + success: true, + returnTo, + })) + setWebCallbackExecutorForTest(executor) + + render( + + + + + , + ) + + expect(await screen.findByText("Authentication Failed")).toBeTruthy() + expect(executor).toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getByRole("button", { name: "Try Again" })) + + expect(await screen.findByText("Authentication Successful")).toBeTruthy() + expect(executor).toHaveBeenCalledTimes(2) + }) + + it("keeps non-retryable failures terminal after the first attempt", async () => { + const executor = vi.fn(async () => ({ + success: false as const, + error: new OAuthCodeExpiredError({ + message: "Authorization code expired or already used", + }), + })) + setWebCallbackExecutorForTest(executor) + + render( + + + + + , + ) + + expect(await screen.findByText("Authentication Failed")).toBeTruthy() + expect(executor).toHaveBeenCalledTimes(1) + + await waitFor(() => { + expect(screen.queryByRole("button", { name: "Try Again" })).toBeNull() + }) + }) + + it("surfaces typed state-mismatch failures with the simplified message", async () => { + const executor = vi.fn(async () => ({ + success: false as const, + error: new OAuthStateMismatchError({ + message: "state mismatch", + }), + })) + setWebCallbackExecutorForTest(executor) + + render( + + + + + , + ) + + expect(await screen.findByText("Authentication Failed")).toBeTruthy() + expect( + screen.getByText("This login callback did not match the active session. Please start again."), + ).toBeTruthy() + + await waitFor(() => { + expect(screen.queryByRole("button", { name: "Try Again" })).toBeNull() + }) + }) +}) diff --git a/apps/web/src/routes/auth/callback.tsx b/apps/web/src/routes/auth/callback.tsx index 8c916687c..fe3a6e9e4 100644 --- a/apps/web/src/routes/auth/callback.tsx +++ b/apps/web/src/routes/auth/callback.tsx @@ -4,13 +4,15 @@ * @description Receives OAuth callback from WorkOS, exchanges code for JWT tokens, and stores them in localStorage */ -import { useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { useAtomValue } from "@effect/atom-react" import { createFileRoute, useNavigate } from "@tanstack/react-router" import { Schema } from "effect" -import { useEffect, useMemo } from "react" +import { useEffect } from "react" import { - createWebCallbackInitAtom, - retryWebCallbackAtom, + getWebCallbackAttemptKey, + resetCallbackState, + retryWebCallback, + startWebCallback, webCallbackStatusAtom, } from "~/atoms/web-callback-atoms" import { Logo } from "~/components/logo" @@ -25,7 +27,7 @@ const AuthStateSchema = Schema.Struct({ // Schema for search params - state can be string or parsed object (TanStack Router auto-parses JSON) const RawSearchParams = Schema.Struct({ code: Schema.optional(Schema.String), - state: Schema.optional(Schema.Union(Schema.String, AuthStateSchema)), + state: Schema.optional(Schema.Union([Schema.String, AuthStateSchema])), error: Schema.optional(Schema.String), error_description: Schema.optional(Schema.String), }) @@ -35,17 +37,15 @@ export const Route = createFileRoute("/auth/callback")({ validateSearch: (search: Record) => Schema.decodeUnknownSync(RawSearchParams)(search), }) -function WebCallbackPage() { +export function WebCallbackPage() { const search = Route.useSearch() const navigate = useNavigate() const status = useAtomValue(webCallbackStatusAtom) + const callbackAttemptKey = getWebCallbackAttemptKey(search) - // Create and mount init atom - triggers callback automatically when mounted - const initAtom = useMemo(() => createWebCallbackInitAtom(search), [search]) - useAtomValue(initAtom) - - // Get action atom setters - const retryCallback = useAtomSet(retryWebCallbackAtom) + useEffect(() => { + void startWebCallback(search) + }, [callbackAttemptKey]) // Redirect on success useEffect(() => { @@ -59,10 +59,11 @@ function WebCallbackPage() { }, [status, navigate]) function handleRetry() { - retryCallback(search) + void retryWebCallback(search) } function handleBackToLogin() { + resetCallbackState() navigate({ to: "/auth/login", replace: true }) } diff --git a/apps/web/src/routes/auth/desktop-callback.tsx b/apps/web/src/routes/auth/desktop-callback.tsx index 85dfad813..4f25b097e 100644 --- a/apps/web/src/routes/auth/desktop-callback.tsx +++ b/apps/web/src/routes/auth/desktop-callback.tsx @@ -4,7 +4,7 @@ * @description Receives OAuth callback from WorkOS and forwards to desktop app's local server */ -import { useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import { DesktopAuthState } from "@hazel/domain/http" import { createFileRoute } from "@tanstack/react-router" import { Schema } from "effect" diff --git a/apps/web/src/routes/auth/desktop-login.tsx b/apps/web/src/routes/auth/desktop-login.tsx index d700e0c97..7b1fce534 100644 --- a/apps/web/src/routes/auth/desktop-login.tsx +++ b/apps/web/src/routes/auth/desktop-login.tsx @@ -4,7 +4,7 @@ * @description Login page for desktop app that initiates OAuth flow via system browser */ -import { useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import { createFileRoute, Navigate, redirect } from "@tanstack/react-router" import { desktopAuthErrorAtom, diff --git a/apps/web/src/routes/auth/login.test.tsx b/apps/web/src/routes/auth/login.test.tsx new file mode 100644 index 000000000..927527971 --- /dev/null +++ b/apps/web/src/routes/auth/login.test.tsx @@ -0,0 +1,90 @@ +import { render, waitFor } from "@testing-library/react" +import { StrictMode } from "react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const getMockLoginSearch = () => + ( + globalThis as typeof globalThis & { + __authLoginSearch: { + returnTo?: string + organizationId?: string + invitationToken?: string + } + } + ).__authLoginSearch + +const getMockUseAuth = () => + ( + globalThis as typeof globalThis & { + __authLoginUseAuth: ReturnType + } + ).__authLoginUseAuth + +vi.mock("@tanstack/react-router", () => ({ + createFileRoute: () => (config: Record) => ({ + ...config, + useSearch: () => getMockLoginSearch(), + }), + Navigate: () => null, +})) + +vi.mock("../../lib/auth", () => ({ + useAuth: () => (getMockUseAuth() as () => ReturnType)(), +})) + +import { resetAllWebLoginRedirects } from "~/lib/web-login-single-flight" +import { LoginPage } from "./login" + +describe("/auth/login", () => { + beforeEach(() => { + resetAllWebLoginRedirects() + const mockLogin = vi.fn() + ;( + globalThis as typeof globalThis & { + __authLoginSearch: { + returnTo?: string + organizationId?: string + invitationToken?: string + } + __authLoginUseAuth: ReturnType + __authLoginFn: ReturnType + } + ).__authLoginSearch = { + returnTo: "/", + organizationId: undefined, + invitationToken: undefined, + } + ;(globalThis as typeof globalThis & { __authLoginFn: ReturnType }).__authLoginFn = + mockLogin + ;( + globalThis as typeof globalThis & { __authLoginUseAuth: ReturnType } + ).__authLoginUseAuth = vi.fn() + getMockUseAuth().mockReturnValue({ + user: null, + login: mockLogin, + isLoading: false, + }) + }) + + it("starts login once under StrictMode for the same search params", async () => { + render( + + + , + ) + + await waitFor(() => { + expect( + (globalThis as typeof globalThis & { __authLoginFn: ReturnType }).__authLoginFn, + ).toHaveBeenCalledTimes(1) + }) + + expect( + (globalThis as typeof globalThis & { __authLoginFn: ReturnType }).__authLoginFn, + ).toHaveBeenCalledWith({ + returnTo: "/", + organizationId: undefined, + invitationToken: undefined, + }) + }) +}) diff --git a/apps/web/src/routes/auth/login.tsx b/apps/web/src/routes/auth/login.tsx index c6d294039..dc3a2c66a 100644 --- a/apps/web/src/routes/auth/login.tsx +++ b/apps/web/src/routes/auth/login.tsx @@ -1,7 +1,8 @@ import type { OrganizationId } from "@hazel/schema" import { createFileRoute, Navigate } from "@tanstack/react-router" -import { useEffect, useRef } from "react" +import { useEffect } from "react" import { Loader } from "~/components/ui/loader" +import { getWebLoginAttemptKey, startWebLoginRedirectOnce } from "~/lib/web-login-single-flight" import { useAuth } from "../../lib/auth" export const Route = createFileRoute("/auth/login")({ @@ -21,24 +22,27 @@ export const Route = createFileRoute("/auth/login")({ }, }) -function LoginPage() { +export function LoginPage() { const { user, login, isLoading } = useAuth() const search = Route.useSearch() - - // Use ref to track if login was initiated - const hasInitiatedLogin = useRef(false) + const loginAttemptKey = getWebLoginAttemptKey({ + returnTo: search.returnTo || "/", + organizationId: search.organizationId, + invitationToken: search.invitationToken, + }) // Initiate login in useEffect when conditions are met useEffect(() => { - if (!user && !isLoading && !hasInitiatedLogin.current) { - hasInitiatedLogin.current = true - login({ - returnTo: search.returnTo || "/", - organizationId: search.organizationId as OrganizationId | undefined, - invitationToken: search.invitationToken, - }) + if (!user && !isLoading) { + startWebLoginRedirectOnce(loginAttemptKey, () => + login({ + returnTo: search.returnTo || "/", + organizationId: search.organizationId as OrganizationId | undefined, + invitationToken: search.invitationToken, + }), + ) } - }, [user, isLoading, login, search.returnTo, search.organizationId, search.invitationToken]) + }, [user, isLoading, login, loginAttemptKey]) if (isLoading) { return ( diff --git a/apps/web/src/routes/join/$slug.tsx b/apps/web/src/routes/join/$slug.tsx index 5c37276d7..e6bcd77ed 100644 --- a/apps/web/src/routes/join/$slug.tsx +++ b/apps/web/src/routes/join/$slug.tsx @@ -1,4 +1,5 @@ -import { Result, useAtomSet, useAtomValue } from "@effect-atom/atom-react" +import { AsyncResult } from "effect/unstable/reactivity" +import { useAtomSet, useAtomValue } from "@effect/atom-react" import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" import { Match } from "effect" import { motion } from "motion/react" @@ -158,7 +159,7 @@ function JoinPage() { ) } - const org = Result.getOrElse(orgResult, () => null) + const org = AsyncResult.getOrElse(orgResult, () => null) // Organization not found or not public if (!org) { diff --git a/apps/web/src/utils/attachment-url.ts b/apps/web/src/utils/attachment-url.ts index 2eba5d80f..5d1ff16b5 100644 --- a/apps/web/src/utils/attachment-url.ts +++ b/apps/web/src/utils/attachment-url.ts @@ -2,6 +2,5 @@ import type { Attachment } from "@hazel/domain/models" const FALLBACK_PUBLIC_URL = import.meta.env.VITE_R2_PUBLIC_URL || "https://cdn.hazel.sh" -export const getAttachmentUrl = ( - attachment: Pick, -): string => attachment.externalUrl?.trim() || `${FALLBACK_PUBLIC_URL}/${attachment.id}` +export const getAttachmentUrl = (attachment: Pick): string => + attachment.externalUrl?.trim() || `${FALLBACK_PUBLIC_URL}/${attachment.id}` diff --git a/apps/web/src/utils/presence.ts b/apps/web/src/utils/presence.ts index a577687e9..043f2dedc 100644 --- a/apps/web/src/utils/presence.ts +++ b/apps/web/src/utils/presence.ts @@ -1,10 +1,12 @@ +import type { DateTime } from "effect" + export type PresenceStatus = "online" | "away" | "busy" | "dnd" | "offline" export const DEFAULT_OFFLINE_THRESHOLD_MS = 45_000 export type PresenceLike = { status?: string | null | undefined - lastSeenAt?: Date | null | undefined + lastSeenAt?: Date | DateTime.Utc | null | undefined } | null /** @@ -24,11 +26,16 @@ export function getEffectivePresenceStatus( if (!presence) return "offline" const lastSeenAt = presence.lastSeenAt - if (!(lastSeenAt instanceof Date) || Number.isNaN(lastSeenAt.getTime())) { + if (!lastSeenAt) { + return "offline" + } + + const lastSeenMs = lastSeenAt instanceof Date ? lastSeenAt.getTime() : lastSeenAt.epochMilliseconds + if (Number.isNaN(lastSeenMs)) { return "offline" } - if (nowMs - lastSeenAt.getTime() > offlineThresholdMs) { + if (nowMs - lastSeenMs > offlineThresholdMs) { return "offline" } diff --git a/apps/web/src/utils/status.ts b/apps/web/src/utils/status.ts index 1db348e29..c89836c7f 100644 --- a/apps/web/src/utils/status.ts +++ b/apps/web/src/utils/status.ts @@ -66,11 +66,13 @@ export function getStatusLabel(status?: PresenceStatus | string): string { /** * Formats the status expiration time in a human-readable way */ -export function formatStatusExpiration(expiresAt: Date | null | undefined): string | null { +export function formatStatusExpiration( + expiresAt: Date | { epochMilliseconds: number } | null | undefined, +): string | null { if (!expiresAt) return null const now = new Date() - const expiry = new Date(expiresAt) + const expiry = expiresAt instanceof Date ? expiresAt : new Date(expiresAt.epochMilliseconds) // If already expired, return null if (expiry <= now) return null diff --git a/bots/hazel-bot/package.json b/bots/hazel-bot/package.json index de540ac32..94cd1d4aa 100644 --- a/bots/hazel-bot/package.json +++ b/bots/hazel-bot/package.json @@ -9,10 +9,8 @@ "dev": "bun run --watch src/index.ts" }, "dependencies": { - "@effect/ai": "catalog:effect", - "@effect/platform": "catalog:effect", + "@effect/ai-openrouter": "catalog:effect", "@hazel-chat/bot-sdk": "workspace:*", - "@hazel/ai-openrouter": "workspace:*", "@hazel/domain": "workspace:*", "@hazel/integrations": "workspace:*", "@hazel/schema": "workspace:*", diff --git a/bots/hazel-bot/src/agent-loop.ts b/bots/hazel-bot/src/agent-loop.ts index 01b197ae5..783d78da3 100644 --- a/bots/hazel-bot/src/agent-loop.ts +++ b/bots/hazel-bot/src/agent-loop.ts @@ -1,13 +1,15 @@ -import { AiError, LanguageModel, Prompt, type Response, type Toolkit } from "@effect/ai" -import { Duration, Effect, Mailbox, Stream } from "effect" +import { AiError, LanguageModel, Prompt, type Response, type Toolkit } from "effect/unstable/ai" +import { Cause, Duration, Effect, Queue, Stream } from "effect" import { withDegenerationDetection } from "./degeneration-detector.ts" -import { IterationTimeoutError, StreamIdleTimeoutError } from "./errors.ts" +import { DegenerateOutputError, IterationTimeoutError, StreamIdleTimeoutError } from "./errors.ts" const MAX_ITERATIONS = 10 const IDLE_TIMEOUT = Duration.seconds(15) const ITERATION_TIMEOUT = Duration.minutes(2) +type AgentError = AiError.AiError | StreamIdleTimeoutError | IterationTimeoutError | DegenerateOutputError + /** * Multi-step streaming agent loop. * @@ -17,25 +19,21 @@ const ITERATION_TIMEOUT = Duration.minutes(2) * again, until it responds without tool calls or MAX_ITERATIONS is reached. * * All stream parts (text deltas, tool calls, tool results) from every iteration - * are emitted in real-time via a Mailbox-backed stream. + * are emitted in real-time via a Queue-backed stream. * * Safeguards per iteration: - * - Idle timeout: fails if no chunk received for 15s + * - Idle timeout: fails if no chunk received for 15s (via stream timeout + concat fail) * - Degeneration detection: fails if repetitive patterns detected * - Iteration timeout: fails if a single LLM call exceeds 2 minutes */ export const streamAgentLoop = (options: { prompt: Prompt.RawInput toolkit: Toolkit.WithHandler -}): Stream.Stream< - Response.AnyPart, - AiError.AiError | StreamIdleTimeoutError | IterationTimeoutError, - LanguageModel.LanguageModel -> => +}): Stream.Stream => Effect.gen(function* () { - const mailbox = yield* Mailbox.make< + const queue: Queue.Queue = yield* Queue.make< Response.AnyPart, - AiError.AiError | StreamIdleTimeoutError | IterationTimeoutError + AgentError | Cause.Done >() yield* Effect.gen(function* () { @@ -49,26 +47,30 @@ export const streamAgentLoop = (options: { toolkit: options.toolkit, toolChoice: "auto" as any, }).pipe( - // Idle timeout: resets on each emitted element - Stream.timeoutFail( - () => + // Idle timeout: ends the stream if no element arrives for 15s, + // then we concat a failure stream so that timeout = error + Stream.timeout(IDLE_TIMEOUT), + Stream.concat( + Stream.fail( new StreamIdleTimeoutError({ message: "No data received from AI model for 15 seconds", }), - IDLE_TIMEOUT, + ), ), // Degeneration detection: fails on repetitive patterns withDegenerationDetection, Stream.runForEach((part) => { collectedParts.push(part as Response.AnyPart) - return mailbox.offer(part as Response.AnyPart) + return Queue.offer(queue, part as Response.AnyPart) }), // Iteration timeout: wall-clock limit per LLM call - Effect.timeoutFail({ + Effect.timeoutOrElse({ onTimeout: () => - new IterationTimeoutError({ - message: "Single LLM call exceeded 2 minute time limit", - }), + Effect.fail( + new IterationTimeoutError({ + message: "Single LLM call exceeded 2 minute time limit", + }), + ), duration: ITERATION_TIMEOUT, }), ) @@ -78,13 +80,9 @@ export const streamAgentLoop = (options: { if (!hasToolCalls) break // Append assistant response + tool results to prompt for next iteration - currentPrompt = Prompt.merge(currentPrompt, Prompt.fromResponseParts(collectedParts)) + currentPrompt = Prompt.concat(currentPrompt, Prompt.fromResponseParts(collectedParts)) } - }).pipe(Mailbox.into(mailbox), Effect.forkScoped) + }).pipe(Queue.into(queue), Effect.forkScoped) - return Mailbox.toStream(mailbox) - }).pipe(Stream.unwrapScoped) as Stream.Stream< - Response.AnyPart, - AiError.AiError | StreamIdleTimeoutError | IterationTimeoutError, - LanguageModel.LanguageModel - > + return Stream.fromQueue(queue) + }).pipe(Stream.unwrap) as Stream.Stream diff --git a/bots/hazel-bot/src/degeneration-detector.ts b/bots/hazel-bot/src/degeneration-detector.ts index 8edc7d322..8726f7f1b 100644 --- a/bots/hazel-bot/src/degeneration-detector.ts +++ b/bots/hazel-bot/src/degeneration-detector.ts @@ -1,4 +1,4 @@ -import type { Response } from "@effect/ai" +import type { Response } from "effect/unstable/ai" import { Effect, Stream } from "effect" import { DegenerateOutputError } from "./errors.ts" @@ -20,26 +20,41 @@ const MIN_REPEATS = 8 export const withDegenerationDetection = ( stream: Stream.Stream, ): Stream.Stream => - Stream.mapAccumEffect(stream, "", (window, part: Response.AnyPart) => { - if (part.type !== "text-delta") { - return Effect.succeed([window, part] as const) - } + stream.pipe( + Stream.mapAccum( + () => "", + (window: string, part: Response.AnyPart) => { + if (part.type !== "text-delta") { + return [window, [part]] as const + } - const updated = (window + part.delta).slice(-WINDOW_SIZE) - const detected = findRepetition(updated) + const updated = (window + part.delta).slice(-WINDOW_SIZE) + const detected = findRepetition(updated) - if (detected) { - return Effect.fail( - new DegenerateOutputError({ - message: `Detected repeating pattern "${detected.pattern}" (${detected.repeats}x)`, - pattern: detected.pattern, - repeats: detected.repeats, - }), - ) - } + if (detected) { + // Return a sentinel value to trigger failure after mapAccum + return [ + updated, + [{ __degenerate: true, pattern: detected.pattern, repeats: detected.repeats } as any], + ] as const + } - return Effect.succeed([updated, part] as const) - }) + return [updated, [part]] as const + }, + ), + Stream.filterEffect((part: any) => { + if (part.__degenerate) { + return Effect.fail( + new DegenerateOutputError({ + message: `Detected repeating pattern "${part.pattern}" (${part.repeats}x)`, + pattern: part.pattern, + repeats: part.repeats, + }), + ) + } + return Effect.succeed(true) + }), + ) as Stream.Stream /** * Scans the tail of the window for any substring of length 2-10 that repeats diff --git a/bots/hazel-bot/src/errors.ts b/bots/hazel-bot/src/errors.ts index f7ad2497a..875f51ca1 100644 --- a/bots/hazel-bot/src/errors.ts +++ b/bots/hazel-bot/src/errors.ts @@ -1,13 +1,13 @@ import { Schema } from "effect" -export class StreamIdleTimeoutError extends Schema.TaggedError()( +export class StreamIdleTimeoutError extends Schema.TaggedErrorClass()( "StreamIdleTimeoutError", { message: Schema.String, }, ) {} -export class DegenerateOutputError extends Schema.TaggedError()( +export class DegenerateOutputError extends Schema.TaggedErrorClass()( "DegenerateOutputError", { message: Schema.String, @@ -16,13 +16,16 @@ export class DegenerateOutputError extends Schema.TaggedError()( +export class IterationTimeoutError extends Schema.TaggedErrorClass()( "IterationTimeoutError", { message: Schema.String, }, ) {} -export class SessionTimeoutError extends Schema.TaggedError()("SessionTimeoutError", { - message: Schema.String, -}) {} +export class SessionTimeoutError extends Schema.TaggedErrorClass()( + "SessionTimeoutError", + { + message: Schema.String, + }, +) {} diff --git a/bots/hazel-bot/src/handler.ts b/bots/hazel-bot/src/handler.ts index 5ce83a603..3fd5374e2 100644 --- a/bots/hazel-bot/src/handler.ts +++ b/bots/hazel-bot/src/handler.ts @@ -1,4 +1,4 @@ -import { LanguageModel } from "@effect/ai" +import { LanguageModel } from "effect/unstable/ai" import { generateIntegrationInstructions, type AIContentChunk, @@ -44,7 +44,7 @@ export const handleAIRequest = (params: { Effect.gen(function* () { const { bot, message, channelId, orgId } = params - const enabledIntegrations = yield* bot.integration.getEnabled(orgId) + const enabledIntegrations = yield* (bot as any).integration.getEnabled(orgId) yield* Effect.log(`Enabled integrations for org ${orgId}:`, { integrations: Array.from(enabledIntegrations), @@ -78,7 +78,7 @@ export const handleAIRequest = (params: { // Use acquireUseRelease for guaranteed cleanup of the streaming session. yield* Effect.acquireUseRelease( - bot.ai.stream(channelId, { + (bot as any).ai.stream(channelId, { model: modelName, showThinking: true, showToolCalls: true, @@ -88,7 +88,7 @@ export const handleAIRequest = (params: { throbbing: true, }, }), - (session) => + (session: any) => Effect.gen(function* () { yield* Effect.log(`Created streaming message ${session.messageId}`) @@ -113,30 +113,41 @@ export const handleAIRequest = (params: { yield* session.complete() yield* Effect.log(`Agent response complete: ${session.messageId}`) }).pipe( - Effect.timeoutFail({ + Effect.timeoutOrElse({ onTimeout: () => - new SessionTimeoutError({ - message: "Overall AI session exceeded 3 minute time limit", - }), + Effect.fail( + new SessionTimeoutError({ + message: "Overall AI session exceeded 3 minute time limit", + }), + ), duration: Duration.minutes(3), }), ), // Release: on failure/interrupt, persist the error state - (session, exit) => + (session: any, exit) => Exit.isSuccess(exit) ? Effect.void : Effect.gen(function* () { const cause = exit.cause yield* Effect.logError("Agent streaming failed", { error: cause }) - const userMessage: string = Cause.match(cause, { - onEmpty: "Request was cancelled.", - onFail: (error) => mapErrorToUserMessage(error), - onDie: () => "An unexpected error occurred.", - onInterrupt: () => "Request was cancelled.", - onSequential: (left: string) => left, - onParallel: (left: string) => left, - }) + // Extract a user-facing message from the cause + const failReason = cause.reasons.find(Cause.isFailReason) + const dieReason = cause.reasons.find(Cause.isDieReason) + const interruptReason = cause.reasons.find(Cause.isInterruptReason) + + let userMessage: string + if (failReason) { + userMessage = mapErrorToUserMessage(failReason.error) + } else if (dieReason) { + userMessage = "An unexpected error occurred." + } else if (interruptReason) { + userMessage = "Request was cancelled." + } else if (cause.reasons.length === 0) { + userMessage = "Request was cancelled." + } else { + userMessage = "An unexpected error occurred." + } yield* session.fail(userMessage).pipe(Effect.ignore) }), @@ -145,9 +156,11 @@ export const handleAIRequest = (params: { // Provide the LanguageModel dynamically based on config Effect.provideServiceEffect( LanguageModel.LanguageModel, - Config.string("AI_MODEL").pipe( - Config.withDefault("google/gemini-3-flash-preview"), - Effect.flatMap((model) => makeOpenRouterModel(model)), - ), + Effect.gen(function* () { + const model = yield* Config.string("AI_MODEL").pipe( + Config.withDefault("google/gemini-3-flash-preview"), + ) + return yield* makeOpenRouterModel(model) + }), ), ) diff --git a/bots/hazel-bot/src/index.ts b/bots/hazel-bot/src/index.ts index 17931582f..29f7fb8a6 100644 --- a/bots/hazel-bot/src/index.ts +++ b/bots/hazel-bot/src/index.ts @@ -12,7 +12,7 @@ const ACTIVE_THREADS_KEY = "hazel-bot:active-threads" const ActiveThreadsState = Schema.Struct({ order: Schema.Array(Schema.String), - byChannel: Schema.Record({ key: Schema.String, value: Schema.String }), + byChannel: Schema.Record(Schema.String, Schema.String), }) const defaultActiveThreadsState = () => ({ @@ -24,21 +24,23 @@ const bot = defineBot({ serviceName: "hazel-bot", commands, mentionable: true, - layers: [LinearApiClient.Default, CraftApiClient.Default], + layers: [LinearApiClient.layer, CraftApiClient.layer], setup: (bot) => Effect.gen(function* () { + const botAny = bot as any + const loadActiveThreads = () => - bot.state + botAny.state .getJson(ACTIVE_THREADS_KEY, ActiveThreadsState) - .pipe(Effect.map((state) => state ?? defaultActiveThreadsState())) + .pipe(Effect.map((state: any) => state ?? defaultActiveThreadsState())) const saveActiveThreads = (state: Schema.Schema.Type) => - bot.state.setJson(ACTIVE_THREADS_KEY, ActiveThreadsState, state) + botAny.state.setJson(ACTIVE_THREADS_KEY, ActiveThreadsState, state) const trackThread = (channelId: string, orgId: OrganizationId) => Effect.gen(function* () { const current = yield* loadActiveThreads() - const nextOrder = current.order.filter((id) => id !== channelId) + const nextOrder = current.order.filter((id: string) => id !== channelId) const nextByChannel = { ...current.byChannel } nextOrder.push(channelId) @@ -61,7 +63,7 @@ const bot = defineBot({ }) // /ask command handler - yield* bot.onCommand(AskCommand, (ctx) => + yield* botAny.onCommand(AskCommand, (ctx: any) => Effect.gen(function* () { yield* Effect.log(`Received /ask: ${ctx.args.message}`) yield* handleAIRequest({ @@ -74,11 +76,11 @@ const bot = defineBot({ ) // /test command handler - yield* bot.onCommand(TestCommand, (ctx) => + yield* botAny.onCommand(TestCommand, (ctx: any) => Effect.gen(function* () { const now = new Date(ctx.timestamp).toISOString() yield* Effect.log(`Received /test in ${ctx.channelId}`) - yield* bot.message.send( + yield* botAny.message.send( ctx.channelId, `Hazel Bot gateway test OK.\nchannel=${ctx.channelId}\norg=${ctx.orgId}\ntimestamp=${now}`, ) @@ -86,9 +88,9 @@ const bot = defineBot({ ) // Thread follow-up handler - yield* bot.onMessage((message) => + yield* botAny.onMessage((message: any) => Effect.gen(function* () { - const authContext = yield* bot.getAuthContext + const authContext = yield* botAny.getAuthContext // Skip bot's own messages to prevent infinite loops if (message.authorId === authContext.userId) return @@ -101,12 +103,12 @@ const bot = defineBot({ yield* Effect.log(`Thread follow-up in ${message.channelId}: ${message.content}`) // Fetch thread history for conversation context - const { data: messages } = yield* bot.message.list(message.channelId, { + const { data: messages } = yield* botAny.message.list(message.channelId, { limit: 50, }) // messages are newest-first, reverse to chronological order - const history = [...messages].reverse().map((m) => ({ + const history = [...messages].reverse().map((m: any) => ({ role: (m.authorId === authContext.userId ? "assistant" : "user") as | "user" | "assistant", @@ -124,10 +126,10 @@ const bot = defineBot({ ) // @mention handler — reply in a thread - yield* bot.onMention((message) => + yield* botAny.onMention((message: any) => Effect.gen(function* () { yield* Effect.log(`Received @mention: ${message.content}`) - const authContext = yield* bot.getAuthContext + const authContext = yield* botAny.getAuthContext // Strip the bot mention from content to get the question const question = message.content @@ -137,12 +139,12 @@ const bot = defineBot({ yield* Effect.log(`Received question: ${question}`) if (!question) { - yield* bot.message.reply(message, "Hey! What can I help you with?") + yield* botAny.message.reply(message, "Hey! What can I help you with?") return } // Resolve thread + org context - const thread = yield* bot.channel.createThread(message.id, message.channelId) + const thread = yield* botAny.channel.createThread(message.id, message.channelId) yield* Effect.log(`Created thread: ${thread.id}`) diff --git a/bots/hazel-bot/src/openrouter.ts b/bots/hazel-bot/src/openrouter.ts index 7a1ce26cb..2403a7a9f 100644 --- a/bots/hazel-bot/src/openrouter.ts +++ b/bots/hazel-bot/src/openrouter.ts @@ -1,5 +1,5 @@ -import { OpenRouterClient, OpenRouterLanguageModel } from "@hazel/ai-openrouter" -import { FetchHttpClient } from "@effect/platform" +import { OpenRouterClient, OpenRouterLanguageModel } from "@effect/ai-openrouter" +import { FetchHttpClient } from "effect/unstable/http" import { Config, Effect, Layer } from "effect" const OpenRouterClientLayer = OpenRouterClient.layerConfig({ diff --git a/bots/hazel-bot/src/stream.ts b/bots/hazel-bot/src/stream.ts index 290657271..e5544dece 100644 --- a/bots/hazel-bot/src/stream.ts +++ b/bots/hazel-bot/src/stream.ts @@ -1,5 +1,5 @@ import type { AIContentChunk } from "@hazel-chat/bot-sdk" -import type { Response } from "@effect/ai" +import type { Response } from "effect/unstable/ai" import { Match } from "effect" export const mapEffectPartToChunk: (part: Response.AnyPart) => AIContentChunk | null = diff --git a/bots/hazel-bot/src/tools/base.ts b/bots/hazel-bot/src/tools/base.ts index 8ea786b1a..6bff1cc74 100644 --- a/bots/hazel-bot/src/tools/base.ts +++ b/bots/hazel-bot/src/tools/base.ts @@ -1,4 +1,4 @@ -import { Tool } from "@effect/ai" +import { Tool } from "effect/unstable/ai" import { Schema } from "effect" export const GetCurrentTime = Tool.make("get_current_time", { @@ -8,12 +8,12 @@ export const GetCurrentTime = Tool.make("get_current_time", { export const Calculate = Tool.make("calculate", { description: "Perform basic arithmetic calculations", - parameters: { - operation: Schema.Literal("add", "subtract", "multiply", "divide").annotations({ + parameters: Schema.Struct({ + operation: Schema.Literals(["add", "subtract", "multiply", "divide"]).annotate({ description: "The arithmetic operation to perform", }), - a: Schema.Number.annotations({ description: "First operand" }), - b: Schema.Number.annotations({ description: "Second operand" }), - }, + a: Schema.Number.annotate({ description: "First operand" }), + b: Schema.Number.annotate({ description: "Second operand" }), + }), success: Schema.Number, }) diff --git a/bots/hazel-bot/src/tools/craft.ts b/bots/hazel-bot/src/tools/craft.ts index 3d5bfa320..5a21ff2ec 100644 --- a/bots/hazel-bot/src/tools/craft.ts +++ b/bots/hazel-bot/src/tools/craft.ts @@ -1,76 +1,76 @@ -import { Tool } from "@effect/ai" +import { Tool } from "effect/unstable/ai" import { Schema } from "effect" export const CraftSearchDocuments = Tool.make("craft_search_documents", { description: "Search across all documents in the connected Craft space", - parameters: { - query: Schema.String.annotations({ description: "Search query text" }), - }, + parameters: Schema.Struct({ + query: Schema.String.annotate({ description: "Search query text" }), + }), success: Schema.Unknown, }) export const CraftGetDocument = Tool.make("craft_get_document", { description: "Fetch the content blocks of a Craft document by its ID", - parameters: { - documentId: Schema.String.annotations({ description: "The document ID to fetch" }), - }, + parameters: Schema.Struct({ + documentId: Schema.String.annotate({ description: "The document ID to fetch" }), + }), success: Schema.Unknown, }) export const CraftCreateDocument = Tool.make("craft_create_document", { description: "Create a new Craft document. Use this after confirming with the user what you will create.", - parameters: { - title: Schema.String.annotations({ description: "Document title" }), + parameters: Schema.Struct({ + title: Schema.String.annotate({ description: "Document title" }), content: Schema.optional( - Schema.String.annotations({ description: "Initial text content for the document" }), + Schema.String.annotate({ description: "Initial text content for the document" }), ), folderId: Schema.optional( - Schema.String.annotations({ description: "Optional folder ID to create the document in" }), + Schema.String.annotate({ description: "Optional folder ID to create the document in" }), ), - }, + }), success: Schema.Unknown, }) export const CraftInsertBlocks = Tool.make("craft_insert_blocks", { description: "Add content blocks to an existing Craft document", - parameters: { - documentId: Schema.String.annotations({ description: "The document ID to add blocks to" }), + parameters: Schema.Struct({ + documentId: Schema.String.annotate({ description: "The document ID to add blocks to" }), blocks: Schema.Array( Schema.Struct({ - type: Schema.String.annotations({ + type: Schema.String.annotate({ description: 'Block type (e.g., "text")', }), - content: Schema.optional(Schema.String.annotations({ description: "Block text content" })), + content: Schema.optional(Schema.String.annotate({ description: "Block text content" })), }), - ).annotations({ description: "Array of blocks to insert" }), + ).annotate({ description: "Array of blocks to insert" }), parentBlockId: Schema.optional( - Schema.String.annotations({ description: "Optional parent block ID to nest under" }), + Schema.String.annotate({ description: "Optional parent block ID to nest under" }), ), - }, + }), success: Schema.Unknown, }) export const CraftGetTasks = Tool.make("craft_get_tasks", { description: "List tasks from the connected Craft space", - parameters: { + parameters: Schema.Struct({ scope: Schema.optional( - Schema.Literal("inbox", "active", "upcoming", "logbook").annotations({ + Schema.Literals(["inbox", "active", "upcoming", "logbook"]).annotate({ description: "Task scope filter (inbox, active, upcoming, or logbook)", }), ), - }, + }), success: Schema.Unknown, }) export const CraftCreateTask = Tool.make("craft_create_task", { description: "Create a task in the connected Craft space. Use this after confirming with the user what you will create.", - parameters: { - content: Schema.String.annotations({ description: "Task content/description" }), + parameters: Schema.Struct({ + content: Schema.String.annotate({ description: "Task content/description" }), documentId: Schema.optional( - Schema.String.annotations({ description: "Optional document ID to associate the task with" }), + Schema.String.annotate({ description: "Optional document ID to associate the task with" }), ), - }, + }), success: Schema.Unknown, }) @@ -81,10 +81,10 @@ export const CraftGetFolders = Tool.make("craft_get_folders", { export const CraftSearchBlocks = Tool.make("craft_search_blocks", { description: "Search within a specific Craft document for matching blocks", - parameters: { - documentId: Schema.String.annotations({ description: "The document ID to search within" }), - query: Schema.String.annotations({ description: "Search query text" }), - }, + parameters: Schema.Struct({ + documentId: Schema.String.annotate({ description: "The document ID to search within" }), + query: Schema.String.annotate({ description: "Search query text" }), + }), success: Schema.Unknown, }) diff --git a/bots/hazel-bot/src/tools/linear.ts b/bots/hazel-bot/src/tools/linear.ts index fdb290c92..253a9d118 100644 --- a/bots/hazel-bot/src/tools/linear.ts +++ b/bots/hazel-bot/src/tools/linear.ts @@ -1,4 +1,4 @@ -import { Tool } from "@effect/ai" +import { Tool } from "effect/unstable/ai" import { Schema } from "effect" export const LinearGetAccountInfo = Tool.make("linear_get_account_info", { @@ -18,72 +18,72 @@ export const LinearGetDefaultTeam = Tool.make("linear_get_default_team", { export const LinearCreateIssue = Tool.make("linear_create_issue", { description: "Create a Linear issue. Use this after confirming with the user what you will create.", - parameters: { - title: Schema.String.annotations({ + parameters: Schema.Struct({ + title: Schema.String.annotate({ description: "Issue title (max ~80 chars recommended)", }), description: Schema.optional( - Schema.String.annotations({ description: "Markdown description for the issue" }), + Schema.String.annotate({ description: "Markdown description for the issue" }), ), teamId: Schema.optional( - Schema.String.annotations({ + Schema.String.annotate({ description: "Optional team ID; if omitted, uses the user's default team", }), ), - }, + }), success: Schema.Struct({ issue: Schema.Unknown }), }) export const LinearFetchIssue = Tool.make("linear_fetch_issue", { description: 'Fetch a Linear issue by key (e.g. "ENG-123")', - parameters: { - issueKey: Schema.String.annotations({ description: 'Issue key like "ENG-123"' }), - }, + parameters: Schema.Struct({ + issueKey: Schema.String.annotate({ description: 'Issue key like "ENG-123"' }), + }), success: Schema.Struct({ issue: Schema.Unknown }), }) export const LinearListIssues = Tool.make("linear_list_issues", { description: "List Linear issues with optional filters (team, state, assignee, priority). Returns paginated results.", - parameters: { - teamId: Schema.optional(Schema.String.annotations({ description: "Filter by team ID" })), + parameters: Schema.Struct({ + teamId: Schema.optional(Schema.String.annotate({ description: "Filter by team ID" })), stateType: Schema.optional( - Schema.Literal("triage", "backlog", "unstarted", "started", "completed", "canceled").annotations({ + Schema.Literals(["triage", "backlog", "unstarted", "started", "completed", "canceled"]).annotate({ description: "Filter by state type", }), ), - assigneeId: Schema.optional(Schema.String.annotations({ description: "Filter by assignee ID" })), + assigneeId: Schema.optional(Schema.String.annotate({ description: "Filter by assignee ID" })), priority: Schema.optional( - Schema.Number.annotations({ + Schema.Number.annotate({ description: "Filter by priority (0=None, 1=Urgent, 2=High, 3=Medium, 4=Low)", }), ), first: Schema.optional( - Schema.Number.annotations({ + Schema.Number.annotate({ description: "Number of issues to return (default 25, max 50)", }), ), - after: Schema.optional(Schema.String.annotations({ description: "Pagination cursor for next page" })), - }, + after: Schema.optional(Schema.String.annotate({ description: "Pagination cursor for next page" })), + }), success: Schema.Unknown, }) export const LinearSearchIssues = Tool.make("linear_search_issues", { description: "Search Linear issues by text query. Searches across title, description, and comments.", - parameters: { - query: Schema.String.annotations({ description: "Search text to find issues" }), + parameters: Schema.Struct({ + query: Schema.String.annotate({ description: "Search text to find issues" }), first: Schema.optional( - Schema.Number.annotations({ + Schema.Number.annotate({ description: "Number of issues to return (default 25, max 50)", }), ), - after: Schema.optional(Schema.String.annotations({ description: "Pagination cursor for next page" })), + after: Schema.optional(Schema.String.annotate({ description: "Pagination cursor for next page" })), includeArchived: Schema.optional( - Schema.Boolean.annotations({ + Schema.Boolean.annotate({ description: "Include archived issues in search (default false)", }), ), - }, + }), success: Schema.Unknown, }) @@ -95,37 +95,37 @@ export const LinearListTeams = Tool.make("linear_list_teams", { export const LinearGetWorkflowStates = Tool.make("linear_get_workflow_states", { description: "Get available workflow states (statuses) from Linear. Optionally filter by team. Use this to find valid state IDs before updating issues.", - parameters: { - teamId: Schema.optional(Schema.String.annotations({ description: "Filter states by team ID" })), - }, + parameters: Schema.Struct({ + teamId: Schema.optional(Schema.String.annotate({ description: "Filter states by team ID" })), + }), success: Schema.Unknown, }) export const LinearUpdateIssue = Tool.make("linear_update_issue", { description: "Update an existing Linear issue. Use this after confirming with the user what changes to make. First use linear_get_workflow_states to get valid state IDs if changing status.", - parameters: { - issueId: Schema.String.annotations({ + parameters: Schema.Struct({ + issueId: Schema.String.annotate({ description: 'Issue identifier (e.g., "ENG-123" or UUID)', }), - title: Schema.optional(Schema.String.annotations({ description: "New title for the issue" })), - description: Schema.optional(Schema.String.annotations({ description: "New markdown description" })), + title: Schema.optional(Schema.String.annotate({ description: "New title for the issue" })), + description: Schema.optional(Schema.String.annotate({ description: "New markdown description" })), stateId: Schema.optional( - Schema.String.annotations({ + Schema.String.annotate({ description: "New state/status ID (get valid IDs from linear_get_workflow_states)", }), ), assigneeId: Schema.optional( - Schema.NullOr(Schema.String).annotations({ + Schema.NullOr(Schema.String).annotate({ description: "New assignee ID, or null to unassign", }), ), priority: Schema.optional( - Schema.Number.annotations({ + Schema.Number.annotate({ description: "New priority (0=None, 1=Urgent, 2=High, 3=Medium, 4=Low)", }), ), - }, + }), success: Schema.Unknown, }) diff --git a/bots/hazel-bot/src/tools/toolkit.ts b/bots/hazel-bot/src/tools/toolkit.ts index 952e02132..cc144b7a3 100644 --- a/bots/hazel-bot/src/tools/toolkit.ts +++ b/bots/hazel-bot/src/tools/toolkit.ts @@ -1,4 +1,4 @@ -import { Toolkit } from "@effect/ai" +import { Toolkit } from "effect/unstable/ai" import { LinearApiClient, makeLinearSdkClient } from "@hazel/integrations/linear" import { CraftApiClient } from "@hazel/integrations/craft" import type { IntegrationConnection } from "@hazel/domain/models" @@ -107,26 +107,31 @@ const baseHandlers = { const buildLinearHandlers = (options: { bot: HazelBotClient; orgId: OrganizationId }) => { const getLinearToken = () => - options.bot.integration.getToken(options.orgId, "linear").pipe(Effect.map((r) => r.accessToken)) + (options.bot as any).integration + .getToken(options.orgId, "linear") + .pipe(Effect.map((r: any) => r.accessToken)) return { linear_get_account_info: () => Effect.gen(function* () { const accessToken = yield* getLinearToken() - return yield* LinearApiClient.getAccountInfo(accessToken) + const client = yield* LinearApiClient + return yield* client.getAccountInfo(accessToken) }), linear_get_default_team: () => Effect.gen(function* () { const accessToken = yield* getLinearToken() - const team = yield* LinearApiClient.getDefaultTeam(accessToken) + const client = yield* LinearApiClient + const team = yield* client.getDefaultTeam(accessToken) return { team } }), linear_create_issue: (args: { title: string; description?: string; teamId?: string }) => Effect.gen(function* () { const accessToken = yield* getLinearToken() - const issue = yield* LinearApiClient.createIssue(accessToken, { + const client = yield* LinearApiClient + const issue = yield* client.createIssue(accessToken, { title: args.title, description: args.description, teamId: args.teamId, @@ -137,7 +142,8 @@ const buildLinearHandlers = (options: { bot: HazelBotClient; orgId: Organization linear_fetch_issue: (args: { issueKey: string }) => Effect.gen(function* () { const accessToken = yield* getLinearToken() - const issue = yield* LinearApiClient.fetchIssue(args.issueKey, accessToken) + const client = yield* LinearApiClient + const issue = yield* client.fetchIssue(args.issueKey, accessToken) return { issue } }), @@ -204,9 +210,9 @@ const buildLinearHandlers = (options: { bot: HazelBotClient; orgId: Organization const buildCraftHandlers = (options: { bot: HazelBotClient; orgId: OrganizationId }) => { const getCraftCredentials = () => - options.bot.integration.getToken(options.orgId, "craft").pipe( - Effect.map((r) => ({ - accessToken: r.accessToken, + (options.bot as any).integration.getToken(options.orgId, "craft").pipe( + Effect.map((r: any) => ({ + accessToken: r.accessToken as string, baseUrl: (r.settings as Record | null)?.baseUrl as string, })), ) @@ -215,19 +221,22 @@ const buildCraftHandlers = (options: { bot: HazelBotClient; orgId: OrganizationI craft_search_documents: (args: { query: string }) => Effect.gen(function* () { const { baseUrl, accessToken } = yield* getCraftCredentials() - return yield* CraftApiClient.searchDocuments(baseUrl, accessToken, args.query) + const client = yield* CraftApiClient + return yield* client.searchDocuments(baseUrl, accessToken, args.query) }), craft_get_document: (args: { documentId: string }) => Effect.gen(function* () { const { baseUrl, accessToken } = yield* getCraftCredentials() - return yield* CraftApiClient.getBlocks(baseUrl, accessToken, args.documentId) + const client = yield* CraftApiClient + return yield* client.getBlocks(baseUrl, accessToken, args.documentId) }), craft_create_document: (args: { title: string; content?: string; folderId?: string }) => Effect.gen(function* () { const { baseUrl, accessToken } = yield* getCraftCredentials() - return yield* CraftApiClient.createDocuments(baseUrl, accessToken, [ + const client = yield* CraftApiClient + return yield* client.createDocuments(baseUrl, accessToken, [ { title: args.title, content: args.content, folderId: args.folderId }, ]) }), @@ -239,7 +248,8 @@ const buildCraftHandlers = (options: { bot: HazelBotClient; orgId: OrganizationI }) => Effect.gen(function* () { const { baseUrl, accessToken } = yield* getCraftCredentials() - return yield* CraftApiClient.insertBlocks( + const client = yield* CraftApiClient + return yield* client.insertBlocks( baseUrl, accessToken, args.documentId, @@ -251,13 +261,15 @@ const buildCraftHandlers = (options: { bot: HazelBotClient; orgId: OrganizationI craft_get_tasks: (args: { scope?: "inbox" | "active" | "upcoming" | "logbook" }) => Effect.gen(function* () { const { baseUrl, accessToken } = yield* getCraftCredentials() - return yield* CraftApiClient.getTasks(baseUrl, accessToken, args.scope) + const client = yield* CraftApiClient + return yield* client.getTasks(baseUrl, accessToken, args.scope) }), craft_create_task: (args: { content: string; documentId?: string }) => Effect.gen(function* () { const { baseUrl, accessToken } = yield* getCraftCredentials() - return yield* CraftApiClient.createTasks(baseUrl, accessToken, [ + const client = yield* CraftApiClient + return yield* client.createTasks(baseUrl, accessToken, [ { content: args.content, documentId: args.documentId }, ]) }), @@ -265,13 +277,15 @@ const buildCraftHandlers = (options: { bot: HazelBotClient; orgId: OrganizationI craft_get_folders: () => Effect.gen(function* () { const { baseUrl, accessToken } = yield* getCraftCredentials() - return yield* CraftApiClient.listFolders(baseUrl, accessToken) + const client = yield* CraftApiClient + return yield* client.listFolders(baseUrl, accessToken) }), craft_search_blocks: (args: { documentId: string; query: string }) => Effect.gen(function* () { const { baseUrl, accessToken } = yield* getCraftCredentials() - return yield* CraftApiClient.searchBlocks(baseUrl, accessToken, args.documentId, args.query) + const client = yield* CraftApiClient + return yield* client.searchBlocks(baseUrl, accessToken, args.documentId, args.query) }), } as const } @@ -289,41 +303,37 @@ export const buildToolkit = (options: { const hasCraft = options.enabledIntegrations.has("craft") if (hasLinear && hasCraft) { + const handlers = { + ...baseHandlers, + ...buildLinearHandlers(options), + ...buildCraftHandlers(options), + } return Effect.gen(function* () { - const handlers = { - ...baseHandlers, - ...buildLinearHandlers(options), - ...buildCraftHandlers(options), - } - const ctx = yield* LinearAndCraftToolkit.toContext(handlers as any) - return yield* Effect.provide(LinearAndCraftToolkit, ctx) - }) + return yield* LinearAndCraftToolkit + }).pipe(Effect.provide(LinearAndCraftToolkit.toLayer(handlers as any))) } if (hasLinear) { + const handlers = { + ...baseHandlers, + ...buildLinearHandlers(options), + } return Effect.gen(function* () { - const handlers = { - ...baseHandlers, - ...buildLinearHandlers(options), - } - const ctx = yield* LinearToolkit.toContext(handlers as any) - return yield* Effect.provide(LinearToolkit, ctx) - }) + return yield* LinearToolkit + }).pipe(Effect.provide(LinearToolkit.toLayer(handlers as any))) } if (hasCraft) { + const handlers = { + ...baseHandlers, + ...buildCraftHandlers(options), + } return Effect.gen(function* () { - const handlers = { - ...baseHandlers, - ...buildCraftHandlers(options), - } - const ctx = yield* CraftToolkit.toContext(handlers as any) - return yield* Effect.provide(CraftToolkit, ctx) - }) + return yield* CraftToolkit + }).pipe(Effect.provide(CraftToolkit.toLayer(handlers as any))) } return Effect.gen(function* () { - const ctx = yield* BaseToolkit.toContext(baseHandlers) - return yield* Effect.provide(BaseToolkit, ctx) - }) + return yield* BaseToolkit + }).pipe(Effect.provide(BaseToolkit.toLayer(baseHandlers))) } diff --git a/bots/linear-bot/package.json b/bots/linear-bot/package.json index e99fcc3f5..53088e110 100644 --- a/bots/linear-bot/package.json +++ b/bots/linear-bot/package.json @@ -9,10 +9,8 @@ "dev": "bun run --watch src/index.ts" }, "dependencies": { - "@effect/ai": "catalog:effect", - "@effect/platform": "catalog:effect", + "@effect/ai-openrouter": "catalog:effect", "@hazel-chat/bot-sdk": "workspace:*", - "@hazel/ai-openrouter": "workspace:*", "@hazel/db": "workspace:*", "@hazel/domain": "workspace:*", "@hazel/integrations": "workspace:*", diff --git a/bots/linear-bot/src/index.ts b/bots/linear-bot/src/index.ts index 667a25a23..670274422 100644 --- a/bots/linear-bot/src/index.ts +++ b/bots/linear-bot/src/index.ts @@ -1,6 +1,6 @@ -import { LanguageModel } from "@effect/ai" -import { OpenRouterClient, OpenRouterLanguageModel } from "@hazel/ai-openrouter" -import { FetchHttpClient } from "@effect/platform" +import { LanguageModel } from "effect/unstable/ai" +import { OpenRouterClient, OpenRouterLanguageModel } from "@effect/ai-openrouter" +import { FetchHttpClient } from "effect/unstable/http" import { Config, Effect, Layer, Schema } from "effect" import { runHazelBot } from "@hazel-chat/bot-sdk" import { LinearApiClient } from "@hazel/integrations/linear" @@ -25,10 +25,12 @@ const OpenRouterModelLayer = OpenRouterLanguageModel.layer({ runHazelBot({ serviceName: "linear-bot", commands, - layers: [LinearApiClient.Default], + layers: [LinearApiClient.layer], setup: (bot) => Effect.gen(function* () { - yield* bot.onCommand(IssueCommand, (ctx) => + const botAny = bot as any + + yield* botAny.onCommand(IssueCommand, (ctx: any) => Effect.gen(function* () { yield* Effect.log(`Received /issue command from ${ctx.userId}`) @@ -36,37 +38,38 @@ runHazelBot({ yield* Effect.log(`Creating Linear issue: ${title}`) - const { accessToken } = yield* bot.integration.getToken(ctx.orgId, "linear") + const { accessToken } = yield* botAny.integration.getToken(ctx.orgId, "linear") - const issue = yield* LinearApiClient.createIssue(accessToken, { + const linearClient = yield* LinearApiClient + const issue = yield* linearClient.createIssue(accessToken, { title, description, }) yield* Effect.log(`Created Linear issue: ${issue.identifier}`) - yield* bot.message.send( + yield* botAny.message.send( ctx.channelId, `@[userId:${ctx.userId}] created an issue: ${issue.url}`, ) - }).pipe(bot.withErrorHandler(ctx)), + }).pipe(botAny.withErrorHandler(ctx)), ) - yield* bot.onCommand(IssueifyCommand, (ctx) => + yield* botAny.onCommand(IssueifyCommand, (ctx: any) => Effect.gen(function* () { yield* Effect.log(`Received /issueify command from ${ctx.userId}`) // Fetch messages from the channel - const { data: messages } = yield* bot.message.list(ctx.channelId, { + const { data: messages } = yield* botAny.message.list(ctx.channelId, { limit: 20, }) if (messages.length === 0) { - yield* bot.message.send(ctx.channelId, "No messages found in this channel.") + yield* botAny.message.send(ctx.channelId, "No messages found in this channel.") return } - const stream = yield* bot.ai.stream(ctx.channelId, { + const stream = yield* botAny.ai.stream(ctx.channelId, { model: "moonshotai/kimi-k2.5 (agent)", loading: { text: "Thinking...", @@ -83,7 +86,7 @@ runHazelBot({ // Format messages for AI analysis const conversationText = chronologicalMessages - .map((msg) => { + .map((msg: any) => { const timestamp = new Date(msg.createdAt).toLocaleString() const content = msg.content.replace(/@\[userId:[^\]]+\]/g, "@user") return `[${timestamp}] User: ${content}` @@ -117,15 +120,18 @@ ${conversationText}`, const text = response.text // Parse the JSON response - const generatedIssue = yield* Schema.decodeUnknown(GeneratedIssueSchema)(JSON.parse(text)) + const generatedIssue = yield* Schema.decodeUnknownEffect(GeneratedIssueSchema)( + JSON.parse(text), + ) yield* Effect.log(`Generated issue: ${generatedIssue.title}`) yield* stream.setText("📝 Creating Linear issue...") - const { accessToken } = yield* bot.integration.getToken(ctx.orgId, "linear") + const { accessToken } = yield* botAny.integration.getToken(ctx.orgId, "linear") - const issue = yield* LinearApiClient.createIssue(accessToken, { + const linearClient = yield* LinearApiClient + const issue = yield* linearClient.createIssue(accessToken, { title: generatedIssue.title, description: generatedIssue.description, }) @@ -137,7 +143,7 @@ ${conversationText}`, `@[userId:${ctx.userId}] created an issue from this conversation: ${issue.url}`, ) yield* stream.complete() - }).pipe(bot.withErrorHandler(ctx), Effect.provide(OpenRouterModelLayer)), + }).pipe(botAny.withErrorHandler(ctx), Effect.provide(OpenRouterModelLayer)), ) }), }) diff --git a/bun.lock b/bun.lock index 2434fe79e..9ef4dfadd 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "maki-chat", "devDependencies": { - "@effect/vitest": "^0.27.0", + "@effect/vitest": "catalog:effect", "@rolldown/plugin-babel": "^0.2.1", "@vitest/coverage-v8": "^4.1.0", "oxfmt": "^0.40.0", @@ -14,7 +14,7 @@ "turbo": "^2.8.16", "typescript": "^5.9.3", "vitest": "^4.1.0", - "web": "^0.0.2", + "web": "workspace:*", "wrangler": "^4.73.0", }, }, @@ -35,14 +35,9 @@ "apps/backend": { "name": "@hazel/backend", "dependencies": { - "@effect/cluster": "catalog:effect", - "@effect/experimental": "catalog:effect", "@effect/opentelemetry": "catalog:effect", - "@effect/platform": "catalog:effect", "@effect/platform-bun": "catalog:effect", "@effect/platform-node": "catalog:effect", - "@effect/rpc": "catalog:effect", - "@effect/sql": "catalog:effect", "@effect/sql-pg": "catalog:effect", "@hazel/auth": "workspace:*", "@hazel/backend-core": "workspace:*", @@ -52,14 +47,13 @@ "@hazel/integrations": "workspace:*", "@hazel/schema": "workspace:*", "@workos-inc/node": "^7.77.0", - "dfx": "^0.127.0", + "dfx": "^1.0.10", "drizzle-orm": "^0.45.1", "effect": "catalog:effect", "jose": "^6.1.3", "pg": "^8.16.3", }, "devDependencies": { - "@effect/language-service": "catalog:effect", "@testcontainers/postgresql": "^10.18.0", "@types/bun": "1.3.9", "drizzle-kit": "^0.31.8", @@ -78,7 +72,6 @@ "effect": "catalog:effect", }, "devDependencies": { - "@effect/language-service": "catalog:effect", "@types/bun": "1.3.9", "typescript": "^5.9.3", }, @@ -86,14 +79,9 @@ "apps/cluster": { "name": "cluster", "dependencies": { - "@effect/ai": "catalog:effect", - "@effect/cluster": "catalog:effect", - "@effect/experimental": "catalog:effect", - "@effect/platform": "catalog:effect", + "@effect/ai-openrouter": "catalog:effect", "@effect/platform-bun": "catalog:effect", "@effect/sql-pg": "catalog:effect", - "@effect/workflow": "catalog:effect", - "@hazel/ai-openrouter": "workspace:*", "@hazel/backend-core": "workspace:*", "@hazel/db": "workspace:*", "@hazel/domain": "workspace:*", @@ -163,8 +151,6 @@ "name": "@hazel/electric-proxy", "version": "0.0.0", "dependencies": { - "@effect/experimental": "catalog:effect", - "@effect/platform": "catalog:effect", "@effect/platform-bun": "catalog:effect", "@electric-sql/client": "1.5.12", "@hazel/auth": "workspace:*", @@ -221,7 +207,6 @@ "name": "@hazel/link-preview-worker", "version": "0.0.0", "dependencies": { - "@effect/platform": "catalog:effect", "effect": "catalog:effect", "metascraper": "^5.49.5", "metascraper-description": "^5.49.5", @@ -234,7 +219,6 @@ }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.8.19", - "@effect/language-service": "catalog:effect", "typescript": "^5.9.3", "vitest": "^4.1.0", "wrangler": "^4.73.0", @@ -245,12 +229,9 @@ "dependencies": { "@cloudflare/realtimekit-react": "^1.2.4", "@cloudflare/realtimekit-react-ui": "^1.1.0", - "@effect-atom/atom-react": "catalog:effect", - "@effect/experimental": "catalog:effect", + "@effect/atom-react": "catalog:effect", "@effect/opentelemetry": "catalog:effect", - "@effect/platform": "catalog:effect", "@effect/platform-browser": "catalog:effect", - "@effect/rpc": "catalog:effect", "@electric-sql/client": "1.5.12", "@fontsource/inter": "^5.2.8", "@hazel/actors": "workspace:*", @@ -327,7 +308,6 @@ "workbox-window": "^7.4.0", }, "devDependencies": { - "@effect/language-service": "catalog:effect", "@react-types/overlays": "^3.9.4", "@react-types/shared": "^3.33.1", "@tailwindcss/postcss": "^4.2.1", @@ -355,10 +335,8 @@ "name": "hazel-bot", "version": "1.0.0", "dependencies": { - "@effect/ai": "catalog:effect", - "@effect/platform": "catalog:effect", + "@effect/ai-openrouter": "catalog:effect", "@hazel-chat/bot-sdk": "workspace:*", - "@hazel/ai-openrouter": "workspace:*", "@hazel/domain": "workspace:*", "@hazel/integrations": "workspace:*", "@hazel/schema": "workspace:*", @@ -372,10 +350,8 @@ "name": "linear-bot", "version": "1.0.0", "dependencies": { - "@effect/ai": "catalog:effect", - "@effect/platform": "catalog:effect", + "@effect/ai-openrouter": "catalog:effect", "@hazel-chat/bot-sdk": "workspace:*", - "@hazel/ai-openrouter": "workspace:*", "@hazel/db": "workspace:*", "@hazel/domain": "workspace:*", "@hazel/integrations": "workspace:*", @@ -386,29 +362,12 @@ "@types/bun": "1.3.9", }, }, - "libs/ai-openrouter": { - "name": "@hazel/ai-openrouter", - "version": "0.0.1", - "dependencies": { - "@effect/ai": "catalog:effect", - "@effect/experimental": "catalog:effect", - "@effect/platform": "catalog:effect", - "effect": "catalog:effect", - }, - "devDependencies": { - "@effect/language-service": "catalog:effect", - "typescript": "^5.9.3", - }, - }, "libs/bot-sdk": { "name": "@hazel-chat/bot-sdk", "version": "0.1.0", "devDependencies": { - "@effect/language-service": "catalog:effect", "@effect/opentelemetry": "catalog:effect", - "@effect/platform": "catalog:effect", "@effect/platform-bun": "catalog:effect", - "@effect/rpc": "catalog:effect", "@hazel/actors": "workspace:*", "@hazel/domain": "workspace:*", "@hazel/effect-bun": "workspace:*", @@ -422,18 +381,14 @@ "typescript": "^5.9.3", }, "peerDependencies": { - "@effect/opentelemetry": ">=0.61.0", - "@effect/platform": ">=0.94.0", - "@effect/platform-bun": ">=0.87.0", - "@effect/rpc": ">=0.73.0", - "@effect/workflow": ">=0.16.0", - "effect": ">=3.19.0", + "@effect/opentelemetry": ">=4.0.0-beta.0", + "@effect/platform-bun": ">=4.0.0-beta.0", + "effect": ">=4.0.0-beta.0", "jose": ">=6.0.0", "rivetkit": ">=2.0.0", }, "optionalPeers": [ "@effect/opentelemetry", - "@effect/workflow", "jose", "rivetkit", ], @@ -442,7 +397,6 @@ "name": "@hazel/effect-electric-db-collection", "version": "0.0.0", "dependencies": { - "@effect-atom/atom": "catalog:effect", "@electric-sql/client": "1.5.12", "@standard-schema/spec": "^1.1.0", "@tanstack/db": "0.5.31", @@ -452,7 +406,6 @@ "effect": "catalog:effect", }, "devDependencies": { - "@effect/language-service": "catalog:effect", "@vitest/coverage-istanbul": "^4.0.17", "typescript": "^5.9.3", }, @@ -461,12 +414,11 @@ "name": "@hazel/tanstack-db-atom", "version": "0.0.0", "dependencies": { - "@effect-atom/atom-react": "catalog:effect", + "@effect/atom-react": "catalog:effect", "@tanstack/db": "0.5.31", "effect": "catalog:effect", }, "devDependencies": { - "@effect/language-service": "catalog:effect", "typescript": "^5.9.3", }, }, @@ -474,7 +426,6 @@ "name": "@hazel/actors", "version": "0.0.1", "dependencies": { - "@effect/platform": "catalog:effect", "@hazel/rivet-effect": "workspace:*", "@hazel/schema": "workspace:*", "effect": "catalog:effect", @@ -492,8 +443,6 @@ "name": "@hazel/auth", "version": "0.0.0", "dependencies": { - "@effect/experimental": "catalog:effect", - "@effect/platform": "catalog:effect", "@hazel/db": "workspace:*", "@hazel/domain": "workspace:*", "@hazel/effect-bun": "workspace:*", @@ -503,7 +452,6 @@ "jose": "^6.1.3", }, "devDependencies": { - "@effect/language-service": "catalog:effect", "@types/bun": "1.3.9", "typescript": "^5.9.3", }, @@ -512,7 +460,6 @@ "name": "@hazel/backend-core", "version": "0.0.0", "dependencies": { - "@effect/platform": "catalog:effect", "@hazel/db": "workspace:*", "@hazel/domain": "workspace:*", "@hazel/schema": "workspace:*", @@ -521,7 +468,6 @@ "effect": "catalog:effect", }, "devDependencies": { - "@effect/language-service": "catalog:effect", "@types/bun": "1.3.9", "typescript": "^5.9.3", }, @@ -534,7 +480,6 @@ "name": "@hazel/db", "version": "0.0.0", "dependencies": { - "@effect/experimental": "catalog:effect", "@hazel/domain": "workspace:*", "@hazel/schema": "workspace:*", "drizzle-orm": "^0.45.1", @@ -542,7 +487,6 @@ "postgres": "^3.4.7", }, "devDependencies": { - "@effect/language-service": "catalog:effect", "@testcontainers/postgresql": "^10.18.0", "@types/bun": "1.3.9", "drizzle-kit": "^0.31.8", @@ -553,18 +497,12 @@ "name": "@hazel/domain", "version": "0.0.0", "dependencies": { - "@effect/cluster": "catalog:effect", - "@effect/experimental": "catalog:effect", - "@effect/platform": "catalog:effect", - "@effect/rpc": "catalog:effect", - "@effect/workflow": "catalog:effect", "@hazel/integrations": "workspace:*", "@hazel/schema": "workspace:*", "drizzle-orm": "^0.45.1", "effect": "catalog:effect", }, "devDependencies": { - "@effect/language-service": "catalog:effect", "@types/bun": "1.3.9", "typescript": "^5.9.3", }, @@ -573,14 +511,11 @@ "name": "@hazel/effect-bun", "version": "0.0.0", "dependencies": { - "@effect/experimental": "catalog:effect", "@effect/opentelemetry": "catalog:effect", - "@effect/platform": "catalog:effect", "@effect/platform-bun": "catalog:effect", "effect": "catalog:effect", }, "devDependencies": { - "@effect/language-service": "catalog:effect", "@types/bun": "latest", "typescript": "^5.9.3", }, @@ -589,7 +524,6 @@ "name": "@hazel/integrations", "version": "0.0.0", "dependencies": { - "@effect/platform": "catalog:effect", "@linear/sdk": "^73.0.0", "effect": "catalog:effect", "he": "^1.2.0", @@ -597,7 +531,6 @@ "rss-parser": "^3.13.0", }, "devDependencies": { - "@effect/language-service": "catalog:effect", "@types/bun": "1.3.9", "@types/he": "^1.2.3", "typescript": "^5.9.3", @@ -611,8 +544,7 @@ "rivetkit": "2.1.6", }, "devDependencies": { - "@effect/language-service": "catalog:effect", - "@effect/vitest": "^0.27.0", + "@effect/vitest": "catalog:effect", "@types/bun": "1.3.9", "typescript": "^5.9.3", }, @@ -621,11 +553,9 @@ "name": "@hazel/schema", "version": "0.0.0", "dependencies": { - "@effect/platform": "catalog:effect", "effect": "catalog:effect", }, "devDependencies": { - "@effect/language-service": "catalog:effect", "@types/bun": "1.3.9", "typescript": "^5.9.3", }, @@ -637,11 +567,7 @@ "hazel-setup": "./src/index.ts", }, "dependencies": { - "@effect/cli": "catalog:effect", - "@effect/platform": "catalog:effect", "@effect/platform-bun": "catalog:effect", - "@effect/printer": "catalog:effect", - "@effect/printer-ansi": "catalog:effect", "@hazel/db": "workspace:*", "@hazel/schema": "workspace:*", "@workos-inc/node": "^7.77.0", @@ -649,7 +575,6 @@ "picocolors": "^1.1.1", }, "devDependencies": { - "@effect/language-service": "catalog:effect", "@types/bun": "1.3.9", "typescript": "^5.9.3", }, @@ -668,25 +593,15 @@ }, "catalogs": { "effect": { - "@effect-atom/atom": "0.5.3", - "@effect-atom/atom-react": "0.5.0", - "@effect/ai": "0.33.2", - "@effect/cli": "0.73.2", - "@effect/cluster": "0.56.4", - "@effect/experimental": "0.58.0", - "@effect/language-service": "0.79.0", - "@effect/opentelemetry": "0.61.0", - "@effect/platform": "0.94.5", - "@effect/platform-browser": "0.74.0", - "@effect/platform-bun": "0.87.1", - "@effect/platform-node": "0.104.1", - "@effect/printer": "0.47.0", - "@effect/printer-ansi": "0.47.0", - "@effect/rpc": "0.73.2", - "@effect/sql": "0.49.0", - "@effect/sql-pg": "0.50.3", - "@effect/workflow": "0.16.0", - "effect": "3.19.19", + "@effect/ai-openrouter": "4.0.0-beta.33", + "@effect/atom-react": "4.0.0-beta.33", + "@effect/opentelemetry": "4.0.0-beta.33", + "@effect/platform-browser": "4.0.0-beta.33", + "@effect/platform-bun": "4.0.0-beta.33", + "@effect/platform-node": "4.0.0-beta.33", + "@effect/sql-pg": "4.0.0-beta.33", + "@effect/vitest": "4.0.0-beta.33", + "effect": "4.0.0-beta.33", }, }, "packages": { @@ -980,47 +895,27 @@ "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], - "@effect-atom/atom": ["@effect-atom/atom@0.5.3", "", { "peerDependencies": { "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.2", "@effect/rpc": "^0.73.0", "effect": "^3.19.15" } }, "sha512-TRZv/i+YT3TtnN0oFORJqXdxSs1fc7lrJlH+1xZvDFyjC9hgoVnrcKbeZsDFmr6r0wYRqVo7U3IftxiQNjpNZA=="], - - "@effect-atom/atom-react": ["@effect-atom/atom-react@0.5.0", "", { "dependencies": { "@effect-atom/atom": "^0.5.0" }, "peerDependencies": { "effect": "^3.19", "react": ">=18 <20", "scheduler": "*" } }, "sha512-aFfjWi4rEJCqfM12Oi36/EKaDm/W6n4/N6yM5vL0t/QozKhJhK05rQL/GY4XMxlH2eqkQ4ih8jBQa3Yyp0Fiqw=="], + "@effect/ai-openrouter": ["@effect/ai-openrouter@4.0.0-beta.33", "", { "peerDependencies": { "effect": "^4.0.0-beta.33" } }, "sha512-J+ixuKp8Ya7RkPEL7d79ySQmjsbCR/1DLvDbwLv1OIuegtOfN7Abo16L6Qa19Vo/JV67Uwds9uXmchsE+txrAg=="], - "@effect/ai": ["@effect/ai@0.33.2", "", { "dependencies": { "find-my-way-ts": "^0.1.6" }, "peerDependencies": { "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.1", "@effect/rpc": "^0.73.0", "effect": "^3.19.14" } }, "sha512-iJ6pz9qiKFXhcnriJJSBQ5+Bz3oEg+6hVQ7OPmmTa0dVkKUPIgX9nHqHfstcwLnDfdXBj/zCl79U+iMpGtQBnA=="], + "@effect/atom-react": ["@effect/atom-react@4.0.0-beta.33", "", { "peerDependencies": { "effect": "^4.0.0-beta.33", "react": "^19.2.4", "scheduler": "*" } }, "sha512-w4sbCoBJFez5BpD/fM4pYt9xGGKaPkMnSarcgQRjhmrUU6bsDu82jaYGGduIInucYVc9EanXsMcTDCwtU43X0Q=="], - "@effect/cli": ["@effect/cli@0.73.2", "", { "dependencies": { "ini": "^4.1.3", "toml": "^3.0.0", "yaml": "^2.5.0" }, "peerDependencies": { "@effect/platform": "^0.94.3", "@effect/printer": "^0.47.0", "@effect/printer-ansi": "^0.47.0", "effect": "^3.19.16" } }, "sha512-K8IJo81+qa1LU8dhxcDU4QO/bIjL/dPd3zUOSCpLiuUNz8Y3/T+WNs3GqIXEhMfCFMSlRZERN0YgmtRlEZUREA=="], - - "@effect/cluster": ["@effect/cluster@0.56.4", "", { "dependencies": { "kubernetes-types": "^1.30.0" }, "peerDependencies": { "@effect/platform": "^0.94.5", "@effect/rpc": "^0.73.1", "@effect/sql": "^0.49.0", "@effect/workflow": "^0.16.0", "effect": "^3.19.17" } }, "sha512-7Je5/JlbZOlsSxsbKjr97dJed2cNGWsb+TLNgMcr5mRDbcWlFOTUGvsrisEJV6waosYLIg+2omPdvnvRoYKdhA=="], - - "@effect/experimental": ["@effect/experimental@0.58.0", "", { "dependencies": { "uuid": "^11.0.3" }, "peerDependencies": { "@effect/platform": "^0.94.0", "effect": "^3.19.13", "ioredis": "^5", "lmdb": "^3" }, "optionalPeers": ["ioredis", "lmdb"] }, "sha512-IEP9sapjF6rFy5TkoqDPc86st/fnqUfjT7Xa3pWJrFGr1hzaMXHo+mWsYOZS9LAOVKnpHuVziDK97EP5qsCHVA=="], - - "@effect/language-service": ["@effect/language-service@0.79.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-DEmIOsg1GjjP6s9HXH1oJrW+gDmzkhVv9WOZl6to5eNyyCrjz1S2PDqQ7aYrW/HuifhfwI5Bik1pK4pj7Z+lrg=="], - - "@effect/opentelemetry": ["@effect/opentelemetry@0.61.0", "", { "peerDependencies": { "@effect/platform": "^0.94.2", "@opentelemetry/api": "^1.9", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^3.19.15" } }, "sha512-aTg4qWzMxGDoUZKSyws/dMZqVNoSCQIvHaPDJnWitUAVuWDAPOXiaiHJDWO39cwDlySBTCSF6rEanm3+nR/2Mg=="], + "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.33", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.33" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-Vi4G/ZRdEv4HMww+VAof2asOvoayO6qtO9dmwLK61gYagVgK20a+k3n28NCSg6zsHkrx7hlljLT+nW0gI2usZg=="], "@effect/platform": ["@effect/platform@0.94.5", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.19.17" } }, "sha512-z05APUiDDPbodhTkH/RJqOLoCU11bU2IZLfcwLFrld03+ob1VeqRnELQlmueLIYm6NZifHAtjl32V+GRt34y4A=="], - "@effect/platform-browser": ["@effect/platform-browser@0.74.0", "", { "dependencies": { "multipasta": "^0.2.7" }, "peerDependencies": { "@effect/platform": "^0.94.0", "effect": "^3.19.13" } }, "sha512-PAgkg5L5cASQpScA0SZTSy543MVA4A9kmpVCjo2fCINLRpTeuCFAOQHgPmw8dKHnYS0yGs2TYn7AlrhhqQ5o3g=="], - - "@effect/platform-bun": ["@effect/platform-bun@0.87.1", "", { "dependencies": { "@effect/platform-node-shared": "^0.57.1", "multipasta": "^0.2.7" }, "peerDependencies": { "@effect/cluster": "^0.56.1", "@effect/platform": "^0.94.2", "@effect/rpc": "^0.73.0", "@effect/sql": "^0.49.0", "effect": "^3.19.15" } }, "sha512-I88d0YqWbvLY2GGeIxK3r5k0l/MoUCCnxiHJG+X6gqaHu+pIs0djDtJ+ORhw/3qha9ojcVu6pyaBmnUjgzQHWQ=="], + "@effect/platform-browser": ["@effect/platform-browser@4.0.0-beta.33", "", { "dependencies": { "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^4.0.0-beta.33" } }, "sha512-8DO/bLu04edHKW7GffRNcnBi8Z4r4F4sXR/VIPZfVA8AceeZ2BEJJftSceozftnlaxm8MsqbcTz4cSPnyuDVYw=="], - "@effect/platform-node": ["@effect/platform-node@0.104.1", "", { "dependencies": { "@effect/platform-node-shared": "^0.57.1", "mime": "^3.0.0", "undici": "^7.10.0", "ws": "^8.18.2" }, "peerDependencies": { "@effect/cluster": "^0.56.1", "@effect/platform": "^0.94.2", "@effect/rpc": "^0.73.0", "@effect/sql": "^0.49.0", "effect": "^3.19.15" } }, "sha512-jT1a/z98niK6fnEU8pWHPPCdJMVDRCIdB65lolcOjse5rsTwVbczMjvKkhVQpF63mNWoOnol7OTRNkw5L54llg=="], + "@effect/platform-bun": ["@effect/platform-bun@4.0.0-beta.33", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.33" }, "peerDependencies": { "effect": "^4.0.0-beta.33" } }, "sha512-CtjRdSC9ZFGREw0PYL5Y1bGVo3pOZ3ZkwtO9aWU699Tq6I+/o4HJLfKKLfo2G17BAkEoq0Gn6hyoQqFxUcplWg=="], - "@effect/platform-node-shared": ["@effect/platform-node-shared@0.57.1", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "multipasta": "^0.2.7", "ws": "^8.18.2" }, "peerDependencies": { "@effect/cluster": "^0.56.1", "@effect/platform": "^0.94.2", "@effect/rpc": "^0.73.0", "@effect/sql": "^0.49.0", "effect": "^3.19.15" } }, "sha512-oX/bApMdoKsyrDiNdJxo7U9Rz1RXsjRv+ecfAPp1qGlSdGIo32wVRvJ2XCHqYj0sqaYJS0pU0/GCulRfVGuJag=="], + "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.33", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.33", "mime": "^4.1.0", "undici": "^7.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.33", "ioredis": "^5.7.0" } }, "sha512-mw/zCuq4bSRP5nm3hPlfjX+veKlG6kC3NleuMhRuVSa8NzlHF08rXptd6S9ks9JuDz5F6dgzIf/beaGAYF8TmA=="], - "@effect/printer": ["@effect/printer@0.47.0", "", { "peerDependencies": { "@effect/typeclass": "^0.38.0", "effect": "^3.19.0" } }, "sha512-VgR8e+YWWhMEAh9qFOjwiZ3OXluAbcVLIOtvp2S5di1nSrPOZxj78g8LE77JSvyfp5y5bS2gmFW+G7xD5uU+2Q=="], - - "@effect/printer-ansi": ["@effect/printer-ansi@0.47.0", "", { "dependencies": { "@effect/printer": "^0.47.0" }, "peerDependencies": { "@effect/typeclass": "^0.38.0", "effect": "^3.19.0" } }, "sha512-tDEQ9XJpXDNYoWMQJHFRMxKGmEOu6z32x3Kb8YLOV5nkauEKnKmWNs7NBp8iio/pqoJbaSwqDwUg9jXVquxfWQ=="], + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.33", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.33" } }, "sha512-jaJnvYz1IiPZyN//fCJsvwnmujJS5KD8noCVVLhb4ZGCWKhQpt0x2iuax6HFzMlPEQSfl04GLU+PVKh0nkzPyA=="], "@effect/rpc": ["@effect/rpc@0.73.2", "", { "dependencies": { "msgpackr": "^1.11.4" }, "peerDependencies": { "@effect/platform": "^0.94.5", "effect": "^3.19.18" } }, "sha512-td7LHDgBOYKg+VgGWEelD8rSAmvjXz7am17vfxZROX5qIYuvH7drL/z4p5xQFadhHZ7DYdlFpqdO9ggc77OCIw=="], - "@effect/sql": ["@effect/sql@0.49.0", "", { "dependencies": { "uuid": "^11.0.3" }, "peerDependencies": { "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.0", "effect": "^3.19.13" } }, "sha512-9UEKR+z+MrI/qMAmSvb/RiD9KlgIazjZUCDSpwNgm0lEK9/Q6ExEyfziiYFVCPiptp52cBw8uBHRic8hHnwqXA=="], - - "@effect/sql-pg": ["@effect/sql-pg@0.50.3", "", { "dependencies": { "pg": "^8.16.3", "pg-connection-string": "2.9.1", "pg-cursor": "^2.15.3", "pg-pool": "^3.10.1", "pg-types": "^4.1.0" }, "peerDependencies": { "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.4", "@effect/sql": "^0.49.0", "effect": "^3.19.16" } }, "sha512-mKY8+qJFWvmMeX+TA2j1r01XXnI23Z5gu0KI1jDgjLATNImPXWsPIb1bnoZTyUNW5CIUu6X/wFT6M+8sZYLCiA=="], - - "@effect/typeclass": ["@effect/typeclass@0.38.0", "", { "peerDependencies": { "effect": "^3.19.0" } }, "sha512-lMUcJTRtG8KXhXoczapZDxbLK5os7M6rn0zkvOgncJW++A0UyelZfMVMKdT5R+fgpZcsAU/1diaqw3uqLJwGxA=="], - - "@effect/vitest": ["@effect/vitest@0.27.0", "", { "peerDependencies": { "effect": "^3.19.0", "vitest": "^3.2.0" } }, "sha512-8bM7n9xlMUYw9GqPIVgXFwFm2jf27m/R7psI64PGpwU5+26iwyxp9eAXEsfT5S6lqztYfpQQ1Ubp5o6HfNYzJQ=="], + "@effect/sql-pg": ["@effect/sql-pg@4.0.0-beta.33", "", { "dependencies": { "pg": "^8.18.0", "pg-connection-string": "2.11.0", "pg-cursor": "^2.17.0", "pg-pool": "^3.11.0", "pg-types": "^4.1.0" }, "peerDependencies": { "effect": "^4.0.0-beta.33" } }, "sha512-WTUre9mS73PY2a36PIS55VhEwL6FOCfiRj2R7oVr5/eZGjiBCrs9xluK6X/so74elnfvtPIbHmAEkEldUukEuA=="], - "@effect/workflow": ["@effect/workflow@0.16.0", "", { "peerDependencies": { "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.0", "@effect/rpc": "^0.73.0", "effect": "^3.19.13" } }, "sha512-MiAdlxx3TixkgHdbw+Yf1Z3tHAAE0rOQga12kIydJqj05Fnod+W/I+kQGRMY/XWRg+QUsVxhmh1qTr7Ype6lrw=="], + "@effect/vitest": ["@effect/vitest@4.0.0-beta.33", "", { "peerDependencies": { "effect": "^4.0.0-beta.33", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-atoJmncSbrKm8Fb1W+09mju6LwWRdhfvBicHpChwoPWCiij5fFrwRD7EBgIAmYUjycikR2/RYsPpeKXi8L26kw=="], "@electric-sql/client": ["@electric-sql/client@1.5.12", "", { "dependencies": { "@microsoft/fetch-event-source": "^2.0.1" }, "optionalDependencies": { "@rollup/rollup-darwin-arm64": "^4.18.1" }, "bin": { "intent": "bin/intent.mjs" } }, "sha512-mWDEpKog0Zo4WOjReW4x9ELaHsjTthpziLVJkjmSdh/4Y/ZHw5EoY5e9dJX9itPSKVk9GNFz3YfHFv5EAyCOmw=="], @@ -1140,8 +1035,6 @@ "@hazel/actors": ["@hazel/actors@workspace:packages/actors"], - "@hazel/ai-openrouter": ["@hazel/ai-openrouter@workspace:libs/ai-openrouter"], - "@hazel/auth": ["@hazel/auth@workspace:packages/auth"], "@hazel/backend": ["@hazel/backend@workspace:apps/backend"], @@ -1254,6 +1147,8 @@ "@internationalized/string": ["@internationalized/string@3.2.7", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-D4OHBjrinH+PFZPvfCXvG28n2LSykWcJ7GIioQL+ok0LON15SdfoUssoHzzOUmVZLbRoREsQXVzA6r8JKsbP6A=="], + "@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="], + "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="], @@ -1332,9 +1227,7 @@ "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], - "@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.5.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-uOXpVX0ZjO7heSVjhheW2XEPrhQAWr2BScDPoZ9UDycl5iuHG+Usyc3AIfG6kZeC1GyLpMInpQ6X5+9n69yOFw=="], - - "@opentelemetry/core": ["@opentelemetry/core@2.4.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-KtcyFHssTn5ZgDu6SXmUznS80OFs/wN7y6MyFRRcKU6TOw8hNcGxKvt8hsdaLJfhzUszNSjURetq5Qpkad14Gw=="], + "@opentelemetry/core": ["@opentelemetry/core@2.5.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ=="], "@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-exporter-base": "0.208.0", "@opentelemetry/otlp-transformer": "0.208.0", "@opentelemetry/sdk-logs": "0.208.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg=="], @@ -1342,7 +1235,7 @@ "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/sdk-logs": "0.208.0", "@opentelemetry/sdk-metrics": "2.2.0", "@opentelemetry/sdk-trace-base": "2.2.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ=="], - "@opentelemetry/resources": ["@opentelemetry/resources@2.4.0", "", { "dependencies": { "@opentelemetry/core": "2.4.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-RWvGLj2lMDZd7M/5tjkI/2VHMpXebLgPKvBUd9LRasEWR2xAynDwEYZuLvY9P2NGG73HF07jbbgWX2C9oavcQg=="], + "@opentelemetry/resources": ["@opentelemetry/resources@2.5.0", "", { "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g=="], "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA=="], @@ -1350,10 +1243,6 @@ "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw=="], - "@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.5.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.5.0", "@opentelemetry/core": "2.5.0", "@opentelemetry/sdk-trace-base": "2.5.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-O6N/ejzburFm2C84aKNrwJVPpt6HSTSq8T0ZUMq3xT2XmqT4cwxUItcL5UWGThYuq8RTcbH8u1sfj6dmRci0Ow=="], - - "@opentelemetry/sdk-trace-web": ["@opentelemetry/sdk-trace-web@2.5.0", "", { "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/sdk-trace-base": "2.5.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-xWibakHs+xbx6vxH7Q8TbFS6zjf812o/kIS4xBDB32qSL9wF+Z5IZl2ZAGu4rtmPBQ7coZcOd684DobMhf8dKw=="], - "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.38.0", "", {}, "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg=="], "@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="], @@ -1524,34 +1413,6 @@ "@paper-design/shaders-react": ["@paper-design/shaders-react@0.0.71", "", { "dependencies": { "@paper-design/shaders": "0.0.71" }, "peerDependencies": { "@types/react": "^18 || ^19", "react": "^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-kTqjIlyZcpkwqJie+3ldEDscTtx1oOi8eRBD5QgWKI21GaNn/SSg26092M5zzqr3e8dVANv0ktS2ICSjbMFKbw=="], - "@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", "micromatch": "^4.0.5", "node-addon-api": "^7.0.0" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="], - - "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="], - - "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw=="], - - "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg=="], - - "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ=="], - - "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA=="], - - "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q=="], - - "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w=="], - - "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg=="], - - "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A=="], - - "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg=="], - - "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw=="], - - "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ=="], - - "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="], - "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.5.0", "", { "dependencies": { "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-YM/nFfskFJSlHqv59ed6dZlLZqtZQwjRVJ4bBAiWV08Oc+1rSd5lDZcBEx0lGDHfSoH3UziI2pXt2UM33KerPQ=="], "@peculiar/json-schema": ["@peculiar/json-schema@1.1.12", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w=="], @@ -2302,6 +2163,8 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], "@untitledui/file-icons": ["@untitledui/file-icons@0.0.9", "", { "peerDependencies": { "react": ">= 18" } }, "sha512-wz3btNnSSv2hTujgyxEFL21oyjPtGTj3osU8ZEMe8nwdlmLQEILLe96NMg9b1ciiOC5UOWNDeYIt7IfxEM6qtg=="], @@ -2574,6 +2437,8 @@ "cluster": ["cluster@workspace:apps/cluster"], + "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], + "cobe": ["cobe@0.6.5", "", { "dependencies": { "phenomenon": "^1.6.0" } }, "sha512-MA8bu81EFY6JjQpj+FovEuhyJ25khx2Q7Lh+ot/UkCJe5yKyDgzdc6u2lGZIOmsZTXK6Itg1i4lQZIJZbPWnAg=="], "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], @@ -2688,6 +2553,8 @@ "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], + "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], @@ -2702,13 +2569,13 @@ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], - "dfx": ["dfx@0.127.0", "", { "dependencies": { "discord-api-types": "^0.38.33" }, "optionalDependencies": { "discord-verify": "^1.2.0" }, "peerDependencies": { "@effect/platform": "^0.93", "effect": "^3.19" } }, "sha512-WQGzD80m8Jx0Kn8lqbrjTrMINUC+mTgU8C+4Kv6yxZc8ZK02rhqkeiBhdMzNDXQX8c/gr3R9GcUYoBJ31wQBoA=="], + "dfx": ["dfx@1.0.10", "", { "dependencies": { "discord-api-types": "^0.38.40" }, "optionalDependencies": { "discord-verify": "^1.2.0" }, "peerDependencies": { "effect": "4.0.0-beta.22" } }, "sha512-m0BX8PpMLdSFYoKQz5IZzhDhqs0FsVaQx4PqtV3/u/mx6Rgo14m8PdFrWTjPnYH+W7GW6JxkBqN9c9tUNDidGQ=="], "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], "direction": ["direction@1.0.4", "", { "bin": { "direction": "cli.js" } }, "sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ=="], - "discord-api-types": ["discord-api-types@0.38.38", "", {}, "sha512-7qcM5IeZrfb+LXW07HvoI5L+j4PQeMZXEkSm1htHAHh4Y9JSMXBWjy/r7zmUCOj4F7zNjMcm7IMWr131MT2h0Q=="], + "discord-api-types": ["discord-api-types@0.38.42", "", {}, "sha512-qs1kya7S84r5RR8m9kgttywGrmmoHaRifU1askAoi+wkoSefLpZP6aGXusjNw5b0jD3zOg3LTwUa3Tf2iHIceQ=="], "discord-verify": ["discord-verify@1.2.0", "", { "dependencies": { "@types/express": "^4.17.17" } }, "sha512-8qlrMROW8DhpzWWzgNq9kpeLDxKanWa4EDVoj/ASVv2nr+dSr4JPmu2tFSydf3hAGI/OIJTnZyD0JulMYIxx4w=="], @@ -2746,7 +2613,7 @@ "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], - "effect": ["effect@3.19.19", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-Yc8U/SVXo2dHnaP7zNBlAo83h/nzSJpi7vph6Hzyl4ulgMBIgPmz3UzOjb9sBgpFE00gC0iETR244sfXDNLHRg=="], + "effect": ["effect@4.0.0-beta.33", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-ln9emWPd1SemokSdOV43r2CbH1j8GTe9qbPvttmh9/j2OR0WNmj7UpjbN34llQgF9QV4IdcN6QdV2w8G7B7RyQ=="], "effect-rpc-tanstack-devtools": ["effect-rpc-tanstack-devtools@0.1.1", "", { "peerDependencies": { "@effect/rpc": ">=0.70.0", "@tanstack/devtools-event-client": ">=0.3.0", "effect": ">=3.0.0", "react": ">=18.0.0" } }, "sha512-iYddWCEwdUCquNrXexHfOzKPxLR5fD5HalSTgRnr7cW3aRPcTAjQ1XGXel0nJBeUoPcSkiEIuvDVsYYySviLyg=="], @@ -2850,7 +2717,7 @@ "facehash": ["facehash@0.1.0", "", { "peerDependencies": { "@types/react": "", "next": ">=15", "react": ">=18 <20", "react-dom": ">=18 <20" }, "optionalPeers": ["@types/react", "next"] }, "sha512-tv/QVZjLvEXHssqBaJECq+kRLFwwhd017PKk8ucT7aLingL2OZ5zEqKwPMHmT9+YQO92MVFWGZQP6vxV+P5vrQ=="], - "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + "fast-check": ["fast-check@4.6.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-h7H6Dm0Fy+H4ciQYFxFjXnXkzR2kr9Fb22c0UBpHnm59K2zpr2t13aPTHlltFiNT6zuxp6HMPAVVvgur4BLdpA=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -3054,7 +2921,7 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ini": ["ini@4.1.3", "", {}, "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg=="], + "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], @@ -3068,6 +2935,8 @@ "invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="], + "ioredis": ["ioredis@5.10.0", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA=="], + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], "ip-regex": ["ip-regex@4.3.0", "", {}, "sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q=="], @@ -3272,6 +3141,10 @@ "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], + "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], + + "lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="], + "lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="], "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], @@ -3428,11 +3301,9 @@ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], - "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - "microsoft-capitalize": ["microsoft-capitalize@1.0.5", "", {}, "sha512-iqDMU9J643BHg8Zp7EMZNLTp6Pgs2f1S2SMnCW2VlUqMs17xCZ5vwVjalBJEGVcUfG+/1ePqeEGcMW3VfzHK5A=="], - "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], "miniflare": ["miniflare@4.20260312.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.18.2", "workerd": "1.20260312.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-pieP2rfXynPT6VRINYaiHe/tfMJ4c5OIhqRlIdLF6iZ9g5xgpEmvimvIgMpgAdDJuFlrLcwDUi8MfAo2R6dt/w=="], @@ -3466,7 +3337,7 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "msgpackr": ["msgpackr@1.11.5", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA=="], + "msgpackr": ["msgpackr@1.11.9", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-FkoAAyyA6HM8wL882EcEyFZ9s7hVADSwG9xrVx3dxxNQAtgADTrJoEWivID82Iv1zWDsv/OtbrrcZAzGzOMdNw=="], "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="], @@ -3496,8 +3367,6 @@ "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="], - "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], - "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], "node-gyp": ["node-gyp@11.5.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "make-fetch-happen": "^14.0.3", "nopt": "^8.0.0", "proc-log": "^5.0.0", "semver": "^7.3.5", "tar": "^7.4.3", "tinyglobby": "^0.2.12", "which": "^5.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ=="], @@ -3600,21 +3469,21 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "pg": ["pg@8.16.3", "", { "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", "pg-protocol": "^1.10.3", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.2.7" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw=="], + "pg": ["pg@8.20.0", "", { "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", "pg-protocol": "^1.13.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA=="], - "pg-cloudflare": ["pg-cloudflare@1.2.7", "", {}, "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg=="], + "pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="], - "pg-connection-string": ["pg-connection-string@2.9.1", "", {}, "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w=="], + "pg-connection-string": ["pg-connection-string@2.11.0", "", {}, "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ=="], - "pg-cursor": ["pg-cursor@2.15.3", "", { "peerDependencies": { "pg": "^8" } }, "sha512-eHw63TsiGtFEfAd7tOTZ+TLy+i/2ePKS20H84qCQ+aQ60pve05Okon9tKMC+YN3j6XyeFoHnaim7Lt9WVafQsA=="], + "pg-cursor": ["pg-cursor@2.19.0", "", { "peerDependencies": { "pg": "^8" } }, "sha512-J5cF1MUz7LRJ9emOqF/06QjabMHMZy587rSPF0UuA8rCwKeeYl2co8Pp+6k5UU9YrAYHMzWkLxilfZB0hqsWWw=="], "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], "pg-numeric": ["pg-numeric@1.0.2", "", {}, "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw=="], - "pg-pool": ["pg-pool@3.10.1", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg=="], + "pg-pool": ["pg-pool@3.13.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA=="], - "pg-protocol": ["pg-protocol@1.10.3", "", {}, "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ=="], + "pg-protocol": ["pg-protocol@1.13.0", "", {}, "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w=="], "pg-types": ["pg-types@4.1.0", "", { "dependencies": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", "postgres-array": "~3.0.1", "postgres-bytea": "~3.0.0", "postgres-date": "~2.1.0", "postgres-interval": "^3.0.0", "postgres-range": "^1.1.1" } }, "sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg=="], @@ -3694,7 +3563,7 @@ "punycode2": ["punycode2@1.0.1", "", {}, "sha512-+TXpd9YRW4YUZZPoRHJ3DILtWwootGc2DsgvfHmklQ8It1skINAuqSdqizt5nlTaBmwrYACHkHApCXjc9gHk2Q=="], - "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "pure-rand": ["pure-rand@8.0.0", "", {}, "sha512-7rgWlxG2gAvFPIQfUreo1XYlNvrQ9VnQPFWdncPkdl3icucLK0InOxsaafbvxGTnI6Bk/Rxmslg0lQlRCuzOXw=="], "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], @@ -3760,6 +3629,10 @@ "recma-stringify": ["recma-stringify@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-to-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g=="], + "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], + + "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], @@ -3968,6 +3841,8 @@ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], @@ -4148,7 +4023,7 @@ "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], - "undici": ["undici@7.18.2", "", {}, "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw=="], + "undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], @@ -4216,7 +4091,7 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "uuid": ["uuid@12.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-USe1zesMYh4fjCA8ZH5+X5WIVD0J4V1Jksm1bFTVBX2F/cwSXt0RO5w/3UXbdLKmZX65MiWV+hwhSS8p6oBTGA=="], + "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], "vbare": ["vbare@0.0.4", "", {}, "sha512-QsxSVw76NqYUWYPVcQmOnQPX8buIVjgn+yqldTHlWISulBTB9TJ9rnzZceDu+GZmycOtzsmuPbPN1YNxvK12fg=="], @@ -4540,11 +4415,15 @@ "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], - "@effect-atom/atom-react/@effect-atom/atom": ["@effect-atom/atom@0.5.0", "", { "peerDependencies": { "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.2", "@effect/rpc": "^0.73.0", "effect": "^3.19.15" } }, "sha512-qg6Qpf+ESi63taFJrufPtYDHghRLXxAte/xgpKqN+m7T6I3Lw1jgiNxR4AJeNG7f2UOkpmPhA8AYdWMgaUU61w=="], + "@effect/platform/effect": ["effect@3.19.19", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-Yc8U/SVXo2dHnaP7zNBlAo83h/nzSJpi7vph6Hzyl4ulgMBIgPmz3UzOjb9sBgpFE00gC0iETR244sfXDNLHRg=="], + + "@effect/platform/msgpackr": ["msgpackr@1.11.5", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA=="], + + "@effect/platform-node-shared/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], - "@effect/experimental/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + "@effect/rpc/effect": ["effect@3.19.19", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-Yc8U/SVXo2dHnaP7zNBlAo83h/nzSJpi7vph6Hzyl4ulgMBIgPmz3UzOjb9sBgpFE00gC0iETR244sfXDNLHRg=="], - "@effect/sql/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + "@effect/rpc/msgpackr": ["msgpackr@1.11.5", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], @@ -4576,6 +4455,8 @@ "@metascraper/helpers/jsdom": ["jsdom@27.0.1", "", { "dependencies": { "@asamuzakjp/dom-selector": "^6.7.2", "cssstyle": "^5.3.1", "data-urls": "^6.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.1.0", "ws": "^8.18.3", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA=="], + "@metascraper/helpers/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "@opentelemetry/exporter-logs-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], @@ -4598,16 +4479,6 @@ "@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], - "@opentelemetry/sdk-trace-node/@opentelemetry/core": ["@opentelemetry/core@2.5.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ=="], - - "@opentelemetry/sdk-trace-node/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.5.0", "", { "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ=="], - - "@opentelemetry/sdk-trace-web/@opentelemetry/core": ["@opentelemetry/core@2.5.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ=="], - - "@opentelemetry/sdk-trace-web/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.5.0", "", { "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ=="], - - "@parcel/watcher/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], - "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -4618,6 +4489,8 @@ "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@rivetkit/engine-runner/uuid": ["uuid@12.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-USe1zesMYh4fjCA8ZH5+X5WIVD0J4V1Jksm1bFTVBX2F/cwSXt0RO5w/3UXbdLKmZX65MiWV+hwhSS8p6oBTGA=="], + "@rollup/plugin-babel/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], "@rollup/plugin-babel/@rollup/pluginutils": ["@rollup/pluginutils@3.1.0", "", { "dependencies": { "@types/estree": "0.0.39", "estree-walker": "^1.0.1", "picomatch": "^2.2.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0" } }, "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg=="], @@ -4758,6 +4631,8 @@ "@types/koa/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], + "@types/pg/pg-protocol": ["pg-protocol@1.10.3", "", {}, "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ=="], + "@types/pg/pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], "@types/send/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], @@ -4830,7 +4705,7 @@ "cssstyle/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], - "dfx/@effect/platform": ["@effect/platform@0.94.3", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.19.16" } }, "sha512-bvTR8xLQoRpKgHuprZDOeQdPkhyVw+WT05iI9jl2s8Qiblyk5Dz2JLwJU+EFeksIBaPYz49xa635Om91T1CefQ=="], + "dfx/effect": ["effect@4.0.0-beta.32", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-22DEm3JB9njSJzYxBXA6h38JHIxLu6JpKQmhQw2C28gwptpnQXc4Ba3bIQubqsKSzYqNjp5fz9YdOZuKSwxkXQ=="], "docker-modem/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], @@ -4842,6 +4717,8 @@ "drizzle-kit/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], + "effect-rpc-tanstack-devtools/effect": ["effect@3.19.19", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-Yc8U/SVXo2dHnaP7zNBlAo83h/nzSJpi7vph6Hzyl4ulgMBIgPmz3UzOjb9sBgpFE00gC0iETR244sfXDNLHRg=="], + "filelist/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], "fumadocs-core/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], @@ -4878,8 +4755,6 @@ "istanbul-reports/html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], - "jsdom/undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="], - "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], @@ -4892,7 +4767,7 @@ "mdast-util-to-markdown/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], - "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "miniflare/undici": ["undici@7.18.2", "", {}, "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw=="], "miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], @@ -4904,6 +4779,8 @@ "msw/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + "nitro/undici": ["undici@7.18.2", "", {}, "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw=="], + "node-gyp/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], @@ -4914,9 +4791,9 @@ "parse5-parser-stream/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], - "pg/pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + "pg/pg-connection-string": ["pg-connection-string@2.12.0", "", {}, "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ=="], - "posthog-js/@opentelemetry/resources": ["@opentelemetry/resources@2.5.0", "", { "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g=="], + "pg/pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], "promise-retry/retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], @@ -4932,6 +4809,8 @@ "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "rivetkit/uuid": ["uuid@12.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-USe1zesMYh4fjCA8ZH5+X5WIVD0J4V1Jksm1bFTVBX2F/cwSXt0RO5w/3UXbdLKmZX65MiWV+hwhSS8p6oBTGA=="], + "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.9", "", {}, "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw=="], "rolldown-plugin-dts/@babel/generator": ["@babel/generator@8.0.0-rc.1", "", { "dependencies": { "@babel/parser": "^8.0.0-rc.1", "@babel/types": "^8.0.0-rc.1", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-3ypWOOiC4AYHKr8vYRVtWtWmyvcoItHtVqF8paFax+ydpmUdPsJpLBkBBs5ItmhdrwC3a0ZSqqFAdzls4ODP3w=="], @@ -5234,6 +5113,10 @@ "@cloudflare/vitest-pool-workers/wrangler/workerd": ["workerd@1.20250906.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20250906.0", "@cloudflare/workerd-darwin-arm64": "1.20250906.0", "@cloudflare/workerd-linux-64": "1.20250906.0", "@cloudflare/workerd-linux-arm64": "1.20250906.0", "@cloudflare/workerd-windows-64": "1.20250906.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-ryVyEaqXPPsr/AxccRmYZZmDAkfQVjhfRqrNTlEeN8aftBk6Ca1u7/VqmfOayjCXrA+O547TauebU+J3IpvFXw=="], + "@effect/platform/effect/fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + + "@effect/rpc/effect/fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -5310,10 +5193,6 @@ "@metascraper/helpers/jsdom/whatwg-url": ["whatwg-url@15.1.0", "", { "dependencies": { "tr46": "^6.0.0", "webidl-conversions": "^8.0.0" } }, "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g=="], - "@opentelemetry/sdk-trace-node/@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.5.0", "", { "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g=="], - - "@opentelemetry/sdk-trace-web/@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.5.0", "", { "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g=="], - "@rollup/plugin-babel/@babel/helper-module-imports/@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="], "@rollup/plugin-babel/@rollup/pluginutils/@types/estree": ["@types/estree@0.0.39", "", {}, "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw=="], @@ -5648,6 +5527,8 @@ "drizzle-kit/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="], + "effect-rpc-tanstack-devtools/effect/fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + "fumadocs-core/shiki/@shikijs/core": ["@shikijs/core@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA=="], "fumadocs-core/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-jdKhfgW9CRtj3Tor0L7+yPwdG3CgP7W+ZEqSsojrMzCjD1e0IxIbwUMDDpYlVBlC08TACg4puwFGkZfLS+56Tw=="], @@ -5762,8 +5643,6 @@ "pg/pg-types/postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], - "posthog-js/@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.5.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ=="], - "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.2", "", {}, "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ=="], "ssh-remote-port-forward/@types/ssh2/@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], @@ -6050,6 +5929,8 @@ "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20250906.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Q8Qjfs8jGVILnZL6vUpQ90q/8MTCYaGR3d1LGxZMBqte8Vr7xF3KFHPEy7tFs0j0mMjnqCYzlofmPNY+9ZaDRg=="], + "@cloudflare/vitest-pool-workers/wrangler/@cloudflare/kv-asset-handler/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "@cloudflare/vitest-pool-workers/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="], "@cloudflare/vitest-pool-workers/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="], @@ -6110,6 +5991,10 @@ "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20250906.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Q8Qjfs8jGVILnZL6vUpQ90q/8MTCYaGR3d1LGxZMBqte8Vr7xF3KFHPEy7tFs0j0mMjnqCYzlofmPNY+9ZaDRg=="], + "@effect/platform/effect/fast-check/pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + + "@effect/rpc/effect/fast-check/pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "@grpc/grpc-js/@grpc/proto-loader/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "@grpc/grpc-js/@grpc/proto-loader/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -6368,6 +6253,8 @@ "dockerode/tar-fs/tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "effect-rpc-tanstack-devtools/effect/fast-check/pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "istanbul-lib-instrument/@babel/core/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="], "istanbul-lib-instrument/@babel/core/@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], diff --git a/libs/ai-openrouter/package.json b/libs/ai-openrouter/package.json deleted file mode 100644 index a1aed71e1..000000000 --- a/libs/ai-openrouter/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@hazel/ai-openrouter", - "version": "0.0.1", - "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts", - "exports": { - ".": "./src/index.ts", - "./*": "./src/*.ts" - }, - "scripts": { - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@effect/ai": "catalog:effect", - "@effect/experimental": "catalog:effect", - "@effect/platform": "catalog:effect", - "effect": "catalog:effect" - }, - "devDependencies": { - "@effect/language-service": "catalog:effect", - "typescript": "^5.9.3" - } -} diff --git a/libs/ai-openrouter/src/Generated.ts b/libs/ai-openrouter/src/Generated.ts deleted file mode 100644 index 052825191..000000000 --- a/libs/ai-openrouter/src/Generated.ts +++ /dev/null @@ -1,6415 +0,0 @@ -/** - * @since 1.0.0 - */ -import type * as HttpClient from "@effect/platform/HttpClient" -import * as HttpClientError from "@effect/platform/HttpClientError" -import * as HttpClientRequest from "@effect/platform/HttpClientRequest" -import * as HttpClientResponse from "@effect/platform/HttpClientResponse" -import * as Data from "effect/Data" -import * as Effect from "effect/Effect" -import type { ParseError } from "effect/ParseResult" -import * as S from "effect/Schema" - -export class CacheControlEphemeral extends S.Class("CacheControlEphemeral")({ - type: S.Literal("ephemeral"), -}) {} - -export class OpenResponsesReasoningFormat extends S.Literal( - "unknown", - "openai-responses-v1", - "azure-openai-responses-v1", - "xai-responses-v1", - "anthropic-claude-v1", - "google-gemini-v1", -) {} - -export class OpenResponsesReasoningType extends S.Literal("reasoning") {} - -export class ReasoningTextContentType extends S.Literal("reasoning_text") {} - -export class ReasoningTextContent extends S.Class("ReasoningTextContent")({ - type: ReasoningTextContentType, - text: S.String, -}) {} - -export class ReasoningSummaryTextType extends S.Literal("summary_text") {} - -export class ReasoningSummaryText extends S.Class("ReasoningSummaryText")({ - type: ReasoningSummaryTextType, - text: S.String, -}) {} - -export class OpenResponsesReasoningStatusEnum extends S.Literal("in_progress") {} - -export class OpenResponsesReasoning extends S.Class("OpenResponsesReasoning")({ - signature: S.optionalWith(S.String, { nullable: true }), - format: S.optionalWith(OpenResponsesReasoningFormat, { nullable: true }), - type: OpenResponsesReasoningType, - id: S.String, - content: S.optionalWith(S.Array(ReasoningTextContent), { nullable: true }), - summary: S.Array(ReasoningSummaryText), - encrypted_content: S.optionalWith(S.String, { nullable: true }), - status: S.optionalWith( - S.Union( - OpenResponsesReasoningStatusEnum, - OpenResponsesReasoningStatusEnum, - OpenResponsesReasoningStatusEnum, - ), - { nullable: true }, - ), -}) {} - -export class ReasoningDetailSummary extends S.Class("ReasoningDetailSummary")({ - id: S.optionalWith(S.String, { nullable: true }), - type: S.Literal("reasoning.summary"), - index: S.optional(S.Number), - format: S.optionalWith(OpenResponsesReasoningFormat, { nullable: true }), - summary: S.String, -}) {} - -export class ReasoningDetailEncrypted extends S.Class("ReasoningDetailEncrypted")({ - id: S.optionalWith(S.String, { nullable: true }), - type: S.Literal("reasoning.encrypted"), - index: S.optional(S.Number), - format: S.optionalWith(OpenResponsesReasoningFormat, { nullable: true }), - data: S.String, -}) {} - -export class ReasoningDetailText extends S.Class("ReasoningDetailText")({ - id: S.optionalWith(S.String, { nullable: true }), - type: S.Literal("reasoning.text"), - index: S.optional(S.Number), - format: S.optionalWith(OpenResponsesReasoningFormat, { nullable: true }), - text: S.optionalWith(S.String, { nullable: true }), - signature: S.optionalWith(S.String, { nullable: true }), -}) {} - -export class ReasoningDetail extends S.Union( - ReasoningDetailSummary, - ReasoningDetailEncrypted, - ReasoningDetailText, -) {} - -export class FileAnnotationDetail extends S.Class("FileAnnotationDetail")({ - type: S.Literal("file"), - file: S.Struct({ - hash: S.String, - name: S.optionalWith(S.String, { nullable: true }), - content: S.Array( - S.Union( - S.Struct({ - type: S.Literal("text"), - text: S.String, - }), - S.Struct({ - type: S.Literal("image_url"), - image_url: S.Struct({ - url: S.String, - }), - }), - ), - ), - }), -}) {} - -export class URLCitationAnnotationDetail extends S.Class( - "URLCitationAnnotationDetail", -)({ - type: S.Literal("url_citation"), - url_citation: S.Struct({ - end_index: S.Number, - start_index: S.Number, - title: S.String, - url: S.String, - content: S.optionalWith(S.String, { nullable: true }), - }), -}) {} - -export class AnnotationDetail extends S.Union(FileAnnotationDetail, URLCitationAnnotationDetail) {} - -export class OpenResponsesEasyInputMessageType extends S.Literal("message") {} - -export class OpenResponsesEasyInputMessageRoleEnum extends S.Literal("developer") {} - -export class ResponseInputTextType extends S.Literal("input_text") {} - -/** - * Text input content item - */ -export class ResponseInputText extends S.Class("ResponseInputText")({ - type: ResponseInputTextType, - text: S.String, -}) {} - -export class ResponseInputFileType extends S.Literal("input_file") {} - -/** - * File input content item - */ -export class ResponseInputFile extends S.Class("ResponseInputFile")({ - type: ResponseInputFileType, - file_id: S.optionalWith(S.String, { nullable: true }), - file_data: S.optionalWith(S.String, { nullable: true }), - filename: S.optionalWith(S.String, { nullable: true }), - file_url: S.optionalWith(S.String, { nullable: true }), -}) {} - -export class ResponseInputAudioType extends S.Literal("input_audio") {} - -export class ResponseInputAudioInputAudioFormat extends S.Literal("mp3", "wav") {} - -/** - * Audio input content item - */ -export class ResponseInputAudio extends S.Class("ResponseInputAudio")({ - type: ResponseInputAudioType, - input_audio: S.Struct({ - data: S.String, - format: ResponseInputAudioInputAudioFormat, - }), -}) {} - -export class ResponseInputVideoType extends S.Literal("input_video") {} - -/** - * Video input content item - */ -export class ResponseInputVideo extends S.Class("ResponseInputVideo")({ - type: ResponseInputVideoType, - /** - * A base64 data URL or remote URL that resolves to a video file - */ - video_url: S.String, -}) {} - -export class OpenResponsesEasyInputMessage extends S.Class( - "OpenResponsesEasyInputMessage", -)({ - type: S.optionalWith(OpenResponsesEasyInputMessageType, { nullable: true }), - role: S.Union( - OpenResponsesEasyInputMessageRoleEnum, - OpenResponsesEasyInputMessageRoleEnum, - OpenResponsesEasyInputMessageRoleEnum, - OpenResponsesEasyInputMessageRoleEnum, - ), - content: S.Union( - S.Array( - S.Union( - ResponseInputText, - /** - * Image input content item - */ - S.Struct({ - type: S.Literal("input_image"), - detail: S.Literal("auto", "high", "low"), - image_url: S.optionalWith(S.String, { nullable: true }), - }), - ResponseInputFile, - ResponseInputAudio, - ResponseInputVideo, - ), - ), - S.String, - ), -}) {} - -export class OpenResponsesInputMessageItemType extends S.Literal("message") {} - -export class OpenResponsesInputMessageItemRoleEnum extends S.Literal("developer") {} - -export class OpenResponsesInputMessageItem extends S.Class( - "OpenResponsesInputMessageItem", -)({ - id: S.optionalWith(S.String, { nullable: true }), - type: S.optionalWith(OpenResponsesInputMessageItemType, { nullable: true }), - role: S.Union( - OpenResponsesInputMessageItemRoleEnum, - OpenResponsesInputMessageItemRoleEnum, - OpenResponsesInputMessageItemRoleEnum, - ), - content: S.Array( - S.Union( - ResponseInputText, - /** - * Image input content item - */ - S.Struct({ - type: S.Literal("input_image"), - detail: S.Literal("auto", "high", "low"), - image_url: S.optionalWith(S.String, { nullable: true }), - }), - ResponseInputFile, - ResponseInputAudio, - ResponseInputVideo, - ), - ), -}) {} - -export class OpenResponsesFunctionToolCallType extends S.Literal("function_call") {} - -export class ToolCallStatus extends S.Literal("in_progress", "completed", "incomplete") {} - -/** - * A function call initiated by the model - */ -export class OpenResponsesFunctionToolCall extends S.Class( - "OpenResponsesFunctionToolCall", -)({ - type: OpenResponsesFunctionToolCallType, - call_id: S.String, - name: S.String, - arguments: S.String, - id: S.String, - status: S.optionalWith(ToolCallStatus, { nullable: true }), -}) {} - -export class OpenResponsesFunctionCallOutputType extends S.Literal("function_call_output") {} - -/** - * The output from a function call execution - */ -export class OpenResponsesFunctionCallOutput extends S.Class( - "OpenResponsesFunctionCallOutput", -)({ - type: OpenResponsesFunctionCallOutputType, - id: S.optionalWith(S.String, { nullable: true }), - call_id: S.String, - output: S.String, - status: S.optionalWith(ToolCallStatus, { nullable: true }), -}) {} - -export class ResponsesOutputMessageRole extends S.Literal("assistant") {} - -export class ResponsesOutputMessageType extends S.Literal("message") {} - -export class ResponsesOutputMessageStatusEnum extends S.Literal("in_progress") {} - -export class ResponseOutputTextType extends S.Literal("output_text") {} - -export class FileCitationType extends S.Literal("file_citation") {} - -export class FileCitation extends S.Class("FileCitation")({ - type: FileCitationType, - file_id: S.String, - filename: S.String, - index: S.Number, -}) {} - -export class URLCitationType extends S.Literal("url_citation") {} - -export class URLCitation extends S.Class("URLCitation")({ - type: URLCitationType, - url: S.String, - title: S.String, - start_index: S.Number, - end_index: S.Number, -}) {} - -export class FilePathType extends S.Literal("file_path") {} - -export class FilePath extends S.Class("FilePath")({ - type: FilePathType, - file_id: S.String, - index: S.Number, -}) {} - -export class OpenAIResponsesAnnotation extends S.Union(FileCitation, URLCitation, FilePath) {} - -export class ResponseOutputText extends S.Class("ResponseOutputText")({ - type: ResponseOutputTextType, - text: S.String, - annotations: S.optionalWith(S.Array(OpenAIResponsesAnnotation), { nullable: true }), - logprobs: S.optionalWith( - S.Array( - S.Struct({ - token: S.String, - bytes: S.Array(S.Number), - logprob: S.Number, - top_logprobs: S.Array( - S.Struct({ - token: S.String, - bytes: S.Array(S.Number), - logprob: S.Number, - }), - ), - }), - ), - { nullable: true }, - ), -}) {} - -export class OpenAIResponsesRefusalContentType extends S.Literal("refusal") {} - -export class OpenAIResponsesRefusalContent extends S.Class( - "OpenAIResponsesRefusalContent", -)({ - type: OpenAIResponsesRefusalContentType, - refusal: S.String, -}) {} - -export class ResponsesOutputMessage extends S.Class("ResponsesOutputMessage")({ - id: S.String, - role: ResponsesOutputMessageRole, - type: ResponsesOutputMessageType, - status: S.optionalWith( - S.Union( - ResponsesOutputMessageStatusEnum, - ResponsesOutputMessageStatusEnum, - ResponsesOutputMessageStatusEnum, - ), - { nullable: true }, - ), - content: S.Array(S.Union(ResponseOutputText, OpenAIResponsesRefusalContent)), -}) {} - -/** - * The format of the reasoning content - */ -export class ResponsesOutputItemReasoningFormat extends S.Literal( - "unknown", - "openai-responses-v1", - "azure-openai-responses-v1", - "xai-responses-v1", - "anthropic-claude-v1", - "google-gemini-v1", -) {} - -export class ResponsesOutputItemReasoningType extends S.Literal("reasoning") {} - -export class ResponsesOutputItemReasoningStatusEnum extends S.Literal("in_progress") {} - -export class ResponsesOutputItemReasoning extends S.Class( - "ResponsesOutputItemReasoning", -)({ - /** - * A signature for the reasoning content, used for verification - */ - signature: S.optionalWith(S.String, { nullable: true }), - /** - * The format of the reasoning content - */ - format: S.optionalWith(ResponsesOutputItemReasoningFormat, { nullable: true }), - type: ResponsesOutputItemReasoningType, - id: S.String, - content: S.optionalWith(S.Array(ReasoningTextContent), { nullable: true }), - summary: S.Array(ReasoningSummaryText), - encrypted_content: S.optionalWith(S.String, { nullable: true }), - status: S.optionalWith( - S.Union( - ResponsesOutputItemReasoningStatusEnum, - ResponsesOutputItemReasoningStatusEnum, - ResponsesOutputItemReasoningStatusEnum, - ), - { nullable: true }, - ), -}) {} - -export class ResponsesOutputItemFunctionCallType extends S.Literal("function_call") {} - -export class ResponsesOutputItemFunctionCallStatusEnum extends S.Literal("in_progress") {} - -export class ResponsesOutputItemFunctionCall extends S.Class( - "ResponsesOutputItemFunctionCall", -)({ - type: ResponsesOutputItemFunctionCallType, - id: S.optionalWith(S.String, { nullable: true }), - name: S.String, - arguments: S.String, - call_id: S.String, - status: S.optionalWith( - S.Union( - ResponsesOutputItemFunctionCallStatusEnum, - ResponsesOutputItemFunctionCallStatusEnum, - ResponsesOutputItemFunctionCallStatusEnum, - ), - { nullable: true }, - ), -}) {} - -export class ResponsesWebSearchCallOutputType extends S.Literal("web_search_call") {} - -export class WebSearchStatus extends S.Literal("completed", "searching", "in_progress", "failed") {} - -export class ResponsesWebSearchCallOutput extends S.Class( - "ResponsesWebSearchCallOutput", -)({ - type: ResponsesWebSearchCallOutputType, - id: S.String, - status: WebSearchStatus, -}) {} - -export class ResponsesOutputItemFileSearchCallType extends S.Literal("file_search_call") {} - -export class ResponsesOutputItemFileSearchCall extends S.Class( - "ResponsesOutputItemFileSearchCall", -)({ - type: ResponsesOutputItemFileSearchCallType, - id: S.String, - queries: S.Array(S.String), - status: WebSearchStatus, -}) {} - -export class ResponsesImageGenerationCallType extends S.Literal("image_generation_call") {} - -export class ImageGenerationStatus extends S.Literal("in_progress", "completed", "generating", "failed") {} - -export class ResponsesImageGenerationCall extends S.Class( - "ResponsesImageGenerationCall", -)({ - type: ResponsesImageGenerationCallType, - id: S.String, - result: S.optionalWith(S.NullOr(S.String), { default: () => null }), - status: ImageGenerationStatus, -}) {} - -/** - * Input for a response request - can be a string or array of items - */ -export class OpenResponsesInput extends S.Union( - S.String, - S.Array( - S.Union( - OpenResponsesReasoning, - OpenResponsesEasyInputMessage, - OpenResponsesInputMessageItem, - OpenResponsesFunctionToolCall, - OpenResponsesFunctionCallOutput, - ResponsesOutputMessage, - ResponsesOutputItemReasoning, - ResponsesOutputItemFunctionCall, - ResponsesWebSearchCallOutput, - ResponsesOutputItemFileSearchCall, - ResponsesImageGenerationCall, - ), - ), -) {} - -/** - * Metadata key-value pairs for the request. Keys must be ≤64 characters and cannot contain brackets. Values must be ≤512 characters. Maximum 16 pairs allowed. - */ -export class OpenResponsesRequestMetadata extends S.Record({ key: S.String, value: S.Unknown }) {} - -export class OpenResponsesWebSearchPreviewToolType extends S.Literal("web_search_preview") {} - -/** - * Size of the search context for web search tools - */ -export class ResponsesSearchContextSize extends S.Literal("low", "medium", "high") {} - -export class WebSearchPreviewToolUserLocationType extends S.Literal("approximate") {} - -export class WebSearchPreviewToolUserLocation extends S.Class( - "WebSearchPreviewToolUserLocation", -)({ - type: WebSearchPreviewToolUserLocationType, - city: S.optionalWith(S.String, { nullable: true }), - country: S.optionalWith(S.String, { nullable: true }), - region: S.optionalWith(S.String, { nullable: true }), - timezone: S.optionalWith(S.String, { nullable: true }), -}) {} - -/** - * Web search preview tool configuration - */ -export class OpenResponsesWebSearchPreviewTool extends S.Class( - "OpenResponsesWebSearchPreviewTool", -)({ - type: OpenResponsesWebSearchPreviewToolType, - search_context_size: S.optionalWith(ResponsesSearchContextSize, { nullable: true }), - user_location: S.optionalWith(WebSearchPreviewToolUserLocation, { nullable: true }), -}) {} - -export class OpenResponsesWebSearchPreview20250311ToolType extends S.Literal( - "web_search_preview_2025_03_11", -) {} - -/** - * Web search preview tool configuration (2025-03-11 version) - */ -export class OpenResponsesWebSearchPreview20250311Tool extends S.Class( - "OpenResponsesWebSearchPreview20250311Tool", -)({ - type: OpenResponsesWebSearchPreview20250311ToolType, - search_context_size: S.optionalWith(ResponsesSearchContextSize, { nullable: true }), - user_location: S.optionalWith(WebSearchPreviewToolUserLocation, { nullable: true }), -}) {} - -export class OpenResponsesWebSearchToolType extends S.Literal("web_search") {} - -export class ResponsesWebSearchUserLocationType extends S.Literal("approximate") {} - -/** - * User location information for web search - */ -export class ResponsesWebSearchUserLocation extends S.Class( - "ResponsesWebSearchUserLocation", -)({ - type: S.optionalWith(ResponsesWebSearchUserLocationType, { nullable: true }), - city: S.optionalWith(S.String, { nullable: true }), - country: S.optionalWith(S.String, { nullable: true }), - region: S.optionalWith(S.String, { nullable: true }), - timezone: S.optionalWith(S.String, { nullable: true }), -}) {} - -/** - * Web search tool configuration - */ -export class OpenResponsesWebSearchTool extends S.Class( - "OpenResponsesWebSearchTool", -)({ - type: OpenResponsesWebSearchToolType, - filters: S.optionalWith( - S.Struct({ - allowed_domains: S.optionalWith(S.Array(S.String), { nullable: true }), - }), - { nullable: true }, - ), - search_context_size: S.optionalWith(ResponsesSearchContextSize, { nullable: true }), - user_location: S.optionalWith(ResponsesWebSearchUserLocation, { nullable: true }), -}) {} - -export class OpenResponsesWebSearch20250826ToolType extends S.Literal("web_search_2025_08_26") {} - -/** - * Web search tool configuration (2025-08-26 version) - */ -export class OpenResponsesWebSearch20250826Tool extends S.Class( - "OpenResponsesWebSearch20250826Tool", -)({ - type: OpenResponsesWebSearch20250826ToolType, - filters: S.optionalWith( - S.Struct({ - allowed_domains: S.optionalWith(S.Array(S.String), { nullable: true }), - }), - { nullable: true }, - ), - search_context_size: S.optionalWith(ResponsesSearchContextSize, { nullable: true }), - user_location: S.optionalWith(ResponsesWebSearchUserLocation, { nullable: true }), -}) {} - -export class OpenAIResponsesToolChoiceEnum extends S.Literal("required") {} - -export class OpenAIResponsesToolChoiceEnumType extends S.Literal("function") {} - -export class OpenAIResponsesToolChoiceEnumTypeEnum extends S.Literal("web_search_preview") {} - -export class OpenAIResponsesToolChoice extends S.Union( - OpenAIResponsesToolChoiceEnum, - OpenAIResponsesToolChoiceEnum, - OpenAIResponsesToolChoiceEnum, - S.Struct({ - type: OpenAIResponsesToolChoiceEnumType, - name: S.String, - }), - S.Struct({ - type: S.Union(OpenAIResponsesToolChoiceEnumTypeEnum, OpenAIResponsesToolChoiceEnumTypeEnum), - }), -) {} - -export class ResponsesFormatTextType extends S.Literal("text") {} - -/** - * Plain text response format - */ -export class ResponsesFormatText extends S.Class("ResponsesFormatText")({ - type: ResponsesFormatTextType, -}) {} - -export class ResponsesFormatJSONObjectType extends S.Literal("json_object") {} - -/** - * JSON object response format - */ -export class ResponsesFormatJSONObject extends S.Class( - "ResponsesFormatJSONObject", -)({ - type: ResponsesFormatJSONObjectType, -}) {} - -export class ResponsesFormatTextJSONSchemaConfigType extends S.Literal("json_schema") {} - -/** - * JSON schema constrained response format - */ -export class ResponsesFormatTextJSONSchemaConfig extends S.Class( - "ResponsesFormatTextJSONSchemaConfig", -)({ - type: ResponsesFormatTextJSONSchemaConfigType, - name: S.String, - description: S.optionalWith(S.String, { nullable: true }), - strict: S.optionalWith(S.Boolean, { nullable: true }), - schema: S.Record({ key: S.String, value: S.Unknown }), -}) {} - -/** - * Text response format configuration - */ -export class ResponseFormatTextConfig extends S.Union( - ResponsesFormatText, - ResponsesFormatJSONObject, - ResponsesFormatTextJSONSchemaConfig, -) {} - -export class OpenResponsesResponseTextVerbosity extends S.Literal("high", "low", "medium") {} - -/** - * Text output configuration including format and verbosity - */ -export class OpenResponsesResponseText extends S.Class( - "OpenResponsesResponseText", -)({ - format: S.optionalWith(ResponseFormatTextConfig, { nullable: true }), - verbosity: S.optionalWith(OpenResponsesResponseTextVerbosity, { nullable: true }), -}) {} - -export class OpenAIResponsesReasoningEffort extends S.Literal( - "xhigh", - "high", - "medium", - "low", - "minimal", - "none", -) {} - -export class ReasoningSummaryVerbosity extends S.Literal("auto", "concise", "detailed") {} - -export class OpenResponsesReasoningConfig extends S.Class( - "OpenResponsesReasoningConfig", -)({ - max_tokens: S.optionalWith(S.Number, { nullable: true }), - enabled: S.optionalWith(S.Boolean, { nullable: true }), - effort: S.optionalWith(OpenAIResponsesReasoningEffort, { nullable: true }), - summary: S.optionalWith(ReasoningSummaryVerbosity, { nullable: true }), -}) {} - -export class ResponsesOutputModality extends S.Literal("text", "image") {} - -export class OpenAIResponsesPrompt extends S.Class("OpenAIResponsesPrompt")({ - id: S.String, - variables: S.optionalWith(S.Record({ key: S.String, value: S.Unknown }), { nullable: true }), -}) {} - -export class OpenAIResponsesIncludable extends S.Literal( - "file_search_call.results", - "message.input_image.image_url", - "computer_call_output.output.image_url", - "reasoning.encrypted_content", - "code_interpreter_call.outputs", -) {} - -export class OpenResponsesRequestServiceTier extends S.Literal("auto") {} - -export class OpenResponsesRequestTruncationEnum extends S.Literal("auto", "disabled") {} - -export class OpenResponsesRequestTruncation extends OpenResponsesRequestTruncationEnum {} - -/** - * Data collection setting. If no available model provider meets the requirement, your request will return an error. - * - allow: (default) allow providers which store user data non-transiently and may train on it - * - * - deny: use only providers which do not collect user data. - */ -export class DataCollection extends S.Literal("deny", "allow") {} - -export class ProviderName extends S.Literal( - "AI21", - "AionLabs", - "Alibaba", - "Amazon Bedrock", - "Amazon Nova", - "Anthropic", - "Arcee AI", - "AtlasCloud", - "Avian", - "Azure", - "BaseTen", - "BytePlus", - "Black Forest Labs", - "Cerebras", - "Chutes", - "Cirrascale", - "Clarifai", - "Cloudflare", - "Cohere", - "Crusoe", - "DeepInfra", - "DeepSeek", - "Featherless", - "Fireworks", - "Friendli", - "GMICloud", - "Google", - "Google AI Studio", - "Groq", - "Hyperbolic", - "Inception", - "Inceptron", - "InferenceNet", - "Infermatic", - "Inflection", - "Liquid", - "Mara", - "Mancer 2", - "Minimax", - "ModelRun", - "Mistral", - "Modular", - "Moonshot AI", - "Morph", - "NCompass", - "Nebius", - "NextBit", - "Novita", - "Nvidia", - "OpenAI", - "OpenInference", - "Parasail", - "Perplexity", - "Phala", - "Relace", - "SambaNova", - "Seed", - "SiliconFlow", - "Sourceful", - "Stealth", - "StreamLake", - "Switchpoint", - "Together", - "Upstage", - "Venice", - "WandB", - "Xiaomi", - "xAI", - "Z.AI", - "FakeProvider", -) {} - -export class Quantization extends S.Literal( - "int4", - "int8", - "fp4", - "fp6", - "fp8", - "fp16", - "bf16", - "fp32", - "unknown", -) {} - -export class ProviderSort extends S.Literal("price", "throughput", "latency") {} - -export class ProviderSortConfigPartitionEnum extends S.Literal("model", "none") {} - -export class ProviderSortConfig extends S.Class("ProviderSortConfig")({ - by: S.optionalWith(ProviderSort, { nullable: true }), - partition: S.optionalWith(ProviderSortConfigPartitionEnum, { nullable: true }), -}) {} - -/** - * A value in string format that is a large number - */ -export class BigNumberUnion extends S.String {} - -/** - * Percentile-based throughput cutoffs. All specified cutoffs must be met for an endpoint to be preferred. - */ -export class PercentileThroughputCutoffs extends S.Class( - "PercentileThroughputCutoffs", -)({ - /** - * Minimum p50 throughput (tokens/sec) - */ - p50: S.optionalWith(S.Number, { nullable: true }), - /** - * Minimum p75 throughput (tokens/sec) - */ - p75: S.optionalWith(S.Number, { nullable: true }), - /** - * Minimum p90 throughput (tokens/sec) - */ - p90: S.optionalWith(S.Number, { nullable: true }), - /** - * Minimum p99 throughput (tokens/sec) - */ - p99: S.optionalWith(S.Number, { nullable: true }), -}) {} - -/** - * Preferred minimum throughput (in tokens per second). Can be a number (applies to p50) or an object with percentile-specific cutoffs. Endpoints below the threshold(s) may still be used, but are deprioritized in routing. When using fallback models, this may cause a fallback model to be used instead of the primary model if it meets the threshold. - */ -export class PreferredMinThroughput extends S.Union(S.Number, PercentileThroughputCutoffs) {} - -/** - * Percentile-based latency cutoffs. All specified cutoffs must be met for an endpoint to be preferred. - */ -export class PercentileLatencyCutoffs extends S.Class("PercentileLatencyCutoffs")({ - /** - * Maximum p50 latency (seconds) - */ - p50: S.optionalWith(S.Number, { nullable: true }), - /** - * Maximum p75 latency (seconds) - */ - p75: S.optionalWith(S.Number, { nullable: true }), - /** - * Maximum p90 latency (seconds) - */ - p90: S.optionalWith(S.Number, { nullable: true }), - /** - * Maximum p99 latency (seconds) - */ - p99: S.optionalWith(S.Number, { nullable: true }), -}) {} - -/** - * Preferred maximum latency (in seconds). Can be a number (applies to p50) or an object with percentile-specific cutoffs. Endpoints above the threshold(s) may still be used, but are deprioritized in routing. When using fallback models, this may cause a fallback model to be used instead of the primary model if it meets the threshold. - */ -export class PreferredMaxLatency extends S.Union(S.Number, PercentileLatencyCutoffs) {} - -/** - * The search engine to use for web search. - */ -export class WebSearchEngine extends S.Literal("native", "exa") {} - -/** - * The engine to use for parsing PDF files. - */ -export class PDFParserEngine extends S.Literal("mistral-ocr", "pdf-text", "native") {} - -/** - * Options for PDF parsing. - */ -export class PDFParserOptions extends S.Class("PDFParserOptions")({ - engine: S.optionalWith(PDFParserEngine, { nullable: true }), -}) {} - -/** - * **DEPRECATED** Use providers.sort.partition instead. Backwards-compatible alias for providers.sort.partition. Accepts legacy values: "fallback" (maps to "model"), "sort" (maps to "none"). - */ -export class OpenResponsesRequestRoute extends S.Literal("fallback", "sort") {} - -/** - * Request schema for Responses endpoint - */ -export class OpenResponsesRequest extends S.Class("OpenResponsesRequest")({ - input: S.optionalWith(OpenResponsesInput, { nullable: true }), - instructions: S.optionalWith(S.String, { nullable: true }), - metadata: S.optionalWith(OpenResponsesRequestMetadata, { nullable: true }), - tools: S.optionalWith( - S.Array( - S.Union( - /** - * Function tool definition - */ - S.Struct({}), - OpenResponsesWebSearchPreviewTool, - OpenResponsesWebSearchPreview20250311Tool, - OpenResponsesWebSearchTool, - OpenResponsesWebSearch20250826Tool, - ), - ), - { nullable: true }, - ), - tool_choice: S.optionalWith(OpenAIResponsesToolChoice, { nullable: true }), - parallel_tool_calls: S.optionalWith(S.Boolean, { nullable: true }), - model: S.optionalWith(S.String, { nullable: true }), - models: S.optionalWith(S.Array(S.String), { nullable: true }), - text: S.optionalWith(OpenResponsesResponseText, { nullable: true }), - reasoning: S.optionalWith(OpenResponsesReasoningConfig, { nullable: true }), - max_output_tokens: S.optionalWith(S.Number, { nullable: true }), - temperature: S.optionalWith(S.Number.pipe(S.greaterThanOrEqualTo(0), S.lessThanOrEqualTo(2)), { - nullable: true, - }), - top_p: S.optionalWith(S.Number.pipe(S.greaterThanOrEqualTo(0)), { nullable: true }), - top_logprobs: S.optionalWith(S.Int.pipe(S.greaterThanOrEqualTo(0), S.lessThanOrEqualTo(20)), { - nullable: true, - }), - max_tool_calls: S.optionalWith(S.Int, { nullable: true }), - presence_penalty: S.optionalWith(S.Number.pipe(S.greaterThanOrEqualTo(-2), S.lessThanOrEqualTo(2)), { - nullable: true, - }), - frequency_penalty: S.optionalWith(S.Number.pipe(S.greaterThanOrEqualTo(-2), S.lessThanOrEqualTo(2)), { - nullable: true, - }), - top_k: S.optionalWith(S.Number, { nullable: true }), - /** - * Provider-specific image configuration options. Keys and values vary by model/provider. See https://openrouter.ai/docs/features/multimodal/image-generation for more details. - */ - image_config: S.optionalWith(S.Record({ key: S.String, value: S.Unknown }), { nullable: true }), - /** - * Output modalities for the response. Supported values are "text" and "image". - */ - modalities: S.optionalWith(S.Array(ResponsesOutputModality), { nullable: true }), - prompt_cache_key: S.optionalWith(S.String, { nullable: true }), - previous_response_id: S.optionalWith(S.String, { nullable: true }), - prompt: S.optionalWith(OpenAIResponsesPrompt, { nullable: true }), - include: S.optionalWith(S.Array(OpenAIResponsesIncludable), { nullable: true }), - background: S.optionalWith(S.Boolean, { nullable: true }), - safety_identifier: S.optionalWith(S.String, { nullable: true }), - store: S.optionalWith(S.Literal(false), { nullable: true, default: () => false as const }), - service_tier: S.optionalWith(OpenResponsesRequestServiceTier, { - nullable: true, - default: () => "auto" as const, - }), - truncation: S.optionalWith(OpenResponsesRequestTruncation, { nullable: true }), - stream: S.optionalWith(S.Boolean, { nullable: true, default: () => false as const }), - /** - * When multiple model providers are available, optionally indicate your routing preference. - */ - provider: S.optionalWith( - S.Struct({ - /** - * Whether to allow backup providers to serve requests - * - true: (default) when the primary provider (or your custom providers in "order") is unavailable, use the next best provider. - * - false: use only the primary/custom provider, and return the upstream error if it's unavailable. - */ - allow_fallbacks: S.optionalWith(S.Boolean, { nullable: true }), - /** - * Whether to filter providers to only those that support the parameters you've provided. If this setting is omitted or set to false, then providers will receive only the parameters they support, and ignore the rest. - */ - require_parameters: S.optionalWith(S.Boolean, { nullable: true }), - data_collection: S.optionalWith(DataCollection, { nullable: true }), - /** - * Whether to restrict routing to only ZDR (Zero Data Retention) endpoints. When true, only endpoints that do not retain prompts will be used. - */ - zdr: S.optionalWith(S.Boolean, { nullable: true }), - /** - * Whether to restrict routing to only models that allow text distillation. When true, only models where the author has allowed distillation will be used. - */ - enforce_distillable_text: S.optionalWith(S.Boolean, { nullable: true }), - /** - * An ordered list of provider slugs. The router will attempt to use the first provider in the subset of this list that supports your requested model, and fall back to the next if it is unavailable. If no providers are available, the request will fail with an error message. - */ - order: S.optionalWith(S.Array(S.Union(ProviderName, S.String)), { nullable: true }), - /** - * List of provider slugs to allow. If provided, this list is merged with your account-wide allowed provider settings for this request. - */ - only: S.optionalWith(S.Array(S.Union(ProviderName, S.String)), { nullable: true }), - /** - * List of provider slugs to ignore. If provided, this list is merged with your account-wide ignored provider settings for this request. - */ - ignore: S.optionalWith(S.Array(S.Union(ProviderName, S.String)), { nullable: true }), - /** - * A list of quantization levels to filter the provider by. - */ - quantizations: S.optionalWith(S.Array(Quantization), { nullable: true }), - /** - * The sorting strategy to use for this request, if "order" is not specified. When set, no load balancing is performed. - */ - sort: S.optionalWith(S.Union(ProviderSort, ProviderSortConfig), { nullable: true }), - /** - * The object specifying the maximum price you want to pay for this request. USD price per million tokens, for prompt and completion. - */ - max_price: S.optionalWith( - S.Struct({ - prompt: S.optionalWith(BigNumberUnion, { nullable: true }), - completion: S.optionalWith(BigNumberUnion, { nullable: true }), - image: S.optionalWith(BigNumberUnion, { nullable: true }), - audio: S.optionalWith(BigNumberUnion, { nullable: true }), - request: S.optionalWith(BigNumberUnion, { nullable: true }), - }), - { nullable: true }, - ), - preferred_min_throughput: S.optionalWith(PreferredMinThroughput, { nullable: true }), - preferred_max_latency: S.optionalWith(PreferredMaxLatency, { nullable: true }), - }), - { nullable: true }, - ), - /** - * Plugins you want to enable for this request, including their settings. - */ - plugins: S.optionalWith( - S.Array( - S.Union( - S.Struct({ - id: S.Literal("auto-router"), - /** - * Set to false to disable the auto-router plugin for this request. Defaults to true. - */ - enabled: S.optionalWith(S.Boolean, { nullable: true }), - /** - * List of model patterns to filter which models the auto-router can route between. Supports wildcards (e.g., "anthropic/*" matches all Anthropic models). When not specified, uses the default supported models list. - */ - allowed_models: S.optionalWith(S.Array(S.String), { nullable: true }), - }), - S.Struct({ - id: S.Literal("moderation"), - }), - S.Struct({ - id: S.Literal("web"), - /** - * Set to false to disable the web-search plugin for this request. Defaults to true. - */ - enabled: S.optionalWith(S.Boolean, { nullable: true }), - max_results: S.optionalWith(S.Number, { nullable: true }), - search_prompt: S.optionalWith(S.String, { nullable: true }), - engine: S.optionalWith(WebSearchEngine, { nullable: true }), - }), - S.Struct({ - id: S.Literal("file-parser"), - /** - * Set to false to disable the file-parser plugin for this request. Defaults to true. - */ - enabled: S.optionalWith(S.Boolean, { nullable: true }), - pdf: S.optionalWith(PDFParserOptions, { nullable: true }), - }), - S.Struct({ - id: S.Literal("response-healing"), - /** - * Set to false to disable the response-healing plugin for this request. Defaults to true. - */ - enabled: S.optionalWith(S.Boolean, { nullable: true }), - }), - ), - ), - { nullable: true }, - ), - /** - * **DEPRECATED** Use providers.sort.partition instead. Backwards-compatible alias for providers.sort.partition. Accepts legacy values: "fallback" (maps to "model"), "sort" (maps to "none"). - */ - route: S.optionalWith(OpenResponsesRequestRoute, { nullable: true }), - /** - * A unique identifier representing your end-user, which helps distinguish between different users of your app. This allows your app to identify specific users in case of abuse reports, preventing your entire app from being affected by the actions of individual users. Maximum of 128 characters. - */ - user: S.optionalWith(S.String.pipe(S.maxLength(128)), { nullable: true }), - /** - * A unique identifier for grouping related requests (e.g., a conversation or agent workflow) for observability. If provided in both the request body and the x-session-id header, the body value takes precedence. Maximum of 128 characters. - */ - session_id: S.optionalWith(S.String.pipe(S.maxLength(128)), { nullable: true }), -}) {} - -export class OutputMessageRole extends S.Literal("assistant") {} - -export class OutputMessageType extends S.Literal("message") {} - -export class OutputMessageStatusEnum extends S.Literal("in_progress") {} - -export class OutputMessage extends S.Class("OutputMessage")({ - id: S.String, - role: OutputMessageRole, - type: OutputMessageType, - status: S.optionalWith( - S.Union(OutputMessageStatusEnum, OutputMessageStatusEnum, OutputMessageStatusEnum), - { - nullable: true, - }, - ), - content: S.Array(S.Union(ResponseOutputText, OpenAIResponsesRefusalContent)), -}) {} - -export class OutputItemReasoningType extends S.Literal("reasoning") {} - -export class OutputItemReasoningStatusEnum extends S.Literal("in_progress") {} - -export class OutputItemReasoning extends S.Class("OutputItemReasoning")({ - type: OutputItemReasoningType, - id: S.String, - content: S.optionalWith(S.Array(ReasoningTextContent), { nullable: true }), - summary: S.Array(ReasoningSummaryText), - encrypted_content: S.optionalWith(S.String, { nullable: true }), - status: S.optionalWith( - S.Union(OutputItemReasoningStatusEnum, OutputItemReasoningStatusEnum, OutputItemReasoningStatusEnum), - { nullable: true }, - ), -}) {} - -export class OutputItemFunctionCallType extends S.Literal("function_call") {} - -export class OutputItemFunctionCallStatusEnum extends S.Literal("in_progress") {} - -export class OutputItemFunctionCall extends S.Class("OutputItemFunctionCall")({ - type: OutputItemFunctionCallType, - id: S.optionalWith(S.String, { nullable: true }), - name: S.String, - arguments: S.String, - call_id: S.String, - status: S.optionalWith( - S.Union( - OutputItemFunctionCallStatusEnum, - OutputItemFunctionCallStatusEnum, - OutputItemFunctionCallStatusEnum, - ), - { nullable: true }, - ), -}) {} - -export class OutputItemWebSearchCallType extends S.Literal("web_search_call") {} - -export class OutputItemWebSearchCall extends S.Class("OutputItemWebSearchCall")({ - type: OutputItemWebSearchCallType, - id: S.String, - status: WebSearchStatus, -}) {} - -export class OutputItemFileSearchCallType extends S.Literal("file_search_call") {} - -export class OutputItemFileSearchCall extends S.Class("OutputItemFileSearchCall")({ - type: OutputItemFileSearchCallType, - id: S.String, - queries: S.Array(S.String), - status: WebSearchStatus, -}) {} - -export class OutputItemImageGenerationCallType extends S.Literal("image_generation_call") {} - -export class OutputItemImageGenerationCall extends S.Class( - "OutputItemImageGenerationCall", -)({ - type: OutputItemImageGenerationCallType, - id: S.String, - result: S.optionalWith(S.NullOr(S.String), { default: () => null }), - status: ImageGenerationStatus, -}) {} - -export class OpenAIResponsesUsage extends S.Class("OpenAIResponsesUsage")({ - input_tokens: S.Number, - input_tokens_details: S.Struct({ - cached_tokens: S.Number, - }), - output_tokens: S.Number, - output_tokens_details: S.Struct({ - reasoning_tokens: S.Number, - }), - total_tokens: S.Number, -}) {} - -export class OpenResponsesNonStreamingResponseObject extends S.Literal("response") {} - -export class OpenAIResponsesResponseStatus extends S.Literal( - "completed", - "incomplete", - "in_progress", - "failed", - "cancelled", - "queued", -) {} - -export class ResponsesErrorFieldCode extends S.Literal( - "server_error", - "rate_limit_exceeded", - "invalid_prompt", - "vector_store_timeout", - "invalid_image", - "invalid_image_format", - "invalid_base64_image", - "invalid_image_url", - "image_too_large", - "image_too_small", - "image_parse_error", - "image_content_policy_violation", - "invalid_image_mode", - "image_file_too_large", - "unsupported_image_media_type", - "empty_image_file", - "failed_to_download_image", - "image_file_not_found", -) {} - -/** - * Error information returned from the API - */ -export class ResponsesErrorField extends S.Class("ResponsesErrorField")({ - code: ResponsesErrorFieldCode, - message: S.String, -}) {} - -export class OpenAIResponsesIncompleteDetailsReason extends S.Literal( - "max_output_tokens", - "content_filter", -) {} - -export class OpenAIResponsesIncompleteDetails extends S.Class( - "OpenAIResponsesIncompleteDetails", -)({ - reason: S.optionalWith(OpenAIResponsesIncompleteDetailsReason, { nullable: true }), -}) {} - -export class ResponseInputImageType extends S.Literal("input_image") {} - -export class ResponseInputImageDetail extends S.Literal("auto", "high", "low") {} - -/** - * Image input content item - */ -export class ResponseInputImage extends S.Class("ResponseInputImage")({ - type: ResponseInputImageType, - detail: ResponseInputImageDetail, - image_url: S.optionalWith(S.String, { nullable: true }), -}) {} - -export class OpenAIResponsesInput extends S.Union( - S.String, - S.Array( - S.Union( - S.Struct({ - type: S.optionalWith(S.Literal("message"), { nullable: true }), - role: S.Union( - S.Literal("user"), - S.Literal("system"), - S.Literal("assistant"), - S.Literal("developer"), - ), - content: S.Union( - S.Array( - S.Union(ResponseInputText, ResponseInputImage, ResponseInputFile, ResponseInputAudio), - ), - S.String, - ), - }), - S.Struct({ - id: S.String, - type: S.optionalWith(S.Literal("message"), { nullable: true }), - role: S.Union(S.Literal("user"), S.Literal("system"), S.Literal("developer")), - content: S.Array( - S.Union(ResponseInputText, ResponseInputImage, ResponseInputFile, ResponseInputAudio), - ), - }), - S.Struct({ - type: S.Literal("function_call_output"), - id: S.optionalWith(S.String, { nullable: true }), - call_id: S.String, - output: S.String, - status: S.optionalWith(ToolCallStatus, { nullable: true }), - }), - S.Struct({ - type: S.Literal("function_call"), - call_id: S.String, - name: S.String, - arguments: S.String, - id: S.optionalWith(S.String, { nullable: true }), - status: S.optionalWith(ToolCallStatus, { nullable: true }), - }), - OutputItemImageGenerationCall, - OutputMessage, - ), - ), -) {} - -export class OpenAIResponsesReasoningConfig extends S.Class( - "OpenAIResponsesReasoningConfig", -)({ - effort: S.optionalWith(OpenAIResponsesReasoningEffort, { nullable: true }), - summary: S.optionalWith(ReasoningSummaryVerbosity, { nullable: true }), -}) {} - -export class OpenAIResponsesServiceTier extends S.Literal("auto", "default", "flex", "priority", "scale") {} - -export class OpenAIResponsesTruncation extends S.Literal("auto", "disabled") {} - -export class ResponseTextConfigVerbosity extends S.Literal("high", "low", "medium") {} - -/** - * Text output configuration including format and verbosity - */ -export class ResponseTextConfig extends S.Class("ResponseTextConfig")({ - format: S.optionalWith(ResponseFormatTextConfig, { nullable: true }), - verbosity: S.optionalWith(ResponseTextConfigVerbosity, { nullable: true }), -}) {} - -export class OpenResponsesNonStreamingResponse extends S.Class( - "OpenResponsesNonStreamingResponse", -)({ - output: S.Array( - S.Union( - OutputMessage, - OutputItemReasoning, - OutputItemFunctionCall, - OutputItemWebSearchCall, - OutputItemFileSearchCall, - OutputItemImageGenerationCall, - ), - ), - usage: S.optionalWith(OpenAIResponsesUsage, { nullable: true }), - id: S.String, - object: OpenResponsesNonStreamingResponseObject, - created_at: S.Number, - model: S.String, - status: OpenAIResponsesResponseStatus, - completed_at: S.NullOr(S.Number), - user: S.optionalWith(S.String, { nullable: true }), - output_text: S.optionalWith(S.String, { nullable: true }), - prompt_cache_key: S.optionalWith(S.String, { nullable: true }), - safety_identifier: S.optionalWith(S.String, { nullable: true }), - error: S.NullOr(ResponsesErrorField), - incomplete_details: S.NullOr(OpenAIResponsesIncompleteDetails), - max_tool_calls: S.optionalWith(S.Number, { nullable: true }), - top_logprobs: S.optionalWith(S.Number, { nullable: true }), - max_output_tokens: S.optionalWith(S.Number, { nullable: true }), - temperature: S.NullOr(S.Number), - top_p: S.NullOr(S.Number), - presence_penalty: S.NullOr(S.Number), - frequency_penalty: S.NullOr(S.Number), - instructions: OpenAIResponsesInput, - metadata: S.NullOr(OpenResponsesRequestMetadata), - tools: S.Array( - S.Union( - /** - * Function tool definition - */ - S.Struct({}), - OpenResponsesWebSearchPreviewTool, - OpenResponsesWebSearchPreview20250311Tool, - OpenResponsesWebSearchTool, - OpenResponsesWebSearch20250826Tool, - ), - ), - tool_choice: OpenAIResponsesToolChoice, - parallel_tool_calls: S.Boolean, - prompt: S.optionalWith(OpenAIResponsesPrompt, { nullable: true }), - background: S.optionalWith(S.Boolean, { nullable: true }), - previous_response_id: S.optionalWith(S.String, { nullable: true }), - reasoning: S.optionalWith(OpenAIResponsesReasoningConfig, { nullable: true }), - service_tier: S.optionalWith(OpenAIResponsesServiceTier, { nullable: true }), - store: S.optionalWith(S.Boolean, { nullable: true }), - truncation: S.optionalWith(OpenAIResponsesTruncation, { nullable: true }), - text: S.optionalWith(ResponseTextConfig, { nullable: true }), -}) {} - -/** - * Error data for BadRequestResponse - */ -export class BadRequestResponseErrorData extends S.Class( - "BadRequestResponseErrorData", -)({ - code: S.Int, - message: S.String, - metadata: S.optionalWith(S.Record({ key: S.String, value: S.Unknown }), { nullable: true }), -}) {} - -/** - * Bad Request - Invalid request parameters or malformed input - */ -export class BadRequestResponse extends S.Class("BadRequestResponse")({ - error: BadRequestResponseErrorData, - user_id: S.optionalWith(S.String, { nullable: true }), -}) {} - -/** - * Error data for UnauthorizedResponse - */ -export class UnauthorizedResponseErrorData extends S.Class( - "UnauthorizedResponseErrorData", -)({ - code: S.Int, - message: S.String, - metadata: S.optionalWith(S.Record({ key: S.String, value: S.Unknown }), { nullable: true }), -}) {} - -/** - * Unauthorized - Authentication required or invalid credentials - */ -export class UnauthorizedResponse extends S.Class("UnauthorizedResponse")({ - error: UnauthorizedResponseErrorData, - user_id: S.optionalWith(S.String, { nullable: true }), -}) {} - -/** - * Error data for PaymentRequiredResponse - */ -export class PaymentRequiredResponseErrorData extends S.Class( - "PaymentRequiredResponseErrorData", -)({ - code: S.Int, - message: S.String, - metadata: S.optionalWith(S.Record({ key: S.String, value: S.Unknown }), { nullable: true }), -}) {} - -/** - * Payment Required - Insufficient credits or quota to complete request - */ -export class PaymentRequiredResponse extends S.Class("PaymentRequiredResponse")({ - error: PaymentRequiredResponseErrorData, - user_id: S.optionalWith(S.String, { nullable: true }), -}) {} - -/** - * Error data for NotFoundResponse - */ -export class NotFoundResponseErrorData extends S.Class( - "NotFoundResponseErrorData", -)({ - code: S.Int, - message: S.String, - metadata: S.optionalWith(S.Record({ key: S.String, value: S.Unknown }), { nullable: true }), -}) {} - -/** - * Not Found - Resource does not exist - */ -export class NotFoundResponse extends S.Class("NotFoundResponse")({ - error: NotFoundResponseErrorData, - user_id: S.optionalWith(S.String, { nullable: true }), -}) {} - -/** - * Error data for RequestTimeoutResponse - */ -export class RequestTimeoutResponseErrorData extends S.Class( - "RequestTimeoutResponseErrorData", -)({ - code: S.Int, - message: S.String, - metadata: S.optionalWith(S.Record({ key: S.String, value: S.Unknown }), { nullable: true }), -}) {} - -/** - * Request Timeout - Operation exceeded time limit - */ -export class RequestTimeoutResponse extends S.Class("RequestTimeoutResponse")({ - error: RequestTimeoutResponseErrorData, - user_id: S.optionalWith(S.String, { nullable: true }), -}) {} - -/** - * Error data for PayloadTooLargeResponse - */ -export class PayloadTooLargeResponseErrorData extends S.Class( - "PayloadTooLargeResponseErrorData", -)({ - code: S.Int, - message: S.String, - metadata: S.optionalWith(S.Record({ key: S.String, value: S.Unknown }), { nullable: true }), -}) {} - -/** - * Payload Too Large - Request payload exceeds size limits - */ -export class PayloadTooLargeResponse extends S.Class("PayloadTooLargeResponse")({ - error: PayloadTooLargeResponseErrorData, - user_id: S.optionalWith(S.String, { nullable: true }), -}) {} - -/** - * Error data for UnprocessableEntityResponse - */ -export class UnprocessableEntityResponseErrorData extends S.Class( - "UnprocessableEntityResponseErrorData", -)({ - code: S.Int, - message: S.String, - metadata: S.optionalWith(S.Record({ key: S.String, value: S.Unknown }), { nullable: true }), -}) {} - -/** - * Unprocessable Entity - Semantic validation failure - */ -export class UnprocessableEntityResponse extends S.Class( - "UnprocessableEntityResponse", -)({ - error: UnprocessableEntityResponseErrorData, - user_id: S.optionalWith(S.String, { nullable: true }), -}) {} - -/** - * Error data for TooManyRequestsResponse - */ -export class TooManyRequestsResponseErrorData extends S.Class( - "TooManyRequestsResponseErrorData", -)({ - code: S.Int, - message: S.String, - metadata: S.optionalWith(S.Record({ key: S.String, value: S.Unknown }), { nullable: true }), -}) {} - -/** - * Too Many Requests - Rate limit exceeded - */ -export class TooManyRequestsResponse extends S.Class("TooManyRequestsResponse")({ - error: TooManyRequestsResponseErrorData, - user_id: S.optionalWith(S.String, { nullable: true }), -}) {} - -/** - * Error data for InternalServerResponse - */ -export class InternalServerResponseErrorData extends S.Class( - "InternalServerResponseErrorData", -)({ - code: S.Int, - message: S.String, - metadata: S.optionalWith(S.Record({ key: S.String, value: S.Unknown }), { nullable: true }), -}) {} - -/** - * Internal Server Error - Unexpected server error - */ -export class InternalServerResponse extends S.Class("InternalServerResponse")({ - error: InternalServerResponseErrorData, - user_id: S.optionalWith(S.String, { nullable: true }), -}) {} - -/** - * Error data for BadGatewayResponse - */ -export class BadGatewayResponseErrorData extends S.Class( - "BadGatewayResponseErrorData", -)({ - code: S.Int, - message: S.String, - metadata: S.optionalWith(S.Record({ key: S.String, value: S.Unknown }), { nullable: true }), -}) {} - -/** - * Bad Gateway - Provider/upstream API failure - */ -export class BadGatewayResponse extends S.Class("BadGatewayResponse")({ - error: BadGatewayResponseErrorData, - user_id: S.optionalWith(S.String, { nullable: true }), -}) {} - -/** - * Error data for ServiceUnavailableResponse - */ -export class ServiceUnavailableResponseErrorData extends S.Class( - "ServiceUnavailableResponseErrorData", -)({ - code: S.Int, - message: S.String, - metadata: S.optionalWith(S.Record({ key: S.String, value: S.Unknown }), { nullable: true }), -}) {} - -/** - * Service Unavailable - Service temporarily unavailable - */ -export class ServiceUnavailableResponse extends S.Class( - "ServiceUnavailableResponse", -)({ - error: ServiceUnavailableResponseErrorData, - user_id: S.optionalWith(S.String, { nullable: true }), -}) {} - -/** - * Error data for EdgeNetworkTimeoutResponse - */ -export class EdgeNetworkTimeoutResponseErrorData extends S.Class( - "EdgeNetworkTimeoutResponseErrorData", -)({ - code: S.Int, - message: S.String, - metadata: S.optionalWith(S.Record({ key: S.String, value: S.Unknown }), { nullable: true }), -}) {} - -/** - * Infrastructure Timeout - Provider request timed out at edge network - */ -export class EdgeNetworkTimeoutResponse extends S.Class( - "EdgeNetworkTimeoutResponse", -)({ - error: EdgeNetworkTimeoutResponseErrorData, - user_id: S.optionalWith(S.String, { nullable: true }), -}) {} - -/** - * Error data for ProviderOverloadedResponse - */ -export class ProviderOverloadedResponseErrorData extends S.Class( - "ProviderOverloadedResponseErrorData", -)({ - code: S.Int, - message: S.String, - metadata: S.optionalWith(S.Record({ key: S.String, value: S.Unknown }), { nullable: true }), -}) {} - -/** - * Provider Overloaded - Provider is temporarily overloaded - */ -export class ProviderOverloadedResponse extends S.Class( - "ProviderOverloadedResponse", -)({ - error: ProviderOverloadedResponseErrorData, - user_id: S.optionalWith(S.String, { nullable: true }), -}) {} - -export class OpenRouterAnthropicMessageParamRole extends S.Literal("user", "assistant") {} - -/** - * Anthropic message with OpenRouter extensions - */ -export class OpenRouterAnthropicMessageParam extends S.Class( - "OpenRouterAnthropicMessageParam", -)({ - role: OpenRouterAnthropicMessageParamRole, - content: S.Union( - S.String, - S.Array( - S.Union( - S.Struct({ - type: S.Literal("text"), - text: S.String, - citations: S.optionalWith( - S.Array( - S.Union( - S.Struct({ - type: S.Literal("char_location"), - cited_text: S.String, - document_index: S.Number, - document_title: S.NullOr(S.String), - start_char_index: S.Number, - end_char_index: S.Number, - }), - S.Struct({ - type: S.Literal("page_location"), - cited_text: S.String, - document_index: S.Number, - document_title: S.NullOr(S.String), - start_page_number: S.Number, - end_page_number: S.Number, - }), - S.Struct({ - type: S.Literal("content_block_location"), - cited_text: S.String, - document_index: S.Number, - document_title: S.NullOr(S.String), - start_block_index: S.Number, - end_block_index: S.Number, - }), - S.Struct({ - type: S.Literal("web_search_result_location"), - cited_text: S.String, - encrypted_index: S.String, - title: S.NullOr(S.String), - url: S.String, - }), - S.Struct({ - type: S.Literal("search_result_location"), - cited_text: S.String, - search_result_index: S.Number, - source: S.String, - title: S.NullOr(S.String), - start_block_index: S.Number, - end_block_index: S.Number, - }), - ), - ), - { nullable: true }, - ), - cache_control: S.optionalWith( - S.Struct({ - type: S.Literal("ephemeral"), - ttl: S.optionalWith(S.Literal("5m", "1h"), { nullable: true }), - }), - { nullable: true }, - ), - }), - S.Struct({ - type: S.Literal("image"), - source: S.Union( - S.Struct({ - type: S.Literal("base64"), - media_type: S.Literal("image/jpeg", "image/png", "image/gif", "image/webp"), - data: S.String, - }), - S.Struct({ - type: S.Literal("url"), - url: S.String, - }), - ), - cache_control: S.optionalWith( - S.Struct({ - type: S.Literal("ephemeral"), - ttl: S.optionalWith(S.Literal("5m", "1h"), { nullable: true }), - }), - { nullable: true }, - ), - }), - S.Struct({ - type: S.Literal("document"), - source: S.Union( - S.Struct({ - type: S.Literal("base64"), - media_type: S.Literal("application/pdf"), - data: S.String, - }), - S.Struct({ - type: S.Literal("text"), - media_type: S.Literal("text/plain"), - data: S.String, - }), - S.Struct({ - type: S.Literal("content"), - content: S.Union( - S.String, - S.Array( - S.Union( - S.Struct({ - type: S.Literal("text"), - text: S.String, - citations: S.optionalWith( - S.Array( - S.Union( - S.Struct({ - type: S.Literal("char_location"), - cited_text: S.String, - document_index: S.Number, - document_title: S.NullOr(S.String), - start_char_index: S.Number, - end_char_index: S.Number, - }), - S.Struct({ - type: S.Literal("page_location"), - cited_text: S.String, - document_index: S.Number, - document_title: S.NullOr(S.String), - start_page_number: S.Number, - end_page_number: S.Number, - }), - S.Struct({ - type: S.Literal("content_block_location"), - cited_text: S.String, - document_index: S.Number, - document_title: S.NullOr(S.String), - start_block_index: S.Number, - end_block_index: S.Number, - }), - S.Struct({ - type: S.Literal("web_search_result_location"), - cited_text: S.String, - encrypted_index: S.String, - title: S.NullOr(S.String), - url: S.String, - }), - S.Struct({ - type: S.Literal("search_result_location"), - cited_text: S.String, - search_result_index: S.Number, - source: S.String, - title: S.NullOr(S.String), - start_block_index: S.Number, - end_block_index: S.Number, - }), - ), - ), - { nullable: true }, - ), - cache_control: S.optionalWith( - S.Struct({ - type: S.Literal("ephemeral"), - ttl: S.optionalWith(S.Literal("5m", "1h"), { - nullable: true, - }), - }), - { nullable: true }, - ), - }), - S.Struct({ - type: S.Literal("image"), - source: S.Union( - S.Struct({ - type: S.Literal("base64"), - media_type: S.Literal( - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - ), - data: S.String, - }), - S.Struct({ - type: S.Literal("url"), - url: S.String, - }), - ), - cache_control: S.optionalWith( - S.Struct({ - type: S.Literal("ephemeral"), - ttl: S.optionalWith(S.Literal("5m", "1h"), { - nullable: true, - }), - }), - { nullable: true }, - ), - }), - ), - ), - ), - }), - S.Struct({ - type: S.Literal("url"), - url: S.String, - }), - ), - citations: S.optionalWith( - S.Struct({ - enabled: S.optionalWith(S.Boolean, { nullable: true }), - }), - { nullable: true }, - ), - context: S.optionalWith(S.String, { nullable: true }), - title: S.optionalWith(S.String, { nullable: true }), - cache_control: S.optionalWith( - S.Struct({ - type: S.Literal("ephemeral"), - ttl: S.optionalWith(S.Literal("5m", "1h"), { nullable: true }), - }), - { nullable: true }, - ), - }), - S.Struct({ - type: S.Literal("tool_use"), - id: S.String, - name: S.String, - cache_control: S.optionalWith( - S.Struct({ - type: S.Literal("ephemeral"), - ttl: S.optionalWith(S.Literal("5m", "1h"), { nullable: true }), - }), - { nullable: true }, - ), - }), - S.Struct({ - type: S.Literal("tool_result"), - tool_use_id: S.String, - content: S.optionalWith( - S.Union( - S.String, - S.Array( - S.Union( - S.Struct({ - type: S.Literal("text"), - text: S.String, - citations: S.optionalWith( - S.Array( - S.Union( - S.Struct({ - type: S.Literal("char_location"), - cited_text: S.String, - document_index: S.Number, - document_title: S.NullOr(S.String), - start_char_index: S.Number, - end_char_index: S.Number, - }), - S.Struct({ - type: S.Literal("page_location"), - cited_text: S.String, - document_index: S.Number, - document_title: S.NullOr(S.String), - start_page_number: S.Number, - end_page_number: S.Number, - }), - S.Struct({ - type: S.Literal("content_block_location"), - cited_text: S.String, - document_index: S.Number, - document_title: S.NullOr(S.String), - start_block_index: S.Number, - end_block_index: S.Number, - }), - S.Struct({ - type: S.Literal("web_search_result_location"), - cited_text: S.String, - encrypted_index: S.String, - title: S.NullOr(S.String), - url: S.String, - }), - S.Struct({ - type: S.Literal("search_result_location"), - cited_text: S.String, - search_result_index: S.Number, - source: S.String, - title: S.NullOr(S.String), - start_block_index: S.Number, - end_block_index: S.Number, - }), - ), - ), - { nullable: true }, - ), - cache_control: S.optionalWith( - S.Struct({ - type: S.Literal("ephemeral"), - ttl: S.optionalWith(S.Literal("5m", "1h"), { - nullable: true, - }), - }), - { nullable: true }, - ), - }), - S.Struct({ - type: S.Literal("image"), - source: S.Union( - S.Struct({ - type: S.Literal("base64"), - media_type: S.Literal( - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - ), - data: S.String, - }), - S.Struct({ - type: S.Literal("url"), - url: S.String, - }), - ), - cache_control: S.optionalWith( - S.Struct({ - type: S.Literal("ephemeral"), - ttl: S.optionalWith(S.Literal("5m", "1h"), { - nullable: true, - }), - }), - { nullable: true }, - ), - }), - ), - ), - ), - { nullable: true }, - ), - is_error: S.optionalWith(S.Boolean, { nullable: true }), - cache_control: S.optionalWith( - S.Struct({ - type: S.Literal("ephemeral"), - ttl: S.optionalWith(S.Literal("5m", "1h"), { nullable: true }), - }), - { nullable: true }, - ), - }), - S.Struct({ - type: S.Literal("thinking"), - thinking: S.String, - signature: S.String, - }), - S.Struct({ - type: S.Literal("redacted_thinking"), - data: S.String, - }), - S.Struct({ - type: S.Literal("server_tool_use"), - id: S.String, - name: S.Literal("web_search"), - cache_control: S.optionalWith( - S.Struct({ - type: S.Literal("ephemeral"), - ttl: S.optionalWith(S.Literal("5m", "1h"), { nullable: true }), - }), - { nullable: true }, - ), - }), - S.Struct({ - type: S.Literal("web_search_tool_result"), - tool_use_id: S.String, - content: S.Union( - S.Array( - S.Struct({ - type: S.Literal("web_search_result"), - encrypted_content: S.String, - title: S.String, - url: S.String, - page_age: S.optionalWith(S.String, { nullable: true }), - }), - ), - S.Struct({ - type: S.Literal("web_search_tool_result_error"), - error_code: S.Literal( - "invalid_tool_input", - "unavailable", - "max_uses_exceeded", - "too_many_requests", - "query_too_long", - ), - }), - ), - cache_control: S.optionalWith( - S.Struct({ - type: S.Literal("ephemeral"), - ttl: S.optionalWith(S.Literal("5m", "1h"), { nullable: true }), - }), - { nullable: true }, - ), - }), - S.Struct({ - type: S.Literal("search_result"), - source: S.String, - title: S.String, - content: S.Array( - S.Struct({ - type: S.Literal("text"), - text: S.String, - citations: S.optionalWith( - S.Array( - S.Union( - S.Struct({ - type: S.Literal("char_location"), - cited_text: S.String, - document_index: S.Number, - document_title: S.NullOr(S.String), - start_char_index: S.Number, - end_char_index: S.Number, - }), - S.Struct({ - type: S.Literal("page_location"), - cited_text: S.String, - document_index: S.Number, - document_title: S.NullOr(S.String), - start_page_number: S.Number, - end_page_number: S.Number, - }), - S.Struct({ - type: S.Literal("content_block_location"), - cited_text: S.String, - document_index: S.Number, - document_title: S.NullOr(S.String), - start_block_index: S.Number, - end_block_index: S.Number, - }), - S.Struct({ - type: S.Literal("web_search_result_location"), - cited_text: S.String, - encrypted_index: S.String, - title: S.NullOr(S.String), - url: S.String, - }), - S.Struct({ - type: S.Literal("search_result_location"), - cited_text: S.String, - search_result_index: S.Number, - source: S.String, - title: S.NullOr(S.String), - start_block_index: S.Number, - end_block_index: S.Number, - }), - ), - ), - { nullable: true }, - ), - cache_control: S.optionalWith( - S.Struct({ - type: S.Literal("ephemeral"), - ttl: S.optionalWith(S.Literal("5m", "1h"), { nullable: true }), - }), - { nullable: true }, - ), - }), - ), - citations: S.optionalWith( - S.Struct({ - enabled: S.optionalWith(S.Boolean, { nullable: true }), - }), - { nullable: true }, - ), - cache_control: S.optionalWith( - S.Struct({ - type: S.Literal("ephemeral"), - ttl: S.optionalWith(S.Literal("5m", "1h"), { nullable: true }), - }), - { nullable: true }, - ), - }), - ), - ), - ), -}) {} - -export class AnthropicMessagesRequestToolChoiceEnumType extends S.Literal("tool") {} - -export class AnthropicMessagesRequestThinkingEnumType extends S.Literal("disabled") {} - -export class AnthropicMessagesRequestServiceTier extends S.Literal("auto", "standard_only") {} - -/** - * The sorting strategy to use for this request, if "order" is not specified. When set, no load balancing is performed. - */ -export class AnthropicMessagesRequestProviderSort extends S.Literal("price", "throughput", "latency") {} - -/** - * **DEPRECATED** Use providers.sort.partition instead. Backwards-compatible alias for providers.sort.partition. Accepts legacy values: "fallback" (maps to "model"), "sort" (maps to "none"). - */ -export class AnthropicMessagesRequestRoute extends S.Literal("fallback", "sort") {} - -/** - * Request schema for Anthropic Messages API endpoint - */ -export class AnthropicMessagesRequest extends S.Class("AnthropicMessagesRequest")({ - model: S.String, - max_tokens: S.Number, - messages: S.Array(OpenRouterAnthropicMessageParam), - system: S.optionalWith( - S.Union( - S.String, - S.Array( - S.Struct({ - type: S.Literal("text"), - text: S.String, - citations: S.optionalWith( - S.Array( - S.Union( - S.Struct({ - type: S.Literal("char_location"), - cited_text: S.String, - document_index: S.Number, - document_title: S.NullOr(S.String), - start_char_index: S.Number, - end_char_index: S.Number, - }), - S.Struct({ - type: S.Literal("page_location"), - cited_text: S.String, - document_index: S.Number, - document_title: S.NullOr(S.String), - start_page_number: S.Number, - end_page_number: S.Number, - }), - S.Struct({ - type: S.Literal("content_block_location"), - cited_text: S.String, - document_index: S.Number, - document_title: S.NullOr(S.String), - start_block_index: S.Number, - end_block_index: S.Number, - }), - S.Struct({ - type: S.Literal("web_search_result_location"), - cited_text: S.String, - encrypted_index: S.String, - title: S.NullOr(S.String), - url: S.String, - }), - S.Struct({ - type: S.Literal("search_result_location"), - cited_text: S.String, - search_result_index: S.Number, - source: S.String, - title: S.NullOr(S.String), - start_block_index: S.Number, - end_block_index: S.Number, - }), - ), - ), - { nullable: true }, - ), - cache_control: S.optionalWith( - S.Struct({ - type: S.Literal("ephemeral"), - ttl: S.optionalWith(S.Literal("5m", "1h"), { nullable: true }), - }), - { nullable: true }, - ), - }), - ), - ), - { nullable: true }, - ), - metadata: S.optionalWith( - S.Struct({ - user_id: S.optionalWith(S.String, { nullable: true }), - }), - { nullable: true }, - ), - stop_sequences: S.optionalWith(S.Array(S.String), { nullable: true }), - stream: S.optionalWith(S.Boolean, { nullable: true }), - temperature: S.optionalWith(S.Number, { nullable: true }), - top_p: S.optionalWith(S.Number, { nullable: true }), - top_k: S.optionalWith(S.Number, { nullable: true }), - tools: S.optionalWith( - S.Array( - S.Union( - S.Struct({ - name: S.String, - description: S.optionalWith(S.String, { nullable: true }), - input_schema: S.Struct({ - type: S.Literal("object"), - required: S.optionalWith(S.Array(S.String), { nullable: true }), - }), - type: S.optionalWith(S.Literal("custom"), { nullable: true }), - cache_control: S.optionalWith( - S.Struct({ - type: S.Literal("ephemeral"), - ttl: S.optionalWith(S.Literal("5m", "1h"), { nullable: true }), - }), - { nullable: true }, - ), - }), - S.Struct({ - type: S.Literal("bash_20250124"), - name: S.Literal("bash"), - cache_control: S.optionalWith( - S.Struct({ - type: S.Literal("ephemeral"), - ttl: S.optionalWith(S.Literal("5m", "1h"), { nullable: true }), - }), - { nullable: true }, - ), - }), - S.Struct({ - type: S.Literal("text_editor_20250124"), - name: S.Literal("str_replace_editor"), - cache_control: S.optionalWith( - S.Struct({ - type: S.Literal("ephemeral"), - ttl: S.optionalWith(S.Literal("5m", "1h"), { nullable: true }), - }), - { nullable: true }, - ), - }), - S.Struct({ - type: S.Literal("web_search_20250305"), - name: S.Literal("web_search"), - allowed_domains: S.optionalWith(S.Array(S.String), { nullable: true }), - blocked_domains: S.optionalWith(S.Array(S.String), { nullable: true }), - max_uses: S.optionalWith(S.Number, { nullable: true }), - user_location: S.optionalWith( - S.Struct({ - type: S.Literal("approximate"), - city: S.optionalWith(S.String, { nullable: true }), - country: S.optionalWith(S.String, { nullable: true }), - region: S.optionalWith(S.String, { nullable: true }), - timezone: S.optionalWith(S.String, { nullable: true }), - }), - { nullable: true }, - ), - cache_control: S.optionalWith( - S.Struct({ - type: S.Literal("ephemeral"), - ttl: S.optionalWith(S.Literal("5m", "1h"), { nullable: true }), - }), - { nullable: true }, - ), - }), - ), - ), - { nullable: true }, - ), - tool_choice: S.optionalWith( - S.Union( - S.Struct({ - type: AnthropicMessagesRequestToolChoiceEnumType, - disable_parallel_tool_use: S.optionalWith(S.Boolean, { nullable: true }), - }), - S.Struct({ - type: AnthropicMessagesRequestToolChoiceEnumType, - disable_parallel_tool_use: S.optionalWith(S.Boolean, { nullable: true }), - }), - S.Struct({ - type: AnthropicMessagesRequestToolChoiceEnumType, - }), - S.Struct({ - type: AnthropicMessagesRequestToolChoiceEnumType, - name: S.String, - disable_parallel_tool_use: S.optionalWith(S.Boolean, { nullable: true }), - }), - ), - { nullable: true }, - ), - thinking: S.optionalWith( - S.Union( - S.Struct({ - type: AnthropicMessagesRequestThinkingEnumType, - budget_tokens: S.Number, - }), - S.Struct({ - type: AnthropicMessagesRequestThinkingEnumType, - }), - ), - { nullable: true }, - ), - service_tier: S.optionalWith(AnthropicMessagesRequestServiceTier, { nullable: true }), - /** - * When multiple model providers are available, optionally indicate your routing preference. - */ - provider: S.optionalWith( - S.Struct({ - /** - * Whether to allow backup providers to serve requests - * - true: (default) when the primary provider (or your custom providers in "order") is unavailable, use the next best provider. - * - false: use only the primary/custom provider, and return the upstream error if it's unavailable. - */ - allow_fallbacks: S.optionalWith(S.Boolean, { nullable: true }), - /** - * Whether to filter providers to only those that support the parameters you've provided. If this setting is omitted or set to false, then providers will receive only the parameters they support, and ignore the rest. - */ - require_parameters: S.optionalWith(S.Boolean, { nullable: true }), - data_collection: S.optionalWith(DataCollection, { nullable: true }), - /** - * Whether to restrict routing to only ZDR (Zero Data Retention) endpoints. When true, only endpoints that do not retain prompts will be used. - */ - zdr: S.optionalWith(S.Boolean, { nullable: true }), - /** - * Whether to restrict routing to only models that allow text distillation. When true, only models where the author has allowed distillation will be used. - */ - enforce_distillable_text: S.optionalWith(S.Boolean, { nullable: true }), - /** - * An ordered list of provider slugs. The router will attempt to use the first provider in the subset of this list that supports your requested model, and fall back to the next if it is unavailable. If no providers are available, the request will fail with an error message. - */ - order: S.optionalWith(S.Array(S.Union(ProviderName, S.String)), { nullable: true }), - /** - * List of provider slugs to allow. If provided, this list is merged with your account-wide allowed provider settings for this request. - */ - only: S.optionalWith(S.Array(S.Union(ProviderName, S.String)), { nullable: true }), - /** - * List of provider slugs to ignore. If provided, this list is merged with your account-wide ignored provider settings for this request. - */ - ignore: S.optionalWith(S.Array(S.Union(ProviderName, S.String)), { nullable: true }), - /** - * A list of quantization levels to filter the provider by. - */ - quantizations: S.optionalWith(S.Array(Quantization), { nullable: true }), - sort: S.optionalWith(AnthropicMessagesRequestProviderSort, { nullable: true }), - /** - * The object specifying the maximum price you want to pay for this request. USD price per million tokens, for prompt and completion. - */ - max_price: S.optionalWith( - S.Struct({ - prompt: S.optionalWith(BigNumberUnion, { nullable: true }), - completion: S.optionalWith(BigNumberUnion, { nullable: true }), - image: S.optionalWith(BigNumberUnion, { nullable: true }), - audio: S.optionalWith(BigNumberUnion, { nullable: true }), - request: S.optionalWith(BigNumberUnion, { nullable: true }), - }), - { nullable: true }, - ), - preferred_min_throughput: S.optionalWith(PreferredMinThroughput, { nullable: true }), - preferred_max_latency: S.optionalWith(PreferredMaxLatency, { nullable: true }), - }), - { nullable: true }, - ), - /** - * Plugins you want to enable for this request, including their settings. - */ - plugins: S.optionalWith( - S.Array( - S.Union( - S.Struct({ - id: S.Literal("auto-router"), - /** - * Set to false to disable the auto-router plugin for this request. Defaults to true. - */ - enabled: S.optionalWith(S.Boolean, { nullable: true }), - /** - * List of model patterns to filter which models the auto-router can route between. Supports wildcards (e.g., "anthropic/*" matches all Anthropic models). When not specified, uses the default supported models list. - */ - allowed_models: S.optionalWith(S.Array(S.String), { nullable: true }), - }), - S.Struct({ - id: S.Literal("moderation"), - }), - S.Struct({ - id: S.Literal("web"), - /** - * Set to false to disable the web-search plugin for this request. Defaults to true. - */ - enabled: S.optionalWith(S.Boolean, { nullable: true }), - max_results: S.optionalWith(S.Number, { nullable: true }), - search_prompt: S.optionalWith(S.String, { nullable: true }), - engine: S.optionalWith(WebSearchEngine, { nullable: true }), - }), - S.Struct({ - id: S.Literal("file-parser"), - /** - * Set to false to disable the file-parser plugin for this request. Defaults to true. - */ - enabled: S.optionalWith(S.Boolean, { nullable: true }), - pdf: S.optionalWith(PDFParserOptions, { nullable: true }), - }), - S.Struct({ - id: S.Literal("response-healing"), - /** - * Set to false to disable the response-healing plugin for this request. Defaults to true. - */ - enabled: S.optionalWith(S.Boolean, { nullable: true }), - }), - ), - ), - { nullable: true }, - ), - /** - * **DEPRECATED** Use providers.sort.partition instead. Backwards-compatible alias for providers.sort.partition. Accepts legacy values: "fallback" (maps to "model"), "sort" (maps to "none"). - */ - route: S.optionalWith(AnthropicMessagesRequestRoute, { nullable: true }), - /** - * A unique identifier representing your end-user, which helps distinguish between different users of your app. This allows your app to identify specific users in case of abuse reports, preventing your entire app from being affected by the actions of individual users. Maximum of 128 characters. - */ - user: S.optionalWith(S.String.pipe(S.maxLength(128)), { nullable: true }), - /** - * A unique identifier for grouping related requests (e.g., a conversation or agent workflow) for observability. If provided in both the request body and the x-session-id header, the body value takes precedence. Maximum of 128 characters. - */ - session_id: S.optionalWith(S.String.pipe(S.maxLength(128)), { nullable: true }), - models: S.optionalWith(S.Array(S.String), { nullable: true }), -}) {} - -export class AnthropicMessagesResponseType extends S.Literal("message") {} - -export class AnthropicMessagesResponseRole extends S.Literal("assistant") {} - -export class AnthropicMessagesResponseStopReason extends S.Literal( - "end_turn", - "max_tokens", - "stop_sequence", - "tool_use", - "pause_turn", - "refusal", - "model_context_window_exceeded", -) {} - -export class AnthropicMessagesResponseUsageServiceTier extends S.Literal("standard", "priority", "batch") {} - -export class AnthropicMessagesResponse extends S.Class( - "AnthropicMessagesResponse", -)({ - id: S.String, - type: AnthropicMessagesResponseType, - role: AnthropicMessagesResponseRole, - content: S.Array( - S.Union( - S.Struct({ - type: S.Literal("text"), - text: S.String, - citations: S.NullOr( - S.Array( - S.Union( - S.Struct({ - type: S.Literal("char_location"), - cited_text: S.String, - document_index: S.Number, - document_title: S.NullOr(S.String), - start_char_index: S.Number, - end_char_index: S.Number, - file_id: S.NullOr(S.String), - }), - S.Struct({ - type: S.Literal("page_location"), - cited_text: S.String, - document_index: S.Number, - document_title: S.NullOr(S.String), - start_page_number: S.Number, - end_page_number: S.Number, - file_id: S.NullOr(S.String), - }), - S.Struct({ - type: S.Literal("content_block_location"), - cited_text: S.String, - document_index: S.Number, - document_title: S.NullOr(S.String), - start_block_index: S.Number, - end_block_index: S.Number, - file_id: S.NullOr(S.String), - }), - S.Struct({ - type: S.Literal("web_search_result_location"), - cited_text: S.String, - encrypted_index: S.String, - title: S.NullOr(S.String), - url: S.String, - }), - S.Struct({ - type: S.Literal("search_result_location"), - cited_text: S.String, - search_result_index: S.Number, - source: S.String, - title: S.NullOr(S.String), - start_block_index: S.Number, - end_block_index: S.Number, - }), - ), - ), - ), - }), - S.Struct({ - type: S.Literal("tool_use"), - id: S.String, - name: S.String, - }), - S.Struct({ - type: S.Literal("thinking"), - thinking: S.String, - signature: S.String, - }), - S.Struct({ - type: S.Literal("redacted_thinking"), - data: S.String, - }), - S.Struct({ - type: S.Literal("server_tool_use"), - id: S.String, - name: S.Literal("web_search"), - }), - S.Struct({ - type: S.Literal("web_search_tool_result"), - tool_use_id: S.String, - content: S.Union( - S.Array( - S.Struct({ - type: S.Literal("web_search_result"), - encrypted_content: S.String, - page_age: S.NullOr(S.String), - title: S.String, - url: S.String, - }), - ), - S.Struct({ - type: S.Literal("web_search_tool_result_error"), - error_code: S.Literal( - "invalid_tool_input", - "unavailable", - "max_uses_exceeded", - "too_many_requests", - "query_too_long", - ), - }), - ), - }), - ), - ), - model: S.String, - stop_reason: S.NullOr(AnthropicMessagesResponseStopReason), - stop_sequence: S.NullOr(S.String), - usage: S.Struct({ - input_tokens: S.Number, - output_tokens: S.Number, - cache_creation_input_tokens: S.NullOr(S.Number), - cache_read_input_tokens: S.NullOr(S.Number), - cache_creation: S.NullOr( - S.Struct({ - ephemeral_5m_input_tokens: S.Number, - ephemeral_1h_input_tokens: S.Number, - }), - ), - server_tool_use: S.NullOr( - S.Struct({ - web_search_requests: S.Number, - }), - ), - service_tier: S.NullOr(AnthropicMessagesResponseUsageServiceTier), - }), -}) {} - -export class CreateMessages400Type extends S.Literal("error") {} - -export class CreateMessages400 extends S.Struct({ - type: CreateMessages400Type, - error: S.Struct({ - type: S.String, - message: S.String, - }), -}) {} - -export class CreateMessages401Type extends S.Literal("error") {} - -export class CreateMessages401 extends S.Struct({ - type: CreateMessages401Type, - error: S.Struct({ - type: S.String, - message: S.String, - }), -}) {} - -export class CreateMessages403Type extends S.Literal("error") {} - -export class CreateMessages403 extends S.Struct({ - type: CreateMessages403Type, - error: S.Struct({ - type: S.String, - message: S.String, - }), -}) {} - -export class CreateMessages404Type extends S.Literal("error") {} - -export class CreateMessages404 extends S.Struct({ - type: CreateMessages404Type, - error: S.Struct({ - type: S.String, - message: S.String, - }), -}) {} - -export class CreateMessages429Type extends S.Literal("error") {} - -export class CreateMessages429 extends S.Struct({ - type: CreateMessages429Type, - error: S.Struct({ - type: S.String, - message: S.String, - }), -}) {} - -export class CreateMessages500Type extends S.Literal("error") {} - -export class CreateMessages500 extends S.Struct({ - type: CreateMessages500Type, - error: S.Struct({ - type: S.String, - message: S.String, - }), -}) {} - -export class CreateMessages503Type extends S.Literal("error") {} - -export class CreateMessages503 extends S.Struct({ - type: CreateMessages503Type, - error: S.Struct({ - type: S.String, - message: S.String, - }), -}) {} - -export class CreateMessages529Type extends S.Literal("error") {} - -export class CreateMessages529 extends S.Struct({ - type: CreateMessages529Type, - error: S.Struct({ - type: S.String, - message: S.String, - }), -}) {} - -export class GetUserActivityParams extends S.Struct({ - /** - * Filter by a single UTC date in the last 30 days (YYYY-MM-DD format). - */ - date: S.optionalWith(S.String, { nullable: true }), -}) {} - -export class ActivityItem extends S.Class("ActivityItem")({ - /** - * Date of the activity (YYYY-MM-DD format) - */ - date: S.String, - /** - * Model slug (e.g., "openai/gpt-4.1") - */ - model: S.String, - /** - * Model permaslug (e.g., "openai/gpt-4.1-2025-04-14") - */ - model_permaslug: S.String, - /** - * Unique identifier for the endpoint - */ - endpoint_id: S.String, - /** - * Name of the provider serving this endpoint - */ - provider_name: S.String, - /** - * Total cost in USD (OpenRouter credits spent) - */ - usage: S.Number, - /** - * BYOK inference cost in USD (external credits spent) - */ - byok_usage_inference: S.Number, - /** - * Number of requests made - */ - requests: S.Number, - /** - * Total prompt tokens used - */ - prompt_tokens: S.Number, - /** - * Total completion tokens generated - */ - completion_tokens: S.Number, - /** - * Total reasoning tokens used - */ - reasoning_tokens: S.Number, -}) {} - -export class GetUserActivity200 extends S.Struct({ - /** - * List of activity items - */ - data: S.Array(ActivityItem), -}) {} - -/** - * Error data for ForbiddenResponse - */ -export class ForbiddenResponseErrorData extends S.Class( - "ForbiddenResponseErrorData", -)({ - code: S.Int, - message: S.String, - metadata: S.optionalWith(S.Record({ key: S.String, value: S.Unknown }), { nullable: true }), -}) {} - -/** - * Forbidden - Authentication successful but insufficient permissions - */ -export class ForbiddenResponse extends S.Class("ForbiddenResponse")({ - error: ForbiddenResponseErrorData, - user_id: S.optionalWith(S.String, { nullable: true }), -}) {} - -/** - * Total credits purchased and used - */ -export class GetCredits200 extends S.Struct({ - data: S.Struct({ - /** - * Total credits purchased - */ - total_credits: S.Number, - /** - * Total credits used - */ - total_usage: S.Number, - }), -}) {} - -export class CreateChargeRequestChainId extends S.Literal(1, 137, 8453) {} - -/** - * Create a Coinbase charge for crypto payment - */ -export class CreateChargeRequest extends S.Class("CreateChargeRequest")({ - amount: S.Number, - sender: S.String, - chain_id: CreateChargeRequestChainId, -}) {} - -export class CreateCoinbaseCharge200 extends S.Struct({ - data: S.Struct({ - id: S.String, - created_at: S.String, - expires_at: S.String, - web3_data: S.Struct({ - transfer_intent: S.Struct({ - call_data: S.Struct({ - deadline: S.String, - fee_amount: S.String, - id: S.String, - operator: S.String, - prefix: S.String, - recipient: S.String, - recipient_amount: S.String, - recipient_currency: S.String, - refund_destination: S.String, - signature: S.String, - }), - metadata: S.Struct({ - chain_id: S.Number, - contract_address: S.String, - sender: S.String, - }), - }), - }), - }), -}) {} - -export class CreateEmbeddingsRequestEncodingFormat extends S.Literal("float", "base64") {} - -/** - * The sorting strategy to use for this request, if "order" is not specified. When set, no load balancing is performed. - */ -export class ProviderPreferencesSort extends S.Literal("price", "throughput", "latency") {} - -/** - * Provider routing preferences for the request. - */ -export class ProviderPreferences extends S.Class("ProviderPreferences")({ - /** - * Whether to allow backup providers to serve requests - * - true: (default) when the primary provider (or your custom providers in "order") is unavailable, use the next best provider. - * - false: use only the primary/custom provider, and return the upstream error if it's unavailable. - */ - allow_fallbacks: S.optionalWith(S.Boolean, { nullable: true }), - /** - * Whether to filter providers to only those that support the parameters you've provided. If this setting is omitted or set to false, then providers will receive only the parameters they support, and ignore the rest. - */ - require_parameters: S.optionalWith(S.Boolean, { nullable: true }), - data_collection: S.optionalWith(DataCollection, { nullable: true }), - /** - * Whether to restrict routing to only ZDR (Zero Data Retention) endpoints. When true, only endpoints that do not retain prompts will be used. - */ - zdr: S.optionalWith(S.Boolean, { nullable: true }), - /** - * Whether to restrict routing to only models that allow text distillation. When true, only models where the author has allowed distillation will be used. - */ - enforce_distillable_text: S.optionalWith(S.Boolean, { nullable: true }), - /** - * An ordered list of provider slugs. The router will attempt to use the first provider in the subset of this list that supports your requested model, and fall back to the next if it is unavailable. If no providers are available, the request will fail with an error message. - */ - order: S.optionalWith(S.Array(S.Union(ProviderName, S.String)), { nullable: true }), - /** - * List of provider slugs to allow. If provided, this list is merged with your account-wide allowed provider settings for this request. - */ - only: S.optionalWith(S.Array(S.Union(ProviderName, S.String)), { nullable: true }), - /** - * List of provider slugs to ignore. If provided, this list is merged with your account-wide ignored provider settings for this request. - */ - ignore: S.optionalWith(S.Array(S.Union(ProviderName, S.String)), { nullable: true }), - /** - * A list of quantization levels to filter the provider by. - */ - quantizations: S.optionalWith(S.Array(Quantization), { nullable: true }), - sort: S.optionalWith(ProviderPreferencesSort, { nullable: true }), - /** - * The object specifying the maximum price you want to pay for this request. USD price per million tokens, for prompt and completion. - */ - max_price: S.optionalWith( - S.Struct({ - prompt: S.optionalWith(BigNumberUnion, { nullable: true }), - completion: S.optionalWith(BigNumberUnion, { nullable: true }), - image: S.optionalWith(BigNumberUnion, { nullable: true }), - audio: S.optionalWith(BigNumberUnion, { nullable: true }), - request: S.optionalWith(BigNumberUnion, { nullable: true }), - }), - { nullable: true }, - ), - preferred_min_throughput: S.optionalWith(PreferredMinThroughput, { nullable: true }), - preferred_max_latency: S.optionalWith(PreferredMaxLatency, { nullable: true }), -}) {} - -export class CreateEmbeddingsRequest extends S.Class("CreateEmbeddingsRequest")({ - input: S.Union( - S.String, - S.Array(S.String), - S.Array(S.Number), - S.Array(S.Array(S.Number)), - S.Array( - S.Struct({ - content: S.Array( - S.Union( - S.Struct({ - type: S.Literal("text"), - text: S.String, - }), - S.Struct({ - type: S.Literal("image_url"), - image_url: S.Struct({ - url: S.String, - }), - }), - ), - ), - }), - ), - ), - model: S.String, - encoding_format: S.optionalWith(CreateEmbeddingsRequestEncodingFormat, { nullable: true }), - dimensions: S.optionalWith(S.Int.pipe(S.greaterThan(0)), { nullable: true }), - user: S.optionalWith(S.String, { nullable: true }), - provider: S.optionalWith(ProviderPreferences, { nullable: true }), - input_type: S.optionalWith(S.String, { nullable: true }), -}) {} - -export class CreateEmbeddings200Object extends S.Literal("list") {} - -export class CreateEmbeddings200 extends S.Struct({ - id: S.optionalWith(S.String, { nullable: true }), - object: CreateEmbeddings200Object, - data: S.Array( - S.Struct({ - object: S.Literal("embedding"), - embedding: S.Union(S.Array(S.Number), S.String), - index: S.optionalWith(S.Number, { nullable: true }), - }), - ), - model: S.String, - usage: S.optionalWith( - S.Struct({ - prompt_tokens: S.Number, - total_tokens: S.Number, - cost: S.optionalWith(S.Number, { nullable: true }), - }), - { nullable: true }, - ), -}) {} - -/** - * Pricing information for the model - */ -export class PublicPricing extends S.Class("PublicPricing")({ - prompt: BigNumberUnion, - completion: BigNumberUnion, - request: S.optionalWith(BigNumberUnion, { nullable: true }), - image: S.optionalWith(BigNumberUnion, { nullable: true }), - image_token: S.optionalWith(BigNumberUnion, { nullable: true }), - image_output: S.optionalWith(BigNumberUnion, { nullable: true }), - audio: S.optionalWith(BigNumberUnion, { nullable: true }), - audio_output: S.optionalWith(BigNumberUnion, { nullable: true }), - input_audio_cache: S.optionalWith(BigNumberUnion, { nullable: true }), - web_search: S.optionalWith(BigNumberUnion, { nullable: true }), - internal_reasoning: S.optionalWith(BigNumberUnion, { nullable: true }), - input_cache_read: S.optionalWith(BigNumberUnion, { nullable: true }), - input_cache_write: S.optionalWith(BigNumberUnion, { nullable: true }), - discount: S.optionalWith(S.Number, { nullable: true }), -}) {} - -/** - * Tokenizer type used by the model - */ -export class ModelGroup extends S.Literal( - "Router", - "Media", - "Other", - "GPT", - "Claude", - "Gemini", - "Grok", - "Cohere", - "Nova", - "Qwen", - "Yi", - "DeepSeek", - "Mistral", - "Llama2", - "Llama3", - "Llama4", - "PaLM", - "RWKV", - "Qwen3", -) {} - -/** - * Instruction format type - */ -export class ModelArchitectureInstructType extends S.Literal( - "none", - "airoboros", - "alpaca", - "alpaca-modif", - "chatml", - "claude", - "code-llama", - "gemma", - "llama2", - "llama3", - "mistral", - "nemotron", - "neural", - "openchat", - "phi3", - "rwkv", - "vicuna", - "zephyr", - "deepseek-r1", - "deepseek-v3.1", - "qwq", - "qwen3", -) {} - -export class InputModality extends S.Literal("text", "image", "file", "audio", "video") {} - -export class OutputModality extends S.Literal("text", "image", "embeddings", "audio") {} - -/** - * Model architecture information - */ -export class ModelArchitecture extends S.Class("ModelArchitecture")({ - tokenizer: S.optionalWith(ModelGroup, { nullable: true }), - /** - * Instruction format type - */ - instruct_type: S.optionalWith(ModelArchitectureInstructType, { nullable: true }), - /** - * Primary modality of the model - */ - modality: S.NullOr(S.String), - /** - * Supported input modalities - */ - input_modalities: S.Array(InputModality), - /** - * Supported output modalities - */ - output_modalities: S.Array(OutputModality), -}) {} - -/** - * Information about the top provider for this model - */ -export class TopProviderInfo extends S.Class("TopProviderInfo")({ - /** - * Context length from the top provider - */ - context_length: S.optionalWith(S.Number, { nullable: true }), - /** - * Maximum completion tokens from the top provider - */ - max_completion_tokens: S.optionalWith(S.Number, { nullable: true }), - /** - * Whether the top provider moderates content - */ - is_moderated: S.Boolean, -}) {} - -/** - * Per-request token limits - */ -export class PerRequestLimits extends S.Class("PerRequestLimits")({ - /** - * Maximum prompt tokens per request - */ - prompt_tokens: S.Number, - /** - * Maximum completion tokens per request - */ - completion_tokens: S.Number, -}) {} - -export class Parameter extends S.Literal( - "temperature", - "top_p", - "top_k", - "min_p", - "top_a", - "frequency_penalty", - "presence_penalty", - "repetition_penalty", - "max_tokens", - "logit_bias", - "logprobs", - "top_logprobs", - "seed", - "response_format", - "structured_outputs", - "stop", - "tools", - "tool_choice", - "parallel_tool_calls", - "include_reasoning", - "reasoning", - "reasoning_effort", - "web_search_options", - "verbosity", -) {} - -/** - * Default parameters for this model - */ -export class DefaultParameters extends S.Class("DefaultParameters")({ - temperature: S.optionalWith(S.Number.pipe(S.greaterThanOrEqualTo(0), S.lessThanOrEqualTo(2)), { - nullable: true, - }), - top_p: S.optionalWith(S.Number.pipe(S.greaterThanOrEqualTo(0), S.lessThanOrEqualTo(1)), { - nullable: true, - }), - frequency_penalty: S.optionalWith(S.Number.pipe(S.greaterThanOrEqualTo(-2), S.lessThanOrEqualTo(2)), { - nullable: true, - }), -}) {} - -/** - * Information about an AI model available on OpenRouter - */ -export class Model extends S.Class("Model")({ - /** - * Unique identifier for the model - */ - id: S.String, - /** - * Canonical slug for the model - */ - canonical_slug: S.String, - /** - * Hugging Face model identifier, if applicable - */ - hugging_face_id: S.optionalWith(S.String, { nullable: true }), - /** - * Display name of the model - */ - name: S.String, - /** - * Unix timestamp of when the model was created - */ - created: S.Number, - /** - * Description of the model - */ - description: S.optionalWith(S.String, { nullable: true }), - pricing: PublicPricing, - /** - * Maximum context length in tokens - */ - context_length: S.NullOr(S.Number), - architecture: ModelArchitecture, - top_provider: TopProviderInfo, - per_request_limits: S.NullOr(PerRequestLimits), - /** - * List of supported parameters for this model - */ - supported_parameters: S.Array(Parameter), - default_parameters: S.NullOr(DefaultParameters), - /** - * The date after which the model may be removed. ISO 8601 date string (YYYY-MM-DD) or null if no expiration. - */ - expiration_date: S.optionalWith(S.String, { nullable: true }), -}) {} - -/** - * List of available models - */ -export class ModelsListResponseData extends S.Array(Model) {} - -/** - * List of available models - */ -export class ModelsListResponse extends S.Class("ModelsListResponse")({ - data: ModelsListResponseData, -}) {} - -export class GetGenerationParams extends S.Struct({ - id: S.String.pipe(S.minLength(1)), -}) {} - -/** - * Type of API used for the generation - */ -export class GetGeneration200DataApiType extends S.Literal("completions", "embeddings") {} - -/** - * Generation response - */ -export class GetGeneration200 extends S.Struct({ - /** - * Generation data - */ - data: S.Struct({ - /** - * Unique identifier for the generation - */ - id: S.String, - /** - * Upstream provider's identifier for this generation - */ - upstream_id: S.NullOr(S.String), - /** - * Total cost of the generation in USD - */ - total_cost: S.Number, - /** - * Discount applied due to caching - */ - cache_discount: S.NullOr(S.Number), - /** - * Cost charged by the upstream provider - */ - upstream_inference_cost: S.NullOr(S.Number), - /** - * ISO 8601 timestamp of when the generation was created - */ - created_at: S.String, - /** - * Model used for the generation - */ - model: S.String, - /** - * ID of the app that made the request - */ - app_id: S.NullOr(S.Number), - /** - * Whether the response was streamed - */ - streamed: S.NullOr(S.Boolean), - /** - * Whether the generation was cancelled - */ - cancelled: S.NullOr(S.Boolean), - /** - * Name of the provider that served the request - */ - provider_name: S.NullOr(S.String), - /** - * Total latency in milliseconds - */ - latency: S.NullOr(S.Number), - /** - * Moderation latency in milliseconds - */ - moderation_latency: S.NullOr(S.Number), - /** - * Time taken for generation in milliseconds - */ - generation_time: S.NullOr(S.Number), - /** - * Reason the generation finished - */ - finish_reason: S.NullOr(S.String), - /** - * Number of tokens in the prompt - */ - tokens_prompt: S.NullOr(S.Number), - /** - * Number of tokens in the completion - */ - tokens_completion: S.NullOr(S.Number), - /** - * Native prompt tokens as reported by provider - */ - native_tokens_prompt: S.NullOr(S.Number), - /** - * Native completion tokens as reported by provider - */ - native_tokens_completion: S.NullOr(S.Number), - /** - * Native completion image tokens as reported by provider - */ - native_tokens_completion_images: S.NullOr(S.Number), - /** - * Native reasoning tokens as reported by provider - */ - native_tokens_reasoning: S.NullOr(S.Number), - /** - * Native cached tokens as reported by provider - */ - native_tokens_cached: S.NullOr(S.Number), - /** - * Number of media items in the prompt - */ - num_media_prompt: S.NullOr(S.Number), - /** - * Number of audio inputs in the prompt - */ - num_input_audio_prompt: S.NullOr(S.Number), - /** - * Number of media items in the completion - */ - num_media_completion: S.NullOr(S.Number), - /** - * Number of search results included - */ - num_search_results: S.NullOr(S.Number), - /** - * Origin URL of the request - */ - origin: S.String, - /** - * Usage amount in USD - */ - usage: S.Number, - /** - * Whether this used bring-your-own-key - */ - is_byok: S.Boolean, - /** - * Native finish reason as reported by provider - */ - native_finish_reason: S.NullOr(S.String), - /** - * External user identifier - */ - external_user: S.NullOr(S.String), - /** - * Type of API used for the generation - */ - api_type: S.NullOr(GetGeneration200DataApiType), - /** - * Router used for the request (e.g., openrouter/auto) - */ - router: S.NullOr(S.String), - }), -}) {} - -/** - * Model count data - */ -export class ModelsCountResponse extends S.Class("ModelsCountResponse")({ - /** - * Model count data - */ - data: S.Struct({ - /** - * Total number of available models - */ - count: S.Number, - }), -}) {} - -/** - * Filter models by use case category - */ -export class GetModelsParamsCategory extends S.Literal( - "programming", - "roleplay", - "marketing", - "marketing/seo", - "technology", - "science", - "translation", - "legal", - "finance", - "health", - "trivia", - "academia", -) {} - -export class GetModelsParams extends S.Struct({ - /** - * Filter models by use case category - */ - category: S.optionalWith(GetModelsParamsCategory, { nullable: true }), - supported_parameters: S.optionalWith(S.String, { nullable: true }), -}) {} - -/** - * Instruction format type - */ -export class ListEndpointsResponseArchitectureEnumInstructType extends S.Literal( - "none", - "airoboros", - "alpaca", - "alpaca-modif", - "chatml", - "claude", - "code-llama", - "gemma", - "llama2", - "llama3", - "mistral", - "nemotron", - "neural", - "openchat", - "phi3", - "rwkv", - "vicuna", - "zephyr", - "deepseek-r1", - "deepseek-v3.1", - "qwq", - "qwen3", -) {} - -/** - * Model architecture information - */ -export class ListEndpointsResponseArchitecture extends S.Struct({ - tokenizer: ModelGroup, - /** - * Instruction format type - */ - instruct_type: S.NullOr( - S.Literal( - "none", - "airoboros", - "alpaca", - "alpaca-modif", - "chatml", - "claude", - "code-llama", - "gemma", - "llama2", - "llama3", - "mistral", - "nemotron", - "neural", - "openchat", - "phi3", - "rwkv", - "vicuna", - "zephyr", - "deepseek-r1", - "deepseek-v3.1", - "qwq", - "qwen3", - ), - ), - /** - * Primary modality of the model - */ - modality: S.NullOr(S.String), - /** - * Supported input modalities - */ - input_modalities: S.Array(InputModality), - /** - * Supported output modalities - */ - output_modalities: S.Array(OutputModality), -}) {} - -export class PublicEndpointQuantizationEnum extends S.Literal( - "int4", - "int8", - "fp4", - "fp6", - "fp8", - "fp16", - "bf16", - "fp32", - "unknown", -) {} - -export class PublicEndpointQuantization extends PublicEndpointQuantizationEnum {} - -export class EndpointStatus extends S.Literal(0, -1, -2, -3, -5, -10) {} - -/** - * Latency percentiles in milliseconds over the last 30 minutes. Latency measures time to first token. Only visible when authenticated with an API key or cookie; returns null for unauthenticated requests. - */ -export class PercentileStats extends S.Class("PercentileStats")({ - /** - * Median (50th percentile) - */ - p50: S.Number, - /** - * 75th percentile - */ - p75: S.Number, - /** - * 90th percentile - */ - p90: S.Number, - /** - * 99th percentile - */ - p99: S.Number, -}) {} - -/** - * Throughput percentiles in tokens per second over the last 30 minutes. Throughput measures output token generation speed. Only visible when authenticated with an API key or cookie; returns null for unauthenticated requests. - */ -export class PublicEndpointThroughputLast30M extends S.Struct({ - /** - * Median (50th percentile) - */ - p50: S.Number, - /** - * 75th percentile - */ - p75: S.Number, - /** - * 90th percentile - */ - p90: S.Number, - /** - * 99th percentile - */ - p99: S.Number, -}) {} - -/** - * Information about a specific model endpoint - */ -export class PublicEndpoint extends S.Class("PublicEndpoint")({ - name: S.String, - /** - * The unique identifier for the model (permaslug) - */ - model_id: S.String, - model_name: S.String, - context_length: S.Number, - pricing: S.Struct({ - prompt: BigNumberUnion, - completion: BigNumberUnion, - request: S.optionalWith(BigNumberUnion, { nullable: true }), - image: S.optionalWith(BigNumberUnion, { nullable: true }), - image_token: S.optionalWith(BigNumberUnion, { nullable: true }), - image_output: S.optionalWith(BigNumberUnion, { nullable: true }), - audio: S.optionalWith(BigNumberUnion, { nullable: true }), - audio_output: S.optionalWith(BigNumberUnion, { nullable: true }), - input_audio_cache: S.optionalWith(BigNumberUnion, { nullable: true }), - web_search: S.optionalWith(BigNumberUnion, { nullable: true }), - internal_reasoning: S.optionalWith(BigNumberUnion, { nullable: true }), - input_cache_read: S.optionalWith(BigNumberUnion, { nullable: true }), - input_cache_write: S.optionalWith(BigNumberUnion, { nullable: true }), - discount: S.optionalWith(S.Number, { nullable: true }), - }), - provider_name: ProviderName, - tag: S.String, - quantization: PublicEndpointQuantization, - max_completion_tokens: S.NullOr(S.Number), - max_prompt_tokens: S.NullOr(S.Number), - supported_parameters: S.Array(Parameter), - status: S.optionalWith(EndpointStatus, { nullable: true }), - uptime_last_30m: S.NullOr(S.Number), - supports_implicit_caching: S.Boolean, - latency_last_30m: S.NullOr(PercentileStats), - throughput_last_30m: PublicEndpointThroughputLast30M, -}) {} - -/** - * List of available endpoints for a model - */ -export class ListEndpointsResponse extends S.Class("ListEndpointsResponse")({ - /** - * Unique identifier for the model - */ - id: S.String, - /** - * Display name of the model - */ - name: S.String, - /** - * Unix timestamp of when the model was created - */ - created: S.Number, - /** - * Description of the model - */ - description: S.String, - architecture: ListEndpointsResponseArchitecture, - /** - * List of available endpoints for this model - */ - endpoints: S.Array(PublicEndpoint), -}) {} - -export class ListEndpoints200 extends S.Struct({ - data: ListEndpointsResponse, -}) {} - -export class ListEndpointsZdr200 extends S.Struct({ - data: S.Array(PublicEndpoint), -}) {} - -export class ListProviders200 extends S.Struct({ - data: S.Array( - S.Struct({ - /** - * Display name of the provider - */ - name: S.String, - /** - * URL-friendly identifier for the provider - */ - slug: S.String, - /** - * URL to the provider's privacy policy - */ - privacy_policy_url: S.NullOr(S.String), - /** - * URL to the provider's terms of service - */ - terms_of_service_url: S.optionalWith(S.String, { nullable: true }), - /** - * URL to the provider's status page - */ - status_page_url: S.optionalWith(S.String, { nullable: true }), - }), - ), -}) {} - -export class ListParams extends S.Struct({ - /** - * Whether to include disabled API keys in the response - */ - include_disabled: S.optionalWith(S.String, { nullable: true }), - /** - * Number of API keys to skip for pagination - */ - offset: S.optionalWith(S.String, { nullable: true }), -}) {} - -export class List200 extends S.Struct({ - /** - * List of API keys - */ - data: S.Array( - S.Struct({ - /** - * Unique hash identifier for the API key - */ - hash: S.String, - /** - * Name of the API key - */ - name: S.String, - /** - * Human-readable label for the API key - */ - label: S.String, - /** - * Whether the API key is disabled - */ - disabled: S.Boolean, - /** - * Spending limit for the API key in USD - */ - limit: S.NullOr(S.Number), - /** - * Remaining spending limit in USD - */ - limit_remaining: S.NullOr(S.Number), - /** - * Type of limit reset for the API key - */ - limit_reset: S.NullOr(S.String), - /** - * Whether to include external BYOK usage in the credit limit - */ - include_byok_in_limit: S.Boolean, - /** - * Total OpenRouter credit usage (in USD) for the API key - */ - usage: S.Number, - /** - * OpenRouter credit usage (in USD) for the current UTC day - */ - usage_daily: S.Number, - /** - * OpenRouter credit usage (in USD) for the current UTC week (Monday-Sunday) - */ - usage_weekly: S.Number, - /** - * OpenRouter credit usage (in USD) for the current UTC month - */ - usage_monthly: S.Number, - /** - * Total external BYOK usage (in USD) for the API key - */ - byok_usage: S.Number, - /** - * External BYOK usage (in USD) for the current UTC day - */ - byok_usage_daily: S.Number, - /** - * External BYOK usage (in USD) for the current UTC week (Monday-Sunday) - */ - byok_usage_weekly: S.Number, - /** - * External BYOK usage (in USD) for current UTC month - */ - byok_usage_monthly: S.Number, - /** - * ISO 8601 timestamp of when the API key was created - */ - created_at: S.String, - /** - * ISO 8601 timestamp of when the API key was last updated - */ - updated_at: S.NullOr(S.String), - /** - * ISO 8601 UTC timestamp when the API key expires, or null if no expiration - */ - expires_at: S.optionalWith(S.String, { nullable: true }), - }), - ), -}) {} - -/** - * Type of limit reset for the API key (daily, weekly, monthly, or null for no reset). Resets happen automatically at midnight UTC, and weeks are Monday through Sunday. - */ -export class CreateKeysRequestLimitReset extends S.Literal("daily", "weekly", "monthly") {} - -export class CreateKeysRequest extends S.Class("CreateKeysRequest")({ - /** - * Name for the new API key - */ - name: S.String.pipe(S.minLength(1)), - /** - * Optional spending limit for the API key in USD - */ - limit: S.optionalWith(S.Number, { nullable: true }), - /** - * Type of limit reset for the API key (daily, weekly, monthly, or null for no reset). Resets happen automatically at midnight UTC, and weeks are Monday through Sunday. - */ - limit_reset: S.optionalWith(CreateKeysRequestLimitReset, { nullable: true }), - /** - * Whether to include BYOK usage in the limit - */ - include_byok_in_limit: S.optionalWith(S.Boolean, { nullable: true }), - /** - * Optional ISO 8601 UTC timestamp when the API key should expire. Must be UTC, other timezones will be rejected - */ - expires_at: S.optionalWith(S.String, { nullable: true }), -}) {} - -export class CreateKeys201 extends S.Struct({ - /** - * The created API key information - */ - data: S.Struct({ - /** - * Unique hash identifier for the API key - */ - hash: S.String, - /** - * Name of the API key - */ - name: S.String, - /** - * Human-readable label for the API key - */ - label: S.String, - /** - * Whether the API key is disabled - */ - disabled: S.Boolean, - /** - * Spending limit for the API key in USD - */ - limit: S.NullOr(S.Number), - /** - * Remaining spending limit in USD - */ - limit_remaining: S.NullOr(S.Number), - /** - * Type of limit reset for the API key - */ - limit_reset: S.NullOr(S.String), - /** - * Whether to include external BYOK usage in the credit limit - */ - include_byok_in_limit: S.Boolean, - /** - * Total OpenRouter credit usage (in USD) for the API key - */ - usage: S.Number, - /** - * OpenRouter credit usage (in USD) for the current UTC day - */ - usage_daily: S.Number, - /** - * OpenRouter credit usage (in USD) for the current UTC week (Monday-Sunday) - */ - usage_weekly: S.Number, - /** - * OpenRouter credit usage (in USD) for the current UTC month - */ - usage_monthly: S.Number, - /** - * Total external BYOK usage (in USD) for the API key - */ - byok_usage: S.Number, - /** - * External BYOK usage (in USD) for the current UTC day - */ - byok_usage_daily: S.Number, - /** - * External BYOK usage (in USD) for the current UTC week (Monday-Sunday) - */ - byok_usage_weekly: S.Number, - /** - * External BYOK usage (in USD) for current UTC month - */ - byok_usage_monthly: S.Number, - /** - * ISO 8601 timestamp of when the API key was created - */ - created_at: S.String, - /** - * ISO 8601 timestamp of when the API key was last updated - */ - updated_at: S.NullOr(S.String), - /** - * ISO 8601 UTC timestamp when the API key expires, or null if no expiration - */ - expires_at: S.optionalWith(S.String, { nullable: true }), - }), - /** - * The actual API key string (only shown once) - */ - key: S.String, -}) {} - -export class GetKey200 extends S.Struct({ - /** - * The API key information - */ - data: S.Struct({ - /** - * Unique hash identifier for the API key - */ - hash: S.String, - /** - * Name of the API key - */ - name: S.String, - /** - * Human-readable label for the API key - */ - label: S.String, - /** - * Whether the API key is disabled - */ - disabled: S.Boolean, - /** - * Spending limit for the API key in USD - */ - limit: S.NullOr(S.Number), - /** - * Remaining spending limit in USD - */ - limit_remaining: S.NullOr(S.Number), - /** - * Type of limit reset for the API key - */ - limit_reset: S.NullOr(S.String), - /** - * Whether to include external BYOK usage in the credit limit - */ - include_byok_in_limit: S.Boolean, - /** - * Total OpenRouter credit usage (in USD) for the API key - */ - usage: S.Number, - /** - * OpenRouter credit usage (in USD) for the current UTC day - */ - usage_daily: S.Number, - /** - * OpenRouter credit usage (in USD) for the current UTC week (Monday-Sunday) - */ - usage_weekly: S.Number, - /** - * OpenRouter credit usage (in USD) for the current UTC month - */ - usage_monthly: S.Number, - /** - * Total external BYOK usage (in USD) for the API key - */ - byok_usage: S.Number, - /** - * External BYOK usage (in USD) for the current UTC day - */ - byok_usage_daily: S.Number, - /** - * External BYOK usage (in USD) for the current UTC week (Monday-Sunday) - */ - byok_usage_weekly: S.Number, - /** - * External BYOK usage (in USD) for current UTC month - */ - byok_usage_monthly: S.Number, - /** - * ISO 8601 timestamp of when the API key was created - */ - created_at: S.String, - /** - * ISO 8601 timestamp of when the API key was last updated - */ - updated_at: S.NullOr(S.String), - /** - * ISO 8601 UTC timestamp when the API key expires, or null if no expiration - */ - expires_at: S.optionalWith(S.String, { nullable: true }), - }), -}) {} - -export class DeleteKeys200 extends S.Struct({ - /** - * Confirmation that the API key was deleted - */ - deleted: S.Literal(true), -}) {} - -/** - * New limit reset type for the API key (daily, weekly, monthly, or null for no reset). Resets happen automatically at midnight UTC, and weeks are Monday through Sunday. - */ -export class UpdateKeysRequestLimitReset extends S.Literal("daily", "weekly", "monthly") {} - -export class UpdateKeysRequest extends S.Class("UpdateKeysRequest")({ - /** - * New name for the API key - */ - name: S.optionalWith(S.String, { nullable: true }), - /** - * Whether to disable the API key - */ - disabled: S.optionalWith(S.Boolean, { nullable: true }), - /** - * New spending limit for the API key in USD - */ - limit: S.optionalWith(S.Number, { nullable: true }), - /** - * New limit reset type for the API key (daily, weekly, monthly, or null for no reset). Resets happen automatically at midnight UTC, and weeks are Monday through Sunday. - */ - limit_reset: S.optionalWith(UpdateKeysRequestLimitReset, { nullable: true }), - /** - * Whether to include BYOK usage in the limit - */ - include_byok_in_limit: S.optionalWith(S.Boolean, { nullable: true }), -}) {} - -export class UpdateKeys200 extends S.Struct({ - /** - * The updated API key information - */ - data: S.Struct({ - /** - * Unique hash identifier for the API key - */ - hash: S.String, - /** - * Name of the API key - */ - name: S.String, - /** - * Human-readable label for the API key - */ - label: S.String, - /** - * Whether the API key is disabled - */ - disabled: S.Boolean, - /** - * Spending limit for the API key in USD - */ - limit: S.NullOr(S.Number), - /** - * Remaining spending limit in USD - */ - limit_remaining: S.NullOr(S.Number), - /** - * Type of limit reset for the API key - */ - limit_reset: S.NullOr(S.String), - /** - * Whether to include external BYOK usage in the credit limit - */ - include_byok_in_limit: S.Boolean, - /** - * Total OpenRouter credit usage (in USD) for the API key - */ - usage: S.Number, - /** - * OpenRouter credit usage (in USD) for the current UTC day - */ - usage_daily: S.Number, - /** - * OpenRouter credit usage (in USD) for the current UTC week (Monday-Sunday) - */ - usage_weekly: S.Number, - /** - * OpenRouter credit usage (in USD) for the current UTC month - */ - usage_monthly: S.Number, - /** - * Total external BYOK usage (in USD) for the API key - */ - byok_usage: S.Number, - /** - * External BYOK usage (in USD) for the current UTC day - */ - byok_usage_daily: S.Number, - /** - * External BYOK usage (in USD) for the current UTC week (Monday-Sunday) - */ - byok_usage_weekly: S.Number, - /** - * External BYOK usage (in USD) for current UTC month - */ - byok_usage_monthly: S.Number, - /** - * ISO 8601 timestamp of when the API key was created - */ - created_at: S.String, - /** - * ISO 8601 timestamp of when the API key was last updated - */ - updated_at: S.NullOr(S.String), - /** - * ISO 8601 UTC timestamp when the API key expires, or null if no expiration - */ - expires_at: S.optionalWith(S.String, { nullable: true }), - }), -}) {} - -export class ListGuardrailsParams extends S.Struct({ - /** - * Number of records to skip for pagination - */ - offset: S.optionalWith(S.String, { nullable: true }), - /** - * Maximum number of records to return (max 100) - */ - limit: S.optionalWith(S.String, { nullable: true }), -}) {} - -export class ListGuardrails200 extends S.Struct({ - /** - * List of guardrails - */ - data: S.Array( - S.Struct({ - /** - * Unique identifier for the guardrail - */ - id: S.String, - /** - * Name of the guardrail - */ - name: S.String, - /** - * Description of the guardrail - */ - description: S.optionalWith(S.String, { nullable: true }), - /** - * Spending limit in USD - */ - limit_usd: S.optionalWith(S.Number.pipe(S.greaterThan(0)), { nullable: true }), - /** - * Interval at which the limit resets (daily, weekly, monthly) - */ - reset_interval: S.optionalWith(S.Literal("daily", "weekly", "monthly"), { nullable: true }), - /** - * List of allowed provider IDs - */ - allowed_providers: S.optionalWith(S.Array(S.String), { nullable: true }), - /** - * Array of model canonical_slugs (immutable identifiers) - */ - allowed_models: S.optionalWith(S.Array(S.String), { nullable: true }), - /** - * Whether to enforce zero data retention - */ - enforce_zdr: S.optionalWith(S.Boolean, { nullable: true }), - /** - * ISO 8601 timestamp of when the guardrail was created - */ - created_at: S.String, - /** - * ISO 8601 timestamp of when the guardrail was last updated - */ - updated_at: S.optionalWith(S.String, { nullable: true }), - }), - ), - /** - * Total number of guardrails - */ - total_count: S.Number, -}) {} - -/** - * Interval at which the limit resets (daily, weekly, monthly) - */ -export class CreateGuardrailRequestResetInterval extends S.Literal("daily", "weekly", "monthly") {} - -export class CreateGuardrailRequest extends S.Class("CreateGuardrailRequest")({ - /** - * Name for the new guardrail - */ - name: S.String.pipe(S.minLength(1), S.maxLength(200)), - /** - * Description of the guardrail - */ - description: S.optionalWith(S.String.pipe(S.maxLength(1000)), { nullable: true }), - /** - * Spending limit in USD - */ - limit_usd: S.optionalWith(S.Number.pipe(S.greaterThan(0)), { nullable: true }), - /** - * Interval at which the limit resets (daily, weekly, monthly) - */ - reset_interval: S.optionalWith(CreateGuardrailRequestResetInterval, { nullable: true }), - /** - * List of allowed provider IDs - */ - allowed_providers: S.optionalWith(S.NonEmptyArray(S.String).pipe(S.minItems(1)), { nullable: true }), - /** - * Array of model identifiers (slug or canonical_slug accepted) - */ - allowed_models: S.optionalWith(S.NonEmptyArray(S.String).pipe(S.minItems(1)), { nullable: true }), - /** - * Whether to enforce zero data retention - */ - enforce_zdr: S.optionalWith(S.Boolean, { nullable: true }), -}) {} - -/** - * Interval at which the limit resets (daily, weekly, monthly) - */ -export class CreateGuardrail201DataResetInterval extends S.Literal("daily", "weekly", "monthly") {} - -export class CreateGuardrail201 extends S.Struct({ - /** - * The created guardrail - */ - data: S.Struct({ - /** - * Unique identifier for the guardrail - */ - id: S.String, - /** - * Name of the guardrail - */ - name: S.String, - /** - * Description of the guardrail - */ - description: S.optionalWith(S.String, { nullable: true }), - /** - * Spending limit in USD - */ - limit_usd: S.optionalWith(S.Number.pipe(S.greaterThan(0)), { nullable: true }), - /** - * Interval at which the limit resets (daily, weekly, monthly) - */ - reset_interval: S.optionalWith(CreateGuardrail201DataResetInterval, { nullable: true }), - /** - * List of allowed provider IDs - */ - allowed_providers: S.optionalWith(S.Array(S.String), { nullable: true }), - /** - * Array of model canonical_slugs (immutable identifiers) - */ - allowed_models: S.optionalWith(S.Array(S.String), { nullable: true }), - /** - * Whether to enforce zero data retention - */ - enforce_zdr: S.optionalWith(S.Boolean, { nullable: true }), - /** - * ISO 8601 timestamp of when the guardrail was created - */ - created_at: S.String, - /** - * ISO 8601 timestamp of when the guardrail was last updated - */ - updated_at: S.optionalWith(S.String, { nullable: true }), - }), -}) {} - -/** - * Interval at which the limit resets (daily, weekly, monthly) - */ -export class GetGuardrail200DataResetInterval extends S.Literal("daily", "weekly", "monthly") {} - -export class GetGuardrail200 extends S.Struct({ - /** - * The guardrail - */ - data: S.Struct({ - /** - * Unique identifier for the guardrail - */ - id: S.String, - /** - * Name of the guardrail - */ - name: S.String, - /** - * Description of the guardrail - */ - description: S.optionalWith(S.String, { nullable: true }), - /** - * Spending limit in USD - */ - limit_usd: S.optionalWith(S.Number.pipe(S.greaterThan(0)), { nullable: true }), - /** - * Interval at which the limit resets (daily, weekly, monthly) - */ - reset_interval: S.optionalWith(GetGuardrail200DataResetInterval, { nullable: true }), - /** - * List of allowed provider IDs - */ - allowed_providers: S.optionalWith(S.Array(S.String), { nullable: true }), - /** - * Array of model canonical_slugs (immutable identifiers) - */ - allowed_models: S.optionalWith(S.Array(S.String), { nullable: true }), - /** - * Whether to enforce zero data retention - */ - enforce_zdr: S.optionalWith(S.Boolean, { nullable: true }), - /** - * ISO 8601 timestamp of when the guardrail was created - */ - created_at: S.String, - /** - * ISO 8601 timestamp of when the guardrail was last updated - */ - updated_at: S.optionalWith(S.String, { nullable: true }), - }), -}) {} - -export class DeleteGuardrail200 extends S.Struct({ - /** - * Confirmation that the guardrail was deleted - */ - deleted: S.Literal(true), -}) {} - -/** - * Interval at which the limit resets (daily, weekly, monthly) - */ -export class UpdateGuardrailRequestResetInterval extends S.Literal("daily", "weekly", "monthly") {} - -export class UpdateGuardrailRequest extends S.Class("UpdateGuardrailRequest")({ - /** - * New name for the guardrail - */ - name: S.optionalWith(S.String.pipe(S.minLength(1), S.maxLength(200)), { nullable: true }), - /** - * New description for the guardrail - */ - description: S.optionalWith(S.String.pipe(S.maxLength(1000)), { nullable: true }), - /** - * New spending limit in USD - */ - limit_usd: S.optionalWith(S.Number.pipe(S.greaterThan(0)), { nullable: true }), - /** - * Interval at which the limit resets (daily, weekly, monthly) - */ - reset_interval: S.optionalWith(UpdateGuardrailRequestResetInterval, { nullable: true }), - /** - * New list of allowed provider IDs - */ - allowed_providers: S.optionalWith(S.NonEmptyArray(S.String).pipe(S.minItems(1)), { nullable: true }), - /** - * Array of model identifiers (slug or canonical_slug accepted) - */ - allowed_models: S.optionalWith(S.NonEmptyArray(S.String).pipe(S.minItems(1)), { nullable: true }), - /** - * Whether to enforce zero data retention - */ - enforce_zdr: S.optionalWith(S.Boolean, { nullable: true }), -}) {} - -/** - * Interval at which the limit resets (daily, weekly, monthly) - */ -export class UpdateGuardrail200DataResetInterval extends S.Literal("daily", "weekly", "monthly") {} - -export class UpdateGuardrail200 extends S.Struct({ - /** - * The updated guardrail - */ - data: S.Struct({ - /** - * Unique identifier for the guardrail - */ - id: S.String, - /** - * Name of the guardrail - */ - name: S.String, - /** - * Description of the guardrail - */ - description: S.optionalWith(S.String, { nullable: true }), - /** - * Spending limit in USD - */ - limit_usd: S.optionalWith(S.Number.pipe(S.greaterThan(0)), { nullable: true }), - /** - * Interval at which the limit resets (daily, weekly, monthly) - */ - reset_interval: S.optionalWith(UpdateGuardrail200DataResetInterval, { nullable: true }), - /** - * List of allowed provider IDs - */ - allowed_providers: S.optionalWith(S.Array(S.String), { nullable: true }), - /** - * Array of model canonical_slugs (immutable identifiers) - */ - allowed_models: S.optionalWith(S.Array(S.String), { nullable: true }), - /** - * Whether to enforce zero data retention - */ - enforce_zdr: S.optionalWith(S.Boolean, { nullable: true }), - /** - * ISO 8601 timestamp of when the guardrail was created - */ - created_at: S.String, - /** - * ISO 8601 timestamp of when the guardrail was last updated - */ - updated_at: S.optionalWith(S.String, { nullable: true }), - }), -}) {} - -export class ListKeyAssignmentsParams extends S.Struct({ - /** - * Number of records to skip for pagination - */ - offset: S.optionalWith(S.String, { nullable: true }), - /** - * Maximum number of records to return (max 100) - */ - limit: S.optionalWith(S.String, { nullable: true }), -}) {} - -export class ListKeyAssignments200 extends S.Struct({ - /** - * List of key assignments - */ - data: S.Array( - S.Struct({ - /** - * Unique identifier for the assignment - */ - id: S.String, - /** - * Hash of the assigned API key - */ - key_hash: S.String, - /** - * ID of the guardrail - */ - guardrail_id: S.String, - /** - * Name of the API key - */ - key_name: S.String, - /** - * Label of the API key - */ - key_label: S.String, - /** - * User ID of who made the assignment - */ - assigned_by: S.NullOr(S.String), - /** - * ISO 8601 timestamp of when the assignment was created - */ - created_at: S.String, - }), - ), - /** - * Total number of key assignments for this guardrail - */ - total_count: S.Number, -}) {} - -export class ListMemberAssignmentsParams extends S.Struct({ - /** - * Number of records to skip for pagination - */ - offset: S.optionalWith(S.String, { nullable: true }), - /** - * Maximum number of records to return (max 100) - */ - limit: S.optionalWith(S.String, { nullable: true }), -}) {} - -export class ListMemberAssignments200 extends S.Struct({ - /** - * List of member assignments - */ - data: S.Array( - S.Struct({ - /** - * Unique identifier for the assignment - */ - id: S.String, - /** - * Clerk user ID of the assigned member - */ - user_id: S.String, - /** - * Organization ID - */ - organization_id: S.String, - /** - * ID of the guardrail - */ - guardrail_id: S.String, - /** - * User ID of who made the assignment - */ - assigned_by: S.NullOr(S.String), - /** - * ISO 8601 timestamp of when the assignment was created - */ - created_at: S.String, - }), - ), - /** - * Total number of member assignments - */ - total_count: S.Number, -}) {} - -export class ListGuardrailKeyAssignmentsParams extends S.Struct({ - /** - * Number of records to skip for pagination - */ - offset: S.optionalWith(S.String, { nullable: true }), - /** - * Maximum number of records to return (max 100) - */ - limit: S.optionalWith(S.String, { nullable: true }), -}) {} - -export class ListGuardrailKeyAssignments200 extends S.Struct({ - /** - * List of key assignments - */ - data: S.Array( - S.Struct({ - /** - * Unique identifier for the assignment - */ - id: S.String, - /** - * Hash of the assigned API key - */ - key_hash: S.String, - /** - * ID of the guardrail - */ - guardrail_id: S.String, - /** - * Name of the API key - */ - key_name: S.String, - /** - * Label of the API key - */ - key_label: S.String, - /** - * User ID of who made the assignment - */ - assigned_by: S.NullOr(S.String), - /** - * ISO 8601 timestamp of when the assignment was created - */ - created_at: S.String, - }), - ), - /** - * Total number of key assignments for this guardrail - */ - total_count: S.Number, -}) {} - -export class BulkAssignKeysToGuardrailRequest extends S.Class( - "BulkAssignKeysToGuardrailRequest", -)({ - /** - * Array of API key hashes to assign to the guardrail - */ - key_hashes: S.NonEmptyArray(S.String.pipe(S.minLength(1))).pipe(S.minItems(1)), -}) {} - -export class BulkAssignKeysToGuardrail200 extends S.Struct({ - /** - * Number of keys successfully assigned - */ - assigned_count: S.Number, -}) {} - -export class ListGuardrailMemberAssignmentsParams extends S.Struct({ - /** - * Number of records to skip for pagination - */ - offset: S.optionalWith(S.String, { nullable: true }), - /** - * Maximum number of records to return (max 100) - */ - limit: S.optionalWith(S.String, { nullable: true }), -}) {} - -export class ListGuardrailMemberAssignments200 extends S.Struct({ - /** - * List of member assignments - */ - data: S.Array( - S.Struct({ - /** - * Unique identifier for the assignment - */ - id: S.String, - /** - * Clerk user ID of the assigned member - */ - user_id: S.String, - /** - * Organization ID - */ - organization_id: S.String, - /** - * ID of the guardrail - */ - guardrail_id: S.String, - /** - * User ID of who made the assignment - */ - assigned_by: S.NullOr(S.String), - /** - * ISO 8601 timestamp of when the assignment was created - */ - created_at: S.String, - }), - ), - /** - * Total number of member assignments - */ - total_count: S.Number, -}) {} - -export class BulkAssignMembersToGuardrailRequest extends S.Class( - "BulkAssignMembersToGuardrailRequest", -)({ - /** - * Array of member user IDs to assign to the guardrail - */ - member_user_ids: S.NonEmptyArray(S.String.pipe(S.minLength(1))).pipe(S.minItems(1)), -}) {} - -export class BulkAssignMembersToGuardrail200 extends S.Struct({ - /** - * Number of members successfully assigned - */ - assigned_count: S.Number, -}) {} - -export class BulkUnassignKeysFromGuardrailRequest extends S.Class( - "BulkUnassignKeysFromGuardrailRequest", -)({ - /** - * Array of API key hashes to unassign from the guardrail - */ - key_hashes: S.NonEmptyArray(S.String.pipe(S.minLength(1))).pipe(S.minItems(1)), -}) {} - -export class BulkUnassignKeysFromGuardrail200 extends S.Struct({ - /** - * Number of keys successfully unassigned - */ - unassigned_count: S.Number, -}) {} - -export class BulkUnassignMembersFromGuardrailRequest extends S.Class( - "BulkUnassignMembersFromGuardrailRequest", -)({ - /** - * Array of member user IDs to unassign from the guardrail - */ - member_user_ids: S.NonEmptyArray(S.String.pipe(S.minLength(1))).pipe(S.minItems(1)), -}) {} - -export class BulkUnassignMembersFromGuardrail200 extends S.Struct({ - /** - * Number of members successfully unassigned - */ - unassigned_count: S.Number, -}) {} - -export class GetCurrentKey200 extends S.Struct({ - /** - * Current API key information - */ - data: S.Struct({ - /** - * Human-readable label for the API key - */ - label: S.String, - /** - * Spending limit for the API key in USD - */ - limit: S.NullOr(S.Number), - /** - * Total OpenRouter credit usage (in USD) for the API key - */ - usage: S.Number, - /** - * OpenRouter credit usage (in USD) for the current UTC day - */ - usage_daily: S.Number, - /** - * OpenRouter credit usage (in USD) for the current UTC week (Monday-Sunday) - */ - usage_weekly: S.Number, - /** - * OpenRouter credit usage (in USD) for the current UTC month - */ - usage_monthly: S.Number, - /** - * Total external BYOK usage (in USD) for the API key - */ - byok_usage: S.Number, - /** - * External BYOK usage (in USD) for the current UTC day - */ - byok_usage_daily: S.Number, - /** - * External BYOK usage (in USD) for the current UTC week (Monday-Sunday) - */ - byok_usage_weekly: S.Number, - /** - * External BYOK usage (in USD) for current UTC month - */ - byok_usage_monthly: S.Number, - /** - * Whether this is a free tier API key - */ - is_free_tier: S.Boolean, - /** - * Whether this is a provisioning key - */ - is_provisioning_key: S.Boolean, - /** - * Remaining spending limit in USD - */ - limit_remaining: S.NullOr(S.Number), - /** - * Type of limit reset for the API key - */ - limit_reset: S.NullOr(S.String), - /** - * Whether to include external BYOK usage in the credit limit - */ - include_byok_in_limit: S.Boolean, - /** - * ISO 8601 UTC timestamp when the API key expires, or null if no expiration - */ - expires_at: S.optionalWith(S.String, { nullable: true }), - /** - * Legacy rate limit information about a key. Will always return -1. - */ - rate_limit: S.Struct({ - /** - * Number of requests allowed per interval - */ - requests: S.Number, - /** - * Rate limit interval - */ - interval: S.String, - /** - * Note about the rate limit - */ - note: S.String, - }), - }), -}) {} - -/** - * The method used to generate the code challenge - */ -export class ExchangeAuthCodeForAPIKeyRequestCodeChallengeMethod extends S.Literal("S256", "plain") {} - -export class ExchangeAuthCodeForAPIKeyRequest extends S.Class( - "ExchangeAuthCodeForAPIKeyRequest", -)({ - /** - * The authorization code received from the OAuth redirect - */ - code: S.String, - /** - * The code verifier if code_challenge was used in the authorization request - */ - code_verifier: S.optionalWith(S.String, { nullable: true }), - /** - * The method used to generate the code challenge - */ - code_challenge_method: S.optionalWith(ExchangeAuthCodeForAPIKeyRequestCodeChallengeMethod, { - nullable: true, - }), -}) {} - -export class ExchangeAuthCodeForAPIKey200 extends S.Struct({ - /** - * The API key to use for OpenRouter requests - */ - key: S.String, - /** - * User ID associated with the API key - */ - user_id: S.NullOr(S.String), -}) {} - -/** - * The method used to generate the code challenge - */ -export class CreateAuthKeysCodeRequestCodeChallengeMethod extends S.Literal("S256", "plain") {} - -export class CreateAuthKeysCodeRequest extends S.Class( - "CreateAuthKeysCodeRequest", -)({ - /** - * The callback URL to redirect to after authorization. Note, only https URLs on ports 443 and 3000 are allowed. - */ - callback_url: S.String, - /** - * PKCE code challenge for enhanced security - */ - code_challenge: S.optionalWith(S.String, { nullable: true }), - /** - * The method used to generate the code challenge - */ - code_challenge_method: S.optionalWith(CreateAuthKeysCodeRequestCodeChallengeMethod, { nullable: true }), - /** - * Credit limit for the API key to be created - */ - limit: S.optionalWith(S.Number, { nullable: true }), - /** - * Optional expiration time for the API key to be created - */ - expires_at: S.optionalWith(S.String, { nullable: true }), -}) {} - -export class CreateAuthKeysCode200 extends S.Struct({ - /** - * Auth code data - */ - data: S.Struct({ - /** - * The authorization code ID to use in the exchange request - */ - id: S.String, - /** - * The application ID associated with this auth code - */ - app_id: S.Number, - /** - * ISO 8601 timestamp of when the auth code was created - */ - created_at: S.String, - }), -}) {} - -export class ChatGenerationParamsProviderEnumDataCollectionEnum extends S.Literal("deny", "allow") {} - -export class Schema0 extends S.Array( - S.Union( - S.Literal( - "AI21", - "AionLabs", - "Alibaba", - "Amazon Bedrock", - "Amazon Nova", - "Anthropic", - "Arcee AI", - "AtlasCloud", - "Avian", - "Azure", - "BaseTen", - "BytePlus", - "Black Forest Labs", - "Cerebras", - "Chutes", - "Cirrascale", - "Clarifai", - "Cloudflare", - "Cohere", - "Crusoe", - "DeepInfra", - "DeepSeek", - "Featherless", - "Fireworks", - "Friendli", - "GMICloud", - "Google", - "Google AI Studio", - "Groq", - "Hyperbolic", - "Inception", - "Inceptron", - "InferenceNet", - "Infermatic", - "Inflection", - "Liquid", - "Mara", - "Mancer 2", - "Minimax", - "ModelRun", - "Mistral", - "Modular", - "Moonshot AI", - "Morph", - "NCompass", - "Nebius", - "NextBit", - "Novita", - "Nvidia", - "OpenAI", - "OpenInference", - "Parasail", - "Perplexity", - "Phala", - "Relace", - "SambaNova", - "Seed", - "SiliconFlow", - "Sourceful", - "Stealth", - "StreamLake", - "Switchpoint", - "Together", - "Upstage", - "Venice", - "WandB", - "Xiaomi", - "xAI", - "Z.AI", - "FakeProvider", - ), - S.String, - ), -) {} - -export class ProviderSortUnion extends S.Union(ProviderSort, ProviderSortConfig) {} - -export class Schema1 extends S.Union(S.Number, S.String, S.Number) {} - -export class ChatGenerationParamsRouteEnum extends S.Literal("fallback", "sort") {} - -export class ChatMessageContentItemCacheControlTtl extends S.Literal("5m", "1h") {} - -export class ChatMessageContentItemCacheControl extends S.Class( - "ChatMessageContentItemCacheControl", -)({ - type: S.Literal("ephemeral"), - ttl: S.optionalWith(ChatMessageContentItemCacheControlTtl, { nullable: true }), -}) {} - -export class ChatMessageContentItemText extends S.Class( - "ChatMessageContentItemText", -)({ - type: S.Literal("text"), - text: S.String, - cache_control: S.optionalWith(ChatMessageContentItemCacheControl, { nullable: true }), -}) {} - -export class SystemMessage extends S.Class("SystemMessage")({ - role: S.Literal("system"), - content: S.Union(S.String, S.Array(ChatMessageContentItemText)), - name: S.optionalWith(S.String, { nullable: true }), -}) {} - -export class ChatMessageContentItemImageImageUrlDetail extends S.Literal("auto", "low", "high") {} - -export class ChatMessageContentItemImage extends S.Class( - "ChatMessageContentItemImage", -)({ - type: S.Literal("image_url"), - image_url: S.Struct({ - url: S.String, - detail: S.optionalWith(ChatMessageContentItemImageImageUrlDetail, { nullable: true }), - }), -}) {} - -export class ChatMessageContentItemAudio extends S.Class( - "ChatMessageContentItemAudio", -)({ - type: S.Literal("input_audio"), - input_audio: S.Struct({ - data: S.String, - format: S.String, - }), -}) {} - -export class ChatMessageContentItemVideo extends S.Record({ key: S.String, value: S.Unknown }) {} - -export class ChatMessageContentItem extends S.Record({ key: S.String, value: S.Unknown }) {} - -export class UserMessage extends S.Class("UserMessage")({ - role: S.Literal("user"), - content: S.Union(S.String, S.Array(ChatMessageContentItem)), - name: S.optionalWith(S.String, { nullable: true }), -}) {} - -export class ChatMessageToolCall extends S.Class("ChatMessageToolCall")({ - id: S.String, - type: S.Literal("function"), - function: S.Struct({ - name: S.String, - arguments: S.String, - }), -}) {} - -export class Schema3 extends S.Union(S.String, S.Null) {} - -export class Schema4Enum extends S.Literal( - "unknown", - "openai-responses-v1", - "azure-openai-responses-v1", - "xai-responses-v1", - "anthropic-claude-v1", - "google-gemini-v1", -) {} - -export class Schema4 extends S.Union(Schema4Enum, S.Null) {} - -export class Schema5 extends S.Number {} - -export class Schema2 extends S.Record({ key: S.String, value: S.Unknown }) {} - -export class AssistantMessage extends S.Class("AssistantMessage")({ - role: S.Literal("assistant"), - content: S.optionalWith(S.Union(S.String, S.Array(ChatMessageContentItem)), { nullable: true }), - name: S.optionalWith(S.String, { nullable: true }), - tool_calls: S.optionalWith(S.Array(ChatMessageToolCall), { nullable: true }), - refusal: S.optionalWith(S.String, { nullable: true }), - reasoning: S.optionalWith(S.String, { nullable: true }), - reasoning_details: S.optionalWith(S.Array(ReasoningDetail), { nullable: true }), - images: S.optionalWith( - S.Array( - S.Struct({ - image_url: S.Struct({ - url: S.String, - }), - }), - ), - { nullable: true }, - ), - annotations: S.optionalWith(S.Array(AnnotationDetail), { nullable: true }), -}) {} - -export class ToolResponseMessage extends S.Class("ToolResponseMessage")({ - role: S.Literal("tool"), - content: S.Union(S.String, S.Array(ChatMessageContentItem)), - tool_call_id: S.String, -}) {} - -export class Message extends S.Record({ key: S.String, value: S.Unknown }) {} - -export class ModelName extends S.String {} - -export class ChatGenerationParamsReasoningEffortEnum extends S.Literal( - "xhigh", - "high", - "medium", - "low", - "minimal", - "none", -) {} - -export class JSONSchemaConfig extends S.Class("JSONSchemaConfig")({ - name: S.String.pipe(S.maxLength(64)), - description: S.optionalWith(S.String, { nullable: true }), - schema: S.optionalWith(S.Record({ key: S.String, value: S.Unknown }), { nullable: true }), - strict: S.optionalWith(S.Boolean, { nullable: true }), -}) {} - -export class ResponseFormatJSONSchema extends S.Class("ResponseFormatJSONSchema")({ - type: S.Literal("json_schema"), - json_schema: JSONSchemaConfig, -}) {} - -export class ResponseFormatTextGrammar extends S.Class( - "ResponseFormatTextGrammar", -)({ - type: S.Literal("grammar"), - grammar: S.String, -}) {} - -export class ChatStreamOptions extends S.Class("ChatStreamOptions")({ - include_usage: S.optionalWith(S.Boolean, { nullable: true }), -}) {} - -export class NamedToolChoice extends S.Class("NamedToolChoice")({ - type: S.Literal("function"), - function: S.Struct({ - name: S.String, - }), -}) {} - -export class ToolChoiceOption extends S.Union( - S.Literal("none"), - S.Literal("auto"), - S.Literal("required"), - NamedToolChoice, -) {} - -export class ToolDefinitionJson extends S.Class("ToolDefinitionJson")({ - type: S.Literal("function"), - function: S.Struct({ - name: S.String.pipe(S.maxLength(64)), - description: S.optionalWith(S.String, { nullable: true }), - parameters: S.optionalWith(S.Record({ key: S.String, value: S.Unknown }), { nullable: true }), - strict: S.optionalWith(S.Boolean, { nullable: true }), - }), -}) {} - -export class ChatGenerationParams extends S.Class("ChatGenerationParams")({ - /** - * When multiple model providers are available, optionally indicate your routing preference. - */ - provider: S.optionalWith( - S.Struct({ - /** - * Whether to allow backup providers to serve requests - * - true: (default) when the primary provider (or your custom providers in "order") is unavailable, use the next best provider. - * - false: use only the primary/custom provider, and return the upstream error if it's unavailable. - */ - allow_fallbacks: S.optionalWith(S.Boolean, { nullable: true }), - /** - * Whether to filter providers to only those that support the parameters you've provided. If this setting is omitted or set to false, then providers will receive only the parameters they support, and ignore the rest. - */ - require_parameters: S.optionalWith(S.Boolean, { nullable: true }), - /** - * Data collection setting. If no available model provider meets the requirement, your request will return an error. - * - allow: (default) allow providers which store user data non-transiently and may train on it - * - * - deny: use only providers which do not collect user data. - */ - data_collection: S.optionalWith(S.Literal("deny", "allow"), { nullable: true }), - zdr: S.optionalWith(S.Boolean, { nullable: true }), - enforce_distillable_text: S.optionalWith(S.Boolean, { nullable: true }), - /** - * An ordered list of provider slugs. The router will attempt to use the first provider in the subset of this list that supports your requested model, and fall back to the next if it is unavailable. If no providers are available, the request will fail with an error message. - */ - order: S.optionalWith(Schema0, { nullable: true }), - /** - * List of provider slugs to allow. If provided, this list is merged with your account-wide allowed provider settings for this request. - */ - only: S.optionalWith(Schema0, { nullable: true }), - /** - * List of provider slugs to ignore. If provided, this list is merged with your account-wide ignored provider settings for this request. - */ - ignore: S.optionalWith(Schema0, { nullable: true }), - /** - * A list of quantization levels to filter the provider by. - */ - quantizations: S.optionalWith( - S.Array(S.Literal("int4", "int8", "fp4", "fp6", "fp8", "fp16", "bf16", "fp32", "unknown")), - { nullable: true }, - ), - /** - * The sorting strategy to use for this request, if "order" is not specified. When set, no load balancing is performed. - */ - sort: S.optionalWith(ProviderSortUnion, { nullable: true }), - /** - * The object specifying the maximum price you want to pay for this request. USD price per million tokens, for prompt and completion. - */ - max_price: S.optionalWith( - S.Struct({ - prompt: S.optionalWith(Schema1, { nullable: true }), - completion: S.optionalWith(Schema1, { nullable: true }), - image: S.optionalWith(Schema1, { nullable: true }), - audio: S.optionalWith(Schema1, { nullable: true }), - request: S.optionalWith(Schema1, { nullable: true }), - }), - { nullable: true }, - ), - /** - * Preferred minimum throughput (in tokens per second). Can be a number (applies to p50) or an object with percentile-specific cutoffs. Endpoints below the threshold(s) may still be used, but are deprioritized in routing. When using fallback models, this may cause a fallback model to be used instead of the primary model if it meets the threshold. - */ - preferred_min_throughput: S.optionalWith( - S.Union( - S.Number, - S.Struct({ - p50: S.optionalWith(S.Number, { nullable: true }), - p75: S.optionalWith(S.Number, { nullable: true }), - p90: S.optionalWith(S.Number, { nullable: true }), - p99: S.optionalWith(S.Number, { nullable: true }), - }), - ), - { nullable: true }, - ), - /** - * Preferred maximum latency (in seconds). Can be a number (applies to p50) or an object with percentile-specific cutoffs. Endpoints above the threshold(s) may still be used, but are deprioritized in routing. When using fallback models, this may cause a fallback model to be used instead of the primary model if it meets the threshold. - */ - preferred_max_latency: S.optionalWith( - S.Union( - S.Number, - S.Struct({ - p50: S.optionalWith(S.Number, { nullable: true }), - p75: S.optionalWith(S.Number, { nullable: true }), - p90: S.optionalWith(S.Number, { nullable: true }), - p99: S.optionalWith(S.Number, { nullable: true }), - }), - ), - { nullable: true }, - ), - }), - { nullable: true }, - ), - /** - * Plugins you want to enable for this request, including their settings. - */ - plugins: S.optionalWith(S.Array(S.Record({ key: S.String, value: S.Unknown })), { nullable: true }), - route: S.optionalWith(ChatGenerationParamsRouteEnum, { nullable: true }), - user: S.optionalWith(S.String, { nullable: true }), - /** - * A unique identifier for grouping related requests (e.g., a conversation or agent workflow) for observability. If provided in both the request body and the x-session-id header, the body value takes precedence. Maximum of 128 characters. - */ - session_id: S.optionalWith(S.String.pipe(S.maxLength(128)), { nullable: true }), - messages: S.NonEmptyArray(Message).pipe(S.minItems(1)), - model: S.optionalWith(ModelName, { nullable: true }), - models: S.optionalWith(S.Array(ModelName), { nullable: true }), - frequency_penalty: S.optionalWith(S.Number.pipe(S.greaterThanOrEqualTo(-2), S.lessThanOrEqualTo(2)), { - nullable: true, - }), - logit_bias: S.optionalWith(S.Record({ key: S.String, value: S.Unknown }), { nullable: true }), - logprobs: S.optionalWith(S.Boolean, { nullable: true }), - top_logprobs: S.optionalWith(S.Number.pipe(S.greaterThanOrEqualTo(0), S.lessThanOrEqualTo(20)), { - nullable: true, - }), - max_completion_tokens: S.optionalWith(S.Number.pipe(S.greaterThanOrEqualTo(1)), { nullable: true }), - max_tokens: S.optionalWith(S.Number.pipe(S.greaterThanOrEqualTo(1)), { nullable: true }), - metadata: S.optionalWith(S.Record({ key: S.String, value: S.Unknown }), { nullable: true }), - presence_penalty: S.optionalWith(S.Number.pipe(S.greaterThanOrEqualTo(-2), S.lessThanOrEqualTo(2)), { - nullable: true, - }), - reasoning: S.optionalWith( - S.Struct({ - effort: S.optionalWith(ChatGenerationParamsReasoningEffortEnum, { nullable: true }), - summary: S.optionalWith(ReasoningSummaryVerbosity, { nullable: true }), - }), - { nullable: true }, - ), - response_format: S.optionalWith(S.Record({ key: S.String, value: S.Unknown }), { nullable: true }), - seed: S.optionalWith( - S.Int.pipe(S.greaterThanOrEqualTo(-9007199254740991), S.lessThanOrEqualTo(9007199254740991)), - { - nullable: true, - }, - ), - stop: S.optionalWith(S.Union(S.String, S.Array(S.String).pipe(S.maxItems(4))), { nullable: true }), - stream: S.optionalWith(S.Boolean, { nullable: true, default: () => false as const }), - stream_options: S.optionalWith(ChatStreamOptions, { nullable: true }), - temperature: S.optionalWith(S.Number.pipe(S.greaterThanOrEqualTo(0), S.lessThanOrEqualTo(2)), { - nullable: true, - default: () => 1 as const, - }), - tool_choice: S.optionalWith(ToolChoiceOption, { nullable: true }), - tools: S.optionalWith(S.Array(ToolDefinitionJson), { nullable: true }), - top_p: S.optionalWith(S.Number.pipe(S.greaterThanOrEqualTo(0), S.lessThanOrEqualTo(1)), { - nullable: true, - default: () => 1 as const, - }), - debug: S.optionalWith( - S.Struct({ - echo_upstream_body: S.optionalWith(S.Boolean, { nullable: true }), - }), - { nullable: true }, - ), - image_config: S.optionalWith(S.Record({ key: S.String, value: S.Unknown }), { nullable: true }), - modalities: S.optionalWith(S.Array(S.Literal("text", "image")), { nullable: true }), -}) {} - -export class ChatCompletionFinishReason extends S.Literal( - "tool_calls", - "stop", - "length", - "content_filter", - "error", -) {} - -export class Schema6 extends S.Union(ChatCompletionFinishReason, S.Null) {} - -export class ChatMessageTokenLogprob extends S.Class("ChatMessageTokenLogprob")({ - token: S.String, - logprob: S.Number, - bytes: S.NullOr(S.Array(S.Number)), - top_logprobs: S.Array( - S.Struct({ - token: S.String, - logprob: S.Number, - bytes: S.NullOr(S.Array(S.Number)), - }), - ), -}) {} - -export class ChatMessageTokenLogprobs extends S.Class("ChatMessageTokenLogprobs")({ - content: S.optionalWith(S.Array(ChatMessageTokenLogprob), { nullable: true }), - refusal: S.optionalWith(S.Array(ChatMessageTokenLogprob), { nullable: true }), -}) {} - -export class ChatResponseChoice extends S.Class("ChatResponseChoice")({ - finish_reason: S.NullOr(ChatCompletionFinishReason), - index: S.Number, - message: AssistantMessage, - logprobs: S.optionalWith(ChatMessageTokenLogprobs, { nullable: true }), -}) {} - -export class ChatGenerationTokenUsage extends S.Class("ChatGenerationTokenUsage")({ - completion_tokens: S.Number, - prompt_tokens: S.Number, - total_tokens: S.Number, - completion_tokens_details: S.optionalWith( - S.Struct({ - reasoning_tokens: S.optionalWith(S.Number, { nullable: true }), - audio_tokens: S.optionalWith(S.Number, { nullable: true }), - accepted_prediction_tokens: S.optionalWith(S.Number, { nullable: true }), - rejected_prediction_tokens: S.optionalWith(S.Number, { nullable: true }), - }), - { nullable: true }, - ), - prompt_tokens_details: S.optionalWith( - S.Struct({ - cached_tokens: S.optionalWith(S.Number, { nullable: true }), - cache_write_tokens: S.optionalWith(S.Number, { nullable: true }), - audio_tokens: S.optionalWith(S.Number, { nullable: true }), - video_tokens: S.optionalWith(S.Number, { nullable: true }), - }), - { nullable: true }, - ), - cost: S.optionalWith(S.Number, { nullable: true }), - cost_details: S.optionalWith( - S.Struct({ upstream_inference_cost: S.optionalWith(S.Number, { nullable: true }) }), - { - nullable: true, - }, - ), -}) {} - -export class ChatResponse extends S.Class("ChatResponse")({ - id: S.String, - provider: S.optionalWith(S.String, { nullable: true }), - choices: S.Array(ChatResponseChoice), - created: S.Number, - model: S.String, - object: S.Literal("chat.completion"), - system_fingerprint: S.optionalWith(S.String, { nullable: true }), - usage: S.optionalWith(ChatGenerationTokenUsage, { nullable: true }), -}) {} - -export class ChatError extends S.Class("ChatError")({ - error: S.Struct({ - code: S.NullOr(S.Union(S.String, S.Number)), - message: S.String, - param: S.optionalWith(S.String, { nullable: true }), - type: S.optionalWith(S.String, { nullable: true }), - }), -}) {} - -export class CompletionCreateParams extends S.Class("CompletionCreateParams")({ - model: S.optionalWith(ModelName, { nullable: true }), - models: S.optionalWith(S.Array(ModelName), { nullable: true }), - prompt: S.Union(S.String, S.Array(S.String), S.Array(S.Number), S.Array(S.Array(S.Number))), - best_of: S.optionalWith(S.Int.pipe(S.greaterThanOrEqualTo(1), S.lessThanOrEqualTo(20)), { - nullable: true, - }), - echo: S.optionalWith(S.Boolean, { nullable: true }), - frequency_penalty: S.optionalWith(S.Number.pipe(S.greaterThanOrEqualTo(-2), S.lessThanOrEqualTo(2)), { - nullable: true, - }), - logit_bias: S.optionalWith(S.Record({ key: S.String, value: S.Unknown }), { nullable: true }), - logprobs: S.optionalWith(S.Int.pipe(S.greaterThanOrEqualTo(0), S.lessThanOrEqualTo(5)), { - nullable: true, - }), - max_tokens: S.optionalWith(S.Int.pipe(S.greaterThanOrEqualTo(1), S.lessThanOrEqualTo(9007199254740991)), { - nullable: true, - }), - n: S.optionalWith(S.Int.pipe(S.greaterThanOrEqualTo(1), S.lessThanOrEqualTo(128)), { nullable: true }), - presence_penalty: S.optionalWith(S.Number.pipe(S.greaterThanOrEqualTo(-2), S.lessThanOrEqualTo(2)), { - nullable: true, - }), - seed: S.optionalWith( - S.Int.pipe(S.greaterThanOrEqualTo(-9007199254740991), S.lessThanOrEqualTo(9007199254740991)), - { - nullable: true, - }, - ), - stop: S.optionalWith(S.Union(S.String, S.Array(S.String)), { nullable: true }), - stream: S.optionalWith(S.Boolean, { nullable: true, default: () => false as const }), - stream_options: S.optionalWith( - S.Struct({ - include_usage: S.optionalWith(S.Boolean, { nullable: true }), - }), - { nullable: true }, - ), - suffix: S.optionalWith(S.String, { nullable: true }), - temperature: S.optionalWith(S.Number.pipe(S.greaterThanOrEqualTo(0), S.lessThanOrEqualTo(2)), { - nullable: true, - }), - top_p: S.optionalWith(S.Number.pipe(S.greaterThanOrEqualTo(0), S.lessThanOrEqualTo(1)), { - nullable: true, - }), - user: S.optionalWith(S.String, { nullable: true }), - metadata: S.optionalWith(S.Record({ key: S.String, value: S.Unknown }), { nullable: true }), - response_format: S.optionalWith(S.Record({ key: S.String, value: S.Unknown }), { nullable: true }), -}) {} - -export class CompletionLogprobs extends S.Class("CompletionLogprobs")({ - tokens: S.Array(S.String), - token_logprobs: S.Array(S.Number), - top_logprobs: S.NullOr(S.Array(S.Record({ key: S.String, value: S.Unknown }))), - text_offset: S.Array(S.Number), -}) {} - -export class CompletionFinishReasonEnum extends S.Literal("stop", "length", "content_filter") {} - -export class CompletionFinishReason extends S.Union(CompletionFinishReasonEnum, S.Null) {} - -export class CompletionChoice extends S.Class("CompletionChoice")({ - text: S.String, - index: S.Number, - logprobs: S.NullOr(CompletionLogprobs), - finish_reason: S.NullOr(S.Literal("stop", "length", "content_filter")), - native_finish_reason: S.optionalWith(S.String, { nullable: true }), - reasoning: S.optionalWith(S.String, { nullable: true }), -}) {} - -export class CompletionUsage extends S.Class("CompletionUsage")({ - prompt_tokens: S.Number, - completion_tokens: S.Number, - total_tokens: S.Number, -}) {} - -export class CompletionResponse extends S.Class("CompletionResponse")({ - id: S.String, - object: S.Literal("text_completion"), - created: S.Number, - model: S.String, - provider: S.optionalWith(S.String, { nullable: true }), - system_fingerprint: S.optionalWith(S.String, { nullable: true }), - choices: S.Array(CompletionChoice), - usage: S.optionalWith(CompletionUsage, { nullable: true }), -}) {} - -export const make = ( - httpClient: HttpClient.HttpClient, - options: { - readonly transformClient?: - | ((client: HttpClient.HttpClient) => Effect.Effect) - | undefined - } = {}, -): Client => { - const unexpectedStatus = (response: HttpClientResponse.HttpClientResponse) => - Effect.flatMap( - Effect.orElseSucceed(response.json, () => "Unexpected status code"), - (description) => - Effect.fail( - new HttpClientError.ResponseError({ - request: response.request, - response, - reason: "StatusCode", - description: - typeof description === "string" ? description : JSON.stringify(description), - }), - ), - ) - const withResponse: ( - f: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect, - ) => (request: HttpClientRequest.HttpClientRequest) => Effect.Effect = options.transformClient - ? (f) => (request) => - Effect.flatMap( - Effect.flatMap(options.transformClient!(httpClient), (client) => client.execute(request)), - f, - ) - : (f) => (request) => Effect.flatMap(httpClient.execute(request), f) - const decodeSuccess = - (schema: S.Schema) => - (response: HttpClientResponse.HttpClientResponse) => - HttpClientResponse.schemaBodyJson(schema)(response) - const decodeError = - (tag: Tag, schema: S.Schema) => - (response: HttpClientResponse.HttpClientResponse) => - Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)(response), (cause) => - Effect.fail(ClientError(tag, cause, response)), - ) - return { - httpClient, - createResponses: (options) => - HttpClientRequest.post(`/responses`).pipe( - HttpClientRequest.bodyUnsafeJson(options), - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(OpenResponsesNonStreamingResponse), - "400": decodeError("BadRequestResponse", BadRequestResponse), - "401": decodeError("UnauthorizedResponse", UnauthorizedResponse), - "402": decodeError("PaymentRequiredResponse", PaymentRequiredResponse), - "404": decodeError("NotFoundResponse", NotFoundResponse), - "408": decodeError("RequestTimeoutResponse", RequestTimeoutResponse), - "413": decodeError("PayloadTooLargeResponse", PayloadTooLargeResponse), - "422": decodeError("UnprocessableEntityResponse", UnprocessableEntityResponse), - "429": decodeError("TooManyRequestsResponse", TooManyRequestsResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - "502": decodeError("BadGatewayResponse", BadGatewayResponse), - "503": decodeError("ServiceUnavailableResponse", ServiceUnavailableResponse), - "524": decodeError("EdgeNetworkTimeoutResponse", EdgeNetworkTimeoutResponse), - "529": decodeError("ProviderOverloadedResponse", ProviderOverloadedResponse), - orElse: unexpectedStatus, - }), - ), - ), - createMessages: (options) => - HttpClientRequest.post(`/messages`).pipe( - HttpClientRequest.bodyUnsafeJson(options), - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(AnthropicMessagesResponse), - "400": decodeError("CreateMessages400", CreateMessages400), - "401": decodeError("CreateMessages401", CreateMessages401), - "403": decodeError("CreateMessages403", CreateMessages403), - "404": decodeError("CreateMessages404", CreateMessages404), - "429": decodeError("CreateMessages429", CreateMessages429), - "500": decodeError("CreateMessages500", CreateMessages500), - "503": decodeError("CreateMessages503", CreateMessages503), - "529": decodeError("CreateMessages529", CreateMessages529), - orElse: unexpectedStatus, - }), - ), - ), - getUserActivity: (options) => - HttpClientRequest.get(`/activity`).pipe( - HttpClientRequest.setUrlParams({ date: options?.["date"] as any }), - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(GetUserActivity200), - "400": decodeError("BadRequestResponse", BadRequestResponse), - "401": decodeError("UnauthorizedResponse", UnauthorizedResponse), - "403": decodeError("ForbiddenResponse", ForbiddenResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - getCredits: () => - HttpClientRequest.get(`/credits`).pipe( - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(GetCredits200), - "401": decodeError("UnauthorizedResponse", UnauthorizedResponse), - "403": decodeError("ForbiddenResponse", ForbiddenResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - createCoinbaseCharge: (options) => - HttpClientRequest.post(`/credits/coinbase`).pipe( - HttpClientRequest.bodyUnsafeJson(options), - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(CreateCoinbaseCharge200), - "400": decodeError("BadRequestResponse", BadRequestResponse), - "401": decodeError("UnauthorizedResponse", UnauthorizedResponse), - "429": decodeError("TooManyRequestsResponse", TooManyRequestsResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - createEmbeddings: (options) => - HttpClientRequest.post(`/embeddings`).pipe( - HttpClientRequest.bodyUnsafeJson(options), - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(CreateEmbeddings200), - "400": decodeError("BadRequestResponse", BadRequestResponse), - "401": decodeError("UnauthorizedResponse", UnauthorizedResponse), - "402": decodeError("PaymentRequiredResponse", PaymentRequiredResponse), - "404": decodeError("NotFoundResponse", NotFoundResponse), - "429": decodeError("TooManyRequestsResponse", TooManyRequestsResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - "502": decodeError("BadGatewayResponse", BadGatewayResponse), - "503": decodeError("ServiceUnavailableResponse", ServiceUnavailableResponse), - "524": decodeError("EdgeNetworkTimeoutResponse", EdgeNetworkTimeoutResponse), - "529": decodeError("ProviderOverloadedResponse", ProviderOverloadedResponse), - orElse: unexpectedStatus, - }), - ), - ), - listEmbeddingsModels: () => - HttpClientRequest.get(`/embeddings/models`).pipe( - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(ModelsListResponse), - "400": decodeError("BadRequestResponse", BadRequestResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - getGeneration: (options) => - HttpClientRequest.get(`/generation`).pipe( - HttpClientRequest.setUrlParams({ id: options?.["id"] as any }), - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(GetGeneration200), - "401": decodeError("UnauthorizedResponse", UnauthorizedResponse), - "402": decodeError("PaymentRequiredResponse", PaymentRequiredResponse), - "404": decodeError("NotFoundResponse", NotFoundResponse), - "429": decodeError("TooManyRequestsResponse", TooManyRequestsResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - "502": decodeError("BadGatewayResponse", BadGatewayResponse), - "524": decodeError("EdgeNetworkTimeoutResponse", EdgeNetworkTimeoutResponse), - "529": decodeError("ProviderOverloadedResponse", ProviderOverloadedResponse), - orElse: unexpectedStatus, - }), - ), - ), - listModelsCount: () => - HttpClientRequest.get(`/models/count`).pipe( - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(ModelsCountResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - getModels: (options) => - HttpClientRequest.get(`/models`).pipe( - HttpClientRequest.setUrlParams({ - category: options?.["category"] as any, - supported_parameters: options?.["supported_parameters"] as any, - }), - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(ModelsListResponse), - "400": decodeError("BadRequestResponse", BadRequestResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - listModelsUser: () => - HttpClientRequest.get(`/models/user`).pipe( - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(ModelsListResponse), - "401": decodeError("UnauthorizedResponse", UnauthorizedResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - listEndpoints: (author, slug) => - HttpClientRequest.get(`/models/${author}/${slug}/endpoints`).pipe( - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(ListEndpoints200), - "404": decodeError("NotFoundResponse", NotFoundResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - listEndpointsZdr: () => - HttpClientRequest.get(`/endpoints/zdr`).pipe( - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(ListEndpointsZdr200), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - listProviders: () => - HttpClientRequest.get(`/providers`).pipe( - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(ListProviders200), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - list: (options) => - HttpClientRequest.get(`/keys`).pipe( - HttpClientRequest.setUrlParams({ - include_disabled: options?.["include_disabled"] as any, - offset: options?.["offset"] as any, - }), - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(List200), - "401": decodeError("UnauthorizedResponse", UnauthorizedResponse), - "429": decodeError("TooManyRequestsResponse", TooManyRequestsResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - createKeys: (options) => - HttpClientRequest.post(`/keys`).pipe( - HttpClientRequest.bodyUnsafeJson(options), - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(CreateKeys201), - "400": decodeError("BadRequestResponse", BadRequestResponse), - "401": decodeError("UnauthorizedResponse", UnauthorizedResponse), - "429": decodeError("TooManyRequestsResponse", TooManyRequestsResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - getKey: (hash) => - HttpClientRequest.get(`/keys/${hash}`).pipe( - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(GetKey200), - "401": decodeError("UnauthorizedResponse", UnauthorizedResponse), - "404": decodeError("NotFoundResponse", NotFoundResponse), - "429": decodeError("TooManyRequestsResponse", TooManyRequestsResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - deleteKeys: (hash) => - HttpClientRequest.del(`/keys/${hash}`).pipe( - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(DeleteKeys200), - "401": decodeError("UnauthorizedResponse", UnauthorizedResponse), - "404": decodeError("NotFoundResponse", NotFoundResponse), - "429": decodeError("TooManyRequestsResponse", TooManyRequestsResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - updateKeys: (hash, options) => - HttpClientRequest.patch(`/keys/${hash}`).pipe( - HttpClientRequest.bodyUnsafeJson(options), - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(UpdateKeys200), - "400": decodeError("BadRequestResponse", BadRequestResponse), - "401": decodeError("UnauthorizedResponse", UnauthorizedResponse), - "404": decodeError("NotFoundResponse", NotFoundResponse), - "429": decodeError("TooManyRequestsResponse", TooManyRequestsResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - listGuardrails: (options) => - HttpClientRequest.get(`/guardrails`).pipe( - HttpClientRequest.setUrlParams({ - offset: options?.["offset"] as any, - limit: options?.["limit"] as any, - }), - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(ListGuardrails200), - "401": decodeError("UnauthorizedResponse", UnauthorizedResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - createGuardrail: (options) => - HttpClientRequest.post(`/guardrails`).pipe( - HttpClientRequest.bodyUnsafeJson(options), - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(CreateGuardrail201), - "400": decodeError("BadRequestResponse", BadRequestResponse), - "401": decodeError("UnauthorizedResponse", UnauthorizedResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - getGuardrail: (id) => - HttpClientRequest.get(`/guardrails/${id}`).pipe( - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(GetGuardrail200), - "401": decodeError("UnauthorizedResponse", UnauthorizedResponse), - "404": decodeError("NotFoundResponse", NotFoundResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - deleteGuardrail: (id) => - HttpClientRequest.del(`/guardrails/${id}`).pipe( - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(DeleteGuardrail200), - "401": decodeError("UnauthorizedResponse", UnauthorizedResponse), - "404": decodeError("NotFoundResponse", NotFoundResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - updateGuardrail: (id, options) => - HttpClientRequest.patch(`/guardrails/${id}`).pipe( - HttpClientRequest.bodyUnsafeJson(options), - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(UpdateGuardrail200), - "400": decodeError("BadRequestResponse", BadRequestResponse), - "401": decodeError("UnauthorizedResponse", UnauthorizedResponse), - "404": decodeError("NotFoundResponse", NotFoundResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - listKeyAssignments: (options) => - HttpClientRequest.get(`/guardrails/assignments/keys`).pipe( - HttpClientRequest.setUrlParams({ - offset: options?.["offset"] as any, - limit: options?.["limit"] as any, - }), - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(ListKeyAssignments200), - "401": decodeError("UnauthorizedResponse", UnauthorizedResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - listMemberAssignments: (options) => - HttpClientRequest.get(`/guardrails/assignments/members`).pipe( - HttpClientRequest.setUrlParams({ - offset: options?.["offset"] as any, - limit: options?.["limit"] as any, - }), - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(ListMemberAssignments200), - "401": decodeError("UnauthorizedResponse", UnauthorizedResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - listGuardrailKeyAssignments: (id, options) => - HttpClientRequest.get(`/guardrails/${id}/assignments/keys`).pipe( - HttpClientRequest.setUrlParams({ - offset: options?.["offset"] as any, - limit: options?.["limit"] as any, - }), - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(ListGuardrailKeyAssignments200), - "401": decodeError("UnauthorizedResponse", UnauthorizedResponse), - "404": decodeError("NotFoundResponse", NotFoundResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - bulkAssignKeysToGuardrail: (id, options) => - HttpClientRequest.post(`/guardrails/${id}/assignments/keys`).pipe( - HttpClientRequest.bodyUnsafeJson(options), - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(BulkAssignKeysToGuardrail200), - "400": decodeError("BadRequestResponse", BadRequestResponse), - "401": decodeError("UnauthorizedResponse", UnauthorizedResponse), - "404": decodeError("NotFoundResponse", NotFoundResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - listGuardrailMemberAssignments: (id, options) => - HttpClientRequest.get(`/guardrails/${id}/assignments/members`).pipe( - HttpClientRequest.setUrlParams({ - offset: options?.["offset"] as any, - limit: options?.["limit"] as any, - }), - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(ListGuardrailMemberAssignments200), - "401": decodeError("UnauthorizedResponse", UnauthorizedResponse), - "404": decodeError("NotFoundResponse", NotFoundResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - bulkAssignMembersToGuardrail: (id, options) => - HttpClientRequest.post(`/guardrails/${id}/assignments/members`).pipe( - HttpClientRequest.bodyUnsafeJson(options), - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(BulkAssignMembersToGuardrail200), - "400": decodeError("BadRequestResponse", BadRequestResponse), - "401": decodeError("UnauthorizedResponse", UnauthorizedResponse), - "404": decodeError("NotFoundResponse", NotFoundResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - bulkUnassignKeysFromGuardrail: (id, options) => - HttpClientRequest.post(`/guardrails/${id}/assignments/keys/remove`).pipe( - HttpClientRequest.bodyUnsafeJson(options), - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(BulkUnassignKeysFromGuardrail200), - "400": decodeError("BadRequestResponse", BadRequestResponse), - "401": decodeError("UnauthorizedResponse", UnauthorizedResponse), - "404": decodeError("NotFoundResponse", NotFoundResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - bulkUnassignMembersFromGuardrail: (id, options) => - HttpClientRequest.post(`/guardrails/${id}/assignments/members/remove`).pipe( - HttpClientRequest.bodyUnsafeJson(options), - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(BulkUnassignMembersFromGuardrail200), - "400": decodeError("BadRequestResponse", BadRequestResponse), - "401": decodeError("UnauthorizedResponse", UnauthorizedResponse), - "404": decodeError("NotFoundResponse", NotFoundResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - getCurrentKey: () => - HttpClientRequest.get(`/key`).pipe( - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(GetCurrentKey200), - "401": decodeError("UnauthorizedResponse", UnauthorizedResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - exchangeAuthCodeForAPIKey: (options) => - HttpClientRequest.post(`/auth/keys`).pipe( - HttpClientRequest.bodyUnsafeJson(options), - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(ExchangeAuthCodeForAPIKey200), - "400": decodeError("BadRequestResponse", BadRequestResponse), - "403": decodeError("ForbiddenResponse", ForbiddenResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - createAuthKeysCode: (options) => - HttpClientRequest.post(`/auth/keys/code`).pipe( - HttpClientRequest.bodyUnsafeJson(options), - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(CreateAuthKeysCode200), - "400": decodeError("BadRequestResponse", BadRequestResponse), - "401": decodeError("UnauthorizedResponse", UnauthorizedResponse), - "500": decodeError("InternalServerResponse", InternalServerResponse), - orElse: unexpectedStatus, - }), - ), - ), - sendChatCompletionRequest: (options) => - HttpClientRequest.post(`/chat/completions`).pipe( - HttpClientRequest.bodyUnsafeJson(options), - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(ChatResponse), - "400": decodeError("ChatError", ChatError), - "401": decodeError("ChatError", ChatError), - "429": decodeError("ChatError", ChatError), - "500": decodeError("ChatError", ChatError), - orElse: unexpectedStatus, - }), - ), - ), - createCompletions: (options) => - HttpClientRequest.post(`/completions`).pipe( - HttpClientRequest.bodyUnsafeJson(options), - withResponse( - HttpClientResponse.matchStatus({ - "2xx": decodeSuccess(CompletionResponse), - "400": decodeError("ChatError", ChatError), - "401": decodeError("ChatError", ChatError), - "429": decodeError("ChatError", ChatError), - "500": decodeError("ChatError", ChatError), - orElse: unexpectedStatus, - }), - ), - ), - } -} - -export interface Client { - readonly httpClient: HttpClient.HttpClient - /** - * Creates a streaming or non-streaming response using OpenResponses API format - */ - readonly createResponses: ( - options: typeof OpenResponsesRequest.Encoded, - ) => Effect.Effect< - typeof OpenResponsesNonStreamingResponse.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"BadRequestResponse", typeof BadRequestResponse.Type> - | ClientError<"UnauthorizedResponse", typeof UnauthorizedResponse.Type> - | ClientError<"PaymentRequiredResponse", typeof PaymentRequiredResponse.Type> - | ClientError<"NotFoundResponse", typeof NotFoundResponse.Type> - | ClientError<"RequestTimeoutResponse", typeof RequestTimeoutResponse.Type> - | ClientError<"PayloadTooLargeResponse", typeof PayloadTooLargeResponse.Type> - | ClientError<"UnprocessableEntityResponse", typeof UnprocessableEntityResponse.Type> - | ClientError<"TooManyRequestsResponse", typeof TooManyRequestsResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - | ClientError<"BadGatewayResponse", typeof BadGatewayResponse.Type> - | ClientError<"ServiceUnavailableResponse", typeof ServiceUnavailableResponse.Type> - | ClientError<"EdgeNetworkTimeoutResponse", typeof EdgeNetworkTimeoutResponse.Type> - | ClientError<"ProviderOverloadedResponse", typeof ProviderOverloadedResponse.Type> - > - /** - * Creates a message using the Anthropic Messages API format. Supports text, images, PDFs, tools, and extended thinking. - */ - readonly createMessages: ( - options: typeof AnthropicMessagesRequest.Encoded, - ) => Effect.Effect< - typeof AnthropicMessagesResponse.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"CreateMessages400", typeof CreateMessages400.Type> - | ClientError<"CreateMessages401", typeof CreateMessages401.Type> - | ClientError<"CreateMessages403", typeof CreateMessages403.Type> - | ClientError<"CreateMessages404", typeof CreateMessages404.Type> - | ClientError<"CreateMessages429", typeof CreateMessages429.Type> - | ClientError<"CreateMessages500", typeof CreateMessages500.Type> - | ClientError<"CreateMessages503", typeof CreateMessages503.Type> - | ClientError<"CreateMessages529", typeof CreateMessages529.Type> - > - /** - * Returns user activity data grouped by endpoint for the last 30 (completed) UTC days. [Provisioning key](/docs/guides/overview/auth/provisioning-api-keys) required. - */ - readonly getUserActivity: ( - options?: typeof GetUserActivityParams.Encoded | undefined, - ) => Effect.Effect< - typeof GetUserActivity200.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"BadRequestResponse", typeof BadRequestResponse.Type> - | ClientError<"UnauthorizedResponse", typeof UnauthorizedResponse.Type> - | ClientError<"ForbiddenResponse", typeof ForbiddenResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * Get total credits purchased and used for the authenticated user. [Provisioning key](/docs/guides/overview/auth/provisioning-api-keys) required. - */ - readonly getCredits: () => Effect.Effect< - typeof GetCredits200.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"UnauthorizedResponse", typeof UnauthorizedResponse.Type> - | ClientError<"ForbiddenResponse", typeof ForbiddenResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * Create a Coinbase charge for crypto payment - */ - readonly createCoinbaseCharge: ( - options: typeof CreateChargeRequest.Encoded, - ) => Effect.Effect< - typeof CreateCoinbaseCharge200.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"BadRequestResponse", typeof BadRequestResponse.Type> - | ClientError<"UnauthorizedResponse", typeof UnauthorizedResponse.Type> - | ClientError<"TooManyRequestsResponse", typeof TooManyRequestsResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * Submits an embedding request to the embeddings router - */ - readonly createEmbeddings: ( - options: typeof CreateEmbeddingsRequest.Encoded, - ) => Effect.Effect< - typeof CreateEmbeddings200.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"BadRequestResponse", typeof BadRequestResponse.Type> - | ClientError<"UnauthorizedResponse", typeof UnauthorizedResponse.Type> - | ClientError<"PaymentRequiredResponse", typeof PaymentRequiredResponse.Type> - | ClientError<"NotFoundResponse", typeof NotFoundResponse.Type> - | ClientError<"TooManyRequestsResponse", typeof TooManyRequestsResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - | ClientError<"BadGatewayResponse", typeof BadGatewayResponse.Type> - | ClientError<"ServiceUnavailableResponse", typeof ServiceUnavailableResponse.Type> - | ClientError<"EdgeNetworkTimeoutResponse", typeof EdgeNetworkTimeoutResponse.Type> - | ClientError<"ProviderOverloadedResponse", typeof ProviderOverloadedResponse.Type> - > - /** - * Returns a list of all available embeddings models and their properties - */ - readonly listEmbeddingsModels: () => Effect.Effect< - typeof ModelsListResponse.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"BadRequestResponse", typeof BadRequestResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * Get request & usage metadata for a generation - */ - readonly getGeneration: ( - options: typeof GetGenerationParams.Encoded, - ) => Effect.Effect< - typeof GetGeneration200.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"UnauthorizedResponse", typeof UnauthorizedResponse.Type> - | ClientError<"PaymentRequiredResponse", typeof PaymentRequiredResponse.Type> - | ClientError<"NotFoundResponse", typeof NotFoundResponse.Type> - | ClientError<"TooManyRequestsResponse", typeof TooManyRequestsResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - | ClientError<"BadGatewayResponse", typeof BadGatewayResponse.Type> - | ClientError<"EdgeNetworkTimeoutResponse", typeof EdgeNetworkTimeoutResponse.Type> - | ClientError<"ProviderOverloadedResponse", typeof ProviderOverloadedResponse.Type> - > - /** - * Get total count of available models - */ - readonly listModelsCount: () => Effect.Effect< - typeof ModelsCountResponse.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * List all models and their properties - */ - readonly getModels: ( - options?: typeof GetModelsParams.Encoded | undefined, - ) => Effect.Effect< - typeof ModelsListResponse.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"BadRequestResponse", typeof BadRequestResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * List models filtered by user provider preferences - */ - readonly listModelsUser: () => Effect.Effect< - typeof ModelsListResponse.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"UnauthorizedResponse", typeof UnauthorizedResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * List all endpoints for a model - */ - readonly listEndpoints: ( - author: string, - slug: string, - ) => Effect.Effect< - typeof ListEndpoints200.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"NotFoundResponse", typeof NotFoundResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * Preview the impact of ZDR on the available endpoints - */ - readonly listEndpointsZdr: () => Effect.Effect< - typeof ListEndpointsZdr200.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * List all providers - */ - readonly listProviders: () => Effect.Effect< - typeof ListProviders200.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * List all API keys for the authenticated user. [Provisioning key](/docs/guides/overview/auth/provisioning-api-keys) required. - */ - readonly list: ( - options?: typeof ListParams.Encoded | undefined, - ) => Effect.Effect< - typeof List200.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"UnauthorizedResponse", typeof UnauthorizedResponse.Type> - | ClientError<"TooManyRequestsResponse", typeof TooManyRequestsResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * Create a new API key for the authenticated user. [Provisioning key](/docs/guides/overview/auth/provisioning-api-keys) required. - */ - readonly createKeys: ( - options: typeof CreateKeysRequest.Encoded, - ) => Effect.Effect< - typeof CreateKeys201.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"BadRequestResponse", typeof BadRequestResponse.Type> - | ClientError<"UnauthorizedResponse", typeof UnauthorizedResponse.Type> - | ClientError<"TooManyRequestsResponse", typeof TooManyRequestsResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * Get a single API key by hash. [Provisioning key](/docs/guides/overview/auth/provisioning-api-keys) required. - */ - readonly getKey: ( - hash: string, - ) => Effect.Effect< - typeof GetKey200.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"UnauthorizedResponse", typeof UnauthorizedResponse.Type> - | ClientError<"NotFoundResponse", typeof NotFoundResponse.Type> - | ClientError<"TooManyRequestsResponse", typeof TooManyRequestsResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * Delete an existing API key. [Provisioning key](/docs/guides/overview/auth/provisioning-api-keys) required. - */ - readonly deleteKeys: ( - hash: string, - ) => Effect.Effect< - typeof DeleteKeys200.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"UnauthorizedResponse", typeof UnauthorizedResponse.Type> - | ClientError<"NotFoundResponse", typeof NotFoundResponse.Type> - | ClientError<"TooManyRequestsResponse", typeof TooManyRequestsResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * Update an existing API key. [Provisioning key](/docs/guides/overview/auth/provisioning-api-keys) required. - */ - readonly updateKeys: ( - hash: string, - options: typeof UpdateKeysRequest.Encoded, - ) => Effect.Effect< - typeof UpdateKeys200.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"BadRequestResponse", typeof BadRequestResponse.Type> - | ClientError<"UnauthorizedResponse", typeof UnauthorizedResponse.Type> - | ClientError<"NotFoundResponse", typeof NotFoundResponse.Type> - | ClientError<"TooManyRequestsResponse", typeof TooManyRequestsResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * List all guardrails for the authenticated user. [Provisioning key](/docs/guides/overview/auth/provisioning-api-keys) required. - */ - readonly listGuardrails: ( - options?: typeof ListGuardrailsParams.Encoded | undefined, - ) => Effect.Effect< - typeof ListGuardrails200.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"UnauthorizedResponse", typeof UnauthorizedResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * Create a new guardrail for the authenticated user. [Provisioning key](/docs/guides/overview/auth/provisioning-api-keys) required. - */ - readonly createGuardrail: ( - options: typeof CreateGuardrailRequest.Encoded, - ) => Effect.Effect< - typeof CreateGuardrail201.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"BadRequestResponse", typeof BadRequestResponse.Type> - | ClientError<"UnauthorizedResponse", typeof UnauthorizedResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * Get a single guardrail by ID. [Provisioning key](/docs/guides/overview/auth/provisioning-api-keys) required. - */ - readonly getGuardrail: ( - id: string, - ) => Effect.Effect< - typeof GetGuardrail200.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"UnauthorizedResponse", typeof UnauthorizedResponse.Type> - | ClientError<"NotFoundResponse", typeof NotFoundResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * Delete an existing guardrail. [Provisioning key](/docs/guides/overview/auth/provisioning-api-keys) required. - */ - readonly deleteGuardrail: ( - id: string, - ) => Effect.Effect< - typeof DeleteGuardrail200.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"UnauthorizedResponse", typeof UnauthorizedResponse.Type> - | ClientError<"NotFoundResponse", typeof NotFoundResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * Update an existing guardrail. [Provisioning key](/docs/guides/overview/auth/provisioning-api-keys) required. - */ - readonly updateGuardrail: ( - id: string, - options: typeof UpdateGuardrailRequest.Encoded, - ) => Effect.Effect< - typeof UpdateGuardrail200.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"BadRequestResponse", typeof BadRequestResponse.Type> - | ClientError<"UnauthorizedResponse", typeof UnauthorizedResponse.Type> - | ClientError<"NotFoundResponse", typeof NotFoundResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * List all API key guardrail assignments for the authenticated user. [Provisioning key](/docs/guides/overview/auth/provisioning-api-keys) required. - */ - readonly listKeyAssignments: ( - options?: typeof ListKeyAssignmentsParams.Encoded | undefined, - ) => Effect.Effect< - typeof ListKeyAssignments200.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"UnauthorizedResponse", typeof UnauthorizedResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * List all organization member guardrail assignments for the authenticated user. [Provisioning key](/docs/guides/overview/auth/provisioning-api-keys) required. - */ - readonly listMemberAssignments: ( - options?: typeof ListMemberAssignmentsParams.Encoded | undefined, - ) => Effect.Effect< - typeof ListMemberAssignments200.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"UnauthorizedResponse", typeof UnauthorizedResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * List all API key assignments for a specific guardrail. [Provisioning key](/docs/guides/overview/auth/provisioning-api-keys) required. - */ - readonly listGuardrailKeyAssignments: ( - id: string, - options?: typeof ListGuardrailKeyAssignmentsParams.Encoded | undefined, - ) => Effect.Effect< - typeof ListGuardrailKeyAssignments200.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"UnauthorizedResponse", typeof UnauthorizedResponse.Type> - | ClientError<"NotFoundResponse", typeof NotFoundResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * Assign multiple API keys to a specific guardrail. [Provisioning key](/docs/guides/overview/auth/provisioning-api-keys) required. - */ - readonly bulkAssignKeysToGuardrail: ( - id: string, - options: typeof BulkAssignKeysToGuardrailRequest.Encoded, - ) => Effect.Effect< - typeof BulkAssignKeysToGuardrail200.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"BadRequestResponse", typeof BadRequestResponse.Type> - | ClientError<"UnauthorizedResponse", typeof UnauthorizedResponse.Type> - | ClientError<"NotFoundResponse", typeof NotFoundResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * List all organization member assignments for a specific guardrail. [Provisioning key](/docs/guides/overview/auth/provisioning-api-keys) required. - */ - readonly listGuardrailMemberAssignments: ( - id: string, - options?: typeof ListGuardrailMemberAssignmentsParams.Encoded | undefined, - ) => Effect.Effect< - typeof ListGuardrailMemberAssignments200.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"UnauthorizedResponse", typeof UnauthorizedResponse.Type> - | ClientError<"NotFoundResponse", typeof NotFoundResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * Assign multiple organization members to a specific guardrail. [Provisioning key](/docs/guides/overview/auth/provisioning-api-keys) required. - */ - readonly bulkAssignMembersToGuardrail: ( - id: string, - options: typeof BulkAssignMembersToGuardrailRequest.Encoded, - ) => Effect.Effect< - typeof BulkAssignMembersToGuardrail200.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"BadRequestResponse", typeof BadRequestResponse.Type> - | ClientError<"UnauthorizedResponse", typeof UnauthorizedResponse.Type> - | ClientError<"NotFoundResponse", typeof NotFoundResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * Unassign multiple API keys from a specific guardrail. [Provisioning key](/docs/guides/overview/auth/provisioning-api-keys) required. - */ - readonly bulkUnassignKeysFromGuardrail: ( - id: string, - options: typeof BulkUnassignKeysFromGuardrailRequest.Encoded, - ) => Effect.Effect< - typeof BulkUnassignKeysFromGuardrail200.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"BadRequestResponse", typeof BadRequestResponse.Type> - | ClientError<"UnauthorizedResponse", typeof UnauthorizedResponse.Type> - | ClientError<"NotFoundResponse", typeof NotFoundResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * Unassign multiple organization members from a specific guardrail. [Provisioning key](/docs/guides/overview/auth/provisioning-api-keys) required. - */ - readonly bulkUnassignMembersFromGuardrail: ( - id: string, - options: typeof BulkUnassignMembersFromGuardrailRequest.Encoded, - ) => Effect.Effect< - typeof BulkUnassignMembersFromGuardrail200.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"BadRequestResponse", typeof BadRequestResponse.Type> - | ClientError<"UnauthorizedResponse", typeof UnauthorizedResponse.Type> - | ClientError<"NotFoundResponse", typeof NotFoundResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * Get information on the API key associated with the current authentication session - */ - readonly getCurrentKey: () => Effect.Effect< - typeof GetCurrentKey200.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"UnauthorizedResponse", typeof UnauthorizedResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * Exchange an authorization code from the PKCE flow for a user-controlled API key - */ - readonly exchangeAuthCodeForAPIKey: ( - options: typeof ExchangeAuthCodeForAPIKeyRequest.Encoded, - ) => Effect.Effect< - typeof ExchangeAuthCodeForAPIKey200.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"BadRequestResponse", typeof BadRequestResponse.Type> - | ClientError<"ForbiddenResponse", typeof ForbiddenResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * Create an authorization code for the PKCE flow to generate a user-controlled API key - */ - readonly createAuthKeysCode: ( - options: typeof CreateAuthKeysCodeRequest.Encoded, - ) => Effect.Effect< - typeof CreateAuthKeysCode200.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"BadRequestResponse", typeof BadRequestResponse.Type> - | ClientError<"UnauthorizedResponse", typeof UnauthorizedResponse.Type> - | ClientError<"InternalServerResponse", typeof InternalServerResponse.Type> - > - /** - * Sends a request for a model response for the given chat conversation. Supports both streaming and non-streaming modes. - */ - readonly sendChatCompletionRequest: ( - options: typeof ChatGenerationParams.Encoded, - ) => Effect.Effect< - typeof ChatResponse.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"ChatError", typeof ChatError.Type> - | ClientError<"ChatError", typeof ChatError.Type> - | ClientError<"ChatError", typeof ChatError.Type> - | ClientError<"ChatError", typeof ChatError.Type> - > - /** - * Creates a completion for the provided prompt and parameters. Supports both streaming and non-streaming modes. - */ - readonly createCompletions: ( - options: typeof CompletionCreateParams.Encoded, - ) => Effect.Effect< - typeof CompletionResponse.Type, - | HttpClientError.HttpClientError - | ParseError - | ClientError<"ChatError", typeof ChatError.Type> - | ClientError<"ChatError", typeof ChatError.Type> - | ClientError<"ChatError", typeof ChatError.Type> - | ClientError<"ChatError", typeof ChatError.Type> - > -} - -export interface ClientError { - readonly _tag: Tag - readonly request: HttpClientRequest.HttpClientRequest - readonly response: HttpClientResponse.HttpClientResponse - readonly cause: E -} - -class ClientErrorImpl extends Data.Error<{ - _tag: string - cause: any - request: HttpClientRequest.HttpClientRequest - response: HttpClientResponse.HttpClientResponse -}> {} - -export const ClientError = ( - tag: Tag, - cause: E, - response: HttpClientResponse.HttpClientResponse, -): ClientError => - new ClientErrorImpl({ - _tag: tag, - cause, - response, - request: response.request, - }) as any diff --git a/libs/ai-openrouter/src/OpenRouterClient.ts b/libs/ai-openrouter/src/OpenRouterClient.ts deleted file mode 100644 index 0daf34047..000000000 --- a/libs/ai-openrouter/src/OpenRouterClient.ts +++ /dev/null @@ -1,374 +0,0 @@ -/** - * @since 1.0.0 - */ -import * as AiError from "@effect/ai/AiError" -import * as Sse from "@effect/experimental/Sse" -import * as HttpBody from "@effect/platform/HttpBody" -import * as HttpClient from "@effect/platform/HttpClient" -import * as HttpClientRequest from "@effect/platform/HttpClientRequest" -import * as Config from "effect/Config" -import type { ConfigError } from "effect/ConfigError" -import * as Context from "effect/Context" -import * as Effect from "effect/Effect" -import { identity } from "effect/Function" -import * as Layer from "effect/Layer" -import type * as Redacted from "effect/Redacted" -import * as Schema from "effect/Schema" -import * as Stream from "effect/Stream" -import * as Generated from "./Generated.js" -import { OpenRouterConfig } from "./OpenRouterConfig.js" - -/** - * @since 1.0.0 - * @category Context - */ -export class OpenRouterClient extends Context.Tag("@effect/ai-openrouter/OpenRouterClient")< - OpenRouterClient, - Service ->() {} - -/** - * @since 1.0.0 - * @category Models - */ -export interface Service { - /** - * The underlying HTTP client capable of communicating with the OpenRouter API. - * - * This client is pre-configured with authentication, base URL, and standard - * headers required for OpenRouter API communication. It provides direct access - * to the generated OpenRouter API client for operations not covered by the - * higher-level methods. - * - * Use this when you need to: - * - Access provider-specific API endpoints not available through the AI SDK - * - Implement custom request/response handling - * - Use OpenRouter API features not yet supported by the Effect AI abstractions - * - Perform batch operations or non-streaming requests - * - * The client automatically handles authentication and follows OpenRouter's - * API conventions for request formatting and error handling. - */ - readonly client: Generated.Client - - readonly createChatCompletion: ( - options: typeof Generated.ChatGenerationParams.Encoded, - ) => Effect.Effect - - readonly createChatCompletionStream: ( - options: Omit, - ) => Stream.Stream -} - -/** - * @since 1.0.0 - * @category Constructors - */ -export const make: (options: { - readonly apiKey?: Redacted.Redacted | undefined - readonly apiUrl?: string | undefined - /** - * Optional URL of your site for rankings on `openrouter.ai`. - */ - readonly referrer?: string | undefined - /** - * Optional title of your site for rankings on `openrouter.ai`. - */ - readonly title?: string | undefined - /** - * A function to transform the underlying HTTP client before it's used to send - * API requests. - * - * This transformation function receives the configured HTTP client and returns - * a modified version. It's applied after all standard client configuration - * (authentication, base URL, headers) but before any requests are made. - * - * Use this for: - * - Adding custom middleware (logging, metrics, caching) - * - Modifying request/response processing behavior - * - Adding custom retry logic or error handling - * - Integrating with monitoring or debugging tools - * - Applying organization-specific HTTP client policies - * - * The transformation is applied once during client initialization and affects - * all subsequent API requests made through this client instance. - * - * Leave absent or set to `undefined` if no custom HTTP client behavior is - * needed. - */ - readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient) | undefined -}) => Effect.Effect = Effect.fnUntraced(function* (options) { - const httpClient = (yield* HttpClient.HttpClient).pipe( - HttpClient.mapRequest((request) => - request.pipe( - HttpClientRequest.prependUrl(options.apiUrl ?? "https://openrouter.ai/api/v1"), - options.apiKey ? HttpClientRequest.bearerToken(options.apiKey) : identity, - options.referrer ? HttpClientRequest.setHeader("HTTP-Referrer", options.referrer) : identity, - options.title ? HttpClientRequest.setHeader("X-Title", options.title) : identity, - HttpClientRequest.acceptJson, - ), - ), - options.transformClient ?? identity, - ) - - const httpClientOk = HttpClient.filterStatusOk(httpClient) - - const client = Generated.make(httpClient, { - transformClient: (client) => - OpenRouterConfig.getOrUndefined.pipe( - Effect.map((config) => (config?.transformClient ? config.transformClient(client) : client)), - ), - }) - - const streamRequest = ( - request: HttpClientRequest.HttpClientRequest, - schema: Schema.Schema, - ): Stream.Stream => { - const decodeEvent = Schema.decode(Schema.parseJson(schema)) - return httpClientOk.execute(request).pipe( - Effect.map((r) => r.stream), - Stream.unwrapScoped, - Stream.decodeText(), - Stream.pipeThroughChannel(Sse.makeChannel()), - Stream.takeWhile((event) => event.data !== "[DONE]"), - Stream.mapEffect((event) => decodeEvent(event.data)), - Stream.catchTags({ - RequestError: (error) => - AiError.HttpRequestError.fromRequestError({ - module: "OpenRouterClient", - method: "streamRequest", - error, - }), - ResponseError: (error) => - AiError.HttpResponseError.fromResponseError({ - module: "OpenRouterClient", - method: "streamRequest", - error, - }), - ParseError: (error) => - AiError.MalformedOutput.fromParseError({ - module: "OpenRouterClient", - method: "streamRequest", - error, - }), - }), - ) - } - - const createChatCompletion: ( - options: typeof Generated.ChatGenerationParams.Encoded, - ) => Effect.Effect = Effect.fnUntraced(function* (options) { - return yield* client.sendChatCompletionRequest(options).pipe( - Effect.catchTag( - "ChatError", - (error) => - new AiError.HttpResponseError({ - module: "OpenRouterClient", - method: "createChatCompletion", - reason: "StatusCode", - request: { - hash: error.request.hash, - headers: error.request.headers, - method: error.request.method, - url: error.request.url, - urlParams: error.request.urlParams, - }, - response: { - headers: error.response.headers, - status: error.response.status, - }, - }), - ), - Effect.catchTags({ - RequestError: (error) => - AiError.HttpRequestError.fromRequestError({ - module: "OpenRouterClient", - method: "createChatCompletion", - error, - }), - ResponseError: (error) => - AiError.HttpResponseError.fromResponseError({ - module: "OpenRouterClient", - method: "createChatCompletion", - error, - }), - ParseError: (error) => - AiError.MalformedOutput.fromParseError({ - module: "OpenRouterClient", - method: "createChatCompletion", - error, - }), - }), - ) - }) - - const createChatCompletionStream = ( - options: Omit, - ): Stream.Stream => { - const request = HttpClientRequest.post("/chat/completions", { - body: HttpBody.unsafeJson({ - ...options, - stream: true, - stream_options: { include_usage: true }, - }), - }) - return streamRequest(request, ChatStreamingResponseChunk) - } - - return OpenRouterClient.of({ - client, - createChatCompletion, - createChatCompletionStream, - }) -}) - -/** - * @since 1.0.0 - * @category Layers - */ -export const layer = (options: { - readonly apiKey?: Redacted.Redacted | undefined - readonly apiUrl?: string | undefined - /** - * Optional URL of your site for rankings on `openrouter.ai`. - */ - readonly referrer?: string | undefined - /** - * Optional title of your site for rankings on `openrouter.ai`. - */ - readonly title?: string | undefined - /** - * A function to transform the underlying HTTP client before it's used to send - * API requests. - * - * This transformation function receives the configured HTTP client and returns - * a modified version. It's applied after all standard client configuration - * (authentication, base URL, headers) but before any requests are made. - * - * Use this for: - * - Adding custom middleware (logging, metrics, caching) - * - Modifying request/response processing behavior - * - Adding custom retry logic or error handling - * - Integrating with monitoring or debugging tools - * - Applying organization-specific HTTP client policies - * - * The transformation is applied once during client initialization and affects - * all subsequent API requests made through this client instance. - * - * Leave absent or set to `undefined` if no custom HTTP client behavior is - * needed. - */ - readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient) | undefined -}): Layer.Layer => - Layer.effect(OpenRouterClient, make(options)) - -/** - * @since 1.0.0 - * @category Layers - */ -export const layerConfig = (options: { - readonly apiKey?: Config.Config | undefined - readonly apiUrl?: Config.Config | undefined - /** - * Optional URL of your site for rankings on `openrouter.ai`. - */ - readonly referrer?: Config.Config | undefined - /** - * Optional title of your site for rankings on `openrouter.ai`. - */ - readonly title?: Config.Config | undefined - /** - * A function to transform the underlying HTTP client before it's used to send - * API requests. - * - * This transformation function receives the configured HTTP client and returns - * a modified version. It's applied after all standard client configuration - * (authentication, base URL, headers) but before any requests are made. - * - * Use this for: - * - Adding custom middleware (logging, metrics, caching) - * - Modifying request/response processing behavior - * - Adding custom retry logic or error handling - * - Integrating with monitoring or debugging tools - * - Applying organization-specific HTTP client policies - * - * The transformation is applied once during client initialization and affects - * all subsequent API requests made through this client instance. - * - * Leave absent or set to `undefined` if no custom HTTP client behavior is - * needed. - */ - readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient) | undefined -}): Layer.Layer => { - const { transformClient, ...configs } = options - return Config.all(configs).pipe( - Effect.flatMap((configs) => make({ ...configs, transformClient })), - Layer.effect(OpenRouterClient), - ) -} - -/** - * @since 1.0.0 - * @category Schemas - */ -export class ChatStreamingMessageToolCall extends Schema.Class( - "@effect/ai-openrouter/ChatStreamingMessageToolCall", -)({ - index: Schema.Number, - id: Schema.optionalWith(Schema.String, { nullable: true }), - type: Schema.Literal("function"), - function: Schema.Struct({ - name: Schema.optionalWith(Schema.String, { nullable: true }), - arguments: Schema.String, - }), -}) {} - -/** - * @since 1.0.0 - * @category Schemas - */ -export class ChatStreamingMessageChunk extends Schema.Class( - "@effect/ai-openrouter/ChatStreamingMessageChunk", -)({ - role: Schema.optionalWith(Schema.Literal("assistant"), { nullable: true }), - content: Schema.optionalWith(Schema.String, { nullable: true }), - reasoning: Schema.optionalWith(Schema.String, { nullable: true }), - reasoning_details: Schema.optionalWith(Schema.Array(Generated.ReasoningDetail), { nullable: true }), - images: Schema.optionalWith(Schema.Array(Generated.ChatMessageContentItemImage), { nullable: true }), - refusal: Schema.optionalWith(Schema.String, { nullable: true }), - tool_calls: Schema.optionalWith(Schema.Array(ChatStreamingMessageToolCall), { nullable: true }), - annotations: Schema.optionalWith(Schema.Array(Generated.AnnotationDetail), { nullable: true }), -}) {} - -/** - * @since 1.0.0 - * @category Schemas - */ -export class ChatStreamingChoice extends Schema.Class( - "@effect/ai-openrouter/ChatStreamingChoice", -)({ - index: Schema.Number, - delta: Schema.optionalWith(ChatStreamingMessageChunk, { nullable: true }), - finish_reason: Schema.optionalWith(Generated.ChatCompletionFinishReason, { nullable: true }), - native_finish_reason: Schema.optionalWith(Schema.String, { nullable: true }), - logprobs: Schema.optionalWith(Generated.ChatMessageTokenLogprobs, { nullable: true }), -}) {} - -/** - * @since 1.0.0 - * @category Schemas - */ -export class ChatStreamingResponseChunk extends Schema.Class( - "@effect/ai-openrouter/ChatStreamingResponseChunk", -)({ - id: Schema.optionalWith(Schema.String, { nullable: true }), - model: Schema.optionalWith(Schema.TemplateLiteral(Schema.String, Schema.Literal("/"), Schema.String), { - nullable: true, - }), - provider: Schema.optionalWith(Schema.String, { nullable: true }), - created: Schema.DateTimeUtcFromNumber, - choices: Schema.Array(ChatStreamingChoice), - error: Schema.optionalWith(Generated.ChatError.fields.error, { nullable: true }), - system_fingerprint: Schema.optionalWith(Schema.String, { nullable: true }), - usage: Schema.optionalWith(Generated.ChatGenerationTokenUsage, { nullable: true }), -}) {} diff --git a/libs/ai-openrouter/src/OpenRouterConfig.ts b/libs/ai-openrouter/src/OpenRouterConfig.ts deleted file mode 100644 index 39c5d5bfa..000000000 --- a/libs/ai-openrouter/src/OpenRouterConfig.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * @since 1.0.0 - */ -import type { HttpClient } from "@effect/platform/HttpClient" -import * as Context from "effect/Context" -import * as Effect from "effect/Effect" -import { dual } from "effect/Function" - -/** - * @since 1.0.0 - * @category Context - */ -export class OpenRouterConfig extends Context.Tag("@effect/ai-openrouter/OpenRouterConfig")< - OpenRouterConfig, - OpenRouterConfig.Service ->() { - /** - * @since 1.0.0 - */ - static readonly getOrUndefined: Effect.Effect = Effect.map( - Effect.context(), - (context) => context.unsafeMap.get(OpenRouterConfig.key), - ) -} - -/** - * @since 1.0.0 - */ -export declare namespace OpenRouterConfig { - /** - * @since 1.0.0 - * @category Models - */ - export interface Service { - readonly transformClient?: (client: HttpClient) => HttpClient - } -} - -/** - * @since 1.0.0 - * @category Configuration - */ -export const withClientTransform: { - /** - * @since 1.0.0 - * @category Configuration - */ - ( - transform: (client: HttpClient) => HttpClient, - ): (self: Effect.Effect) => Effect.Effect - /** - * @since 1.0.0 - * @category Configuration - */ - ( - self: Effect.Effect, - transform: (client: HttpClient) => HttpClient, - ): Effect.Effect -} = dual< - /** - * @since 1.0.0 - * @category Configuration - */ - ( - transform: (client: HttpClient) => HttpClient, - ) => (self: Effect.Effect) => Effect.Effect, - /** - * @since 1.0.0 - * @category Configuration - */ - ( - self: Effect.Effect, - transform: (client: HttpClient) => HttpClient, - ) => Effect.Effect ->(2, (self, transformClient) => - Effect.flatMap(OpenRouterConfig.getOrUndefined, (config) => - Effect.provideService(self, OpenRouterConfig, { ...config, transformClient }), - ), -) diff --git a/libs/ai-openrouter/src/OpenRouterLanguageModel.ts b/libs/ai-openrouter/src/OpenRouterLanguageModel.ts deleted file mode 100644 index 6703e3ddd..000000000 --- a/libs/ai-openrouter/src/OpenRouterLanguageModel.ts +++ /dev/null @@ -1,1178 +0,0 @@ -/** - * @since 1.0.0 - */ -import * as AiError from "@effect/ai/AiError" -import * as LanguageModel from "@effect/ai/LanguageModel" -import * as AiModel from "@effect/ai/Model" -import type * as Prompt from "@effect/ai/Prompt" -import type * as Response from "@effect/ai/Response" -import { addGenAIAnnotations } from "@effect/ai/Telemetry" -import * as Tool from "@effect/ai/Tool" -import * as Arr from "effect/Array" -import * as Context from "effect/Context" -import * as DateTime from "effect/DateTime" -import * as Effect from "effect/Effect" -import * as Encoding from "effect/Encoding" -import { dual } from "effect/Function" -import * as Layer from "effect/Layer" -import * as Predicate from "effect/Predicate" -import * as Stream from "effect/Stream" -import type { Span } from "effect/Tracer" -import type { Simplify } from "effect/Types" -import type * as Generated from "./Generated.js" -import * as InternalUtilities from "./internal/utilities.js" -import type { ChatStreamingResponseChunk } from "./OpenRouterClient.js" -import { OpenRouterClient } from "./OpenRouterClient.js" - -// ============================================================================= -// Configuration -// ============================================================================= - -/** - * @since 1.0.0 - * @category Context - */ -export class Config extends Context.Tag("@effect/ai-openrouter/OpenRouterLanguageModel/Config")< - Config, - Config.Service ->() { - /** - * @since 1.0.0 - */ - static readonly getOrUndefined: Effect.Effect = Effect.map( - Effect.context(), - (context) => context.unsafeMap.get(Config.key), - ) -} - -/** - * @since 1.0.0 - */ -export declare namespace Config { - /** - * @since 1.0.0 - * @category Configuration - */ - export interface Service extends Simplify< - Partial< - Omit< - typeof Generated.ChatGenerationParams.Encoded, - "messages" | "response_format" | "tools" | "tool_choice" | "stream" - > - > - > {} -} - -// ============================================================================= -// OpenRouter Provider Options / Metadata -// ============================================================================= - -/** - * @since 1.0.0 - * @category Provider Metadata - */ -export type OpenRouterReasoningInfo = - | { - readonly type: "reasoning" - readonly signature: string | undefined - } - | { - readonly type: "encrypted_reasoning" - readonly format: (typeof Generated.ReasoningDetailSummary.Type)["format"] - readonly redactedData: string - } - -/** - * @since 1.0.0 - * @category Provider Options - */ -declare module "@effect/ai/Prompt" { - export interface SystemMessageOptions extends ProviderOptions { - readonly openrouter?: - | { - /** - * A breakpoint which marks the end of reusable content eligible for caching. - */ - readonly cacheControl?: typeof Generated.CacheControlEphemeral.Encoded | undefined - } - | undefined - } - - export interface UserMessageOptions extends ProviderOptions { - readonly openrouter?: - | { - /** - * A breakpoint which marks the end of reusable content eligible for caching. - */ - readonly cacheControl?: typeof Generated.CacheControlEphemeral.Encoded | undefined - } - | undefined - } - - export interface AssistantMessageOptions extends ProviderOptions { - readonly openrouter?: - | { - /** - * A breakpoint which marks the end of reusable content eligible for caching. - */ - readonly cacheControl?: typeof Generated.CacheControlEphemeral.Encoded | undefined - } - | undefined - } - - export interface ToolMessageOptions extends ProviderOptions { - readonly openrouter?: - | { - /** - * A breakpoint which marks the end of reusable content eligible for caching. - */ - readonly cacheControl?: typeof Generated.CacheControlEphemeral.Encoded | undefined - } - | undefined - } - - export interface TextPartOptions extends ProviderOptions { - readonly openrouter?: - | { - /** - * A breakpoint which marks the end of reusable content eligible for caching. - */ - readonly cacheControl?: typeof Generated.CacheControlEphemeral.Encoded | undefined - } - | undefined - } - - export interface ReasoningPartOptions extends ProviderOptions { - readonly openrouter?: - | { - /** - * A breakpoint which marks the end of reusable content eligible for caching. - */ - readonly cacheControl?: typeof Generated.CacheControlEphemeral.Encoded | undefined - } - | undefined - } - - export interface FilePartOptions extends ProviderOptions { - readonly openrouter?: - | { - /** - * The name to give to the file. Will be prioritized over the file name - * associated with the file part, if present. - */ - readonly fileName?: string | undefined - /** - * A breakpoint which marks the end of reusable content eligible for caching. - */ - readonly cacheControl?: typeof Generated.CacheControlEphemeral.Encoded | undefined - } - | undefined - } - - export interface ToolResultPartOptions extends ProviderOptions { - readonly openrouter?: - | { - /** - * A breakpoint which marks the end of reusable content eligible for caching. - */ - readonly cacheControl?: typeof Generated.CacheControlEphemeral.Encoded | undefined - } - | undefined - } -} - -/** - * @since 1.0.0 - * @category Provider Metadata - */ -declare module "@effect/ai/Response" { - export interface ReasoningPartMetadata extends ProviderMetadata { - readonly openrouter?: OpenRouterReasoningInfo | undefined - } - - export interface ReasoningStartPartMetadata extends ProviderMetadata { - readonly openrouter?: OpenRouterReasoningInfo | undefined - } - - export interface ReasoningDeltaPartMetadata extends ProviderMetadata { - readonly openrouter?: OpenRouterReasoningInfo | undefined - } - - export interface UrlSourcePartMetadata extends ProviderMetadata { - readonly openrouter?: - | { - readonly content?: string | undefined - } - | undefined - } - - export interface FinishPartMetadata extends ProviderMetadata { - readonly openrouter?: - | { - /** - * The provider used to generate the response. - */ - readonly provider?: string | undefined - /** - * Additional usage information. - */ - readonly usage?: - | { - /** - * The total cost of generating the response. - */ - readonly cost?: number | undefined - /** - * Additional details about cost. - */ - readonly costDetails?: - | { - readonly upstream_inference_cost?: number | undefined - } - | undefined - /** - * Additional details about prompt token usage. - */ - readonly promptTokensDetails?: { - readonly audio_tokens?: number | undefined - readonly cached_tokens?: number | undefined - } - /** - * Additional details about completion token usage. - */ - readonly completionTokensDetails?: - | { - readonly reasoning_tokens?: number | undefined - readonly audio_tokens?: number | undefined - readonly accepted_prediction_tokens?: number | undefined - readonly rejected_prediction_tokens?: number | undefined - } - | undefined - } - | undefined - } - | undefined - } -} - -// ============================================================================= -// OpenRouter Language Model -// ============================================================================= - -/** - * @since 1.0.0 - * @category Ai Models - */ -export const model = ( - model: string, - config?: Omit, -): AiModel.Model<"openrouter", LanguageModel.LanguageModel, OpenRouterClient> => - AiModel.make("openrouter", layer({ model, config })) - -/** - * @since 1.0.0 - * @category Constructors - */ -export const make = Effect.fnUntraced(function* (options: { - readonly model: string - readonly config?: Omit -}) { - const client = yield* OpenRouterClient - - const makeRequest = Effect.fnUntraced(function* (providerOptions: LanguageModel.ProviderOptions) { - const context = yield* Effect.context() - const config = { model: options.model, ...options.config, ...context.unsafeMap.get(Config.key) } - const messages = yield* prepareMessages(providerOptions) - const { toolChoice, tools } = yield* prepareTools(providerOptions) - const responseFormat = providerOptions.responseFormat - const request: typeof Generated.ChatGenerationParams.Encoded = { - ...config, - messages, - tools, - tool_choice: toolChoice, - response_format: - responseFormat.type === "text" - ? undefined - : { - type: "json_schema", - json_schema: { - name: responseFormat.objectName, - description: - Tool.getDescriptionFromSchemaAst(responseFormat.schema.ast) ?? - "Respond with a JSON object", - schema: Tool.getJsonSchemaFromSchemaAst(responseFormat.schema.ast), - strict: true, - }, - }, - } - return request - }) - - return yield* LanguageModel.make({ - generateText: Effect.fnUntraced(function* (options) { - const request = yield* makeRequest(options) - annotateRequest(options.span, request) - const rawResponse = yield* client.createChatCompletion(request) - annotateResponse(options.span, rawResponse) - return yield* makeResponse(rawResponse) - }), - streamText: Effect.fnUntraced( - function* (options) { - const request = yield* makeRequest(options) - annotateRequest(options.span, request) - return client.createChatCompletionStream(request) - }, - (effect, options) => - effect.pipe( - Effect.flatMap((stream) => makeStreamResponse(stream)), - Stream.unwrap, - Stream.map((response) => { - annotateStreamResponse(options.span, response) - return response - }), - ), - ), - }) -}) - -/** - * @since 1.0.0 - * @category Layers - */ -export const layer = (options: { - readonly model: string - readonly config?: Omit -}): Layer.Layer => - Layer.effect(LanguageModel.LanguageModel, make({ model: options.model, config: options.config })) - -/** - * @since 1.0.0 - * @category Configuration - */ -export const withConfigOverride: { - /** - * @since 1.0.0 - * @category Configuration - */ - (config: Config.Service): (self: Effect.Effect) => Effect.Effect - /** - * @since 1.0.0 - * @category Configuration - */ - (self: Effect.Effect, config: Config.Service): Effect.Effect -} = dual< - /** - * @since 1.0.0 - * @category Configuration - */ - (config: Config.Service) => (self: Effect.Effect) => Effect.Effect, - /** - * @since 1.0.0 - * @category Configuration - */ - (self: Effect.Effect, config: Config.Service) => Effect.Effect ->(2, (self, overrides) => - Effect.flatMap(Config.getOrUndefined, (config) => - Effect.provideService(self, Config, { ...config, ...overrides }), - ), -) - -// ============================================================================= -// Prompt Conversion -// ============================================================================= - -const prepareMessages: ( - options: LanguageModel.ProviderOptions, -) => Effect.Effect, AiError.AiError> = Effect.fnUntraced( - function* (options) { - const messages: Array = [] - - for (const message of options.prompt.content) { - switch (message.role) { - case "system": { - messages.push({ - role: "system", - content: message.content, - cache_control: getCacheControl(message), - }) - break - } - - case "user": { - const firstPart = message.content[0] - if (message.content.length === 1 && firstPart?.type === "text") { - const cacheControl = getCacheControl(message) ?? getCacheControl(firstPart) - messages.push({ - role: "user", - content: Predicate.isNotUndefined(cacheControl) - ? [{ type: "text", text: firstPart.text, cache_control: cacheControl }] - : firstPart.text, - }) - } else { - const content: Array = [] - const messageCacheControl = getCacheControl(message) - for (const part of message.content) { - const partCacheControl = getCacheControl(part) - const cacheControl = partCacheControl ?? messageCacheControl - switch (part.type) { - case "text": { - content.push({ - type: "text", - text: part.text, - cache_control: cacheControl, - }) - break - } - case "file": { - if (part.mediaType.startsWith("image/")) { - const mediaType = - part.mediaType === "image/*" ? "image/jpeg" : part.mediaType - content.push({ - type: "image_url", - image_url: { - url: - part.data instanceof URL - ? part.data.toString() - : part.data instanceof Uint8Array - ? `data:${mediaType};base64,${Encoding.encodeBase64(part.data)}` - : part.data, - }, - cache_control: cacheControl, - }) - } else { - const options = part.options.openrouter - const fileName = options?.fileName ?? part.fileName ?? "" - content.push({ - type: "file", - file: { - filename: fileName, - file_data: - part.data instanceof URL - ? part.data.toString() - : part.data instanceof Uint8Array - ? `data:${part.mediaType};base64,${Encoding.encodeBase64(part.data)}` - : part.data, - }, - cache_control: - part.data instanceof URL ? cacheControl : undefined, - }) - } - break - } - } - } - messages.push({ - role: "user", - content, - }) - } - break - } - - case "assistant": { - let text = "" - let reasoning = "" - const reasoningDetails: Array = [] - const toolCalls: Array = [] - const cacheControl = getCacheControl(message) - for (const part of message.content) { - switch (part.type) { - case "text": { - text += part.text - break - } - case "reasoning": { - reasoning += part.text - reasoningDetails.push({ - type: "reasoning.text", - text: part.text, - }) - break - } - case "tool-call": { - toolCalls.push({ - id: part.id, - type: "function", - function: { - name: part.name, - arguments: JSON.stringify(part.params), - }, - }) - break - } - } - } - messages.push({ - role: "assistant", - content: text, - tool_calls: toolCalls.length > 0 ? toolCalls : undefined, - reasoning: reasoning.length > 0 ? reasoning : undefined, - reasoning_details: reasoningDetails.length > 0 ? reasoningDetails : undefined, - cache_control: cacheControl, - }) - break - } - - case "tool": { - const cacheControl = getCacheControl(message) - for (const part of message.content) { - messages.push({ - role: "tool", - tool_call_id: part.id, - content: JSON.stringify(part.result), - cache_control: cacheControl, - }) - } - break - } - } - } - - return messages - }, -) - -// ============================================================================= -// Tool Conversion -// ============================================================================= - -const prepareTools: (options: LanguageModel.ProviderOptions) => Effect.Effect< - { - readonly tools: ReadonlyArray | undefined - readonly toolChoice: typeof Generated.ToolChoiceOption.Encoded | undefined - }, - AiError.AiError -> = Effect.fnUntraced(function* (options: LanguageModel.ProviderOptions) { - if (options.tools.length === 0) { - return { tools: undefined, toolChoice: undefined } - } - - const hasProviderDefinedTools = options.tools.some((tool) => Tool.isProviderDefined(tool)) - if (hasProviderDefinedTools) { - return yield* new AiError.MalformedInput({ - module: "OpenRouterLanguageModel", - method: "prepareTools", - description: - "Provider-defined tools are unsupported by the OpenRouter " + - "provider integration at this time", - }) - } - - let tools: Array = [] - let toolChoice: typeof Generated.ToolChoiceOption.Encoded | undefined = undefined - - for (const tool of options.tools) { - tools.push({ - type: "function", - function: { - name: tool.name, - description: Tool.getDescription(tool as any), - parameters: Tool.getJsonSchema(tool as any) as any, - strict: true, - }, - }) - } - - if (options.toolChoice === "none") { - toolChoice = "none" - } else if (options.toolChoice === "auto") { - toolChoice = "auto" - } else if (options.toolChoice === "required") { - toolChoice = "required" - } else if ("tool" in options.toolChoice) { - toolChoice = { type: "function", function: { name: options.toolChoice.tool } } - } else { - const allowedTools = new Set(options.toolChoice.oneOf) - tools = tools.filter((tool) => allowedTools.has(tool.function.name)) - toolChoice = options.toolChoice.mode === "auto" ? "auto" : "required" - } - - return { tools, toolChoice } -}) - -// ============================================================================= -// Response Conversion -// ============================================================================= - -const makeResponse: ( - response: Generated.ChatResponse, -) => Effect.Effect, AiError.AiError> = Effect.fnUntraced(function* (response) { - const choice = response.choices[0] - - if (Predicate.isUndefined(choice)) { - return yield* new AiError.MalformedOutput({ - module: "OpenRouterLanguageModel", - method: "makeResponse", - description: "Received response with no valid choices", - }) - } - - const parts: Array = [] - const message = choice.message - - const createdAt = new Date(response.created * 1000) - parts.push({ - type: "response-metadata", - id: response.id, - modelId: response.model, - timestamp: DateTime.formatIso(DateTime.unsafeFromDate(createdAt)), - }) - - if (Predicate.isNotNullable(message.reasoning) && message.reasoning.length > 0) { - parts.push({ - type: "reasoning", - text: message.reasoning, - }) - } - - if (Predicate.isNotNullable(message.reasoning_details) && message.reasoning_details.length > 0) { - for (const detail of message.reasoning_details) { - switch (detail.type) { - case "reasoning.summary": { - if (Predicate.isNotUndefined(detail.summary) && detail.summary.length > 0) { - parts.push({ - type: "reasoning", - text: detail.summary, - }) - } - break - } - case "reasoning.encrypted": { - if (Predicate.isNotUndefined(detail.data) && detail.data.length > 0) { - parts.push({ - type: "reasoning", - text: "", - metadata: { - openrouter: { - type: "encrypted_reasoning", - format: detail.format, - redactedData: detail.data, - }, - }, - }) - } - break - } - case "reasoning.text": { - if (Predicate.isNotUndefined(detail.text) && detail.text.length > 0) { - parts.push({ - type: "reasoning", - text: detail.text, - metadata: { - openrouter: { - type: "reasoning", - signature: detail.signature, - }, - }, - }) - } - break - } - } - } - } - - if (Predicate.isNotNullable(message.content) && message.content.length > 0) { - parts.push({ - type: "text", - text: message.content as string, - }) - } - - if (Predicate.isNotNullable(message.tool_calls)) { - for (const toolCall of message.tool_calls) { - const toolName = toolCall.function.name - const toolParams = toolCall.function.arguments - const params = yield* Effect.try({ - try: () => Tool.unsafeSecureJsonParse(toolParams), - catch: (cause) => - new AiError.MalformedOutput({ - module: "OpenRouterLanguageModel", - method: "makeResponse", - description: - "Failed to securely parse tool call parameters " + - `for tool '${toolName}':\nParameters: ${toolParams}`, - cause, - }), - }) - parts.push({ - type: "tool-call", - id: toolCall.id, - name: toolName, - params, - }) - } - } - - if (Predicate.isNotNullable(message.annotations)) { - for (const annotation of message.annotations) { - if (annotation.type === "url_citation") { - parts.push({ - type: "source", - sourceType: "url", - id: annotation.url_citation.url, - url: annotation.url_citation.url, - title: annotation.url_citation.title, - metadata: { - openrouter: { - content: annotation.url_citation.content, - }, - }, - }) - } - } - } - - if (Predicate.isNotNullable(message.images)) { - for (const image of message.images) { - parts.push({ - type: "file", - mediaType: getMediaType(image.image_url.url) ?? "image/jpeg", - data: getBase64FromDataUrl(image.image_url.url), - }) - } - } - - parts.push({ - type: "finish", - reason: InternalUtilities.resolveFinishReason(choice.finish_reason), - usage: { - inputTokens: response.usage?.prompt_tokens, - outputTokens: response.usage?.completion_tokens, - totalTokens: response.usage?.total_tokens, - reasoningTokens: response.usage?.completion_tokens_details?.reasoning_tokens, - cachedInputTokens: response.usage?.prompt_tokens_details?.cached_tokens, - }, - metadata: { - openrouter: { - provider: response.provider, - usage: { - cost: response.usage?.cost, - promptTokensDetails: response.usage?.prompt_tokens_details, - completionTokensDetails: response.usage?.completion_tokens_details, - costDetails: response.usage?.cost_details, - }, - }, - }, - }) - - return parts -}) - -const makeStreamResponse: ( - stream: Stream.Stream, -) => Effect.Effect> = Effect.fnUntraced( - function* (stream) { - let idCounter = 0 - let activeTextId: string | undefined = undefined - let activeReasoningId: string | undefined = undefined - let finishReason: Response.FinishReason = "unknown" - let responseMetadataEmitted = false - - const activeToolCalls: Record< - number, - { - readonly index: number - readonly id: string - readonly name: string - params: string - } - > = {} - - return stream.pipe( - Stream.mapEffect( - Effect.fnUntraced(function* (event) { - const parts: Array = [] - - if ("error" in event) { - parts.push({ - type: "error", - error: event.error, - }) - return parts - } - - // Response Metadata - - if (Predicate.isNotUndefined(event.id) && !responseMetadataEmitted) { - parts.push({ - type: "response-metadata", - id: event.id, - modelId: event.model, - timestamp: DateTime.formatIso(yield* DateTime.now), - }) - responseMetadataEmitted = true - } - - const choice = event.choices[0] - - if (Predicate.isUndefined(choice)) { - // Usage-only chunk from stream_options.include_usage — no content to emit - return parts - } - - const delta = choice.delta - - if (Predicate.isUndefined(delta)) { - return parts - } - - // Reasoning Parts - - const emitReasoningPart = ( - delta: string, - metadata: OpenRouterReasoningInfo | undefined = undefined, - ) => { - // End in-progress text part if present before starting reasoning - if (Predicate.isNotUndefined(activeTextId)) { - parts.push({ - type: "text-end", - id: activeTextId, - }) - activeTextId = undefined - } - // Start a new reasoning part if necessary - if (Predicate.isUndefined(activeReasoningId)) { - activeReasoningId = (idCounter++).toString() - parts.push({ - type: "reasoning-start", - id: activeReasoningId, - metadata: { openrouter: metadata }, - }) - } - // Emit the reasoning delta - parts.push({ - type: "reasoning-delta", - id: activeReasoningId, - delta, - metadata: { openrouter: metadata }, - }) - } - - if ( - Predicate.isNotNullable(delta.reasoning_details) && - delta.reasoning_details.length > 0 - ) { - for (const detail of delta.reasoning_details) { - switch (detail.type) { - case "reasoning.summary": { - if ( - Predicate.isNotUndefined(detail.summary) && - detail.summary.length > 0 - ) { - emitReasoningPart(detail.summary) - } - break - } - case "reasoning.encrypted": { - if (Predicate.isNotUndefined(detail.data) && detail.data.length > 0) { - emitReasoningPart("", { - type: "encrypted_reasoning", - format: detail.format, - redactedData: detail.data, - }) - } - break - } - case "reasoning.text": { - if (Predicate.isNotUndefined(detail.text) && detail.text.length > 0) { - emitReasoningPart(detail.text, { - type: "reasoning", - signature: detail.signature, - }) - } - break - } - } - } - } else if (Predicate.isNotNullable(delta.reasoning) && delta.reasoning.length > 0) { - emitReasoningPart(delta.reasoning) - } - - // Text Parts - - if (Predicate.isNotNullable(delta.content) && delta.content.length > 0) { - // End in-progress reasoning part if present before starting text - if (Predicate.isNotUndefined(activeReasoningId)) { - parts.push({ - type: "reasoning-end", - id: activeReasoningId, - }) - activeReasoningId = undefined - } - // Start a new text part if necessary - if (Predicate.isUndefined(activeTextId)) { - activeTextId = (idCounter++).toString() - parts.push({ - type: "text-start", - id: activeTextId, - }) - } - // Emit the text delta - parts.push({ - type: "text-delta", - id: activeTextId, - delta: delta.content, - }) - } - - // Source Parts - - if (Predicate.isNotNullable(delta.annotations)) { - for (const annotation of delta.annotations) { - if (annotation.type === "url_citation") { - parts.push({ - type: "source", - sourceType: "url", - id: annotation.url_citation.url, - url: annotation.url_citation.url, - title: annotation.url_citation.title, - metadata: { - openrouter: { - content: annotation.url_citation.content, - }, - }, - }) - } - } - } - - // Tool Call Parts - - if (Predicate.isNotNullable(delta.tool_calls) && delta.tool_calls.length > 0) { - for (const toolCall of delta.tool_calls) { - // Get the active tool call, if present - let activeToolCall = activeToolCalls[toolCall.index] - - // If no active tool call was found, start a new active tool call - if (Predicate.isUndefined(activeToolCall)) { - // The tool call id and function name always come back with the - // first tool call delta - activeToolCall = { - index: toolCall.index, - id: toolCall.id!, - name: toolCall.function.name!, - params: toolCall.function.arguments ?? "", - } - - activeToolCalls[toolCall.index] = activeToolCall - - parts.push({ - type: "tool-params-start", - id: activeToolCall.id, - name: activeToolCall.name, - }) - - // Emit a tool call delta part if parameters were also sent - if (activeToolCall.params.length > 0) { - parts.push({ - type: "tool-params-delta", - id: activeToolCall.id, - delta: activeToolCall.params, - }) - } - } else { - // If an active tool call was found, update and emit the delta for - // the tool call's parameters - activeToolCall.params += toolCall.function.arguments - parts.push({ - type: "tool-params-delta", - id: activeToolCall.id, - delta: activeToolCall.params, - }) - } - - // Check if the tool call is complete - try { - const params = Tool.unsafeSecureJsonParse(activeToolCall.params) - parts.push({ - type: "tool-params-end", - id: activeToolCall.id, - }) - parts.push({ - type: "tool-call", - id: activeToolCall.id, - name: activeToolCall.name, - params, - }) - delete activeToolCalls[toolCall.index] - } catch { - // Tool call incomplete, continue parsing - continue - } - } - } - - // File Parts - - if (Predicate.isNotNullable(delta.images)) { - for (const image of delta.images) { - parts.push({ - type: "file", - mediaType: getMediaType(image.image_url.url) ?? "image/jpeg", - data: getBase64FromDataUrl(image.image_url.url), - }) - } - } - - // Finish Parts - - if (Predicate.isNotNullable(choice.finish_reason)) { - finishReason = InternalUtilities.resolveFinishReason(choice.finish_reason) - } - - // Usage is only emitted by the last part of the stream, so we need to - // handle flushing any remaining text / reasoning / tool calls - if (Predicate.isNotUndefined(event.usage)) { - // Complete any remaining tool calls if the finish reason is tool-calls - if (finishReason === "tool-calls") { - for (const toolCall of Object.values(activeToolCalls)) { - // Coerce invalid tool call parameters to an empty object - const params = yield* Effect.try(() => - Tool.unsafeSecureJsonParse(toolCall.params), - ).pipe(Effect.catchAll(() => Effect.succeed({}))) - parts.push({ - type: "tool-params-end", - id: toolCall.id, - }) - parts.push({ - type: "tool-call", - id: toolCall.id, - name: toolCall.name, - params, - }) - delete activeToolCalls[toolCall.index] - } - } - - // Flush remaining reasoning parts - if (Predicate.isNotUndefined(activeReasoningId)) { - parts.push({ - type: "reasoning-end", - id: activeReasoningId, - }) - activeReasoningId = undefined - } - - // Flush remaining text parts - if (Predicate.isNotUndefined(activeTextId)) { - parts.push({ - type: "text-end", - id: activeTextId, - }) - activeTextId = undefined - } - - parts.push({ - type: "finish", - reason: finishReason, - usage: { - inputTokens: event.usage?.prompt_tokens, - outputTokens: event.usage?.completion_tokens, - totalTokens: event.usage?.total_tokens, - reasoningTokens: event.usage?.completion_tokens_details?.reasoning_tokens, - cachedInputTokens: event.usage?.prompt_tokens_details?.cached_tokens, - }, - metadata: { - openrouter: { - provider: event.provider, - usage: { - cost: event.usage?.cost, - promptTokensDetails: event.usage?.prompt_tokens_details, - completionTokensDetails: event.usage?.completion_tokens_details, - costDetails: event.usage?.cost_details, - }, - }, - }, - }) - } - - return parts - }), - ), - Stream.flattenIterables, - ) - }, -) - -// ============================================================================= -// Telemetry -// ============================================================================= - -const annotateRequest = (span: Span, request: typeof Generated.ChatGenerationParams.Encoded): void => { - addGenAIAnnotations(span, { - system: "openrouter", - operation: { name: "chat" }, - request: { - model: request.model, - temperature: request.temperature, - topP: request.top_p, - maxTokens: request.max_tokens, - stopSequences: Arr.ensure(request.stop).filter(Predicate.isNotNullable), - }, - }) -} - -const annotateResponse = (span: Span, response: Generated.ChatResponse): void => { - addGenAIAnnotations(span, { - response: { - id: response.id, - model: response.model, - finishReasons: response.choices - .map((choice) => choice.finish_reason) - .filter(Predicate.isNotNullable), - }, - usage: { - inputTokens: response.usage?.prompt_tokens, - outputTokens: response.usage?.completion_tokens, - }, - }) -} - -const annotateStreamResponse = (span: Span, part: Response.StreamPartEncoded) => { - if (part.type === "response-metadata") { - addGenAIAnnotations(span, { - response: { - id: part.id, - model: part.modelId, - }, - }) - } - if (part.type === "finish") { - addGenAIAnnotations(span, { - response: { - finishReasons: [part.reason], - }, - usage: { - inputTokens: part.usage.inputTokens, - outputTokens: part.usage.outputTokens, - }, - }) - } -} - -// ============================================================================= -// Utilities -// ============================================================================= - -const getCacheControl = ( - part: - | Prompt.SystemMessage - | Prompt.UserMessage - | Prompt.AssistantMessage - | Prompt.ToolMessage - | Prompt.TextPart - | Prompt.ReasoningPart - | Prompt.FilePart - | Prompt.ToolResultPart, -): typeof Generated.CacheControlEphemeral.Encoded | undefined => part.options.openrouter?.cacheControl - -const getMediaType = (dataUrl: string): string | undefined => { - const match = dataUrl.match(/^data:([^;]+)/) - return match ? match[1] : undefined -} - -const getBase64FromDataUrl = (dataUrl: string): string => { - const match = dataUrl.match(/^data:[^;]*;base64,(.+)$/) - return match ? match[1]! : dataUrl -} diff --git a/libs/ai-openrouter/src/index.ts b/libs/ai-openrouter/src/index.ts deleted file mode 100644 index 8d2a1b0eb..000000000 --- a/libs/ai-openrouter/src/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @since 1.0.0 - */ -export * as Generated from "./Generated.js" - -/** - * @since 1.0.0 - */ -export * as OpenRouterClient from "./OpenRouterClient.js" - -/** - * @since 1.0.0 - */ -export * as OpenRouterConfig from "./OpenRouterConfig.js" - -/** - * @since 1.0.0 - */ -export * as OpenRouterLanguageModel from "./OpenRouterLanguageModel.js" diff --git a/libs/ai-openrouter/src/internal/utilities.ts b/libs/ai-openrouter/src/internal/utilities.ts deleted file mode 100644 index 54bb0a415..000000000 --- a/libs/ai-openrouter/src/internal/utilities.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type * as Response from "@effect/ai/Response" -import * as Predicate from "effect/Predicate" -import type * as Generated from "../Generated.js" - -const finishReasonMap: Record = { - content_filter: "content-filter", - error: "error", - function_call: "tool-calls", - tool_calls: "tool-calls", - length: "length", - stop: "stop", -} - -/** @internal */ -export const resolveFinishReason = ( - finishReason: typeof Generated.ChatCompletionFinishReason.Type | null, -): Response.FinishReason => { - if (Predicate.isNull(finishReason)) { - return "unknown" - } - const reason = finishReasonMap[finishReason] - if (Predicate.isUndefined(reason)) { - return "unknown" - } - return reason -} diff --git a/libs/ai-openrouter/tsconfig.json b/libs/ai-openrouter/tsconfig.json deleted file mode 100644 index a98bb77f1..000000000 --- a/libs/ai-openrouter/tsconfig.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "include": ["src/**/*.ts"], - "exclude": ["**/*.test.ts", "**/*.spec.ts"], - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "lib": ["ES2022", "DOM"], - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, - - /* Declaration output */ - "declaration": true, - "declarationMap": true, - - /* Linting */ - "skipLibCheck": true, - "strict": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true, - "plugins": [ - { - "name": "@effect/language-service", - "reportSuggestionsAsWarningsInTsc": true - } - ] - } -} diff --git a/libs/bot-sdk/examples/simple-echo-bot/index.ts b/libs/bot-sdk/examples/simple-echo-bot/index.ts index 2fe71c8d1..a807b72ad 100644 --- a/libs/bot-sdk/examples/simple-echo-bot/index.ts +++ b/libs/bot-sdk/examples/simple-echo-bot/index.ts @@ -95,7 +95,7 @@ const program = Effect.gen(function* () { // ctx.args.text is typed as string! yield* bot.message.send(ctx.channelId, `Echo: ${ctx.args.text}`).pipe( Effect.tap((msg) => Effect.log(`✅ Sent echo response: ${msg.id}`)), - Effect.catchAll((error) => Effect.logError(`Failed to send echo: ${error}`)), + Effect.catch((error) => Effect.logError(`Failed to send echo: ${error}`)), ) }), ) @@ -106,7 +106,7 @@ const program = Effect.gen(function* () { // ctx.args is typed as {} (empty object) since PingCommand has no args yield* bot.message.send(ctx.channelId, "Pong! 🏓").pipe( Effect.tap(() => Effect.log("✅ Sent pong response")), - Effect.catchAll((error) => Effect.logError(`Failed to send pong: ${error}`)), + Effect.catch((error) => Effect.logError(`Failed to send pong: ${error}`)), ) }), ) @@ -131,7 +131,7 @@ const program = Effect.gen(function* () { // Echo the message back using bot.message.reply yield* bot.message.reply(message, `Echo: ${message.content}`).pipe( Effect.tap((sentMessage) => Effect.log(`✅ Echoed message: ${sentMessage.id}`)), - Effect.catchAll((error) => Effect.logError(`Failed to send echo: ${error}`)), + Effect.catch((error) => Effect.logError(`Failed to send echo: ${error}`)), ) }), ) @@ -142,7 +142,7 @@ const program = Effect.gen(function* () { if (message.content.toLowerCase().includes("hello")) { yield* bot.message.react(message, "👋").pipe( Effect.tap(() => Effect.log("👋 Waved at hello message")), - Effect.catchAll((error) => Effect.logError(`Failed to react: ${error}`)), + Effect.catch((error) => Effect.logError(`Failed to react: ${error}`)), ) } }), diff --git a/libs/bot-sdk/package.json b/libs/bot-sdk/package.json index a85161188..a89ddb4e5 100644 --- a/libs/bot-sdk/package.json +++ b/libs/bot-sdk/package.json @@ -50,11 +50,8 @@ "prepublishOnly": "GENERATE_DTS=1 bun run build" }, "devDependencies": { - "@effect/language-service": "catalog:effect", "@effect/opentelemetry": "catalog:effect", - "@effect/platform": "catalog:effect", "@effect/platform-bun": "catalog:effect", - "@effect/rpc": "catalog:effect", "@hazel/actors": "workspace:*", "@hazel/domain": "workspace:*", "@hazel/effect-bun": "workspace:*", @@ -68,12 +65,9 @@ "typescript": "^5.9.3" }, "peerDependencies": { - "@effect/opentelemetry": ">=0.61.0", - "@effect/platform": ">=0.94.0", - "@effect/platform-bun": ">=0.87.0", - "@effect/rpc": ">=0.73.0", - "@effect/workflow": ">=0.16.0", - "effect": ">=3.19.0", + "@effect/opentelemetry": ">=4.0.0-beta.0", + "@effect/platform-bun": ">=4.0.0-beta.0", + "effect": ">=4.0.0-beta.0", "jose": ">=6.0.0", "rivetkit": ">=2.0.0" }, @@ -86,9 +80,6 @@ }, "rivetkit": { "optional": true - }, - "@effect/workflow": { - "optional": true } }, "engines": { diff --git a/libs/bot-sdk/src/auth.ts b/libs/bot-sdk/src/auth.ts index 6578e515d..f8799b83e 100644 --- a/libs/bot-sdk/src/auth.ts +++ b/libs/bot-sdk/src/auth.ts @@ -1,6 +1,7 @@ -import { FetchHttpClient, HttpApiClient, HttpClient, HttpClientRequest } from "@effect/platform" +import { HttpApiClient } from "effect/unstable/httpapi" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" import { HazelApi } from "@hazel/domain/http" -import { Duration, Effect, Schedule } from "effect" +import { Layer, ServiceMap, Duration, Effect, Schedule } from "effect" import { AuthenticationError } from "./errors.ts" /** @@ -36,9 +37,8 @@ export interface BotAuthContext { /** * Service for bot authentication */ -export class BotAuth extends Effect.Service()("BotAuth", { - accessors: true, - effect: Effect.fn(function* (context: BotAuthContext) { +export class BotAuth extends ServiceMap.Service()("BotAuth", { + make: Effect.fn(function* (context: BotAuthContext) { return { getContext: Effect.succeed(context), @@ -56,7 +56,9 @@ export class BotAuth extends Effect.Service()("BotAuth", { }), } }), -}) {} +}) { + static readonly layer = (context: BotAuthContext) => Layer.effect(this, this.make(context)) +} /** * Helper to create auth context from bot token by calling the backend API @@ -83,8 +85,8 @@ export const createAuthContextFromToken = ( Effect.retry( Schedule.exponential("1 second", 2).pipe( Schedule.jittered, - Schedule.whileOutput((duration) => - Duration.lessThanOrEqualTo(duration, Duration.seconds(30)), + Schedule.while((metadata) => + Duration.isLessThanOrEqualTo(metadata.output, Duration.seconds(30)), ), ), ), diff --git a/libs/bot-sdk/src/bot-config.ts b/libs/bot-sdk/src/bot-config.ts index c0952e6db..cdeb6c600 100644 --- a/libs/bot-sdk/src/bot-config.ts +++ b/libs/bot-sdk/src/bot-config.ts @@ -5,7 +5,7 @@ * Provides automatic validation and helpful error messages. */ -import { Config } from "effect" +import { Config, type Effect } from "effect" const DEFAULT_ACTORS_URL = "https://rivet.hazel.sh" @@ -18,24 +18,14 @@ const DEFAULT_ACTORS_URL = "https://rivet.hazel.sh" * - GATEWAY_URL (optional) - Gateway URL for inbound bot websocket delivery */ export const BotEnvConfig = Config.all({ - botToken: Config.redacted("BOT_TOKEN").pipe(Config.withDescription("Bot authentication token")), - backendUrl: Config.string("BACKEND_URL").pipe( - Config.withDefault("https://api.hazel.sh"), - Config.withDescription("Backend API URL"), - ), - gatewayUrl: Config.string("GATEWAY_URL").pipe( - Config.withDefault("https://bot-gateway.hazel.sh"), - Config.withDescription("Gateway API URL for inbound bot websocket delivery"), - ), + botToken: Config.redacted("BOT_TOKEN"), + backendUrl: Config.string("BACKEND_URL").pipe(Config.withDefault("https://api.hazel.sh")), + gatewayUrl: Config.string("GATEWAY_URL").pipe(Config.withDefault("https://bot-gateway.hazel.sh")), actorsUrl: Config.string("ACTORS_URL").pipe( Config.orElse(() => Config.string("RIVET_URL")), Config.withDefault(DEFAULT_ACTORS_URL), - Config.withDescription("Actors/Rivet endpoint for live state streaming"), - ), - healthPort: Config.number("PORT").pipe( - Config.withDefault(0), - Config.withDescription("Health check server port (default 0, OS-assigned)"), ), + healthPort: Config.number("PORT").pipe(Config.withDefault(0)), }) -export type BotEnvConfig = Config.Config.Success +export type BotEnvConfig = Effect.Success diff --git a/libs/bot-sdk/src/command.ts b/libs/bot-sdk/src/command.ts index e5380f5e6..54130aa06 100644 --- a/libs/bot-sdk/src/command.ts +++ b/libs/bot-sdk/src/command.ts @@ -93,7 +93,7 @@ export type CommandArgsFields = */ export type CommandArgs = G extends CommandGroup - ? Extract extends { argsSchema: Schema.Schema } + ? Extract extends { argsSchema: Schema.Schema } ? A : {} : never diff --git a/libs/bot-sdk/src/errors.ts b/libs/bot-sdk/src/errors.ts index 243fab60a..bca775af7 100644 --- a/libs/bot-sdk/src/errors.ts +++ b/libs/bot-sdk/src/errors.ts @@ -3,15 +3,18 @@ import { Schema } from "effect" /** * Error thrown when bot authentication fails. */ -export class AuthenticationError extends Schema.TaggedError()("AuthenticationError", { - message: Schema.String, - cause: Schema.Unknown, -}) {} +export class AuthenticationError extends Schema.TaggedErrorClass()( + "AuthenticationError", + { + message: Schema.String, + cause: Schema.Unknown, + }, +) {} /** * Error thrown when a command payload cannot be decoded. */ -export class CommandArgsDecodeError extends Schema.TaggedError()( +export class CommandArgsDecodeError extends Schema.TaggedErrorClass()( "CommandArgsDecodeError", { message: Schema.String, @@ -23,16 +26,19 @@ export class CommandArgsDecodeError extends Schema.TaggedError()("CommandHandlerError", { - message: Schema.String, - commandName: Schema.String, - cause: Schema.Unknown, -}) {} +export class CommandHandlerError extends Schema.TaggedErrorClass()( + "CommandHandlerError", + { + message: Schema.String, + commandName: Schema.String, + cause: Schema.Unknown, + }, +) {} /** * Error thrown when syncing slash commands with the backend fails. */ -export class CommandSyncError extends Schema.TaggedError()("CommandSyncError", { +export class CommandSyncError extends Schema.TaggedErrorClass()("CommandSyncError", { message: Schema.String, cause: Schema.Unknown, }) {} @@ -40,23 +46,26 @@ export class CommandSyncError extends Schema.TaggedError()("Co /** * Error thrown when syncing mentionable settings fails. */ -export class MentionableSyncError extends Schema.TaggedError()("MentionableSyncError", { - message: Schema.String, - cause: Schema.Unknown, -}) {} +export class MentionableSyncError extends Schema.TaggedErrorClass()( + "MentionableSyncError", + { + message: Schema.String, + cause: Schema.Unknown, + }, +) {} -export class GatewayReadError extends Schema.TaggedError()("GatewayReadError", { +export class GatewayReadError extends Schema.TaggedErrorClass()("GatewayReadError", { message: Schema.String, cause: Schema.Unknown, }) {} -export class GatewayDecodeError extends Schema.TaggedError()("GatewayDecodeError", { +export class GatewayDecodeError extends Schema.TaggedErrorClass()("GatewayDecodeError", { message: Schema.String, payload: Schema.String, cause: Schema.Unknown, }) {} -export class GatewaySessionStoreError extends Schema.TaggedError()( +export class GatewaySessionStoreError extends Schema.TaggedErrorClass()( "GatewaySessionStoreError", { message: Schema.String, @@ -67,7 +76,7 @@ export class GatewaySessionStoreError extends Schema.TaggedError()("MessageSendError", { +export class MessageSendError extends Schema.TaggedErrorClass()("MessageSendError", { message: Schema.String, channelId: Schema.String, cause: Schema.Unknown, @@ -76,7 +85,7 @@ export class MessageSendError extends Schema.TaggedError()("Me /** * Error thrown when replying to a message fails. */ -export class MessageReplyError extends Schema.TaggedError()("MessageReplyError", { +export class MessageReplyError extends Schema.TaggedErrorClass()("MessageReplyError", { message: Schema.String, channelId: Schema.String, replyToMessageId: Schema.String, @@ -86,7 +95,7 @@ export class MessageReplyError extends Schema.TaggedError()(" /** * Error thrown when updating a message fails. */ -export class MessageUpdateError extends Schema.TaggedError()("MessageUpdateError", { +export class MessageUpdateError extends Schema.TaggedErrorClass()("MessageUpdateError", { message: Schema.String, messageId: Schema.String, cause: Schema.Unknown, @@ -95,7 +104,7 @@ export class MessageUpdateError extends Schema.TaggedError() /** * Error thrown when deleting a message fails. */ -export class MessageDeleteError extends Schema.TaggedError()("MessageDeleteError", { +export class MessageDeleteError extends Schema.TaggedErrorClass()("MessageDeleteError", { message: Schema.String, messageId: Schema.String, cause: Schema.Unknown, @@ -104,7 +113,7 @@ export class MessageDeleteError extends Schema.TaggedError() /** * Error thrown when toggling a reaction fails. */ -export class MessageReactError extends Schema.TaggedError()("MessageReactError", { +export class MessageReactError extends Schema.TaggedErrorClass()("MessageReactError", { message: Schema.String, messageId: Schema.String, emoji: Schema.String, @@ -114,7 +123,7 @@ export class MessageReactError extends Schema.TaggedError()(" /** * Error thrown when listing messages fails. */ -export class MessageListError extends Schema.TaggedError()("MessageListError", { +export class MessageListError extends Schema.TaggedErrorClass()("MessageListError", { message: Schema.String, channelId: Schema.String, cause: Schema.Unknown, @@ -123,7 +132,7 @@ export class MessageListError extends Schema.TaggedError()("Me /** * Error thrown when an event handler execution fails. */ -export class EventHandlerError extends Schema.TaggedError()("EventHandlerError", { +export class EventHandlerError extends Schema.TaggedErrorClass()("EventHandlerError", { message: Schema.String, eventType: Schema.String, cause: Schema.Unknown, diff --git a/libs/bot-sdk/src/gateway.test.ts b/libs/bot-sdk/src/gateway.test.ts index 3c2d9a82d..2c561cf01 100644 --- a/libs/bot-sdk/src/gateway.test.ts +++ b/libs/bot-sdk/src/gateway.test.ts @@ -9,7 +9,7 @@ import { createGatewayWebSocketUrl, } from "./gateway.ts" -const BOT_ID = "00000000-0000-0000-0000-000000000111" as BotId +const BOT_ID = "00000000-0000-4000-8000-000000000111" as BotId describe("InMemoryGatewaySessionStoreLive", () => { it("loads and saves offsets per bot", () => diff --git a/libs/bot-sdk/src/gateway.ts b/libs/bot-sdk/src/gateway.ts index ec6936761..0877663e1 100644 --- a/libs/bot-sdk/src/gateway.ts +++ b/libs/bot-sdk/src/gateway.ts @@ -1,5 +1,5 @@ import type { BotId } from "@hazel/schema" -import { Context, Effect, Layer, Ref } from "effect" +import { ServiceMap, Effect, Layer, Ref } from "effect" import { GatewaySessionStoreError } from "./errors.ts" export interface GatewaySessionStore { @@ -7,7 +7,7 @@ export interface GatewaySessionStore { save(botId: BotId, offset: string): Effect.Effect } -export const GatewaySessionStoreTag = Context.GenericTag( +export const GatewaySessionStoreTag = ServiceMap.Service( "@hazel/bot-sdk/GatewaySessionStore", ) @@ -53,7 +53,7 @@ export interface BotStateStore { delete(botId: BotId, key: string): Effect.Effect } -export const BotStateStoreTag = Context.GenericTag("@hazel/bot-sdk/BotStateStore") +export const BotStateStoreTag = ServiceMap.Service("@hazel/bot-sdk/BotStateStore") export const InMemoryBotStateStoreLive = Layer.effect( BotStateStoreTag, diff --git a/libs/bot-sdk/src/hazel-bot-sdk.error-handling.test.ts b/libs/bot-sdk/src/hazel-bot-sdk.error-handling.test.ts index 954048f27..f14065c65 100644 --- a/libs/bot-sdk/src/hazel-bot-sdk.error-handling.test.ts +++ b/libs/bot-sdk/src/hazel-bot-sdk.error-handling.test.ts @@ -11,12 +11,16 @@ import { BotStateStoreTag, GatewaySessionStoreTag } from "./gateway.ts" import { HazelBotClient, HazelBotRuntimeConfigTag } from "./hazel-bot-sdk.ts" import { BotRpcClient, BotRpcClientConfigTag } from "./rpc/client.ts" +vi.mock("@hazel/effect-bun/Telemetry", () => ({ + createTracingLayer: () => Layer.empty, +})) + const BACKEND_URL = "http://localhost:3070" const GATEWAY_URL = "http://localhost:3034" -const BOT_ID = "00000000-0000-0000-0000-000000000111" -const USER_ID = "00000000-0000-0000-0000-000000000222" -const ORG_ID = "00000000-0000-0000-0000-000000000333" -const CHANNEL_ID = "00000000-0000-0000-0000-000000000444" +const BOT_ID = "00000000-0000-4000-8000-000000000111" +const USER_ID = "00000000-0000-4000-8000-000000000222" +const ORG_ID = "00000000-0000-4000-8000-000000000333" +const CHANNEL_ID = "00000000-0000-4000-8000-000000000444" const BOT_TOKEN = "test-bot-token" const EchoCommand = Command.make("echo", { @@ -37,7 +41,7 @@ const server = setupServer() const makeMessageResponse = (content: string) => ({ data: { - id: "00000000-0000-0000-0000-000000000999", + id: "00000000-0000-4000-8000-000000000999", channelId: CHANNEL_ID, authorId: USER_ID, content, @@ -48,13 +52,13 @@ const makeMessageResponse = (content: string) => ({ updatedAt: null, deletedAt: null, }, - transactionId: "00000000-0000-0000-0000-000000000998", + transactionId: "00000000-0000-4000-8000-000000000998", }) const makeHazelBotLayer = () => - HazelBotClient.Default.pipe( + HazelBotClient.layer.pipe( Layer.provide( - BotAuth.Default({ + BotAuth.layer({ botId: BOT_ID, botName: "Test Bot", userId: USER_ID, diff --git a/libs/bot-sdk/src/hazel-bot-sdk.test.ts b/libs/bot-sdk/src/hazel-bot-sdk.test.ts index c5269da91..69f21bf27 100644 --- a/libs/bot-sdk/src/hazel-bot-sdk.test.ts +++ b/libs/bot-sdk/src/hazel-bot-sdk.test.ts @@ -2,6 +2,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "@effect/vi import { Duration, Effect, Layer, Ref } from "effect" import { delay, http, HttpResponse } from "msw" import { setupServer } from "msw/node" +import { vi } from "vitest" import { BotGatewayAckFrame, BotGatewayClientFrame, @@ -17,12 +18,16 @@ import { BotRpcClient, BotRpcClientConfigTag } from "./rpc/client.ts" import { BotStateStoreTag, GatewaySessionStoreTag } from "./gateway.ts" import { Schema } from "effect" +vi.mock("@hazel/effect-bun/Telemetry", () => ({ + createTracingLayer: () => Layer.empty, +})) + const BACKEND_URL = "http://localhost:3070" const GATEWAY_URL = "http://localhost:3034" -const BOT_ID = "00000000-0000-0000-0000-000000000111" -const USER_ID = "00000000-0000-0000-0000-000000000222" -const ORG_ID = "00000000-0000-0000-0000-000000000333" -const CHANNEL_ID = "00000000-0000-0000-0000-000000000444" +const BOT_ID = "00000000-0000-4000-8000-000000000111" +const USER_ID = "00000000-0000-4000-8000-000000000222" +const ORG_ID = "00000000-0000-4000-8000-000000000333" +const CHANNEL_ID = "00000000-0000-4000-8000-000000000444" const BOT_TOKEN = "test-bot-token" const EchoCommand = Command.make("echo", { @@ -122,9 +127,9 @@ const makeHazelBotLayer = (options: { delete: (botId: string, key: string) => Effect.Effect } }) => { - return HazelBotClient.Default.pipe( + return HazelBotClient.layer.pipe( Layer.provide( - BotAuth.Default({ + BotAuth.layer({ botId: BOT_ID, botName: "Test Bot", userId: USER_ID, @@ -316,7 +321,7 @@ describe("HazelBotClient durable gateway", () => { const bot = yield* HazelBotClient yield* bot.onCommand(EchoCommand, () => Ref.update(attemptsRef, (attempts) => attempts + 1).pipe( - Effect.zipRight(Effect.fail(new Error("boom"))), + Effect.andThen(Effect.fail(new Error("boom"))), ), ) yield* bot.start diff --git a/libs/bot-sdk/src/hazel-bot-sdk.ts b/libs/bot-sdk/src/hazel-bot-sdk.ts index a9631c08e..bc7163a1c 100644 --- a/libs/bot-sdk/src/hazel-bot-sdk.ts +++ b/libs/bot-sdk/src/hazel-bot-sdk.ts @@ -5,7 +5,8 @@ * Hazel message, channel, membership, and command events are pre-configured. */ -import { FetchHttpClient, HttpApiClient } from "@effect/platform" +import { HttpApiClient } from "effect/unstable/httpapi" +import { FetchHttpClient } from "effect/unstable/http" import type { AttachmentId, BotId, @@ -32,24 +33,30 @@ import { isTemporaryActorServiceError, } from "@hazel/domain" import type { IntegrationConnection } from "@hazel/domain/models" -import { HazelApi } from "@hazel/domain/http" +import { + CreateMessageRequest, + HazelApi, + SyncBotCommandsRequest, + ToggleReactionRequest, + UpdateBotSettingsRequest, + UpdateMessageRequest, +} from "@hazel/domain/http" import { Channel, ChannelMember, Message } from "@hazel/domain/models" import { createTracingLayer } from "@hazel/effect-bun/Telemetry" import { Cache, Config, - Context, Duration, Effect, Layer, LogLevel, ManagedRuntime, Option, - RateLimiter, Redacted, Ref, - Runtime, Schema, + Semaphore, + ServiceMap, } from "effect" import { BotAuth, createAuthContextFromToken } from "./auth.ts" import { createLoggerLayer, logLevelFromString, type BotLogConfig, type LogFormat } from "./log-config.ts" @@ -95,6 +102,7 @@ import { type AIStreamOptions, type AIStreamSession, type CreateStreamOptions, + type MessageCreateFn, type MessageUpdateFn, } from "./streaming/index.ts" @@ -118,17 +126,17 @@ export interface HazelBotRuntimeConfig = Comm readonly heartbeatIntervalMs?: number } -export class HazelBotRuntimeConfigTag extends Context.Tag("@hazel/bot-sdk/HazelBotRuntimeConfig")< +export class HazelBotRuntimeConfigTag extends ServiceMap.Service< HazelBotRuntimeConfigTag, HazelBotRuntimeConfig ->() {} +>()("@hazel/bot-sdk/HazelBotRuntimeConfig") {} /** * Hazel-specific type aliases for convenience */ -export type MessageType = Schema.Schema.Type -export type ChannelType = Schema.Schema.Type -export type ChannelMemberType = Schema.Schema.Type +export type MessageType = Schema.Schema.Type +export type ChannelType = Schema.Schema.Type +export type ChannelMemberType = Schema.Schema.Type /** * Hazel-specific event handlers @@ -184,9 +192,8 @@ export interface SendMessageOptions { * Hazel Bot Client - Effect Service with typed convenience methods * Uses scoped: since it manages scoped resources (RateLimiter) */ -export class HazelBotClient extends Effect.Service()("HazelBotClient", { - accessors: true, - scoped: Effect.gen(function* () { +export class HazelBotClient extends ServiceMap.Service()("HazelBotClient", { + make: Effect.gen(function* () { const auth = yield* BotAuth // Get the RPC client from context const rpc = yield* BotRpcClient @@ -215,12 +222,11 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl botName: authContext.botName, } - // Create rate limiter for outbound message operations - // Default: 10 messages per second to prevent API rate limiting - const messageLimiter = yield* RateLimiter.make({ - limit: 10, - interval: Duration.seconds(1), - }) + // Create semaphore for outbound message operations + // Limit to 10 concurrent requests to prevent API rate limiting + const messageSemaphore = Semaphore.makeUnsafe(10) + const messageLimiter = (effect: Effect.Effect) => + Semaphore.withPermit(messageSemaphore)(effect) // Get the runtime config (optional - contains commands to sync) const runtimeConfigOption = yield* Effect.serviceOption(HazelBotRuntimeConfigTag) @@ -268,7 +274,7 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl capacity: 100, timeToLive: Duration.seconds(30), lookup: (orgId: OrganizationId) => - httpApiClient["bot-commands"].getEnabledIntegrations({ path: { orgId } }).pipe( + httpApiClient["bot-commands"].getEnabledIntegrations({ params: { orgId } }).pipe( Effect.map((r) => new Set(r.providers)), Effect.withSpan("bot.integration.getEnabled", { attributes: { orgId } }), ), @@ -295,7 +301,7 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl }), }).pipe( Effect.flatMap((parsed) => - Schema.decodeUnknown(schema)(parsed).pipe( + Schema.decodeUnknownEffect(schema)(parsed).pipe( Effect.mapError( (cause) => new GatewayDecodeError({ @@ -312,7 +318,7 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl ) const setBotState = (key: string, schema: Schema.Schema, value: A) => - Schema.encode(schema)(value).pipe( + Schema.encodeEffect(schema)(value).pipe( Effect.flatMap((encoded) => botStateStore.set(authContext.botId as BotId, key, JSON.stringify(encoded)), ), @@ -372,14 +378,14 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl const decodeCommandArgs = (event: Extract) => Option.match( Option.flatMap(commandGroup, (group) => - Option.fromNullable( + Option.fromNullishOr( group.commands.find((c: CommandDef) => c.name === event.payload.commandName), ), ), { onNone: () => Effect.succeed(event.payload.arguments), onSome: (def) => - Schema.decodeUnknown(def.argsSchema)(event.payload.arguments).pipe( + Schema.decodeUnknownEffect(def.argsSchema)(event.payload.arguments).pipe( Effect.mapError( (cause) => new CommandArgsDecodeError({ @@ -503,7 +509,7 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl partitionEvents, (envelope) => dispatchGatewayEvent(envelope).pipe( - Effect.tapErrorCause((cause) => + Effect.tapCause((cause) => Effect.logError("Gateway event handler failed", { eventType: envelope.eventType, partitionKey: envelope.partitionKey, @@ -568,7 +574,7 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl const startWebSocketGatewayLoop = (runtimeConfig: HazelBotRuntimeConfig) => Effect.gen(function* () { - const runtime = yield* Effect.runtime() + const services = yield* Effect.services() let nextResumeOffset = yield* loadResumeOffset(runtimeConfig) let nextSessionId = yield* loadGatewaySessionId() let hasConnected = false @@ -666,7 +672,7 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl } case "READY": { sessionId = frame.sessionId - Runtime.runPromise(runtime)( + Effect.runPromiseWith(services)( setBotState( GATEWAY_SESSION_ID_STATE_KEY, Schema.String, @@ -683,7 +689,7 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl }, ), }), - Effect.zipRight( + Effect.andThen( Effect.logInfo( hasConnected || frame.resumed ? "Bot gateway websocket reconnected" @@ -702,7 +708,7 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl return } case "DISPATCH": { - Runtime.runPromise(runtime)( + Effect.runPromiseWith(services)( Effect.gen(function* () { yield* processGatewayBatch(frame.events) yield* gatewaySessionStore.save( @@ -743,7 +749,7 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl return } case "INVALID_SESSION": { - Runtime.runPromise(runtime)( + Effect.runPromiseWith(services)( Effect.gen(function* () { yield* gatewaySessionStore.save( authContext.botId as BotId, @@ -803,30 +809,28 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl }), }) - yield* Effect.forkScoped( - Effect.forever( - Effect.gen(function* () { - const nextState = yield* connectOnce.pipe( - Effect.catchAll((error) => - Effect.logWarning("Bot gateway websocket failed, retrying", { - error, - botId: authContext.botId, - offset: nextResumeOffset, + yield* Effect.forever( + Effect.gen(function* () { + const nextState = yield* connectOnce.pipe( + Effect.catch((error) => + Effect.logWarning("Bot gateway websocket failed, retrying", { + error, + botId: authContext.botId, + offset: nextResumeOffset, + sessionId: nextSessionId, + }).pipe( + Effect.andThen(Effect.sleep(Duration.seconds(1))), + Effect.as({ + resumeOffset: nextResumeOffset, sessionId: nextSessionId, - }).pipe( - Effect.zipRight(Effect.sleep(Duration.seconds(1))), - Effect.as({ - resumeOffset: nextResumeOffset, - sessionId: nextSessionId, - }), - ), + }), ), - ) - nextResumeOffset = nextState.resumeOffset - nextSessionId = nextState.sessionId - }).pipe(Effect.zipRight(Effect.sleep(Duration.millis(250)))), - ), - ) + ), + ) + nextResumeOffset = nextState.resumeOffset + nextSessionId = nextState.sessionId + }).pipe(Effect.andThen(Effect.sleep(Duration.millis(250)))), + ).pipe(Effect.forkScoped) }) const startGatewayLoop = () => @@ -880,14 +884,14 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl // Call the sync endpoint using type-safe HttpApiClient const response = yield* httpApiClient["bot-commands"].syncCommands({ - payload: { + payload: new SyncBotCommandsRequest({ commands: cmds.map((cmd: CommandDef) => ({ name: cmd.name, description: cmd.description, arguments: schemaFieldsToArgs(cmd.args), usageExample: cmd.usageExample ?? null, })), - }, + }), }) yield* Effect.logDebug(`Synced ${response.syncedCount} commands successfully`) @@ -913,7 +917,7 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl yield* Effect.logDebug(`Syncing mentionable=${config.mentionable} with backend...`) yield* httpApiClient["bot-commands"].updateBotSettings({ - payload: { mentionable: config.mentionable }, + payload: new UpdateBotSettingsRequest({ mentionable: config.mentionable }), }) yield* Effect.logDebug("Mentionable flag synced successfully") @@ -959,7 +963,7 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl * Helper to create the message creation function. * Shared between stream.create and ai.stream to avoid code duplication. */ - const createMessageFnHelper = ( + const createMessageFnHelper: MessageCreateFn = ( chId: ChannelId, content: string, opts?: { @@ -983,13 +987,13 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl messageLimiter( httpApiClient["api-v1-messages"] .createMessage({ - payload: { + payload: new CreateMessageRequest({ channelId: chId, content, replyToMessageId: opts?.replyToMessageId ?? null, threadChannelId: opts?.threadChannelId ?? null, embeds: opts?.embeds ?? null, - }, + }), }) .pipe( Effect.map((r) => r.data), @@ -1033,11 +1037,11 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl messageLimiter( httpApiClient["api-v1-messages"] .updateMessage({ - path: { id: messageId }, - payload: { + params: { id: messageId }, + payload: new UpdateMessageRequest({ content: payload.content, embeds: payload.embeds ?? null, - }, + }), }) .pipe( Effect.map((r) => r.data), @@ -1154,7 +1158,7 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl messageLimiter( httpApiClient["api-v1-messages"] .createMessage({ - payload: { + payload: new CreateMessageRequest({ channelId, content, replyToMessageId: options?.replyToMessageId ?? null, @@ -1163,7 +1167,7 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl ? [...options.attachmentIds] : undefined, embeds: options?.embeds ?? null, - }, + }), }) .pipe( Effect.map((r) => r.data), @@ -1193,7 +1197,7 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl messageLimiter( httpApiClient["api-v1-messages"] .createMessage({ - payload: { + payload: new CreateMessageRequest({ channelId: message.channelId, content, replyToMessageId: message.id, @@ -1202,7 +1206,7 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl ? [...options.attachmentIds] : undefined, embeds: null, - }, + }), }) .pipe( Effect.map((r) => r.data), @@ -1233,8 +1237,8 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl messageLimiter( httpApiClient["api-v1-messages"] .updateMessage({ - path: { id: message.id }, - payload: { content }, + params: { id: message.id }, + payload: new UpdateMessageRequest({ content }), }) .pipe( Effect.map((r) => r.data), @@ -1260,7 +1264,7 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl messageLimiter( httpApiClient["api-v1-messages"] .deleteMessage({ - path: { id }, + params: { id }, }) .pipe( Effect.mapError( @@ -1284,11 +1288,11 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl messageLimiter( httpApiClient["api-v1-messages"] .toggleReaction({ - path: { id: message.id }, - payload: { + params: { id: message.id }, + payload: new ToggleReactionRequest({ emoji, channelId: message.channelId, - }, + }), }) .pipe( Effect.mapError( @@ -1337,7 +1341,7 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl ) => httpApiClient["api-v1-messages"] .listMessages({ - urlParams: { + query: { channel_id: channelId, starting_after: options?.startingAfter, ending_before: options?.endingBefore, @@ -1373,19 +1377,17 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl description?: string | null }, ) => - rpc.channel - .update({ - id: channel.id, - type: channel.type, - organizationId: channel.organizationId, - parentChannelId: channel.parentChannelId, - name: updates.name ?? channel.name, - ...updates, - }) - .pipe( - Effect.map((r) => r.data), - Effect.withSpan("bot.channel.update", { attributes: { channelId: channel.id } }), - ), + rpc["channel.update"]({ + id: channel.id, + type: channel.type, + organizationId: channel.organizationId, + parentChannelId: channel.parentChannelId, + name: updates.name ?? channel.name, + ...updates, + }).pipe( + Effect.map((r: any) => r.data), + Effect.withSpan("bot.channel.update", { attributes: { channelId: channel.id } }), + ), /** * Ensure a thread exists on a message and return it. @@ -1397,24 +1399,22 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl * @throws MessageNotFoundError if the message doesn't exist */ createThread: (messageId: MessageId, channelId: ChannelId) => - rpc.channel - .createThread({ - messageId, - }) - .pipe( - Effect.timeout(Duration.seconds(15)), - Effect.tapErrorCause((cause) => - Effect.logError("[bot.channel.createThread] Failed to ensure thread", { - messageId, - channelId, - cause, - }), - ), - Effect.map((r) => r.data), - Effect.withSpan("bot.channel.createThread", { - attributes: { messageId, channelId }, + rpc["channel.createThread"]({ + messageId, + }).pipe( + Effect.timeout(Duration.seconds(15)), + Effect.tapCause((cause) => + Effect.logError("[bot.channel.createThread] Failed to ensure thread", { + messageId, + channelId, + cause, }), ), + Effect.map((r: any) => r.data), + Effect.withSpan("bot.channel.createThread", { + attributes: { messageId, channelId }, + }), + ), }, /** @@ -1427,30 +1427,26 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl * @param memberId - Channel member ID */ start: (channelId: ChannelId, memberId: ChannelMemberId) => - rpc.typingIndicator - .create({ - channelId, - memberId, - lastTyped: Date.now(), - }) - .pipe( - Effect.map((r) => r.data), - Effect.withSpan("bot.typing.start", { attributes: { channelId, memberId } }), - ), + rpc["typingIndicator.create"]({ + channelId, + memberId, + lastTyped: Date.now(), + }).pipe( + Effect.map((r: any) => r.data), + Effect.withSpan("bot.typing.start", { attributes: { channelId, memberId } }), + ), /** * Stop showing typing indicator * @param id - Typing indicator ID */ stop: (id: TypingIndicatorId) => - rpc.typingIndicator - .delete({ - id, - }) - .pipe( - Effect.map((r) => r.data), - Effect.withSpan("bot.typing.stop", { attributes: { typingIndicatorId: id } }), - ), + rpc["typingIndicator.delete"]({ + id, + }).pipe( + Effect.map((r: any) => r.data), + Effect.withSpan("bot.typing.stop", { attributes: { typingIndicatorId: id } }), + ), }, /** @@ -1473,7 +1469,7 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl * ``` */ getToken: (orgId: OrganizationId, provider: IntegrationConnection.IntegrationProvider) => - httpApiClient["bot-commands"].getIntegrationToken({ path: { orgId, provider } }).pipe( + httpApiClient["bot-commands"].getIntegrationToken({ params: { orgId, provider } }).pipe( Effect.withSpan("bot.integration.getToken", { attributes: { orgId, provider }, }), @@ -1495,7 +1491,7 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl * } * ``` */ - getEnabled: (orgId: OrganizationId) => enabledIntegrationsCache.get(orgId), + getEnabled: (orgId: OrganizationId) => Cache.get(enabledIntegrationsCache, orgId), /** * Invalidate the enabled integrations cache for an organization. @@ -1503,7 +1499,7 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl * * @param orgId - The organization ID to invalidate cache for */ - invalidateCache: (orgId: OrganizationId) => enabledIntegrationsCache.invalidate(orgId), + invalidateCache: (orgId: OrganizationId) => Cache.invalidate(enabledIntegrationsCache, orgId), }, /** @@ -1559,9 +1555,7 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl */ withErrorHandler: (ctx: TypedCommandContext) => - ( - effect: Effect.Effect, - ): Effect.Effect => + (effect: Effect.Effect) => effect.pipe( Effect.mapError( (cause) => @@ -1732,7 +1726,7 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl */ withErrorHandler: (ctx: TypedCommandContext, session: AIStreamSession) => - (effect: Effect.Effect): Effect.Effect => + (effect: Effect.Effect) => effect.pipe( Effect.mapError( (cause) => @@ -1751,13 +1745,13 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl // Mark the session as failed - this updates the existing message yield* session.fail(userMessage).pipe( - Effect.catchAllCause((cause) => + Effect.catchCause((cause) => Effect.gen(function* () { yield* Effect.logError("Failed to mark AI stream as failed", { cause, }) yield* sendCommandErrorMessage(ctx, userMessage).pipe( - Effect.catchAllCause((messageCause) => + Effect.catchCause((messageCause) => Effect.logError( "Failed to send AI fallback error message", { @@ -1777,7 +1771,9 @@ export class HazelBotClient extends Effect.Service()("HazelBotCl }, } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} /** * Configuration for creating a Hazel bot @@ -1911,7 +1907,7 @@ export interface HazelBotConfig = EmptyComman * @example * ```typescript * import { createHazelBot, HazelBotClient, Command, CommandGroup } from "@hazel/bot-sdk" - * import { Schema } from "effect" + * import { ServiceMap, Schema } from "effect" * * // Define typesafe commands * const EchoCommand = Command.make("echo", { @@ -1954,9 +1950,9 @@ export const createHazelBot = = EmptyCommand process.env.RIVET_URL ?? DEFAULT_ACTORS_ENDPOINT - const AuthLayer = Layer.unwrapEffect( + const AuthLayer = Layer.unwrap( createAuthContextFromToken(config.botToken, backendUrl).pipe( - Effect.map((context) => BotAuth.Default(context)), + Effect.map((context) => BotAuth.layer(context)), ), ) @@ -1993,7 +1989,7 @@ export const createHazelBot = = EmptyCommand // Create logger layer with configurable level and format // Defaults: INFO level, format based on NODE_ENV // LOG_LEVEL env var overrides config (e.g. LOG_LEVEL=debug bun run dev) - const LoggerLayer = Layer.unwrapEffect( + const LoggerLayer = Layer.unwrap( Effect.gen(function* () { const nodeEnv = yield* Config.string("NODE_ENV").pipe(Config.withDefault("development")) const envLogLevel = yield* Config.string("LOG_LEVEL").pipe(Config.withDefault("")) @@ -2001,7 +1997,7 @@ export const createHazelBot = = EmptyCommand const resolvedLevel = envLogLevel ? logLevelFromString(envLogLevel) - : (config.logging?.level ?? LogLevel.Info) + : (config.logging?.level ?? "Info") const logConfig: BotLogConfig = { level: resolvedLevel, @@ -2022,7 +2018,7 @@ export const createHazelBot = = EmptyCommand // Compose all layers with proper dependency order const AllLayers = Layer.mergeAll( - HazelBotClient.Default.pipe( + HazelBotClient.layer.pipe( Layer.provide(RpcClientLayer), Layer.provide(RpcClientConfigLayer), Layer.provide(BotStateStoreLayer), @@ -2033,5 +2029,5 @@ export const createHazelBot = = EmptyCommand ).pipe(Layer.provide(AuthLayer), Layer.provide(LoggerLayer), Layer.provide(TracingLayer)) // Create runtime - return ManagedRuntime.make(AllLayers) + return ManagedRuntime.make(AllLayers as Layer.Layer) } diff --git a/libs/bot-sdk/src/log-config.ts b/libs/bot-sdk/src/log-config.ts index b78ace6ea..77a2fbb44 100644 --- a/libs/bot-sdk/src/log-config.ts +++ b/libs/bot-sdk/src/log-config.ts @@ -4,7 +4,7 @@ * Provides configurable log levels and formats for bot logging. */ -import { Layer, Logger, LogLevel } from "effect" +import { Effect, Layer, Logger, LogLevel, References } from "effect" /** * Log output format options @@ -69,7 +69,7 @@ export interface BotLogConfig { * Default log configuration */ export const defaultLogConfig: BotLogConfig = { - level: LogLevel.Info, + level: "Info", format: "pretty", } @@ -77,7 +77,7 @@ export const defaultLogConfig: BotLogConfig = { * Production log configuration */ export const productionLogConfig: BotLogConfig = { - level: LogLevel.Info, + level: "Info", format: "structured", } @@ -85,7 +85,7 @@ export const productionLogConfig: BotLogConfig = { * Debug log configuration (all DEBUG output) */ export const debugLogConfig: BotLogConfig = { - level: LogLevel.Debug, + level: "Debug", format: "pretty", } @@ -93,9 +93,9 @@ export const debugLogConfig: BotLogConfig = { * Create a logger layer from log configuration */ export const createLoggerLayer = (config: BotLogConfig): Layer.Layer => { - const formatLayer = config.format === "structured" ? Logger.structured : Logger.pretty + const logger = config.format === "structured" ? Logger.consoleStructured : Logger.consolePretty() - return Layer.mergeAll(formatLayer, Logger.minimumLogLevel(config.level)) + return Layer.mergeAll(Logger.layer([logger]), Layer.succeed(References.MinimumLogLevel, config.level)) } /** @@ -104,22 +104,23 @@ export const createLoggerLayer = (config: BotLogConfig): Layer.Layer => { export const logLevelFromString = (level: string): LogLevel.LogLevel => { switch (level.toLowerCase()) { case "all": - return LogLevel.All + return "All" case "trace": - return LogLevel.Trace + return "Trace" case "debug": - return LogLevel.Debug + return "Debug" case "info": - return LogLevel.Info + return "Info" case "warning": - return LogLevel.Warning + case "warn": + return "Warn" case "error": - return LogLevel.Error + return "Error" case "fatal": - return LogLevel.Fatal + return "Fatal" case "none": - return LogLevel.None + return "None" default: - return LogLevel.Info + return "Info" } } diff --git a/libs/bot-sdk/src/log-context.ts b/libs/bot-sdk/src/log-context.ts index bbe94844f..0f08225c8 100644 --- a/libs/bot-sdk/src/log-context.ts +++ b/libs/bot-sdk/src/log-context.ts @@ -6,7 +6,7 @@ */ import type { ChannelId, OrganizationId, UserId } from "@hazel/schema" -import { Effect, FiberRef, type Tracer } from "effect" +import { Effect, ServiceMap, type Tracer } from "effect" type GatewayEventType = string type GatewayEventOperation = string @@ -52,12 +52,12 @@ export interface LogContext { } /** - * FiberRef for fiber-local context propagation + * Reference for fiber-local context propagation * Allows context to flow through Effect operations without explicit passing */ -export const currentLogContext: FiberRef.FiberRef = FiberRef.unsafeMake( - null, -) +export const currentLogContext = ServiceMap.Reference("@hazel/bot-sdk/CurrentLogContext", { + defaultValue: () => null, +}) /** * Generate a unique correlation ID @@ -203,20 +203,22 @@ const contextToSpanAttributes = (ctx: LogContext): Record => { export const withLogContext = ( ctx: LogContext, spanName: string, - effect: Effect.Effect, + make: Effect.Effect, options?: { readonly parent?: Tracer.AnySpan }, ): Effect.Effect => - effect.pipe( + make.pipe( Effect.annotateLogs(contextToAnnotations(ctx)), Effect.withSpan(spanName, { attributes: contextToSpanAttributes(ctx), ...options }), - (eff) => Effect.locally(eff, currentLogContext, ctx), + Effect.provideService(currentLogContext, ctx), ) /** * Get the current log context from the FiberRef * Returns null if no context is set */ -export const getLogContext: Effect.Effect = FiberRef.get(currentLogContext) +export const getLogContext: Effect.Effect = Effect.gen(function* () { + return yield* currentLogContext +}) /** * Get the current correlation ID from context diff --git a/libs/bot-sdk/src/retry.ts b/libs/bot-sdk/src/retry.ts index 739d41686..78feba9ae 100644 --- a/libs/bot-sdk/src/retry.ts +++ b/libs/bot-sdk/src/retry.ts @@ -21,7 +21,8 @@ export const RetryStrategy = { */ transientErrors: Schedule.exponential(Duration.millis(100), 2).pipe( Schedule.jittered, - Schedule.whileInput((error) => { + Schedule.while((metadata) => { + const error = metadata.input const tag = typeof error === "object" && error !== null && "_tag" in error ? String((error as Record)["_tag"]) @@ -30,7 +31,7 @@ export const RetryStrategy = { ? retryPolicyForTag(tag) === "transient" || retryPolicyForTag(tag) === "connection" : isRetryableError(error) }), - Schedule.intersect(Schedule.recurs(5)), + Schedule.both(Schedule.recurs(5)), ), /** @@ -42,14 +43,15 @@ export const RetryStrategy = { */ connectionErrors: Schedule.exponential(Duration.seconds(1), 2).pipe( Schedule.jittered, - Schedule.whileInput((error) => { + Schedule.while((metadata) => { + const error = metadata.input const tag = typeof error === "object" && error !== null && "_tag" in error ? String((error as Record)["_tag"]) : "" return tag.length > 0 ? retryPolicyForTag(tag) === "connection" : isRetryableError(error) }), - Schedule.intersect(Schedule.recurs(10)), + Schedule.both(Schedule.recurs(10)), ), /** @@ -60,14 +62,15 @@ export const RetryStrategy = { */ quickRetry: Schedule.exponential(Duration.millis(50), 2).pipe( Schedule.jittered, - Schedule.whileInput((error) => { + Schedule.while((metadata) => { + const error = metadata.input const tag = typeof error === "object" && error !== null && "_tag" in error ? String((error as Record)["_tag"]) : "" return tag.length > 0 ? retryPolicyForTag(tag) === "quick" : isRetryableError(error) }), - Schedule.intersect(Schedule.recurs(3)), + Schedule.both(Schedule.recurs(3)), ), /** @@ -78,15 +81,16 @@ export const RetryStrategy = { schedule: Schedule.Schedule, options: { readonly threshold?: number - readonly resetAfter?: Duration.DurationInput + readonly resetAfter?: Duration.Input } = {}, ) => { const threshold = options.threshold ?? 5 - const resetAfter = options.resetAfter ?? Duration.seconds(30) return schedule.pipe( - Schedule.resetAfter(resetAfter), - Schedule.whileOutput((attempt) => (typeof attempt === "number" ? attempt < threshold : true)), + Schedule.while((metadata) => { + const attempt = metadata.output + return typeof attempt === "number" ? attempt < threshold : true + }), ) }, @@ -111,4 +115,4 @@ export const RetryStrategy = { export const composeRetryStrategies = ( first: Schedule.Schedule, second: Schedule.Schedule, -) => Schedule.intersect(first, second) +) => Schedule.both(first, second) diff --git a/libs/bot-sdk/src/rpc/auth-middleware.ts b/libs/bot-sdk/src/rpc/auth-middleware.ts index fa27b0c6b..0d0cfee31 100644 --- a/libs/bot-sdk/src/rpc/auth-middleware.ts +++ b/libs/bot-sdk/src/rpc/auth-middleware.ts @@ -5,8 +5,8 @@ * into all RPC requests for authentication with the backend. */ -import { Headers } from "@effect/platform" -import { RpcMiddleware } from "@effect/rpc" +import { Headers } from "effect/unstable/http" +import { RpcMiddleware } from "effect/unstable/rpc" import { AuthMiddleware } from "@hazel/domain/rpc" import { Effect } from "effect" @@ -21,15 +21,15 @@ import { Effect } from "effect" * ```typescript * const BotAuthMiddlewareLive = createBotAuthMiddleware(config.botToken) * - * const RpcClientLayer = BotRpcClient.Default.pipe( + * const RpcClientLayer = BotRpcClient.layer.pipe( * Layer.provide(RpcProtocolLive), * Layer.provide(BotAuthMiddlewareLive), * ) * ``` */ export const createBotAuthMiddleware = (botToken: string) => - RpcMiddleware.layerClient(AuthMiddleware, ({ request }) => - Effect.succeed({ + RpcMiddleware.layerClient(AuthMiddleware, ({ request, next }) => + next({ ...request, headers: Headers.set(request.headers ?? Headers.empty, "authorization", `Bearer ${botToken}`), }), diff --git a/libs/bot-sdk/src/rpc/client.ts b/libs/bot-sdk/src/rpc/client.ts index be265281a..076e6839f 100644 --- a/libs/bot-sdk/src/rpc/client.ts +++ b/libs/bot-sdk/src/rpc/client.ts @@ -5,10 +5,10 @@ * Uses FetchHttpClient for HTTP transport and NDJSON serialization. */ -import { FetchHttpClient } from "@effect/platform" -import { RpcClient, RpcSerialization } from "@effect/rpc" +import { FetchHttpClient } from "effect/unstable/http" +import { RpcClient, RpcSerialization } from "effect/unstable/rpc" import { ChannelRpcs, MessageReactionRpcs, MessageRpcs, TypingIndicatorRpcs } from "@hazel/domain/rpc" -import { Context, Effect, Layer } from "effect" +import { Effect, Layer, ServiceMap } from "effect" import { createBotAuthMiddleware } from "./auth-middleware.ts" /** @@ -36,24 +36,23 @@ export interface BotRpcClientConfig { /** * Internal context tag for the RPC client configuration */ -export class BotRpcClientConfigTag extends Context.Tag("@hazel/bot-sdk/BotRpcClientConfig")< - BotRpcClientConfigTag, - BotRpcClientConfig ->() {} +export class BotRpcClientConfigTag extends ServiceMap.Service()( + "@hazel/bot-sdk/BotRpcClientConfig", +) {} /** * Context tag for the RPC client instance * Type is inferred from the actual RpcClient.make result */ -export class BotRpcClient extends Context.Tag("@hazel/bot-sdk/BotRpcClient")< +export class BotRpcClient extends ServiceMap.Service< BotRpcClient, - Effect.Effect.Success> ->() {} + Effect.Success> +>()("@hazel/bot-sdk/BotRpcClient") {} /** * Create a scoped layer that provides the RPC client */ -export const BotRpcClientLive = Layer.scoped( +export const BotRpcClientLive = Layer.effect( BotRpcClient, Effect.gen(function* () { const config = yield* BotRpcClientConfigTag diff --git a/libs/bot-sdk/src/run-bot.ts b/libs/bot-sdk/src/run-bot.ts index ba41375df..425769769 100644 --- a/libs/bot-sdk/src/run-bot.ts +++ b/libs/bot-sdk/src/run-bot.ts @@ -9,7 +9,7 @@ */ import { BunRuntime } from "@effect/platform-bun" -import { Effect, Layer, Redacted } from "effect" +import { Effect, Layer, Redacted, ServiceMap } from "effect" import type { CommandGroup, EmptyCommands } from "./command.ts" import { createHazelBot, HazelBotClient, type HazelBotConfig } from "./hazel-bot-sdk.ts" import { BotEnvConfig } from "./bot-config.ts" @@ -47,7 +47,9 @@ export interface RunBotConfig = EmptyCommands * Receives the HazelBotClient and should register handlers. * Does NOT need to call bot.start - that's handled automatically. */ - readonly setup: (bot: HazelBotClient) => Effect.Effect + readonly setup: ( + bot: ServiceMap.Service.Shape, + ) => Effect.Effect /** * Optional override for bot configuration. diff --git a/libs/bot-sdk/src/services/health-server.ts b/libs/bot-sdk/src/services/health-server.ts index 3782c50ba..aa466f858 100644 --- a/libs/bot-sdk/src/services/health-server.ts +++ b/libs/bot-sdk/src/services/health-server.ts @@ -7,16 +7,16 @@ * Enabled by default on port 9090. Set `healthPort: false` in config to disable. */ -import { Context, Effect, Layer, Runtime } from "effect" +import { Effect, Layer, ServiceMap } from "effect" export interface BotHealthServerConfig { readonly port: number } -export class BotHealthServerConfigTag extends Context.Tag("@hazel/bot-sdk/BotHealthServerConfig")< +export class BotHealthServerConfigTag extends ServiceMap.Service< BotHealthServerConfigTag, BotHealthServerConfig ->() {} +>()("@hazel/bot-sdk/BotHealthServerConfig") {} interface HealthResponse { readonly status: "healthy" @@ -24,12 +24,11 @@ interface HealthResponse { readonly uptime_ms: number } -export class BotHealthServer extends Effect.Service()("BotHealthServer", { - accessors: true, - scoped: Effect.gen(function* () { +export class BotHealthServer extends ServiceMap.Service()("BotHealthServer", { + make: Effect.gen(function* () { const config = yield* BotHealthServerConfigTag const startTime = Date.now() - const runtime = yield* Effect.runtime() + const services = yield* Effect.services() const collectHealth = Effect.sync( (): HealthResponse => ({ @@ -46,8 +45,8 @@ export class BotHealthServer extends Effect.Service()("BotHealt fetch(req) { const url = new URL(req.url) if (req.method === "GET" && url.pathname === "/health") { - return Runtime.runPromise(runtime)(collectHealth).then( - (health) => + return Effect.runPromiseWith(services)(collectHealth).then( + (health: HealthResponse) => new Response(JSON.stringify(health), { status: 200, headers: { "Content-Type": "application/json" }, @@ -74,7 +73,9 @@ export class BotHealthServer extends Effect.Service()("BotHealt return { port: server.port } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} export const BotHealthServerLive = (port: number) => - Layer.provide(BotHealthServer.Default, Layer.succeed(BotHealthServerConfigTag, { port })) + Layer.provide(BotHealthServer.layer, Layer.succeed(BotHealthServerConfigTag, { port })) diff --git a/libs/bot-sdk/src/streaming/actors-client.ts b/libs/bot-sdk/src/streaming/actors-client.ts index 63c5d1220..8b3095460 100644 --- a/libs/bot-sdk/src/streaming/actors-client.ts +++ b/libs/bot-sdk/src/streaming/actors-client.ts @@ -8,7 +8,7 @@ */ import { createActorsClient } from "@hazel/actors/client" -import { Effect } from "effect" +import { ServiceMap, Effect, Layer } from "effect" // biome-ignore lint/suspicious/noExplicitAny: Opaque type to avoid non-portable DTS references to @hazel/actors internals export type MessageActor = any @@ -60,7 +60,7 @@ export interface ActorsClientConfig { * @example * ```typescript * // Create layer with config - * const layer = ActorsClient.Default({ + * const layer = ActorsClient.layer({ * botToken: "hzl_bot_xxx", * endpoint: "http://localhost:6420" * }) @@ -72,9 +72,8 @@ export interface ActorsClientConfig { * }).pipe(Effect.provide(layer)) * ``` */ -export class ActorsClient extends Effect.Service()("@hazel/bot-sdk/ActorsClient", { - accessors: true, - effect: Effect.fn("ActorsClient.create")(function* (config: ActorsClientConfig) { +export class ActorsClient extends ServiceMap.Service()("@hazel/bot-sdk/ActorsClient", { + make: Effect.fn("ActorsClient.create")(function* (config: ActorsClientConfig) { const endpoint = config.endpoint ?? "https://rivet.hazel.sh" const client = createActorsClient(endpoint) @@ -94,4 +93,6 @@ export class ActorsClient extends Effect.Service()("@hazel/bot-sdk botToken: config.botToken, } as ActorsClientService }), -}) {} +}) { + static readonly layer = (config: ActorsClientConfig) => Layer.effect(this, this.make(config)) +} diff --git a/libs/bot-sdk/src/streaming/errors.ts b/libs/bot-sdk/src/streaming/errors.ts index 2f14b6ef4..d8373e94b 100644 --- a/libs/bot-sdk/src/streaming/errors.ts +++ b/libs/bot-sdk/src/streaming/errors.ts @@ -10,16 +10,19 @@ import { Schema } from "effect" /** * Error thrown when connecting to a message actor fails */ -export class ActorConnectionError extends Schema.TaggedError()("ActorConnectionError", { - messageId: Schema.String, - message: Schema.String, - cause: Schema.Unknown, -}) {} +export class ActorConnectionError extends Schema.TaggedErrorClass()( + "ActorConnectionError", + { + messageId: Schema.String, + message: Schema.String, + cause: Schema.Unknown, + }, +) {} /** * Error thrown when creating a message with live state fails */ -export class MessageCreateError extends Schema.TaggedError()("MessageCreateError", { +export class MessageCreateError extends Schema.TaggedErrorClass()("MessageCreateError", { channelId: Schema.String, message: Schema.String, cause: Schema.Unknown, @@ -28,16 +31,19 @@ export class MessageCreateError extends Schema.TaggedError() /** * Error thrown when an actor operation (appendText, complete, etc.) fails */ -export class ActorOperationError extends Schema.TaggedError()("ActorOperationError", { - operation: Schema.String, - message: Schema.String, - cause: Schema.Unknown, -}) {} +export class ActorOperationError extends Schema.TaggedErrorClass()( + "ActorOperationError", + { + operation: Schema.String, + message: Schema.String, + cause: Schema.Unknown, + }, +) {} /** * Error thrown when processing an async stream of chunks fails */ -export class StreamProcessingError extends Schema.TaggedError()( +export class StreamProcessingError extends Schema.TaggedErrorClass()( "StreamProcessingError", { message: Schema.String, @@ -48,7 +54,7 @@ export class StreamProcessingError extends Schema.TaggedError()( +export class BotNotConfiguredError extends Schema.TaggedErrorClass()( "BotNotConfiguredError", { message: Schema.String, @@ -60,11 +66,14 @@ export class BotNotConfiguredError extends Schema.TaggedError()("MessagePersistError", { - messageId: Schema.String, - message: Schema.String, - cause: Schema.Unknown, -}) {} +export class MessagePersistError extends Schema.TaggedErrorClass()( + "MessagePersistError", + { + messageId: Schema.String, + message: Schema.String, + cause: Schema.Unknown, + }, +) {} /** * Union type for all streaming errors. diff --git a/libs/bot-sdk/src/streaming/streaming-service.ts b/libs/bot-sdk/src/streaming/streaming-service.ts index 56428d4c6..8a686282b 100644 --- a/libs/bot-sdk/src/streaming/streaming-service.ts +++ b/libs/bot-sdk/src/streaming/streaming-service.ts @@ -51,7 +51,7 @@ export type MessageCreateFn = ( }[] | null }, -) => Effect.Effect<{ id: string }, unknown> +) => Effect.Effect<{ id: string }, unknown, unknown> /** * Message update function type - matches the message update API @@ -73,7 +73,7 @@ export type MessageUpdateFn = ( } }> | null }, -) => Effect.Effect<{ id: string }, unknown> +) => Effect.Effect<{ id: string }, unknown, unknown> /** * Wrap an actor method call with Effect, error handling, and tracing. @@ -161,7 +161,7 @@ const createSessionFromActor = ( embeds: [{ liveState: { enabled: true, cached } }], }) .pipe( - Effect.catchAll((error) => + Effect.catch((error) => Effect.logWarning("Failed to persist streaming message to database", { messageId, error: String(error), @@ -199,7 +199,7 @@ const createSessionFromActor = ( embeds: [{ liveState: { enabled: true, cached } }], }) .pipe( - Effect.catchAll((persistError) => + Effect.catch((persistError) => Effect.logWarning("Failed to persist failed streaming state to database", { messageId, error: String(persistError), diff --git a/libs/bot-sdk/src/streaming/types.ts b/libs/bot-sdk/src/streaming/types.ts index ee51d4fa2..24ac5ea37 100644 --- a/libs/bot-sdk/src/streaming/types.ts +++ b/libs/bot-sdk/src/streaming/types.ts @@ -65,10 +65,10 @@ export interface StreamSession { ): Effect.Effect /** Mark the stream as completed */ - complete(finalData?: Record): Effect.Effect + complete(finalData?: Record): Effect.Effect /** Mark the stream as failed */ - fail(error: string): Effect.Effect + fail(error: string): Effect.Effect } /** @@ -131,7 +131,7 @@ export const ToolCallChunk = Schema.Struct({ type: Schema.Literal("tool_call"), id: Schema.String, name: Schema.String, - input: Schema.Record({ key: Schema.String, value: Schema.Unknown }), + input: Schema.Record(Schema.String, Schema.Unknown), }) export type ToolCallChunk = Schema.Schema.Type @@ -168,7 +168,7 @@ export type ToolResultChunk = Schema.Schema.Type * } * ``` */ -export const AIContentChunk = Schema.Union(TextChunk, ThinkingChunk, ToolCallChunk, ToolResultChunk) +export const AIContentChunk = Schema.Union([TextChunk, ThinkingChunk, ToolCallChunk, ToolResultChunk]) export type AIContentChunk = Schema.Schema.Type /** diff --git a/libs/effect-electric-db-collection/package.json b/libs/effect-electric-db-collection/package.json index c0ad024e8..2983a439b 100644 --- a/libs/effect-electric-db-collection/package.json +++ b/libs/effect-electric-db-collection/package.json @@ -12,7 +12,6 @@ "test": "npx vitest --run" }, "dependencies": { - "@effect-atom/atom": "catalog:effect", "@electric-sql/client": "1.5.12", "@standard-schema/spec": "^1.1.0", "@tanstack/db": "0.5.31", @@ -22,7 +21,6 @@ "effect": "catalog:effect" }, "devDependencies": { - "@effect/language-service": "catalog:effect", "@vitest/coverage-istanbul": "^4.0.17", "typescript": "^5.9.3" } diff --git a/libs/effect-electric-db-collection/src/collection.ts b/libs/effect-electric-db-collection/src/collection.ts index b906ff871..560487d1e 100644 --- a/libs/effect-electric-db-collection/src/collection.ts +++ b/libs/effect-electric-db-collection/src/collection.ts @@ -17,7 +17,7 @@ export type { CollectionStatus } from "@tanstack/db" * Error returned when the collection's last error is retrieved. * Wraps the underlying TanStack DB error with collection context. */ -export class CollectionSyncEffectError extends Schema.TaggedError()( +export class CollectionSyncEffectError extends Schema.TaggedErrorClass()( "CollectionSyncEffectError", { message: Schema.String, @@ -421,7 +421,7 @@ export type EffectCollection< * id: "messages", * runtime: runtime, * shapeOptions: { url: electricUrl, params: { table: "messages" } }, - * schema: Message.Model.json, // Direct Effect Schema! + * schema: Message.Schema, // Direct Effect Schema! * getKey: (item) => item.id, * onInsert: ({ transaction }) => Effect.gen(function* () { ... }), * }) @@ -429,17 +429,17 @@ export type EffectCollection< * // messageCollection.utils.awaitTxIdEffect is properly typed! * ``` */ -export function createEffectCollection, I, TRuntime>( +export function createEffectCollection, TRuntime>( config: Omit< EffectElectricCollectionConfig, TRuntime>, "schema" > & { - schema: Schema.Schema + schema: Schema.Schema runtime: ManagedRuntime.ManagedRuntime }, ): EffectCollection { // Convert Effect Schema to StandardSchemaV1 internally - const standardSchema = Schema.standardSchemaV1(config.schema) + const standardSchema = Schema.toStandardSchemaV1(config.schema as any) const options = effectElectricCollectionOptions({ ...config, diff --git a/libs/effect-electric-db-collection/src/errors.ts b/libs/effect-electric-db-collection/src/errors.ts index f9ea9c760..9f6bf5324 100644 --- a/libs/effect-electric-db-collection/src/errors.ts +++ b/libs/effect-electric-db-collection/src/errors.ts @@ -3,7 +3,7 @@ import { Schema } from "effect" /** * Base error for Electric Collection operations */ -export class ElectricCollectionError extends Schema.TaggedError()( +export class ElectricCollectionError extends Schema.TaggedErrorClass()( "ElectricCollectionError", { message: Schema.String, @@ -14,7 +14,7 @@ export class ElectricCollectionError extends Schema.TaggedError()("InsertError", { +export class InsertError extends Schema.TaggedErrorClass()("InsertError", { message: Schema.String, data: Schema.optional(Schema.Unknown), cause: Schema.optional(Schema.Unknown), @@ -23,7 +23,7 @@ export class InsertError extends Schema.TaggedError()("InsertError" /** * Error thrown when an update operation fails */ -export class UpdateError extends Schema.TaggedError()("UpdateError", { +export class UpdateError extends Schema.TaggedErrorClass()("UpdateError", { message: Schema.String, key: Schema.optional(Schema.Unknown), cause: Schema.optional(Schema.Unknown), @@ -32,7 +32,7 @@ export class UpdateError extends Schema.TaggedError()("UpdateError" /** * Error thrown when a delete operation fails */ -export class DeleteError extends Schema.TaggedError()("DeleteError", { +export class DeleteError extends Schema.TaggedErrorClass()("DeleteError", { message: Schema.String, key: Schema.optional(Schema.Unknown), cause: Schema.optional(Schema.Unknown), @@ -41,7 +41,7 @@ export class DeleteError extends Schema.TaggedError()("DeleteError" /** * Error thrown when waiting for a transaction ID times out */ -export class TxIdTimeoutError extends Schema.TaggedError()("TxIdTimeoutError", { +export class TxIdTimeoutError extends Schema.TaggedErrorClass()("TxIdTimeoutError", { message: Schema.String, txid: Schema.Number, timeout: Schema.Number, @@ -50,15 +50,15 @@ export class TxIdTimeoutError extends Schema.TaggedError()("Tx /** * Error thrown when a required transaction ID is missing from handler result */ -export class MissingTxIdError extends Schema.TaggedError()("MissingTxIdError", { +export class MissingTxIdError extends Schema.TaggedErrorClass()("MissingTxIdError", { message: Schema.String, - operation: Schema.Literal("insert", "update", "delete"), + operation: Schema.Literals(["insert", "update", "delete"]), }) {} /** * Error thrown when an invalid transaction ID type is provided */ -export class InvalidTxIdError extends Schema.TaggedError()("InvalidTxIdError", { +export class InvalidTxIdError extends Schema.TaggedErrorClass()("InvalidTxIdError", { message: Schema.String, receivedType: Schema.String, }) {} @@ -66,7 +66,7 @@ export class InvalidTxIdError extends Schema.TaggedError()("In /** * Error thrown when sync configuration is invalid */ -export class SyncConfigError extends Schema.TaggedError()("SyncConfigError", { +export class SyncConfigError extends Schema.TaggedErrorClass()("SyncConfigError", { message: Schema.String, cause: Schema.optional(Schema.Unknown), }) {} @@ -74,7 +74,7 @@ export class SyncConfigError extends Schema.TaggedError()("Sync /** * Error thrown when an optimistic action fails */ -export class OptimisticActionError extends Schema.TaggedError()( +export class OptimisticActionError extends Schema.TaggedErrorClass()( "OptimisticActionError", { message: Schema.String, @@ -85,7 +85,7 @@ export class OptimisticActionError extends Schema.TaggedError()("SyncError", { +export class SyncError extends Schema.TaggedErrorClass()("SyncError", { message: Schema.String, txid: Schema.optional(Schema.Number), collectionName: Schema.optional(Schema.String), diff --git a/libs/effect-electric-db-collection/src/handlers.ts b/libs/effect-electric-db-collection/src/handlers.ts index 8a762d2f8..cf0e5aa2d 100644 --- a/libs/effect-electric-db-collection/src/handlers.ts +++ b/libs/effect-electric-db-collection/src/handlers.ts @@ -6,7 +6,7 @@ import type { UtilsRecord, } from "@tanstack/db" import type { Txid } from "@tanstack/electric-db-collection" -import { Effect, Exit, type ManagedRuntime } from "effect" +import { Cause, Effect, Exit, type ManagedRuntime } from "effect" import { DeleteError, InsertError, MissingTxIdError, UpdateError } from "./errors" import type { EffectDeleteHandler, EffectInsertHandler, EffectUpdateHandler } from "./types" @@ -32,7 +32,7 @@ export function convertInsertHandler< return async (params: InsertMutationFnParams) => { const effect = handler(params).pipe( - Effect.catchAll((error: E | unknown) => + Effect.catch((error: E | unknown) => Effect.fail( new InsertError({ message: `Insert operation failed`, @@ -52,8 +52,9 @@ export function convertInsertHandler< // Handle the Exit type if (Exit.isFailure(exit)) { const cause = exit.cause - if (cause._tag === "Fail") { - throw cause.error + const failReason = cause.reasons.find(Cause.isFailReason) + if (failReason) { + throw failReason.error } throw new InsertError({ message: `Insert operation failed unexpectedly`, @@ -97,7 +98,7 @@ export function convertUpdateHandler< return async (params: UpdateMutationFnParams) => { const effect = handler(params).pipe( - Effect.catchAll((error: E | unknown) => + Effect.catch((error: E | unknown) => Effect.fail( new UpdateError({ message: `Update operation failed`, @@ -117,8 +118,9 @@ export function convertUpdateHandler< // Handle the Exit type if (Exit.isFailure(exit)) { const cause = exit.cause - if (cause._tag === "Fail") { - throw cause.error + const failReason = cause.reasons.find(Cause.isFailReason) + if (failReason) { + throw failReason.error } throw new UpdateError({ message: `Update operation failed unexpectedly`, @@ -162,7 +164,7 @@ export function convertDeleteHandler< return async (params: DeleteMutationFnParams) => { const effect = handler(params).pipe( - Effect.catchAll((error: E | unknown) => + Effect.catch((error: E | unknown) => Effect.fail( new DeleteError({ message: `Delete operation failed`, @@ -182,8 +184,9 @@ export function convertDeleteHandler< // Handle the Exit type if (Exit.isFailure(exit)) { const cause = exit.cause - if (cause._tag === "Fail") { - throw cause.error + const failReason = cause.reasons.find(Cause.isFailReason) + if (failReason) { + throw failReason.error } throw new DeleteError({ message: `Delete operation failed unexpectedly`, diff --git a/libs/effect-electric-db-collection/src/optimistic-action.ts b/libs/effect-electric-db-collection/src/optimistic-action.ts index 7728d8d3e..cb340f12d 100644 --- a/libs/effect-electric-db-collection/src/optimistic-action.ts +++ b/libs/effect-electric-db-collection/src/optimistic-action.ts @@ -1,4 +1,4 @@ -import { Atom, type Result } from "@effect-atom/atom" +import { Atom, type AsyncResult } from "effect/unstable/reactivity" import type { Collection, Transaction } from "@tanstack/db" import { createTransaction } from "@tanstack/db" import type { Txid } from "@tanstack/electric-db-collection" @@ -213,7 +213,7 @@ export function optimisticAction< >( config: OptimisticActionConfig, ): Atom.Writable< - Result.Result< + AsyncResult.AsyncResult< OptimisticActionResult, TError | SyncError | OptimisticActionError >, @@ -241,8 +241,9 @@ export function optimisticAction< if (Exit.isFailure(exit)) { const cause = exit.cause - if (cause._tag === "Fail") { - throw cause.error // Typed TError + const failReason = cause.reasons.find(Cause.isFailReason) + if (failReason) { + throw failReason.error // Typed TError } throw new OptimisticActionError({ message: "Mutation failed unexpectedly", @@ -288,8 +289,9 @@ export function optimisticAction< `cause:`, Cause.pretty(cause), ) - if (cause._tag === "Fail") { - throw cause.error // SyncError + const syncFailReason = cause.reasons.find(Cause.isFailReason) + if (syncFailReason) { + throw syncFailReason.error // SyncError } throw new SyncError({ message: "Sync failed unexpectedly", diff --git a/libs/effect-electric-db-collection/src/service.ts b/libs/effect-electric-db-collection/src/service.ts index 91b8fee6e..c4fcb0973 100644 --- a/libs/effect-electric-db-collection/src/service.ts +++ b/libs/effect-electric-db-collection/src/service.ts @@ -3,7 +3,7 @@ import type { StandardSchemaV1 } from "@standard-schema/spec" import type { Collection } from "@tanstack/db" import { createCollection } from "@tanstack/db" import type { Txid } from "@tanstack/electric-db-collection" -import { Context, Effect, Layer } from "effect" +import { ServiceMap, Effect, Layer } from "effect" import { effectElectricCollectionOptions } from "./collection" import { DeleteError, InsertError, type InvalidTxIdError, type TxIdTimeoutError, UpdateError } from "./errors" import type { EffectElectricCollectionConfig } from "./types" @@ -81,7 +81,7 @@ export interface ElectricCollectionService< /** * Create a tag for an Electric collection service. * - * This factory uses `Context.Tag` instead of `Effect.Service` because collection + * This factory uses `ServiceMap.Service` instead of `Effect.Service` because collection * instances are created at runtime with dynamic configuration (shape URLs, schemas, * handlers) that vary per application context. The tag pattern allows: * @@ -118,14 +118,14 @@ export interface ElectricCollectionService< */ export const ElectricCollection = , TKey extends string | number = string | number>( id: string, -): Context.Tag, ElectricCollectionService> => - Context.GenericTag>(`ElectricCollection<${id}>`) +): ServiceMap.Service, ElectricCollectionService> => + ServiceMap.Service>(`ElectricCollection<${id}>`) /** * Create a Layer for an Electric collection service */ export function makeElectricCollectionLayer( - tag: Context.Tag< + tag: ServiceMap.Service< ElectricCollectionService, string | number>, ElectricCollectionService, string | number> >, @@ -135,7 +135,7 @@ export function makeElectricCollectionLayer( ): Layer.Layer, string | number>, never, never> export function makeElectricCollectionLayer>( - tag: Context.Tag< + tag: ServiceMap.Service< ElectricCollectionService, ElectricCollectionService >, @@ -145,7 +145,7 @@ export function makeElectricCollectionLayer>( ): Layer.Layer, never, never> export function makeElectricCollectionLayer( - tag: Context.Tag, + tag: ServiceMap.Service, config: EffectElectricCollectionConfig, ): Layer.Layer { return Layer.succeed( @@ -158,7 +158,7 @@ export function makeElectricCollectionLayer( collection, insert: (data) => - Effect.async((resume) => { + Effect.callback((resume) => { const tx = collection.insert(data) tx.isPersisted.promise .then(() => resume(Effect.void)) @@ -176,7 +176,7 @@ export function makeElectricCollectionLayer( }), update: (keys, updateFn) => - Effect.async((resume) => { + Effect.callback((resume) => { const tx = collection.update(keys as any, updateFn as any) tx.isPersisted.promise .then(() => resume(Effect.void)) @@ -194,7 +194,7 @@ export function makeElectricCollectionLayer( }), delete: (keys) => - Effect.async((resume) => { + Effect.callback((resume) => { const tx = collection.delete(keys as any) tx.isPersisted.promise .then(() => resume(Effect.void)) diff --git a/libs/effect-electric-db-collection/src/tanstack-errors.ts b/libs/effect-electric-db-collection/src/tanstack-errors.ts index 3d839ee8c..009c3f515 100644 --- a/libs/effect-electric-db-collection/src/tanstack-errors.ts +++ b/libs/effect-electric-db-collection/src/tanstack-errors.ts @@ -5,7 +5,7 @@ import { Schema } from "effect" */ export const ValidationIssue = Schema.Struct({ message: Schema.String, - path: Schema.optional(Schema.Array(Schema.Union(Schema.String, Schema.Number))), + path: Schema.optional(Schema.Array(Schema.Union([Schema.String, Schema.Number]))), }) export type ValidationIssue = typeof ValidationIssue.Type @@ -21,11 +21,11 @@ export type ValidationIssue = typeof ValidationIssue.Type * * @permanent This error will not resolve on retry - the data must be modified */ -export class DuplicateKeyEffectError extends Schema.TaggedError()( +export class DuplicateKeyEffectError extends Schema.TaggedErrorClass()( "DuplicateKeyEffectError", { message: Schema.String, - key: Schema.Union(Schema.String, Schema.Number), + key: Schema.Union([Schema.String, Schema.Number]), collectionId: Schema.optional(Schema.String), }, ) {} @@ -36,12 +36,12 @@ export class DuplicateKeyEffectError extends Schema.TaggedError()( +export class KeyUpdateNotAllowedEffectError extends Schema.TaggedErrorClass()( "KeyUpdateNotAllowedEffectError", { message: Schema.String, - originalKey: Schema.Union(Schema.String, Schema.Number), - newKey: Schema.Union(Schema.String, Schema.Number), + originalKey: Schema.Union([Schema.String, Schema.Number]), + newKey: Schema.Union([Schema.String, Schema.Number]), }, ) {} @@ -51,7 +51,7 @@ export class KeyUpdateNotAllowedEffectError extends Schema.TaggedError()( +export class UndefinedKeyEffectError extends Schema.TaggedErrorClass()( "UndefinedKeyEffectError", { message: Schema.String, @@ -65,11 +65,11 @@ export class UndefinedKeyEffectError extends Schema.TaggedError()( +export class SchemaValidationEffectError extends Schema.TaggedErrorClass()( "SchemaValidationEffectError", { message: Schema.String, - operation: Schema.Literal("insert", "update"), + operation: Schema.Literals(["insert", "update"]), issues: Schema.Array(ValidationIssue), }, ) {} @@ -86,12 +86,12 @@ export class SchemaValidationEffectError extends Schema.TaggedError()( +export class KeyNotFoundEffectError extends Schema.TaggedErrorClass()( "KeyNotFoundEffectError", { message: Schema.String, - key: Schema.Union(Schema.String, Schema.Number), - operation: Schema.Literal("update", "delete"), + key: Schema.Union([Schema.String, Schema.Number]), + operation: Schema.Literals(["update", "delete"]), }, ) {} @@ -101,7 +101,7 @@ export class KeyNotFoundEffectError extends Schema.TaggedError()( +export class CollectionInErrorEffectError extends Schema.TaggedErrorClass()( "CollectionInErrorEffectError", { message: Schema.String, @@ -116,11 +116,11 @@ export class CollectionInErrorEffectError extends Schema.TaggedError()( +export class TransactionStateEffectError extends Schema.TaggedErrorClass()( "TransactionStateEffectError", { message: Schema.String, - state: Schema.Literal("not-pending-mutate", "already-completed-rollback", "not-pending-commit"), + state: Schema.Literals(["not-pending-mutate", "already-completed-rollback", "not-pending-commit"]), }, ) {} @@ -317,7 +317,7 @@ export function wrapTanStackError( /** * Union schema of all TanStack DB Effect errors for type-safe matching. */ -export const TanStackEffectErrorSchema = Schema.Union( +export const TanStackEffectErrorSchema = Schema.Union([ DuplicateKeyEffectError, KeyUpdateNotAllowedEffectError, UndefinedKeyEffectError, @@ -325,6 +325,6 @@ export const TanStackEffectErrorSchema = Schema.Union( KeyNotFoundEffectError, CollectionInErrorEffectError, TransactionStateEffectError, -) +]) export type TanStackEffectError = typeof TanStackEffectErrorSchema.Type diff --git a/libs/tanstack-db-atom/package.json b/libs/tanstack-db-atom/package.json index efc4f0e64..702329ecb 100644 --- a/libs/tanstack-db-atom/package.json +++ b/libs/tanstack-db-atom/package.json @@ -8,12 +8,11 @@ ".": "./src/index.ts" }, "dependencies": { - "@effect-atom/atom-react": "catalog:effect", + "@effect/atom-react": "catalog:effect", "@tanstack/db": "0.5.31", "effect": "catalog:effect" }, "devDependencies": { - "@effect/language-service": "catalog:effect", "typescript": "^5.9.3" } } diff --git a/libs/tanstack-db-atom/src/AtomTanStackDB.result.test.ts b/libs/tanstack-db-atom/src/AtomTanStackDB.result.test.ts index 4ee0bd52f..1dd05ab33 100644 --- a/libs/tanstack-db-atom/src/AtomTanStackDB.result.test.ts +++ b/libs/tanstack-db-atom/src/AtomTanStackDB.result.test.ts @@ -9,7 +9,7 @@ * @since 1.0.0 */ -import { Atom, Registry, Result } from "@effect-atom/atom-react" +import { Atom, AsyncResult as Result, AtomRegistry as Registry } from "effect/unstable/reactivity" import { type Collection, createCollection, eq, type NonSingleResult } from "@tanstack/db" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { makeCollectionAtom, makeQuery, makeQueryConditional, makeQueryUnsafe } from "./AtomTanStackDB" diff --git a/libs/tanstack-db-atom/src/AtomTanStackDB.subscription.test.ts b/libs/tanstack-db-atom/src/AtomTanStackDB.subscription.test.ts index 9773a80f2..9b34e5aa7 100644 --- a/libs/tanstack-db-atom/src/AtomTanStackDB.subscription.test.ts +++ b/libs/tanstack-db-atom/src/AtomTanStackDB.subscription.test.ts @@ -9,7 +9,7 @@ * @since 1.0.0 */ -import { Atom, Registry, Result } from "@effect-atom/atom-react" +import { Atom, AsyncResult as Result, AtomRegistry as Registry } from "effect/unstable/reactivity" import { type Collection, createCollection, eq, type NonSingleResult } from "@tanstack/db" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { makeCollectionAtom, makeQuery } from "./AtomTanStackDB" diff --git a/libs/tanstack-db-atom/src/AtomTanStackDB.test.ts b/libs/tanstack-db-atom/src/AtomTanStackDB.test.ts index 7e784fa2c..1552c9e30 100644 --- a/libs/tanstack-db-atom/src/AtomTanStackDB.test.ts +++ b/libs/tanstack-db-atom/src/AtomTanStackDB.test.ts @@ -16,7 +16,7 @@ * @since 1.0.0 */ -import { Registry, Result } from "@effect-atom/atom-react" +import { AsyncResult as Result, AtomRegistry as Registry } from "effect/unstable/reactivity" import { type Collection, createCollection, eq, type NonSingleResult, type SingleResult } from "@tanstack/db" import { describe, expect, it } from "vitest" import { @@ -205,7 +205,7 @@ describe("makeCollectionAtom", () => { const todosAtom = makeCollectionAtom(collection) // Track updates - const updates: Array, Error>> = [] + const updates: Array, Error>> = [] const unsubscribe = registry.subscribe(todosAtom, (value) => { updates.push(value) }) diff --git a/libs/tanstack-db-atom/src/AtomTanStackDB.timing.test.ts b/libs/tanstack-db-atom/src/AtomTanStackDB.timing.test.ts index 4ec4032cb..e929f408f 100644 --- a/libs/tanstack-db-atom/src/AtomTanStackDB.timing.test.ts +++ b/libs/tanstack-db-atom/src/AtomTanStackDB.timing.test.ts @@ -9,7 +9,7 @@ * @since 1.0.0 */ -import { Atom, Registry, Result } from "@effect-atom/atom-react" +import { Atom, AsyncResult as Result, AtomRegistry as Registry } from "effect/unstable/reactivity" import { type Collection, createCollection, eq, type NonSingleResult } from "@tanstack/db" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { makeCollectionAtom, makeQuery } from "./AtomTanStackDB" @@ -230,7 +230,7 @@ describe("Timing and Async Behavior", () => { const todosAtom = makeCollectionAtom(collection) - const updates: Array, Error>> = [] + const updates: Array, Error>> = [] registry.subscribe(todosAtom, (value) => { updates.push(value) }) diff --git a/libs/tanstack-db-atom/src/AtomTanStackDB.ts b/libs/tanstack-db-atom/src/AtomTanStackDB.ts index 7e734ec2a..db502d166 100644 --- a/libs/tanstack-db-atom/src/AtomTanStackDB.ts +++ b/libs/tanstack-db-atom/src/AtomTanStackDB.ts @@ -4,7 +4,7 @@ * @since 1.0.0 */ -import { Atom, Result } from "@effect-atom/atom-react" +import { Atom, AsyncResult } from "effect/unstable/reactivity" import { type Collection, type Context, @@ -25,7 +25,7 @@ import type { CollectionStatus, ConditionalQueryFn, QueryFn, QueryOptions } from */ export const makeCollectionAtom = ( collection: Collection & NonSingleResult, -): Atom.Atom, Error>> => { +): Atom.Atom, Error>> => { return Atom.readable((get) => { // Start sync if not already started collection.startSyncImmediate() @@ -36,22 +36,22 @@ export const makeCollectionAtom = value) - get.setSelf(Result.success(newData)) + get.setSelf(AsyncResult.success(newData)) }) // Cleanup on unmount @@ -63,21 +63,21 @@ export const makeCollectionAtom = value) - return Result.success(initialData) + return AsyncResult.success(initialData) }) } @@ -87,7 +87,7 @@ export const makeCollectionAtom = ( collection: Collection & SingleResult, -): Atom.Atom> => { +): Atom.Atom> => { return Atom.readable((get) => { // Start sync if not already started collection.startSyncImmediate() @@ -98,23 +98,23 @@ export const makeSingleCollectionAtom = 0 ? entries[0]![1] : undefined - get.setSelf(Result.success(newData)) + get.setSelf(AsyncResult.success(newData)) }) // Cleanup on unmount @@ -126,22 +126,22 @@ export const makeSingleCollectionAtom = 0 ? entries[0]![1] : undefined - return Result.success(initialData) + return AsyncResult.success(initialData) }) } @@ -152,7 +152,7 @@ export const makeSingleCollectionAtom = ( queryFn: QueryFn, options?: QueryOptions, -): Atom.Atom, Error>> => { +): Atom.Atom, Error>> => { return Atom.readable((get) => { // Create live query collection const collection = createLiveQueryCollection({ @@ -167,17 +167,17 @@ export const makeQuery = ( const status: CollectionStatus = collection.status if (status === "error") { - get.setSelf(Result.fail(new Error("Query failed to load"))) + get.setSelf(AsyncResult.fail(new Error("Query failed to load"))) return } if (status === "loading" || status === "idle") { - get.setSelf(Result.initial(true)) + get.setSelf(AsyncResult.initial(true)) return } if (status === "cleaned-up") { - get.setSelf(Result.fail(new Error("Query collection has been cleaned up"))) + get.setSelf(AsyncResult.fail(new Error("Query collection has been cleaned up"))) return } @@ -185,7 +185,7 @@ export const makeQuery = ( const isSingleResult = (collection as any).config?.singleResult === true const entries = Array.from(collection.entries()).map(([_, value]) => value) const newData = (isSingleResult ? entries[0] : entries) as InferResultType - get.setSelf(Result.success(newData)) + get.setSelf(AsyncResult.success(newData)) }) // Cleanup on unmount @@ -197,15 +197,15 @@ export const makeQuery = ( const status: CollectionStatus = collection.status if (status === "error") { - return Result.fail(new Error("Query failed to load")) + return AsyncResult.fail(new Error("Query failed to load")) } if (status === "loading" || status === "idle") { - return Result.initial(true) + return AsyncResult.initial(true) } if (status === "cleaned-up") { - return Result.fail(new Error("Query collection has been cleaned up")) + return AsyncResult.fail(new Error("Query collection has been cleaned up")) } // Get current data - handle both single and array results @@ -213,7 +213,7 @@ export const makeQuery = ( const entries = Array.from(collection.entries()).map(([_, value]) => value) const initialData = (isSingleResult ? entries[0] : entries) as InferResultType - return Result.success(initialData) + return AsyncResult.success(initialData) }) } @@ -227,7 +227,7 @@ export const makeQueryUnsafe = ( ): Atom.Atom | undefined> => { return Atom.readable((get) => { const result = get(makeQuery(queryFn, options)) - return Result.getOrElse(result, constUndefined) as InferResultType | undefined + return AsyncResult.getOrElse(result, constUndefined) as InferResultType | undefined }) } @@ -238,7 +238,7 @@ export const makeQueryUnsafe = ( export const makeQueryConditional = ( queryFn: ConditionalQueryFn, options?: QueryOptions, -): Atom.Atom, Error> | undefined> => { +): Atom.Atom, Error> | undefined> => { return Atom.readable((get) => { // Create a proxy query builder to detect if query function returns null/undefined // without actually executing any query methods diff --git a/libs/tanstack-db-atom/src/types.ts b/libs/tanstack-db-atom/src/types.ts index be1120132..d3d4d4b9b 100644 --- a/libs/tanstack-db-atom/src/types.ts +++ b/libs/tanstack-db-atom/src/types.ts @@ -3,7 +3,7 @@ * @since 1.0.0 */ -import type { Atom, Result } from "@effect-atom/atom-react" +import type { Atom, AsyncResult } from "effect/unstable/reactivity" import type { Collection, Context, diff --git a/package.json b/package.json index 5e13c7ff1..a70ade795 100644 --- a/package.json +++ b/package.json @@ -10,25 +10,15 @@ ], "catalogs": { "effect": { - "effect": "3.19.19", - "@effect/platform": "0.94.5", - "@effect/platform-bun": "0.87.1", - "@effect/platform-browser": "0.74.0", - "@effect/platform-node": "0.104.1", - "@effect/rpc": "0.73.2", - "@effect/experimental": "0.58.0", - "@effect/language-service": "0.79.0", - "@effect/sql": "0.49.0", - "@effect/sql-pg": "0.50.3", - "@effect/cluster": "0.56.4", - "@effect/opentelemetry": "0.61.0", - "@effect/workflow": "0.16.0", - "@effect-atom/atom": "0.5.3", - "@effect-atom/atom-react": "0.5.0", - "@effect/ai": "0.33.2", - "@effect/cli": "0.73.2", - "@effect/printer": "0.47.0", - "@effect/printer-ansi": "0.47.0" + "effect": "4.0.0-beta.33", + "@effect/platform-bun": "4.0.0-beta.33", + "@effect/platform-browser": "4.0.0-beta.33", + "@effect/platform-node": "4.0.0-beta.33", + "@effect/sql-pg": "4.0.0-beta.33", + "@effect/opentelemetry": "4.0.0-beta.33", + "@effect/atom-react": "4.0.0-beta.33", + "@effect/ai-openrouter": "4.0.0-beta.33", + "@effect/vitest": "4.0.0-beta.33" } } }, @@ -49,7 +39,7 @@ "test:coverage": "vitest run --coverage --coverage.reporter=text" }, "devDependencies": { - "@effect/vitest": "^0.27.0", + "@effect/vitest": "catalog:effect", "@rolldown/plugin-babel": "^0.2.1", "@vitest/coverage-v8": "^4.1.0", "oxfmt": "^0.40.0", diff --git a/packages/actors/package.json b/packages/actors/package.json index 884760668..72153ee47 100644 --- a/packages/actors/package.json +++ b/packages/actors/package.json @@ -13,7 +13,6 @@ "test": "vitest run" }, "dependencies": { - "@effect/platform": "catalog:effect", "@hazel/rivet-effect": "workspace:*", "@hazel/schema": "workspace:*", "effect": "catalog:effect", diff --git a/packages/actors/src/actors/message-actor.create-conn-state.test.ts b/packages/actors/src/actors/message-actor.create-conn-state.test.ts index 41443c9d2..c5d3b24ca 100644 --- a/packages/actors/src/actors/message-actor.create-conn-state.test.ts +++ b/packages/actors/src/actors/message-actor.create-conn-state.test.ts @@ -1,6 +1,18 @@ import { createServer } from "node:http" -import { exportJWK, generateKeyPair, SignJWT } from "jose" +import { BotId, OrganizationId, UserId, WorkOSOrganizationId, WorkOSUserId } from "@hazel/schema" +import { decodeJwt, exportJWK, generateKeyPair, SignJWT } from "jose" import { afterEach, describe, expect, it, vi } from "vitest" +import type * as EffectType from "effect/Effect" +import type * as HttpClientType from "effect/unstable/http/HttpClient" +import type { + AuthenticatedClient, + BotClient, + BotTokenValidationError, + ConfigError, + InvalidTokenFormatError, + JwtValidationError, + UserClient, +} from "../auth" const ORIGINAL_ENV = { ...process.env } @@ -31,11 +43,160 @@ const createContext = () => ({ const loadCreateConnState = async () => { vi.resetModules() + vi.doMock("../effect/runtime", async () => { + const { Effect, Layer, ManagedRuntime, Schema } = await import("effect") + const { FetchHttpClient, HttpClient } = await import("effect/unstable/http") + const auth = await import("../auth") + const { + BotTokenValidationError, + ConfigError, + InvalidTokenFormatError, + JwtValidationError, + TokenValidationService, + } = auth + + const decodeUserId = Schema.decodeUnknownSync(UserId) + const decodeBotId = Schema.decodeUnknownSync(BotId) + const decodeOrganizationId = Schema.decodeUnknownSync(OrganizationId) + const decodeWorkOsUserId = Schema.decodeUnknownSync(WorkOSUserId) + const decodeWorkOsOrganizationId = Schema.decodeUnknownSync(WorkOSOrganizationId) + + const validateBotToken = ( + token: string, + ): EffectType.Effect => + Effect.gen(function* () { + yield* HttpClient.HttpClient + + const backendUrl = + process.env.BACKEND_URL ?? + process.env.API_BASE_URL ?? + process.env.VITE_BACKEND_URL ?? + process.env.VITE_API_BASE_URL + + if (!backendUrl) { + return yield* Effect.fail( + new ConfigError({ + message: + "BACKEND_URL or API_BASE_URL environment variable is required for bot token actor authentication", + }), + ) + } + + const response = yield* Effect.tryPromise({ + try: () => + fetch(`${backendUrl}/internal/actors/validate-bot-token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }), + catch: (cause) => + new BotTokenValidationError({ + message: `Failed to validate bot token: ${String(cause)}`, + }), + }) + + if (!response.ok) { + const errorText = yield* Effect.promise(() => response.text()) + return yield* Effect.fail( + new BotTokenValidationError({ + message: `Invalid bot token: ${errorText}`, + statusCode: response.status, + }), + ) + } + + const data = (yield* Effect.promise(() => response.json())) as { + userId: string + botId: string + organizationId: string | null + scopes: readonly string[] | null + } + + return { + type: "bot" as const, + userId: decodeUserId(data.userId), + botId: decodeBotId(data.botId), + organizationId: + data.organizationId === null ? null : decodeOrganizationId(data.organizationId), + scopes: data.scopes, + } + }) + + const validateJwt = ( + token: string, + ): EffectType.Effect => + Effect.gen(function* () { + if (!process.env.WORKOS_CLIENT_ID) { + return yield* Effect.fail( + new ConfigError({ + message: + "WORKOS_CLIENT_ID environment variable is required for JWT actor authentication", + }), + ) + } + + const claims = yield* Effect.try({ + try: () => decodeJwt(token), + catch: (cause) => + new JwtValidationError({ + message: "Invalid or expired token", + cause, + }), + }) + + return { + type: "user" as const, + workosUserId: decodeWorkOsUserId(String(claims.sub)), + workosOrganizationId: + typeof claims.org_id === "string" ? decodeWorkOsOrganizationId(claims.org_id) : null, + role: claims.role === "admin" ? "admin" : "member", + } + }) + + const validateToken = ( + token: string, + ): EffectType.Effect< + AuthenticatedClient, + InvalidTokenFormatError | JwtValidationError | BotTokenValidationError | ConfigError, + HttpClientType.HttpClient + > => { + if (token.startsWith("hzl_bot_")) { + return validateBotToken(token) + } + if (token.split(".").length === 3) { + return validateJwt(token) + } + return Effect.fail( + new InvalidTokenFormatError({ + message: "Invalid token format", + }), + ) + } + + return { + messageActorRuntime: ManagedRuntime.make( + Layer.mergeAll( + FetchHttpClient.layer, + Layer.succeed( + TokenValidationService, + TokenValidationService.of({ + validateBotToken, + validateJwt, + validateToken, + }), + ), + ), + ), + } + }) const mod = await import("./message-actor.ts") - return (mod.messageActor as any).config.createConnState as ( - context: unknown, - params: { token?: string }, - ) => Promise + return ( + mod.messageActor as { + config: { + createConnState: (context: unknown, params: { token?: string }) => Promise + } + } + ).config.createConnState as (context: unknown, params: { token?: string }) => Promise } afterEach(() => { @@ -44,9 +205,9 @@ afterEach(() => { vi.restoreAllMocks() }) -const BOT_USER_ID = "00000000-0000-0000-0000-000000000011" -const BOT_ID = "00000000-0000-0000-0000-000000000022" -const BOT_ORG_ID = "00000000-0000-0000-0000-000000000033" +const BOT_USER_ID = "00000000-0000-4000-8000-000000000011" +const BOT_ID = "00000000-0000-4000-8000-000000000022" +const BOT_ORG_ID = "00000000-0000-4000-8000-000000000033" describe("messageActor.createConnState", () => { it("returns invalid_token user error for invalid token format", async () => { @@ -155,7 +316,7 @@ describe("messageActor.createConnState", () => { vi.stubGlobal( "fetch", - vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + vi.fn(async (input: Parameters[0], init?: Parameters[1]) => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url @@ -166,7 +327,7 @@ describe("messageActor.createConnState", () => { }) } - return originalFetch(input as any, init) + return originalFetch(input, init) }), ) diff --git a/packages/actors/src/actors/message-actor.ts b/packages/actors/src/actors/message-actor.ts index a37f0ed84..df6c3fa42 100644 --- a/packages/actors/src/actors/message-actor.ts +++ b/packages/actors/src/actors/message-actor.ts @@ -1,7 +1,14 @@ import { Action, CreateConnState, Log, actor } from "@hazel/rivet-effect" import { Effect } from "effect" import { UserError } from "rivetkit" -import { TokenValidationService, type ActorConnectParams } from "../auth" +import { + TokenValidationService, + type ActorConnectParams, + type InvalidTokenFormatError, + type JwtValidationError, + type BotTokenValidationError, + type ConfigError, +} from "../auth" import { messageActorRuntime } from "../effect/runtime" const getTokenKind = (token: string): "bot" | "jwt" | "unknown" => { @@ -123,7 +130,7 @@ export const messageActor = actor({ return yield* service.validateToken(params.token) }).pipe( Effect.catchTags({ - InvalidTokenFormatError: (e) => + InvalidTokenFormatError: (e: InvalidTokenFormatError) => Log.error("Token validation failed: invalid format", { tokenKind: getTokenKind(params.token), tokenPrefix: params.token.slice(0, 12), @@ -132,7 +139,7 @@ export const messageActor = actor({ Effect.fail(new UserError(e.message, { code: "invalid_token" })), ), ), - JwtValidationError: (e) => + JwtValidationError: (e: JwtValidationError) => Log.error("Token validation failed: JWT error", { error: e.message, tokenKind: getTokenKind(params.token), @@ -141,7 +148,7 @@ export const messageActor = actor({ Effect.fail(new UserError(e.message, { code: "invalid_token" })), ), ), - BotTokenValidationError: (e) => + BotTokenValidationError: (e: BotTokenValidationError) => Log.error("Token validation failed: bot token error", { statusCode: e.statusCode, tokenKind: getTokenKind(params.token), @@ -152,7 +159,7 @@ export const messageActor = actor({ Effect.fail(new UserError(e.message, { code: "invalid_token" })), ), ), - ConfigError: (e) => + ConfigError: (e: ConfigError) => Log.error("Token validation failed: auth config unavailable", { error: e.message, tokenKind: getTokenKind(params.token), diff --git a/packages/actors/src/auth/config-service.test.ts b/packages/actors/src/auth/config-service.test.ts index 8aacbc9c8..934c02f74 100644 --- a/packages/actors/src/auth/config-service.test.ts +++ b/packages/actors/src/auth/config-service.test.ts @@ -1,4 +1,4 @@ -import { Effect, Option, Redacted } from "effect" +import { ConfigProvider, Effect, Option, Redacted } from "effect" import { afterEach, describe, expect, it } from "vitest" import { TokenValidationConfigService } from "./config-service" @@ -20,9 +20,13 @@ const resetEnv = () => { } } -const loadConfig = Effect.gen(function* () { - return yield* TokenValidationConfigService -}).pipe(Effect.provide(TokenValidationConfigService.Default)) +const loadConfig = () => + Effect.gen(function* () { + return yield* TokenValidationConfigService + }).pipe( + Effect.provide(TokenValidationConfigService.layer), + Effect.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(process.env))), + ) afterEach(() => { resetEnv() @@ -37,7 +41,7 @@ describe("TokenValidationConfigService", () => { delete process.env.VITE_API_BASE_URL delete process.env.INTERNAL_SECRET - const config = await Effect.runPromise(loadConfig) + const config = await Effect.runPromise(loadConfig()) expect(Option.isNone(config.workosClientId)).toBe(true) expect(Option.isNone(config.backendUrl)).toBe(true) @@ -49,7 +53,7 @@ describe("TokenValidationConfigService", () => { process.env.BACKEND_URL = "https://backend.example.com" process.env.INTERNAL_SECRET = "super-secret" - const config = await Effect.runPromise(loadConfig) + const config = await Effect.runPromise(loadConfig()) expect(Option.isSome(config.workosClientId)).toBe(true) expect(Option.isSome(config.backendUrl)).toBe(true) diff --git a/packages/actors/src/auth/config-service.ts b/packages/actors/src/auth/config-service.ts index 53943152b..b691fd1d0 100644 --- a/packages/actors/src/auth/config-service.ts +++ b/packages/actors/src/auth/config-service.ts @@ -1,5 +1,5 @@ import type { WorkOSClientId } from "@hazel/schema" -import { Config, Effect, Option, Redacted, Schema } from "effect" +import { ServiceMap, Config, Effect, Layer, Option, Redacted, Schema } from "effect" import { WorkOSClientId as WorkOSClientIdSchema } from "@hazel/schema" /** @@ -14,41 +14,44 @@ export interface TokenValidationConfig { readonly internalSecret: Option.Option } -const optionalValue = (effect: Effect.Effect) => effect.pipe(Effect.option) +const optionalValue = (effect: Effect.Effect) => effect.pipe(Effect.option) /** * Service for loading and providing token validation configuration. * * Uses Effect.Config to load from environment variables with proper fallbacks. */ -export class TokenValidationConfigService extends Effect.Service()( +export class TokenValidationConfigService extends ServiceMap.Service()( "TokenValidationConfigService", { - accessors: true, - effect: Effect.gen(function* () { + make: Effect.gen(function* () { const workosClientId = yield* optionalValue( - Config.string("WORKOS_CLIENT_ID").pipe( - Effect.flatMap((value) => Schema.decodeUnknown(WorkOSClientIdSchema)(value)), + Effect.flatMap(Config.string("WORKOS_CLIENT_ID").asEffect(), (value) => + Schema.decodeUnknownEffect(WorkOSClientIdSchema)(value), ), ) const backendUrl = yield* optionalValue( - Config.string("BACKEND_URL").pipe( - Effect.orElse(() => Config.string("API_BASE_URL")), - Effect.orElse(() => Config.string("VITE_BACKEND_URL")), - Effect.orElse(() => Config.string("VITE_API_BASE_URL")), - ), + Config.string("BACKEND_URL") + .pipe( + Config.orElse(() => Config.string("API_BASE_URL")), + Config.orElse(() => Config.string("VITE_BACKEND_URL")), + Config.orElse(() => Config.string("VITE_API_BASE_URL")), + ) + .asEffect(), ) - const internalSecret = yield* optionalValue(Config.redacted("INTERNAL_SECRET")) + const internalSecret = yield* optionalValue(Config.redacted("INTERNAL_SECRET").asEffect()) const config: TokenValidationConfig = { - workosClientId, - backendUrl, - internalSecret, + workosClientId: workosClientId as Option.Option, + backendUrl: backendUrl as Option.Option, + internalSecret: internalSecret as Option.Option, } return config }), }, -) {} +) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/actors/src/auth/errors.ts b/packages/actors/src/auth/errors.ts index ee9fb4efd..c3971947b 100644 --- a/packages/actors/src/auth/errors.ts +++ b/packages/actors/src/auth/errors.ts @@ -3,14 +3,14 @@ import { Schema } from "effect" /** * Error when loading configuration from environment */ -export class ConfigError extends Schema.TaggedError()("ConfigError", { +export class ConfigError extends Schema.TaggedErrorClass()("ConfigError", { message: Schema.String, }) {} /** * Error when token format is invalid (not a JWT or bot token) */ -export class InvalidTokenFormatError extends Schema.TaggedError()( +export class InvalidTokenFormatError extends Schema.TaggedErrorClass()( "InvalidTokenFormatError", { message: Schema.String, @@ -20,7 +20,7 @@ export class InvalidTokenFormatError extends Schema.TaggedError()("JwtValidationError", { +export class JwtValidationError extends Schema.TaggedErrorClass()("JwtValidationError", { message: Schema.String, cause: Schema.optional(Schema.Unknown), }) {} @@ -28,7 +28,7 @@ export class JwtValidationError extends Schema.TaggedError() /** * Error when bot token validation fails (invalid token, backend error, etc.) */ -export class BotTokenValidationError extends Schema.TaggedError()( +export class BotTokenValidationError extends Schema.TaggedErrorClass()( "BotTokenValidationError", { message: Schema.String, diff --git a/packages/actors/src/auth/jwks-service.test.ts b/packages/actors/src/auth/jwks-service.test.ts index ac6ef424e..39e52b4b0 100644 --- a/packages/actors/src/auth/jwks-service.test.ts +++ b/packages/actors/src/auth/jwks-service.test.ts @@ -1,4 +1,4 @@ -import { Effect, Either } from "effect" +import { ConfigProvider, Effect, Result } from "effect" import { afterEach, describe, expect, it } from "vitest" import { JwksService } from "./jwks-service" @@ -32,12 +32,16 @@ describe("JwksService", () => { Effect.gen(function* () { const service = yield* JwksService return yield* service.getJwks() - }).pipe(Effect.provide(JwksService.Default), Effect.either), + }).pipe( + Effect.provide(JwksService.layer), + Effect.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(process.env))), + Effect.result, + ), ) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(result.left._tag).toBe("ConfigError") + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + expect(result.failure._tag).toBe("ConfigError") } }) @@ -50,7 +54,10 @@ describe("JwksService", () => { const firstJwks = yield* service.getJwks() const secondJwks = yield* service.getJwks() return [firstJwks, secondJwks] as const - }).pipe(Effect.provide(JwksService.Default)), + }).pipe( + Effect.provide(JwksService.layer), + Effect.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(process.env))), + ), ) expect(typeof first).toBe("function") diff --git a/packages/actors/src/auth/jwks-service.ts b/packages/actors/src/auth/jwks-service.ts index a3249e9d3..3fc99257e 100644 --- a/packages/actors/src/auth/jwks-service.ts +++ b/packages/actors/src/auth/jwks-service.ts @@ -1,4 +1,4 @@ -import { Effect, Option, Ref } from "effect" +import { ServiceMap, Effect, Layer, Option, Ref } from "effect" import { createRemoteJWKSet, type JWTVerifyGetKey } from "jose" import { TokenValidationConfigService } from "./config-service" import { ConfigError } from "./errors" @@ -8,10 +8,8 @@ import { ConfigError } from "./errors" * * The keyset is created lazily the first time JWT validation is requested. */ -export class JwksService extends Effect.Service()("JwksService", { - accessors: true, - dependencies: [TokenValidationConfigService.Default], - effect: Effect.gen(function* () { +export class JwksService extends ServiceMap.Service()("JwksService", { + make: Effect.gen(function* () { const config = yield* TokenValidationConfigService const jwksRef = yield* Ref.make>(Option.none()) @@ -41,4 +39,8 @@ export class JwksService extends Effect.Service()("JwksService", { getJwks, } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(TokenValidationConfigService.layer), + ) +} diff --git a/packages/actors/src/auth/token-validation-service.ts b/packages/actors/src/auth/token-validation-service.ts index 30207f91b..c69a62b7d 100644 --- a/packages/actors/src/auth/token-validation-service.ts +++ b/packages/actors/src/auth/token-validation-service.ts @@ -1,7 +1,6 @@ -import { HttpClient, HttpClientRequest } from "@effect/platform" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { WorkOSJwtClaims, WorkOSRole } from "@hazel/schema" -import { Either, Effect, Option, Redacted, Schema } from "effect" -import { TreeFormatter } from "effect/ParseResult" +import { ServiceMap, Result, Effect, Layer, Option, Redacted, Schema } from "effect" import type { JWTPayload } from "jose" import { jwtVerify } from "jose" import { TokenValidationConfigService } from "./config-service" @@ -39,16 +38,14 @@ function isBotToken(token: string): boolean { * * Provides Effect-native token validation with proper error types. */ -export class TokenValidationService extends Effect.Service()( +export class TokenValidationService extends ServiceMap.Service()( "TokenValidationService", { - accessors: true, - dependencies: [TokenValidationConfigService.Default, JwksService.Default], - effect: Effect.gen(function* () { + make: Effect.gen(function* () { const config = yield* TokenValidationConfigService const jwksService = yield* JwksService - const decodeClaims = Schema.decodeUnknown(WorkOSJwtClaims) - const decodeBotValidationResponse = Schema.decodeUnknown(BotTokenValidationResponseSchema) + const decodeClaims = Schema.decodeUnknownEffect(WorkOSJwtClaims) + const decodeBotValidationResponse = Schema.decodeUnknownEffect(BotTokenValidationResponseSchema) /** * Validate a WorkOS JWT token. @@ -82,21 +79,21 @@ export class TokenValidationService extends Effect.Service Effect.tryPromise(() => jwtVerify(token, jwks, { issuer })).pipe( Effect.map((result) => result.payload as JWTPayloadWithClaims), - Effect.either, + Effect.result, ), { concurrency: 1 }, ).pipe( Effect.flatMap((results) => { - const success = results.find(Either.isRight) + const success = results.find(Result.isSuccess) if (success) { - return Effect.succeed(success.right) + return Effect.succeed(success.success) } return Effect.fail( new JwtValidationError({ message: "Invalid or expired token", cause: results.map((result) => - Either.isLeft(result) ? result.left : null, + Result.isFailure(result) ? result.failure : null, ), }), ) @@ -107,8 +104,7 @@ export class TokenValidationService extends Effect.Service new JwtValidationError({ - message: "Invalid JWT claims", - cause: TreeFormatter.formatErrorSync(error), + message: `Invalid JWT claims: ${error.message}`, }), ), ) @@ -148,36 +144,29 @@ export class TokenValidationService extends Effect.Service requestBase, - onSome: (secret) => + onSome: (secret: Redacted.Redacted) => requestBase.pipe( HttpClientRequest.setHeader("X-Internal-Secret", Redacted.value(secret)), ), }) const response = yield* httpClient.execute(request).pipe( - Effect.catchTag("RequestError", (err) => + Effect.catchTag("HttpClientError", (err) => Effect.fail( new BotTokenValidationError({ message: `Failed to validate bot token: ${err.message}`, }), ), ), - Effect.catchTag("ResponseError", (err) => - Effect.fail( - new BotTokenValidationError({ - message: `Failed to get response: ${err.message}`, - }), - ), - ), ) if (response.status >= 400) { const errorText = yield* response.text.pipe( - Effect.catchAll(() => Effect.succeed("Unknown error")), + Effect.catch(() => Effect.succeed("Unknown error")), ) return yield* Effect.fail( @@ -189,7 +178,7 @@ export class TokenValidationService extends Effect.Service + Effect.catchTag("HttpClientError", (err) => Effect.fail( new BotTokenValidationError({ message: `Failed to parse bot token response: ${err.message}`, @@ -201,7 +190,7 @@ export class TokenValidationService extends Effect.Service new BotTokenValidationError({ - message: `Failed to decode bot token response: ${TreeFormatter.formatErrorSync(error)}`, + message: `Failed to decode bot token response: ${error.message}`, }), ), ) @@ -243,10 +232,15 @@ export class TokenValidationService extends Effect.Service()("@hazel/auth/UserLookupCache", { - accessors: true, - scoped: Effect.gen(function* () { - const persistence = yield* Persistence.ResultPersistence +export class UserLookupCache extends ServiceMap.Service()("@hazel/auth/UserLookupCache", { + make: Effect.gen(function* () { + const persistence = yield* Persistence.Persistence const store = yield* persistence.make({ storeId: USER_LOOKUP_CACHE_PREFIX, @@ -59,21 +58,21 @@ export class UserLookupCache extends Effect.Service()("@hazel/a // Record latency yield* Metric.update(userLookupCacheOperationLatency, Date.now() - startTime) - if (Option.isNone(cached)) { - yield* Metric.increment(userLookupCacheMisses) + if (cached === undefined) { + yield* Metric.update(userLookupCacheMisses, 1) yield* Effect.annotateCurrentSpan("cache.result", "miss") return Option.none() } // Exit contains Success or Failure - if (cached.value._tag === "Success") { - yield* Metric.increment(userLookupCacheHits) + if (Exit.isSuccess(cached)) { + yield* Metric.update(userLookupCacheHits, 1) yield* Effect.annotateCurrentSpan("cache.result", "hit") - return Option.some(cached.value.value) + return Option.some(cached.value) } // Cached a failure - treat as cache miss - yield* Metric.increment(userLookupCacheMisses) + yield* Metric.update(userLookupCacheMisses, 1) yield* Effect.annotateCurrentSpan("cache.result", "miss") yield* Effect.annotateCurrentSpan("cache.skip_reason", "failure_cached") return Option.none() @@ -143,9 +142,10 @@ export class UserLookupCache extends Effect.Service()("@hazel/a } }), }) { + static readonly layer = Layer.effect(this, this.make) + /** Test layer that always returns cache miss */ static Test = Layer.mock(this, { - _tag: "@hazel/auth/UserLookupCache", get: (_workosUserId: WorkOSUserId) => Effect.succeed(Option.none()), set: (_workosUserId: WorkOSUserId, _internalUserId: UserId) => Effect.void, invalidate: (_workosUserId: WorkOSUserId) => Effect.void, @@ -154,7 +154,6 @@ export class UserLookupCache extends Effect.Service()("@hazel/a /** Test layer factory for configurable cache behavior */ static TestWith = (options: { cachedResult?: UserLookupResult }) => Layer.mock(UserLookupCache, { - _tag: "@hazel/auth/UserLookupCache", get: (_workosUserId: WorkOSUserId) => Effect.succeed(options.cachedResult ? Option.some(options.cachedResult) : Option.none()), set: (_workosUserId: WorkOSUserId, _internalUserId: UserId) => Effect.void, diff --git a/packages/auth/src/cache/user-lookup-request.ts b/packages/auth/src/cache/user-lookup-request.ts index b0fc5596a..602843618 100644 --- a/packages/auth/src/cache/user-lookup-request.ts +++ b/packages/auth/src/cache/user-lookup-request.ts @@ -1,5 +1,6 @@ import { UserId, WorkOSUserId } from "@hazel/schema" -import { PrimaryKey, Schema } from "effect" +import { Schema } from "effect" +import { Persistable } from "effect/unstable/persistence" import { UserLookupCacheError } from "../errors.ts" /** @@ -14,24 +15,15 @@ export type UserLookupResult = typeof UserLookupResult.Type /** * Request type for user lookup cache operations. - * Implements TaggedRequest for use with @effect/experimental Persistence. + * Implements Persistable.Class for use with Persistence. */ -export class UserLookupCacheRequest extends Schema.TaggedRequest()( - "UserLookupCacheRequest", - { - failure: UserLookupCacheError, - success: UserLookupResult, - payload: { - /** WorkOS user ID (external ID) */ - workosUserId: WorkOSUserId, - }, - }, -) { - /** - * Primary key for cache storage. - * Used by ResultPersistence to generate the cache key. - */ - [PrimaryKey.symbol]() { - return this.workosUserId +export class UserLookupCacheRequest extends Persistable.Class<{ + payload: { + /** WorkOS user ID (external ID) */ + workosUserId: typeof WorkOSUserId.Type } -} +}>()("UserLookupCacheRequest", { + primaryKey: (payload) => payload.workosUserId, + success: UserLookupResult, + error: UserLookupCacheError, +}) {} diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts index b38a4af52..b50d3adb5 100644 --- a/packages/auth/src/config.ts +++ b/packages/auth/src/config.ts @@ -1,4 +1,4 @@ -import { Config, Effect, Layer } from "effect" +import { ServiceMap, Config, Effect, Layer } from "effect" /** * Configuration for auth services. @@ -15,9 +15,8 @@ export interface AuthConfigShape { * Auth configuration service. * Provides WorkOS credentials from environment variables. */ -export class AuthConfig extends Effect.Service()("@hazel/auth/AuthConfig", { - accessors: true, - effect: Effect.gen(function* () { +export class AuthConfig extends ServiceMap.Service()("@hazel/auth/AuthConfig", { + make: Effect.gen(function* () { const workosApiKey = yield* Config.string("WORKOS_API_KEY") const workosClientId = yield* Config.string("WORKOS_CLIENT_ID") @@ -27,8 +26,9 @@ export class AuthConfig extends Effect.Service()("@hazel/auth/AuthCo } satisfies AuthConfigShape }), }) { + static readonly layer = Layer.effect(this, this.make) + static Test = Layer.mock(this, { - _tag: "@hazel/auth/AuthConfig", workosApiKey: "sk_test_123", workosClientId: "client_test_123", }) diff --git a/packages/auth/src/consumers/backend-auth.test.ts b/packages/auth/src/consumers/backend-auth.test.ts index 95240ddc1..41dfc3adf 100644 --- a/packages/auth/src/consumers/backend-auth.test.ts +++ b/packages/auth/src/consumers/backend-auth.test.ts @@ -9,7 +9,7 @@ import { } from "./backend-auth.ts" import type { UserId, WorkOSUserId } from "@hazel/schema" -const MOCK_USER_ID = "00000000-0000-0000-0000-000000000001" as UserId +const MOCK_USER_ID = "00000000-0000-4000-8000-000000000001" as UserId // ===== Mock UserRepo Factory ===== @@ -91,10 +91,10 @@ describe("BackendAuth", () => { it.effect("decodes internal organization IDs from WorkOS externalId", () => Effect.gen(function* () { const orgId = yield* decodeInternalOrganizationIdFromWorkOS( - "00000000-0000-0000-0000-000000000099", + "00000000-0000-4000-8000-000000000099", ) - expect(orgId).toBe("00000000-0000-0000-0000-000000000099") + expect(orgId).toBe("00000000-0000-4000-8000-000000000099") }), ) diff --git a/packages/auth/src/consumers/backend-auth.ts b/packages/auth/src/consumers/backend-auth.ts index 8dd24d7f2..d172401f8 100644 --- a/packages/auth/src/consumers/backend-auth.ts +++ b/packages/auth/src/consumers/backend-auth.ts @@ -7,8 +7,7 @@ import { type WorkOSOrganizationId, type WorkOSUserId, } from "@hazel/schema" -import { Config, Effect, Layer, Option, Schema } from "effect" -import { TreeFormatter } from "effect/ParseResult" +import { ServiceMap, Config, Effect, Layer, Option, Schema } from "effect" import { createRemoteJWKSet, jwtVerify } from "jose" import { WorkOSClient } from "../session/workos-client.ts" @@ -73,15 +72,15 @@ export interface UserRepoLike { timezone: string | null settings: User.UserSettings | null }, - { _tag: "DatabaseError" } | { _tag: "ParseError" }, + { _tag: "DatabaseError" } | { _tag: "SchemaError" }, any > } -export const decodeWorkOSJwtClaims = Schema.decodeUnknown(WorkOSJwtClaims) +export const decodeWorkOSJwtClaims = Schema.decodeUnknownEffect(WorkOSJwtClaims) export const decodeInternalOrganizationIdFromWorkOS = (externalId: string) => - Schema.decodeUnknown(OrganizationId)(externalId) + Schema.decodeUnknownEffect(OrganizationId)(externalId) /** * Backend authentication service. @@ -89,12 +88,10 @@ export const decodeInternalOrganizationIdFromWorkOS = (externalId: string) => * * This is used by the backend HTTP API and WebSocket RPC handlers. */ -export class BackendAuth extends Effect.Service()("@hazel/auth/BackendAuth", { - accessors: true, - dependencies: [WorkOSClient.Default], - effect: Effect.gen(function* () { +export class BackendAuth extends ServiceMap.Service()("@hazel/auth/BackendAuth", { + make: Effect.gen(function* () { const workos = yield* WorkOSClient - const clientId = yield* Config.string("WORKOS_CLIENT_ID").pipe(Effect.orDie) + const clientId = yield* Config.string("WORKOS_CLIENT_ID") const decodeClaims = decodeWorkOSJwtClaims /** @@ -115,7 +112,7 @@ export class BackendAuth extends Effect.Service()("@hazel/auth/Back ): Effect.Effect => workos.getOrganization(workosOrgId).pipe( Effect.flatMap((org) => - Option.fromNullable(org.externalId).pipe( + Option.fromNullishOr(org.externalId).pipe( Option.match({ onNone: () => Effect.logWarning("WorkOS organization is missing externalId", { @@ -123,13 +120,13 @@ export class BackendAuth extends Effect.Service()("@hazel/auth/Back }).pipe(Effect.as(undefined)), onSome: (externalId) => decodeInternalOrganizationIdFromWorkOS(externalId).pipe( - Effect.catchAll((error) => + Effect.catch((error) => Effect.logWarning( "Failed to decode WorkOS external organization ID", { workosOrgId, externalId, - error: TreeFormatter.formatErrorSync(error), + error: String(error), }, ).pipe(Effect.as(undefined)), ), @@ -239,7 +236,7 @@ export class BackendAuth extends Effect.Service()("@hazel/auth/Back detail: String(err), }), ), - ParseError: (err) => + SchemaError: (err) => Effect.fail( new SessionLoadError({ message: "Failed to parse user update response", @@ -279,7 +276,7 @@ export class BackendAuth extends Effect.Service()("@hazel/auth/Back }) const { payload } = yield* verifyWithIssuer("https://api.workos.com").pipe( - Effect.orElse(() => + Effect.catch(() => verifyWithIssuer(`https://api.workos.com/user_management/${clientId}`), ), ) @@ -289,7 +286,7 @@ export class BackendAuth extends Effect.Service()("@hazel/auth/Back (error) => new InvalidJwtPayloadError({ message: "Invalid JWT claims", - detail: TreeFormatter.formatErrorSync(error), + detail: String(error), }), ), ) @@ -353,8 +350,10 @@ export class BackendAuth extends Effect.Service()("@hazel/auth/Back } }), }) { + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(WorkOSClient.layer)) + /** Mock user ID - a valid UUID */ - static readonly mockUserId = "00000000-0000-0000-0000-000000000001" as UserId + static readonly mockUserId = "00000000-0000-4000-8000-000000000001" as UserId /** Default mock user for tests */ static mockUser = () => ({ @@ -385,7 +384,6 @@ export class BackendAuth extends Effect.Service()("@hazel/auth/Back /** Test layer with successful authentication */ static Test = Layer.mock(this, { - _tag: "@hazel/auth/BackendAuth", authenticateWithBearer: (_bearerToken: string, _userRepo: UserRepoLike) => Effect.succeed(BackendAuth.mockCurrentUser()), syncUserFromWorkOS: ( @@ -406,7 +404,6 @@ export class BackendAuth extends Effect.Service()("@hazel/auth/Back } }) => Layer.mock(BackendAuth, { - _tag: "@hazel/auth/BackendAuth", authenticateWithBearer: (_bearerToken: string, _userRepo: UserRepoLike) => options.shouldFail?.authenticateWithBearer ?? Effect.succeed(options.currentUser ?? BackendAuth.mockCurrentUser()), @@ -424,7 +421,7 @@ export class BackendAuth extends Effect.Service()("@hazel/auth/Back /** * Layer that provides BackendAuth with all its dependencies. * - * With Effect.Service dependencies, BackendAuth.Default automatically includes: - * - WorkOSClient.Default (which includes AuthConfig.Default) + * With Effect.Service dependencies, BackendAuth.layer automatically includes: + * - WorkOSClient.layer (which includes AuthConfig.layer) */ -export const BackendAuthLive = BackendAuth.Default +export const BackendAuthLive = BackendAuth.layer diff --git a/packages/auth/src/consumers/proxy-auth.ts b/packages/auth/src/consumers/proxy-auth.ts index 333582a8f..500cee02b 100644 --- a/packages/auth/src/consumers/proxy-auth.ts +++ b/packages/auth/src/consumers/proxy-auth.ts @@ -6,8 +6,7 @@ import { type WorkOSOrganizationId, type WorkOSUserId, } from "@hazel/schema" -import { Effect, Option, Schema } from "effect" -import { TreeFormatter } from "effect/ParseResult" +import { ServiceMap, Effect, Layer, Option, Schema } from "effect" import { createRemoteJWKSet, jwtVerify } from "jose" import { UserLookupCache } from "../cache/user-lookup-cache.ts" import { WorkOSClient } from "../session/workos-client.ts" @@ -16,7 +15,7 @@ import type { AuthenticatedUserContext } from "../types.ts" /** * Authentication error for proxy auth. */ -export class ProxyAuthenticationError extends Schema.TaggedError()( +export class ProxyAuthenticationError extends Schema.TaggedErrorClass()( "ProxyAuthenticationError", { message: Schema.String, @@ -35,35 +34,33 @@ export class ProxyAuthenticationError extends Schema.TaggedError()("@hazel/auth/ProxyAuth", { - accessors: true, - dependencies: [UserLookupCache.Default, WorkOSClient.Default], - effect: Effect.gen(function* () { +export class ProxyAuth extends ServiceMap.Service()("@hazel/auth/ProxyAuth", { + make: Effect.gen(function* () { const userLookupCache = yield* UserLookupCache const workos = yield* WorkOSClient const db = yield* Database.Database - const decodeClaims = Schema.decodeUnknown(WorkOSJwtClaims) + const decodeClaims = Schema.decodeUnknownEffect(WorkOSJwtClaims) const resolveInternalOrganizationId = ( workosOrgId: WorkOSOrganizationId, ): Effect.Effect => workos.getOrganization(workosOrgId).pipe( Effect.flatMap((org) => - Option.fromNullable(org.externalId).pipe( + Option.fromNullishOr(org.externalId).pipe( Option.match({ onNone: () => Effect.logWarning("WorkOS organization is missing externalId", { workosOrgId, }).pipe(Effect.as(undefined)), onSome: (externalId) => - Schema.decodeUnknown(OrganizationId)(externalId).pipe( - Effect.catchAll((error) => + Schema.decodeUnknownEffect(OrganizationId)(externalId).pipe( + Effect.catch((error) => Effect.logWarning( "Failed to decode WorkOS external organization ID", { workosOrgId, externalId, - error: TreeFormatter.formatErrorSync(error), + error: String(error), }, ).pipe(Effect.as(undefined)), ), @@ -86,7 +83,7 @@ export class ProxyAuth extends Effect.Service()("@hazel/auth/ProxyAut const lookupUser = Effect.fn("ProxyAuth.lookupUser")(function* (workosUserId: WorkOSUserId) { // Check cache first const cached = yield* userLookupCache.get(workosUserId).pipe( - Effect.catchAll((error) => { + Effect.catch((error) => { // Log cache error but continue with database lookup return Effect.logWarning("User lookup cache error", error).pipe( Effect.map(() => Option.none<{ internalUserId: UserId }>()), @@ -109,21 +106,21 @@ export class ProxyAuth extends Effect.Service()("@hazel/auth/ProxyAut .limit(1), ) .pipe( - Effect.catchTag( - "DatabaseError", - (error) => + Effect.catchTag("DatabaseError", (error) => + Effect.fail( new ProxyAuthenticationError({ message: "Failed to lookup user in database", detail: error.message, }), + ), ), ) - const userOption = Option.fromNullable(userResult[0]) + const userOption = Option.fromNullishOr(userResult[0]) // Cache successful lookup if (Option.isSome(userOption)) { yield* userLookupCache.set(workosUserId, userOption.value.id).pipe( - Effect.catchAll((error) => + Effect.catch((error) => // Log cache error but don't fail the request Effect.logWarning("Failed to cache user lookup", error), ), @@ -158,7 +155,7 @@ export class ProxyAuth extends Effect.Service()("@hazel/auth/ProxyAut }) const { payload } = yield* verifyWithIssuer("https://api.workos.com").pipe( - Effect.orElse(() => verifyWithIssuer(`https://api.workos.com/user_management/${clientId}`)), + Effect.catch(() => verifyWithIssuer(`https://api.workos.com/user_management/${clientId}`)), ) const claims = yield* decodeClaims(payload).pipe( @@ -166,7 +163,7 @@ export class ProxyAuth extends Effect.Service()("@hazel/auth/ProxyAut (error) => new ProxyAuthenticationError({ message: "Invalid JWT claims", - detail: TreeFormatter.formatErrorSync(error), + detail: String(error), }), ), ) @@ -206,7 +203,12 @@ export class ProxyAuth extends Effect.Service()("@hazel/auth/ProxyAut validateBearerToken, } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(UserLookupCache.layer), + Layer.provide(WorkOSClient.layer), + ) +} /** * Layer that provides ProxyAuth with all its dependencies via Effect.Service dependencies. @@ -214,4 +216,4 @@ export class ProxyAuth extends Effect.Service()("@hazel/auth/ProxyAut * External dependencies that must be provided: * - Database.Database (for user lookup) */ -export const ProxyAuthLive = ProxyAuth.Default +export const ProxyAuthLive = ProxyAuth.layer diff --git a/packages/auth/src/errors.ts b/packages/auth/src/errors.ts index 7f726fea3..1922f58bd 100644 --- a/packages/auth/src/errors.ts +++ b/packages/auth/src/errors.ts @@ -3,7 +3,7 @@ import { Schema } from "effect" /** * Error thrown when session cache operations fail */ -export class SessionCacheError extends Schema.TaggedError()("SessionCacheError", { +export class SessionCacheError extends Schema.TaggedErrorClass()("SessionCacheError", { message: Schema.String, cause: Schema.optional(Schema.Unknown), }) {} @@ -11,15 +11,18 @@ export class SessionCacheError extends Schema.TaggedError()(" /** * Error thrown when user lookup cache operations fail */ -export class UserLookupCacheError extends Schema.TaggedError()("UserLookupCacheError", { - message: Schema.String, - cause: Schema.optional(Schema.Unknown), -}) {} +export class UserLookupCacheError extends Schema.TaggedErrorClass()( + "UserLookupCacheError", + { + message: Schema.String, + cause: Schema.optional(Schema.Unknown), + }, +) {} /** * Error thrown when fetching organization from WorkOS fails */ -export class OrganizationFetchError extends Schema.TaggedError()( +export class OrganizationFetchError extends Schema.TaggedErrorClass()( "OrganizationFetchError", { message: Schema.String, diff --git a/packages/auth/src/metrics.ts b/packages/auth/src/metrics.ts index 80ea629d0..12e18a1f5 100644 --- a/packages/auth/src/metrics.ts +++ b/packages/auth/src/metrics.ts @@ -3,7 +3,7 @@ * * Provides counters and histograms for monitoring auth performance. */ -import { Metric, MetricBoundaries } from "effect" +import { Metric } from "effect" // ============================================================================ // Counters @@ -20,13 +20,11 @@ export const userLookupCacheMisses = Metric.counter("user_lookup.cache.misses") // ============================================================================ /** User lookup cache operation latency (get/set) */ -export const userLookupCacheOperationLatency = Metric.histogram( - "user_lookup.cache.operation.latency_ms", - MetricBoundaries.fromIterable([1, 2, 5, 10, 25, 50]), -) +export const userLookupCacheOperationLatency = Metric.histogram("user_lookup.cache.operation.latency_ms", { + boundaries: [1, 2, 5, 10, 25, 50], +}) /** WorkOS organization lookup latency */ -export const orgLookupLatency = Metric.histogram( - "session.org.lookup.latency_ms", - MetricBoundaries.fromIterable([5, 10, 25, 50, 100, 250, 500]), -) +export const orgLookupLatency = Metric.histogram("session.org.lookup.latency_ms", { + boundaries: [5, 10, 25, 50, 100, 250, 500], +}) diff --git a/packages/auth/src/session/jwt-decoder.ts b/packages/auth/src/session/jwt-decoder.ts index f06d2291f..4e50310ad 100644 --- a/packages/auth/src/session/jwt-decoder.ts +++ b/packages/auth/src/session/jwt-decoder.ts @@ -1,6 +1,5 @@ import { InvalidJwtPayloadError, JwtPayload } from "@hazel/domain" import { Effect, Schema } from "effect" -import { TreeFormatter } from "effect/ParseResult" import { decodeJwt } from "jose" /** @@ -10,12 +9,12 @@ export const decodeSessionJwt = (accessToken: string): Effect.Effect new InvalidJwtPayloadError({ message: "Invalid JWT payload from WorkOS", - detail: TreeFormatter.formatErrorSync(error), + detail: String(error), }), ), ) diff --git a/packages/auth/src/session/workos-client.ts b/packages/auth/src/session/workos-client.ts index d2d0f896d..65541e75a 100644 --- a/packages/auth/src/session/workos-client.ts +++ b/packages/auth/src/session/workos-client.ts @@ -3,17 +3,15 @@ import { WorkOSClientId, WorkOSOrganizationId, WorkOSUserId } from "@hazel/schem import type { Organization, User as WorkOSUser } from "@workos-inc/node" import { OrganizationFetchError } from "../errors.ts" import { WorkOS as WorkOSNodeAPI } from "@workos-inc/node" -import { Effect, Layer, Schema } from "effect" +import { ServiceMap, Effect, Layer, Schema } from "effect" import { AuthConfig } from "../config.ts" /** * WorkOS client wrapper with Effect integration. * Provides type-safe access to WorkOS SDK operations. */ -export class WorkOSClient extends Effect.Service()("@hazel/auth/WorkOSClient", { - accessors: true, - dependencies: [AuthConfig.Default], - effect: Effect.gen(function* () { +export class WorkOSClient extends ServiceMap.Service()("@hazel/auth/WorkOSClient", { + make: Effect.gen(function* () { const config = yield* AuthConfig const client = new WorkOSNodeAPI(config.workosApiKey, { clientId: config.workosClientId, @@ -56,6 +54,8 @@ export class WorkOSClient extends Effect.Service()("@hazel/auth/Wo } }), }) { + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(AuthConfig.layer)) + /** Default mock user for tests */ static readonly mockUser: WorkOSUser = { id: Schema.decodeUnknownSync(WorkOSUserId)("user_01ABC123"), @@ -88,7 +88,6 @@ export class WorkOSClient extends Effect.Service()("@hazel/auth/Wo /** Test layer with mock WorkOS responses */ static Test = Layer.mock(this, { - _tag: "@hazel/auth/WorkOSClient", getUser: (userId: WorkOSUserId) => Effect.succeed({ ...WorkOSClient.mockUser, id: userId }), getOrganization: (orgId: WorkOSOrganizationId) => Effect.succeed({ ...WorkOSClient.mockOrganization, id: orgId }), @@ -98,7 +97,6 @@ export class WorkOSClient extends Effect.Service()("@hazel/auth/Wo /** Test layer factory for configurable WorkOS behavior */ static TestWith = (options: { user?: WorkOSUser; organization?: Organization }) => Layer.mock(WorkOSClient, { - _tag: "@hazel/auth/WorkOSClient", getUser: (userId: WorkOSUserId) => Effect.succeed({ ...(options.user ?? WorkOSClient.mockUser), id: userId }), getOrganization: (orgId: WorkOSOrganizationId) => diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json index b23122550..0b85aedd5 100644 --- a/packages/backend-core/package.json +++ b/packages/backend-core/package.json @@ -13,7 +13,6 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@effect/platform": "catalog:effect", "@hazel/db": "workspace:*", "@hazel/domain": "workspace:*", "@hazel/schema": "workspace:*", @@ -22,7 +21,6 @@ "effect": "catalog:effect" }, "devDependencies": { - "@effect/language-service": "catalog:effect", "@types/bun": "1.3.9", "typescript": "^5.9.3" } diff --git a/packages/backend-core/src/repositories/attachment-repo.ts b/packages/backend-core/src/repositories/attachment-repo.ts index 09b78e0fd..e49e65564 100644 --- a/packages/backend-core/src/repositories/attachment-repo.ts +++ b/packages/backend-core/src/repositories/attachment-repo.ts @@ -1,15 +1,20 @@ -import { ModelRepository, schema } from "@hazel/db" +import { Repository, schema } from "@hazel/db" import { Attachment } from "@hazel/domain/models" -import { Effect } from "effect" +import { ServiceMap, Effect, Layer } from "effect" -export class AttachmentRepo extends Effect.Service()("AttachmentRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository(schema.attachmentsTable, Attachment.Model, { - idColumn: "id", - name: "Attachment", - }) +export class AttachmentRepo extends ServiceMap.Service()("AttachmentRepo", { + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( + schema.attachmentsTable, + { insert: Attachment.Insert, update: Attachment.Update }, + { + idColumn: "id", + name: "Attachment", + }, + ) return baseRepo }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/backend-core/src/repositories/bot-command-repo.ts b/packages/backend-core/src/repositories/bot-command-repo.ts index e2ece259a..e50ed0b52 100644 --- a/packages/backend-core/src/repositories/bot-command-repo.ts +++ b/packages/backend-core/src/repositories/bot-command-repo.ts @@ -1,16 +1,19 @@ -import { and, Database, eq, inArray, lt, ModelRepository, schema, type TxFn } from "@hazel/db" +import { and, Database, eq, inArray, lt, Repository, schema, type TxFn } from "@hazel/db" import type { BotCommandId, BotId } from "@hazel/schema" import { BotCommand } from "@hazel/domain/models" -import { Effect, Option } from "effect" +import { ServiceMap, Effect, Layer, Option } from "effect" -export class BotCommandRepo extends Effect.Service()("BotCommandRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository(schema.botCommandsTable, BotCommand.Model, { - idColumn: "id", - name: "BotCommand", - }) +export class BotCommandRepo extends ServiceMap.Service()("BotCommandRepo", { + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( + schema.botCommandsTable, + { insert: BotCommand.Insert, update: BotCommand.Update }, + { + idColumn: "id", + name: "BotCommand", + }, + ) const db = yield* Database.Database // Find all commands for a bot @@ -77,7 +80,7 @@ export class BotCommandRepo extends Effect.Service()("BotCommand .limit(1), ), )({ botId, name }, tx) - .pipe(Effect.map((results) => Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) // Upsert command (for bot sync) const upsert = ( @@ -159,4 +162,6 @@ export class BotCommandRepo extends Effect.Service()("BotCommand deleteStaleCommands, } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/backend-core/src/repositories/bot-installation-repo.ts b/packages/backend-core/src/repositories/bot-installation-repo.ts index 82267a240..7a01200cf 100644 --- a/packages/backend-core/src/repositories/bot-installation-repo.ts +++ b/packages/backend-core/src/repositories/bot-installation-repo.ts @@ -1,15 +1,14 @@ -import { and, Database, eq, inArray, ModelRepository, schema, type TxFn } from "@hazel/db" +import { and, Database, eq, inArray, Repository, schema, type TxFn } from "@hazel/db" import type { BotId, BotInstallationId, OrganizationId } from "@hazel/schema" import { BotInstallation } from "@hazel/domain/models" -import { Effect, Option } from "effect" +import { ServiceMap, Effect, Layer, Option } from "effect" -export class BotInstallationRepo extends Effect.Service()("BotInstallationRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository( +export class BotInstallationRepo extends ServiceMap.Service()("BotInstallationRepo", { + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( schema.botInstallationsTable, - BotInstallation.Model, + { insert: BotInstallation.Insert, update: BotInstallation.Update }, { idColumn: "id", name: "BotInstallation", @@ -56,7 +55,7 @@ export class BotInstallationRepo extends Effect.Service()(" .limit(1), ), )({ botId, organizationId }, tx) - .pipe(Effect.map((results) => Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) // Check if a bot is installed in an organization const isInstalled = (botId: BotId, organizationId: OrganizationId, tx?: TxFn) => @@ -77,4 +76,6 @@ export class BotInstallationRepo extends Effect.Service()(" getBotIdsForOrg, } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/backend-core/src/repositories/bot-repo.ts b/packages/backend-core/src/repositories/bot-repo.ts index 96c7d4ea3..4e15b7deb 100644 --- a/packages/backend-core/src/repositories/bot-repo.ts +++ b/packages/backend-core/src/repositories/bot-repo.ts @@ -1,28 +1,19 @@ -import { - and, - Database, - eq, - ilike, - inArray, - isNull, - ModelRepository, - or, - schema, - sql, - type TxFn, -} from "@hazel/db" +import { and, Database, eq, ilike, inArray, isNull, Repository, or, schema, sql, type TxFn } from "@hazel/db" import type { BotId, UserId } from "@hazel/schema" import { Bot } from "@hazel/domain/models" -import { Effect, Option, type Schema } from "effect" +import { ServiceMap, Effect, Layer, Option, type Schema } from "effect" -export class BotRepo extends Effect.Service()("BotRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository(schema.botsTable, Bot.Model, { - idColumn: "id", - name: "Bot", - }) +export class BotRepo extends ServiceMap.Service()("BotRepo", { + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( + schema.botsTable, + { insert: Bot.Insert, update: Bot.Update }, + { + idColumn: "id", + name: "Bot", + }, + ) const db = yield* Database.Database // Find bot by ID @@ -37,7 +28,7 @@ export class BotRepo extends Effect.Service()("BotRepo", { .limit(1), ), )({ id }, tx) - .pipe(Effect.map((results) => Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) // Find bot by token hash const findByTokenHash = (tokenHash: string, tx?: TxFn) => @@ -56,7 +47,7 @@ export class BotRepo extends Effect.Service()("BotRepo", { .limit(1), ), )({ tokenHash }, tx) - .pipe(Effect.map((results) => Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) // Find bot by user ID const findByUserId = (userId: UserId, tx?: TxFn) => @@ -75,7 +66,7 @@ export class BotRepo extends Effect.Service()("BotRepo", { .limit(1), ), )({ userId }, tx) - .pipe(Effect.map((results) => Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) // Find all bots created by a user const findByCreator = (createdBy: UserId, tx?: TxFn) => @@ -262,4 +253,6 @@ export class BotRepo extends Effect.Service()("BotRepo", { decrementInstallCount, } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/backend-core/src/repositories/channel-member-repo.ts b/packages/backend-core/src/repositories/channel-member-repo.ts index c798db544..f17bbbbbd 100644 --- a/packages/backend-core/src/repositories/channel-member-repo.ts +++ b/packages/backend-core/src/repositories/channel-member-repo.ts @@ -1,15 +1,14 @@ -import { and, Database, eq, inArray, isNull, ModelRepository, schema, sql, type TxFn } from "@hazel/db" +import { and, Database, eq, inArray, isNull, Repository, schema, sql, type TxFn } from "@hazel/db" import type { ChannelId, OrganizationId, UserId } from "@hazel/schema" import { ChannelMember } from "@hazel/domain/models" -import { Effect, Option } from "effect" +import { ServiceMap, Effect, Layer, Option } from "effect" -export class ChannelMemberRepo extends Effect.Service()("ChannelMemberRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository( +export class ChannelMemberRepo extends ServiceMap.Service()("ChannelMemberRepo", { + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( schema.channelMembersTable, - ChannelMember.Model, + { insert: ChannelMember.Insert, update: ChannelMember.Update }, { idColumn: "id", name: "ChannelMember", @@ -35,7 +34,7 @@ export class ChannelMemberRepo extends Effect.Service()("Chan .limit(1), ), )({ channelId, userId }, tx) - .pipe(Effect.map((results) => Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) // Find existing single DM channel between two users const findExistingSingleDmChannel = ( @@ -79,7 +78,7 @@ export class ChannelMemberRepo extends Effect.Service()("Chan .limit(1), ), )({ userId1, userId2, organizationId }, tx) - .pipe(Effect.map((results) => Option.fromNullable(results[0]?.channel))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]?.channel))) const listByChannel = (channelId: ChannelId, tx?: TxFn) => db.makeQuery((execute, input: ChannelId) => @@ -103,4 +102,6 @@ export class ChannelMemberRepo extends Effect.Service()("Chan listByChannel, } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/backend-core/src/repositories/channel-repo.ts b/packages/backend-core/src/repositories/channel-repo.ts index 0a0b22b41..c713b82a2 100644 --- a/packages/backend-core/src/repositories/channel-repo.ts +++ b/packages/backend-core/src/repositories/channel-repo.ts @@ -1,16 +1,19 @@ -import { and, Database, eq, isNull, ModelRepository, schema, type TxFn } from "@hazel/db" +import { and, Database, eq, isNull, Repository, schema, type TxFn } from "@hazel/db" import type { OrganizationId } from "@hazel/schema" import { Channel } from "@hazel/domain/models" -import { Effect, Option } from "effect" +import { ServiceMap, Effect, Layer, Option } from "effect" -export class ChannelRepo extends Effect.Service()("ChannelRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository(schema.channelsTable, Channel.Model, { - idColumn: "id", - name: "Channel", - }) +export class ChannelRepo extends ServiceMap.Service()("ChannelRepo", { + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( + schema.channelsTable, + { insert: Channel.Insert, update: Channel.Update }, + { + idColumn: "id", + name: "Channel", + }, + ) const db = yield* Database.Database const findByOrgAndName = (organizationId: OrganizationId, name: string, tx?: TxFn) => @@ -30,11 +33,13 @@ export class ChannelRepo extends Effect.Service()("ChannelRepo", { .limit(1), ), )({ organizationId, name }, tx) - .pipe(Effect.map((results) => Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) return { ...baseRepo, findByOrgAndName, } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/backend-core/src/repositories/channel-section-repo.ts b/packages/backend-core/src/repositories/channel-section-repo.ts index a73fcf5d1..35152266c 100644 --- a/packages/backend-core/src/repositories/channel-section-repo.ts +++ b/packages/backend-core/src/repositories/channel-section-repo.ts @@ -1,13 +1,12 @@ -import { ModelRepository, schema } from "@hazel/db" +import { Repository, schema } from "@hazel/db" import { ChannelSection } from "@hazel/domain/models" -import { Effect } from "effect" +import { ServiceMap, Effect, Layer } from "effect" -export class ChannelSectionRepo extends Effect.Service()("ChannelSectionRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository( +export class ChannelSectionRepo extends ServiceMap.Service()("ChannelSectionRepo", { + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( schema.channelSectionsTable, - ChannelSection.Model, + { insert: ChannelSection.Insert, update: ChannelSection.Update }, { idColumn: "id", name: "ChannelSection", @@ -16,4 +15,6 @@ export class ChannelSectionRepo extends Effect.Service()("Ch return baseRepo }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/backend-core/src/repositories/channel-webhook-repo.ts b/packages/backend-core/src/repositories/channel-webhook-repo.ts index 4864ef7fd..dee2e2c11 100644 --- a/packages/backend-core/src/repositories/channel-webhook-repo.ts +++ b/packages/backend-core/src/repositories/channel-webhook-repo.ts @@ -1,15 +1,14 @@ -import { and, Database, eq, isNull, ModelRepository, schema, type TxFn } from "@hazel/db" +import { and, Database, eq, isNull, Repository, schema, type TxFn } from "@hazel/db" import type { ChannelId, ChannelWebhookId, OrganizationId } from "@hazel/schema" import { ChannelWebhook } from "@hazel/domain/models" -import { Effect, Option } from "effect" +import { ServiceMap, Effect, Layer, Option } from "effect" -export class ChannelWebhookRepo extends Effect.Service()("ChannelWebhookRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository( +export class ChannelWebhookRepo extends ServiceMap.Service()("ChannelWebhookRepo", { + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( schema.channelWebhooksTable, - ChannelWebhook.Model, + { insert: ChannelWebhook.Insert, update: ChannelWebhook.Update }, { idColumn: "id", name: "ChannelWebhook", @@ -50,7 +49,7 @@ export class ChannelWebhookRepo extends Effect.Service()("Ch .limit(1), ), )({ tokenHash }, tx) - .pipe(Effect.map((results) => Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) // Update last used timestamp const updateLastUsed = (id: ChannelWebhookId, tx?: TxFn) => @@ -121,4 +120,6 @@ export class ChannelWebhookRepo extends Effect.Service()("Ch findByOrganization, } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/backend-core/src/repositories/chat-sync-channel-link-repo.ts b/packages/backend-core/src/repositories/chat-sync-channel-link-repo.ts index 1c425e47d..5e84be646 100644 --- a/packages/backend-core/src/repositories/chat-sync-channel-link-repo.ts +++ b/packages/backend-core/src/repositories/chat-sync-channel-link-repo.ts @@ -1,24 +1,23 @@ -import { and, Database, eq, isNull, ModelRepository, schema, type TxFn } from "@hazel/db" +import { and, Database, eq, isNull, Repository, schema, type TxFn } from "@hazel/db" import { ChatSyncChannelLink } from "@hazel/domain/models" import type { ChannelId, ExternalChannelId, SyncChannelLinkId, SyncConnectionId } from "@hazel/schema" -import { Effect, Option, Schema } from "effect" +import { ServiceMap, Effect, Layer, Option, Schema } from "effect" -export class ChatSyncChannelLinkRepo extends Effect.Service()( +export class ChatSyncChannelLinkRepo extends ServiceMap.Service()( "ChatSyncChannelLinkRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository( + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( schema.chatSyncChannelLinksTable, - ChatSyncChannelLink.Model, + { insert: ChatSyncChannelLink.Insert, update: ChatSyncChannelLink.Update }, { idColumn: "id", name: "ChatSyncChannelLink", }, ) const db = yield* Database.Database - const decodeChannelLink = Schema.decodeUnknownSync(ChatSyncChannelLink.Model) + const decodeChannelLink = Schema.decodeUnknownSync(ChatSyncChannelLink.Schema) const decodeChannelLinkRows = (rows: readonly unknown[]) => rows.map((row) => decodeChannelLink(row)) const decodeChannelLinkOption = (value: Option.Option) => @@ -47,7 +46,7 @@ export class ChatSyncChannelLinkRepo extends Effect.Service - Option.fromNullable(results[0]).pipe(decodeChannelLinkOption), + Option.fromNullishOr(results[0]).pipe(decodeChannelLinkOption), ), ) @@ -128,7 +127,7 @@ export class ChatSyncChannelLinkRepo extends Effect.Service - Option.fromNullable(results[0]).pipe(decodeChannelLinkOption), + Option.fromNullishOr(results[0]).pipe(decodeChannelLinkOption), ), ) @@ -168,7 +167,7 @@ export class ChatSyncChannelLinkRepo extends Effect.Service - Option.fromNullable(results[0]).pipe(decodeChannelLinkOption), + Option.fromNullishOr(results[0]).pipe(decodeChannelLinkOption), ), ) @@ -294,4 +293,6 @@ export class ChatSyncChannelLinkRepo extends Effect.Service()( +export class ChatSyncConnectionRepo extends ServiceMap.Service()( "ChatSyncConnectionRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository( + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( schema.chatSyncConnectionsTable, - ChatSyncConnection.Model, + { insert: ChatSyncConnection.Insert, update: ChatSyncConnection.Update }, { idColumn: "id", name: "ChatSyncConnection", @@ -71,7 +70,7 @@ export class ChatSyncConnectionRepo extends Effect.Service Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) const findActiveByProvider = (provider: string, tx?: TxFn) => db.makeQuery((execute, data: { provider: string }) => @@ -111,7 +110,7 @@ export class ChatSyncConnectionRepo extends Effect.Service Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) const updateStatus = ( id: SyncConnectionId, @@ -182,4 +181,6 @@ export class ChatSyncConnectionRepo extends Effect.Service()( +export class ChatSyncEventReceiptRepo extends ServiceMap.Service()( "ChatSyncEventReceiptRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository( + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( schema.chatSyncEventReceiptsTable, - ChatSyncEventReceipt.Model, + { insert: ChatSyncEventReceipt.Insert, update: ChatSyncEventReceipt.Update }, { idColumn: "id", name: "ChatSyncEventReceipt", @@ -52,7 +51,7 @@ export class ChatSyncEventReceiptRepo extends Effect.Service Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) const claimByDedupeKey = ( params: { @@ -220,4 +219,6 @@ export class ChatSyncEventReceiptRepo extends Effect.Service()( +export class ChatSyncMessageLinkRepo extends ServiceMap.Service()( "ChatSyncMessageLinkRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository( + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( schema.chatSyncMessageLinksTable, - ChatSyncMessageLink.Model, + { insert: ChatSyncMessageLink.Insert, update: ChatSyncMessageLink.Update }, { idColumn: "id", name: "ChatSyncMessageLink", }, ) const db = yield* Database.Database - const decodeMessageLink = Schema.decodeUnknownSync(ChatSyncMessageLink.Model) + const decodeMessageLink = Schema.decodeUnknownSync(ChatSyncMessageLink.Schema) const decodeMessageLinkRows = (rows: readonly unknown[]) => rows.map((row) => decodeMessageLink(row)) const decodeMessageLinkOption = (value: Option.Option) => @@ -91,7 +90,7 @@ export class ChatSyncMessageLinkRepo extends Effect.Service - Option.fromNullable(results[0]).pipe(decodeMessageLinkOption), + Option.fromNullishOr(results[0]).pipe(decodeMessageLinkOption), ), ) @@ -131,7 +130,7 @@ export class ChatSyncMessageLinkRepo extends Effect.Service - Option.fromNullable(results[0]).pipe(decodeMessageLinkOption), + Option.fromNullishOr(results[0]).pipe(decodeMessageLinkOption), ), ) @@ -170,7 +169,7 @@ export class ChatSyncMessageLinkRepo extends Effect.Service - Option.fromNullable(results[0]).pipe(decodeMessageLinkOption), + Option.fromNullishOr(results[0]).pipe(decodeMessageLinkOption), ), ) @@ -214,4 +213,6 @@ export class ChatSyncMessageLinkRepo extends Effect.Service()( +export class ConnectConversationChannelRepo extends ServiceMap.Service()( "ConnectConversationChannelRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository( + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( schema.connectConversationChannelsTable, - ConnectConversationChannel.Model, + { insert: ConnectConversationChannel.Insert, update: ConnectConversationChannel.Update }, { idColumn: "id", name: "ConnectConversationChannel", @@ -34,7 +33,7 @@ export class ConnectConversationChannelRepo extends Effect.Service Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) const findByConversationId = (conversationId: ConnectConversationId, tx?: TxFn) => db.makeQuery((execute, input: ConnectConversationId) => @@ -82,7 +81,7 @@ export class ConnectConversationChannelRepo extends Effect.Service Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) return { ...baseRepo, @@ -92,4 +91,6 @@ export class ConnectConversationChannelRepo extends Effect.Service()( +export class ConnectConversationRepo extends ServiceMap.Service()( "ConnectConversationRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository( + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( schema.connectConversationsTable, - ConnectConversation.Model, + { insert: ConnectConversation.Insert, update: ConnectConversation.Update }, { idColumn: "id", name: "ConnectConversation", @@ -34,7 +33,7 @@ export class ConnectConversationRepo extends Effect.Service Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) return { ...baseRepo, @@ -42,4 +41,6 @@ export class ConnectConversationRepo extends Effect.Service()("ConnectInviteRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository( +export class ConnectInviteRepo extends ServiceMap.Service()("ConnectInviteRepo", { + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( schema.connectInvitesTable, - ConnectInvite.Model, + { insert: ConnectInvite.Insert, update: ConnectInvite.Update }, { idColumn: "id", name: "ConnectInvite", @@ -32,7 +31,7 @@ export class ConnectInviteRepo extends Effect.Service()("Conn .limit(1), ), )(id, tx) - .pipe(Effect.map((results) => Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) const listIncomingForOrganization = (organizationId: OrganizationId, tx?: TxFn) => db.makeQuery((execute, input: OrganizationId) => @@ -91,4 +90,6 @@ export class ConnectInviteRepo extends Effect.Service()("Conn findPendingForGuestOrganization, } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/backend-core/src/repositories/connect-participant-repo.ts b/packages/backend-core/src/repositories/connect-participant-repo.ts index c19a32ec3..fb4fb910b 100644 --- a/packages/backend-core/src/repositories/connect-participant-repo.ts +++ b/packages/backend-core/src/repositories/connect-participant-repo.ts @@ -1,16 +1,15 @@ -import { and, Database, eq, isNull, ModelRepository, schema, type TxFn } from "@hazel/db" +import { and, Database, eq, isNull, Repository, schema, type TxFn } from "@hazel/db" import type { ChannelId, ConnectConversationId, UserId } from "@hazel/schema" import { ConnectParticipant } from "@hazel/domain/models" -import { Effect, Option, type Schema as EffectSchema } from "effect" +import { ServiceMap, Effect, Layer, Option, type Schema as EffectSchema } from "effect" -export class ConnectParticipantRepo extends Effect.Service()( +export class ConnectParticipantRepo extends ServiceMap.Service()( "ConnectParticipantRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository( + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( schema.connectParticipantsTable, - ConnectParticipant.Model, + { insert: ConnectParticipant.Insert, update: ConnectParticipant.Update }, { idColumn: "id", name: "ConnectParticipant", @@ -35,7 +34,7 @@ export class ConnectParticipantRepo extends Effect.Service Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) const listByChannel = (channelId: ChannelId, tx?: TxFn) => db.makeQuery((execute, input: ChannelId) => @@ -113,4 +112,6 @@ export class ConnectParticipantRepo extends Effect.Service()("CustomEmojiRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository(schema.customEmojisTable, CustomEmoji.Model, { - idColumn: "id", - name: "CustomEmoji", - }) +export class CustomEmojiRepo extends ServiceMap.Service()("CustomEmojiRepo", { + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( + schema.customEmojisTable, + { insert: CustomEmoji.Insert, update: CustomEmoji.Update }, + { + idColumn: "id", + name: "CustomEmoji", + }, + ) const db = yield* Database.Database const findByOrgAndName = (organizationId: OrganizationId, name: string) => @@ -30,7 +33,7 @@ export class CustomEmojiRepo extends Effect.Service()("CustomEm .limit(1), ), )({ organizationId, name }) - .pipe(Effect.map((results) => Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) const findDeletedByOrgAndName = (organizationId: OrganizationId, name: string) => db @@ -49,7 +52,7 @@ export class CustomEmojiRepo extends Effect.Service()("CustomEm .limit(1), ), )({ organizationId, name }) - .pipe(Effect.map((results) => Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) const restore = (id: CustomEmojiId, imageUrl?: string) => db @@ -71,7 +74,7 @@ export class CustomEmojiRepo extends Effect.Service()("CustomEm .returning(), ), )({ id, imageUrl }) - .pipe(Effect.map((results) => Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) const softDelete = (id: CustomEmojiId) => db @@ -89,7 +92,7 @@ export class CustomEmojiRepo extends Effect.Service()("CustomEm .returning(), ), )(id) - .pipe(Effect.map((results) => Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) return { ...baseRepo, @@ -99,4 +102,6 @@ export class CustomEmojiRepo extends Effect.Service()("CustomEm restore, } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/backend-core/src/repositories/github-subscription-repo.ts b/packages/backend-core/src/repositories/github-subscription-repo.ts index 3bd9529d7..40a7f61a4 100644 --- a/packages/backend-core/src/repositories/github-subscription-repo.ts +++ b/packages/backend-core/src/repositories/github-subscription-repo.ts @@ -1,17 +1,16 @@ -import { and, Database, eq, isNull, ModelRepository, schema, type TxFn } from "@hazel/db" +import { and, Database, eq, isNull, Repository, schema, type TxFn } from "@hazel/db" import type { ChannelId, GitHubSubscriptionId, OrganizationId } from "@hazel/schema" import { GitHubSubscription } from "@hazel/domain/models" -import { Effect, Option } from "effect" +import { ServiceMap, Effect, Layer, Option } from "effect" -export class GitHubSubscriptionRepo extends Effect.Service()( +export class GitHubSubscriptionRepo extends ServiceMap.Service()( "GitHubSubscriptionRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository( + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( schema.githubSubscriptionsTable, - GitHubSubscription.Model, + { insert: GitHubSubscription.Insert, update: GitHubSubscription.Update }, { idColumn: "id", name: "GitHubSubscription", @@ -69,7 +68,7 @@ export class GitHubSubscriptionRepo extends Effect.Service Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) // Update subscription settings const updateSettings = ( @@ -134,4 +133,6 @@ export class GitHubSubscriptionRepo extends Effect.Service()( +export class IntegrationConnectionRepo extends ServiceMap.Service()( "IntegrationConnectionRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository( + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( schema.integrationConnectionsTable, - IntegrationConnection.Model, + { insert: IntegrationConnection.Insert, update: IntegrationConnection.Update }, { idColumn: "id", name: "IntegrationConnection", @@ -53,7 +52,7 @@ export class IntegrationConnectionRepo extends Effect.Service Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) // Find user-level connection for a specific provider const findUserConnection = ( @@ -91,7 +90,7 @@ export class IntegrationConnectionRepo extends Effect.Service Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) // Find user-level connection for a specific provider, including soft-deleted rows. // Used by upsert to reactivate previously disconnected links. @@ -129,7 +128,7 @@ export class IntegrationConnectionRepo extends Effect.Service Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) // Find active user-level connection by external account ID. const findActiveUserByExternalAccountId = ( @@ -171,7 +170,7 @@ export class IntegrationConnectionRepo extends Effect.Service Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) // Get all connections for an organization (both org-level and user-level) const findAllForOrg = (organizationId: OrganizationId, tx?: TxFn) => @@ -277,7 +276,7 @@ export class IntegrationConnectionRepo extends Effect.Service Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) // Find all connections for a GitHub installation ID (stored in metadata JSONB) const findAllByGitHubInstallationId = (installationId: string, tx?: TxFn) => @@ -324,7 +323,7 @@ export class IntegrationConnectionRepo extends Effect.Service()("IntegrationTokenRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository( +export class IntegrationTokenRepo extends ServiceMap.Service()("IntegrationTokenRepo", { + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( schema.integrationTokensTable, - IntegrationToken.Model, + { insert: IntegrationToken.Insert, update: IntegrationToken.Update }, { idColumn: "id", name: "IntegrationToken", @@ -29,7 +28,7 @@ export class IntegrationTokenRepo extends Effect.Service() .limit(1), ), )({ connectionId }, tx) - .pipe(Effect.map((results) => Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) // Update token (for refresh) const updateToken = ( @@ -95,4 +94,6 @@ export class IntegrationTokenRepo extends Effect.Service() deleteByConnectionId, } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/backend-core/src/repositories/invitation-repo.ts b/packages/backend-core/src/repositories/invitation-repo.ts index 37f005d45..47ca34915 100644 --- a/packages/backend-core/src/repositories/invitation-repo.ts +++ b/packages/backend-core/src/repositories/invitation-repo.ts @@ -1,16 +1,19 @@ -import { and, Database, eq, lte, ModelRepository, schema, type TxFn } from "@hazel/db" +import { and, Database, eq, lte, Repository, schema, type TxFn } from "@hazel/db" import type { InvitationId, OrganizationId, WorkOSInvitationId } from "@hazel/schema" import { Invitation } from "@hazel/domain/models" -import { Effect, Option, type Schema } from "effect" +import { ServiceMap, Effect, Layer, Option, type Schema } from "effect" -export class InvitationRepo extends Effect.Service()("InvitationRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository(schema.invitationsTable, Invitation.Model, { - idColumn: "id", - name: "Invitation", - }) +export class InvitationRepo extends ServiceMap.Service()("InvitationRepo", { + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( + schema.invitationsTable, + { insert: Invitation.Insert, update: Invitation.Update }, + { + idColumn: "id", + name: "Invitation", + }, + ) const db = yield* Database.Database const findByWorkosId = (workosInvitationId: WorkOSInvitationId, tx?: TxFn) => @@ -24,7 +27,7 @@ export class InvitationRepo extends Effect.Service()("Invitation .limit(1), ), )(workosInvitationId, tx) - .pipe(Effect.map((results) => Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) const upsertByWorkosId = (data: Schema.Schema.Type, tx?: TxFn) => db @@ -32,12 +35,12 @@ export class InvitationRepo extends Effect.Service()("Invitation execute((client) => client .insert(schema.invitationsTable) - .values(input) + .values(input as any) .onConflictDoUpdate({ target: schema.invitationsTable.workosInvitationId, set: { status: input.status, - acceptedAt: input.acceptedAt, + acceptedAt: input.acceptedAt as any, acceptedBy: input.acceptedBy, }, }) @@ -120,4 +123,6 @@ export class InvitationRepo extends Effect.Service()("Invitation bulkUpsertByWorkosId, } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/backend-core/src/repositories/message-outbox-repo.ts b/packages/backend-core/src/repositories/message-outbox-repo.ts index 2f3b0e713..f1be20410 100644 --- a/packages/backend-core/src/repositories/message-outbox-repo.ts +++ b/packages/backend-core/src/repositories/message-outbox-repo.ts @@ -1,7 +1,7 @@ import { Database, and, asc, eq, inArray, or, schema, sql } from "@hazel/db" import type { DatabaseError, TxFn } from "@hazel/db" import { ChannelId, MessageId, MessageOutboxEventId, MessageReactionId, UserId } from "@hazel/schema" -import { Effect, Option, Schema } from "effect" +import { ServiceMap, Effect, Layer, Option, Schema } from "effect" export const MessageCreatedPayloadSchema = Schema.Struct({ messageId: MessageId, @@ -31,25 +31,25 @@ export const ReactionDeletedPayloadSchema = Schema.Struct({ userId: Schema.optional(UserId), }) -export const MessageOutboxEventType = Schema.Literal( +export const MessageOutboxEventType = Schema.Literals([ "message_created", "message_updated", "message_deleted", "reaction_created", "reaction_deleted", -) +]) export type MessageOutboxEventType = Schema.Schema.Type -export const MessageOutboxEventStatus = Schema.Literal("pending", "processing", "processed", "failed") +export const MessageOutboxEventStatus = Schema.Literals(["pending", "processing", "processed", "failed"]) export type MessageOutboxEventStatus = Schema.Schema.Type -export const MessageOutboxEventPayloadSchema = Schema.Union( +export const MessageOutboxEventPayloadSchema = Schema.Union([ MessageCreatedPayloadSchema, MessageUpdatedPayloadSchema, MessageDeletedPayloadSchema, ReactionCreatedPayloadSchema, ReactionDeletedPayloadSchema, -) +]) export type MessageOutboxEventPayload = Schema.Schema.Type export type MessageCreatedPayload = Schema.Schema.Type @@ -84,16 +84,15 @@ export type MessageOutboxEventRecord = typeof schema.messageOutboxEventsTable.$i const InsertMessageOutboxEventSchema = Schema.Struct({ eventType: MessageOutboxEventType, - aggregateId: Schema.UUID, + aggregateId: Schema.String.check(Schema.isUUID()), channelId: ChannelId, payload: MessageOutboxEventPayloadSchema, }) const InsertMessageOutboxEventArraySchema = Schema.Array(InsertMessageOutboxEventSchema) -export class MessageOutboxRepo extends Effect.Service()("MessageOutboxRepo", { - accessors: true, - effect: Effect.gen(function* () { +export class MessageOutboxRepo extends ServiceMap.Service()("MessageOutboxRepo", { + make: Effect.gen(function* () { const db = yield* Database.Database const insert = (data: InsertMessageOutboxEvent, tx?: TxFn) => @@ -193,7 +192,7 @@ export class MessageOutboxRepo extends Effect.Service()("Mess }) .where(eq(schema.messageOutboxEventsTable.id, eventId)) .returning(), - ).pipe(Effect.map((rows) => Option.fromNullable(rows[0]))), + ).pipe(Effect.map((rows) => Option.fromNullishOr(rows[0]))), )(id, tx) const markRetry = (id: MessageOutboxEventId, params: RetryMessageOutboxEventParams, tx?: TxFn) => @@ -219,7 +218,7 @@ export class MessageOutboxRepo extends Effect.Service()("Mess }) .where(eq(schema.messageOutboxEventsTable.id, data.id)) .returning(), - ).pipe(Effect.map((rows) => Option.fromNullable(rows[0]))), + ).pipe(Effect.map((rows) => Option.fromNullishOr(rows[0]))), )( { id, @@ -250,7 +249,7 @@ export class MessageOutboxRepo extends Effect.Service()("Mess }) .where(eq(schema.messageOutboxEventsTable.id, data.id)) .returning(), - ).pipe(Effect.map((rows) => Option.fromNullable(rows[0]))), + ).pipe(Effect.map((rows) => Option.fromNullishOr(rows[0]))), )( { id, @@ -268,4 +267,6 @@ export class MessageOutboxRepo extends Effect.Service()("Mess markFailed, } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/backend-core/src/repositories/message-reaction-repo.ts b/packages/backend-core/src/repositories/message-reaction-repo.ts index 40624802d..66e0cec07 100644 --- a/packages/backend-core/src/repositories/message-reaction-repo.ts +++ b/packages/backend-core/src/repositories/message-reaction-repo.ts @@ -1,15 +1,14 @@ -import { and, Database, eq, isNull, ModelRepository, schema, type TxFn } from "@hazel/db" +import { and, Database, eq, isNull, Repository, schema, type TxFn } from "@hazel/db" import type { ChannelId, ConnectConversationId, MessageId, UserId } from "@hazel/schema" import { MessageReaction } from "@hazel/domain/models" -import { Effect, Option } from "effect" +import { ServiceMap, Effect, Layer, Option } from "effect" -export class MessageReactionRepo extends Effect.Service()("MessageReactionRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository( +export class MessageReactionRepo extends ServiceMap.Service()("MessageReactionRepo", { + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( schema.messageReactionsTable, - MessageReaction.Model, + { insert: MessageReaction.Insert, update: MessageReaction.Update }, { idColumn: "id", name: "MessageReaction", @@ -35,7 +34,7 @@ export class MessageReactionRepo extends Effect.Service()(" .limit(1), ), )({ messageId, userId, emoji }) - .pipe(Effect.map((results) => Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) const backfillConversationIdForChannel = ( channelId: ChannelId, @@ -62,4 +61,6 @@ export class MessageReactionRepo extends Effect.Service()(" backfillConversationIdForChannel, } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/backend-core/src/repositories/message-repo.ts b/packages/backend-core/src/repositories/message-repo.ts index 0551478ec..4c7ae1380 100644 --- a/packages/backend-core/src/repositories/message-repo.ts +++ b/packages/backend-core/src/repositories/message-repo.ts @@ -8,7 +8,7 @@ import { inArray, isNull, lt, - ModelRepository, + Repository, schema, sql, type TxFn, @@ -16,7 +16,7 @@ import { import type { ChannelId, ConnectConversationId, MessageId, OrganizationId, UserId } from "@hazel/schema" import { Message } from "@hazel/domain/models" -import { Effect, Option } from "effect" +import { ServiceMap, Effect, Layer, Option } from "effect" export interface ListByChannelParams { channelId: ChannelId @@ -35,13 +35,16 @@ export interface ListByChannelParams { limit: number } -export class MessageRepo extends Effect.Service()("MessageRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository(schema.messagesTable, Message.Model, { - idColumn: "id", - name: "Message", - }) +export class MessageRepo extends ServiceMap.Service()("MessageRepo", { + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( + schema.messagesTable, + { insert: Message.Insert, update: Message.Update }, + { + idColumn: "id", + name: "Message", + }, + ) const db = yield* Database.Database /** @@ -134,7 +137,7 @@ export class MessageRepo extends Effect.Service()("MessageRepo", { .limit(1), ), )(params, tx) - .pipe(Effect.map((results) => Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) const backfillConversationIdForChannel = ( channelId: ChannelId, @@ -248,4 +251,6 @@ export class MessageRepo extends Effect.Service()("MessageRepo", { backfillConversationIdForChannel, } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/backend-core/src/repositories/notification-repo.ts b/packages/backend-core/src/repositories/notification-repo.ts index 26b4d853d..a2f588b37 100644 --- a/packages/backend-core/src/repositories/notification-repo.ts +++ b/packages/backend-core/src/repositories/notification-repo.ts @@ -1,15 +1,14 @@ -import { and, Database, eq, inArray, ModelRepository, schema, type TxFn } from "@hazel/db" +import { and, Database, eq, inArray, Repository, schema, type TxFn } from "@hazel/db" import type { ChannelId, MessageId, OrganizationMemberId } from "@hazel/schema" import { Notification } from "@hazel/domain/models" -import { Effect } from "effect" +import { ServiceMap, Effect, Layer } from "effect" -export class NotificationRepo extends Effect.Service()("NotificationRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository( +export class NotificationRepo extends ServiceMap.Service()("NotificationRepo", { + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( schema.notificationsTable, - Notification.Model, + { insert: Notification.Insert, update: Notification.Update }, { idColumn: "id", name: "Notification", @@ -78,4 +77,6 @@ export class NotificationRepo extends Effect.Service()("Notifi deleteByChannelId, } as const }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/backend-core/src/repositories/organization-member-repo.ts b/packages/backend-core/src/repositories/organization-member-repo.ts index 77b98923b..67879b649 100644 --- a/packages/backend-core/src/repositories/organization-member-repo.ts +++ b/packages/backend-core/src/repositories/organization-member-repo.ts @@ -1,17 +1,16 @@ -import { and, count, Database, eq, isNull, ModelRepository, schema, type TxFn } from "@hazel/db" +import { and, count, Database, eq, isNull, Repository, schema, type TxFn } from "@hazel/db" import type { OrganizationId, OrganizationMemberId, UserId } from "@hazel/schema" import { OrganizationMember } from "@hazel/domain/models" -import { Effect, Option, type Schema } from "effect" +import { ServiceMap, Effect, Layer, Option, type Schema } from "effect" -export class OrganizationMemberRepo extends Effect.Service()( +export class OrganizationMemberRepo extends ServiceMap.Service()( "OrganizationMemberRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository( + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( schema.organizationMembersTable, - OrganizationMember.Model, + { insert: OrganizationMember.Insert, update: OrganizationMember.Update }, { idColumn: "id", name: "OrganizationMember", @@ -40,7 +39,7 @@ export class OrganizationMemberRepo extends Effect.Service Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) const upsertByOrgAndUser = ( data: Schema.Schema.Type, @@ -51,7 +50,7 @@ export class OrganizationMemberRepo extends Effect.Service Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) const bulkUpsertByOrgAndUser = ( members: Schema.Schema.Type[], @@ -176,4 +175,6 @@ export class OrganizationMemberRepo extends Effect.Service()("OrganizationRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository( +export class OrganizationRepo extends ServiceMap.Service()("OrganizationRepo", { + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( schema.organizationsTable, - Organization.Model, + { insert: Organization.Insert, update: Organization.Update }, { idColumn: "id", name: "Organization", @@ -31,7 +30,7 @@ export class OrganizationRepo extends Effect.Service()("Organi .limit(1), ), )(slug, tx) - .pipe(Effect.map((results) => Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) const findBySlugIfPublic = (slug: string, tx?: TxFn) => db @@ -50,7 +49,7 @@ export class OrganizationRepo extends Effect.Service()("Organi .limit(1), ), )(slug, tx) - .pipe(Effect.map((results) => Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) const findAllActive = (tx?: TxFn) => db.makeQuery((execute, _data: {}) => @@ -117,5 +116,9 @@ export class OrganizationRepo extends Effect.Service()("Organi setupDefaultChannels, } }), - dependencies: [ChannelRepo.Default, ChannelMemberRepo.Default], -}) {} +}) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(ChannelRepo.layer), + Layer.provide(ChannelMemberRepo.layer), + ) +} diff --git a/packages/backend-core/src/repositories/pinned-message-repo.ts b/packages/backend-core/src/repositories/pinned-message-repo.ts index 4fcfc1bb9..4ce5d4ff9 100644 --- a/packages/backend-core/src/repositories/pinned-message-repo.ts +++ b/packages/backend-core/src/repositories/pinned-message-repo.ts @@ -1,13 +1,12 @@ -import { ModelRepository, schema } from "@hazel/db" +import { Repository, schema } from "@hazel/db" import { PinnedMessage } from "@hazel/domain/models" -import { Effect } from "effect" +import { ServiceMap, Effect, Layer } from "effect" -export class PinnedMessageRepo extends Effect.Service()("PinnedMessageRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository( +export class PinnedMessageRepo extends ServiceMap.Service()("PinnedMessageRepo", { + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( schema.pinnedMessagesTable, - PinnedMessage.Model, + { insert: PinnedMessage.Insert, update: PinnedMessage.Update }, { idColumn: "id", name: "PinnedMessage", @@ -16,4 +15,6 @@ export class PinnedMessageRepo extends Effect.Service()("Pinn return baseRepo }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/backend-core/src/repositories/rss-subscription-repo.ts b/packages/backend-core/src/repositories/rss-subscription-repo.ts index 416539651..6775670b1 100644 --- a/packages/backend-core/src/repositories/rss-subscription-repo.ts +++ b/packages/backend-core/src/repositories/rss-subscription-repo.ts @@ -1,15 +1,14 @@ -import { and, Database, eq, isNull, ModelRepository, schema, type TxFn } from "@hazel/db" +import { and, Database, eq, isNull, Repository, schema, type TxFn } from "@hazel/db" import type { ChannelId, OrganizationId, RssSubscriptionId } from "@hazel/schema" import { RssSubscription } from "@hazel/domain/models" -import { Effect, Option } from "effect" +import { ServiceMap, Effect, Layer, Option } from "effect" -export class RssSubscriptionRepo extends Effect.Service()("RssSubscriptionRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository( +export class RssSubscriptionRepo extends ServiceMap.Service()("RssSubscriptionRepo", { + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( schema.rssSubscriptionsTable, - RssSubscription.Model, + { insert: RssSubscription.Insert, update: RssSubscription.Update }, { idColumn: "id", name: "RssSubscription", @@ -67,7 +66,7 @@ export class RssSubscriptionRepo extends Effect.Service()(" .limit(1), ), )({ channelId, feedUrl }, tx) - .pipe(Effect.map((results) => Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) // Update subscription settings const updateSettings = ( @@ -126,4 +125,6 @@ export class RssSubscriptionRepo extends Effect.Service()(" softDelete, } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/backend-core/src/repositories/typing-indicator-repo.ts b/packages/backend-core/src/repositories/typing-indicator-repo.ts index b1049f304..64d572427 100644 --- a/packages/backend-core/src/repositories/typing-indicator-repo.ts +++ b/packages/backend-core/src/repositories/typing-indicator-repo.ts @@ -1,16 +1,15 @@ -import { and, Database, eq, lt, ModelRepository, schema, type TxFn } from "@hazel/db" +import { and, Database, eq, lt, Repository, schema, type TxFn } from "@hazel/db" import { ChannelId, ChannelMemberId, TypingIndicatorId } from "@hazel/schema" import { TypingIndicator } from "@hazel/domain/models" -import { Effect } from "effect" +import { ServiceMap, Effect, Layer } from "effect" -export class TypingIndicatorRepo extends Effect.Service()("TypingIndicatorRepo", { - accessors: true, - effect: Effect.gen(function* () { +export class TypingIndicatorRepo extends ServiceMap.Service()("TypingIndicatorRepo", { + make: Effect.gen(function* () { const db = yield* Database.Database - const baseRepo = yield* ModelRepository.makeRepository( + const baseRepo = yield* Repository.makeRepository( schema.typingIndicatorsTable, - TypingIndicator.Model, + { insert: TypingIndicator.Insert, update: TypingIndicator.Update }, { idColumn: "id", name: "TypingIndicator", @@ -55,7 +54,7 @@ export class TypingIndicatorRepo extends Effect.Service()(" return client .insert(schema.typingIndicatorsTable) .values({ - id: TypingIndicatorId.make(crypto.randomUUID()), + id: TypingIndicatorId.makeUnsafe(crypto.randomUUID()), channelId: params.channelId, memberId: params.memberId, lastTyped: params.lastTyped, @@ -91,4 +90,6 @@ export class TypingIndicatorRepo extends Effect.Service()(" deleteStale, } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/backend-core/src/repositories/user-presence-status-repo.ts b/packages/backend-core/src/repositories/user-presence-status-repo.ts index d2dd761da..d59053c7a 100644 --- a/packages/backend-core/src/repositories/user-presence-status-repo.ts +++ b/packages/backend-core/src/repositories/user-presence-status-repo.ts @@ -1,18 +1,17 @@ -import { and, Database, eq, inArray, lt, ModelRepository, ne, schema, type TxFn } from "@hazel/db" +import { and, Database, eq, inArray, lt, Repository, ne, schema, type TxFn } from "@hazel/db" import type { ChannelId, UserId } from "@hazel/schema" import { UserPresenceStatus } from "@hazel/domain/models" -import { Effect, Option, type Schema } from "effect" +import { ServiceMap, Effect, Layer, Option, type Schema } from "effect" -export class UserPresenceStatusRepo extends Effect.Service()( +export class UserPresenceStatusRepo extends ServiceMap.Service()( "UserPresenceStatusRepo", { - accessors: true, - effect: Effect.gen(function* () { + make: Effect.gen(function* () { const db = yield* Database.Database - const baseRepo = yield* ModelRepository.makeRepository( + const baseRepo = yield* Repository.makeRepository( schema.userPresenceStatusTable, - UserPresenceStatus.Model, + { insert: UserPresenceStatus.Insert, update: UserPresenceStatus.Update }, { idColumn: "id", name: "UserPresenceStatus", @@ -31,7 +30,7 @@ export class UserPresenceStatusRepo extends Effect.Service Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) // Upsert user presence status const upsertByUserId = (data: Schema.Schema.Type, tx?: TxFn) => @@ -40,14 +39,14 @@ export class UserPresenceStatusRepo extends Effect.Service client .insert(schema.userPresenceStatusTable) - .values(input) + .values(input as any) .onConflictDoUpdate({ target: schema.userPresenceStatusTable.userId, set: { status: input.status, customMessage: input.customMessage, statusEmoji: input.statusEmoji, - statusExpiresAt: input.statusExpiresAt, + statusExpiresAt: input.statusExpiresAt as any, activeChannelId: input.activeChannelId, updatedAt: new Date(), lastSeenAt: new Date(), @@ -116,7 +115,7 @@ export class UserPresenceStatusRepo extends Effect.Service Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) // Find users with stale heartbeats (for cron job cleanup) const findStaleUsers = (timeout: Date) => @@ -161,4 +160,6 @@ export class UserPresenceStatusRepo extends Effect.Service()("UserRepo", { - accessors: true, - effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository(schema.usersTable, User.Model, { - idColumn: "id", - name: "User", - }) +export class UserRepo extends ServiceMap.Service()("UserRepo", { + make: Effect.gen(function* () { + const baseRepo = yield* Repository.makeRepository( + schema.usersTable, + { insert: User.Insert, update: User.Update }, + { + idColumn: "id", + name: "User", + }, + ) const db = yield* Database.Database const findByExternalId = (externalId: string, tx?: TxFn) => @@ -24,7 +27,7 @@ export class UserRepo extends Effect.Service()("UserRepo", { .limit(1), ), )(externalId, tx) - .pipe(Effect.map((results) => Option.fromNullable(results[0]))) + .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) const findByWorkOSUserId = (workosUserId: WorkOSUserId, tx?: TxFn) => findByExternalId(workosUserId, tx) @@ -47,7 +50,7 @@ export class UserRepo extends Effect.Service()("UserRepo", { execute((client) => client .insert(schema.usersTable) - .values(input) + .values(input as any) .onConflictDoUpdate({ target: schema.usersTable.externalId, set: { @@ -119,4 +122,6 @@ export class UserRepo extends Effect.Service()("UserRepo", { bulkUpsertByExternalId, } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/backend-core/src/services/workos-sync.test.ts b/packages/backend-core/src/services/workos-sync.test.ts index 8b677ebf9..84384c17b 100644 --- a/packages/backend-core/src/services/workos-sync.test.ts +++ b/packages/backend-core/src/services/workos-sync.test.ts @@ -10,11 +10,11 @@ import { describe("WorkOSSync helpers", () => { it("decodes valid internal organization IDs from WorkOS external IDs", async () => { const orgId = await Effect.runPromise( - decodeInternalOrganizationId("00000000-0000-0000-0000-000000000055"), + decodeInternalOrganizationId("00000000-0000-4000-8000-000000000055"), ) expect(orgId).toBe( - "00000000-0000-0000-0000-000000000055" as Schema.Schema.Type, + "00000000-0000-4000-8000-000000000055" as Schema.Schema.Type, ) }) @@ -32,7 +32,7 @@ describe("WorkOSSync helpers", () => { it("accepts org webhook payloads with missing externalId", async () => { const payload = await Effect.runPromise( - Schema.decodeUnknown(WorkOSSyncOrganizationPayload)({ + Schema.decodeUnknownEffect(WorkOSSyncOrganizationPayload)({ id: "org_01ABC123", name: "Acme", }), diff --git a/packages/backend-core/src/services/workos-sync.ts b/packages/backend-core/src/services/workos-sync.ts index 1ba66f93a..7242fc6d8 100644 --- a/packages/backend-core/src/services/workos-sync.ts +++ b/packages/backend-core/src/services/workos-sync.ts @@ -8,8 +8,7 @@ import { type UserId, } from "@hazel/schema" import type { Event } from "@workos-inc/node" -import { Effect, Match, Option, pipe, Schema, Stream } from "effect" -import { TreeFormatter } from "effect/ParseResult" +import { ServiceMap, Effect, Layer, Match, Option, pipe, Result, Schema, Stream } from "effect" import { InvitationRepo } from "../repositories/invitation-repo" import { OrganizationMemberRepo } from "../repositories/organization-member-repo" import { OrganizationRepo } from "../repositories/organization-repo" @@ -17,7 +16,7 @@ import { UserRepo } from "../repositories/user-repo" import { WorkOSClient } from "./workos" // Error types -export class WorkOSSyncError extends Schema.TaggedError("WorkOSSyncError")( +export class WorkOSSyncError extends Schema.TaggedErrorClass("WorkOSSyncError")( "WorkOSSyncError", { message: Schema.String, @@ -77,18 +76,17 @@ export const WorkOSSyncMembershipRemovedPayload = Schema.Struct({ }) export const decodeInternalOrganizationId = (externalId: string) => - Schema.decodeUnknown(OrganizationId)(externalId) + Schema.decodeUnknownEffect(OrganizationId)(externalId) export const normalizeWorkOSRole = ( role: unknown, ): Effect.Effect, never> => - Schema.decodeUnknown(WorkOSRole)(role).pipe( + Schema.decodeUnknownEffect(WorkOSRole)(role).pipe( Effect.orElseSucceed((): Schema.Schema.Type => "member"), ) -export class WorkOSSync extends Effect.Service()("WorkOSSync", { - accessors: true, - effect: Effect.gen(function* () { +export class WorkOSSync extends ServiceMap.Service()("WorkOSSync", { + make: Effect.gen(function* () { const workos = yield* WorkOSClient const db = yield* Database.Database const userRepo = yield* UserRepo @@ -96,20 +94,20 @@ export class WorkOSSync extends Effect.Service()("WorkOSSync", { const orgMemberRepo = yield* OrganizationMemberRepo const invitationRepo = yield* InvitationRepo const decodeWebhookData = - (schema: S) => + (schema: S) => (data: unknown) => - Schema.decodeUnknown(schema)(data).pipe( + Schema.decodeUnknownEffect(schema)(data).pipe( Effect.mapError( (error) => new WorkOSSyncError({ message: "Invalid WorkOS webhook payload", - cause: TreeFormatter.formatErrorSync(error), + cause: String(error), }), ), ) - const decodeWorkOSUserId = Schema.decodeUnknown(WorkOSUserId) - const decodeWorkOSOrganizationId = Schema.decodeUnknown(WorkOSOrganizationId) - const decodeWorkOSInvitationId = Schema.decodeUnknown(WorkOSInvitationId) + const decodeWorkOSUserId = Schema.decodeUnknownEffect(WorkOSUserId) + const decodeWorkOSOrganizationId = Schema.decodeUnknownEffect(WorkOSOrganizationId) + const decodeWorkOSInvitationId = Schema.decodeUnknownEffect(WorkOSInvitationId) const resolveInternalOrganizationId = (externalId: string, workosOrgId: string) => decodeInternalOrganizationId(externalId).pipe( @@ -117,7 +115,7 @@ export class WorkOSSync extends Effect.Service()("WorkOSSync", { (error) => new WorkOSSyncError({ message: `Invalid WorkOS externalId for organization ${workosOrgId}`, - cause: TreeFormatter.formatErrorSync(error), + cause: String(error), }), ), ) @@ -136,29 +134,29 @@ export class WorkOSSync extends Effect.Service()("WorkOSSync", { ) => pipe( effect, - Effect.either, + Effect.result, // biome-ignore lint/suspicious/useIterableCallbackReturn: - Effect.map((either) => { - if (either._tag === "Right") { - onSuccess(either.right) + Effect.map((r) => { + if (Result.isSuccess(r)) { + onSuccess(r.success) } else { - onError(either.left) + onError(r.failure) } }), ) // Pagination helpers using Effect Stream to fetch all pages from WorkOS - // Stream.paginateEffect takes initial cursor and returns Effect<[pageData, Option]> + // Stream.paginate takes initial cursor and returns Effect<[pageData, Option]> // Stream.runCollect gathers all pages, then we flatten them into a single array const fetchAllUsers = pipe( - Stream.paginateEffect(undefined as string | undefined, (after) => + Stream.paginate(undefined as string | undefined, (after) => workos .call((client) => client.userManagement.listUsers({ limit: 100, after })) .pipe( Effect.map( (response) => - [response.data, Option.fromNullable(response.listMetadata?.after)] as const, + [response.data, Option.fromNullishOr(response.listMetadata?.after)] as const, ), ), ), @@ -168,13 +166,13 @@ export class WorkOSSync extends Effect.Service()("WorkOSSync", { ) const fetchAllOrganizations = pipe( - Stream.paginateEffect(undefined as string | undefined, (after) => + Stream.paginate(undefined as string | undefined, (after) => workos .call((client) => client.organizations.listOrganizations({ limit: 100, after })) .pipe( Effect.map( (response) => - [response.data, Option.fromNullable(response.listMetadata?.after)] as const, + [response.data, Option.fromNullishOr(response.listMetadata?.after)] as const, ), ), ), @@ -185,7 +183,7 @@ export class WorkOSSync extends Effect.Service()("WorkOSSync", { const fetchAllMemberships = (workosOrgId: WorkOSOrganizationId) => pipe( - Stream.paginateEffect(undefined as string | undefined, (after) => + Stream.paginate(undefined as string | undefined, (after) => workos .call((client) => client.userManagement.listOrganizationMemberships({ @@ -199,7 +197,7 @@ export class WorkOSSync extends Effect.Service()("WorkOSSync", { (response) => [ response.data, - Option.fromNullable(response.listMetadata?.after), + Option.fromNullishOr(response.listMetadata?.after), ] as const, ), ), @@ -213,7 +211,7 @@ export class WorkOSSync extends Effect.Service()("WorkOSSync", { const fetchAllInvitations = (workosOrgId: WorkOSOrganizationId) => pipe( - Stream.paginateEffect(undefined as string | undefined, (after) => + Stream.paginate(undefined as string | undefined, (after) => workos .call((client) => client.userManagement.listInvitations({ @@ -227,7 +225,7 @@ export class WorkOSSync extends Effect.Service()("WorkOSSync", { (response) => [ response.data, - Option.fromNullable(response.listMetadata?.after), + Option.fromNullishOr(response.listMetadata?.after), ] as const, ), ), @@ -246,14 +244,14 @@ export class WorkOSSync extends Effect.Service()("WorkOSSync", { // Fetch all users from WorkOS (with pagination) yield* Effect.logInfo("Fetching users from WorkOS...") const fetchStart = Date.now() - const workosUsersResult = yield* pipe(fetchAllUsers, Effect.either) + const workosUsersResult = yield* pipe(fetchAllUsers, Effect.result) - if (workosUsersResult._tag === "Left") { - result.errors.push(`Failed to fetch users from WorkOS: ${workosUsersResult.left}`) + if (workosUsersResult._tag === "Failure") { + result.errors.push(`Failed to fetch users from WorkOS: ${workosUsersResult.failure}`) return result } - const workosUsers = workosUsersResult.right + const workosUsers = workosUsersResult.success yield* Effect.logInfo( `Fetched ${workosUsers.length} users from WorkOS in ${Date.now() - fetchStart}ms`, ) @@ -345,14 +343,14 @@ export class WorkOSSync extends Effect.Service()("WorkOSSync", { // Fetch all organizations from WorkOS (with pagination) yield* Effect.logInfo("Fetching organizations from WorkOS...") const fetchStart = Date.now() - const workosOrgsResult = yield* pipe(fetchAllOrganizations, Effect.either) + const workosOrgsResult = yield* pipe(fetchAllOrganizations, Effect.result) - if (workosOrgsResult._tag === "Left") { - result.errors.push(`Failed to fetch organizations from WorkOS: ${workosOrgsResult.left}`) + if (workosOrgsResult._tag === "Failure") { + result.errors.push(`Failed to fetch organizations from WorkOS: ${workosOrgsResult.failure}`) return result } - const workosOrgs = workosOrgsResult.right + const workosOrgs = workosOrgsResult.success yield* Effect.logInfo( `Fetched ${workosOrgs.length} organizations from WorkOS in ${Date.now() - fetchStart}ms`, ) @@ -440,43 +438,43 @@ export class WorkOSSync extends Effect.Service()("WorkOSSync", { // Fetch WorkOS organization by our internal organization ID (stored as externalId in WorkOS) const workosOrgResult = yield* pipe( workos.call((client) => client.organizations.getOrganizationByExternalId(organizationId)), - Effect.either, + Effect.result, ) - if (workosOrgResult._tag === "Left") { + if (workosOrgResult._tag === "Failure") { result.errors.push( - `Failed to fetch WorkOS org for ${organizationId}: ${workosOrgResult.left}`, + `Failed to fetch WorkOS org for ${organizationId}: ${workosOrgResult.failure}`, ) return result } - const workosOrg = workosOrgResult.right + const workosOrg = workosOrgResult.success // Fetch memberships from WorkOS using WorkOS organization ID (with pagination) const workosOrganizationId = yield* decodeWorkOSOrganizationId(workosOrg.id).pipe( - Effect.mapError((error) => String(TreeFormatter.formatErrorSync(error))), - Effect.either, + Effect.mapError((error) => String(String(error))), + Effect.result, ) - if (workosOrganizationId._tag === "Left") { + if (workosOrganizationId._tag === "Failure") { result.errors.push( - `Failed to decode WorkOS organization ID for ${organizationId}: ${workosOrganizationId.left}`, + `Failed to decode WorkOS organization ID for ${organizationId}: ${workosOrganizationId.failure}`, ) return result } const workosMembershipsResult = yield* pipe( - fetchAllMemberships(workosOrganizationId.right), - Effect.either, + fetchAllMemberships(workosOrganizationId.success), + Effect.result, ) - if (workosMembershipsResult._tag === "Left") { + if (workosMembershipsResult._tag === "Failure") { result.errors.push( - `Failed to fetch memberships for org ${organizationId}: ${workosMembershipsResult.left}`, + `Failed to fetch memberships for org ${organizationId}: ${workosMembershipsResult.failure}`, ) return result } - const workosMemberships = workosMembershipsResult.right + const workosMemberships = workosMembershipsResult.success // Get all existing memberships for this org const existingMemberships = yield* orgMemberRepo.findAllByOrganization(organizationId) @@ -567,43 +565,43 @@ export class WorkOSSync extends Effect.Service()("WorkOSSync", { // Fetch WorkOS organization by our internal organization ID (stored as externalId in WorkOS) const workosOrgResult = yield* pipe( workos.call((client) => client.organizations.getOrganizationByExternalId(organizationId)), - Effect.either, + Effect.result, ) - if (workosOrgResult._tag === "Left") { + if (workosOrgResult._tag === "Failure") { result.errors.push( - `Failed to fetch WorkOS org for ${organizationId}: ${workosOrgResult.left}`, + `Failed to fetch WorkOS org for ${organizationId}: ${workosOrgResult.failure}`, ) return result } - const workosOrg = workosOrgResult.right + const workosOrg = workosOrgResult.success // Fetch invitations from WorkOS using WorkOS organization ID (with pagination) const workosOrganizationId = yield* decodeWorkOSOrganizationId(workosOrg.id).pipe( - Effect.mapError((error) => String(TreeFormatter.formatErrorSync(error))), - Effect.either, + Effect.mapError((error) => String(String(error))), + Effect.result, ) - if (workosOrganizationId._tag === "Left") { + if (workosOrganizationId._tag === "Failure") { result.errors.push( - `Failed to decode WorkOS organization ID for ${organizationId}: ${workosOrganizationId.left}`, + `Failed to decode WorkOS organization ID for ${organizationId}: ${workosOrganizationId.failure}`, ) return result } const workosInvitationsResult = yield* pipe( - fetchAllInvitations(workosOrganizationId.right), - Effect.either, + fetchAllInvitations(workosOrganizationId.success), + Effect.result, ) - if (workosInvitationsResult._tag === "Left") { + if (workosInvitationsResult._tag === "Failure") { result.errors.push( - `Failed to fetch invitations for org ${organizationId}: ${workosInvitationsResult.left}`, + `Failed to fetch invitations for org ${organizationId}: ${workosInvitationsResult.failure}`, ) return result } - const workosInvitations = workosInvitationsResult.right + const workosInvitations = workosInvitationsResult.success // Get all existing invitations for this org const existingInvitations = yield* invitationRepo.findAllByOrganization(organizationId) @@ -675,12 +673,12 @@ export class WorkOSSync extends Effect.Service()("WorkOSSync", { ) // Mark expired invitations - const expiredResult = yield* pipe(invitationRepo.markExpired(), Effect.either) + const expiredResult = yield* pipe(invitationRepo.markExpired(), Effect.result) - if (expiredResult._tag === "Right") { - result.expired = expiredResult.right.length + if (expiredResult._tag === "Success") { + result.expired = expiredResult.success.length } else { - result.errors.push(`Error marking expired invitations: ${expiredResult.left}`) + result.errors.push(`Error marking expired invitations: ${expiredResult.failure}`) } return result @@ -858,15 +856,15 @@ export class WorkOSSync extends Effect.Service()("WorkOSSync", { // Fetch WorkOS org to get externalId (our internal org ID) const workosOrgResult = yield* pipe( workos.call((client) => client.organizations.getOrganization(data.organizationId)), - Effect.either, + Effect.result, ) - if (workosOrgResult._tag === "Left") { + if (workosOrgResult._tag === "Failure") { yield* Effect.logError(`Failed to fetch WorkOS org ${data.organizationId}`) return } - const workosOrg = workosOrgResult.right + const workosOrg = workosOrgResult.success if (!workosOrg.externalId) { yield* Effect.logWarning(`WorkOS org ${data.organizationId} has no externalId`) @@ -903,15 +901,15 @@ export class WorkOSSync extends Effect.Service()("WorkOSSync", { // Fetch WorkOS org to get externalId (our internal org ID) const workosOrgResult = yield* pipe( workos.call((client) => client.organizations.getOrganization(data.organizationId)), - Effect.either, + Effect.result, ) - if (workosOrgResult._tag === "Left") { + if (workosOrgResult._tag === "Failure") { yield* Effect.logError(`Failed to fetch WorkOS org ${data.organizationId}`) return } - const workosOrg = workosOrgResult.right + const workosOrg = workosOrgResult.success if (!workosOrg.externalId) { yield* Effect.logWarning(`WorkOS org ${data.organizationId} has no externalId`) @@ -976,7 +974,7 @@ export class WorkOSSync extends Effect.Service()("WorkOSSync", { ), Effect.tap(() => Effect.logDebug(`Successfully processed: ${event.event}`)), Effect.as({ success: true }), - Effect.catchAll((error) => + Effect.catch((error) => Effect.logError(`Failed to process ${event.event}`, { error }).pipe( Effect.as({ success: false, error: String(error) }), ), @@ -992,11 +990,12 @@ export class WorkOSSync extends Effect.Service()("WorkOSSync", { processWebhookEvent: (event: Event) => processWebhookEvent(event), } }), - dependencies: [ - WorkOSClient.Default, - UserRepo.Default, - OrganizationRepo.Default, - OrganizationMemberRepo.Default, - InvitationRepo.Default, - ], -}) {} +}) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(WorkOSClient.layer), + Layer.provide(UserRepo.layer), + Layer.provide(OrganizationRepo.layer), + Layer.provide(OrganizationMemberRepo.layer), + Layer.provide(InvitationRepo.layer), + ) +} diff --git a/packages/backend-core/src/services/workos.ts b/packages/backend-core/src/services/workos.ts index 86d896116..46835a036 100644 --- a/packages/backend-core/src/services/workos.ts +++ b/packages/backend-core/src/services/workos.ts @@ -1,13 +1,12 @@ import { WorkOS as WorkOSNodeAPI } from "@workos-inc/node" -import { Config, Effect, Redacted, Schema } from "effect" +import { ServiceMap, Config, Effect, Layer, Redacted, Schema } from "effect" -export class WorkOSApiError extends Schema.TaggedError()("WorkOSApiError", { +export class WorkOSApiError extends Schema.TaggedErrorClass()("WorkOSApiError", { cause: Schema.Unknown, }) {} -export class WorkOSClient extends Effect.Service()("WorkOSClient", { - accessors: true, - effect: Effect.gen(function* () { +export class WorkOSClient extends ServiceMap.Service()("WorkOSClient", { + make: Effect.gen(function* () { const apiKey = yield* Config.redacted("WORKOS_API_KEY") const clientId = yield* Config.string("WORKOS_CLIENT_ID") @@ -25,4 +24,6 @@ export class WorkOSClient extends Effect.Service()("WorkOSClient", call, } }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/db/README.md b/packages/db/README.md index f336f954a..23bdb485b 100644 --- a/packages/db/README.md +++ b/packages/db/README.md @@ -182,16 +182,21 @@ Effect.gen(function* () { ### Creating a Repository ```typescript -import { ModelRepository, schema } from "@hazel/db" +import { Repository, schema } from "@hazel/db" +import { User } from "@hazel/domain/models" import { Effect } from "effect" export class UserRepo extends Effect.Service()("UserRepo", { accessors: true, effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository(schema.usersTable, User.Model, { - idColumn: "id", - name: "User", - }) + const baseRepo = yield* Repository.makeRepository( + schema.usersTable, + { insert: User.Insert, update: User.Update }, + { + idColumn: "id", + name: "User", + }, + ) return baseRepo }), @@ -217,7 +222,7 @@ All repositories include these methods: export class ChannelMemberRepo extends Effect.Service()("ChannelMemberRepo", { accessors: true, effect: Effect.gen(function* () { - const baseRepo = yield* ModelRepository.makeRepository(/*...*/) + const baseRepo = yield* Repository.makeRepository(/*...*/) const db = yield* Database.Database // Custom method with automatic transaction support diff --git a/packages/db/package.json b/packages/db/package.json index b8af20dd5..bb190b750 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -17,7 +17,6 @@ "db:studio": "drizzle-kit studio" }, "dependencies": { - "@effect/experimental": "catalog:effect", "@hazel/domain": "workspace:*", "@hazel/schema": "workspace:*", "drizzle-orm": "^0.45.1", @@ -25,7 +24,6 @@ "postgres": "^3.4.7" }, "devDependencies": { - "@effect/language-service": "catalog:effect", "@testcontainers/postgresql": "^10.18.0", "@types/bun": "1.3.9", "drizzle-kit": "^0.31.8", diff --git a/packages/db/src/services/database.test.ts b/packages/db/src/services/database.test.ts index 05b8ac33a..1114a5b4a 100644 --- a/packages/db/src/services/database.test.ts +++ b/packages/db/src/services/database.test.ts @@ -41,7 +41,7 @@ describe("Database.transaction", () => { const txCtx = yield* TransactionContext // Step 1: Insert a record using the transaction client - yield* txCtx.execute((tx) => + yield* txCtx.execute((tx: any) => tx.execute(`INSERT INTO _test_rollback (id) VALUES ('${testId}')`), ) @@ -49,14 +49,15 @@ describe("Database.transaction", () => { return yield* Effect.fail(new Error("Intentional failure")) }), ) - .pipe(Effect.either) + .pipe(Effect.result) // Verify the transaction failed - expect(result._tag).toBe("Left") + expect(result._tag).toBe("Failure") // Verify the insert was rolled back (query outside transaction) const rows = yield* db.execute( - (client) => client.$client`SELECT * FROM _test_rollback WHERE id = ${testId}`, + (client: any) => + client.$client`SELECT * FROM _test_rollback WHERE id = ${testId}` as Promise, ) // Should be empty because transaction rolled back @@ -80,7 +81,7 @@ describe("Database.transaction", () => { // Get the transaction context to execute within the transaction const txCtx = yield* TransactionContext - yield* txCtx.execute((tx) => + yield* txCtx.execute((tx: any) => tx.execute(`INSERT INTO _test_rollback (id) VALUES ('${testId}')`), ) return "success" @@ -89,13 +90,16 @@ describe("Database.transaction", () => { // Verify the insert persisted (query outside transaction) const rows = yield* db.execute( - (client) => client.$client`SELECT * FROM _test_rollback WHERE id = ${testId}`, + (client: any) => + client.$client`SELECT * FROM _test_rollback WHERE id = ${testId}` as Promise, ) expect(rows.length).toBe(1) // Cleanup - yield* db.execute((client) => client.$client`DELETE FROM _test_rollback WHERE id = ${testId}`) + yield* db.execute( + (client: any) => client.$client`DELETE FROM _test_rollback WHERE id = ${testId}`, + ) }) await Effect.runPromise( @@ -120,25 +124,26 @@ describe("Database.transaction", () => { // Get the transaction context to execute within the transaction const txCtx = yield* TransactionContext - yield* txCtx.execute((tx) => + yield* txCtx.execute((tx: any) => tx.execute(`INSERT INTO _test_rollback (id) VALUES ('${testId}')`), ) return yield* Effect.fail(new CustomTestError("Custom error message")) }), ) - .pipe(Effect.either) + .pipe(Effect.result) // Verify the transaction failed with the correct error type - expect(result._tag).toBe("Left") - if (result._tag === "Left") { - const cause = result.left + expect(result._tag).toBe("Failure") + if (result._tag === "Failure") { + const failure = result.failure // The error should be our custom error - expect(cause).toBeDefined() + expect(failure).toBeDefined() } // Verify rollback occurred (query outside transaction) const rows = yield* db.execute( - (client) => client.$client`SELECT * FROM _test_rollback WHERE id = ${testId}`, + (client: any) => + client.$client`SELECT * FROM _test_rollback WHERE id = ${testId}` as Promise, ) expect(rows.length).toBe(0) }) diff --git a/packages/db/src/services/database.ts b/packages/db/src/services/database.ts index 8c94b0eb3..bb3ad95c4 100644 --- a/packages/db/src/services/database.ts +++ b/packages/db/src/services/database.ts @@ -1,14 +1,12 @@ import type { ExtractTablesWithRelations } from "drizzle-orm" import type { PgTransaction } from "drizzle-orm/pg-core" import { drizzle, type PostgresJsDatabase, type PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js" -import { Schema } from "effect" +import { Schema, ServiceMap } from "effect" import * as Effect from "effect/Effect" import * as Exit from "effect/Exit" import * as Layer from "effect/Layer" import * as Option from "effect/Option" -import type { ParseError } from "effect/ParseResult" import * as Redacted from "effect/Redacted" -import * as Runtime from "effect/Runtime" import * as Schedule from "effect/Schedule" import postgres from "postgres" import * as schema from "../schema" @@ -31,19 +29,18 @@ export interface TransactionService { readonly execute: TxFn } -export class TransactionContext extends Effect.Tag("TransactionContext")< - TransactionContext, - TransactionService ->() {} +export class TransactionContext extends ServiceMap.Service()( + "TransactionContext", +) {} -const DatabaseErrorType = Schema.Literal( +const DatabaseErrorType = Schema.Literals([ "unique_violation", "foreign_key_violation", "connection_error", "query_error", -) +]) -export class DatabaseError extends Schema.TaggedError()("DatabaseError", { +export class DatabaseError extends Schema.TaggedErrorClass()("DatabaseError", { type: DatabaseErrorType, cause: Schema.Unknown, }) { @@ -72,7 +69,7 @@ const matchPgError = (error: unknown) => { return null } -export class DatabaseConnectionLostError extends Schema.TaggedError()( +export class DatabaseConnectionLostError extends Schema.TaggedErrorClass()( "DatabaseConnectionLostError", { cause: Schema.Unknown, @@ -108,8 +105,8 @@ const makeService = (config: Config) => yield* Effect.tryPromise(() => sql`SELECT 1`).pipe( Effect.retry( - Schedule.jitteredWith(Schedule.spaced("1.25 seconds"), { min: 0.5, max: 1.5 }).pipe( - Schedule.intersect(Schedule.recurs(10)), + Schedule.jittered(Schedule.spaced("1.25 seconds")).pipe( + Schedule.both(Schedule.recurs(10)), Schedule.tapOutput(([output]) => Effect.logWarning( `[Database client]: Connection to the database failed. Retrying (attempt ${output}).`, @@ -137,10 +134,10 @@ const makeService = (config: Config) => ) const transaction = Effect.fn("Database.transaction")((effect: Effect.Effect) => - Effect.runtime().pipe( - Effect.map((runtime) => Runtime.runPromiseExit(runtime)), + Effect.services().pipe( + Effect.map((services) => Effect.runPromiseExitWith(services)), Effect.flatMap((runPromiseExit) => - Effect.async((resume) => { + Effect.callback((resume) => { db.transaction(async (tx: TransactionClient) => { const txWrapper: TxFn = (fn: (client: TransactionClient) => Promise) => Effect.tryPromise({ @@ -175,13 +172,13 @@ const makeService = (config: Config) => ), ) - const makeQueryWithSchema = ( + const makeQueryWithSchema = ( inputSchema: InputSchema, queryFn: ( execute: ( fn: (client: Client | TransactionClient) => Promise, ) => Effect.Effect, - validatedInput: Schema.Schema.Type, + validatedInput: InputSchema["Type"], options?: { spanPrefix?: string }, ) => Effect.Effect, ) => { @@ -190,9 +187,9 @@ const makeService = (config: Config) => tx?: ( fn: (client: TransactionClient) => Promise, ) => Effect.Effect, - ): Effect.Effect => { + ): Effect.Effect => { return Effect.gen(function* () { - const validatedInput = yield* Schema.decode(inputSchema)(rawData) + const validatedInput = yield* Schema.decodeUnknownEffect(inputSchema)(rawData) if (tx) { return yield* queryFn(tx, validatedInput) @@ -208,7 +205,7 @@ const makeService = (config: Config) => Effect.withSpan("queryWithSchema", { attributes: { "input.schema": inputSchema.ast.toString() }, }), - ) + ) as Effect.Effect } } @@ -257,8 +254,8 @@ const makeService = (config: Config) => } as const }) -type Shape = Effect.Effect.Success> +type Shape = Effect.Success> -export class Database extends Effect.Tag("Database")() {} +export class Database extends ServiceMap.Service()("Database") {} -export const layer = (config: Config) => Layer.scoped(Database, makeService(config)) +export const layer = (config: Config) => Layer.effect(Database, makeService(config)) diff --git a/packages/db/src/services/drizzle-effect.ts b/packages/db/src/services/drizzle-effect.ts index 2ffd5af0e..270be3c1f 100644 --- a/packages/db/src/services/drizzle-effect.ts +++ b/packages/db/src/services/drizzle-effect.ts @@ -16,26 +16,26 @@ type ColumnSchema = TColumn["dataType"] extends : Schema.Schema : TColumn["dataType"] extends "bigint" ? TColumn extends { mode: "number" } - ? Schema.Schema - : Schema.Schema + ? Schema.Schema + : Schema.Schema : TColumn["dataType"] extends "number" ? TColumn["columnType"] extends `PgBigInt${number}` - ? Schema.Schema - : Schema.Schema + ? Schema.Schema + : Schema.Schema : TColumn["columnType"] extends "PgNumeric" - ? Schema.Schema + ? Schema.Schema : TColumn["columnType"] extends "PgUUID" ? Schema.Schema : TColumn["columnType"] extends "PgDate" ? TColumn extends { mode: "string" } - ? Schema.Schema - : Schema.Schema + ? Schema.Schema + : Schema.Schema : TColumn["columnType"] extends "PgTimestamp" ? TColumn extends { mode: "string" } - ? Schema.Schema - : Schema.Schema + ? Schema.Schema + : Schema.Schema : TColumn["dataType"] extends "string" - ? Schema.Schema + ? Schema.Schema : TColumn["dataType"] extends "boolean" ? Schema.Schema : TColumn["dataType"] extends "date" @@ -56,26 +56,25 @@ type StrictJsonArray = readonly StrictJsonValue[] type StrictJsonValue = JsonPrimitive | StrictJsonObject | StrictJsonArray // Non-recursive JSON schema to avoid type inference explosion -export const JsonValue = Schema.Union( +export const JsonValue = Schema.Union([ Schema.String, Schema.Number, Schema.Boolean, Schema.Null, - Schema.Record({ key: Schema.String, value: Schema.Unknown }), + Schema.Record(Schema.String, Schema.Unknown), Schema.Array(Schema.Unknown), -) satisfies Schema.Schema +]) satisfies Schema.Schema // For cases where you need full JSON validation, use this explicit version -export const StrictJsonValue = Schema.suspend( - (): Schema.Schema => - Schema.Union( - Schema.String, - Schema.Number, - Schema.Boolean, - Schema.Null, - Schema.Record({ key: Schema.String, value: StrictJsonValue }), - Schema.Array(StrictJsonValue), - ), +export const StrictJsonValue: Schema.Schema = Schema.suspend(() => + Schema.Union([ + Schema.String, + Schema.Number, + Schema.Boolean, + Schema.Null, + Schema.Record(Schema.String, StrictJsonValue), + Schema.Array(StrictJsonValue), + ]), ) // Utility type to prevent unknown keys (similar to drizzle-zod) @@ -102,29 +101,14 @@ type BuildRefine> = { } // Property signature builders - simplified +// In v4, optional properties are represented via Schema.optional/Schema.optionalKey wrappers type InsertProperty< TColumn extends Drizzle.Column, - TKey extends string, + _TKey extends string, > = TColumn["_"]["notNull"] extends false - ? Schema.PropertySignature< - "?:", - Schema.Schema.Type> | null | undefined, - TKey, - "?:", - Schema.Schema.Encoded> | null | undefined, - false, - never - > + ? Schema.optional>> : TColumn["_"]["hasDefault"] extends true - ? Schema.PropertySignature< - "?:", - Schema.Schema.Type> | undefined, - TKey, - "?:", - Schema.Schema.Encoded> | undefined, - true, - never - > + ? Schema.optional> : ColumnSchema type SelectProperty = TColumn["_"]["notNull"] extends false @@ -164,7 +148,7 @@ export function createInsertSchema = Object.fromEntries( + let schemaEntries: Record = Object.fromEntries( columnEntries.map(([name, column]) => [name, mapColumnToSchema(column)]), ) @@ -172,14 +156,8 @@ export function createInsertSchema [ name, - typeof refineColumn === "function" && - !Schema.isSchema(refineColumn) && - !Schema.isPropertySignature(refineColumn) - ? ( - refineColumn as ( - schema: Schema.Schema.All | Schema.PropertySignature.All, - ) => Schema.Schema.All | Schema.PropertySignature.All - )(schemaEntries[name]!) + typeof refineColumn === "function" && !Schema.isSchema(refineColumn) + ? (refineColumn as (schema: Schema.Top) => Schema.Top)(schemaEntries[name]!) : refineColumn, ]) @@ -189,9 +167,9 @@ export function createInsertSchema = Object.fromEntries( + let schemaEntries: Record = Object.fromEntries( columnEntries.map(([name, column]) => [name, mapColumnToSchema(column)]), ) @@ -220,14 +198,8 @@ export function createSelectSchema [ name, - typeof refineColumn === "function" && - !Schema.isSchema(refineColumn) && - !Schema.isPropertySignature(refineColumn) - ? ( - refineColumn as ( - schema: Schema.Schema.All | Schema.PropertySignature.All, - ) => Schema.Schema.All | Schema.PropertySignature.All - )(schemaEntries[name]!) + typeof refineColumn === "function" && !Schema.isSchema(refineColumn) + ? (refineColumn as (schema: Schema.Top) => Schema.Top)(schemaEntries[name]!) : refineColumn, ]) @@ -237,7 +209,7 @@ export function createSelectSchema { - let type: Schema.Schema | undefined +function mapColumnToSchema(column: Drizzle.Column): Schema.Schema { + let type: Schema.Schema | undefined if (isWithEnum(column)) { - type = column.enumValues.length > 0 ? Schema.Literal(...column.enumValues) : Schema.String + type = column.enumValues.length > 0 ? Schema.Literals(column.enumValues) : Schema.String } if (!type) { if (Drizzle.is(column, DrizzlePg.PgUUID)) { - type = Schema.UUID + type = Schema.String.check(Schema.isUUID()) } else if (column.dataType === "custom") { type = Schema.Any } else if (column.dataType === "json") { @@ -271,17 +243,17 @@ function mapColumnToSchema(column: Drizzle.Column): Schema.Schema { type = Schema.Number } else if (column.dataType === "bigint") { // Check if column has mode: "number" - Drizzle converts to JS number at runtime - type = hasMode(column) && column.mode === "number" ? Schema.Number : Schema.BigIntFromSelf + type = hasMode(column) && column.mode === "number" ? Schema.Number : Schema.BigInt } else if (column.dataType === "boolean") { type = Schema.Boolean } else if (column.dataType === "date") { - type = hasMode(column) && column.mode === "string" ? Schema.String : Schema.DateFromSelf + type = hasMode(column) && column.mode === "string" ? Schema.String : Schema.Date } else if (column.dataType === "string") { // Additional check: if it's a PgTimestamp or PgDate masquerading as string if (Drizzle.is(column, DrizzlePg.PgTimestamp)) { - type = hasMode(column) && column.mode === "string" ? Schema.String : Schema.DateFromSelf + type = hasMode(column) && column.mode === "string" ? Schema.String : Schema.Date } else if (Drizzle.is(column, DrizzlePg.PgDate)) { - type = hasMode(column) && column.mode === "string" ? Schema.String : Schema.DateFromSelf + type = hasMode(column) && column.mode === "string" ? Schema.String : Schema.Date } else { let sType = Schema.String if ( @@ -293,7 +265,7 @@ function mapColumnToSchema(column: Drizzle.Column): Schema.Schema { Drizzle.is(column, DrizzleSqlite.SQLiteText)) && typeof column.length === "number" ) { - sType = sType.pipe(Schema.maxLength(column.length)) + sType = sType.check(Schema.isMaxLength(column.length)) } type = sType } diff --git a/packages/db/src/services/index.ts b/packages/db/src/services/index.ts index 2430651de..cd7026072 100644 --- a/packages/db/src/services/index.ts +++ b/packages/db/src/services/index.ts @@ -1,5 +1,4 @@ export type { DatabaseError, TransactionClient, TxFn } from "./database" export * as Database from "./database" export * as DrizzleEffect from "./drizzle-effect" -export * as Model from "./model" -export * as ModelRepository from "./model-repository" +export * as Repository from "./repository" diff --git a/packages/db/src/services/model-repository.ts b/packages/db/src/services/model-repository.ts deleted file mode 100644 index c145efe30..000000000 --- a/packages/db/src/services/model-repository.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { InferSelectModel, Table } from "drizzle-orm" -import { eq } from "drizzle-orm" -import { pipe } from "effect" -import * as Effect from "effect/Effect" -import * as Option from "effect/Option" -import type { ParseError } from "effect/ParseResult" -import * as Schema from "effect/Schema" -import { Database, type DatabaseError, type TxFn } from "./database" -import { EntityNotFound, type EntitySchema, type Repository, type RepositoryOptions } from "./model" - -export function makeRepository< - T extends Table, - Col extends keyof InferSelectModel, - Name extends string, - RecordType extends InferSelectModel, - S extends EntitySchema, - Id extends InferSelectModel[Col], ->( - table: T, - schema: S, - options: RepositoryOptions, -): Effect.Effect, never, Database> { - return Effect.gen(function* () { - const db = yield* Database - const { idColumn } = options - - const insert = (data: S["insert"]["Type"], tx?: TxFn) => - pipe( - db.makeQueryWithSchema(schema.insert as Schema.Schema, (execute, input) => - execute((client) => client.insert(table).values([input]).returning()), - )(data, tx), - ) as unknown as Effect.Effect - - const insertVoid = (data: S["insert"]["Type"], tx?: TxFn) => - db.makeQueryWithSchema(schema.insert as Schema.Schema, (execute, input) => - execute((client) => client.insert(table).values(input)), - )(data, tx) as unknown as Effect.Effect - - const update = (data: S["update"]["Type"], tx?: TxFn) => - db.makeQueryWithSchema( - Schema.partial(schema.update as Schema.Schema), - (execute, input) => - execute((client) => - client - .update(table) - .set(input) - // @ts-expect-error - .where(eq(table[idColumn], input[idColumn])) - .returning(), - ).pipe( - Effect.flatMap((result) => - result.length > 0 - ? Effect.succeed(result[0] as RecordType) - : Effect.die(new EntityNotFound({ type: options.name, id: input[idColumn] })), - ), - ), - )(data, tx) as Effect.Effect - - const updateVoid = (data: S["update"]["Type"], tx?: TxFn) => - db.makeQueryWithSchema( - Schema.partial(schema.update as Schema.Schema), - (execute, input) => - execute((client) => - client - .update(table) - .set(input) - // @ts-expect-error - .where(eq(table[idColumn], input[idColumn])), - ), - )(data, tx) as unknown as Effect.Effect - - const findById = (id: Id, tx?: TxFn) => - db.makeQuery((execute, id: Id) => - execute((client) => - client - .select() - .from(table as Table) - // @ts-expect-error - .where(eq(table[idColumn], id)) - .limit(1), - ).pipe(Effect.map((results) => Option.fromNullable(results[0] as RecordType))), - )(id, tx) as Effect.Effect, DatabaseError> - - const deleteById = (id: Id, tx?: TxFn) => - db.makeQuery((execute, id: Id) => - // @ts-expect-error - execute((client) => client.delete(table).where(eq(table[idColumn], id))), - )(id, tx) as Effect.Effect - - const with_ = ( - id: Id, - f: (item: RecordType) => Effect.Effect, - ): Effect.Effect => - pipe( - findById(id), - Effect.flatMap( - Option.match({ - onNone: () => Effect.fail(new EntityNotFound({ type: options.name, id })), - onSome: Effect.succeed, - }), - ), - Effect.flatMap(f), - Effect.catchTag("DatabaseError", (err) => Effect.die(err)), - ) - - return { - insert, - insertVoid, - update, - updateVoid, - findById, - deleteById, - with: with_, - } - }) -} diff --git a/packages/db/src/services/model.ts b/packages/db/src/services/model.ts deleted file mode 100644 index 543316459..000000000 --- a/packages/db/src/services/model.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Re-export model utilities from domain and add db-specific Repository types. - */ -import * as Model from "@hazel/domain/models" - -export * from "@hazel/domain/models" - -import type { EntitySchema } from "@hazel/domain/models" -import type * as Effect from "effect/Effect" -import type * as Option from "effect/Option" -import type { ParseError } from "effect/ParseResult" -import * as Schema from "effect/Schema" -import type { DatabaseError, TransactionClient } from "./database" - -export interface RepositoryOptions { - idColumn: Col - name: Name -} - -export type PartialExcept = Partial> & Pick - -export class EntityNotFound extends Schema.TaggedError()("EntityNotFound", { - type: Schema.String, - id: Schema.Any, -}) {} - -export interface Repository { - readonly insert: ( - insert: S["insert"]["Type"], - tx?: (fn: (client: TransactionClient) => Promise) => Effect.Effect, - ) => Effect.Effect - - readonly insertVoid: ( - insert: S["insert"]["Type"], - tx?: (fn: (client: TransactionClient) => Promise) => Effect.Effect, - ) => Effect.Effect - - readonly update: ( - update: PartialExcept, - tx?: (fn: (client: TransactionClient) => Promise) => Effect.Effect, - ) => Effect.Effect - - readonly updateVoid: ( - update: PartialExcept, - tx?: (fn: (client: TransactionClient) => Promise) => Effect.Effect, - ) => Effect.Effect - - // readonly updateManyVoid: ( - // update: PartialExcept[] - // ) => Effect.Effect - - readonly findById: ( - id: Id, - tx?: (fn: (client: TransactionClient) => Promise) => Effect.Effect, - ) => Effect.Effect, DatabaseError> - - readonly with: ( - id: Id, - f: (item: RecordType) => Effect.Effect, - ) => Effect.Effect - - readonly deleteById: ( - id: Id, - tx?: (fn: (client: TransactionClient) => Promise) => Effect.Effect, - ) => Effect.Effect -} diff --git a/packages/db/src/services/repository.ts b/packages/db/src/services/repository.ts new file mode 100644 index 000000000..616f1275e --- /dev/null +++ b/packages/db/src/services/repository.ts @@ -0,0 +1,176 @@ +import type { InferSelectModel, Table } from "drizzle-orm" +import { eq } from "drizzle-orm" +import { pipe, Struct } from "effect" +import * as Effect from "effect/Effect" +import * as Option from "effect/Option" +import * as Schema from "effect/Schema" +import { Database, type DatabaseError, type TransactionClient, type TxFn } from "./database" + +export interface RepositoryOptions { + idColumn: Col + name: Name +} + +export type PartialExcept = Partial> & Pick + +export class EntityNotFound extends Schema.TaggedErrorClass()("EntityNotFound", { + type: Schema.String, + id: Schema.Any, +}) {} + +export interface RepositorySchemas> { + readonly insert: InsertSchema + readonly update: UpdateSchema +} + +export interface Repository< + RecordType, + InsertType, + UpdateType, + Col extends keyof UpdateType & string, + Name extends string, + Id, +> { + readonly insert: ( + insert: InsertType, + tx?: (fn: (client: TransactionClient) => Promise) => Effect.Effect, + ) => Effect.Effect + + readonly insertVoid: ( + insert: InsertType, + tx?: (fn: (client: TransactionClient) => Promise) => Effect.Effect, + ) => Effect.Effect + + readonly update: ( + update: PartialExcept, + tx?: (fn: (client: TransactionClient) => Promise) => Effect.Effect, + ) => Effect.Effect + + readonly updateVoid: ( + update: PartialExcept, + tx?: (fn: (client: TransactionClient) => Promise) => Effect.Effect, + ) => Effect.Effect + + readonly findById: ( + id: Id, + tx?: (fn: (client: TransactionClient) => Promise) => Effect.Effect, + ) => Effect.Effect, DatabaseError> + + readonly with: ( + id: Id, + f: (item: RecordType) => Effect.Effect, + ) => Effect.Effect + + readonly deleteById: ( + id: Id, + tx?: (fn: (client: TransactionClient) => Promise) => Effect.Effect, + ) => Effect.Effect +} + +export function makeRepository< + T extends Table, + InsertSchema extends Schema.Top, + UpdateSchema extends Schema.Struct, + Col extends keyof InferSelectModel & keyof UpdateSchema["Type"] & string, + Name extends string, + RecordType extends InferSelectModel, + Id extends InferSelectModel[Col], +>( + table: T, + schemas: RepositorySchemas, + options: RepositoryOptions, +): Effect.Effect< + Repository, + never, + Database +> { + return Effect.gen(function* () { + const db = yield* Database + const { idColumn } = options + const updateSchema = schemas.update.mapFields(Struct.map(Schema.optional)) + + const insert = (data: InsertSchema["Type"], tx?: TxFn) => + pipe( + db.makeQueryWithSchema(schemas.insert as Schema.Top, (execute, input: any) => + execute((client) => client.insert(table).values([input]).returning()), + )(data, tx), + ) as unknown as Effect.Effect + + const insertVoid = (data: InsertSchema["Type"], tx?: TxFn) => + db.makeQueryWithSchema(schemas.insert as Schema.Top, (execute, input: any) => + execute((client) => client.insert(table).values(input)), + )(data, tx) as unknown as Effect.Effect + + const update = (data: PartialExcept, tx?: TxFn) => + db.makeQueryWithSchema(updateSchema, (execute, input: any) => + execute((client) => + client + .update(table) + .set(input) + // @ts-expect-error drizzle column access is runtime-indexed by configured idColumn + .where(eq(table[idColumn], input[idColumn])) + .returning(), + ).pipe( + Effect.flatMap((result) => + result.length > 0 + ? Effect.succeed(result[0] as RecordType) + : Effect.die(new EntityNotFound({ type: options.name, id: input[idColumn] })), + ), + ), + )(data, tx) as Effect.Effect + + const updateVoid = (data: PartialExcept, tx?: TxFn) => + db.makeQueryWithSchema(updateSchema, (execute, input: any) => + execute((client) => + client + .update(table) + .set(input) + // @ts-expect-error drizzle column access is runtime-indexed by configured idColumn + .where(eq(table[idColumn], input[idColumn])), + ), + )(data, tx) as unknown as Effect.Effect + + const findById = (id: Id, tx?: TxFn) => + db.makeQuery((execute, inputId: Id) => + execute((client) => + client + .select() + .from(table as Table) + // @ts-expect-error drizzle column access is runtime-indexed by configured idColumn + .where(eq(table[idColumn], inputId)) + .limit(1), + ).pipe(Effect.map((results) => Option.fromNullishOr(results[0] as RecordType))), + )(id, tx) as Effect.Effect, DatabaseError> + + const deleteById = (id: Id, tx?: TxFn) => + db.makeQuery((execute, inputId: Id) => + // @ts-expect-error drizzle column access is runtime-indexed by configured idColumn + execute((client) => client.delete(table).where(eq(table[idColumn], inputId))), + )(id, tx) as Effect.Effect + + const with_ = ( + id: Id, + f: (item: RecordType) => Effect.Effect, + ): Effect.Effect => + pipe( + findById(id), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(new EntityNotFound({ type: options.name, id })), + onSome: Effect.succeed, + }), + ), + Effect.flatMap(f), + ) + + return { + insert, + insertVoid, + update, + updateVoid, + findById, + deleteById, + with: with_, + } + }) +} diff --git a/packages/domain/package.json b/packages/domain/package.json index 0adccb463..aa7b17f1f 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -15,18 +15,12 @@ "./scopes": "./src/scopes/index.ts" }, "dependencies": { - "@effect/cluster": "catalog:effect", - "@effect/experimental": "catalog:effect", - "@effect/platform": "catalog:effect", - "@effect/rpc": "catalog:effect", - "@effect/workflow": "catalog:effect", "@hazel/integrations": "workspace:*", "@hazel/schema": "workspace:*", "drizzle-orm": "^0.45.1", "effect": "catalog:effect" }, "devDependencies": { - "@effect/language-service": "catalog:effect", "@types/bun": "1.3.9", "typescript": "^5.9.3" } diff --git a/packages/domain/src/bot-gateway.ts b/packages/domain/src/bot-gateway.ts index 5e4df7b9c..acff67935 100644 --- a/packages/domain/src/bot-gateway.ts +++ b/packages/domain/src/bot-gateway.ts @@ -9,7 +9,7 @@ export const BotGatewayCommandInvokePayload = Schema.Struct({ channelId: ChannelId, userId: UserId, orgId: OrganizationId, - arguments: Schema.Record({ key: Schema.String, value: Schema.String }), + arguments: Schema.Record(Schema.String, Schema.String), timestamp: Schema.Number, }) export type BotGatewayCommandInvokePayload = Schema.Schema.Type @@ -31,52 +31,52 @@ export const BotGatewayCommandInvokeEnvelope = Schema.Struct({ export const BotGatewayMessageCreateEnvelope = Schema.Struct({ ...BaseGatewayEnvelope, eventType: Schema.Literal("message.create"), - payload: Message.Model.json, + payload: Message.Schema, }) export const BotGatewayMessageUpdateEnvelope = Schema.Struct({ ...BaseGatewayEnvelope, eventType: Schema.Literal("message.update"), - payload: Message.Model.json, + payload: Message.Schema, }) export const BotGatewayMessageDeleteEnvelope = Schema.Struct({ ...BaseGatewayEnvelope, eventType: Schema.Literal("message.delete"), - payload: Message.Model.json, + payload: Message.Schema, }) export const BotGatewayChannelCreateEnvelope = Schema.Struct({ ...BaseGatewayEnvelope, eventType: Schema.Literal("channel.create"), - payload: Channel.Model.json, + payload: Channel.Schema, }) export const BotGatewayChannelUpdateEnvelope = Schema.Struct({ ...BaseGatewayEnvelope, eventType: Schema.Literal("channel.update"), - payload: Channel.Model.json, + payload: Channel.Schema, }) export const BotGatewayChannelDeleteEnvelope = Schema.Struct({ ...BaseGatewayEnvelope, eventType: Schema.Literal("channel.delete"), - payload: Channel.Model.json, + payload: Channel.Schema, }) export const BotGatewayChannelMemberAddEnvelope = Schema.Struct({ ...BaseGatewayEnvelope, eventType: Schema.Literal("channel_member.add"), - payload: ChannelMember.Model.json, + payload: ChannelMember.Schema, }) export const BotGatewayChannelMemberRemoveEnvelope = Schema.Struct({ ...BaseGatewayEnvelope, eventType: Schema.Literal("channel_member.remove"), - payload: ChannelMember.Model.json, + payload: ChannelMember.Schema, }) -export const BotGatewayEnvelope = Schema.Union( +export const BotGatewayEnvelope = Schema.Union([ BotGatewayCommandInvokeEnvelope, BotGatewayMessageCreateEnvelope, BotGatewayMessageUpdateEnvelope, @@ -86,7 +86,7 @@ export const BotGatewayEnvelope = Schema.Union( BotGatewayChannelDeleteEnvelope, BotGatewayChannelMemberAddEnvelope, BotGatewayChannelMemberRemoveEnvelope, -) +]) export type BotGatewayEnvelope = Schema.Schema.Type export type BotGatewayEventType = BotGatewayEnvelope["eventType"] @@ -128,12 +128,12 @@ export const BotGatewayHeartbeatFrame = Schema.Struct({ sessionId: Schema.optional(Schema.String), }) -export const BotGatewayClientFrame = Schema.Union( +export const BotGatewayClientFrame = Schema.Union([ BotGatewayIdentifyFrame, BotGatewayResumeFrame, BotGatewayAckFrame, BotGatewayHeartbeatFrame, -) +]) export type BotGatewayClientFrame = Schema.Schema.Type @@ -171,13 +171,13 @@ export const BotGatewayInvalidSessionFrame = Schema.Struct({ reason: Schema.String, }) -export const BotGatewayServerFrame = Schema.Union( +export const BotGatewayServerFrame = Schema.Union([ BotGatewayHelloFrame, BotGatewayReadyFrame, BotGatewayDispatchFrame, BotGatewayHeartbeatAckFrame, BotGatewayReconnectFrame, BotGatewayInvalidSessionFrame, -) +]) export type BotGatewayServerFrame = Schema.Schema.Type diff --git a/packages/domain/src/cluster/activities/bot-activities.ts b/packages/domain/src/cluster/activities/bot-activities.ts index 9a52da747..562a675b9 100644 --- a/packages/domain/src/cluster/activities/bot-activities.ts +++ b/packages/domain/src/cluster/activities/bot-activities.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" // Error types for bot user activities -export class BotUserQueryError extends Schema.TaggedError()("BotUserQueryError", { +export class BotUserQueryError extends Schema.TaggedErrorClass()("BotUserQueryError", { provider: Schema.String, message: Schema.String, cause: Schema.Unknown.pipe(Schema.optional), diff --git a/packages/domain/src/cluster/activities/cleanup-activities.ts b/packages/domain/src/cluster/activities/cleanup-activities.ts index 92fd881a1..478645585 100644 --- a/packages/domain/src/cluster/activities/cleanup-activities.ts +++ b/packages/domain/src/cluster/activities/cleanup-activities.ts @@ -28,7 +28,7 @@ export const MarkUploadsFailedResult = Schema.Struct({ export type MarkUploadsFailedResult = typeof MarkUploadsFailedResult.Type // Error types for cleanup activities -export class FindStaleUploadsError extends Schema.TaggedError()( +export class FindStaleUploadsError extends Schema.TaggedErrorClass()( "FindStaleUploadsError", { message: Schema.String, @@ -38,7 +38,7 @@ export class FindStaleUploadsError extends Schema.TaggedError()( +export class MarkUploadsFailedError extends Schema.TaggedErrorClass()( "MarkUploadsFailedError", { message: Schema.String, @@ -52,4 +52,4 @@ export class MarkUploadsFailedError extends Schema.TaggedError // Error types for GitHub activities -export class GetGitHubSubscriptionsError extends Schema.TaggedError()( +export class GetGitHubSubscriptionsError extends Schema.TaggedErrorClass()( "GetGitHubSubscriptionsError", { repositoryId: Schema.Number, @@ -44,7 +44,7 @@ export class GetGitHubSubscriptionsError extends Schema.TaggedError()( +export class CreateGitHubMessageError extends Schema.TaggedErrorClass()( "CreateGitHubMessageError", { channelId: ChannelId, @@ -59,8 +59,8 @@ export class CreateGitHubMessageError extends Schema.TaggedError // Error types for installation activities -export class FindConnectionByInstallationError extends Schema.TaggedError()( +export class FindConnectionByInstallationError extends Schema.TaggedErrorClass()( "FindConnectionByInstallationError", { installationId: Schema.Number, @@ -38,7 +38,7 @@ export class FindConnectionByInstallationError extends Schema.TaggedError()( +export class UpdateConnectionStatusError extends Schema.TaggedErrorClass()( "UpdateConnectionStatusError", { installationId: Schema.Number, @@ -53,7 +53,7 @@ export class UpdateConnectionStatusError extends Schema.TaggedError()( +export class GetChannelMembersError extends Schema.TaggedErrorClass()( "GetChannelMembersError", { channelId: ChannelId, @@ -56,7 +56,7 @@ export class GetChannelMembersError extends Schema.TaggedError()( +export class CreateNotificationError extends Schema.TaggedErrorClass()( "CreateNotificationError", { messageId: MessageId, @@ -73,4 +73,7 @@ export class CreateNotificationError extends Schema.TaggedError // Error types for RSS activities -export class FetchRssFeedError extends Schema.TaggedError()("FetchRssFeedError", { +export class FetchRssFeedError extends Schema.TaggedErrorClass()("FetchRssFeedError", { subscriptionId: Schema.String, feedUrl: Schema.String, message: Schema.String, @@ -51,7 +51,7 @@ export class FetchRssFeedError extends Schema.TaggedError()(" readonly retryable = true } -export class PostRssItemsError extends Schema.TaggedError()("PostRssItemsError", { +export class PostRssItemsError extends Schema.TaggedErrorClass()("PostRssItemsError", { channelId: ChannelId, message: Schema.String, cause: Schema.Unknown.pipe(Schema.optional), @@ -59,7 +59,7 @@ export class PostRssItemsError extends Schema.TaggedError()(" readonly retryable = true } -export class UpdateSubscriptionStateError extends Schema.TaggedError()( +export class UpdateSubscriptionStateError extends Schema.TaggedErrorClass()( "UpdateSubscriptionStateError", { subscriptionId: Schema.String, @@ -71,9 +71,9 @@ export class UpdateSubscriptionStateError extends Schema.TaggedError()( +export class ThreadChannelNotFoundError extends Schema.TaggedErrorClass()( "ThreadChannelNotFoundError", { threadChannelId: ChannelId }, ) { @@ -51,7 +51,7 @@ export class ThreadChannelNotFoundError extends Schema.TaggedError()( +export class OriginalMessageNotFoundError extends Schema.TaggedErrorClass()( "OriginalMessageNotFoundError", { threadChannelId: ChannelId, messageId: MessageId }, ) { @@ -59,11 +59,11 @@ export class OriginalMessageNotFoundError extends Schema.TaggedError()( +export class ThreadContextQueryError extends Schema.TaggedErrorClass()( "ThreadContextQueryError", { threadChannelId: ChannelId, - operation: Schema.Literal("thread", "originalMessage", "threadMessages"), + operation: Schema.Literals(["thread", "originalMessage", "threadMessages"]), cause: Schema.Unknown.pipe(Schema.optional), }, ) { @@ -75,7 +75,7 @@ export class ThreadContextQueryError extends Schema.TaggedError()( +export class AIProviderUnavailableError extends Schema.TaggedErrorClass()( "AIProviderUnavailableError", { provider: Schema.String, cause: Schema.Unknown.pipe(Schema.optional) }, ) { @@ -83,7 +83,7 @@ export class AIProviderUnavailableError extends Schema.TaggedError()("AIRateLimitError", { +export class AIRateLimitError extends Schema.TaggedErrorClass()("AIRateLimitError", { provider: Schema.String, retryAfter: Schema.Number.pipe(Schema.optional), }) { @@ -91,10 +91,13 @@ export class AIRateLimitError extends Schema.TaggedError()("AI } /** AI response could not be parsed or was empty */ -export class AIResponseParseError extends Schema.TaggedError()("AIResponseParseError", { - threadChannelId: ChannelId, - rawResponse: Schema.String.pipe(Schema.optional), -}) { +export class AIResponseParseError extends Schema.TaggedErrorClass()( + "AIResponseParseError", + { + threadChannelId: ChannelId, + rawResponse: Schema.String.pipe(Schema.optional), + }, +) { readonly retryable = false // Bad data won't fix itself } @@ -103,7 +106,7 @@ export class AIResponseParseError extends Schema.TaggedError()( +export class ThreadNameUpdateError extends Schema.TaggedErrorClass()( "ThreadNameUpdateError", { threadChannelId: ChannelId, newName: Schema.String, cause: Schema.Unknown.pipe(Schema.optional) }, ) { @@ -114,7 +117,7 @@ export class ThreadNameUpdateError extends Schema.TaggedError("CurrentUserSchema")({ id: UserId, organizationId: S.NullishOr(OrganizationId), - role: S.Literal("admin", "member", "owner"), + role: S.Literals(["admin", "member", "owner"]), avatarUrl: S.optional(S.String), firstName: S.optional(S.String), lastName: S.optional(S.String), @@ -27,9 +27,9 @@ export class Schema extends S.Class("CurrentUserSchema")({ settings: S.NullOr(User.UserSettingsSchema), }) {} -export class Context extends C.Tag("CurrentUser")() {} +export class Context extends ServiceMap.Service()("CurrentUser") {} -const AuthFailure = S.Union( +const AuthFailure = S.Union([ UnauthorizedError, SessionLoadError, SessionAuthenticationError, @@ -39,11 +39,15 @@ const AuthFailure = S.Union( SessionExpiredError, InvalidBearerTokenError, WorkOSUserFetchError, -) +]) -export class Authorization extends HttpApiMiddleware.Tag()("Authorization", { - failure: AuthFailure, - provides: Context, +export class Authorization extends HttpApiMiddleware.Service< + Authorization, + { + provides: Context + } +>()("Authorization", { + error: AuthFailure, security: { bearer: HttpApiSecurity.bearer, }, diff --git a/packages/domain/src/desktop-auth-errors.ts b/packages/domain/src/desktop-auth-errors.ts index f4c00e503..2c92f94b9 100644 --- a/packages/domain/src/desktop-auth-errors.ts +++ b/packages/domain/src/desktop-auth-errors.ts @@ -12,18 +12,18 @@ import { Schema } from "effect" /** * Tauri API not available in the current environment */ -export class TauriNotAvailableError extends Schema.TaggedError()( +export class TauriNotAvailableError extends Schema.TaggedErrorClass()( "TauriNotAvailableError", { message: Schema.String, - component: Schema.Literal("opener", "core", "event", "store"), + component: Schema.Literals(["opener", "core", "event", "store"]), }, ) {} /** * A Tauri command invocation failed */ -export class TauriCommandError extends Schema.TaggedError()("TauriCommandError", { +export class TauriCommandError extends Schema.TaggedErrorClass()("TauriCommandError", { message: Schema.String, command: Schema.String, detail: Schema.optional(Schema.String), @@ -36,14 +36,14 @@ export class TauriCommandError extends Schema.TaggedError()(" /** * OAuth callback timed out waiting for user to complete authentication */ -export class OAuthTimeoutError extends Schema.TaggedError()("OAuthTimeoutError", { +export class OAuthTimeoutError extends Schema.TaggedErrorClass()("OAuthTimeoutError", { message: Schema.String, }) {} /** * OAuth provider returned an error during authentication */ -export class OAuthCallbackError extends Schema.TaggedError()("OAuthCallbackError", { +export class OAuthCallbackError extends Schema.TaggedErrorClass()("OAuthCallbackError", { message: Schema.String, error: Schema.String, errorDescription: Schema.optional(Schema.String), @@ -52,9 +52,12 @@ export class OAuthCallbackError extends Schema.TaggedError() /** * No authorization code was received from OAuth callback */ -export class MissingAuthCodeError extends Schema.TaggedError()("MissingAuthCodeError", { - message: Schema.String, -}) {} +export class MissingAuthCodeError extends Schema.TaggedErrorClass()( + "MissingAuthCodeError", + { + message: Schema.String, + }, +) {} // ============================================================================ // Token Storage Errors @@ -63,18 +66,18 @@ export class MissingAuthCodeError extends Schema.TaggedError()("TokenStoreError", { +export class TokenStoreError extends Schema.TaggedErrorClass()("TokenStoreError", { message: Schema.String, - operation: Schema.Literal("load", "get", "set", "delete"), + operation: Schema.Literals(["load", "get", "set", "delete"]), detail: Schema.optional(Schema.String), }) {} /** * A required token was not found in the store */ -export class TokenNotFoundError extends Schema.TaggedError()("TokenNotFoundError", { +export class TokenNotFoundError extends Schema.TaggedErrorClass()("TokenNotFoundError", { message: Schema.String, - tokenType: Schema.Literal("access", "refresh", "expiresAt"), + tokenType: Schema.Literals(["access", "refresh", "expiresAt"]), }) {} // ============================================================================ @@ -84,7 +87,7 @@ export class TokenNotFoundError extends Schema.TaggedError() /** * Failed to exchange authorization code for tokens */ -export class TokenExchangeError extends Schema.TaggedError()("TokenExchangeError", { +export class TokenExchangeError extends Schema.TaggedErrorClass()("TokenExchangeError", { message: Schema.String, detail: Schema.optional(Schema.String), }) {} @@ -92,7 +95,7 @@ export class TokenExchangeError extends Schema.TaggedError() /** * Failed to decode token response from server */ -export class TokenDecodeError extends Schema.TaggedError()("TokenDecodeError", { +export class TokenDecodeError extends Schema.TaggedErrorClass()("TokenDecodeError", { message: Schema.String, detail: Schema.optional(Schema.String), }) {} @@ -104,7 +107,7 @@ export class TokenDecodeError extends Schema.TaggedError()("To /** * Failed to connect to the desktop app's local OAuth server */ -export class DesktopConnectionError extends Schema.TaggedError()( +export class DesktopConnectionError extends Schema.TaggedErrorClass()( "DesktopConnectionError", { message: Schema.String, @@ -116,7 +119,7 @@ export class DesktopConnectionError extends Schema.TaggedError()( +export class InvalidDesktopStateError extends Schema.TaggedErrorClass()( "InvalidDesktopStateError", { message: Schema.String, diff --git a/packages/domain/src/error-utils.ts b/packages/domain/src/error-utils.ts index 3f1c86951..f0381ee65 100644 --- a/packages/domain/src/error-utils.ts +++ b/packages/domain/src/error-utils.ts @@ -26,7 +26,7 @@ export const refailUnauthorized = (entity: string, action: string) => { effect, (e) => !UnauthorizedError.is(e), (e) => - Effect.flatMap(CurrentUser.Context, (actor) => { + CurrentUser.Context.use((actor) => { // Convert PermissionError to UnauthorizedError with scope info if (PermissionError.is(e)) { return Effect.fail( diff --git a/packages/domain/src/errors.ts b/packages/domain/src/errors.ts index 88852eddb..2d21e9845 100644 --- a/packages/domain/src/errors.ts +++ b/packages/domain/src/errors.ts @@ -1,16 +1,13 @@ -import { HttpApiSchema } from "@effect/platform" -import { Effect, Predicate, Schema } from "effect" +import { Effect, Predicate, Schema, SchemaIssue } from "effect" import { ChannelId, MessageId } from "@hazel/schema" -export class UnauthorizedError extends Schema.TaggedError("UnauthorizedError")( +export class UnauthorizedError extends Schema.TaggedErrorClass("UnauthorizedError")( "UnauthorizedError", { message: Schema.String, detail: Schema.String, }, - HttpApiSchema.annotations({ - status: 401, - }), + { httpApiStatus: 401 }, ) { static is(u: unknown): u is UnauthorizedError { return Predicate.isTagged(u, "UnauthorizedError") @@ -21,33 +18,55 @@ export class UnauthorizedError extends Schema.TaggedError("Un * Error thrown when an OAuth authorization code has expired or has already been used. * This is a specific 401 error that indicates the user must restart the OAuth flow. */ -export class OAuthCodeExpiredError extends Schema.TaggedError("OAuthCodeExpiredError")( +export class OAuthCodeExpiredError extends Schema.TaggedErrorClass( + "OAuthCodeExpiredError", +)( "OAuthCodeExpiredError", { message: Schema.String, }, - HttpApiSchema.annotations({ - status: 401, - }), + { httpApiStatus: 401 }, ) { static is(u: unknown): u is OAuthCodeExpiredError { return Predicate.isTagged(u, "OAuthCodeExpiredError") } } -export class InternalServerError extends Schema.TaggedError("InternalServerError")( +export class OAuthStateMismatchError extends Schema.TaggedErrorClass()( + "OAuthStateMismatchError", + { + message: Schema.String, + }, + { httpApiStatus: 400 }, +) { + static is(u: unknown): u is OAuthStateMismatchError { + return Predicate.isTagged(u, "OAuthStateMismatchError") + } +} + +export class OAuthRedemptionPendingError extends Schema.TaggedErrorClass()( + "OAuthRedemptionPendingError", + { + message: Schema.String, + }, + { httpApiStatus: 503 }, +) { + static is(u: unknown): u is OAuthRedemptionPendingError { + return Predicate.isTagged(u, "OAuthRedemptionPendingError") + } +} + +export class InternalServerError extends Schema.TaggedErrorClass("InternalServerError")( "InternalServerError", { message: Schema.String, detail: Schema.optional(Schema.String), cause: Schema.optional(Schema.Any), }, - HttpApiSchema.annotations({ - status: 500, - }), + { httpApiStatus: 500 }, ) {} -export class WorkflowInitializationError extends Schema.TaggedError( +export class WorkflowInitializationError extends Schema.TaggedErrorClass( "WorkflowInitializationError", )( "WorkflowInitializationError", @@ -55,12 +74,10 @@ export class WorkflowInitializationError extends Schema.TaggedError( +export class DmChannelAlreadyExistsError extends Schema.TaggedErrorClass( "DmChannelAlreadyExistsError", )( "DmChannelAlreadyExistsError", @@ -68,99 +85,84 @@ export class DmChannelAlreadyExistsError extends Schema.TaggedError("MessageNotFoundError")( +export class MessageNotFoundError extends Schema.TaggedErrorClass( + "MessageNotFoundError", +)( "MessageNotFoundError", { messageId: MessageId, }, - HttpApiSchema.annotations({ - status: 404, - }), + { httpApiStatus: 404 }, ) {} /** * Error thrown when attempting to create a thread within a thread. * Nested threads are not supported. */ -export class NestedThreadError extends Schema.TaggedError("NestedThreadError")( +export class NestedThreadError extends Schema.TaggedErrorClass("NestedThreadError")( "NestedThreadError", { channelId: ChannelId, }, - HttpApiSchema.annotations({ - status: 400, - }), + { httpApiStatus: 400 }, ) {} /** * Error thrown when the workflow service is unreachable or unavailable. * Used when the cluster service cannot be contacted. */ -export class WorkflowServiceUnavailableError extends Schema.TaggedError( +export class WorkflowServiceUnavailableError extends Schema.TaggedErrorClass( "WorkflowServiceUnavailableError", )( "WorkflowServiceUnavailableError", { message: Schema.String, - cause: Schema.optionalWith(Schema.String, { nullable: true }), + cause: Schema.optional(Schema.NullOr(Schema.String)), }, - HttpApiSchema.annotations({ - status: 503, - }), + { httpApiStatus: 503 }, ) {} export function withRemapDbErrors( entityType: string, action: "update" | "create" | "delete" | "select", - entityId?: any | { value: any; key: string }[], + entityId?: unknown | { value: unknown; key: string }[], ) { return ( effect: Effect.Effect, - ): Effect.Effect | InternalServerError, A> => { + ): Effect.Effect | InternalServerError, A> => { + const toInternalError = (err: unknown, detailPrefix: string) => + Effect.fail( + new InternalServerError({ + message: `Error ${action}ing ${entityType}`, + detail: constructDetailMessage(detailPrefix, entityType, entityId), + cause: String(err), + }), + ) + return effect.pipe( - Effect.catchTags({ - DatabaseError: (err: any) => - Effect.fail( - new InternalServerError({ - message: `Error ${action}ing ${entityType}`, - detail: constructDetailMessage( - "There was an error in parsing when", - entityType, - entityId, - ), - cause: String(err), - }), - ), - ParseError: (err: any) => - Effect.fail( - new InternalServerError({ - message: `Error ${action}ing ${entityType}`, - detail: constructDetailMessage( - "There was an error in parsing when", - entityType, - entityId, - ), - cause: String(err), - }), - ), - }), - ) + Effect.catchIf( + (e): e is Extract => Predicate.isTagged(e, "DatabaseError"), + (err) => toInternalError(err, "There was a database error when"), + ), + Effect.catchIf( + (e): e is Extract => Predicate.isTagged(e, "SchemaError"), + (err) => toInternalError(err, "There was an error in parsing when"), + ), + ) as Effect.Effect | InternalServerError, A> } } const constructDetailMessage = ( title: string, entityType: string, - entityId?: any | { value: any; key: string }[], + entityId?: unknown | { value: unknown; key: string }[], ) => { if (entityId) { if (Array.isArray(entityId)) { diff --git a/packages/domain/src/http/api-v1/messages.ts b/packages/domain/src/http/api-v1/messages.ts index 058038e12..20de1a669 100644 --- a/packages/domain/src/http/api-v1/messages.ts +++ b/packages/domain/src/http/api-v1/messages.ts @@ -1,4 +1,4 @@ -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "@effect/platform" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" import { InternalServerError, MessageNotFoundError, UnauthorizedError } from "../../errors" import { AttachmentId, ChannelId, MessageId } from "@hazel/schema" @@ -18,16 +18,16 @@ export class ListMessagesQuery extends Schema.Class("ListMess ending_before: Schema.optional(MessageId), /** Maximum number of messages to return (1-100, default 25) */ limit: Schema.optional( - Schema.NumberFromString.pipe( - Schema.int(), - Schema.greaterThanOrEqualTo(1), - Schema.lessThanOrEqualTo(100), + Schema.NumberFromString.check( + Schema.isInt(), + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(100), ), ), }) {} export class ListMessagesResponse extends Schema.Class("ListMessagesResponse")({ - data: Schema.Array(Message.Model.json), + data: Schema.Array(Message.Schema as any), has_more: Schema.Boolean, }) {} @@ -55,7 +55,7 @@ export class ToggleReactionRequest extends Schema.Class(" // ============ RESPONSE SCHEMAS ============ export class MessageResponse extends Schema.Class("MessageResponse")({ - data: Message.Model.json, + data: Message.Schema as any, transactionId: TransactionId, }) {} @@ -65,26 +65,26 @@ export class DeleteMessageResponse extends Schema.Class(" export class ToggleReactionResponse extends Schema.Class("ToggleReactionResponse")({ wasCreated: Schema.Boolean, - data: Schema.optional(MessageReaction.Model.json), + data: Schema.optional(MessageReaction.Schema as any), transactionId: TransactionId, }) {} // ============ ERROR TYPES ============ -export class ChannelNotFoundError extends Schema.TaggedError()( +export class ChannelNotFoundError extends Schema.TaggedErrorClass()( "ChannelNotFoundError", { channelId: ChannelId, }, - HttpApiSchema.annotations({ status: 404 }), + { httpApiStatus: 404 }, ) {} -export class InvalidPaginationError extends Schema.TaggedError()( +export class InvalidPaginationError extends Schema.TaggedErrorClass()( "InvalidPaginationError", { message: Schema.String, }, - HttpApiSchema.annotations({ status: 400 }), + { httpApiStatus: 400 }, ) {} // ============ API GROUP ============ @@ -92,14 +92,12 @@ export class InvalidPaginationError extends Schema.TaggedError("TokenRequest")({ state: Schema.String, }) {} +export class AuthRequestHeaders extends Schema.Class("AuthRequestHeaders")({ + "x-auth-attempt-id": Schema.optional(Schema.String), +}) {} + export class TokenResponse extends Schema.Class("TokenResponse")({ accessToken: Schema.String, refreshToken: Schema.String, @@ -58,17 +68,16 @@ export class DesktopAuthState extends Schema.Class("DesktopAut export class AuthGroup extends HttpApiGroup.make("auth") .add( - HttpApiEndpoint.get("login")`/login` - .addSuccess(LoginResponse) - .addError(InternalServerError) - .setUrlParams( - Schema.Struct({ - returnTo: Schema.String, - organizationId: Schema.optional(OrganizationId), - invitationToken: Schema.optional(Schema.String), - }), - ) - .annotateContext( + HttpApiEndpoint.get("login", "/login", { + query: { + returnTo: Schema.String, + organizationId: Schema.optional(OrganizationId), + invitationToken: Schema.optional(Schema.String), + }, + success: LoginResponse, + error: InternalServerError, + }) + .annotateMerge( OpenApi.annotations({ title: "Login", description: "Get WorkOS authorization URL for authentication", @@ -78,17 +87,15 @@ export class AuthGroup extends HttpApiGroup.make("auth") .annotate(RequiredScopes, []), ) .add( - HttpApiEndpoint.get("callback")`/callback` - .addSuccess(Schema.Void, { status: 302 }) - .addError(UnauthorizedError) - .addError(InternalServerError) - .setUrlParams( - Schema.Struct({ - code: Schema.String, - state: Schema.String, - }), - ) - .annotateContext( + HttpApiEndpoint.get("callback", "/callback", { + query: { + code: Schema.String, + state: Schema.String, + }, + success: Schema.Void.pipe(HttpApiSchema.status(302)), + error: [UnauthorizedError, InternalServerError], + }) + .annotateMerge( OpenApi.annotations({ title: "OAuth Callback", description: "Handle OAuth callback from WorkOS and set session cookie", @@ -98,15 +105,14 @@ export class AuthGroup extends HttpApiGroup.make("auth") .annotate(RequiredScopes, []), ) .add( - HttpApiEndpoint.get("logout")`/logout` - .addSuccess(Schema.Void) - .addError(InternalServerError) - .setUrlParams( - Schema.Struct({ - redirectTo: Schema.optional(Schema.String), - }), - ) - .annotateContext( + HttpApiEndpoint.get("logout", "/logout", { + query: { + redirectTo: Schema.optional(Schema.String), + }, + success: Schema.Void, + error: InternalServerError, + }) + .annotateMerge( OpenApi.annotations({ title: "Logout", description: "Clear session and logout user", @@ -116,19 +122,18 @@ export class AuthGroup extends HttpApiGroup.make("auth") .annotate(RequiredScopes, []), ) .add( - HttpApiEndpoint.get("loginDesktop")`/login/desktop` - .addSuccess(Schema.Void, { status: 302 }) - .addError(InternalServerError) - .setUrlParams( - Schema.Struct({ - returnTo: Schema.String, - desktopPort: Schema.NumberFromString, - desktopNonce: Schema.String, - organizationId: Schema.optional(OrganizationId), - invitationToken: Schema.optional(Schema.String), - }), - ) - .annotateContext( + HttpApiEndpoint.get("loginDesktop", "/login/desktop", { + query: { + returnTo: Schema.String, + desktopPort: Schema.NumberFromString, + desktopNonce: Schema.String, + organizationId: Schema.optional(OrganizationId), + invitationToken: Schema.optional(Schema.String), + }, + success: Schema.Void.pipe(HttpApiSchema.status(302)), + error: InternalServerError, + }) + .annotateMerge( OpenApi.annotations({ title: "Desktop Login", description: "Initiate OAuth flow for desktop apps with web callback", @@ -138,13 +143,19 @@ export class AuthGroup extends HttpApiGroup.make("auth") .annotate(RequiredScopes, []), ) .add( - HttpApiEndpoint.post("token")`/token` - .addSuccess(TokenResponse) - .addError(UnauthorizedError) - .addError(OAuthCodeExpiredError) - .addError(InternalServerError) - .setPayload(TokenRequest) - .annotateContext( + HttpApiEndpoint.post("token", "/token", { + headers: AuthRequestHeaders, + payload: TokenRequest, + success: TokenResponse, + error: [ + UnauthorizedError, + OAuthCodeExpiredError, + OAuthStateMismatchError, + OAuthRedemptionPendingError, + InternalServerError, + ], + }) + .annotateMerge( OpenApi.annotations({ title: "Token Exchange", description: "Exchange authorization code for access token (desktop apps)", @@ -154,12 +165,13 @@ export class AuthGroup extends HttpApiGroup.make("auth") .annotate(RequiredScopes, []), ) .add( - HttpApiEndpoint.post("refresh")`/refresh` - .addSuccess(RefreshTokenResponse) - .addError(UnauthorizedError) - .addError(InternalServerError) - .setPayload(RefreshTokenRequest) - .annotateContext( + HttpApiEndpoint.post("refresh", "/refresh", { + headers: AuthRequestHeaders, + payload: RefreshTokenRequest, + success: RefreshTokenResponse, + error: [UnauthorizedError, InternalServerError], + }) + .annotateMerge( OpenApi.annotations({ title: "Refresh Token", description: "Exchange refresh token for new access token (desktop apps)", diff --git a/packages/domain/src/http/bot-commands.ts b/packages/domain/src/http/bot-commands.ts index 0805f4380..994259fe8 100644 --- a/packages/domain/src/http/bot-commands.ts +++ b/packages/domain/src/http/bot-commands.ts @@ -1,4 +1,4 @@ -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "@effect/platform" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" import * as CurrentUser from "../current-user" import { InternalServerError, UnauthorizedError } from "../errors" @@ -18,7 +18,7 @@ export const BotCommandArgumentSchema = Schema.Struct({ description: Schema.NullishOr(Schema.String), required: Schema.Boolean, placeholder: Schema.NullishOr(Schema.String), - type: Schema.Literal("string", "number", "user", "channel"), + type: Schema.Literals(["string", "number", "user", "channel"]), }) export type BotCommandArgumentSchema = typeof BotCommandArgumentSchema.Type @@ -65,16 +65,19 @@ export class BotMeResponse extends Schema.Class("BotMeResponse")( // ============ ERROR TYPES ============ -export class BotNotFoundError extends Schema.TaggedError()("BotNotFoundError", { +export class BotNotFoundError extends Schema.TaggedErrorClass()("BotNotFoundError", { botId: BotId, }) {} -export class BotNotInstalledError extends Schema.TaggedError()("BotNotInstalledError", { - botId: BotId, - orgId: OrganizationId, -}) {} +export class BotNotInstalledError extends Schema.TaggedErrorClass()( + "BotNotInstalledError", + { + botId: BotId, + orgId: OrganizationId, + }, +) {} -export class BotCommandNotFoundError extends Schema.TaggedError()( +export class BotCommandNotFoundError extends Schema.TaggedErrorClass()( "BotCommandNotFoundError", { botId: BotId, @@ -82,7 +85,7 @@ export class BotCommandNotFoundError extends Schema.TaggedError()( +export class BotCommandExecutionError extends Schema.TaggedErrorClass()( "BotCommandExecutionError", { commandName: Schema.String, @@ -99,7 +102,7 @@ export class IntegrationTokenResponse extends Schema.Class( @@ -120,7 +123,7 @@ export class UpdateBotSettingsResponse extends Schema.Class()( +export class IntegrationNotAllowedError extends Schema.TaggedErrorClass()( "IntegrationNotAllowedError", { botId: BotId, @@ -134,9 +137,10 @@ export class BotCommandsApiGroup extends HttpApiGroup.make("bot-commands") // SSE stream for bot commands (bot token auth) // This endpoint uses bot token authentication, not user auth .add( - HttpApiEndpoint.get("streamCommands", `/stream`) - .addError(UnauthorizedError) - .annotateContext( + HttpApiEndpoint.get("streamCommands", `/stream`, { + error: UnauthorizedError, + }) + .annotateMerge( OpenApi.annotations({ title: "Stream Bot Commands", description: "SSE stream for receiving bot commands (used by Bot SDK)", @@ -148,10 +152,11 @@ export class BotCommandsApiGroup extends HttpApiGroup.make("bot-commands") // Get current bot info (for bot token validation) // This endpoint uses bot token authentication, not user auth .add( - HttpApiEndpoint.get("getBotMe", `/me`) - .addSuccess(BotMeResponse) - .addError(UnauthorizedError) - .annotateContext( + HttpApiEndpoint.get("getBotMe", `/me`, { + success: BotMeResponse, + error: UnauthorizedError, + }) + .annotateMerge( OpenApi.annotations({ title: "Get Bot Info", description: "Get current bot info from token (used by Bot SDK for authentication)", @@ -163,13 +168,12 @@ export class BotCommandsApiGroup extends HttpApiGroup.make("bot-commands") // Sync commands from bot (called by Bot SDK on startup) // This endpoint uses bot token authentication, not user auth .add( - HttpApiEndpoint.post("syncCommands", `/sync`) - .addSuccess(SyncBotCommandsResponse) - .addError(BotNotFoundError) - .addError(UnauthorizedError) - .addError(InternalServerError) - .setPayload(SyncBotCommandsRequest) - .annotateContext( + HttpApiEndpoint.post("syncCommands", `/sync`, { + payload: SyncBotCommandsRequest, + success: SyncBotCommandsResponse, + error: [BotNotFoundError, UnauthorizedError, InternalServerError], + }) + .annotateMerge( OpenApi.annotations({ title: "Sync Bot Commands", description: "Sync slash commands from a bot (called by Bot SDK on startup)", @@ -180,23 +184,24 @@ export class BotCommandsApiGroup extends HttpApiGroup.make("bot-commands") ) // Execute a bot command (frontend calls this - requires user auth) .add( - HttpApiEndpoint.post("executeBotCommand", `/:orgId/bots/:botId/commands/:commandName/execute`) - .addSuccess(BotCommandExecutionAccepted) - .addError(BotNotFoundError) - .addError(BotNotInstalledError) - .addError(BotCommandNotFoundError) - .addError(BotCommandExecutionError) - .addError(UnauthorizedError) - .addError(InternalServerError) - .setPath( - Schema.Struct({ - orgId: OrganizationId, - botId: BotId, - commandName: Schema.String, - }), - ) - .setPayload(ExecuteBotCommandRequest) - .annotateContext( + HttpApiEndpoint.post("executeBotCommand", `/:orgId/bots/:botId/commands/:commandName/execute`, { + params: { + orgId: OrganizationId, + botId: BotId, + commandName: Schema.String, + }, + payload: ExecuteBotCommandRequest, + success: BotCommandExecutionAccepted, + error: [ + BotNotFoundError, + BotNotInstalledError, + BotCommandNotFoundError, + BotCommandExecutionError, + UnauthorizedError, + InternalServerError, + ], + }) + .annotateMerge( OpenApi.annotations({ title: "Execute Bot Command", description: "Execute a slash command for a bot", @@ -209,20 +214,21 @@ export class BotCommandsApiGroup extends HttpApiGroup.make("bot-commands") // Get integration token (bot token auth) // Bot must have the provider in its allowedIntegrations and be installed in the org .add( - HttpApiEndpoint.get("getIntegrationToken", `/integrations/:orgId/:provider/token`) - .addSuccess(IntegrationTokenResponse) - .addError(UnauthorizedError) - .addError(BotNotInstalledError) - .addError(IntegrationNotConnectedError) - .addError(IntegrationNotAllowedError) - .addError(InternalServerError) - .setPath( - Schema.Struct({ - orgId: OrganizationId, - provider: IntegrationProvider, - }), - ) - .annotateContext( + HttpApiEndpoint.get("getIntegrationToken", `/integrations/:orgId/:provider/token`, { + params: { + orgId: OrganizationId, + provider: IntegrationProvider, + }, + success: IntegrationTokenResponse, + error: [ + UnauthorizedError, + BotNotInstalledError, + IntegrationNotConnectedError, + IntegrationNotAllowedError, + InternalServerError, + ], + }) + .annotateMerge( OpenApi.annotations({ title: "Get Integration Token", description: @@ -235,17 +241,14 @@ export class BotCommandsApiGroup extends HttpApiGroup.make("bot-commands") // Get enabled integrations (bot token auth) // Returns the intersection of bot's allowedIntegrations and org's active connections .add( - HttpApiEndpoint.get("getEnabledIntegrations", `/integrations/:orgId/enabled`) - .addSuccess(EnabledIntegrationsResponse) - .addError(UnauthorizedError) - .addError(BotNotInstalledError) - .addError(InternalServerError) - .setPath( - Schema.Struct({ - orgId: OrganizationId, - }), - ) - .annotateContext( + HttpApiEndpoint.get("getEnabledIntegrations", `/integrations/:orgId/enabled`, { + params: { + orgId: OrganizationId, + }, + success: EnabledIntegrationsResponse, + error: [UnauthorizedError, BotNotInstalledError, InternalServerError], + }) + .annotateMerge( OpenApi.annotations({ title: "Get Enabled Integrations", description: @@ -258,12 +261,12 @@ export class BotCommandsApiGroup extends HttpApiGroup.make("bot-commands") // Update bot settings (bot token auth) // Allows bots to update their own settings like mentionable flag .add( - HttpApiEndpoint.patch("updateBotSettings", `/settings`) - .addSuccess(UpdateBotSettingsResponse) - .addError(UnauthorizedError) - .addError(InternalServerError) - .setPayload(UpdateBotSettingsRequest) - .annotateContext( + HttpApiEndpoint.patch("updateBotSettings", `/settings`, { + payload: UpdateBotSettingsRequest, + success: UpdateBotSettingsResponse, + error: [UnauthorizedError, InternalServerError], + }) + .annotateMerge( OpenApi.annotations({ title: "Update Bot Settings", description: diff --git a/packages/domain/src/http/chat-sync.ts b/packages/domain/src/http/chat-sync.ts index 0ca641406..b58167e31 100644 --- a/packages/domain/src/http/chat-sync.ts +++ b/packages/domain/src/http/chat-sync.ts @@ -1,4 +1,4 @@ -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "@effect/platform" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { ChannelId, ExternalChannelId, @@ -17,48 +17,48 @@ import { RequiredScopes } from "../scopes/required-scopes" export class ChatSyncConnectionResponse extends Schema.Class( "ChatSyncConnectionResponse", )({ - data: ChatSyncConnection.Model.json, + data: ChatSyncConnection.Schema as any, transactionId: TransactionId, }) {} export class ChatSyncConnectionListResponse extends Schema.Class( "ChatSyncConnectionListResponse", )({ - data: Schema.Array(ChatSyncConnection.Model.json), + data: Schema.Array(ChatSyncConnection.Schema as any), }) {} export class ChatSyncChannelLinkResponse extends Schema.Class( "ChatSyncChannelLinkResponse", )({ - data: ChatSyncChannelLink.Model.json, + data: ChatSyncChannelLink.Schema as any, transactionId: TransactionId, }) {} export class ChatSyncChannelLinkListResponse extends Schema.Class( "ChatSyncChannelLinkListResponse", )({ - data: Schema.Array(ChatSyncChannelLink.Model.json), + data: Schema.Array(ChatSyncChannelLink.Schema as any), }) {} export class ChatSyncDeleteResponse extends Schema.Class("ChatSyncDeleteResponse")({ transactionId: TransactionId, }) {} -export class ChatSyncConnectionNotFoundError extends Schema.TaggedError()( +export class ChatSyncConnectionNotFoundError extends Schema.TaggedErrorClass()( "ChatSyncConnectionNotFoundError", { syncConnectionId: SyncConnectionId, }, ) {} -export class ChatSyncChannelLinkNotFoundError extends Schema.TaggedError()( +export class ChatSyncChannelLinkNotFoundError extends Schema.TaggedErrorClass()( "ChatSyncChannelLinkNotFoundError", { syncChannelLinkId: SyncChannelLinkId, }, ) {} -export class ChatSyncConnectionExistsError extends Schema.TaggedError()( +export class ChatSyncConnectionExistsError extends Schema.TaggedErrorClass()( "ChatSyncConnectionExistsError", { organizationId: OrganizationId, @@ -67,7 +67,7 @@ export class ChatSyncConnectionExistsError extends Schema.TaggedError()( +export class ChatSyncIntegrationNotConnectedError extends Schema.TaggedErrorClass()( "ChatSyncIntegrationNotConnectedError", { organizationId: OrganizationId, @@ -75,7 +75,7 @@ export class ChatSyncIntegrationNotConnectedError extends Schema.TaggedError()( +export class ChatSyncChannelLinkExistsError extends Schema.TaggedErrorClass()( "ChatSyncChannelLinkExistsError", { syncConnectionId: SyncConnectionId, @@ -91,8 +91,8 @@ export class CreateChatSyncConnectionRequest extends Schema.Class( @@ -102,20 +102,23 @@ export class CreateChatSyncChannelLinkRequest extends Schema.Class // OpenStatus status type -export const OpenStatusStatus = Schema.Literal("degraded", "error", "recovered") +export const OpenStatusStatus = Schema.Literals(["degraded", "error", "recovered"]) export type OpenStatusStatus = Schema.Schema.Type // OpenStatus webhook payload @@ -100,49 +100,50 @@ export class WebhookMessageResponse extends Schema.Class }) {} // Error: Webhook not found -export class WebhookNotFoundError extends Schema.TaggedError()( +export class WebhookNotFoundError extends Schema.TaggedErrorClass()( "WebhookNotFoundError", { message: Schema.String, }, - HttpApiSchema.annotations({ status: 404 }), + { httpApiStatus: 404 }, ) {} // Error: Webhook is disabled -export class WebhookDisabledError extends Schema.TaggedError()( +export class WebhookDisabledError extends Schema.TaggedErrorClass()( "WebhookDisabledError", { message: Schema.String, }, - HttpApiSchema.annotations({ status: 403 }), + { httpApiStatus: 403 }, ) {} // Error: Invalid webhook token -export class InvalidWebhookTokenError extends Schema.TaggedError()( +export class InvalidWebhookTokenError extends Schema.TaggedErrorClass()( "InvalidWebhookTokenError", { message: Schema.String, }, - HttpApiSchema.annotations({ status: 401 }), + { httpApiStatus: 401 }, ) {} // Public endpoint - no auth middleware, uses webhook token in URL export class IncomingWebhookGroup extends HttpApiGroup.make("incoming-webhooks") .add( - HttpApiEndpoint.post("execute", `/:webhookId/:token`) - .setPayload(IncomingWebhookPayload) - .addSuccess(WebhookMessageResponse) - .addError(WebhookNotFoundError) - .addError(WebhookDisabledError) - .addError(InvalidWebhookTokenError) - .addError(InternalServerError) - .setPath( - Schema.Struct({ - webhookId: ChannelWebhookId, - token: Schema.String, - }), - ) - .annotateContext( + HttpApiEndpoint.post("execute", `/:webhookId/:token`, { + params: { + webhookId: ChannelWebhookId, + token: Schema.String, + }, + payload: IncomingWebhookPayload, + success: WebhookMessageResponse, + error: [ + WebhookNotFoundError, + WebhookDisabledError, + InvalidWebhookTokenError, + InternalServerError, + ], + }) + .annotateMerge( OpenApi.annotations({ title: "Execute Incoming Webhook", description: @@ -153,20 +154,21 @@ export class IncomingWebhookGroup extends HttpApiGroup.make("incoming-webhooks") .annotate(RequiredScopes, []), ) .add( - HttpApiEndpoint.post("executeOpenStatus", `/:webhookId/:token/openstatus`) - .setPayload(OpenStatusPayload) - .addSuccess(WebhookMessageResponse) - .addError(WebhookNotFoundError) - .addError(WebhookDisabledError) - .addError(InvalidWebhookTokenError) - .addError(InternalServerError) - .setPath( - Schema.Struct({ - webhookId: ChannelWebhookId, - token: Schema.String, - }), - ) - .annotateContext( + HttpApiEndpoint.post("executeOpenStatus", `/:webhookId/:token/openstatus`, { + params: { + webhookId: ChannelWebhookId, + token: Schema.String, + }, + payload: OpenStatusPayload, + success: WebhookMessageResponse, + error: [ + WebhookNotFoundError, + WebhookDisabledError, + InvalidWebhookTokenError, + InternalServerError, + ], + }) + .annotateMerge( OpenApi.annotations({ title: "Execute OpenStatus Webhook", description: @@ -177,20 +179,21 @@ export class IncomingWebhookGroup extends HttpApiGroup.make("incoming-webhooks") .annotate(RequiredScopes, []), ) .add( - HttpApiEndpoint.post("executeRailway", `/:webhookId/:token/railway`) - .setPayload(RailwayPayload) - .addSuccess(WebhookMessageResponse) - .addError(WebhookNotFoundError) - .addError(WebhookDisabledError) - .addError(InvalidWebhookTokenError) - .addError(InternalServerError) - .setPath( - Schema.Struct({ - webhookId: ChannelWebhookId, - token: Schema.String, - }), - ) - .annotateContext( + HttpApiEndpoint.post("executeRailway", `/:webhookId/:token/railway`, { + params: { + webhookId: ChannelWebhookId, + token: Schema.String, + }, + payload: RailwayPayload, + success: WebhookMessageResponse, + error: [ + WebhookNotFoundError, + WebhookDisabledError, + InvalidWebhookTokenError, + InternalServerError, + ], + }) + .annotateMerge( OpenApi.annotations({ title: "Execute Railway Webhook", description: diff --git a/packages/domain/src/http/integration-commands.ts b/packages/domain/src/http/integration-commands.ts index b25529632..5b5efdaec 100644 --- a/packages/domain/src/http/integration-commands.ts +++ b/packages/domain/src/http/integration-commands.ts @@ -1,4 +1,4 @@ -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "@effect/platform" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" import * as CurrentUser from "../current-user" import { InternalServerError, UnauthorizedError } from "../errors" @@ -25,7 +25,7 @@ export const CommandArgumentSchema = Schema.Struct({ description: Schema.NullishOr(Schema.String), required: Schema.Boolean, placeholder: Schema.NullishOr(Schema.String), - type: Schema.Literal("string", "number", "user", "channel"), + type: Schema.Literals(["string", "number", "user", "channel"]), }) export type CommandArgumentSchema = typeof CommandArgumentSchema.Type @@ -60,16 +60,12 @@ export class AvailableCommandsResponse extends Schema.Class()( +export class IntegrationNotConnectedForPreviewError extends Schema.TaggedErrorClass()( "IntegrationNotConnectedForPreviewError", { provider: IntegrationProvider, @@ -139,7 +139,7 @@ export class IntegrationNotConnectedForPreviewError extends Schema.TaggedError()( +export class ResourceNotFoundError extends Schema.TaggedErrorClass()( "ResourceNotFoundError", { url: Schema.String, @@ -148,7 +148,7 @@ export class ResourceNotFoundError extends Schema.TaggedError()( +export class IntegrationResourceError extends Schema.TaggedErrorClass()( "IntegrationResourceError", { url: Schema.String, @@ -160,24 +160,19 @@ export class IntegrationResourceError extends Schema.TaggedError 1 }), - perPage: Schema.optionalWith(Schema.NumberFromString, { default: () => 30 }), - }), - ) - .annotateContext( + HttpApiEndpoint.get("getGitHubRepositories", `/:orgId/github/repositories`, { + params: { orgId: OrganizationId }, + query: { + page: Schema.optional(Schema.NumberFromString).pipe(Schema.withDecodingDefault(() => "1")), + perPage: Schema.optional(Schema.NumberFromString).pipe( + Schema.withDecodingDefault(() => "30"), + ), + }, + success: GitHubRepositoriesResponse, + error: [IntegrationNotConnectedForPreviewError, UnauthorizedError, InternalServerError], + }) + .annotateMerge( OpenApi.annotations({ title: "Get GitHub Repositories", description: "List repositories accessible to the GitHub App installation", @@ -240,18 +225,17 @@ export class IntegrationResourceGroup extends HttpApiGroup.make("integration-res .annotate(RequiredScopes, ["integration-connections:read"]), ) .add( - HttpApiEndpoint.get("getDiscordGuilds", `/:orgId/discord/guilds`) - .addSuccess(DiscordGuildsResponse) - .addError(IntegrationNotConnectedForPreviewError) - .addError(IntegrationResourceError) - .addError(UnauthorizedError) - .addError(InternalServerError) - .setPath( - Schema.Struct({ - orgId: OrganizationId, - }), - ) - .annotateContext( + HttpApiEndpoint.get("getDiscordGuilds", `/:orgId/discord/guilds`, { + params: { orgId: OrganizationId }, + success: DiscordGuildsResponse, + error: [ + IntegrationNotConnectedForPreviewError, + IntegrationResourceError, + UnauthorizedError, + InternalServerError, + ], + }) + .annotateMerge( OpenApi.annotations({ title: "Get Discord Guilds", description: "List Discord guilds visible to the connected Discord account", @@ -261,19 +245,20 @@ export class IntegrationResourceGroup extends HttpApiGroup.make("integration-res .annotate(RequiredScopes, ["integration-connections:read"]), ) .add( - HttpApiEndpoint.get("getDiscordGuildChannels", `/:orgId/discord/guilds/:guildId/channels`) - .addSuccess(DiscordGuildChannelsResponse) - .addError(IntegrationNotConnectedForPreviewError) - .addError(IntegrationResourceError) - .addError(UnauthorizedError) - .addError(InternalServerError) - .setPath( - Schema.Struct({ - orgId: OrganizationId, - guildId: Schema.String, - }), - ) - .annotateContext( + HttpApiEndpoint.get("getDiscordGuildChannels", `/:orgId/discord/guilds/:guildId/channels`, { + params: { + orgId: OrganizationId, + guildId: Schema.String, + }, + success: DiscordGuildChannelsResponse, + error: [ + IntegrationNotConnectedForPreviewError, + IntegrationResourceError, + UnauthorizedError, + InternalServerError, + ], + }) + .annotateMerge( OpenApi.annotations({ title: "Get Discord Guild Channels", description: "List message-capable channels in a Discord guild using the bot token", diff --git a/packages/domain/src/http/integrations.ts b/packages/domain/src/http/integrations.ts index fe9fe3733..096cbd626 100644 --- a/packages/domain/src/http/integrations.ts +++ b/packages/domain/src/http/integrations.ts @@ -1,4 +1,4 @@ -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "@effect/platform" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" import * as CurrentUser from "../current-user" import { InternalServerError, UnauthorizedError } from "../errors" @@ -22,33 +22,33 @@ export class ConnectionStatusResponse extends Schema.Class()( +export class IntegrationNotConnectedError extends Schema.TaggedErrorClass()( "IntegrationNotConnectedError", { provider: IntegrationProvider, }, ) {} -export class InvalidOAuthStateError extends Schema.TaggedError()( +export class InvalidOAuthStateError extends Schema.TaggedErrorClass()( "InvalidOAuthStateError", { message: Schema.String, }, ) {} -export class UnsupportedProviderError extends Schema.TaggedError()( +export class UnsupportedProviderError extends Schema.TaggedErrorClass()( "UnsupportedProviderError", { provider: Schema.String, }, ) {} -export class InvalidApiKeyError extends Schema.TaggedError()("InvalidApiKeyError", { +export class InvalidApiKeyError extends Schema.TaggedErrorClass()("InvalidApiKeyError", { message: Schema.String, }) {} @@ -66,24 +66,19 @@ export class ConnectApiKeyResponse extends Schema.Class(" export class IntegrationGroup extends HttpApiGroup.make("integrations") // Initiate OAuth flow - returns authorization URL for SPA redirect .add( - HttpApiEndpoint.get("getOAuthUrl", `/:orgId/:provider/oauth`) - .addSuccess(OAuthUrlResponse) - .addError(UnsupportedProviderError) - .addError(UnauthorizedError) - .addError(InternalServerError) - .setPath( - Schema.Struct({ - orgId: OrganizationId, - provider: IntegrationProvider, - }), - ) - .setUrlParams( - Schema.Struct({ - level: Schema.optional(ConnectionLevel), - }), - ) + HttpApiEndpoint.get("getOAuthUrl", `/:orgId/:provider/oauth`, { + params: { + orgId: OrganizationId, + provider: IntegrationProvider, + }, + query: { + level: Schema.optional(ConnectionLevel), + }, + success: OAuthUrlResponse, + error: [UnsupportedProviderError, UnauthorizedError, InternalServerError], + }) .middleware(CurrentUser.Authorization) - .annotateContext( + .annotateMerge( OpenApi.annotations({ title: "Get OAuth Authorization URL", description: @@ -95,32 +90,25 @@ export class IntegrationGroup extends HttpApiGroup.make("integrations") ) // OAuth callback handler .add( - HttpApiEndpoint.get("oauthCallback", `/:provider/callback`) - .addSuccess(Schema.Void, { status: 302 }) - .addError(InvalidOAuthStateError) - .addError(UnsupportedProviderError) - .addError(InternalServerError) - .setPath( - Schema.Struct({ - provider: IntegrationProvider, - }), - ) - .setUrlParams( - Schema.Struct({ - // Standard OAuth uses `code` - code: Schema.optional(Schema.String), - // State is optional because GitHub doesn't send it for update callbacks - state: Schema.optional(Schema.String), - // Discord bot scope callback includes selected guild context - guild_id: Schema.optional(Schema.String), - permissions: Schema.optional(Schema.String), - // GitHub App uses `installation_id` instead of code - installation_id: Schema.optional(Schema.String), - // GitHub also sends setup_action (e.g., "install", "update") - setup_action: Schema.optional(Schema.String), - }), - ) - .annotateContext( + HttpApiEndpoint.get("oauthCallback", `/:provider/callback`, { + params: { provider: IntegrationProvider }, + query: { + // Standard OAuth uses `code` + code: Schema.optional(Schema.String), + // State is optional because GitHub doesn't send it for update callbacks + state: Schema.optional(Schema.String), + // Discord bot scope callback includes selected guild context + guild_id: Schema.optional(Schema.String), + permissions: Schema.optional(Schema.String), + // GitHub App uses `installation_id` instead of code + installation_id: Schema.optional(Schema.String), + // GitHub also sends setup_action (e.g., "install", "update") + setup_action: Schema.optional(Schema.String), + }, + success: Schema.Void.pipe(HttpApiSchema.status(302)), + error: [InvalidOAuthStateError, UnsupportedProviderError, InternalServerError], + }) + .annotateMerge( OpenApi.annotations({ title: "OAuth Callback", description: "Handle OAuth callback from integration provider", @@ -131,24 +119,19 @@ export class IntegrationGroup extends HttpApiGroup.make("integrations") ) // Get connection status .add( - HttpApiEndpoint.get("getConnectionStatus", `/:orgId/:provider/status`) - .addSuccess(ConnectionStatusResponse) - .addError(UnsupportedProviderError) - .addError(UnauthorizedError) - .addError(InternalServerError) - .setPath( - Schema.Struct({ - orgId: OrganizationId, - provider: IntegrationProvider, - }), - ) - .setUrlParams( - Schema.Struct({ - level: Schema.optional(ConnectionLevel), - }), - ) + HttpApiEndpoint.get("getConnectionStatus", `/:orgId/:provider/status`, { + params: { + orgId: OrganizationId, + provider: IntegrationProvider, + }, + query: { + level: Schema.optional(ConnectionLevel), + }, + success: ConnectionStatusResponse, + error: [UnsupportedProviderError, UnauthorizedError, InternalServerError], + }) .middleware(CurrentUser.Authorization) - .annotateContext( + .annotateMerge( OpenApi.annotations({ title: "Get Connection Status", description: "Check the connection status for a provider", @@ -159,21 +142,17 @@ export class IntegrationGroup extends HttpApiGroup.make("integrations") ) // Connect via API key (non-OAuth providers like Craft) .add( - HttpApiEndpoint.post("connectApiKey", `/:orgId/:provider/api-key`) - .addSuccess(ConnectApiKeyResponse) - .addError(InvalidApiKeyError) - .addError(UnsupportedProviderError) - .addError(UnauthorizedError) - .addError(InternalServerError) - .setPath( - Schema.Struct({ - orgId: OrganizationId, - provider: IntegrationProvider, - }), - ) - .setPayload(ConnectApiKeyRequest) + HttpApiEndpoint.post("connectApiKey", `/:orgId/:provider/api-key`, { + params: { + orgId: OrganizationId, + provider: IntegrationProvider, + }, + payload: ConnectApiKeyRequest, + success: ConnectApiKeyResponse, + error: [InvalidApiKeyError, UnsupportedProviderError, UnauthorizedError, InternalServerError], + }) .middleware(CurrentUser.Authorization) - .annotateContext( + .annotateMerge( OpenApi.annotations({ title: "Connect via API Key", description: @@ -185,24 +164,23 @@ export class IntegrationGroup extends HttpApiGroup.make("integrations") ) // Disconnect integration .add( - HttpApiEndpoint.del("disconnect", `/:orgId/:provider`) - .addSuccess(Schema.Void) - .addError(IntegrationNotConnectedError) - .addError(UnsupportedProviderError) - .addError(UnauthorizedError) - .addError(InternalServerError) - .setPath( - Schema.Struct({ - orgId: OrganizationId, - provider: IntegrationProvider, - }), - ) - .setUrlParams( - Schema.Struct({ - level: Schema.optional(ConnectionLevel), - }), - ) - .annotateContext( + HttpApiEndpoint.delete("disconnect", `/:orgId/:provider`, { + params: { + orgId: OrganizationId, + provider: IntegrationProvider, + }, + query: { + level: Schema.optional(ConnectionLevel), + }, + success: Schema.Void, + error: [ + IntegrationNotConnectedError, + UnsupportedProviderError, + UnauthorizedError, + InternalServerError, + ], + }) + .annotateMerge( OpenApi.annotations({ title: "Disconnect Integration", description: "Disconnect an integration and revoke tokens", diff --git a/packages/domain/src/http/internal.ts b/packages/domain/src/http/internal.ts index 56992aae3..47264414f 100644 --- a/packages/domain/src/http/internal.ts +++ b/packages/domain/src/http/internal.ts @@ -1,4 +1,4 @@ -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "@effect/platform" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" import { InternalServerError, UnauthorizedError } from "../errors" import { InvalidBearerTokenError } from "../session-errors" @@ -26,13 +26,12 @@ export class ValidateBotTokenResponse extends Schema.Class("KlipyApiError")( +export class KlipyApiError extends Schema.TaggedErrorClass("KlipyApiError")( "KlipyApiError", { message: Schema.String, }, - HttpApiSchema.annotations({ - status: 502, - }), + { httpApiStatus: 502 }, ) {} // ============ API Group ============ export class KlipyGroup extends HttpApiGroup.make("klipy") .add( - HttpApiEndpoint.get("trending", "/trending") - .setUrlParams( - Schema.Struct({ - page: Schema.optionalWith(Schema.NumberFromString, { default: () => 1 }), - per_page: Schema.optionalWith(Schema.NumberFromString, { default: () => 25 }), - }), - ) - .addSuccess(KlipySearchResponse) - .addError(KlipyApiError) - .annotate(RequiredScopes, ["messages:read"]), + HttpApiEndpoint.get("trending", "/trending", { + query: { + page: Schema.optional(Schema.NumberFromString).pipe(Schema.withDecodingDefault(() => "1")), + per_page: Schema.optional(Schema.NumberFromString).pipe( + Schema.withDecodingDefault(() => "25"), + ), + }, + success: KlipySearchResponse, + error: KlipyApiError, + }).annotate(RequiredScopes, ["messages:read"]), ) .add( - HttpApiEndpoint.get("search", "/search") - .setUrlParams( - Schema.Struct({ - q: Schema.String, - page: Schema.optionalWith(Schema.NumberFromString, { default: () => 1 }), - per_page: Schema.optionalWith(Schema.NumberFromString, { default: () => 25 }), - }), - ) - .addSuccess(KlipySearchResponse) - .addError(KlipyApiError) - .annotate(RequiredScopes, ["messages:read"]), + HttpApiEndpoint.get("search", "/search", { + query: { + q: Schema.String, + page: Schema.optional(Schema.NumberFromString).pipe(Schema.withDecodingDefault(() => "1")), + per_page: Schema.optional(Schema.NumberFromString).pipe( + Schema.withDecodingDefault(() => "25"), + ), + }, + success: KlipySearchResponse, + error: KlipyApiError, + }).annotate(RequiredScopes, ["messages:read"]), ) .add( - HttpApiEndpoint.get("categories", "/categories") - .addSuccess(KlipyCategoriesResponse) - .addError(KlipyApiError) - .annotate(RequiredScopes, ["messages:read"]), + HttpApiEndpoint.get("categories", "/categories", { + success: KlipyCategoriesResponse, + error: KlipyApiError, + }).annotate(RequiredScopes, ["messages:read"]), ) .prefix("/klipy") .middleware(CurrentUser.Authorization) {} diff --git a/packages/domain/src/http/mock-data.ts b/packages/domain/src/http/mock-data.ts index 10a3265cc..474e9687b 100644 --- a/packages/domain/src/http/mock-data.ts +++ b/packages/domain/src/http/mock-data.ts @@ -1,4 +1,4 @@ -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "@effect/platform" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" import * as CurrentUser from "../current-user.ts" import { InternalServerError, UnauthorizedError } from "../errors.ts" @@ -7,7 +7,7 @@ import { RequiredScopes } from "../scopes/required-scopes" export class GenerateMockDataRequest extends Schema.Class("GenerateMockDataRequest")( { - organizationId: Schema.UUID, + organizationId: Schema.String.check(Schema.isUUID()), }, ) {} @@ -28,12 +28,12 @@ export class GenerateMockDataResponse extends Schema.Class("Mark export class PresencePublicGroup extends HttpApiGroup.make("presencePublic") .add( - HttpApiEndpoint.post("markOffline")`/offline` - .setPayload(MarkOfflinePayload) - .addSuccess(MarkOfflineResponse) - .addError(InternalServerError) - .annotateContext( + HttpApiEndpoint.post("markOffline", "/offline", { + payload: MarkOfflinePayload, + success: MarkOfflineResponse, + error: InternalServerError, + }) + .annotateMerge( OpenApi.annotations({ title: "Mark User Offline", description: "Mark a user as offline when they close their tab (no auth required)", diff --git a/packages/domain/src/http/root.ts b/packages/domain/src/http/root.ts index 6e5ab5f88..0a11a7b9f 100644 --- a/packages/domain/src/http/root.ts +++ b/packages/domain/src/http/root.ts @@ -1,7 +1,7 @@ -import { HttpApiEndpoint, HttpApiGroup } from "@effect/platform" +import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" import { Schema } from "effect" import { RequiredScopes } from "../scopes/required-scopes" export class RootGroup extends HttpApiGroup.make("root").add( - HttpApiEndpoint.get("root")`/`.addSuccess(Schema.String).annotate(RequiredScopes, []), + HttpApiEndpoint.get("root", "/", { success: Schema.String }).annotate(RequiredScopes, []), ) {} diff --git a/packages/domain/src/http/uploads.ts b/packages/domain/src/http/uploads.ts index c893a2345..00dea1a5e 100644 --- a/packages/domain/src/http/uploads.ts +++ b/packages/domain/src/http/uploads.ts @@ -1,4 +1,4 @@ -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" import { Schema } from "effect" import { CurrentUser, InternalServerError, UnauthorizedError } from "../" import { AttachmentId, BotId, ChannelId, OrganizationId } from "@hazel/schema" @@ -16,13 +16,13 @@ export const ALLOWED_EMOJI_TYPES = ["image/png", "image/gif", "image/webp"] as c // ============ Upload Type Schema ============ -export const UploadType = Schema.Literal( +export const UploadType = Schema.Literals([ "user-avatar", "bot-avatar", "organization-avatar", "attachment", "custom-emoji", -) +]) export type UploadType = typeof UploadType.Type // ============ Request Schemas ============ @@ -35,21 +35,32 @@ const BaseUploadFields = { fileSize: Schema.Number, } +const allowedAvatarTypeFilter = Schema.makeFilter((s) => + ALLOWED_AVATAR_TYPES.includes(s as (typeof ALLOWED_AVATAR_TYPES)[number]) + ? undefined + : "Content type must be image/jpeg, image/png, or image/webp", +) + +const allowedEmojiTypeFilter = Schema.makeFilter((s) => + ALLOWED_EMOJI_TYPES.includes(s as (typeof ALLOWED_EMOJI_TYPES)[number]) + ? undefined + : "Content type must be image/png, image/gif, or image/webp", +) + /** * User avatar upload request */ export class UserAvatarUploadRequest extends Schema.Class("UserAvatarUploadRequest")( { type: Schema.Literal("user-avatar"), - contentType: Schema.String.pipe( - Schema.filter((s) => ALLOWED_AVATAR_TYPES.includes(s as (typeof ALLOWED_AVATAR_TYPES)[number]), { - message: () => "Content type must be image/jpeg, image/png, or image/webp", - }), - ), - fileSize: Schema.Number.pipe( - Schema.between(1, MAX_AVATAR_SIZE, { - message: () => "File size must be between 1 byte and 5MB", - }), + contentType: Schema.String.check(allowedAvatarTypeFilter), + fileSize: Schema.Number.check( + Schema.isBetween( + { minimum: 1, maximum: MAX_AVATAR_SIZE }, + { + message: "File size must be between 1 byte and 5MB", + }, + ), ), }, ) {} @@ -60,15 +71,14 @@ export class UserAvatarUploadRequest extends Schema.Class("BotAvatarUploadRequest")({ type: Schema.Literal("bot-avatar"), botId: BotId, - contentType: Schema.String.pipe( - Schema.filter((s) => ALLOWED_AVATAR_TYPES.includes(s as (typeof ALLOWED_AVATAR_TYPES)[number]), { - message: () => "Content type must be image/jpeg, image/png, or image/webp", - }), - ), - fileSize: Schema.Number.pipe( - Schema.between(1, MAX_AVATAR_SIZE, { - message: () => "File size must be between 1 byte and 5MB", - }), + contentType: Schema.String.check(allowedAvatarTypeFilter), + fileSize: Schema.Number.check( + Schema.isBetween( + { minimum: 1, maximum: MAX_AVATAR_SIZE }, + { + message: "File size must be between 1 byte and 5MB", + }, + ), ), }) {} @@ -80,15 +90,14 @@ export class OrganizationAvatarUploadRequest extends Schema.Class ALLOWED_AVATAR_TYPES.includes(s as (typeof ALLOWED_AVATAR_TYPES)[number]), { - message: () => "Content type must be image/jpeg, image/png, or image/webp", - }), - ), - fileSize: Schema.Number.pipe( - Schema.between(1, MAX_AVATAR_SIZE, { - message: () => "File size must be between 1 byte and 5MB", - }), + contentType: Schema.String.check(allowedAvatarTypeFilter), + fileSize: Schema.Number.check( + Schema.isBetween( + { minimum: 1, maximum: MAX_AVATAR_SIZE }, + { + message: "File size must be between 1 byte and 5MB", + }, + ), ), }) {} @@ -100,10 +109,13 @@ export class AttachmentUploadRequest extends Schema.Class "File size must be between 1 byte and 10MB", - }), + fileSize: Schema.Number.check( + Schema.isBetween( + { minimum: 1, maximum: MAX_ATTACHMENT_SIZE }, + { + message: "File size must be between 1 byte and 10MB", + }, + ), ), organizationId: OrganizationId, channelId: ChannelId, @@ -118,28 +130,27 @@ export class CustomEmojiUploadRequest extends Schema.Class ALLOWED_EMOJI_TYPES.includes(s as (typeof ALLOWED_EMOJI_TYPES)[number]), { - message: () => "Content type must be image/png, image/gif, or image/webp", - }), - ), - fileSize: Schema.Number.pipe( - Schema.between(1, MAX_EMOJI_SIZE, { - message: () => "File size must be between 1 byte and 256KB", - }), + contentType: Schema.String.check(allowedEmojiTypeFilter), + fileSize: Schema.Number.check( + Schema.isBetween( + { minimum: 1, maximum: MAX_EMOJI_SIZE }, + { + message: "File size must be between 1 byte and 256KB", + }, + ), ), }) {} /** * Unified presign upload request - discriminated union of all upload types */ -export const PresignUploadRequest = Schema.Union( +export const PresignUploadRequest = Schema.Union([ UserAvatarUploadRequest, BotAvatarUploadRequest, OrganizationAvatarUploadRequest, AttachmentUploadRequest, CustomEmojiUploadRequest, -) +]) export type PresignUploadRequest = typeof PresignUploadRequest.Type // ============ Response Schema ============ @@ -161,38 +172,32 @@ export class PresignUploadResponse extends Schema.Class(" // ============ Error Schemas ============ -export class UploadError extends Schema.TaggedError("UploadError")( +export class UploadError extends Schema.TaggedErrorClass("UploadError")( "UploadError", { message: Schema.String, }, - HttpApiSchema.annotations({ - status: 500, - }), + { httpApiStatus: 500 }, ) {} -export class BotNotFoundForUploadError extends Schema.TaggedError( +export class BotNotFoundForUploadError extends Schema.TaggedErrorClass( "BotNotFoundForUploadError", )( "BotNotFoundForUploadError", { botId: BotId, }, - HttpApiSchema.annotations({ - status: 404, - }), + { httpApiStatus: 404 }, ) {} -export class OrganizationNotFoundForUploadError extends Schema.TaggedError( +export class OrganizationNotFoundForUploadError extends Schema.TaggedErrorClass( "OrganizationNotFoundForUploadError", )( "OrganizationNotFoundForUploadError", { organizationId: OrganizationId, }, - HttpApiSchema.annotations({ - status: 404, - }), + { httpApiStatus: 404 }, ) {} // ============ API Group ============ @@ -205,16 +210,18 @@ export class OrganizationNotFoundForUploadError extends Schema.TaggedError("WebhookRespo message: Schema.optional(Schema.String), }) {} -export class InvalidWebhookSignature extends Schema.TaggedError( +export class InvalidWebhookSignature extends Schema.TaggedErrorClass( "InvalidWebhookSignature", )( "InvalidWebhookSignature", { message: Schema.String, }, - HttpApiSchema.annotations({ - status: 401, - }), + { httpApiStatus: 401 }, ) {} // GitHub Webhook Types @@ -34,26 +32,24 @@ export class GitHubWebhookResponse extends Schema.Class(" messagesCreated: Schema.optional(Schema.Number), }) {} -export class InvalidGitHubWebhookSignature extends Schema.TaggedError( +export class InvalidGitHubWebhookSignature extends Schema.TaggedErrorClass( "InvalidGitHubWebhookSignature", )( "InvalidGitHubWebhookSignature", { message: Schema.String, }, - HttpApiSchema.annotations({ - status: 401, - }), + { httpApiStatus: 401 }, ) {} export class WebhookGroup extends HttpApiGroup.make("webhooks") .add( - HttpApiEndpoint.post("workos")`/workos` - .setPayload(Schema.Unknown) - .addSuccess(WebhookResponse) - .addError(InvalidWebhookSignature) - .addError(InternalServerError) - .annotateContext( + HttpApiEndpoint.post("workos", "/workos", { + payload: Schema.Unknown, + success: WebhookResponse, + error: [InvalidWebhookSignature, InternalServerError], + }) + .annotateMerge( OpenApi.annotations({ title: "WorkOS Webhook", description: "Receive and process WorkOS webhook events", @@ -63,13 +59,12 @@ export class WebhookGroup extends HttpApiGroup.make("webhooks") .annotate(RequiredScopes, []), ) .add( - HttpApiEndpoint.post("github")`/github` - .setPayload(Schema.Unknown) - .addSuccess(GitHubWebhookResponse) - .addError(InvalidGitHubWebhookSignature) - .addError(InternalServerError) - .addError(WorkflowInitializationError) - .annotateContext( + HttpApiEndpoint.post("github", "/github", { + payload: Schema.Unknown, + success: GitHubWebhookResponse, + error: [InvalidGitHubWebhookSignature, InternalServerError, WorkflowInitializationError], + }) + .annotateMerge( OpenApi.annotations({ title: "GitHub App Webhook", description: "Receive and process GitHub App webhook events", diff --git a/packages/domain/src/models/attachment-model.ts b/packages/domain/src/models/attachment-model.ts index 0c3d3691d..fa9bc8848 100644 --- a/packages/domain/src/models/attachment-model.ts +++ b/packages/domain/src/models/attachment-model.ts @@ -1,24 +1,24 @@ import { AttachmentId, ChannelId, MessageId, OrganizationId, UserId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import * as M from "./utils" import { JsonDate } from "./utils" -export const AttachmentStatus = Schema.Literal("uploading", "complete", "failed") -export type AttachmentStatus = Schema.Schema.Type +export const AttachmentStatus = S.Literals(["uploading", "complete", "failed"]) +export type AttachmentStatus = S.Schema.Type -export class Model extends M.Class("Attachment")({ +class Model extends M.Class("Attachment")({ id: M.GeneratedByApp(AttachmentId), organizationId: OrganizationId, - channelId: Schema.NullOr(ChannelId), - messageId: Schema.NullOr(MessageId), - fileName: Schema.String, - fileSize: Schema.Number, - externalUrl: Schema.NullOr(Schema.String), + channelId: S.NullOr(ChannelId), + messageId: S.NullOr(MessageId), + fileName: S.String, + fileSize: S.Number, + externalUrl: S.NullOr(S.String), uploadedBy: M.GeneratedByApp(UserId), status: AttachmentStatus, uploadedAt: JsonDate, - deletedAt: M.Generated(Schema.NullOr(JsonDate)), + deletedAt: M.Generated(S.NullOr(JsonDate)), }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/bot-command-model.ts b/packages/domain/src/models/bot-command-model.ts index 321a26c3a..fdb4dfb14 100644 --- a/packages/domain/src/models/bot-command-model.ts +++ b/packages/domain/src/models/bot-command-model.ts @@ -1,31 +1,31 @@ import { BotCommandId, BotId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import * as M from "./utils" import { Generated, JsonDate } from "./utils" /** * Argument definition for a bot command */ -export const BotCommandArgument = Schema.Struct({ - name: Schema.String, - description: Schema.NullOr(Schema.String), - required: Schema.Boolean, - placeholder: Schema.NullOr(Schema.String), - type: Schema.Literal("string", "number", "user", "channel"), +export const BotCommandArgument = S.Struct({ + name: S.String, + description: S.NullOr(S.String), + required: S.Boolean, + placeholder: S.NullOr(S.String), + type: S.Literals(["string", "number", "user", "channel"]), }) export type BotCommandArgument = typeof BotCommandArgument.Type -export class Model extends M.Class("BotCommand")({ +class Model extends M.Class("BotCommand")({ id: M.Generated(BotCommandId), botId: BotId, - name: Schema.String, - description: Schema.String, - arguments: Schema.NullOr(Schema.Array(BotCommandArgument)), - usageExample: Schema.NullOr(Schema.String), - isEnabled: Schema.Boolean, + name: S.String, + description: S.String, + arguments: S.NullOr(S.Array(BotCommandArgument)), + usageExample: S.NullOr(S.String), + isEnabled: S.Boolean, createdAt: Generated(JsonDate), - updatedAt: Generated(Schema.NullOr(JsonDate)), + updatedAt: Generated(S.NullOr(JsonDate)), }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/bot-installation-model.ts b/packages/domain/src/models/bot-installation-model.ts index 75ffbf0eb..c4303edff 100644 --- a/packages/domain/src/models/bot-installation-model.ts +++ b/packages/domain/src/models/bot-installation-model.ts @@ -1,9 +1,9 @@ import { BotId, BotInstallationId, OrganizationId, UserId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import * as M from "./utils" import { Generated, JsonDate } from "./utils" -export class Model extends M.Class("BotInstallation")({ +class Model extends M.Class("BotInstallation")({ id: M.Generated(BotInstallationId), botId: BotId, organizationId: OrganizationId, @@ -11,5 +11,5 @@ export class Model extends M.Class("BotInstallation")({ installedAt: Generated(JsonDate), }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/bot-model.ts b/packages/domain/src/models/bot-model.ts index 2261bd2aa..32762912d 100644 --- a/packages/domain/src/models/bot-model.ts +++ b/packages/domain/src/models/bot-model.ts @@ -1,32 +1,27 @@ import { BotId, UserId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import { IntegrationProvider } from "./integration-connection-model" import * as M from "./utils" import { baseFields, JsonDate } from "./utils" -export class Model extends M.Class("Bot")({ +class Model extends M.Class("Bot")({ id: M.Generated(BotId), userId: UserId, createdBy: UserId, - name: Schema.String, - description: Schema.NullOr(Schema.String), - webhookUrl: Schema.NullOr(Schema.String), - apiTokenHash: Schema.String, - scopes: Schema.NullOr(Schema.Array(Schema.String)), - metadata: Schema.NullOr( - Schema.Record({ - key: Schema.String, - value: Schema.Unknown, - }), - ), - isPublic: Schema.Boolean, - installCount: Schema.Number, + name: S.String, + description: S.NullOr(S.String), + webhookUrl: S.NullOr(S.String), + apiTokenHash: S.String, + scopes: S.NullOr(S.Array(S.String)), + metadata: S.NullOr(S.Record(S.String, S.Unknown)), + isPublic: S.Boolean, + installCount: S.Number, // List of integration providers this bot is allowed to use (e.g., ["linear", "github"]) - allowedIntegrations: Schema.NullOr(Schema.Array(IntegrationProvider)), + allowedIntegrations: S.NullOr(S.Array(IntegrationProvider)), // Whether this bot can be @mentioned in messages - mentionable: Schema.Boolean, + mentionable: S.Boolean, ...baseFields, }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/channel-member-model.ts b/packages/domain/src/models/channel-member-model.ts index 59faee83e..fbce145a5 100644 --- a/packages/domain/src/models/channel-member-model.ts +++ b/packages/domain/src/models/channel-member-model.ts @@ -1,21 +1,21 @@ import { ChannelId, ChannelMemberId, MessageId, UserId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import * as M from "./utils" import { JsonDate } from "./utils" -export class Model extends M.Class("ChannelMember")({ +class Model extends M.Class("ChannelMember")({ id: M.Generated(ChannelMemberId), channelId: ChannelId, userId: M.GeneratedByApp(UserId), - isHidden: Schema.Boolean, - isMuted: Schema.Boolean, - isFavorite: Schema.Boolean, - lastSeenMessageId: Schema.NullOr(MessageId), - notificationCount: Schema.Number, + isHidden: S.Boolean, + isMuted: S.Boolean, + isFavorite: S.Boolean, + lastSeenMessageId: S.NullOr(MessageId), + notificationCount: S.Number, joinedAt: M.GeneratedByApp(JsonDate), createdAt: M.Generated(JsonDate), - deletedAt: M.GeneratedByApp(Schema.NullOr(JsonDate)), + deletedAt: M.GeneratedByApp(S.NullOr(JsonDate)), }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/channel-model.ts b/packages/domain/src/models/channel-model.ts index e1324f21d..4603bc6f9 100644 --- a/packages/domain/src/models/channel-model.ts +++ b/packages/domain/src/models/channel-model.ts @@ -1,21 +1,21 @@ import { ChannelIcon, ChannelId, ChannelSectionId, OrganizationId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import * as M from "./utils" import { baseFields } from "./utils" -export const ChannelType = Schema.Literal("public", "private", "thread", "direct", "single") -export type ChannelType = Schema.Schema.Type +export const ChannelType = S.Literals(["public", "private", "thread", "direct", "single"]) +export type ChannelType = S.Schema.Type -export class Model extends M.Class("Channel")({ +class Model extends M.Class("Channel")({ id: M.GeneratedOptional(ChannelId), - name: Schema.String, - icon: Schema.NullOr(ChannelIcon), + name: S.String, + icon: S.NullOr(ChannelIcon), type: ChannelType, organizationId: OrganizationId, - parentChannelId: Schema.NullOr(ChannelId), - sectionId: Schema.NullOr(ChannelSectionId), + parentChannelId: S.NullOr(ChannelId), + sectionId: S.NullOr(ChannelSectionId), ...baseFields, }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/channel-section-model.ts b/packages/domain/src/models/channel-section-model.ts index 597b50b23..263a6627d 100644 --- a/packages/domain/src/models/channel-section-model.ts +++ b/packages/domain/src/models/channel-section-model.ts @@ -1,15 +1,15 @@ import { ChannelSectionId, OrganizationId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import * as M from "./utils" import { baseFields } from "./utils" -export class Model extends M.Class("ChannelSection")({ +class Model extends M.Class("ChannelSection")({ id: M.GeneratedOptional(ChannelSectionId), organizationId: OrganizationId, - name: Schema.String, - order: Schema.Number, + name: S.String, + order: S.Number, ...baseFields, }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/channel-webhook-model.ts b/packages/domain/src/models/channel-webhook-model.ts index 02d599436..4610f800f 100644 --- a/packages/domain/src/models/channel-webhook-model.ts +++ b/packages/domain/src/models/channel-webhook-model.ts @@ -1,23 +1,23 @@ import { ChannelId, ChannelWebhookId, OrganizationId, UserId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import * as M from "./utils" import { baseFields, JsonDate } from "./utils" -export class Model extends M.Class("ChannelWebhook")({ +class Model extends M.Class("ChannelWebhook")({ id: M.Generated(ChannelWebhookId), channelId: ChannelId, organizationId: OrganizationId, botUserId: UserId, - name: Schema.String, - description: Schema.NullOr(Schema.String), - avatarUrl: Schema.NullOr(Schema.String), - tokenHash: M.Sensitive(Schema.String), - tokenSuffix: Schema.String, - isEnabled: Schema.Boolean, + name: S.String, + description: S.NullOr(S.String), + avatarUrl: S.NullOr(S.String), + tokenHash: M.Sensitive(S.String), + tokenSuffix: S.String, + isEnabled: S.Boolean, createdBy: UserId, - lastUsedAt: Schema.NullOr(JsonDate), + lastUsedAt: S.NullOr(JsonDate), ...baseFields, }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Row, Insert, Update, Schema, Create, Patch } = M.exposeWithRow(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/chat-sync-channel-link-model.ts b/packages/domain/src/models/chat-sync-channel-link-model.ts index 31f1fb60c..31bf4f22a 100644 --- a/packages/domain/src/models/chat-sync-channel-link-model.ts +++ b/packages/domain/src/models/chat-sync-channel-link-model.ts @@ -1,66 +1,61 @@ import { ChannelId, ExternalChannelId, SyncChannelLinkId, SyncConnectionId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import * as M from "./utils" import { JsonDate } from "./utils" -export const ChatSyncDirection = Schema.Literal("both", "hazel_to_external", "external_to_hazel") -export type ChatSyncDirection = Schema.Schema.Type +export const ChatSyncDirection = S.Literals(["both", "hazel_to_external", "external_to_hazel"]) +export type ChatSyncDirection = S.Schema.Type -export const ChatSyncOutboundIdentityStrategy = Schema.Literal("webhook", "fallback_bot") -export type ChatSyncOutboundIdentityStrategy = Schema.Schema.Type +export const ChatSyncOutboundIdentityStrategy = S.Literals(["webhook", "fallback_bot"]) +export type ChatSyncOutboundIdentityStrategy = S.Schema.Type -export const DiscordWebhookOutboundIdentityConfig = Schema.Struct({ - kind: Schema.Literal("discord.webhook"), - webhookId: Schema.NonEmptyTrimmedString, - webhookToken: Schema.NonEmptyTrimmedString, - defaultAvatarUrl: Schema.optional(Schema.NonEmptyTrimmedString), +export const DiscordWebhookOutboundIdentityConfig = S.Struct({ + kind: S.Literal("discord.webhook"), + webhookId: S.NonEmptyString, + webhookToken: S.NonEmptyString, + defaultAvatarUrl: S.optional(S.NonEmptyString), }) -export type DiscordWebhookOutboundIdentityConfig = Schema.Schema.Type< - typeof DiscordWebhookOutboundIdentityConfig -> +export type DiscordWebhookOutboundIdentityConfig = S.Schema.Type -export const SlackWebhookOutboundIdentityConfig = Schema.Struct({ - kind: Schema.Literal("slack.webhook"), - webhookUrl: Schema.NonEmptyTrimmedString, - defaultIconUrl: Schema.optional(Schema.NonEmptyTrimmedString), +export const SlackWebhookOutboundIdentityConfig = S.Struct({ + kind: S.Literal("slack.webhook"), + webhookUrl: S.NonEmptyString, + defaultIconUrl: S.optional(S.NonEmptyString), }) -export type SlackWebhookOutboundIdentityConfig = Schema.Schema.Type +export type SlackWebhookOutboundIdentityConfig = S.Schema.Type -export const ProviderOutboundConfig = Schema.Union( +export const ProviderOutboundConfig = S.Union([ DiscordWebhookOutboundIdentityConfig, SlackWebhookOutboundIdentityConfig, - Schema.Struct({ - kind: Schema.NonEmptyTrimmedString, + S.Struct({ + kind: S.NonEmptyString, }), -) -export type ProviderOutboundConfig = Schema.Schema.Type +]) +export type ProviderOutboundConfig = S.Schema.Type -export const OutboundIdentityProviders = Schema.Record({ - key: Schema.String, - value: ProviderOutboundConfig, -}) +export const OutboundIdentityProviders = S.Record(S.String, ProviderOutboundConfig) -export const OutboundIdentitySettings = Schema.Struct({ - enabled: Schema.Boolean, +export const OutboundIdentitySettings = S.Struct({ + enabled: S.Boolean, strategy: ChatSyncOutboundIdentityStrategy, providers: OutboundIdentityProviders, }) -export type OutboundIdentitySettings = Schema.Schema.Type +export type OutboundIdentitySettings = S.Schema.Type -export class Model extends M.Class("ChatSyncChannelLink")({ +class Model extends M.Class("ChatSyncChannelLink")({ id: M.Generated(SyncChannelLinkId), syncConnectionId: SyncConnectionId, hazelChannelId: ChannelId, externalChannelId: ExternalChannelId, - externalChannelName: Schema.NullOr(Schema.String), + externalChannelName: S.NullOr(S.String), direction: ChatSyncDirection, - isActive: Schema.Boolean, - settings: Schema.NullOr(Schema.Record({ key: Schema.String, value: Schema.Unknown })), - lastSyncedAt: Schema.NullOr(JsonDate), + isActive: S.Boolean, + settings: S.NullOr(S.Record(S.String, S.Unknown)), + lastSyncedAt: S.NullOr(JsonDate), createdAt: M.Generated(JsonDate), - updatedAt: M.Generated(Schema.NullOr(JsonDate)), - deletedAt: M.GeneratedByApp(Schema.NullOr(JsonDate)), + updatedAt: M.Generated(S.NullOr(JsonDate)), + deletedAt: M.GeneratedByApp(S.NullOr(JsonDate)), }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/chat-sync-connection-model.ts b/packages/domain/src/models/chat-sync-connection-model.ts index e9a06c5f0..d3ea40a86 100644 --- a/packages/domain/src/models/chat-sync-connection-model.ts +++ b/packages/domain/src/models/chat-sync-connection-model.ts @@ -1,31 +1,31 @@ import { IntegrationConnectionId, OrganizationId, SyncConnectionId, UserId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import * as M from "./utils" import { JsonDate } from "./utils" -export const ChatSyncProvider = Schema.NonEmptyTrimmedString -export type ChatSyncProvider = Schema.Schema.Type +export const ChatSyncProvider = S.NonEmptyString +export type ChatSyncProvider = S.Schema.Type -export const ChatSyncConnectionStatus = Schema.Literal("active", "paused", "error", "disabled") -export type ChatSyncConnectionStatus = Schema.Schema.Type +export const ChatSyncConnectionStatus = S.Literals(["active", "paused", "error", "disabled"]) +export type ChatSyncConnectionStatus = S.Schema.Type -export class Model extends M.Class("ChatSyncConnection")({ +class Model extends M.Class("ChatSyncConnection")({ id: M.Generated(SyncConnectionId), organizationId: OrganizationId, - integrationConnectionId: Schema.NullOr(IntegrationConnectionId), + integrationConnectionId: S.NullOr(IntegrationConnectionId), provider: ChatSyncProvider, - externalWorkspaceId: Schema.String, - externalWorkspaceName: Schema.NullOr(Schema.String), + externalWorkspaceId: S.String, + externalWorkspaceName: S.NullOr(S.String), status: ChatSyncConnectionStatus, - settings: Schema.NullOr(Schema.Record({ key: Schema.String, value: Schema.Unknown })), - metadata: Schema.NullOr(Schema.Record({ key: Schema.String, value: Schema.Unknown })), - errorMessage: Schema.NullOr(Schema.String), - lastSyncedAt: Schema.NullOr(JsonDate), + settings: S.NullOr(S.Record(S.String, S.Unknown)), + metadata: S.NullOr(S.Record(S.String, S.Unknown)), + errorMessage: S.NullOr(S.String), + lastSyncedAt: S.NullOr(JsonDate), createdBy: UserId, createdAt: M.Generated(JsonDate), - updatedAt: M.Generated(Schema.NullOr(JsonDate)), - deletedAt: M.GeneratedByApp(Schema.NullOr(JsonDate)), + updatedAt: M.Generated(S.NullOr(JsonDate)), + deletedAt: M.GeneratedByApp(S.NullOr(JsonDate)), }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/chat-sync-event-receipt-model.ts b/packages/domain/src/models/chat-sync-event-receipt-model.ts index db047bb7e..833a23c4f 100644 --- a/packages/domain/src/models/chat-sync-event-receipt-model.ts +++ b/packages/domain/src/models/chat-sync-event-receipt-model.ts @@ -1,27 +1,27 @@ import { SyncChannelLinkId, SyncConnectionId, SyncEventReceiptId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import * as M from "./utils" import { JsonDate } from "./utils" -export const ChatSyncReceiptSource = Schema.Literal("hazel", "external") -export type ChatSyncReceiptSource = Schema.Schema.Type +export const ChatSyncReceiptSource = S.Literals(["hazel", "external"]) +export type ChatSyncReceiptSource = S.Schema.Type -export const ChatSyncReceiptStatus = Schema.Literal("processed", "ignored", "failed") -export type ChatSyncReceiptStatus = Schema.Schema.Type +export const ChatSyncReceiptStatus = S.Literals(["processed", "ignored", "failed"]) +export type ChatSyncReceiptStatus = S.Schema.Type -export class Model extends M.Class("ChatSyncEventReceipt")({ +class Model extends M.Class("ChatSyncEventReceipt")({ id: M.Generated(SyncEventReceiptId), syncConnectionId: SyncConnectionId, - channelLinkId: Schema.NullOr(SyncChannelLinkId), + channelLinkId: S.NullOr(SyncChannelLinkId), source: ChatSyncReceiptSource, - externalEventId: Schema.NullOr(Schema.String), - dedupeKey: Schema.String, - payloadHash: Schema.NullOr(Schema.String), + externalEventId: S.NullOr(S.String), + dedupeKey: S.String, + payloadHash: S.NullOr(S.String), status: ChatSyncReceiptStatus, - errorMessage: Schema.NullOr(Schema.String), + errorMessage: S.NullOr(S.String), processedAt: M.Generated(JsonDate), createdAt: M.Generated(JsonDate), }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/chat-sync-message-link-model.ts b/packages/domain/src/models/chat-sync-message-link-model.ts index b48df98ab..4ed44b479 100644 --- a/packages/domain/src/models/chat-sync-message-link-model.ts +++ b/packages/domain/src/models/chat-sync-message-link-model.ts @@ -6,26 +6,26 @@ import { SyncChannelLinkId, SyncMessageLinkId, } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import { ChatSyncReceiptSource } from "./chat-sync-event-receipt-model" import * as M from "./utils" import { JsonDate } from "./utils" -export class Model extends M.Class("ChatSyncMessageLink")({ +class Model extends M.Class("ChatSyncMessageLink")({ id: M.Generated(SyncMessageLinkId), channelLinkId: SyncChannelLinkId, hazelMessageId: MessageId, externalMessageId: ExternalMessageId, source: ChatSyncReceiptSource, - rootHazelMessageId: Schema.NullOr(MessageId), - rootExternalMessageId: Schema.NullOr(ExternalMessageId), - hazelThreadChannelId: Schema.NullOr(ChannelId), - externalThreadId: Schema.NullOr(ExternalThreadId), + rootHazelMessageId: S.NullOr(MessageId), + rootExternalMessageId: S.NullOr(ExternalMessageId), + hazelThreadChannelId: S.NullOr(ChannelId), + externalThreadId: S.NullOr(ExternalThreadId), lastSyncedAt: M.Generated(JsonDate), createdAt: M.Generated(JsonDate), - updatedAt: M.Generated(Schema.NullOr(JsonDate)), - deletedAt: M.GeneratedByApp(Schema.NullOr(JsonDate)), + updatedAt: M.Generated(S.NullOr(JsonDate)), + deletedAt: M.GeneratedByApp(S.NullOr(JsonDate)), }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/connect-conversation-channel-model.ts b/packages/domain/src/models/connect-conversation-channel-model.ts index d52d2e97e..5d345cf16 100644 --- a/packages/domain/src/models/connect-conversation-channel-model.ts +++ b/packages/domain/src/models/connect-conversation-channel-model.ts @@ -1,23 +1,23 @@ import { ChannelId, ConnectConversationChannelId, ConnectConversationId, OrganizationId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import * as M from "./utils" import { JsonDate } from "./utils" -export const ConnectConversationChannelRole = Schema.Literal("host", "guest") -export type ConnectConversationChannelRole = Schema.Schema.Type +export const ConnectConversationChannelRole = S.Literals(["host", "guest"]) +export type ConnectConversationChannelRole = S.Schema.Type -export class Model extends M.Class("ConnectConversationChannel")({ +class Model extends M.Class("ConnectConversationChannel")({ id: M.Generated(ConnectConversationChannelId), conversationId: ConnectConversationId, organizationId: OrganizationId, channelId: ChannelId, role: ConnectConversationChannelRole, - allowGuestMemberAdds: Schema.Boolean, - isActive: Schema.Boolean, + allowGuestMemberAdds: S.Boolean, + isActive: S.Boolean, createdAt: M.Generated(JsonDate), - updatedAt: M.Generated(Schema.NullOr(JsonDate)), - deletedAt: M.GeneratedByApp(Schema.NullOr(JsonDate)), + updatedAt: M.Generated(S.NullOr(JsonDate)), + deletedAt: M.GeneratedByApp(S.NullOr(JsonDate)), }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/connect-conversation-model.ts b/packages/domain/src/models/connect-conversation-model.ts index 5cb83d393..d2e9e7eb8 100644 --- a/packages/domain/src/models/connect-conversation-model.ts +++ b/packages/domain/src/models/connect-conversation-model.ts @@ -1,22 +1,22 @@ import { ChannelId, ConnectConversationId, OrganizationId, UserId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import * as M from "./utils" import { JsonDate } from "./utils" -export const ConnectConversationStatus = Schema.Literal("active", "disconnected") -export type ConnectConversationStatus = Schema.Schema.Type +export const ConnectConversationStatus = S.Literals(["active", "disconnected"]) +export type ConnectConversationStatus = S.Schema.Type -export class Model extends M.Class("ConnectConversation")({ +class Model extends M.Class("ConnectConversation")({ id: M.Generated(ConnectConversationId), hostOrganizationId: OrganizationId, hostChannelId: ChannelId, status: ConnectConversationStatus, - settings: Schema.NullOr(Schema.Record({ key: Schema.String, value: Schema.Unknown })), + settings: S.NullOr(S.Record(S.String, S.Unknown)), createdBy: UserId, createdAt: M.Generated(JsonDate), - updatedAt: M.Generated(Schema.NullOr(JsonDate)), - deletedAt: M.GeneratedByApp(Schema.NullOr(JsonDate)), + updatedAt: M.Generated(S.NullOr(JsonDate)), + deletedAt: M.GeneratedByApp(S.NullOr(JsonDate)), }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/connect-invite-model.ts b/packages/domain/src/models/connect-invite-model.ts index f83b70d0b..e80402002 100644 --- a/packages/domain/src/models/connect-invite-model.ts +++ b/packages/domain/src/models/connect-invite-model.ts @@ -1,32 +1,32 @@ import { ChannelId, ConnectConversationId, ConnectInviteId, OrganizationId, UserId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import * as M from "./utils" import { JsonDate } from "./utils" -export const ConnectInviteStatus = Schema.Literal("pending", "accepted", "declined", "revoked", "expired") -export type ConnectInviteStatus = Schema.Schema.Type +export const ConnectInviteStatus = S.Literals(["pending", "accepted", "declined", "revoked", "expired"]) +export type ConnectInviteStatus = S.Schema.Type -export const ConnectInviteTargetKind = Schema.Literal("slug", "email") -export type ConnectInviteTargetKind = Schema.Schema.Type +export const ConnectInviteTargetKind = S.Literals(["slug", "email"]) +export type ConnectInviteTargetKind = S.Schema.Type -export class Model extends M.Class("ConnectInvite")({ +class Model extends M.Class("ConnectInvite")({ id: M.Generated(ConnectInviteId), conversationId: ConnectConversationId, hostOrganizationId: OrganizationId, hostChannelId: ChannelId, targetKind: ConnectInviteTargetKind, - targetValue: Schema.String, - guestOrganizationId: Schema.NullOr(OrganizationId), + targetValue: S.String, + guestOrganizationId: S.NullOr(OrganizationId), status: ConnectInviteStatus, - allowGuestMemberAdds: Schema.Boolean, + allowGuestMemberAdds: S.Boolean, invitedBy: UserId, - acceptedBy: Schema.NullOr(UserId), - acceptedAt: Schema.NullOr(JsonDate), - expiresAt: Schema.NullOr(JsonDate), + acceptedBy: S.NullOr(UserId), + acceptedAt: S.NullOr(JsonDate), + expiresAt: S.NullOr(JsonDate), createdAt: M.Generated(JsonDate), - updatedAt: M.Generated(Schema.NullOr(JsonDate)), - deletedAt: M.GeneratedByApp(Schema.NullOr(JsonDate)), + updatedAt: M.Generated(S.NullOr(JsonDate)), + deletedAt: M.GeneratedByApp(S.NullOr(JsonDate)), }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/connect-participant-model.ts b/packages/domain/src/models/connect-participant-model.ts index 80e1cbeec..8a744ed9e 100644 --- a/packages/domain/src/models/connect-participant-model.ts +++ b/packages/domain/src/models/connect-participant-model.ts @@ -1,20 +1,20 @@ import { ChannelId, ConnectConversationId, ConnectParticipantId, OrganizationId, UserId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import * as M from "./utils" import { JsonDate } from "./utils" -export class Model extends M.Class("ConnectParticipant")({ +class Model extends M.Class("ConnectParticipant")({ id: M.Generated(ConnectParticipantId), conversationId: ConnectConversationId, channelId: ChannelId, userId: UserId, homeOrganizationId: OrganizationId, - isExternal: Schema.Boolean, - addedBy: Schema.NullOr(UserId), + isExternal: S.Boolean, + addedBy: S.NullOr(UserId), createdAt: M.Generated(JsonDate), - updatedAt: M.Generated(Schema.NullOr(JsonDate)), - deletedAt: M.GeneratedByApp(Schema.NullOr(JsonDate)), + updatedAt: M.Generated(S.NullOr(JsonDate)), + deletedAt: M.GeneratedByApp(S.NullOr(JsonDate)), }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/custom-emoji-model.ts b/packages/domain/src/models/custom-emoji-model.ts index 182e5e1bc..b182f245c 100644 --- a/packages/domain/src/models/custom-emoji-model.ts +++ b/packages/domain/src/models/custom-emoji-model.ts @@ -1,18 +1,18 @@ import { CustomEmojiId, OrganizationId, UserId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import * as M from "./utils" import { JsonDate } from "./utils" -export class Model extends M.Class("CustomEmoji")({ +class Model extends M.Class("CustomEmoji")({ id: M.Generated(CustomEmojiId), organizationId: OrganizationId, - name: Schema.String, - imageUrl: Schema.String, + name: S.String, + imageUrl: S.String, createdBy: M.GeneratedByApp(UserId), createdAt: M.Generated(JsonDate), - updatedAt: M.Generated(Schema.NullOr(JsonDate)), - deletedAt: M.Generated(Schema.NullOr(JsonDate)), + updatedAt: M.Generated(S.NullOr(JsonDate)), + deletedAt: M.Generated(S.NullOr(JsonDate)), }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/github-subscription-model.ts b/packages/domain/src/models/github-subscription-model.ts index 375e56524..ecd5d8022 100644 --- a/packages/domain/src/models/github-subscription-model.ts +++ b/packages/domain/src/models/github-subscription-model.ts @@ -1,33 +1,33 @@ import { GitHubEventType, GitHubEventTypes } from "@hazel/integrations/github/schema" import { ChannelId, GitHubSubscriptionId, OrganizationId, UserId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import * as M from "./utils" import { JsonDate } from "./utils" // Re-export from integrations for backwards compatibility export { GitHubEventType, GitHubEventTypes } -export class Model extends M.Class("GitHubSubscription")({ +class Model extends M.Class("GitHubSubscription")({ id: M.Generated(GitHubSubscriptionId), channelId: ChannelId, organizationId: OrganizationId, // Repository identification - GitHub's numeric ID is stable across renames - repositoryId: Schema.Number, - repositoryFullName: Schema.String, // "owner/repo" for display - repositoryOwner: Schema.String, - repositoryName: Schema.String, + repositoryId: S.Number, + repositoryFullName: S.String, // "owner/repo" for display + repositoryOwner: S.String, + repositoryName: S.String, // Event type filters enabledEvents: GitHubEventTypes, // Optional branch filter for push events (null = all branches) - branchFilter: Schema.NullOr(Schema.String), + branchFilter: S.NullOr(S.String), // Whether the subscription is active - isEnabled: Schema.Boolean, + isEnabled: S.Boolean, // Audit fields createdBy: UserId, createdAt: M.Generated(JsonDate), - updatedAt: M.Generated(Schema.NullOr(JsonDate)), - deletedAt: M.GeneratedByApp(Schema.NullOr(JsonDate)), + updatedAt: M.Generated(S.NullOr(JsonDate)), + deletedAt: M.GeneratedByApp(S.NullOr(JsonDate)), }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/integration-connection-model.ts b/packages/domain/src/models/integration-connection-model.ts index 375f2b663..74260e290 100644 --- a/packages/domain/src/models/integration-connection-model.ts +++ b/packages/domain/src/models/integration-connection-model.ts @@ -1,35 +1,35 @@ import { IntegrationConnectionId, OrganizationId, UserId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import * as M from "./utils" import { JsonDate } from "./utils" -export const IntegrationProvider = Schema.Literal("linear", "github", "figma", "notion", "discord", "craft") -export type IntegrationProvider = Schema.Schema.Type +export const IntegrationProvider = S.Literals(["linear", "github", "figma", "notion", "discord", "craft"]) +export type IntegrationProvider = S.Schema.Type -export const ConnectionLevel = Schema.Literal("organization", "user") -export type ConnectionLevel = Schema.Schema.Type +export const ConnectionLevel = S.Literals(["organization", "user"]) +export type ConnectionLevel = S.Schema.Type -export const ConnectionStatus = Schema.Literal("active", "expired", "revoked", "error", "suspended") -export type ConnectionStatus = Schema.Schema.Type +export const ConnectionStatus = S.Literals(["active", "expired", "revoked", "error", "suspended"]) +export type ConnectionStatus = S.Schema.Type -export class Model extends M.Class("IntegrationConnection")({ +class Model extends M.Class("IntegrationConnection")({ id: M.Generated(IntegrationConnectionId), provider: IntegrationProvider, organizationId: OrganizationId, - userId: Schema.NullOr(UserId), + userId: S.NullOr(UserId), level: ConnectionLevel, status: ConnectionStatus, - externalAccountId: Schema.NullOr(Schema.String), - externalAccountName: Schema.NullOr(Schema.String), + externalAccountId: S.NullOr(S.String), + externalAccountName: S.NullOr(S.String), connectedBy: UserId, - settings: Schema.NullOr(Schema.Record({ key: Schema.String, value: Schema.Unknown })), - metadata: Schema.NullOr(Schema.Record({ key: Schema.String, value: Schema.Unknown })), - errorMessage: Schema.NullOr(Schema.String), - lastUsedAt: Schema.NullOr(JsonDate), + settings: S.NullOr(S.Record(S.String, S.Unknown)), + metadata: S.NullOr(S.Record(S.String, S.Unknown)), + errorMessage: S.NullOr(S.String), + lastUsedAt: S.NullOr(JsonDate), createdAt: M.Generated(JsonDate), - updatedAt: M.Generated(Schema.NullOr(JsonDate)), - deletedAt: M.GeneratedByApp(Schema.NullOr(JsonDate)), + updatedAt: M.Generated(S.NullOr(JsonDate)), + deletedAt: M.GeneratedByApp(S.NullOr(JsonDate)), }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/integration-request-model.ts b/packages/domain/src/models/integration-request-model.ts index 1ddd519bd..490885335 100644 --- a/packages/domain/src/models/integration-request-model.ts +++ b/packages/domain/src/models/integration-request-model.ts @@ -1,22 +1,22 @@ import { IntegrationRequestId, OrganizationId, UserId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import * as M from "./utils" import { JsonDate } from "./utils" -export const IntegrationRequestStatus = Schema.Literal("pending", "reviewed", "planned", "rejected") -export type IntegrationRequestStatus = Schema.Schema.Type +export const IntegrationRequestStatus = S.Literals(["pending", "reviewed", "planned", "rejected"]) +export type IntegrationRequestStatus = S.Schema.Type -export class Model extends M.Class("IntegrationRequest")({ +class Model extends M.Class("IntegrationRequest")({ id: M.Generated(IntegrationRequestId), organizationId: OrganizationId, requestedBy: UserId, - integrationName: Schema.NonEmptyTrimmedString, - integrationUrl: Schema.NullOr(Schema.String), - description: Schema.NullOr(Schema.String), + integrationName: S.NonEmptyString, + integrationUrl: S.NullOr(S.String), + description: S.NullOr(S.String), status: IntegrationRequestStatus, createdAt: M.Generated(JsonDate), updatedAt: M.Generated(JsonDate), }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/integration-token-model.ts b/packages/domain/src/models/integration-token-model.ts index 941d0dd5f..58318d120 100644 --- a/packages/domain/src/models/integration-token-model.ts +++ b/packages/domain/src/models/integration-token-model.ts @@ -1,24 +1,24 @@ import { IntegrationConnectionId, IntegrationTokenId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import * as M from "./utils" import { JsonDate } from "./utils" -export class Model extends M.Class("IntegrationToken")({ +class Model extends M.Class("IntegrationToken")({ id: M.Generated(IntegrationTokenId), connectionId: IntegrationConnectionId, - encryptedAccessToken: Schema.String, - encryptedRefreshToken: Schema.NullOr(Schema.String), - iv: Schema.String, - refreshTokenIv: Schema.NullOr(Schema.String), - encryptionKeyVersion: Schema.Number, - tokenType: Schema.NullOr(Schema.String), - scope: Schema.NullOr(Schema.String), - expiresAt: Schema.NullOr(JsonDate), - refreshTokenExpiresAt: Schema.NullOr(JsonDate), - lastRefreshedAt: Schema.NullOr(JsonDate), + encryptedAccessToken: S.String, + encryptedRefreshToken: S.NullOr(S.String), + iv: S.String, + refreshTokenIv: S.NullOr(S.String), + encryptionKeyVersion: S.Number, + tokenType: S.NullOr(S.String), + scope: S.NullOr(S.String), + expiresAt: S.NullOr(JsonDate), + refreshTokenExpiresAt: S.NullOr(JsonDate), + lastRefreshedAt: S.NullOr(JsonDate), createdAt: M.Generated(JsonDate), updatedAt: M.Generated(JsonDate), }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/invitation-model.ts b/packages/domain/src/models/invitation-model.ts index 75242befa..906ca6af0 100644 --- a/packages/domain/src/models/invitation-model.ts +++ b/packages/domain/src/models/invitation-model.ts @@ -1,24 +1,24 @@ import { InvitationId, OrganizationId, UserId, WorkOSInvitationId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import * as M from "./utils" import { JsonDate } from "./utils" -export const InvitationStatus = Schema.Literal("pending", "accepted", "expired", "revoked") -export type InvitationStatus = Schema.Schema.Type +export const InvitationStatus = S.Literals(["pending", "accepted", "expired", "revoked"]) +export type InvitationStatus = S.Schema.Type -export class Model extends M.Class("Invitation")({ +class Model extends M.Class("Invitation")({ id: M.Generated(InvitationId), - invitationUrl: Schema.String, + invitationUrl: S.String, workosInvitationId: WorkOSInvitationId, organizationId: OrganizationId, - email: Schema.String, - invitedBy: Schema.NullOr(UserId), + email: S.String, + invitedBy: S.NullOr(UserId), invitedAt: JsonDate, expiresAt: JsonDate, status: InvitationStatus, - acceptedAt: Schema.NullOr(JsonDate), - acceptedBy: Schema.NullOr(UserId), + acceptedAt: S.NullOr(JsonDate), + acceptedBy: S.NullOr(UserId), }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/message-embed-schema.ts b/packages/domain/src/models/message-embed-schema.ts index 66e4d5d41..60eda0fca 100644 --- a/packages/domain/src/models/message-embed-schema.ts +++ b/packages/domain/src/models/message-embed-schema.ts @@ -2,21 +2,21 @@ import { Schema } from "effect" // Embed author section export const MessageEmbedAuthor = Schema.Struct({ - name: Schema.String.pipe(Schema.maxLength(256)), - url: Schema.optional(Schema.String.pipe(Schema.maxLength(2048))), - iconUrl: Schema.optional(Schema.String.pipe(Schema.maxLength(2048))), + name: Schema.String.check(Schema.isMaxLength(256)), + url: Schema.optional(Schema.String.check(Schema.isMaxLength(2048))), + iconUrl: Schema.optional(Schema.String.check(Schema.isMaxLength(2048))), }) export type MessageEmbedAuthor = Schema.Schema.Type // Embed footer section export const MessageEmbedFooter = Schema.Struct({ - text: Schema.String.pipe(Schema.maxLength(2048)), - iconUrl: Schema.optional(Schema.String.pipe(Schema.maxLength(2048))), + text: Schema.String.check(Schema.isMaxLength(2048)), + iconUrl: Schema.optional(Schema.String.check(Schema.isMaxLength(2048))), }) export type MessageEmbedFooter = Schema.Schema.Type // Badge intent for field styling -export const BadgeIntent = Schema.Literal( +export const BadgeIntent = Schema.Literals([ "primary", "secondary", "success", @@ -24,11 +24,11 @@ export const BadgeIntent = Schema.Literal( "warning", "danger", "outline", -) +]) export type BadgeIntent = Schema.Schema.Type // Field type for rendering mode -export const MessageEmbedFieldType = Schema.Literal("text", "badge") +export const MessageEmbedFieldType = Schema.Literals(["text", "badge"]) export type MessageEmbedFieldType = Schema.Schema.Type // Field options for type-specific settings @@ -39,8 +39,8 @@ export type MessageEmbedFieldOptions = Schema.Schema.Type // Embed badge (for status indicators) export const MessageEmbedBadge = Schema.Struct({ - text: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(64)), - color: Schema.optional(Schema.Number.pipe(Schema.int(), Schema.between(0, 16777215))), // 0x000000 to 0xFFFFFF + text: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(64)), + color: Schema.optional( + Schema.Number.check(Schema.isInt(), Schema.isBetween({ minimum: 0, maximum: 16777215 })), + ), // 0x000000 to 0xFFFFFF }) export type MessageEmbedBadge = Schema.Schema.Type // Agent step for cached state (matches actor's AgentStep) export const CachedAgentStep = Schema.Struct({ id: Schema.String, - type: Schema.Literal("thinking", "tool_call", "tool_result", "text", "error"), - status: Schema.Literal("pending", "active", "completed", "failed"), + type: Schema.Literals(["thinking", "tool_call", "tool_result", "text", "error"]), + status: Schema.Literals(["pending", "active", "completed", "failed"]), content: Schema.optional(Schema.String), toolName: Schema.optional(Schema.String), - toolInput: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Unknown })), + toolInput: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), toolOutput: Schema.optional(Schema.Unknown), toolError: Schema.optional(Schema.String), startedAt: Schema.optional(Schema.Number), @@ -71,8 +73,8 @@ export type CachedAgentStep = Schema.Schema.Type // Live state cached snapshot for non-realtime clients export const MessageEmbedLiveStateCached = Schema.Struct({ - status: Schema.Literal("idle", "active", "completed", "failed"), - data: Schema.Record({ key: Schema.String, value: Schema.Unknown }), + status: Schema.Literals(["idle", "active", "completed", "failed"]), + data: Schema.Record(Schema.String, Schema.Unknown), text: Schema.optional(Schema.String), progress: Schema.optional(Schema.Number), error: Schema.optional(Schema.String), @@ -85,7 +87,7 @@ export const MessageEmbedLoadingState = Schema.Struct({ /** Text to display while loading (default: "Thinking...") */ text: Schema.optional(Schema.String), /** Icon to display: "sparkle" or "brain" (default: "sparkle") */ - icon: Schema.optional(Schema.Literal("sparkle", "brain")), + icon: Schema.optional(Schema.Literals(["sparkle", "brain"])), /** Whether to show spinning animation on the icon (default: true) */ showSpinner: Schema.optional(Schema.Boolean), /** Whether to pulse/throb the entire loading indicator (default: false) */ @@ -104,15 +106,17 @@ export type MessageEmbedLiveState = Schema.Schema.Type +export const LinkType = S.Literals(["created", "mentioned", "resolved", "linked"]) +export type LinkType = S.Schema.Type -export class Model extends M.Class("MessageIntegrationLink")({ +class Model extends M.Class("MessageIntegrationLink")({ id: M.Generated(MessageIntegrationLinkId), messageId: MessageId, connectionId: IntegrationConnectionId, provider: IntegrationProvider, - externalId: Schema.String, - externalUrl: Schema.String, - externalTitle: Schema.NullOr(Schema.String), + externalId: S.String, + externalUrl: S.String, + externalTitle: S.NullOr(S.String), linkType: LinkType, - metadata: Schema.NullOr(Schema.Record({ key: Schema.String, value: Schema.Unknown })), + metadata: S.NullOr(S.Record(S.String, S.Unknown)), createdAt: M.Generated(JsonDate), updatedAt: M.Generated(JsonDate), }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/message-model.ts b/packages/domain/src/models/message-model.ts index fbd1b9263..3fd7a0687 100644 --- a/packages/domain/src/models/message-model.ts +++ b/packages/domain/src/models/message-model.ts @@ -1,33 +1,45 @@ import { AttachmentId, ChannelId, ConnectConversationId, MessageId, UserId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import { MessageEmbeds } from "./message-embed-schema" import * as M from "./utils" import { baseFields } from "./utils" -export class Model extends M.Class("Message")({ +class Model extends M.Class("Message")({ id: M.Generated(MessageId), channelId: ChannelId, - conversationId: M.GeneratedOptional(Schema.NullOr(ConnectConversationId)), + conversationId: M.GeneratedOptional(S.NullOr(ConnectConversationId)), authorId: M.GeneratedByApp(UserId), - content: Schema.String, - embeds: Schema.NullOr(MessageEmbeds), - replyToMessageId: Schema.NullOr(MessageId), - threadChannelId: Schema.NullOr(ChannelId), + content: S.String, + embeds: S.NullOr(MessageEmbeds), + replyToMessageId: S.NullOr(MessageId), + threadChannelId: S.NullOr(ChannelId), ...baseFields, }) {} // Custom insert schema that includes attachmentIds for linking -export const Insert = Schema.Struct({ - ...Model.insert.fields, - conversationId: Schema.optional(Schema.NullOr(ConnectConversationId)), - attachmentIds: Schema.optional(Schema.Array(AttachmentId)), +export const Insert = S.Struct({ + ...M.structFields(Model.insert), + conversationId: S.optional(S.NullOr(ConnectConversationId)), + attachmentIds: S.optional(S.Array(AttachmentId)), }) -export const Update = Model.update +export const { Update, Schema } = M.expose(Model) /** * Custom update schema for JSON API - only allows mutable fields. * Excludes immutable relationship fields (channelId, replyToMessageId, threadChannelId) * to prevent users from moving messages between channels or fabricating conversation context. */ -export const JsonUpdate = Model.jsonUpdate.pipe(Schema.pick("content", "embeds"), Schema.partial) +const JsonUpdate = S.Struct({ + content: S.optionalKey(M.structFields(Model.jsonUpdate).content), + embeds: S.optionalKey(M.structFields(Model.jsonUpdate).embeds), +}) + +export type Type = typeof Schema.Type + +export const Create = S.Struct({ + ...M.structFields(Model.jsonCreate), + attachmentIds: S.optional(S.Array(AttachmentId)), +}) + +export const Patch = JsonUpdate diff --git a/packages/domain/src/models/message-reaction-model.ts b/packages/domain/src/models/message-reaction-model.ts index 313168989..c441f8c96 100644 --- a/packages/domain/src/models/message-reaction-model.ts +++ b/packages/domain/src/models/message-reaction-model.ts @@ -1,20 +1,21 @@ import { ChannelId, ConnectConversationId, MessageId, MessageReactionId, UserId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import * as M from "./utils" import { JsonDate } from "./utils" -export class Model extends M.Class("MessageReaction")({ +class Model extends M.Class("MessageReaction")({ id: M.Generated(MessageReactionId), messageId: MessageId, channelId: ChannelId, - conversationId: M.GeneratedOptional(Schema.NullOr(ConnectConversationId)), + conversationId: M.GeneratedOptional(S.NullOr(ConnectConversationId)), userId: M.GeneratedByApp(UserId), - emoji: Schema.String, + emoji: S.String, createdAt: M.Generated(JsonDate), }) {} -export const Insert = Schema.Struct({ - ...Model.insert.fields, - conversationId: Schema.optional(Schema.NullOr(ConnectConversationId)), +export const Insert = S.Struct({ + ...M.structFields(Model.insert), + conversationId: S.optional(S.NullOr(ConnectConversationId)), }) -export const Update = Model.update +export const { Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/notification-model.ts b/packages/domain/src/models/notification-model.ts index 70f93f6cd..fed35e45e 100644 --- a/packages/domain/src/models/notification-model.ts +++ b/packages/domain/src/models/notification-model.ts @@ -1,18 +1,18 @@ import { NotificationId, OrganizationMemberId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import * as M from "./utils" import { JsonDate } from "./utils" -export class Model extends M.Class("Notification")({ +class Model extends M.Class("Notification")({ id: M.Generated(NotificationId), memberId: OrganizationMemberId, - targetedResourceId: Schema.NullOr(Schema.UUID), - targetedResourceType: Schema.NullOr(Schema.String), - resourceId: Schema.NullOr(Schema.UUID), - resourceType: Schema.NullOr(Schema.String), + targetedResourceId: S.NullOr(S.String.check(S.isUUID())), + targetedResourceType: S.NullOr(S.String), + resourceId: S.NullOr(S.String.check(S.isUUID())), + resourceType: S.NullOr(S.String), createdAt: M.Generated(JsonDate), - readAt: Schema.NullOr(JsonDate), + readAt: S.NullOr(JsonDate), }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/organization-member-model.ts b/packages/domain/src/models/organization-member-model.ts index 823f22a26..3110bf277 100644 --- a/packages/domain/src/models/organization-member-model.ts +++ b/packages/domain/src/models/organization-member-model.ts @@ -1,22 +1,22 @@ import { OrganizationId, OrganizationMemberId, UserId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import * as M from "./utils" import { baseFields, JsonDate } from "./utils" -export const OrganizationRole = Schema.Literal("admin", "member", "owner") -export type OrganizationRole = Schema.Schema.Type +export const OrganizationRole = S.Literals(["admin", "member", "owner"]) +export type OrganizationRole = S.Schema.Type -export class Model extends M.Class("OrganizationMember")({ +class Model extends M.Class("OrganizationMember")({ id: M.Generated(OrganizationMemberId), organizationId: OrganizationId, userId: M.GeneratedByApp(UserId), role: OrganizationRole, - nickname: Schema.NullishOr(Schema.String), + nickname: S.NullishOr(S.String), joinedAt: JsonDate, - invitedBy: Schema.NullOr(UserId), - deletedAt: Schema.NullOr(JsonDate), + invitedBy: S.NullOr(UserId), + deletedAt: S.NullOr(JsonDate), createdAt: M.Generated(JsonDate), }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/organization-model.ts b/packages/domain/src/models/organization-model.ts index b83018c2f..4db1b7a30 100644 --- a/packages/domain/src/models/organization-model.ts +++ b/packages/domain/src/models/organization-model.ts @@ -1,22 +1,17 @@ import { OrganizationId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import * as M from "./utils" import { baseFields } from "./utils" -export class Model extends M.Class("Organization")({ +class Model extends M.Class("Organization")({ id: M.Generated(OrganizationId), - name: Schema.String, - slug: Schema.NullOr(Schema.String), - logoUrl: Schema.NullOr(Schema.String), - settings: Schema.NullOr( - Schema.Record({ - key: Schema.String, - value: Schema.Unknown, - }), - ), - isPublic: Schema.Boolean, + name: S.String, + slug: S.NullOr(S.String), + logoUrl: S.NullOr(S.String), + settings: S.NullOr(S.Record(S.String, S.Unknown)), + isPublic: S.Boolean, ...baseFields, }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/pinned-message-model.ts b/packages/domain/src/models/pinned-message-model.ts index ea6970ddd..5b855c02b 100644 --- a/packages/domain/src/models/pinned-message-model.ts +++ b/packages/domain/src/models/pinned-message-model.ts @@ -2,7 +2,7 @@ import { ChannelId, MessageId, PinnedMessageId, UserId } from "@hazel/schema" import * as M from "./utils" import { JsonDate } from "./utils" -export class Model extends M.Class("PinnedMessage")({ +class Model extends M.Class("PinnedMessage")({ id: M.Generated(PinnedMessageId), channelId: ChannelId, messageId: MessageId, @@ -10,5 +10,5 @@ export class Model extends M.Class("PinnedMessage")({ pinnedAt: JsonDate, }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/rss-subscription-model.ts b/packages/domain/src/models/rss-subscription-model.ts index 150889813..a7e35dbc2 100644 --- a/packages/domain/src/models/rss-subscription-model.ts +++ b/packages/domain/src/models/rss-subscription-model.ts @@ -1,28 +1,28 @@ import { ChannelId, OrganizationId, RssSubscriptionId, UserId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import * as M from "./utils" import { JsonDate } from "./utils" -export class Model extends M.Class("RssSubscription")({ +class Model extends M.Class("RssSubscription")({ id: M.Generated(RssSubscriptionId), channelId: ChannelId, organizationId: OrganizationId, - feedUrl: Schema.String, - feedTitle: Schema.NullOr(Schema.String), - feedIconUrl: Schema.NullOr(Schema.String), - lastFetchedAt: M.Generated(Schema.NullOr(JsonDate)), - lastItemPublishedAt: M.Generated(Schema.NullOr(JsonDate)), - lastItemGuid: M.Generated(Schema.NullOr(Schema.String)), - consecutiveErrors: M.Generated(Schema.Number), - lastErrorMessage: M.Generated(Schema.NullOr(Schema.String)), - lastErrorAt: M.Generated(Schema.NullOr(JsonDate)), - isEnabled: Schema.Boolean, - pollingIntervalMinutes: Schema.Number, + feedUrl: S.String, + feedTitle: S.NullOr(S.String), + feedIconUrl: S.NullOr(S.String), + lastFetchedAt: M.Generated(S.NullOr(JsonDate)), + lastItemPublishedAt: M.Generated(S.NullOr(JsonDate)), + lastItemGuid: M.Generated(S.NullOr(S.String)), + consecutiveErrors: M.Generated(S.Number), + lastErrorMessage: M.Generated(S.NullOr(S.String)), + lastErrorAt: M.Generated(S.NullOr(JsonDate)), + isEnabled: S.Boolean, + pollingIntervalMinutes: S.Number, createdBy: UserId, createdAt: M.Generated(JsonDate), - updatedAt: M.Generated(Schema.NullOr(JsonDate)), - deletedAt: M.GeneratedByApp(Schema.NullOr(JsonDate)), + updatedAt: M.Generated(S.NullOr(JsonDate)), + deletedAt: M.GeneratedByApp(S.NullOr(JsonDate)), }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/schema-migration.test.ts b/packages/domain/src/models/schema-migration.test.ts new file mode 100644 index 000000000..b47bb5944 --- /dev/null +++ b/packages/domain/src/models/schema-migration.test.ts @@ -0,0 +1,123 @@ +import { Schema } from "effect" +import { describe, expect, it } from "vitest" +import { Channel, ChannelWebhook, Message, Organization } from "./index" + +const ORGANIZATION_ID = "00000000-0000-4000-8000-000000000001" +const CHANNEL_ID = "00000000-0000-4000-8000-000000000002" +const CHANNEL_SECTION_ID = "00000000-0000-4000-8000-000000000003" +const USER_ID = "00000000-0000-4000-8000-000000000004" +const MESSAGE_ID = "00000000-0000-4000-8000-000000000005" +const ATTACHMENT_ID = "00000000-0000-4000-8000-000000000006" +const WEBHOOK_ID = "00000000-0000-4000-8000-000000000007" +const CREATED_AT = "2026-03-17T12:00:00.000Z" +const UPDATED_AT = "2026-03-17T12:30:00.000Z" + +describe("model-derived entity schemas", () => { + it("roundtrips a normal entity public payload without changing the wire shape", () => { + const raw = { + id: ORGANIZATION_ID, + name: "Hazel", + slug: "hazel", + logoUrl: null, + settings: { theme: "light" }, + isPublic: false, + createdAt: CREATED_AT, + updatedAt: UPDATED_AT, + deletedAt: null, + } + + const decoded = Schema.decodeUnknownSync(Organization.Schema)(raw) + const encoded = Schema.encodeUnknownSync(Organization.Schema)(decoded) + + expect(encoded).toEqual(raw) + }) + + it("keeps optimistic ids on create while leaving repo-only fields in insert", () => { + const createPayload = { + id: CHANNEL_ID, + name: "general", + icon: null, + type: "public" as const, + organizationId: ORGANIZATION_ID, + parentChannelId: null, + sectionId: CHANNEL_SECTION_ID, + } + + const decodedCreate = Schema.decodeUnknownSync(Channel.Create)(createPayload) + const decodedInsert = Schema.decodeUnknownSync(Channel.Insert)({ + ...createPayload, + deletedAt: null, + }) + + expect(decodedCreate.id).toBe(CHANNEL_ID) + expect("deletedAt" in decodedCreate).toBe(false) + expect(decodedInsert.deletedAt).toBeNull() + }) + + it("keeps sensitive row-only fields out of the public webhook schema", () => { + const row = { + id: WEBHOOK_ID, + channelId: CHANNEL_ID, + organizationId: ORGANIZATION_ID, + botUserId: USER_ID, + name: "Deploy hook", + description: null, + avatarUrl: null, + tokenHash: "hashed-token", + tokenSuffix: "cafe", + isEnabled: true, + createdBy: USER_ID, + lastUsedAt: null, + createdAt: CREATED_AT, + updatedAt: UPDATED_AT, + deletedAt: null, + } + + const publicRaw = { + ...row, + tokenHash: undefined, + } + delete publicRaw.tokenHash + + expect("tokenHash" in ChannelWebhook.Row.fields).toBe(true) + expect("tokenHash" in ChannelWebhook.Schema.fields).toBe(false) + expect(Schema.decodeUnknownSync(ChannelWebhook.Row)(row).tokenHash).toBe("hashed-token") + expect( + Schema.encodeUnknownSync(ChannelWebhook.Schema)( + Schema.decodeUnknownSync(ChannelWebhook.Schema)(publicRaw), + ), + ).toEqual(publicRaw) + }) + + it("preserves the custom message create and patch shapes", () => { + const createPayload = { + channelId: CHANNEL_ID, + authorId: USER_ID, + content: "hello", + embeds: null, + replyToMessageId: null, + threadChannelId: null, + attachmentIds: [ATTACHMENT_ID], + } + + const messageRaw = { + id: MESSAGE_ID, + channelId: CHANNEL_ID, + conversationId: null, + authorId: USER_ID, + content: "hello", + embeds: null, + replyToMessageId: null, + threadChannelId: null, + createdAt: CREATED_AT, + updatedAt: UPDATED_AT, + deletedAt: null, + } + + expect(Schema.decodeUnknownSync(Message.Create)(createPayload).attachmentIds).toEqual([ATTACHMENT_ID]) + expect(Object.keys(Message.Patch.fields).sort()).toEqual(["content", "embeds"]) + expect( + Schema.encodeUnknownSync(Message.Schema)(Schema.decodeUnknownSync(Message.Schema)(messageRaw)), + ).toEqual(messageRaw) + }) +}) diff --git a/packages/domain/src/models/theme-model.ts b/packages/domain/src/models/theme-model.ts index c0b7c4e6f..cc607823f 100644 --- a/packages/domain/src/models/theme-model.ts +++ b/packages/domain/src/models/theme-model.ts @@ -3,7 +3,7 @@ import { Schema } from "effect" /** * Available gray palette options */ -export const GrayPalette = Schema.Literal( +export const GrayPalette = Schema.Literals([ "gray", "gray-blue", "gray-cool", @@ -12,23 +12,21 @@ export const GrayPalette = Schema.Literal( "gray-iron", "gray-true", "gray-warm", -) +]) export type GrayPalette = Schema.Schema.Type /** * Border radius preset options */ -export const RadiusPreset = Schema.Literal("tight", "normal", "round", "full") +export const RadiusPreset = Schema.Literals(["tight", "normal", "round", "full"]) export type RadiusPreset = Schema.Schema.Type /** * Hex color string branded type (e.g., "#6938EF") */ -export const HexColor = Schema.String.pipe( - Schema.pattern(/^#[0-9A-Fa-f]{6}$/), - Schema.brand("HexColor"), - Schema.annotations({ message: () => "Must be a valid hex color (#RRGGBB)" }), -) +export const HexColor = Schema.String.check( + Schema.isPattern(/^#[0-9A-Fa-f]{6}$/, { message: "Must be a valid hex color (#RRGGBB)" }), +).pipe(Schema.brand("HexColor")) export type HexColor = Schema.Schema.Type /** @@ -91,23 +89,21 @@ export type ThemePreset = Schema.Schema.Type /** * Display mode preference */ -export const DisplayMode = Schema.Literal("light", "dark", "system") +export const DisplayMode = Schema.Literals(["light", "dark", "system"]) export type DisplayMode = Schema.Schema.Type /** * User theme settings stored in the database. * Using mutable() to ensure compatibility with TanStack DB collections. */ -export const UserThemeSettings = Schema.mutable( - Schema.Struct({ - /** Active preset ID ("default", "ocean", etc.) or custom preset ID */ - activePresetId: Schema.NullOr(Schema.String), - /** Custom theme when not using a preset */ - customTheme: Schema.NullOr(Schema.mutable(ThemeCustomization)), - /** User's saved custom presets */ - savedPresets: Schema.optional(Schema.mutable(Schema.Array(Schema.mutable(ThemePreset)))), - /** Display mode preference */ - mode: DisplayMode, - }), -) +export const UserThemeSettings = Schema.Struct({ + /** Active preset ID ("default", "ocean", etc.) or custom preset ID */ + activePresetId: Schema.NullOr(Schema.String), + /** Custom theme when not using a preset */ + customTheme: Schema.NullOr(ThemeCustomization), + /** User's saved custom presets */ + savedPresets: Schema.optional(Schema.mutable(Schema.Array(ThemePreset))), + /** Display mode preference */ + mode: DisplayMode, +}) export type UserThemeSettings = Schema.Schema.Type diff --git a/packages/domain/src/models/typing-indicator-model.ts b/packages/domain/src/models/typing-indicator-model.ts index 7a7092265..5795e22b8 100644 --- a/packages/domain/src/models/typing-indicator-model.ts +++ b/packages/domain/src/models/typing-indicator-model.ts @@ -1,16 +1,16 @@ import { ChannelId, ChannelMemberId, TypingIndicatorId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import * as M from "./utils" -export class Model extends M.Class("TypingIndicator")({ +class Model extends M.Class("TypingIndicator")({ id: M.Generated(TypingIndicatorId), channelId: ChannelId, memberId: ChannelMemberId, - lastTyped: Schema.Number.annotations({ + lastTyped: S.Number.annotate({ title: "LastTyped", description: "Unix timestamp of last typing activity", }), }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/user-model.ts b/packages/domain/src/models/user-model.ts index c7bb0005d..197268403 100644 --- a/packages/domain/src/models/user-model.ts +++ b/packages/domain/src/models/user-model.ts @@ -1,46 +1,45 @@ import { UserId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import { UserThemeSettings } from "./theme-model" import * as M from "./utils" import { baseFields } from "./utils" -export const UserType = Schema.Literal("user", "machine") -export type UserType = Schema.Schema.Type +export const UserType = S.Literals(["user", "machine"]) +export type UserType = S.Schema.Type /** * Time in HH:MM format (00:00 - 23:59) */ -export const TimeString = Schema.String.pipe( - Schema.pattern(/^([01]\d|2[0-3]):([0-5]\d)$/), - Schema.brand("TimeString"), +export const TimeString = S.String.check(S.isPattern(/^([01]\d|2[0-3]):([0-5]\d)$/)).pipe( + S.brand("TimeString"), ) -export type TimeString = Schema.Schema.Type +export type TimeString = S.Schema.Type /** * Schema for user settings stored in the database */ -export const UserSettingsSchema = Schema.Struct({ - doNotDisturb: Schema.optional(Schema.Boolean), - quietHoursStart: Schema.optional(TimeString), - quietHoursEnd: Schema.optional(TimeString), - showQuietHoursInStatus: Schema.optional(Schema.Boolean), - theme: Schema.optional(UserThemeSettings), +export const UserSettingsSchema = S.Struct({ + doNotDisturb: S.optional(S.Boolean), + quietHoursStart: S.optional(TimeString), + quietHoursEnd: S.optional(TimeString), + showQuietHoursInStatus: S.optional(S.Boolean), + theme: S.optional(UserThemeSettings), }) -export type UserSettings = Schema.Schema.Type +export type UserSettings = S.Schema.Type -export class Model extends M.Class("User")({ +class Model extends M.Class("User")({ id: M.Generated(UserId), - externalId: Schema.String, - email: Schema.String, - firstName: Schema.String, - lastName: Schema.String, - avatarUrl: Schema.NullishOr(Schema.NonEmptyTrimmedString), + externalId: S.String, + email: S.String, + firstName: S.String, + lastName: S.String, + avatarUrl: S.NullishOr(S.NonEmptyString), userType: UserType, - settings: Schema.NullOr(UserSettingsSchema), - isOnboarded: Schema.Boolean, - timezone: Schema.NullOr(Schema.String), + settings: S.NullOr(UserSettingsSchema), + isOnboarded: S.Boolean, + timezone: S.NullOr(S.String), ...baseFields, }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/user-presence-status-model.ts b/packages/domain/src/models/user-presence-status-model.ts index fd6da59e7..5d9cab7f3 100644 --- a/packages/domain/src/models/user-presence-status-model.ts +++ b/packages/domain/src/models/user-presence-status-model.ts @@ -1,23 +1,23 @@ import { ChannelId, UserId, UserPresenceStatusId } from "@hazel/schema" -import { Schema } from "effect" +import { Schema as S } from "effect" import * as M from "./utils" import { JsonDate } from "./utils" -export const UserPresenceStatusEnum = Schema.Literal("online", "away", "busy", "dnd", "offline") -export type UserPresenceStatusEnum = Schema.Schema.Type +export const UserPresenceStatusEnum = S.Literals(["online", "away", "busy", "dnd", "offline"]) +export type UserPresenceStatusEnum = S.Schema.Type -export class Model extends M.Class("UserPresenceStatus")({ +class Model extends M.Class("UserPresenceStatus")({ id: M.Generated(UserPresenceStatusId), userId: UserId, status: UserPresenceStatusEnum, - customMessage: Schema.NullOr(Schema.String), - statusEmoji: Schema.NullOr(Schema.String), - statusExpiresAt: Schema.NullOr(JsonDate), - activeChannelId: Schema.NullOr(ChannelId), - suppressNotifications: Schema.Boolean, + customMessage: S.NullOr(S.String), + statusEmoji: S.NullOr(S.String), + statusExpiresAt: S.NullOr(JsonDate), + activeChannelId: S.NullOr(ChannelId), + suppressNotifications: S.Boolean, updatedAt: JsonDate, lastSeenAt: JsonDate, }) {} -export const Insert = Model.insert -export const Update = Model.update +export const { Insert, Update, Schema, Create, Patch } = M.expose(Model) +export type Type = typeof Schema.Type diff --git a/packages/domain/src/models/utils.ts b/packages/domain/src/models/utils.ts index 918887e60..717942538 100644 --- a/packages/domain/src/models/utils.ts +++ b/packages/domain/src/models/utils.ts @@ -1,33 +1,24 @@ -import * as VariantSchema from "@effect/experimental/VariantSchema" +import * as VariantSchema from "effect/unstable/schema/VariantSchema" import type { Brand } from "effect/Brand" import * as DateTime from "effect/DateTime" import * as Effect from "effect/Effect" import * as Option from "effect/Option" -import * as ParseResult from "effect/ParseResult" import * as Schema from "effect/Schema" +import * as SchemaGetter from "effect/SchemaGetter" +import * as SchemaIssue from "effect/SchemaIssue" -const { Class, Field, FieldExcept, FieldOnly, Struct, Union, extract, fieldEvolve, fieldFromKey } = - VariantSchema.make({ - variants: ["select", "insert", "update", "json", "jsonCreate", "jsonUpdate"], - defaultVariant: "select", - }) - -export type Any = Schema.Schema.Any & { - readonly fields: Schema.Struct.Fields - readonly insert: Schema.Schema.Any - readonly update: Schema.Schema.Any - readonly json: Schema.Schema.Any - readonly jsonCreate: Schema.Schema.Any - readonly jsonUpdate: Schema.Schema.Any -} +const { Class, Field, FieldExcept, FieldOnly, Struct, Union, extract, fieldEvolve } = VariantSchema.make({ + variants: ["select", "insert", "update", "json", "jsonCreate", "jsonUpdate"], + defaultVariant: "select", +}) -export type AnyNoContext = Schema.Schema.AnyNoContext & { +export type Any = Schema.Top & { readonly fields: Schema.Struct.Fields - readonly insert: Schema.Schema.AnyNoContext - readonly update: Schema.Schema.AnyNoContext - readonly json: Schema.Schema.AnyNoContext - readonly jsonCreate: Schema.Schema.AnyNoContext - readonly jsonUpdate: Schema.Schema.AnyNoContext + readonly insert: Schema.Top + readonly update: Schema.Top + readonly json: Schema.Top + readonly jsonCreate: Schema.Top + readonly jsonUpdate: Schema.Top } export type VariantsDatabase = "select" | "insert" | "update" @@ -59,41 +50,39 @@ export { Field, fieldEvolve, FieldExcept, - fieldFromKey, FieldOnly, Struct, Union, } -export const fields: >(self: A) => A[VariantSchema.TypeId] = +export const fields: >(self: A) => A[typeof VariantSchema.TypeId] = VariantSchema.fields +export const structFields = (self: A): A["fields"] => + self.fields + export const Override: (value: A) => A & Brand<"Override"> = VariantSchema.Override -export interface Generated< - S extends Schema.Schema.All | Schema.PropertySignature.All, -> extends VariantSchema.Field<{ +export interface Generated extends VariantSchema.Field<{ readonly select: S readonly update: S readonly json: S }> {} /** A field for database-generated columns (available for select and update, not insert). */ -export const Generated = ( - schema: S, -): Generated => +export const Generated = (schema: S): Generated => Field({ select: schema, update: schema, json: schema, }) -export interface GeneratedOptional extends VariantSchema.Field<{ +export interface GeneratedOptional extends VariantSchema.Field<{ readonly select: S - readonly insert: Schema.optionalWith + readonly insert: Schema.optionalKey readonly update: S readonly json: S - readonly jsonCreate: Schema.optionalWith + readonly jsonCreate: Schema.optionalKey }> {} /** @@ -102,18 +91,16 @@ export interface GeneratedOptional extends VariantS * - Required for select, update, and json * - Optional for insert and jsonCreate (if not provided, DB generates the value) */ -export const GeneratedOptional = (schema: S): GeneratedOptional => +export const GeneratedOptional = (schema: S): GeneratedOptional => Field({ select: schema, - insert: Schema.optionalWith(schema, { exact: true }), + insert: Schema.optionalKey(schema), update: schema, json: schema, - jsonCreate: Schema.optionalWith(schema, { exact: true }), + jsonCreate: Schema.optionalKey(schema), }) -export interface GeneratedByApp< - S extends Schema.Schema.All | Schema.PropertySignature.All, -> extends VariantSchema.Field<{ +export interface GeneratedByApp extends VariantSchema.Field<{ readonly select: S readonly insert: S readonly update: S @@ -121,9 +108,7 @@ export interface GeneratedByApp< }> {} /** A field for application-generated columns (required for DB variants, optional for JSON). */ -export const GeneratedByApp = ( - schema: S, -): GeneratedByApp => +export const GeneratedByApp = (schema: S): GeneratedByApp => Field({ select: schema, insert: schema, @@ -131,185 +116,148 @@ export const GeneratedByApp = extends VariantSchema.Field<{ +export interface Sensitive extends VariantSchema.Field<{ readonly select: S readonly insert: S readonly update: S }> {} /** A field for sensitive values hidden from JSON variants. */ -export const Sensitive = ( - schema: S, -): Sensitive => +export const Sensitive = (schema: S): Sensitive => Field({ select: schema, insert: schema, update: schema, }) -export interface FieldOption extends VariantSchema.Field<{ +export interface FieldOption extends VariantSchema.Field<{ readonly select: Schema.OptionFromNullOr readonly insert: Schema.OptionFromNullOr readonly update: Schema.OptionFromNullOr - readonly json: Schema.optionalWith - readonly jsonCreate: Schema.optionalWith - readonly jsonUpdate: Schema.optionalWith + readonly json: Schema.OptionFromOptionalNullOr + readonly jsonCreate: Schema.OptionFromOptionalNullOr + readonly jsonUpdate: Schema.OptionFromOptionalNullOr }> {} /** Makes a field optional for all variants (nullable for DB, optional for JSON). */ -export const FieldOption: | Schema.Schema.Any>( +export const FieldOption: | Schema.Top>( self: Field, -) => Field extends Schema.Schema.Any +) => Field extends Schema.Top ? FieldOption : Field extends VariantSchema.Field ? VariantSchema.Field<{ - readonly [K in keyof S]: S[K] extends Schema.Schema.Any + readonly [K in keyof S]: S[K] extends Schema.Top ? K extends VariantsDatabase ? Schema.OptionFromNullOr - : Schema.optionalWith + : Schema.OptionFromOptionalNullOr : never }> : never = fieldEvolve({ select: Schema.OptionFromNullOr, insert: Schema.OptionFromNullOr, update: Schema.OptionFromNullOr, - json: Schema.optionalWith({ as: "Option" }), - jsonCreate: Schema.optionalWith({ as: "Option", nullable: true }), - jsonUpdate: Schema.optionalWith({ as: "Option", nullable: true }), + json: (s: any) => Schema.OptionFromOptionalNullOr(s), + jsonCreate: (s: any) => Schema.OptionFromOptionalNullOr(s), + jsonUpdate: (s: any) => Schema.OptionFromOptionalNullOr(s), }) as any -export interface DateTimeFromDate extends Schema.transform< - typeof Schema.ValidDateFromSelf, - typeof Schema.DateTimeUtcFromSelf -> {} - -export const DateTimeFromDate: DateTimeFromDate = Schema.transform( - Schema.ValidDateFromSelf, - Schema.DateTimeUtcFromSelf, - { - decode: DateTime.unsafeFromDate, - encode: DateTime.toDateUtc, - }, -) +export interface DateTimeFromDate extends Schema.DateTimeUtcFromDate {} -export interface Date extends Schema.transformOrFail< - typeof Schema.String, - typeof Schema.DateTimeUtcFromSelf -> {} +export const DateTimeFromDate: DateTimeFromDate = Schema.DateTimeUtcFromDate -/** A DateTime.Utc serialized as ISO date string (YYYY-MM-DD). */ -export const Date: Date = Schema.transformOrFail(Schema.String, Schema.DateTimeUtcFromSelf, { - decode: (s, _, ast) => - DateTime.make(s).pipe( - Option.map(DateTime.removeTime), - Option.match({ - onNone: () => ParseResult.fail(new ParseResult.Type(ast, s)), - onSome: (dt) => ParseResult.succeed(dt), - }), - ), - encode: (dt) => ParseResult.succeed(DateTime.formatIsoDate(dt)), -}) +export interface Date extends Schema.decodeTo {} -export const DateWithNow = VariantSchema.Overrideable(Date, Schema.DateTimeUtcFromSelf, { - generate: Option.match({ - onNone: () => Effect.map(DateTime.now, DateTime.removeTime), - onSome: (dt) => Effect.succeed(DateTime.removeTime(dt)), +/** A DateTime.Utc serialized as ISO date string (YYYY-MM-DD). */ +export const Date: Date = Schema.String.pipe( + Schema.decodeTo(Schema.DateTimeUtc, { + decode: SchemaGetter.transformOrFail((s: string) => { + const opt = DateTime.make(s) + if (opt._tag === "Some") { + return Effect.succeed(DateTime.removeTime(opt.value)) + } + return Effect.fail( + new SchemaIssue.InvalidValue(Option.some(s), { message: "Invalid date format" }), + ) + }), + encode: SchemaGetter.transform((dt: DateTime.Utc) => DateTime.formatIsoDate(dt)), }), +) as any + +export const DateWithNow = VariantSchema.Overrideable(Date as any, { + defaultValue: Effect.map(DateTime.now, DateTime.removeTime), }) -export const DateTimeWithNow = VariantSchema.Overrideable(Schema.String, Schema.DateTimeUtcFromSelf, { - generate: Option.match({ - onNone: () => Effect.map(DateTime.now, DateTime.formatIso), - onSome: (dt) => Effect.succeed(DateTime.formatIso(dt)), - }), - decode: Schema.DateTimeUtc, +export const DateTimeWithNow = VariantSchema.Overrideable(Schema.DateTimeUtcFromString, { + defaultValue: DateTime.now, }) -export const DateTimeFromDateWithNow = VariantSchema.Overrideable( - Schema.DateFromSelf, - Schema.DateTimeUtcFromSelf, - { - generate: Option.match({ - onNone: () => Effect.map(DateTime.now, DateTime.toDateUtc), - onSome: (dt) => Effect.succeed(DateTime.toDateUtc(dt)), - }), - decode: DateTimeFromDate, - }, -) +export const DateTimeFromDateWithNow = VariantSchema.Overrideable(Schema.DateTimeUtcFromDate, { + defaultValue: DateTime.now, +}) -export const DateTimeFromNumberWithNow = VariantSchema.Overrideable( - Schema.Number, - Schema.DateTimeUtcFromSelf, - { - generate: Option.match({ - onNone: () => Effect.map(DateTime.now, DateTime.toEpochMillis), - onSome: (dt) => Effect.succeed(DateTime.toEpochMillis(dt)), - }), - decode: Schema.DateTimeUtcFromNumber, - }, -) +export const DateTimeFromNumberWithNow = VariantSchema.Overrideable(Schema.DateTimeUtcFromMillis, { + defaultValue: DateTime.now, +}) export interface DateTimeInsert extends VariantSchema.Field<{ - readonly select: typeof Schema.DateTimeUtc - readonly insert: VariantSchema.Overrideable - readonly json: typeof Schema.DateTimeUtc + readonly select: typeof Schema.DateTimeUtcFromString + readonly insert: VariantSchema.Overrideable + readonly json: typeof Schema.DateTimeUtcFromString }> {} /** A DateTime.Utc field set on insert only, serialized as string (createdAt). */ export const DateTimeInsert: DateTimeInsert = Field({ - select: Schema.DateTimeUtc, + select: Schema.DateTimeUtcFromString, insert: DateTimeWithNow, - json: Schema.DateTimeUtc, + json: Schema.DateTimeUtcFromString, }) export interface DateTimeInsertFromDate extends VariantSchema.Field<{ readonly select: DateTimeFromDate - readonly insert: VariantSchema.Overrideable - readonly json: typeof Schema.DateTimeUtc + readonly insert: VariantSchema.Overrideable + readonly json: typeof Schema.DateTimeUtcFromString }> {} /** A DateTime.Utc field set on insert only, serialized as Date object. */ export const DateTimeInsertFromDate: DateTimeInsertFromDate = Field({ select: DateTimeFromDate, insert: DateTimeFromDateWithNow, - json: Schema.DateTimeUtc, + json: Schema.DateTimeUtcFromString, }) export interface DateTimeInsertFromNumber extends VariantSchema.Field<{ - readonly select: typeof Schema.DateTimeUtcFromNumber - readonly insert: VariantSchema.Overrideable - readonly json: typeof Schema.DateTimeUtcFromNumber + readonly select: typeof Schema.DateTimeUtcFromMillis + readonly insert: VariantSchema.Overrideable + readonly json: typeof Schema.DateTimeUtcFromMillis }> {} /** A DateTime.Utc field set on insert only, serialized as epoch milliseconds. */ export const DateTimeInsertFromNumber: DateTimeInsertFromNumber = Field({ - select: Schema.DateTimeUtcFromNumber, + select: Schema.DateTimeUtcFromMillis, insert: DateTimeFromNumberWithNow, - json: Schema.DateTimeUtcFromNumber, + json: Schema.DateTimeUtcFromMillis, }) export interface DateTimeUpdate extends VariantSchema.Field<{ - readonly select: typeof Schema.DateTimeUtc - readonly insert: VariantSchema.Overrideable - readonly update: VariantSchema.Overrideable - readonly json: typeof Schema.DateTimeUtc + readonly select: typeof Schema.DateTimeUtcFromString + readonly insert: VariantSchema.Overrideable + readonly update: VariantSchema.Overrideable + readonly json: typeof Schema.DateTimeUtcFromString }> {} /** A DateTime.Utc field set on insert/update, serialized as string (updatedAt). */ export const DateTimeUpdate: DateTimeUpdate = Field({ - select: Schema.DateTimeUtc, + select: Schema.DateTimeUtcFromString, insert: DateTimeWithNow, update: DateTimeWithNow, - json: Schema.DateTimeUtc, + json: Schema.DateTimeUtcFromString, }) export interface DateTimeUpdateFromDate extends VariantSchema.Field<{ readonly select: DateTimeFromDate - readonly insert: VariantSchema.Overrideable - readonly update: VariantSchema.Overrideable - readonly json: typeof Schema.DateTimeUtc + readonly insert: VariantSchema.Overrideable + readonly update: VariantSchema.Overrideable + readonly json: typeof Schema.DateTimeUtcFromString }> {} /** A DateTime.Utc field set on insert/update, serialized as Date object. */ @@ -317,40 +265,36 @@ export const DateTimeUpdateFromDate: DateTimeUpdateFromDate = Field({ select: DateTimeFromDate, insert: DateTimeFromDateWithNow, update: DateTimeFromDateWithNow, - json: Schema.DateTimeUtc, + json: Schema.DateTimeUtcFromString, }) export interface DateTimeUpdateFromNumber extends VariantSchema.Field<{ - readonly select: typeof Schema.DateTimeUtcFromNumber - readonly insert: VariantSchema.Overrideable - readonly update: VariantSchema.Overrideable - readonly json: typeof Schema.DateTimeUtcFromNumber + readonly select: typeof Schema.DateTimeUtcFromMillis + readonly insert: VariantSchema.Overrideable + readonly update: VariantSchema.Overrideable + readonly json: typeof Schema.DateTimeUtcFromMillis }> {} /** A DateTime.Utc field set on insert/update, serialized as epoch milliseconds. */ export const DateTimeUpdateFromNumber: DateTimeUpdateFromNumber = Field({ - select: Schema.DateTimeUtcFromNumber, + select: Schema.DateTimeUtcFromMillis, insert: DateTimeFromNumberWithNow, update: DateTimeFromNumberWithNow, - json: Schema.DateTimeUtcFromNumber, + json: Schema.DateTimeUtcFromMillis, }) -export interface JsonFromString< - S extends Schema.Schema.All | Schema.PropertySignature.All, -> extends VariantSchema.Field<{ - readonly select: Schema.Schema, string, Schema.Schema.Context> - readonly insert: Schema.Schema, string, Schema.Schema.Context> - readonly update: Schema.Schema, string, Schema.Schema.Context> +export interface JsonFromString extends VariantSchema.Field<{ + readonly select: Schema.fromJsonString + readonly insert: Schema.fromJsonString + readonly update: Schema.fromJsonString readonly json: S readonly jsonCreate: S readonly jsonUpdate: S }> {} /** A JSON value stored as text in the database, object in JSON variants. */ -export const JsonFromString = ( - schema: S, -): JsonFromString => { - const parsed = Schema.parseJson(schema as any) +export const JsonFromString = (schema: S): JsonFromString => { + const parsed = Schema.fromJsonString(schema) return Field({ select: parsed, insert: parsed, @@ -362,27 +306,22 @@ export const JsonFromString = extends VariantSchema.Field<{ - readonly select: Schema.brand - readonly insert: VariantSchema.Overrideable, Uint8Array> - readonly update: Schema.brand - readonly json: Schema.brand + readonly select: Schema.brand + readonly insert: VariantSchema.Overrideable> + readonly update: Schema.brand + readonly json: Schema.brand }> {} export const UuidV4WithGenerate = ( - schema: Schema.brand, -): VariantSchema.Overrideable, Uint8Array> => - VariantSchema.Overrideable(Schema.Uint8ArrayFromSelf, schema, { - generate: Option.match({ - onNone: () => Effect.sync(() => crypto.randomUUID()), - onSome: (id) => Effect.succeed(id as any), - }), - decode: Schema.Uint8ArrayFromSelf, - constructorDefault: () => crypto.randomUUID() as any, + schema: Schema.brand, +): VariantSchema.Overrideable> => + VariantSchema.Overrideable(schema, { + defaultValue: Effect.sync(() => crypto.randomUUID() as any), }) /** A UUID v4 field auto-generated on insert. */ export const UuidV4Insert = ( - schema: Schema.brand, + schema: Schema.brand, ): UuidV4Insert => Field({ select: schema, @@ -392,23 +331,71 @@ export const UuidV4Insert = ( }) /** A boolean parsed from 0 or 1. */ -export class BooleanFromNumber extends Schema.transform(Schema.Literal(0, 1), Schema.Boolean, { - decode: (n) => n === 1, - encode: (b) => (b ? 1 : 0), -}) {} +export const BooleanFromNumber: typeof Schema.BooleanFromBit = Schema.BooleanFromBit + +export interface ExposedModel< + InsertSchema extends Schema.Top, + UpdateSchema extends Schema.Top, + JsonSchema extends Schema.Top, + CreateSchema extends Schema.Top, + PatchSchema extends Schema.Top, +> { + readonly Insert: InsertSchema + readonly Update: UpdateSchema + readonly Schema: JsonSchema + readonly Create: CreateSchema + readonly Patch: PatchSchema +} -export interface EntitySchema extends Schema.Schema.AnyNoContext { - readonly fields: Schema.Struct.Fields - readonly insert: Schema.Schema.AnyNoContext - readonly update: Schema.Schema.AnyNoContext - readonly json: Schema.Schema.AnyNoContext - readonly jsonCreate: Schema.Schema.AnyNoContext - readonly jsonUpdate: Schema.Schema.AnyNoContext +export interface ExposedModelWithRow< + RowSchema extends Any, + InsertSchema extends Schema.Top, + UpdateSchema extends Schema.Top, + JsonSchema extends Schema.Top, + CreateSchema extends Schema.Top, + PatchSchema extends Schema.Top, +> extends ExposedModel { + readonly Row: RowSchema } +export const expose = < + Model extends Any, + InsertSchema extends Schema.Top = Model["insert"], + UpdateSchema extends Schema.Top = Model["update"], + JsonSchema extends Schema.Top = Model["json"], + CreateSchema extends Schema.Top = Model["jsonCreate"], + PatchSchema extends Schema.Top = Model["jsonUpdate"], +>( + model: Model, + overrides: Partial> = {}, +): ExposedModel => ({ + Insert: overrides.Insert ?? (model.insert as unknown as InsertSchema), + Update: overrides.Update ?? (model.update as unknown as UpdateSchema), + Schema: overrides.Schema ?? (model.json as unknown as JsonSchema), + Create: overrides.Create ?? (model.jsonCreate as unknown as CreateSchema), + Patch: overrides.Patch ?? (model.jsonUpdate as unknown as PatchSchema), +}) + +export const exposeWithRow = < + Model extends Any, + InsertSchema extends Schema.Top = Model["insert"], + UpdateSchema extends Schema.Top = Model["update"], + JsonSchema extends Schema.Top = Model["json"], + CreateSchema extends Schema.Top = Model["jsonCreate"], + PatchSchema extends Schema.Top = Model["jsonUpdate"], +>( + model: Model, + overrides: Partial< + ExposedModelWithRow + > = {}, +): ExposedModelWithRow => ({ + ...expose(model, overrides), + Row: overrides.Row ?? model, +}) + // Helper utilities for common model fields -export const JsonDate = Schema.Union(Schema.DateFromString, Schema.DateFromSelf).pipe( - Schema.annotations({ +export const JsonDate = Schema.Union([Schema.DateTimeUtcFromString, Schema.Date]).pipe( + Schema.annotate({ jsonSchema: { type: "string", format: "date-time" }, }), ) diff --git a/packages/domain/src/policy/index.ts b/packages/domain/src/policy/index.ts index 9d9570f63..cfd6cefb1 100644 --- a/packages/domain/src/policy/index.ts +++ b/packages/domain/src/policy/index.ts @@ -7,7 +7,7 @@ export const policy = ( action: Action, f: (actor: typeof CurrentUser.Schema.Type) => Effect.Effect, ): Effect.Effect => - Effect.flatMap(CurrentUser.Context, (actor) => + CurrentUser.Context.use((actor: typeof CurrentUser.Schema.Type) => Effect.flatMap(f(actor), (can) => can ? Effect.void @@ -18,4 +18,4 @@ export const policy = ( }), ), ), - ) + ) as Effect.Effect diff --git a/packages/domain/src/rate-limit-errors.ts b/packages/domain/src/rate-limit-errors.ts index 8cd202b31..333b55adf 100644 --- a/packages/domain/src/rate-limit-errors.ts +++ b/packages/domain/src/rate-limit-errors.ts @@ -1,11 +1,10 @@ -import { HttpApiSchema } from "@effect/platform" import { Schema } from "effect" /** * Error thrown when a user exceeds their rate limit. * Contains information about when they can retry. */ -export class RateLimitExceededError extends Schema.TaggedError()( +export class RateLimitExceededError extends Schema.TaggedErrorClass()( "RateLimitExceededError", { message: Schema.String, @@ -13,7 +12,5 @@ export class RateLimitExceededError extends Schema.TaggedError()( +export class AttachmentNotFoundError extends Schema.TaggedErrorClass()( "AttachmentNotFoundError", { attachmentId: AttachmentId, @@ -50,7 +50,7 @@ export class AttachmentRpcs extends RpcGroup.make( Rpc.make("attachment.delete", { payload: Schema.Struct({ id: AttachmentId }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union(AttachmentNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([AttachmentNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["attachments:write"]) .middleware(AuthMiddleware), @@ -69,8 +69,8 @@ export class AttachmentRpcs extends RpcGroup.make( */ Rpc.make("attachment.complete", { payload: Schema.Struct({ id: AttachmentId }), - success: Attachment.Model, - error: Schema.Union(AttachmentNotFoundError, UnauthorizedError, InternalServerError), + success: Attachment.Schema, + error: Schema.Union([AttachmentNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["attachments:write"]) .middleware(AuthMiddleware), @@ -93,7 +93,7 @@ export class AttachmentRpcs extends RpcGroup.make( reason: Schema.optional(Schema.String), }), success: Schema.Void, - error: Schema.Union(AttachmentNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([AttachmentNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["attachments:write"]) .middleware(AuthMiddleware), diff --git a/packages/domain/src/rpc/bots.ts b/packages/domain/src/rpc/bots.ts index 1bef2f2eb..12ee67bee 100644 --- a/packages/domain/src/rpc/bots.ts +++ b/packages/domain/src/rpc/bots.ts @@ -1,4 +1,4 @@ -import { Rpc, RpcGroup } from "@effect/rpc" +import { Rpc, RpcGroup } from "effect/unstable/rpc" import { BotId } from "@hazel/schema" import { Schema } from "effect" import { InternalServerError, UnauthorizedError } from "../errors" @@ -14,7 +14,7 @@ import { ApiScope } from "../scopes/api-scope" * Contains the bot data and a transaction ID for optimistic updates. */ export class BotResponse extends Schema.Class("BotResponse")({ - data: Bot.Model.json, + data: Bot.Schema, transactionId: TransactionId, }) {} @@ -22,7 +22,7 @@ export class BotResponse extends Schema.Class("BotResponse")({ * Response for bot creation - includes the plain token (only shown once). */ export class BotCreatedResponse extends Schema.Class("BotCreatedResponse")({ - data: Bot.Model.json, + data: Bot.Schema, token: Schema.String, // Plain token, only returned once on creation transactionId: TransactionId, }) {} @@ -31,21 +31,21 @@ export class BotCreatedResponse extends Schema.Class("BotCre * Response for listing bots. */ export class BotListResponse extends Schema.Class("BotListResponse")({ - data: Schema.Array(Bot.Model.json), + data: Schema.Array(Bot.Schema), }) {} /** * Response for listing bot commands. */ export class BotCommandListResponse extends Schema.Class("BotCommandListResponse")({ - data: Schema.Array(BotCommand.Model.json), + data: Schema.Array(BotCommand.Schema), }) {} /** * Public bot info for marketplace - includes install status and creator name. */ export const PublicBotInfo = Schema.Struct({ - ...Bot.Model.json.fields, + ...Bot.Schema.fields, isInstalled: Schema.Boolean, creatorName: Schema.String, }) @@ -61,14 +61,14 @@ export class PublicBotListResponse extends Schema.Class(" /** * Error thrown when a bot is not found. */ -export class BotNotFoundError extends Schema.TaggedError()("BotNotFoundError", { +export class BotNotFoundError extends Schema.TaggedErrorClass()("BotNotFoundError", { botId: BotId, }) {} /** * Error thrown when a bot is already installed in the organization. */ -export class BotAlreadyInstalledError extends Schema.TaggedError()( +export class BotAlreadyInstalledError extends Schema.TaggedErrorClass()( "BotAlreadyInstalledError", { botId: BotId, @@ -113,14 +113,14 @@ export class BotRpcs extends RpcGroup.make( */ Rpc.make("bot.create", { payload: Schema.Struct({ - name: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(100)), - description: Schema.optional(Schema.String.pipe(Schema.maxLength(500))), + name: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(100)), + description: Schema.optional(Schema.String.check(Schema.isMaxLength(500))), webhookUrl: Schema.optional(Schema.String), scopes: Schema.Array(ApiScope), isPublic: Schema.optional(Schema.Boolean), }), success: BotCreatedResponse, - error: Schema.Union(UnauthorizedError, InternalServerError, RateLimitExceededError), + error: Schema.Union([UnauthorizedError, InternalServerError, RateLimitExceededError]), }) .annotate(RequiredScopes, ["bots:write"]) .middleware(AuthMiddleware), @@ -136,7 +136,7 @@ export class BotRpcs extends RpcGroup.make( Rpc.make("bot.list", { payload: Schema.Struct({}), success: BotListResponse, - error: Schema.Union(UnauthorizedError, InternalServerError), + error: Schema.Union([UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["bots:read"]) .middleware(AuthMiddleware), @@ -154,7 +154,7 @@ export class BotRpcs extends RpcGroup.make( Rpc.make("bot.get", { payload: Schema.Struct({ id: BotId }), success: BotResponse, - error: Schema.Union(BotNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([BotNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["bots:read"]) .middleware(AuthMiddleware), @@ -172,14 +172,19 @@ export class BotRpcs extends RpcGroup.make( Rpc.make("bot.update", { payload: Schema.Struct({ id: BotId, - name: Schema.optional(Schema.String.pipe(Schema.minLength(1), Schema.maxLength(100))), - description: Schema.optional(Schema.NullOr(Schema.String.pipe(Schema.maxLength(500)))), + name: Schema.optional(Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(100))), + description: Schema.optional(Schema.NullOr(Schema.String.check(Schema.isMaxLength(500)))), webhookUrl: Schema.optional(Schema.NullOr(Schema.String)), scopes: Schema.optional(Schema.Array(ApiScope)), isPublic: Schema.optional(Schema.Boolean), }), success: BotResponse, - error: Schema.Union(BotNotFoundError, UnauthorizedError, InternalServerError, RateLimitExceededError), + error: Schema.Union([ + BotNotFoundError, + UnauthorizedError, + InternalServerError, + RateLimitExceededError, + ]), }) .annotate(RequiredScopes, ["bots:write"]) .middleware(AuthMiddleware), @@ -197,7 +202,7 @@ export class BotRpcs extends RpcGroup.make( Rpc.make("bot.delete", { payload: Schema.Struct({ id: BotId }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union(BotNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([BotNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["bots:write"]) .middleware(AuthMiddleware), @@ -216,7 +221,12 @@ export class BotRpcs extends RpcGroup.make( Rpc.make("bot.regenerateToken", { payload: Schema.Struct({ id: BotId }), success: BotCreatedResponse, - error: Schema.Union(BotNotFoundError, UnauthorizedError, InternalServerError, RateLimitExceededError), + error: Schema.Union([ + BotNotFoundError, + UnauthorizedError, + InternalServerError, + RateLimitExceededError, + ]), }) .annotate(RequiredScopes, ["bots:write"]) .middleware(AuthMiddleware), @@ -234,7 +244,7 @@ export class BotRpcs extends RpcGroup.make( Rpc.make("bot.getCommands", { payload: Schema.Struct({ botId: BotId }), success: BotCommandListResponse, - error: Schema.Union(BotNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([BotNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["bots:read"]) .middleware(AuthMiddleware), @@ -256,7 +266,7 @@ export class BotRpcs extends RpcGroup.make( search: Schema.optional(Schema.String), }), success: PublicBotListResponse, - error: Schema.Union(UnauthorizedError, InternalServerError), + error: Schema.Union([UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["bots:read"]) .middleware(AuthMiddleware), @@ -272,7 +282,7 @@ export class BotRpcs extends RpcGroup.make( Rpc.make("bot.listInstalled", { payload: Schema.Struct({}), success: BotListResponse, - error: Schema.Union(UnauthorizedError, InternalServerError), + error: Schema.Union([UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["bots:read"]) .middleware(AuthMiddleware), @@ -291,13 +301,13 @@ export class BotRpcs extends RpcGroup.make( Rpc.make("bot.install", { payload: Schema.Struct({ botId: BotId }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union( + error: Schema.Union([ BotNotFoundError, BotAlreadyInstalledError, UnauthorizedError, InternalServerError, RateLimitExceededError, - ), + ]), }) .annotate(RequiredScopes, ["bots:write"]) .middleware(AuthMiddleware), @@ -315,7 +325,12 @@ export class BotRpcs extends RpcGroup.make( Rpc.make("bot.uninstall", { payload: Schema.Struct({ botId: BotId }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union(BotNotFoundError, UnauthorizedError, InternalServerError, RateLimitExceededError), + error: Schema.Union([ + BotNotFoundError, + UnauthorizedError, + InternalServerError, + RateLimitExceededError, + ]), }) .annotate(RequiredScopes, ["bots:write"]) .middleware(AuthMiddleware), @@ -335,13 +350,13 @@ export class BotRpcs extends RpcGroup.make( Rpc.make("bot.installById", { payload: Schema.Struct({ botId: BotId }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union( + error: Schema.Union([ BotNotFoundError, BotAlreadyInstalledError, UnauthorizedError, InternalServerError, RateLimitExceededError, - ), + ]), }) .annotate(RequiredScopes, ["bots:write"]) .middleware(AuthMiddleware), @@ -363,7 +378,7 @@ export class BotRpcs extends RpcGroup.make( avatarUrl: Schema.String, }), success: BotResponse, - error: Schema.Union(BotNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([BotNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["bots:write"]) .middleware(AuthMiddleware), diff --git a/packages/domain/src/rpc/channel-members.ts b/packages/domain/src/rpc/channel-members.ts index ba22ca831..fb72b5e1c 100644 --- a/packages/domain/src/rpc/channel-members.ts +++ b/packages/domain/src/rpc/channel-members.ts @@ -1,4 +1,4 @@ -import { Rpc, RpcGroup } from "@effect/rpc" +import { Rpc, RpcGroup } from "effect/unstable/rpc" import { Schema } from "effect" import { InternalServerError, UnauthorizedError } from "../errors" import { ChannelId, ChannelMemberId } from "@hazel/schema" @@ -13,7 +13,7 @@ import { RequiredScopes } from "../scopes/required-scopes" * Contains the channel member data and a transaction ID for optimistic updates. */ export class ChannelMemberResponse extends Schema.Class("ChannelMemberResponse")({ - data: ChannelMember.Model.json, + data: ChannelMember.Schema, transactionId: TransactionId, }) {} @@ -21,7 +21,7 @@ export class ChannelMemberResponse extends Schema.Class(" * Error thrown when a channel member is not found. * Used in update and delete operations. */ -export class ChannelMemberNotFoundError extends Schema.TaggedError()( +export class ChannelMemberNotFoundError extends Schema.TaggedErrorClass()( "ChannelMemberNotFoundError", { channelMemberId: ChannelMemberId, @@ -81,7 +81,7 @@ export class ChannelMemberRpcs extends RpcGroup.make( channelId: ChannelId, }), success: ChannelMemberResponse, - error: Schema.Union(ChannelNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([ChannelNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["channel-members:write"]) .middleware(AuthMiddleware), @@ -102,9 +102,9 @@ export class ChannelMemberRpcs extends RpcGroup.make( Rpc.make("channelMember.update", { payload: Schema.Struct({ id: ChannelMemberId, - }).pipe(Schema.extend(Schema.partial(ChannelMember.Model.jsonUpdate))), + }).pipe(Schema.fieldsAssign((ChannelMember.Patch as Schema.Struct).fields)), success: ChannelMemberResponse, - error: Schema.Union(ChannelMemberNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([ChannelMemberNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["channel-members:write"]) .middleware(AuthMiddleware), @@ -125,7 +125,7 @@ export class ChannelMemberRpcs extends RpcGroup.make( Rpc.make("channelMember.delete", { payload: Schema.Struct({ id: ChannelMemberId }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union(ChannelMemberNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([ChannelMemberNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["channel-members:write"]) .middleware(AuthMiddleware), @@ -145,7 +145,7 @@ export class ChannelMemberRpcs extends RpcGroup.make( Rpc.make("channelMember.clearNotifications", { payload: Schema.Struct({ channelId: ChannelId }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union(ChannelNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([ChannelNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["channel-members:write"]) .middleware(AuthMiddleware), diff --git a/packages/domain/src/rpc/channel-sections.ts b/packages/domain/src/rpc/channel-sections.ts index 4e9d82ba5..1a1518620 100644 --- a/packages/domain/src/rpc/channel-sections.ts +++ b/packages/domain/src/rpc/channel-sections.ts @@ -1,4 +1,4 @@ -import { Rpc, RpcGroup } from "@effect/rpc" +import { Rpc, RpcGroup } from "effect/unstable/rpc" import { Schema } from "effect" import { InternalServerError, UnauthorizedError } from "../errors" import { ChannelId, ChannelSectionId, OrganizationId } from "@hazel/schema" @@ -13,7 +13,7 @@ import { RequiredScopes } from "../scopes/required-scopes" * Contains the section data and a transaction ID for optimistic updates. */ export class ChannelSectionResponse extends Schema.Class("ChannelSectionResponse")({ - data: ChannelSection.Model.json, + data: ChannelSection.Schema, transactionId: TransactionId, }) {} @@ -21,7 +21,7 @@ export class ChannelSectionResponse extends Schema.Class * Error thrown when a channel section is not found. * Used in update and delete operations. */ -export class ChannelSectionNotFoundError extends Schema.TaggedError()( +export class ChannelSectionNotFoundError extends Schema.TaggedErrorClass()( "ChannelSectionNotFoundError", { sectionId: ChannelSectionId, @@ -32,7 +32,7 @@ export class ChannelSectionNotFoundError extends Schema.TaggedError).fields)), success: ChannelSectionResponse, - error: Schema.Union(ChannelSectionNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([ChannelSectionNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["channel-sections:write"]) .middleware(AuthMiddleware), @@ -93,7 +93,7 @@ export class ChannelSectionRpcs extends RpcGroup.make( Rpc.make("channelSection.delete", { payload: Schema.Struct({ id: ChannelSectionId }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union(ChannelSectionNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([ChannelSectionNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["channel-sections:write"]) .middleware(AuthMiddleware), @@ -116,7 +116,7 @@ export class ChannelSectionRpcs extends RpcGroup.make( sectionIds: Schema.Array(ChannelSectionId), }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union(UnauthorizedError, InternalServerError), + error: Schema.Union([UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["channel-sections:write"]) .middleware(AuthMiddleware), @@ -141,12 +141,12 @@ export class ChannelSectionRpcs extends RpcGroup.make( sectionId: Schema.NullOr(ChannelSectionId), }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union( + error: Schema.Union([ ChannelNotFoundError, ChannelSectionNotFoundError, UnauthorizedError, InternalServerError, - ), + ]), }) .annotate(RequiredScopes, ["channel-sections:write"]) .middleware(AuthMiddleware), diff --git a/packages/domain/src/rpc/channel-webhooks.ts b/packages/domain/src/rpc/channel-webhooks.ts index 17def7c4c..b247c2fbc 100644 --- a/packages/domain/src/rpc/channel-webhooks.ts +++ b/packages/domain/src/rpc/channel-webhooks.ts @@ -1,4 +1,4 @@ -import { Rpc, RpcGroup } from "@effect/rpc" +import { Rpc, RpcGroup } from "effect/unstable/rpc" import { AvatarUrl, ChannelId, ChannelWebhookId } from "@hazel/schema" import { Schema } from "effect" import { InternalServerError, UnauthorizedError } from "../errors" @@ -13,7 +13,7 @@ import { RequiredScopes } from "../scopes/required-scopes" * Contains the webhook data and a transaction ID for optimistic updates. */ export class ChannelWebhookResponse extends Schema.Class("ChannelWebhookResponse")({ - data: ChannelWebhook.Model.json, + data: ChannelWebhook.Schema, transactionId: TransactionId, }) {} @@ -23,7 +23,7 @@ export class ChannelWebhookResponse extends Schema.Class export class ChannelWebhookCreatedResponse extends Schema.Class( "ChannelWebhookCreatedResponse", )({ - data: ChannelWebhook.Model.json, + data: ChannelWebhook.Schema, token: Schema.String, // Plain token, only returned once on creation webhookUrl: Schema.String, // Full URL for the webhook transactionId: TransactionId, @@ -35,13 +35,13 @@ export class ChannelWebhookCreatedResponse extends Schema.Class( "ChannelWebhookListResponse", )({ - data: Schema.Array(ChannelWebhook.Model.json), + data: Schema.Array(ChannelWebhook.Schema), }) {} /** * Error thrown when a webhook is not found. */ -export class ChannelWebhookNotFoundError extends Schema.TaggedError()( +export class ChannelWebhookNotFoundError extends Schema.TaggedErrorClass()( "ChannelWebhookNotFoundError", { webhookId: ChannelWebhookId, @@ -77,14 +77,14 @@ export class ChannelWebhookRpcs extends RpcGroup.make( Rpc.make("channelWebhook.create", { payload: Schema.Struct({ channelId: ChannelId, - name: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(100)), - description: Schema.optional(Schema.String.pipe(Schema.maxLength(500))), + name: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(100)), + description: Schema.optional(Schema.String.check(Schema.isMaxLength(500))), avatarUrl: Schema.optional(AvatarUrl), /** When set, uses a global integration bot user instead of creating a unique webhook bot */ - integrationProvider: Schema.optional(Schema.Literal("openstatus", "railway")), + integrationProvider: Schema.optional(Schema.Literals(["openstatus", "railway"])), }), success: ChannelWebhookCreatedResponse, - error: Schema.Union(ChannelNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([ChannelNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["channel-webhooks:write"]) .middleware(AuthMiddleware), @@ -102,7 +102,7 @@ export class ChannelWebhookRpcs extends RpcGroup.make( Rpc.make("channelWebhook.list", { payload: Schema.Struct({ channelId: ChannelId }), success: ChannelWebhookListResponse, - error: Schema.Union(ChannelNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([ChannelNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["channel-webhooks:read"]) .middleware(AuthMiddleware), @@ -120,13 +120,13 @@ export class ChannelWebhookRpcs extends RpcGroup.make( Rpc.make("channelWebhook.update", { payload: Schema.Struct({ id: ChannelWebhookId, - name: Schema.optional(Schema.String.pipe(Schema.minLength(1), Schema.maxLength(100))), - description: Schema.optional(Schema.NullOr(Schema.String.pipe(Schema.maxLength(500)))), + name: Schema.optional(Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(100))), + description: Schema.optional(Schema.NullOr(Schema.String.check(Schema.isMaxLength(500)))), avatarUrl: Schema.optional(Schema.NullOr(AvatarUrl)), isEnabled: Schema.optional(Schema.Boolean), }), success: ChannelWebhookResponse, - error: Schema.Union(ChannelWebhookNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([ChannelWebhookNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["channel-webhooks:write"]) .middleware(AuthMiddleware), @@ -145,7 +145,7 @@ export class ChannelWebhookRpcs extends RpcGroup.make( Rpc.make("channelWebhook.regenerateToken", { payload: Schema.Struct({ id: ChannelWebhookId }), success: ChannelWebhookCreatedResponse, - error: Schema.Union(ChannelWebhookNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([ChannelWebhookNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["channel-webhooks:write"]) .middleware(AuthMiddleware), @@ -163,7 +163,7 @@ export class ChannelWebhookRpcs extends RpcGroup.make( Rpc.make("channelWebhook.delete", { payload: Schema.Struct({ id: ChannelWebhookId }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union(ChannelWebhookNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([ChannelWebhookNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["channel-webhooks:write"]) .middleware(AuthMiddleware), @@ -180,7 +180,7 @@ export class ChannelWebhookRpcs extends RpcGroup.make( Rpc.make("channelWebhook.listByOrganization", { payload: Schema.Struct({}), success: ChannelWebhookListResponse, - error: Schema.Union(UnauthorizedError, InternalServerError), + error: Schema.Union([UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["channel-webhooks:read"]) .middleware(AuthMiddleware), diff --git a/packages/domain/src/rpc/channels.ts b/packages/domain/src/rpc/channels.ts index 8d193c270..9fd0dda79 100644 --- a/packages/domain/src/rpc/channels.ts +++ b/packages/domain/src/rpc/channels.ts @@ -1,4 +1,4 @@ -import { Rpc, RpcGroup } from "@effect/rpc" +import { Rpc, RpcGroup } from "effect/unstable/rpc" import { Schema } from "effect" import { AIProviderUnavailableError, @@ -28,7 +28,7 @@ import { RequiredScopes } from "../scopes/required-scopes" * Contains the channel data and a transaction ID for optimistic updates. */ export class ChannelResponse extends Schema.Class("ChannelResponse")({ - data: Channel.Model.json, + data: Channel.Schema, transactionId: TransactionId, }) {} @@ -36,9 +36,12 @@ export class ChannelResponse extends Schema.Class("ChannelRespo * Error thrown when a channel is not found. * Used in update and delete operations. */ -export class ChannelNotFoundError extends Schema.TaggedError()("ChannelNotFoundError", { - channelId: ChannelId, -}) {} +export class ChannelNotFoundError extends Schema.TaggedErrorClass()( + "ChannelNotFoundError", + { + channelId: ChannelId, + }, +) {} /** * Request schema for creating DM or group channels. @@ -46,9 +49,9 @@ export class ChannelNotFoundError extends Schema.TaggedError("CreateDmChannelRequest")({ participantIds: Schema.Array(UserId), - type: Schema.Literal("direct", "single"), + type: Schema.Literals(["direct", "single"]), name: Schema.optional(Schema.String), - organizationId: Schema.UUID, + organizationId: OrganizationId, }) {} /** @@ -68,11 +71,8 @@ export class CreateThreadRequest extends Schema.Class("Crea * Uses jsonCreate which includes optional id for optimistic updates. * Extended with addAllMembers option to auto-add all organization members. */ -export const CreateChannelRequest = Schema.extend( - Channel.Model.jsonCreate, - Schema.Struct({ - addAllMembers: Schema.optional(Schema.Boolean), - }), +export const CreateChannelRequest = Channel.Create.pipe( + Schema.fieldsAssign({ addAllMembers: Schema.optional(Schema.Boolean) }), ) export class ChannelRpcs extends RpcGroup.make( @@ -91,7 +91,7 @@ export class ChannelRpcs extends RpcGroup.make( Rpc.make("channel.create", { payload: CreateChannelRequest, success: ChannelResponse, - error: Schema.Union(UnauthorizedError, InternalServerError), + error: Schema.Union([UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["channels:write"]) .middleware(AuthMiddleware), @@ -111,9 +111,9 @@ export class ChannelRpcs extends RpcGroup.make( Rpc.make("channel.update", { payload: Schema.Struct({ id: ChannelId, - }).pipe(Schema.extend(Schema.partial(Channel.Model.jsonUpdate))), + }).pipe(Schema.fieldsAssign((Channel.Patch as Schema.Struct).fields)), success: ChannelResponse, - error: Schema.Union(ChannelNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([ChannelNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["channels:write"]) .middleware(AuthMiddleware), @@ -133,7 +133,7 @@ export class ChannelRpcs extends RpcGroup.make( Rpc.make("channel.delete", { payload: Schema.Struct({ id: ChannelId }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union(ChannelNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([ChannelNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["channels:write"]) .middleware(AuthMiddleware), @@ -154,7 +154,7 @@ export class ChannelRpcs extends RpcGroup.make( Rpc.make("channel.createDm", { payload: CreateDmChannelRequest, success: ChannelResponse, - error: Schema.Union(DmChannelAlreadyExistsError, UnauthorizedError, InternalServerError), + error: Schema.Union([DmChannelAlreadyExistsError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["channels:write"]) .middleware(AuthMiddleware), @@ -176,7 +176,12 @@ export class ChannelRpcs extends RpcGroup.make( Rpc.make("channel.createThread", { payload: CreateThreadRequest, success: ChannelResponse, - error: Schema.Union(MessageNotFoundError, NestedThreadError, UnauthorizedError, InternalServerError), + error: Schema.Union([ + MessageNotFoundError, + NestedThreadError, + UnauthorizedError, + InternalServerError, + ]), }) .annotate(RequiredScopes, ["channels:write"]) .middleware(AuthMiddleware), @@ -205,7 +210,7 @@ export class ChannelRpcs extends RpcGroup.make( Rpc.make("channel.generateName", { payload: Schema.Struct({ channelId: ChannelId }), success: Schema.Struct({ success: Schema.Boolean }), - error: Schema.Union( + error: Schema.Union([ ChannelNotFoundError, MessageNotFoundError, UnauthorizedError, @@ -219,7 +224,7 @@ export class ChannelRpcs extends RpcGroup.make( AIRateLimitError, AIResponseParseError, ThreadNameUpdateError, - ), + ]), }) .annotate(RequiredScopes, ["channels:write"]) .middleware(AuthMiddleware), diff --git a/packages/domain/src/rpc/chat-sync.ts b/packages/domain/src/rpc/chat-sync.ts index 6be6d62ce..38a16990a 100644 --- a/packages/domain/src/rpc/chat-sync.ts +++ b/packages/domain/src/rpc/chat-sync.ts @@ -1,4 +1,4 @@ -import { Rpc, RpcGroup } from "@effect/rpc" +import { Rpc, RpcGroup } from "effect/unstable/rpc" import { ChannelId, ExternalChannelId, @@ -17,44 +17,44 @@ import { RequiredScopes } from "../scopes/required-scopes" export class ChatSyncConnectionResponse extends Schema.Class( "ChatSyncConnectionResponse", )({ - data: ChatSyncConnection.Model.json, + data: ChatSyncConnection.Schema, transactionId: TransactionId, }) {} export class ChatSyncConnectionListResponse extends Schema.Class( "ChatSyncConnectionListResponse", )({ - data: Schema.Array(ChatSyncConnection.Model.json), + data: Schema.Array(ChatSyncConnection.Schema), }) {} export class ChatSyncChannelLinkResponse extends Schema.Class( "ChatSyncChannelLinkResponse", )({ - data: ChatSyncChannelLink.Model.json, + data: ChatSyncChannelLink.Schema, transactionId: TransactionId, }) {} export class ChatSyncChannelLinkListResponse extends Schema.Class( "ChatSyncChannelLinkListResponse", )({ - data: Schema.Array(ChatSyncChannelLink.Model.json), + data: Schema.Array(ChatSyncChannelLink.Schema), }) {} -export class ChatSyncConnectionNotFoundError extends Schema.TaggedError()( +export class ChatSyncConnectionNotFoundError extends Schema.TaggedErrorClass()( "ChatSyncConnectionNotFoundError", { syncConnectionId: SyncConnectionId, }, ) {} -export class ChatSyncChannelLinkNotFoundError extends Schema.TaggedError()( +export class ChatSyncChannelLinkNotFoundError extends Schema.TaggedErrorClass()( "ChatSyncChannelLinkNotFoundError", { syncChannelLinkId: SyncChannelLinkId, }, ) {} -export class ChatSyncConnectionExistsError extends Schema.TaggedError()( +export class ChatSyncConnectionExistsError extends Schema.TaggedErrorClass()( "ChatSyncConnectionExistsError", { organizationId: OrganizationId, @@ -63,7 +63,7 @@ export class ChatSyncConnectionExistsError extends Schema.TaggedError()( +export class ChatSyncIntegrationNotConnectedError extends Schema.TaggedErrorClass()( "ChatSyncIntegrationNotConnectedError", { organizationId: OrganizationId, @@ -71,7 +71,7 @@ export class ChatSyncIntegrationNotConnectedError extends Schema.TaggedError()( +export class ChatSyncChannelLinkExistsError extends Schema.TaggedErrorClass()( "ChatSyncChannelLinkExistsError", { syncConnectionId: SyncConnectionId, @@ -88,16 +88,16 @@ export class ChatSyncRpcs extends RpcGroup.make( externalWorkspaceId: Schema.String, externalWorkspaceName: Schema.optional(Schema.String), integrationConnectionId: Schema.optional(IntegrationConnectionId), - settings: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Unknown })), - metadata: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Unknown })), + settings: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }), success: ChatSyncConnectionResponse, - error: Schema.Union( + error: Schema.Union([ ChatSyncConnectionExistsError, ChatSyncIntegrationNotConnectedError, UnauthorizedError, InternalServerError, - ), + ]), }) .annotate(RequiredScopes, ["integration-connections:write"]) .middleware(AuthMiddleware), @@ -107,7 +107,7 @@ export class ChatSyncRpcs extends RpcGroup.make( organizationId: OrganizationId, }), success: ChatSyncConnectionListResponse, - error: Schema.Union(UnauthorizedError, InternalServerError), + error: Schema.Union([UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["integration-connections:read"]) .middleware(AuthMiddleware), @@ -119,7 +119,7 @@ export class ChatSyncRpcs extends RpcGroup.make( success: Schema.Struct({ transactionId: TransactionId, }), - error: Schema.Union(ChatSyncConnectionNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([ChatSyncConnectionNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["integration-connections:write"]) .middleware(AuthMiddleware), @@ -131,15 +131,15 @@ export class ChatSyncRpcs extends RpcGroup.make( externalChannelId: ExternalChannelId, externalChannelName: Schema.optional(Schema.String), direction: Schema.optional(ChatSyncChannelLink.ChatSyncDirection), - settings: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Unknown })), + settings: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }), success: ChatSyncChannelLinkResponse, - error: Schema.Union( + error: Schema.Union([ ChatSyncConnectionNotFoundError, ChatSyncChannelLinkExistsError, UnauthorizedError, InternalServerError, - ), + ]), }) .annotate(RequiredScopes, ["integration-connections:write"]) .middleware(AuthMiddleware), @@ -149,7 +149,7 @@ export class ChatSyncRpcs extends RpcGroup.make( syncConnectionId: SyncConnectionId, }), success: ChatSyncChannelLinkListResponse, - error: Schema.Union(ChatSyncConnectionNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([ChatSyncConnectionNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["integration-connections:read"]) .middleware(AuthMiddleware), @@ -161,7 +161,7 @@ export class ChatSyncRpcs extends RpcGroup.make( success: Schema.Struct({ transactionId: TransactionId, }), - error: Schema.Union(ChatSyncChannelLinkNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([ChatSyncChannelLinkNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["integration-connections:write"]) .middleware(AuthMiddleware), @@ -173,7 +173,7 @@ export class ChatSyncRpcs extends RpcGroup.make( isActive: Schema.optional(Schema.Boolean), }), success: ChatSyncChannelLinkResponse, - error: Schema.Union(ChatSyncChannelLinkNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([ChatSyncChannelLinkNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["integration-connections:write"]) .middleware(AuthMiddleware), diff --git a/packages/domain/src/rpc/connect-shares.ts b/packages/domain/src/rpc/connect-shares.ts index d52a4ede3..a2047d063 100644 --- a/packages/domain/src/rpc/connect-shares.ts +++ b/packages/domain/src/rpc/connect-shares.ts @@ -1,4 +1,4 @@ -import { Rpc, RpcGroup } from "@effect/rpc" +import { Rpc, RpcGroup } from "effect/unstable/rpc" import { Schema } from "effect" import { ChannelId, @@ -14,28 +14,28 @@ import { AuthMiddleware } from "./middleware" import { RequiredScopes } from "../scopes/required-scopes" export class ConnectInviteResponse extends Schema.Class("ConnectInviteResponse")({ - data: ConnectInvite.Model.json, + data: ConnectInvite.Schema, transactionId: TransactionId, }) {} export class ConnectConversationResponse extends Schema.Class( "ConnectConversationResponse", )({ - data: ConnectConversation.Model.json, + data: ConnectConversation.Schema, transactionId: TransactionId, }) {} export class ConnectParticipantResponse extends Schema.Class( "ConnectParticipantResponse", )({ - data: ConnectParticipant.Model.json, + data: ConnectParticipant.Schema, transactionId: TransactionId, }) {} export class ConnectInviteListResponse extends Schema.Class( "ConnectInviteListResponse", )({ - data: Schema.Array(ConnectInvite.Model.json), + data: Schema.Array(ConnectInvite.Schema), }) {} export class ConnectWorkspaceSearchResult extends Schema.Class( @@ -53,7 +53,7 @@ export class ConnectWorkspaceSearchResponse extends Schema.Class()( +export class ConnectInviteNotFoundError extends Schema.TaggedErrorClass()( "ConnectInviteNotFoundError", { inviteId: ConnectInviteId, @@ -61,7 +61,7 @@ export class ConnectInviteNotFoundError extends Schema.TaggedError()( +export class ConnectInviteInvalidStateError extends Schema.TaggedErrorClass()( "ConnectInviteInvalidStateError", { inviteId: ConnectInviteId, @@ -70,14 +70,14 @@ export class ConnectInviteInvalidStateError extends Schema.TaggedError()( +export class ConnectWorkspaceNotFoundError extends Schema.TaggedErrorClass()( "ConnectWorkspaceNotFoundError", { message: Schema.String, }, ) {} -export class ConnectChannelAlreadySharedError extends Schema.TaggedError()( +export class ConnectChannelAlreadySharedError extends Schema.TaggedErrorClass()( "ConnectChannelAlreadySharedError", { channelId: ChannelId, @@ -93,7 +93,7 @@ export class ConnectShareRpcs extends RpcGroup.make( organizationId: OrganizationId, }), success: ConnectWorkspaceSearchResponse, - error: Schema.Union(UnauthorizedError, InternalServerError), + error: Schema.Union([UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["channels:read"]) .middleware(AuthMiddleware), @@ -109,12 +109,12 @@ export class ConnectShareRpcs extends RpcGroup.make( allowGuestMemberAdds: Schema.Boolean, }), success: ConnectInviteResponse, - error: Schema.Union( + error: Schema.Union([ ConnectWorkspaceNotFoundError, ConnectChannelAlreadySharedError, UnauthorizedError, InternalServerError, - ), + ]), }) .annotate(RequiredScopes, ["channels:write"]) .middleware(AuthMiddleware), @@ -125,14 +125,14 @@ export class ConnectShareRpcs extends RpcGroup.make( guestOrganizationId: OrganizationId, }), success: ConnectConversationResponse, - error: Schema.Union( + error: Schema.Union([ ConnectInviteNotFoundError, ConnectInviteInvalidStateError, ConnectWorkspaceNotFoundError, ConnectChannelAlreadySharedError, UnauthorizedError, InternalServerError, - ), + ]), }) .annotate(RequiredScopes, ["organizations:write"]) .middleware(AuthMiddleware), @@ -142,13 +142,13 @@ export class ConnectShareRpcs extends RpcGroup.make( inviteId: ConnectInviteId, }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union( + error: Schema.Union([ ConnectInviteNotFoundError, ConnectInviteInvalidStateError, ConnectWorkspaceNotFoundError, UnauthorizedError, InternalServerError, - ), + ]), }) .annotate(RequiredScopes, ["organizations:write"]) .middleware(AuthMiddleware), @@ -158,12 +158,12 @@ export class ConnectShareRpcs extends RpcGroup.make( inviteId: ConnectInviteId, }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union( + error: Schema.Union([ ConnectInviteNotFoundError, ConnectInviteInvalidStateError, UnauthorizedError, InternalServerError, - ), + ]), }) .annotate(RequiredScopes, ["channels:write"]) .middleware(AuthMiddleware), @@ -173,7 +173,7 @@ export class ConnectShareRpcs extends RpcGroup.make( organizationId: OrganizationId, }), success: ConnectInviteListResponse, - error: Schema.Union(UnauthorizedError, InternalServerError), + error: Schema.Union([UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["organizations:read"]) .middleware(AuthMiddleware), @@ -183,7 +183,7 @@ export class ConnectShareRpcs extends RpcGroup.make( organizationId: OrganizationId, }), success: ConnectInviteListResponse, - error: Schema.Union(UnauthorizedError, InternalServerError), + error: Schema.Union([UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["channels:read"]) .middleware(AuthMiddleware), @@ -195,7 +195,7 @@ export class ConnectShareRpcs extends RpcGroup.make( status: Schema.optional(ConnectConversation.ConnectConversationStatus), }), success: ConnectConversationResponse, - error: Schema.Union(UnauthorizedError, InternalServerError), + error: Schema.Union([UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["channels:write"]) .middleware(AuthMiddleware), @@ -206,7 +206,7 @@ export class ConnectShareRpcs extends RpcGroup.make( userId: UserId, }), success: ConnectParticipantResponse, - error: Schema.Union(ConnectWorkspaceNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([ConnectWorkspaceNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["channel-members:write"]) .middleware(AuthMiddleware), @@ -217,7 +217,7 @@ export class ConnectShareRpcs extends RpcGroup.make( userId: UserId, }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union(ConnectWorkspaceNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([ConnectWorkspaceNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["channel-members:write"]) .middleware(AuthMiddleware), @@ -228,7 +228,7 @@ export class ConnectShareRpcs extends RpcGroup.make( organizationId: OrganizationId, }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union(UnauthorizedError, InternalServerError), + error: Schema.Union([UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["organizations:write"]) .middleware(AuthMiddleware), diff --git a/packages/domain/src/rpc/custom-emojis.ts b/packages/domain/src/rpc/custom-emojis.ts index b50346085..fb26cc6b6 100644 --- a/packages/domain/src/rpc/custom-emojis.ts +++ b/packages/domain/src/rpc/custom-emojis.ts @@ -1,4 +1,4 @@ -import { Rpc, RpcGroup } from "@effect/rpc" +import { Rpc, RpcGroup } from "effect/unstable/rpc" import { Schema } from "effect" import { InternalServerError, UnauthorizedError } from "../errors" import { CustomEmojiId, OrganizationId, TransactionId } from "@hazel/schema" @@ -11,14 +11,14 @@ import { RequiredScopes } from "../scopes/required-scopes" * Contains the custom emoji data and a transaction ID for optimistic updates. */ export class CustomEmojiResponse extends Schema.Class("CustomEmojiResponse")({ - data: CustomEmoji.Model.json, + data: CustomEmoji.Schema, transactionId: TransactionId, }) {} /** * Error thrown when a custom emoji is not found. */ -export class CustomEmojiNotFoundError extends Schema.TaggedError()( +export class CustomEmojiNotFoundError extends Schema.TaggedErrorClass()( "CustomEmojiNotFoundError", { customEmojiId: CustomEmojiId, @@ -28,7 +28,7 @@ export class CustomEmojiNotFoundError extends Schema.TaggedError()( +export class CustomEmojiNameConflictError extends Schema.TaggedErrorClass()( "CustomEmojiNameConflictError", { name: Schema.String, @@ -40,7 +40,7 @@ export class CustomEmojiNameConflictError extends Schema.TaggedError()( +export class CustomEmojiDeletedExistsError extends Schema.TaggedErrorClass()( "CustomEmojiDeletedExistsError", { customEmojiId: CustomEmojiId, @@ -70,12 +70,12 @@ export class CustomEmojiRpcs extends RpcGroup.make( imageUrl: Schema.String, }), success: CustomEmojiResponse, - error: Schema.Union( + error: Schema.Union([ CustomEmojiNameConflictError, CustomEmojiDeletedExistsError, UnauthorizedError, InternalServerError, - ), + ]), }) .annotate(RequiredScopes, ["custom-emojis:write"]) .middleware(AuthMiddleware), @@ -98,12 +98,12 @@ export class CustomEmojiRpcs extends RpcGroup.make( name: Schema.optional(Schema.String), }), success: CustomEmojiResponse, - error: Schema.Union( + error: Schema.Union([ CustomEmojiNotFoundError, CustomEmojiNameConflictError, UnauthorizedError, InternalServerError, - ), + ]), }) .annotate(RequiredScopes, ["custom-emojis:write"]) .middleware(AuthMiddleware), @@ -122,7 +122,7 @@ export class CustomEmojiRpcs extends RpcGroup.make( Rpc.make("customEmoji.delete", { payload: Schema.Struct({ id: CustomEmojiId }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union(CustomEmojiNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([CustomEmojiNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["custom-emojis:write"]) .middleware(AuthMiddleware), @@ -145,12 +145,12 @@ export class CustomEmojiRpcs extends RpcGroup.make( imageUrl: Schema.optional(Schema.String), }), success: CustomEmojiResponse, - error: Schema.Union( + error: Schema.Union([ CustomEmojiNotFoundError, CustomEmojiNameConflictError, UnauthorizedError, InternalServerError, - ), + ]), }) .annotate(RequiredScopes, ["custom-emojis:write"]) .middleware(AuthMiddleware), diff --git a/packages/domain/src/rpc/github-subscriptions.ts b/packages/domain/src/rpc/github-subscriptions.ts index c8816d840..dbdbbc7af 100644 --- a/packages/domain/src/rpc/github-subscriptions.ts +++ b/packages/domain/src/rpc/github-subscriptions.ts @@ -1,4 +1,4 @@ -import { Rpc, RpcGroup } from "@effect/rpc" +import { Rpc, RpcGroup } from "effect/unstable/rpc" import { ChannelId, GitHubSubscriptionId } from "@hazel/schema" import { Schema } from "effect" import { InternalServerError, UnauthorizedError } from "../errors" @@ -15,7 +15,7 @@ import { RequiredScopes } from "../scopes/required-scopes" export class GitHubSubscriptionResponse extends Schema.Class( "GitHubSubscriptionResponse", )({ - data: GitHubSubscription.Model.json, + data: GitHubSubscription.Schema, transactionId: TransactionId, }) {} @@ -25,13 +25,13 @@ export class GitHubSubscriptionResponse extends Schema.Class( "GitHubSubscriptionListResponse", )({ - data: Schema.Array(GitHubSubscription.Model.json), + data: Schema.Array(GitHubSubscription.Schema), }) {} /** * Error thrown when a GitHub subscription is not found. */ -export class GitHubSubscriptionNotFoundError extends Schema.TaggedError()( +export class GitHubSubscriptionNotFoundError extends Schema.TaggedErrorClass()( "GitHubSubscriptionNotFoundError", { subscriptionId: GitHubSubscriptionId, @@ -41,7 +41,7 @@ export class GitHubSubscriptionNotFoundError extends Schema.TaggedError()( +export class GitHubSubscriptionExistsError extends Schema.TaggedErrorClass()( "GitHubSubscriptionExistsError", { channelId: ChannelId, @@ -52,7 +52,7 @@ export class GitHubSubscriptionExistsError extends Schema.TaggedError()( +export class GitHubNotConnectedError extends Schema.TaggedErrorClass()( "GitHubNotConnectedError", {}, ) {} @@ -93,13 +93,13 @@ export class GitHubSubscriptionRpcs extends RpcGroup.make( branchFilter: Schema.optional(Schema.NullOr(Schema.String)), }), success: GitHubSubscriptionResponse, - error: Schema.Union( + error: Schema.Union([ ChannelNotFoundError, GitHubNotConnectedError, GitHubSubscriptionExistsError, UnauthorizedError, InternalServerError, - ), + ]), }) .annotate(RequiredScopes, ["github-subscriptions:write"]) .middleware(AuthMiddleware), @@ -117,7 +117,7 @@ export class GitHubSubscriptionRpcs extends RpcGroup.make( Rpc.make("githubSubscription.list", { payload: Schema.Struct({ channelId: ChannelId }), success: GitHubSubscriptionListResponse, - error: Schema.Union(ChannelNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([ChannelNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["github-subscriptions:read"]) .middleware(AuthMiddleware), @@ -134,7 +134,7 @@ export class GitHubSubscriptionRpcs extends RpcGroup.make( Rpc.make("githubSubscription.listByOrganization", { payload: Schema.Struct({}), success: GitHubSubscriptionListResponse, - error: Schema.Union(UnauthorizedError, InternalServerError), + error: Schema.Union([UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["github-subscriptions:read"]) .middleware(AuthMiddleware), @@ -157,7 +157,7 @@ export class GitHubSubscriptionRpcs extends RpcGroup.make( isEnabled: Schema.optional(Schema.Boolean), }), success: GitHubSubscriptionResponse, - error: Schema.Union(GitHubSubscriptionNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([GitHubSubscriptionNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["github-subscriptions:write"]) .middleware(AuthMiddleware), @@ -175,7 +175,7 @@ export class GitHubSubscriptionRpcs extends RpcGroup.make( Rpc.make("githubSubscription.delete", { payload: Schema.Struct({ id: GitHubSubscriptionId }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union(GitHubSubscriptionNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([GitHubSubscriptionNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["github-subscriptions:write"]) .middleware(AuthMiddleware), diff --git a/packages/domain/src/rpc/integration-requests.ts b/packages/domain/src/rpc/integration-requests.ts index 7bfd4d3d5..bbb3e85c8 100644 --- a/packages/domain/src/rpc/integration-requests.ts +++ b/packages/domain/src/rpc/integration-requests.ts @@ -1,4 +1,4 @@ -import { Rpc, RpcGroup } from "@effect/rpc" +import { Rpc, RpcGroup } from "effect/unstable/rpc" import { Schema } from "effect" import { InternalServerError, UnauthorizedError } from "../errors" import { IntegrationRequestId, OrganizationId } from "@hazel/schema" @@ -13,7 +13,7 @@ import { RequiredScopes } from "../scopes/required-scopes" export class IntegrationRequestResponse extends Schema.Class( "IntegrationRequestResponse", )({ - data: IntegrationRequest.Model.json, + data: IntegrationRequest.Schema, transactionId: TransactionId, }) {} @@ -24,7 +24,7 @@ export class CreateIntegrationRequestPayload extends Schema.Class("InvitationResponse")({ - data: Invitation.Model.json, + data: Invitation.Schema, transactionId: TransactionId, }) {} @@ -23,7 +23,7 @@ export class InvitationResponse extends Schema.Class("Invita export class InvitationBatchResult extends Schema.Class("InvitationBatchResult")({ email: Schema.String, success: Schema.Boolean, - data: Schema.optional(Invitation.Model.json), + data: Schema.optional(Invitation.Schema), error: Schema.optional(Schema.String), transactionId: Schema.optional(TransactionId), }) {} @@ -44,7 +44,7 @@ export class InvitationBatchResponse extends Schema.Class()( +export class InvitationNotFoundError extends Schema.TaggedErrorClass()( "InvitationNotFoundError", { invitationId: InvitationId, @@ -69,12 +69,12 @@ export class InvitationRpcs extends RpcGroup.make( invites: Schema.Array( Schema.Struct({ email: Schema.String, - role: Schema.Literal("member", "admin"), + role: Schema.Literals(["member", "admin"]), }), ), }), success: InvitationBatchResponse, - error: Schema.Union(UnauthorizedError, InternalServerError), + error: Schema.Union([UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["invitations:write"]) .middleware(AuthMiddleware), @@ -94,7 +94,7 @@ export class InvitationRpcs extends RpcGroup.make( Rpc.make("invitation.resend", { payload: Schema.Struct({ invitationId: InvitationId }), success: InvitationResponse, - error: Schema.Union(InvitationNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([InvitationNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["invitations:write"]) .middleware(AuthMiddleware), @@ -114,7 +114,7 @@ export class InvitationRpcs extends RpcGroup.make( Rpc.make("invitation.revoke", { payload: Schema.Struct({ invitationId: InvitationId }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union(InvitationNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([InvitationNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["invitations:write"]) .middleware(AuthMiddleware), @@ -134,10 +134,10 @@ export class InvitationRpcs extends RpcGroup.make( Rpc.make("invitation.update", { payload: Schema.Struct({ id: InvitationId, - ...Invitation.Model.jsonUpdate.fields, + ...Invitation.Patch.fields, }), success: InvitationResponse, - error: Schema.Union(InvitationNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([InvitationNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["invitations:write"]) .middleware(AuthMiddleware), @@ -157,7 +157,7 @@ export class InvitationRpcs extends RpcGroup.make( Rpc.make("invitation.delete", { payload: Schema.Struct({ id: InvitationId }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union(InvitationNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([InvitationNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["invitations:write"]) .middleware(AuthMiddleware), diff --git a/packages/domain/src/rpc/message-reactions.ts b/packages/domain/src/rpc/message-reactions.ts index fdf2416e2..79de287f0 100644 --- a/packages/domain/src/rpc/message-reactions.ts +++ b/packages/domain/src/rpc/message-reactions.ts @@ -1,4 +1,4 @@ -import { Rpc, RpcGroup } from "@effect/rpc" +import { Rpc, RpcGroup } from "effect/unstable/rpc" import { Schema } from "effect" import { InternalServerError, UnauthorizedError } from "../errors" import { MessageReactionId } from "@hazel/schema" @@ -14,7 +14,7 @@ import { RequiredScopes } from "../scopes/required-scopes" */ export class MessageReactionResponse extends Schema.Class("MessageReactionResponse")( { - data: MessageReaction.Model.json, + data: MessageReaction.Schema, transactionId: TransactionId, }, ) {} @@ -23,7 +23,7 @@ export class MessageReactionResponse extends Schema.Class()( +export class MessageReactionNotFoundError extends Schema.TaggedErrorClass()( "MessageReactionNotFoundError", { messageReactionId: MessageReactionId, @@ -52,10 +52,10 @@ export class MessageReactionRpcs extends RpcGroup.make( }), success: Schema.Struct({ wasCreated: Schema.Boolean, - data: Schema.optional(MessageReaction.Model.json), + data: Schema.optional(MessageReaction.Schema), transactionId: TransactionId, }), - error: Schema.Union(MessageNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([MessageNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["message-reactions:write"]) .middleware(AuthMiddleware), @@ -75,7 +75,7 @@ export class MessageReactionRpcs extends RpcGroup.make( Rpc.make("messageReaction.create", { payload: MessageReaction.Insert, success: MessageReactionResponse, - error: Schema.Union(MessageNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([MessageNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["message-reactions:write"]) .middleware(AuthMiddleware), @@ -95,10 +95,10 @@ export class MessageReactionRpcs extends RpcGroup.make( Rpc.make("messageReaction.update", { payload: Schema.Struct({ id: MessageReactionId, - ...MessageReaction.Model.jsonUpdate.fields, + ...MessageReaction.Patch.fields, }), success: MessageReactionResponse, - error: Schema.Union(MessageReactionNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([MessageReactionNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["message-reactions:write"]) .middleware(AuthMiddleware), @@ -118,7 +118,7 @@ export class MessageReactionRpcs extends RpcGroup.make( Rpc.make("messageReaction.delete", { payload: Schema.Struct({ id: MessageReactionId }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union(MessageReactionNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([MessageReactionNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["message-reactions:write"]) .middleware(AuthMiddleware), diff --git a/packages/domain/src/rpc/messages.ts b/packages/domain/src/rpc/messages.ts index 345e55b23..ccc6e3aba 100644 --- a/packages/domain/src/rpc/messages.ts +++ b/packages/domain/src/rpc/messages.ts @@ -1,4 +1,4 @@ -import { Rpc, RpcGroup } from "@effect/rpc" +import { Rpc, RpcGroup } from "effect/unstable/rpc" import { Schema } from "effect" import { InternalServerError, MessageNotFoundError, UnauthorizedError } from "../errors" import { MessageId } from "@hazel/schema" @@ -17,7 +17,7 @@ export { MessageNotFoundError } from "../errors" * Contains the message data and a transaction ID for optimistic updates. */ export class MessageResponse extends Schema.Class("MessageResponse")({ - data: Message.Model.json, + data: Message.Schema, transactionId: TransactionId, }) {} @@ -71,12 +71,12 @@ export class MessageRpcs extends RpcGroup.make( Rpc.make("message.create", { payload: Message.Insert, success: MessageResponse, - error: Schema.Union( + error: Schema.Union([ ChannelNotFoundError, UnauthorizedError, InternalServerError, RateLimitExceededError, - ), + ]), }) .annotate(RequiredScopes, ["messages:write"]) .middleware(AuthMiddleware), @@ -97,14 +97,14 @@ export class MessageRpcs extends RpcGroup.make( Rpc.make("message.update", { payload: Schema.Struct({ id: MessageId, - }).pipe(Schema.extend(Message.JsonUpdate)), + }).pipe(Schema.fieldsAssign((Message.Patch as Schema.Struct).fields)), success: MessageResponse, - error: Schema.Union( + error: Schema.Union([ MessageNotFoundError, UnauthorizedError, InternalServerError, RateLimitExceededError, - ), + ]), }) .annotate(RequiredScopes, ["messages:write"]) .middleware(AuthMiddleware), @@ -125,12 +125,12 @@ export class MessageRpcs extends RpcGroup.make( Rpc.make("message.delete", { payload: Schema.Struct({ id: MessageId }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union( + error: Schema.Union([ MessageNotFoundError, UnauthorizedError, InternalServerError, RateLimitExceededError, - ), + ]), }) .annotate(RequiredScopes, ["messages:write"]) .middleware(AuthMiddleware), diff --git a/packages/domain/src/rpc/middleware.ts b/packages/domain/src/rpc/middleware.ts index d9c934d29..4327116d2 100644 --- a/packages/domain/src/rpc/middleware.ts +++ b/packages/domain/src/rpc/middleware.ts @@ -5,7 +5,7 @@ * in browser code. Server-side implementations live in the backend package. */ -import { RpcMiddleware } from "@effect/rpc" +import { RpcMiddleware } from "effect/unstable/rpc" import { Schema as S } from "effect" import * as CurrentUser from "../current-user" import { UnauthorizedError } from "../errors" @@ -42,7 +42,7 @@ import { * }) * ``` */ -const AuthFailure = S.Union( +const AuthFailure = S.Union([ UnauthorizedError, SessionLoadError, SessionAuthenticationError, @@ -52,10 +52,14 @@ const AuthFailure = S.Union( SessionExpiredError, InvalidBearerTokenError, WorkOSUserFetchError, -) +]) -export class AuthMiddleware extends RpcMiddleware.Tag()("AuthMiddleware", { - provides: CurrentUser.Context, - failure: AuthFailure, +export class AuthMiddleware extends RpcMiddleware.Service< + AuthMiddleware, + { + provides: CurrentUser.Context + } +>()("AuthMiddleware", { + error: AuthFailure, requiredForClient: true, }) {} diff --git a/packages/domain/src/rpc/notifications.ts b/packages/domain/src/rpc/notifications.ts index fa968aa0f..f4b4e0f4e 100644 --- a/packages/domain/src/rpc/notifications.ts +++ b/packages/domain/src/rpc/notifications.ts @@ -1,4 +1,4 @@ -import { Rpc, RpcGroup } from "@effect/rpc" +import { Rpc, RpcGroup } from "effect/unstable/rpc" import { Schema } from "effect" import { InternalServerError, UnauthorizedError } from "../errors" import { ChannelId, MessageId, NotificationId } from "@hazel/schema" @@ -12,7 +12,7 @@ import { RequiredScopes } from "../scopes/required-scopes" * Contains the notification data and a transaction ID for optimistic updates. */ export class NotificationResponse extends Schema.Class("NotificationResponse")({ - data: Notification.Model.json, + data: Notification.Schema, transactionId: TransactionId, }) {} @@ -20,7 +20,7 @@ export class NotificationResponse extends Schema.Class("No * Error thrown when a notification is not found. * Used in update and delete operations. */ -export class NotificationNotFoundError extends Schema.TaggedError()( +export class NotificationNotFoundError extends Schema.TaggedErrorClass()( "NotificationNotFoundError", { notificationId: NotificationId, @@ -40,9 +40,9 @@ export class NotificationRpcs extends RpcGroup.make( * @throws InternalServerError for unexpected errors */ Rpc.make("notification.create", { - payload: Notification.Model.jsonCreate, + payload: Notification.Create, success: NotificationResponse, - error: Schema.Union(UnauthorizedError, InternalServerError), + error: Schema.Union([UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["notifications:write"]) .middleware(AuthMiddleware), @@ -62,10 +62,10 @@ export class NotificationRpcs extends RpcGroup.make( Rpc.make("notification.update", { payload: Schema.Struct({ id: NotificationId, - ...Notification.Model.jsonUpdate.fields, + ...Notification.Patch.fields, }), success: NotificationResponse, - error: Schema.Union(NotificationNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([NotificationNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["notifications:write"]) .middleware(AuthMiddleware), @@ -85,7 +85,7 @@ export class NotificationRpcs extends RpcGroup.make( Rpc.make("notification.delete", { payload: Schema.Struct({ id: NotificationId }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union(NotificationNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([NotificationNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["notifications:write"]) .middleware(AuthMiddleware), @@ -110,7 +110,7 @@ export class NotificationRpcs extends RpcGroup.make( deletedCount: Schema.Number, transactionId: TransactionId, }), - error: Schema.Union(UnauthorizedError, InternalServerError), + error: Schema.Union([UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["notifications:write"]) .middleware(AuthMiddleware), diff --git a/packages/domain/src/rpc/organization-members.ts b/packages/domain/src/rpc/organization-members.ts index 35c698b2c..099171b98 100644 --- a/packages/domain/src/rpc/organization-members.ts +++ b/packages/domain/src/rpc/organization-members.ts @@ -1,4 +1,4 @@ -import { Rpc, RpcGroup } from "@effect/rpc" +import { Rpc, RpcGroup } from "effect/unstable/rpc" import { Schema } from "effect" import { InternalServerError, UnauthorizedError } from "../errors" import { OrganizationMemberId } from "@hazel/schema" @@ -15,7 +15,7 @@ import { OrganizationNotFoundError } from "./organizations" export class OrganizationMemberResponse extends Schema.Class( "OrganizationMemberResponse", )({ - data: OrganizationMember.Model.json, + data: OrganizationMember.Schema, transactionId: TransactionId, }) {} @@ -23,7 +23,7 @@ export class OrganizationMemberResponse extends Schema.Class()( +export class OrganizationMemberNotFoundError extends Schema.TaggedErrorClass()( "OrganizationMemberNotFoundError", { organizationMemberId: OrganizationMemberId, @@ -75,9 +75,9 @@ export class OrganizationMemberRpcs extends RpcGroup.make( * @throws InternalServerError for unexpected errors */ Rpc.make("organizationMember.create", { - payload: OrganizationMember.Model.jsonCreate, + payload: OrganizationMember.Create, success: OrganizationMemberResponse, - error: Schema.Union(OrganizationNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([OrganizationNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["organization-members:write"]) .middleware(AuthMiddleware), @@ -97,10 +97,10 @@ export class OrganizationMemberRpcs extends RpcGroup.make( Rpc.make("organizationMember.update", { payload: Schema.Struct({ id: OrganizationMemberId, - ...OrganizationMember.Model.jsonUpdate.fields, + ...OrganizationMember.Patch.fields, }), success: OrganizationMemberResponse, - error: Schema.Union(OrganizationMemberNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([OrganizationMemberNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["organization-members:write"]) .middleware(AuthMiddleware), @@ -126,7 +126,7 @@ export class OrganizationMemberRpcs extends RpcGroup.make( }), }), success: OrganizationMemberResponse, - error: Schema.Union(OrganizationMemberNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([OrganizationMemberNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["organization-members:write"]) .middleware(AuthMiddleware), @@ -146,7 +146,7 @@ export class OrganizationMemberRpcs extends RpcGroup.make( Rpc.make("organizationMember.delete", { payload: Schema.Struct({ id: OrganizationMemberId }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union(OrganizationMemberNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([OrganizationMemberNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["organization-members:write"]) .middleware(AuthMiddleware), diff --git a/packages/domain/src/rpc/organizations.ts b/packages/domain/src/rpc/organizations.ts index 61a6031b3..9fca94552 100644 --- a/packages/domain/src/rpc/organizations.ts +++ b/packages/domain/src/rpc/organizations.ts @@ -1,4 +1,4 @@ -import { Rpc, RpcGroup } from "@effect/rpc" +import { Rpc, RpcGroup } from "effect/unstable/rpc" import { Schema } from "effect" import { InternalServerError, UnauthorizedError } from "../errors" import { OrganizationId } from "@hazel/schema" @@ -12,7 +12,7 @@ import { RequiredScopes } from "../scopes/required-scopes" * Contains the organization data and a transaction ID for optimistic updates. */ export class OrganizationResponse extends Schema.Class("OrganizationResponse")({ - data: Organization.Model.json, + data: Organization.Schema, transactionId: TransactionId, }) {} @@ -20,7 +20,7 @@ export class OrganizationResponse extends Schema.Class("Or * Error thrown when an organization is not found. * Used in update and delete operations. */ -export class OrganizationNotFoundError extends Schema.TaggedError()( +export class OrganizationNotFoundError extends Schema.TaggedErrorClass()( "OrganizationNotFoundError", { organizationId: OrganizationId, @@ -30,7 +30,7 @@ export class OrganizationNotFoundError extends Schema.TaggedError()( +export class OrganizationSlugAlreadyExistsError extends Schema.TaggedErrorClass()( "OrganizationSlugAlreadyExistsError", { message: Schema.String, @@ -41,7 +41,7 @@ export class OrganizationSlugAlreadyExistsError extends Schema.TaggedError()( +export class PublicInviteDisabledError extends Schema.TaggedErrorClass()( "PublicInviteDisabledError", { organizationId: OrganizationId, @@ -51,7 +51,7 @@ export class PublicInviteDisabledError extends Schema.TaggedError()("AlreadyMemberError", { +export class AlreadyMemberError extends Schema.TaggedErrorClass()("AlreadyMemberError", { organizationId: OrganizationId, organizationSlug: Schema.NullOr(Schema.String), }) {} @@ -70,9 +70,9 @@ export class PublicOrganizationInfo extends Schema.Class export class OrganizationRpcs extends RpcGroup.make( Rpc.make("organization.create", { - payload: Organization.Model.jsonCreate, + payload: Organization.Create, success: OrganizationResponse, - error: Schema.Union(OrganizationSlugAlreadyExistsError, UnauthorizedError, InternalServerError), + error: Schema.Union([OrganizationSlugAlreadyExistsError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["organizations:write"]) .middleware(AuthMiddleware), @@ -80,14 +80,14 @@ export class OrganizationRpcs extends RpcGroup.make( Rpc.make("organization.update", { payload: Schema.Struct({ id: OrganizationId, - }).pipe(Schema.extend(Schema.partial(Organization.Model.jsonUpdate))), + }).pipe(Schema.fieldsAssign((Organization.Patch as Schema.Struct).fields)), success: OrganizationResponse, - error: Schema.Union( + error: Schema.Union([ OrganizationNotFoundError, OrganizationSlugAlreadyExistsError, UnauthorizedError, InternalServerError, - ), + ]), }) .annotate(RequiredScopes, ["organizations:write"]) .middleware(AuthMiddleware), @@ -95,7 +95,7 @@ export class OrganizationRpcs extends RpcGroup.make( Rpc.make("organization.delete", { payload: Schema.Struct({ id: OrganizationId }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union(OrganizationNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([OrganizationNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["organizations:write"]) .middleware(AuthMiddleware), @@ -106,12 +106,12 @@ export class OrganizationRpcs extends RpcGroup.make( slug: Schema.String, }), success: OrganizationResponse, - error: Schema.Union( + error: Schema.Union([ OrganizationNotFoundError, OrganizationSlugAlreadyExistsError, UnauthorizedError, InternalServerError, - ), + ]), }) .annotate(RequiredScopes, ["organizations:write"]) .middleware(AuthMiddleware), @@ -127,7 +127,7 @@ export class OrganizationRpcs extends RpcGroup.make( isPublic: Schema.Boolean, }), success: OrganizationResponse, - error: Schema.Union(OrganizationNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([OrganizationNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["organizations:write"]) .middleware(AuthMiddleware), @@ -154,13 +154,13 @@ export class OrganizationRpcs extends RpcGroup.make( slug: Schema.String, }), success: OrganizationResponse, - error: Schema.Union( + error: Schema.Union([ OrganizationNotFoundError, PublicInviteDisabledError, AlreadyMemberError, UnauthorizedError, InternalServerError, - ), + ]), }) .annotate(RequiredScopes, ["organizations:write"]) .middleware(AuthMiddleware), @@ -173,10 +173,10 @@ export class OrganizationRpcs extends RpcGroup.make( Rpc.make("organization.getAdminPortalLink", { payload: Schema.Struct({ id: OrganizationId, - intent: Schema.Literal("sso", "domain_verification", "dsync", "audit_logs", "log_streams"), + intent: Schema.Literals(["sso", "domain_verification", "dsync", "audit_logs", "log_streams"]), }), success: Schema.Struct({ link: Schema.String }), - error: Schema.Union(OrganizationNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([OrganizationNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["organizations:write"]) .middleware(AuthMiddleware), @@ -191,11 +191,11 @@ export class OrganizationRpcs extends RpcGroup.make( Schema.Struct({ id: Schema.String, domain: Schema.String, - state: Schema.Literal("pending", "verified", "failed", "legacy_verified"), + state: Schema.Literals(["pending", "verified", "failed", "legacy_verified"]), verificationToken: Schema.NullOr(Schema.String), }), ), - error: Schema.Union(OrganizationNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([OrganizationNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["organizations:write"]) .middleware(AuthMiddleware), @@ -215,7 +215,7 @@ export class OrganizationRpcs extends RpcGroup.make( state: Schema.String, verificationToken: Schema.NullOr(Schema.String), }), - error: Schema.Union(OrganizationNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([OrganizationNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["organizations:write"]) .middleware(AuthMiddleware), @@ -230,7 +230,7 @@ export class OrganizationRpcs extends RpcGroup.make( domainId: Schema.String, }), success: Schema.Struct({ success: Schema.Boolean }), - error: Schema.Union(OrganizationNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([OrganizationNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["organizations:write"]) .middleware(AuthMiddleware), diff --git a/packages/domain/src/rpc/pinned-messages.ts b/packages/domain/src/rpc/pinned-messages.ts index f7b91332c..de442d75a 100644 --- a/packages/domain/src/rpc/pinned-messages.ts +++ b/packages/domain/src/rpc/pinned-messages.ts @@ -1,4 +1,4 @@ -import { Rpc, RpcGroup } from "@effect/rpc" +import { Rpc, RpcGroup } from "effect/unstable/rpc" import { Schema } from "effect" import { InternalServerError, UnauthorizedError } from "../errors" import { ChannelId, MessageId, PinnedMessageId } from "@hazel/schema" @@ -13,7 +13,7 @@ import { RequiredScopes } from "../scopes/required-scopes" * Contains the pinned message data and a transaction ID for optimistic updates. */ export class PinnedMessageResponse extends Schema.Class("PinnedMessageResponse")({ - data: PinnedMessage.Model.json, + data: PinnedMessage.Schema, transactionId: TransactionId, }) {} @@ -21,7 +21,7 @@ export class PinnedMessageResponse extends Schema.Class(" * Error thrown when a pinned message is not found. * Used in update and delete operations. */ -export class PinnedMessageNotFoundError extends Schema.TaggedError()( +export class PinnedMessageNotFoundError extends Schema.TaggedErrorClass()( "PinnedMessageNotFoundError", { pinnedMessageId: PinnedMessageId, @@ -77,7 +77,7 @@ export class PinnedMessageRpcs extends RpcGroup.make( messageId: MessageId, }), success: PinnedMessageResponse, - error: Schema.Union(MessageNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([MessageNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["pinned-messages:write"]) .middleware(AuthMiddleware), @@ -97,10 +97,10 @@ export class PinnedMessageRpcs extends RpcGroup.make( Rpc.make("pinnedMessage.update", { payload: Schema.Struct({ id: PinnedMessageId, - ...PinnedMessage.Model.jsonUpdate.fields, + ...PinnedMessage.Patch.fields, }), success: PinnedMessageResponse, - error: Schema.Union(PinnedMessageNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([PinnedMessageNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["pinned-messages:write"]) .middleware(AuthMiddleware), @@ -120,7 +120,7 @@ export class PinnedMessageRpcs extends RpcGroup.make( Rpc.make("pinnedMessage.delete", { payload: Schema.Struct({ id: PinnedMessageId }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union(PinnedMessageNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([PinnedMessageNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["pinned-messages:write"]) .middleware(AuthMiddleware), diff --git a/packages/domain/src/rpc/rss-subscriptions.ts b/packages/domain/src/rpc/rss-subscriptions.ts index bb91234c2..7812218a8 100644 --- a/packages/domain/src/rpc/rss-subscriptions.ts +++ b/packages/domain/src/rpc/rss-subscriptions.ts @@ -1,4 +1,4 @@ -import { Rpc, RpcGroup } from "@effect/rpc" +import { Rpc, RpcGroup } from "effect/unstable/rpc" import { ChannelId, RssSubscriptionId } from "@hazel/schema" import { Schema } from "effect" import { InternalServerError, UnauthorizedError } from "../errors" @@ -10,7 +10,7 @@ import { RequiredScopes } from "../scopes/required-scopes" export class RssSubscriptionResponse extends Schema.Class("RssSubscriptionResponse")( { - data: RssSubscription.Model.json, + data: RssSubscription.Schema, transactionId: TransactionId, }, ) {} @@ -18,17 +18,17 @@ export class RssSubscriptionResponse extends Schema.Class( "RssSubscriptionListResponse", )({ - data: Schema.Array(RssSubscription.Model.json), + data: Schema.Array(RssSubscription.Schema), }) {} -export class RssSubscriptionNotFoundError extends Schema.TaggedError()( +export class RssSubscriptionNotFoundError extends Schema.TaggedErrorClass()( "RssSubscriptionNotFoundError", { subscriptionId: RssSubscriptionId, }, ) {} -export class RssSubscriptionExistsError extends Schema.TaggedError()( +export class RssSubscriptionExistsError extends Schema.TaggedErrorClass()( "RssSubscriptionExistsError", { channelId: ChannelId, @@ -36,7 +36,7 @@ export class RssSubscriptionExistsError extends Schema.TaggedError()( +export class RssFeedValidationError extends Schema.TaggedErrorClass()( "RssFeedValidationError", { feedUrl: Schema.String, @@ -52,13 +52,13 @@ export class RssSubscriptionRpcs extends RpcGroup.make( pollingIntervalMinutes: Schema.optional(Schema.Number), }), success: RssSubscriptionResponse, - error: Schema.Union( + error: Schema.Union([ ChannelNotFoundError, RssSubscriptionExistsError, RssFeedValidationError, UnauthorizedError, InternalServerError, - ), + ]), }) .annotate(RequiredScopes, ["rss-subscriptions:write"]) .middleware(AuthMiddleware), @@ -66,7 +66,7 @@ export class RssSubscriptionRpcs extends RpcGroup.make( Rpc.make("rssSubscription.list", { payload: Schema.Struct({ channelId: ChannelId }), success: RssSubscriptionListResponse, - error: Schema.Union(ChannelNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([ChannelNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["rss-subscriptions:read"]) .middleware(AuthMiddleware), @@ -74,7 +74,7 @@ export class RssSubscriptionRpcs extends RpcGroup.make( Rpc.make("rssSubscription.listByOrganization", { payload: Schema.Struct({}), success: RssSubscriptionListResponse, - error: Schema.Union(UnauthorizedError, InternalServerError), + error: Schema.Union([UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["rss-subscriptions:read"]) .middleware(AuthMiddleware), @@ -86,7 +86,7 @@ export class RssSubscriptionRpcs extends RpcGroup.make( pollingIntervalMinutes: Schema.optional(Schema.Number), }), success: RssSubscriptionResponse, - error: Schema.Union(RssSubscriptionNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([RssSubscriptionNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["rss-subscriptions:write"]) .middleware(AuthMiddleware), @@ -94,7 +94,7 @@ export class RssSubscriptionRpcs extends RpcGroup.make( Rpc.make("rssSubscription.delete", { payload: Schema.Struct({ id: RssSubscriptionId }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union(RssSubscriptionNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([RssSubscriptionNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["rss-subscriptions:write"]) .middleware(AuthMiddleware), diff --git a/packages/domain/src/rpc/scope-injection-middleware.ts b/packages/domain/src/rpc/scope-injection-middleware.ts index ab831e0d1..8c7106292 100644 --- a/packages/domain/src/rpc/scope-injection-middleware.ts +++ b/packages/domain/src/rpc/scope-injection-middleware.ts @@ -1,4 +1,4 @@ -import { RpcMiddleware } from "@effect/rpc" +import { RpcMiddleware } from "effect/unstable/rpc" /** * Middleware that reads RequiredScopes from the RPC annotation @@ -7,7 +7,6 @@ import { RpcMiddleware } from "@effect/rpc" * Uses `wrap: true` so it wraps the handler with Effect.locally * to set the FiberRef value. */ -export class ScopeInjectionMiddleware extends RpcMiddleware.Tag()( +export class ScopeInjectionMiddleware extends RpcMiddleware.Service()( "ScopeInjectionMiddleware", - { wrap: true }, ) {} diff --git a/packages/domain/src/rpc/typing-indicators.ts b/packages/domain/src/rpc/typing-indicators.ts index d2a348044..4314a5148 100644 --- a/packages/domain/src/rpc/typing-indicators.ts +++ b/packages/domain/src/rpc/typing-indicators.ts @@ -1,4 +1,4 @@ -import { Rpc, RpcGroup } from "@effect/rpc" +import { Rpc, RpcGroup } from "effect/unstable/rpc" import { Schema } from "effect" import { InternalServerError, UnauthorizedError } from "../errors" import { TypingIndicatorId } from "@hazel/schema" @@ -13,7 +13,7 @@ import { RequiredScopes } from "../scopes/required-scopes" */ export class TypingIndicatorResponse extends Schema.Class("TypingIndicatorResponse")( { - data: TypingIndicator.Model.json, + data: TypingIndicator.Schema, transactionId: TransactionId, }, ) {} @@ -22,7 +22,7 @@ export class TypingIndicatorResponse extends Schema.Class()( +export class TypingIndicatorNotFoundError extends Schema.TaggedErrorClass()( "TypingIndicatorNotFoundError", { typingIndicatorId: TypingIndicatorId, @@ -36,8 +36,8 @@ export class TypingIndicatorNotFoundError extends Schema.TaggedError( "CreateTypingIndicatorPayload", )({ - channelId: Schema.UUID.pipe(Schema.brand("@HazelChat/ChannelId")), - memberId: Schema.UUID.pipe(Schema.brand("@HazelChat/ChannelMemberId")), + channelId: Schema.String.check(Schema.isUUID()).pipe(Schema.brand("@HazelChat/ChannelId")), + memberId: Schema.String.check(Schema.isUUID()).pipe(Schema.brand("@HazelChat/ChannelMemberId")), lastTyped: Schema.optional(Schema.Number), }) {} @@ -88,7 +88,7 @@ export class TypingIndicatorRpcs extends RpcGroup.make( Rpc.make("typingIndicator.create", { payload: CreateTypingIndicatorPayload, success: TypingIndicatorResponse, - error: Schema.Union(UnauthorizedError, InternalServerError), + error: Schema.Union([UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["typing-indicators:write"]) .middleware(AuthMiddleware), @@ -111,7 +111,7 @@ export class TypingIndicatorRpcs extends RpcGroup.make( lastTyped: Schema.optional(Schema.Number), }), success: TypingIndicatorResponse, - error: Schema.Union(TypingIndicatorNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([TypingIndicatorNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["typing-indicators:write"]) .middleware(AuthMiddleware), @@ -131,7 +131,7 @@ export class TypingIndicatorRpcs extends RpcGroup.make( Rpc.make("typingIndicator.delete", { payload: Schema.Struct({ id: TypingIndicatorId }), success: TypingIndicatorResponse, - error: Schema.Union(TypingIndicatorNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([TypingIndicatorNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["typing-indicators:write"]) .middleware(AuthMiddleware), diff --git a/packages/domain/src/rpc/user-presence-status.ts b/packages/domain/src/rpc/user-presence-status.ts index 4bb97ea0b..716676c77 100644 --- a/packages/domain/src/rpc/user-presence-status.ts +++ b/packages/domain/src/rpc/user-presence-status.ts @@ -1,4 +1,4 @@ -import { Rpc, RpcGroup } from "@effect/rpc" +import { Rpc, RpcGroup } from "effect/unstable/rpc" import { Schema } from "effect" import { InternalServerError, UnauthorizedError } from "../errors" import { UserPresenceStatusId } from "@hazel/schema" @@ -15,7 +15,7 @@ import { RequiredScopes } from "../scopes/required-scopes" export class UserPresenceStatusResponse extends Schema.Class( "UserPresenceStatusResponse", )({ - data: UserPresenceStatus.Model.json, + data: UserPresenceStatus.Schema, transactionId: TransactionId, }) {} @@ -23,7 +23,7 @@ export class UserPresenceStatusResponse extends Schema.Class()( +export class UserPresenceStatusNotFoundError extends Schema.TaggedErrorClass()( "UserPresenceStatusNotFoundError", { statusId: UserPresenceStatusId, @@ -63,17 +63,15 @@ export class UserPresenceStatusRpcs extends RpcGroup.make( */ Rpc.make("userPresenceStatus.update", { payload: Schema.Struct({ - status: Schema.optional(UserPresenceStatus.Model.json.fields.status), + status: Schema.optional(UserPresenceStatus.Schema.fields.status), customMessage: Schema.optional(Schema.NullOr(Schema.String)), statusEmoji: Schema.optional(Schema.NullOr(Schema.String)), statusExpiresAt: Schema.optional(Schema.NullOr(JsonDate)), - activeChannelId: Schema.optional( - Schema.NullOr(UserPresenceStatus.Model.json.fields.activeChannelId), - ), + activeChannelId: Schema.optional(Schema.NullOr(UserPresenceStatus.Schema.fields.activeChannelId)), suppressNotifications: Schema.optional(Schema.Boolean), }), success: UserPresenceStatusResponse, - error: Schema.Union(UnauthorizedError, InternalServerError), + error: Schema.Union([UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["user-presence-status:write"]) .middleware(AuthMiddleware), @@ -94,7 +92,7 @@ export class UserPresenceStatusRpcs extends RpcGroup.make( success: Schema.Struct({ lastSeenAt: JsonDate, }), - error: Schema.Union(UnauthorizedError, InternalServerError), + error: Schema.Union([UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["user-presence-status:write"]) .middleware(AuthMiddleware), @@ -112,7 +110,7 @@ export class UserPresenceStatusRpcs extends RpcGroup.make( Rpc.make("userPresenceStatus.clearStatus", { payload: Schema.Struct({}), success: UserPresenceStatusResponse, - error: Schema.Union(UnauthorizedError, InternalServerError), + error: Schema.Union([UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["user-presence-status:write"]) .middleware(AuthMiddleware), diff --git a/packages/domain/src/rpc/users.ts b/packages/domain/src/rpc/users.ts index 630ef407b..29dfa4cd5 100644 --- a/packages/domain/src/rpc/users.ts +++ b/packages/domain/src/rpc/users.ts @@ -1,4 +1,4 @@ -import { Rpc, RpcGroup } from "@effect/rpc" +import { Rpc, RpcGroup } from "effect/unstable/rpc" import { Schema } from "effect" import * as CurrentUser from "../current-user" import { InternalServerError, UnauthorizedError } from "../errors" @@ -13,7 +13,7 @@ import { RequiredScopes } from "../scopes/required-scopes" * Contains the user data and a transaction ID for optimistic updates. */ export class UserResponse extends Schema.Class("UserResponse")({ - data: User.Model.json, + data: User.Schema, transactionId: TransactionId, }) {} @@ -21,7 +21,7 @@ export class UserResponse extends Schema.Class("UserResponse")({ * Error thrown when a user is not found. * Used in update and delete operations. */ -export class UserNotFoundError extends Schema.TaggedError()("UserNotFoundError", { +export class UserNotFoundError extends Schema.TaggedErrorClass()("UserNotFoundError", { userId: UserId, }) {} @@ -38,7 +38,7 @@ export class UserRpcs extends RpcGroup.make( Rpc.make("user.me", { payload: Schema.Void, success: CurrentUser.Schema, - error: Schema.Union(UnauthorizedError, InternalServerError), + error: Schema.Union([UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["users:read"]) .middleware(AuthMiddleware), @@ -58,9 +58,9 @@ export class UserRpcs extends RpcGroup.make( Rpc.make("user.update", { payload: Schema.Struct({ id: UserId, - }).pipe(Schema.extend(Schema.partial(User.Model.jsonUpdate))), + }).pipe(Schema.fieldsAssign((User.Patch as Schema.Struct).fields)), success: UserResponse, - error: Schema.Union(UserNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([UserNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["users:write"]) .middleware(AuthMiddleware), @@ -80,7 +80,7 @@ export class UserRpcs extends RpcGroup.make( Rpc.make("user.delete", { payload: Schema.Struct({ id: UserId }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union(UserNotFoundError, UnauthorizedError, InternalServerError), + error: Schema.Union([UserNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["users:write"]) .middleware(AuthMiddleware), @@ -98,7 +98,7 @@ export class UserRpcs extends RpcGroup.make( Rpc.make("user.finalizeOnboarding", { payload: Schema.Void, success: UserResponse, - error: Schema.Union(UnauthorizedError, InternalServerError), + error: Schema.Union([UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["users:write"]) .middleware(AuthMiddleware), @@ -117,7 +117,7 @@ export class UserRpcs extends RpcGroup.make( Rpc.make("user.resetAvatar", { payload: Schema.Void, success: UserResponse, - error: Schema.Union(UnauthorizedError, InternalServerError), + error: Schema.Union([UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["users:write"]) .middleware(AuthMiddleware), diff --git a/packages/domain/src/scopes/api-scope.ts b/packages/domain/src/scopes/api-scope.ts index 8e181e740..e64f0d8ae 100644 --- a/packages/domain/src/scopes/api-scope.ts +++ b/packages/domain/src/scopes/api-scope.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -export const ApiScope = Schema.Literal( +export const ApiScope = Schema.Literals([ "organizations:read", "organizations:write", "channels:read", @@ -41,6 +41,6 @@ export const ApiScope = Schema.Literal( "user-presence-status:write", "users:read", "users:write", -) +]) export type ApiScope = typeof ApiScope.Type diff --git a/packages/domain/src/scopes/current-bot-scopes.ts b/packages/domain/src/scopes/current-bot-scopes.ts index 03e7da439..fd14d2b92 100644 --- a/packages/domain/src/scopes/current-bot-scopes.ts +++ b/packages/domain/src/scopes/current-bot-scopes.ts @@ -1,8 +1,8 @@ -import { FiberRef, Option } from "effect" +import { ServiceMap, Option } from "effect" import type { ApiScope } from "./api-scope" /** - * FiberRef holding the authenticated bot's granted API scopes. + * Service holding the authenticated bot's granted API scopes. * * Set by the auth middleware when the actor is a bot. * When Option.some, OrgResolver uses these scopes instead of role-based @@ -11,4 +11,7 @@ import type { ApiScope } from "./api-scope" * When Option.none (default), OrgResolver falls back to role-based scopes * for normal (human) users. */ -export const CurrentBotScopes = FiberRef.unsafeMake>>(Option.none()) +export class CurrentBotScopes extends ServiceMap.Service< + CurrentBotScopes, + Option.Option> +>()("CurrentBotScopes") {} diff --git a/packages/domain/src/scopes/current-rpc-scopes.ts b/packages/domain/src/scopes/current-rpc-scopes.ts index 30397f972..b70c0f482 100644 --- a/packages/domain/src/scopes/current-rpc-scopes.ts +++ b/packages/domain/src/scopes/current-rpc-scopes.ts @@ -1,15 +1,14 @@ -import { FiberRef } from "effect" +import { ServiceMap } from "effect" import type { ApiScope } from "./api-scope" /** - * FiberRef holding the required scopes for the currently executing RPC. + * Service holding the required scopes for the currently executing RPC. * - * Populated by the ScopeInjectionMiddleware (via Effect.locally) from the + * Populated by the ScopeInjectionMiddleware from the * RPC's RequiredScopes annotation. Policy utilities read from this instead * of accepting hardcoded scope strings, ensuring annotation and enforcement * always match. - * - * Uses FiberRef (not Context.Tag) so it doesn't leak into the R type of - * Effect.Service layers. */ -export const CurrentRpcScopes = FiberRef.unsafeMake>([]) +export class CurrentRpcScopes extends ServiceMap.Service>()( + "CurrentRpcScopes", +) {} diff --git a/packages/domain/src/scopes/permission-error.ts b/packages/domain/src/scopes/permission-error.ts index 3d32990fb..b39ea92e3 100644 --- a/packages/domain/src/scopes/permission-error.ts +++ b/packages/domain/src/scopes/permission-error.ts @@ -1,16 +1,13 @@ -import { HttpApiSchema } from "@effect/platform" import { Predicate, Schema } from "effect" import type { ApiScope } from "./api-scope" -export class PermissionError extends Schema.TaggedError("PermissionError")( +export class PermissionError extends Schema.TaggedErrorClass("PermissionError")( "PermissionError", { message: Schema.String, requiredScope: Schema.optional(Schema.String), }, - HttpApiSchema.annotations({ - status: 403, - }), + { httpApiStatus: 403 }, ) { static is(u: unknown): u is PermissionError { return Predicate.isTagged(u, "PermissionError") diff --git a/packages/domain/src/scopes/required-scopes.ts b/packages/domain/src/scopes/required-scopes.ts index dff7ebd58..f23194b90 100644 --- a/packages/domain/src/scopes/required-scopes.ts +++ b/packages/domain/src/scopes/required-scopes.ts @@ -1,4 +1,4 @@ -import { Context } from "effect" +import { ServiceMap } from "effect" import type { ApiScope } from "./api-scope" /** @@ -14,7 +14,6 @@ import type { ApiScope } from "./api-scope" * - Empty array `[]` = public endpoint (no scope needed) * - Missing annotation = error (caught by startup validation) */ -export class RequiredScopes extends Context.Tag("@hazel/domain/RequiredScopes")< - RequiredScopes, - ReadonlyArray ->() {} +export class RequiredScopes extends ServiceMap.Service>()( + "@hazel/domain/RequiredScopes", +) {} diff --git a/packages/domain/src/scopes/scope-map.ts b/packages/domain/src/scopes/scope-map.ts index 33b869a39..2dcfc3137 100644 --- a/packages/domain/src/scopes/scope-map.ts +++ b/packages/domain/src/scopes/scope-map.ts @@ -1,4 +1,4 @@ -import { Context, Option } from "effect" +import { ServiceMap, Option } from "effect" import type { ApiScope } from "./api-scope" import { RequiredScopes } from "./required-scopes" @@ -10,17 +10,19 @@ export type ScopeMap = Record> /** * Extracts a ScopeMap from an RpcGroup's `requests` map. * - * Each entry in `requests` has an `annotations` field (a `Context.Context`) + * Each entry in `requests` has an `annotations` field (a `ServiceMap.ServiceMap`) * where we look up the `RequiredScopes` tag. */ export const scopeMapFromRpcGroup = ( - requests: ReadonlyMap }>, + requests: ReadonlyMap }>, ): ScopeMap => { const map: Record> = {} for (const [tag, rpc] of requests) { - const scopes = Context.getOption(rpc.annotations, RequiredScopes) - if (Option.isSome(scopes)) { - map[tag] = scopes.value + const scopes = ServiceMap.get(rpc.annotations as any, RequiredScopes) as + | ReadonlyArray + | undefined + if (scopes) { + map[tag] = scopes } } return map diff --git a/packages/domain/src/scopes/validate-scopes.ts b/packages/domain/src/scopes/validate-scopes.ts index aadc0443e..66cf7ac48 100644 --- a/packages/domain/src/scopes/validate-scopes.ts +++ b/packages/domain/src/scopes/validate-scopes.ts @@ -1,4 +1,4 @@ -import { Context, Option } from "effect" +import { ServiceMap } from "effect" import { RequiredScopes } from "./required-scopes" /** @@ -6,13 +6,15 @@ import { RequiredScopes } from "./required-scopes" * Returns the list of RPC tags that are missing annotations. */ export const validateRpcGroupScopes = ( - requests: ReadonlyMap }>, + requests: ReadonlyMap }>, groupName: string, ): { valid: boolean; missing: string[] } => { const missing: string[] = [] for (const [tag, rpc] of requests) { - const scopes = Context.getOption(rpc.annotations, RequiredScopes) - if (Option.isNone(scopes)) { + const scopes = ServiceMap.get(rpc.annotations as any, RequiredScopes) as + | ReadonlyArray + | undefined + if (!scopes) { missing.push(`${groupName}.${tag}`) } } diff --git a/packages/domain/src/session-errors.ts b/packages/domain/src/session-errors.ts index c60b0eac7..e60a654d6 100644 --- a/packages/domain/src/session-errors.ts +++ b/packages/domain/src/session-errors.ts @@ -1,8 +1,7 @@ -import { HttpApiSchema } from "@effect/platform" import { Schema } from "effect" // 401 Errors - Client needs to re-authenticate -export class SessionNotProvidedError extends Schema.TaggedError( +export class SessionNotProvidedError extends Schema.TaggedErrorClass( "SessionNotProvidedError", )( "SessionNotProvidedError", @@ -10,12 +9,10 @@ export class SessionNotProvidedError extends Schema.TaggedError( +export class SessionAuthenticationError extends Schema.TaggedErrorClass( "SessionAuthenticationError", )( "SessionAuthenticationError", @@ -23,12 +20,10 @@ export class SessionAuthenticationError extends Schema.TaggedError( +export class InvalidJwtPayloadError extends Schema.TaggedErrorClass( "InvalidJwtPayloadError", )( "InvalidJwtPayloadError", @@ -36,23 +31,19 @@ export class InvalidJwtPayloadError extends Schema.TaggedError("SessionExpiredError")( +export class SessionExpiredError extends Schema.TaggedErrorClass("SessionExpiredError")( "SessionExpiredError", { message: Schema.String, detail: Schema.String, }, - HttpApiSchema.annotations({ - status: 401, - }), + { httpApiStatus: 401 }, ) {} -export class InvalidBearerTokenError extends Schema.TaggedError( +export class InvalidBearerTokenError extends Schema.TaggedErrorClass( "InvalidBearerTokenError", )( "InvalidBearerTokenError", @@ -60,41 +51,35 @@ export class InvalidBearerTokenError extends Schema.TaggedError("SessionLoadError")( +export class SessionLoadError extends Schema.TaggedErrorClass("SessionLoadError")( "SessionLoadError", { message: Schema.String, detail: Schema.String, }, - HttpApiSchema.annotations({ - status: 503, - }), + { httpApiStatus: 503 }, ) {} -export class SessionRefreshError extends Schema.TaggedError("SessionRefreshError")( +export class SessionRefreshError extends Schema.TaggedErrorClass("SessionRefreshError")( "SessionRefreshError", { message: Schema.String, detail: Schema.String, }, - HttpApiSchema.annotations({ - status: 401, - }), + { httpApiStatus: 401 }, ) {} -export class WorkOSUserFetchError extends Schema.TaggedError("WorkOSUserFetchError")( +export class WorkOSUserFetchError extends Schema.TaggedErrorClass( + "WorkOSUserFetchError", +)( "WorkOSUserFetchError", { message: Schema.String, detail: Schema.String, }, - HttpApiSchema.annotations({ - status: 503, - }), + { httpApiStatus: 503 }, ) {} diff --git a/packages/effect-bun/package.json b/packages/effect-bun/package.json index a7ac8cd6e..f35e11a3b 100644 --- a/packages/effect-bun/package.json +++ b/packages/effect-bun/package.json @@ -15,14 +15,11 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@effect/experimental": "catalog:effect", "@effect/opentelemetry": "catalog:effect", - "@effect/platform": "catalog:effect", "@effect/platform-bun": "catalog:effect", "effect": "catalog:effect" }, "devDependencies": { - "@effect/language-service": "catalog:effect", "@types/bun": "latest", "typescript": "^5.9.3" } diff --git a/packages/effect-bun/src/Redis.ts b/packages/effect-bun/src/Redis.ts index f9f9057e2..0fef120fd 100644 --- a/packages/effect-bun/src/Redis.ts +++ b/packages/effect-bun/src/Redis.ts @@ -1,12 +1,12 @@ import { RedisClient } from "bun" -import { Config, Context, Duration, Effect, Layer, Match, Schema } from "effect" +import { Config, Duration, Effect, Layer, Match, Schema, ServiceMap } from "effect" // ============ Error Types ============ /** * Base Redis error - used for unknown error codes */ -export class RedisError extends Schema.TaggedError()("RedisError", { +export class RedisError extends Schema.TaggedErrorClass()("RedisError", { message: Schema.String, code: Schema.optional(Schema.String), cause: Schema.optional(Schema.Unknown), @@ -16,7 +16,7 @@ export class RedisError extends Schema.TaggedError()("RedisError", { * Connection to Redis server was closed * Bun error code: ERR_REDIS_CONNECTION_CLOSED */ -export class RedisConnectionClosedError extends Schema.TaggedError()( +export class RedisConnectionClosedError extends Schema.TaggedErrorClass()( "RedisConnectionClosedError", { message: Schema.String, @@ -27,7 +27,7 @@ export class RedisConnectionClosedError extends Schema.TaggedError()( +export class RedisAuthenticationError extends Schema.TaggedErrorClass()( "RedisAuthenticationError", { message: Schema.String, @@ -38,7 +38,7 @@ export class RedisAuthenticationError extends Schema.TaggedError()( +export class RedisInvalidResponseError extends Schema.TaggedErrorClass()( "RedisInvalidResponseError", { message: Schema.String, @@ -89,10 +89,10 @@ const sanitizeRedisUrl = (url: string): string => url.replace(/\/\/.*@/, "//***@ * yield* redis.set("key", "value") * const value = yield* redis.get("key") * return value - * }).pipe(Effect.provide(Redis.Default)) + * }).pipe(Effect.provide(Redis.layer)) * ``` */ -export class Redis extends Context.Tag("@hazel/effect-bun/Redis")< +export class Redis extends ServiceMap.Service< Redis, { // String operations @@ -223,12 +223,12 @@ export class Redis extends Context.Tag("@hazel/effect-bun/Redis")< */ readonly connected: boolean } ->() { +>()("@hazel/effect-bun/Redis") { /** * Create a Redis layer with a specific URL */ static readonly layer = (url: string) => - Layer.scoped( + Layer.effect( Redis, Effect.gen(function* () { const client = new RedisClient(url) @@ -237,12 +237,14 @@ export class Redis extends Context.Tag("@hazel/effect-bun/Redis")< try: () => client.connect(), catch: mapRedisError, }).pipe( - Effect.timeoutFail({ + Effect.timeoutOrElse({ duration: Duration.seconds(10), onTimeout: () => - new RedisError({ - message: `Redis connection timed out after 10s (url: ${sanitizeRedisUrl(url)})`, - }), + Effect.fail( + new RedisError({ + message: `Redis connection timed out after 10s (url: ${sanitizeRedisUrl(url)})`, + }), + ), }), ) @@ -261,7 +263,7 @@ export class Redis extends Context.Tag("@hazel/effect-bun/Redis")< /** * Default Redis layer using REDIS_URL environment variable */ - static readonly Default = Layer.scoped( + static readonly Default = Layer.effect( Redis, Effect.gen(function* () { const url = yield* Config.string("REDIS_URL").pipe(Config.withDefault("redis://localhost:6379")) @@ -271,12 +273,14 @@ export class Redis extends Context.Tag("@hazel/effect-bun/Redis")< try: () => client.connect(), catch: mapRedisError, }).pipe( - Effect.timeoutFail({ + Effect.timeoutOrElse({ duration: Duration.seconds(10), onTimeout: () => - new RedisError({ - message: `Redis connection timed out after 10s (url: ${sanitizeRedisUrl(url)})`, - }), + Effect.fail( + new RedisError({ + message: `Redis connection timed out after 10s (url: ${sanitizeRedisUrl(url)})`, + }), + ), }), ) @@ -296,7 +300,7 @@ export class Redis extends Context.Tag("@hazel/effect-bun/Redis")< /** * Create the Redis service implementation from a connected client */ -const makeService = (client: RedisClient, url: string): Context.Tag.Service => ({ +const makeService = (client: RedisClient, url: string): ServiceMap.Service.Shape => ({ // String operations get: (key) => Effect.tryPromise({ diff --git a/packages/effect-bun/src/S3.ts b/packages/effect-bun/src/S3.ts index 22ecfb17a..b8581ee99 100644 --- a/packages/effect-bun/src/S3.ts +++ b/packages/effect-bun/src/S3.ts @@ -1,13 +1,13 @@ import type { BunFile, S3File, S3FilePresignOptions } from "bun" import { s3 as bunS3 } from "bun" -import { Context, Effect, Layer, Match, Schema } from "effect" +import { Effect, Layer, Match, Schema, ServiceMap } from "effect" // ============ Error Types ============ /** * Base S3 error - used for S3 server errors and unknown error codes */ -export class S3Error extends Schema.TaggedError()("S3Error", { +export class S3Error extends Schema.TaggedErrorClass()("S3Error", { message: Schema.String, code: Schema.optional(Schema.String), cause: Schema.optional(Schema.Unknown), @@ -17,7 +17,7 @@ export class S3Error extends Schema.TaggedError()("S3Error", { * Missing S3 credentials (access key or secret) * Bun error code: ERR_S3_MISSING_CREDENTIALS */ -export class S3MissingCredentialsError extends Schema.TaggedError()( +export class S3MissingCredentialsError extends Schema.TaggedErrorClass()( "S3MissingCredentialsError", { message: Schema.String, @@ -28,15 +28,18 @@ export class S3MissingCredentialsError extends Schema.TaggedError()("S3InvalidMethodError", { - message: Schema.String, -}) {} +export class S3InvalidMethodError extends Schema.TaggedErrorClass()( + "S3InvalidMethodError", + { + message: Schema.String, + }, +) {} /** * Invalid S3 path/key * Bun error code: ERR_S3_INVALID_PATH */ -export class S3InvalidPathError extends Schema.TaggedError()("S3InvalidPathError", { +export class S3InvalidPathError extends Schema.TaggedErrorClass()("S3InvalidPathError", { message: Schema.String, }) {} @@ -44,7 +47,7 @@ export class S3InvalidPathError extends Schema.TaggedError() * Invalid S3 endpoint URL * Bun error code: ERR_S3_INVALID_ENDPOINT */ -export class S3InvalidEndpointError extends Schema.TaggedError()( +export class S3InvalidEndpointError extends Schema.TaggedErrorClass()( "S3InvalidEndpointError", { message: Schema.String, @@ -55,7 +58,7 @@ export class S3InvalidEndpointError extends Schema.TaggedError()( +export class S3InvalidSignatureError extends Schema.TaggedErrorClass()( "S3InvalidSignatureError", { message: Schema.String, @@ -66,7 +69,7 @@ export class S3InvalidSignatureError extends Schema.TaggedError()( +export class S3InvalidSessionTokenError extends Schema.TaggedErrorClass()( "S3InvalidSessionTokenError", { message: Schema.String, @@ -138,7 +141,7 @@ export type S3WriteData = string | ArrayBuffer | Uint8Array | Blob | Response | * }) * ``` */ -export class S3 extends Context.Tag("@hazel/effect-bun/S3")< +export class S3 extends ServiceMap.Service< S3, { /** @@ -162,7 +165,7 @@ export class S3 extends Context.Tag("@hazel/effect-bun/S3")< */ readonly exists: (key: string) => Effect.Effect } ->() { +>()("@hazel/effect-bun/S3") { static readonly Default = Layer.sync(S3, () => ({ file: (key) => Effect.try({ diff --git a/packages/effect-bun/src/Telemetry.ts b/packages/effect-bun/src/Telemetry.ts index 1528b75f5..de03a36dd 100644 --- a/packages/effect-bun/src/Telemetry.ts +++ b/packages/effect-bun/src/Telemetry.ts @@ -1,8 +1,8 @@ -import * as DevTools from "@effect/experimental/DevTools" -import * as Otlp from "@effect/opentelemetry/Otlp" -import * as FetchHttpClient from "@effect/platform/FetchHttpClient" import { BunSocket } from "@effect/platform-bun" import { Config, Effect, Layer } from "effect" +import { DevTools } from "effect/unstable/devtools" +import { FetchHttpClient } from "effect/unstable/http" +import { Otlp, OtlpSerialization } from "effect/unstable/observability" /** * Create an OpenTelemetry tracing layer with a specific service name. @@ -29,7 +29,7 @@ import { Config, Effect, Layer } from "effect" * ``` */ export const createTracingLayer = (otelServiceName: string) => - Layer.unwrapEffect( + Layer.unwrap( Effect.gen(function* () { const environment = yield* Config.string("OTEL_ENVIRONMENT").pipe(Config.withDefault("local")) const commitSha = yield* Config.string("RAILWAY_GIT_COMMIT_SHA").pipe( @@ -51,7 +51,7 @@ export const createTracingLayer = (otelServiceName: string) => const otelBaseUrl = yield* Config.string("OTEL_BASE_URL") - return Otlp.layerJson({ + return Otlp.layer({ baseUrl: otelBaseUrl, resource: { serviceName: otelServiceName, @@ -61,6 +61,6 @@ export const createTracingLayer = (otelServiceName: string) => "deployment.commit_sha": commitSha, }, }, - }).pipe(Layer.provide(FetchHttpClient.layer)) + }).pipe(Layer.provide(FetchHttpClient.layer), Layer.provide(OtlpSerialization.layerJson)) }), ) diff --git a/packages/effect-bun/src/persistence/redis-backing.ts b/packages/effect-bun/src/persistence/redis-backing.ts index 18d6f4476..1ab3e3c9e 100644 --- a/packages/effect-bun/src/persistence/redis-backing.ts +++ b/packages/effect-bun/src/persistence/redis-backing.ts @@ -1,8 +1,14 @@ -import { Persistence } from "@effect/experimental" -import { Duration, Effect, Layer, Option } from "effect" +import { Persistence } from "effect/unstable/persistence" +import { Duration, Effect, Layer } from "effect" import { identity } from "effect/Function" import { Redis } from "../Redis.js" +const makePersistenceError = (method: string, error: unknown) => + new Persistence.PersistenceError({ + message: `Persistence error in ${method}`, + cause: error, + }) + /** * Create a BackingPersistence using @hazel/effect-bun Redis service. * This is the core implementation that bridges Redis to Effect's Persistence system. @@ -11,74 +17,53 @@ export const makeRedisBackingPersistence = Effect.gen(function* () { const redis = yield* Redis return Persistence.BackingPersistence.of({ - [Persistence.BackingPersistenceTypeId]: Persistence.BackingPersistenceTypeId, make: (prefix) => Effect.sync(() => { const prefixed = (key: string) => `${prefix}:${key}` - const parse = (method: string) => (str: string | null) => { - if (str === null) return Effect.succeedNone - return Effect.try({ - try: () => Option.some(JSON.parse(str)), - catch: (error) => Persistence.PersistenceBackingError.make(method, error), - }) - } + const parse = + (method: string) => + (str: string | null): Effect.Effect => { + if (str === null) return Effect.succeed(undefined) + return Effect.try({ + try: () => JSON.parse(str) as object, + catch: (error) => makePersistenceError(method, error), + }) + } return identity({ get: (key) => Effect.flatMap( redis .get(prefixed(key)) - .pipe( - Effect.mapError((error) => - Persistence.PersistenceBackingError.make("get", error), - ), - ), + .pipe(Effect.mapError((error) => makePersistenceError("get", error))), parse("get"), ), getMany: (keys) => - Effect.flatMap( - redis - .send<(string | null)[]>("MGET", keys.map(prefixed)) - .pipe( - Effect.mapError((error) => - Persistence.PersistenceBackingError.make("getMany", error), - ), - ), - Effect.forEach(parse("getMany")), + redis.send<(string | null)[]>("MGET", keys.map(prefixed)).pipe( + Effect.mapError((error) => makePersistenceError("getMany", error)), + Effect.flatMap((results) => Effect.forEach(results, parse("getMany"))), + Effect.map((results) => results as any), ), set: (key, value, ttl) => Effect.gen(function* () { const serialized = yield* Effect.try({ try: () => JSON.stringify(value), - catch: (error) => Persistence.PersistenceBackingError.make("set", error), + catch: (error) => makePersistenceError("set", error), }) const pkey = prefixed(key) - if (Option.isSome(ttl)) { + if (ttl !== undefined) { // Atomic SET with PX (milliseconds) - sets value and TTL in single command yield* redis - .send("SET", [ - pkey, - serialized, - "PX", - String(Duration.toMillis(ttl.value)), - ]) - .pipe( - Effect.mapError((error) => - Persistence.PersistenceBackingError.make("set", error), - ), - ) + .send("SET", [pkey, serialized, "PX", String(Duration.toMillis(ttl))]) + .pipe(Effect.mapError((error) => makePersistenceError("set", error))) } else { yield* redis .set(pkey, serialized) - .pipe( - Effect.mapError((error) => - Persistence.PersistenceBackingError.make("set", error), - ), - ) + .pipe(Effect.mapError((error) => makePersistenceError("set", error))) } }), @@ -87,17 +72,12 @@ export const makeRedisBackingPersistence = Effect.gen(function* () { for (const [key, value, ttl] of entries) { const pkey = prefixed(key) const serialized = JSON.stringify(value) - if (Option.isSome(ttl)) { + if (ttl !== undefined) { yield* redis - .send("SET", [ - pkey, - serialized, - "PX", - String(Duration.toMillis(ttl.value)), - ]) + .send("SET", [pkey, serialized, "PX", String(Duration.toMillis(ttl))]) .pipe( Effect.mapError((error) => - Persistence.PersistenceBackingError.make("setMany", error), + makePersistenceError("setMany", error), ), ) } else { @@ -105,7 +85,7 @@ export const makeRedisBackingPersistence = Effect.gen(function* () { .set(pkey, serialized) .pipe( Effect.mapError((error) => - Persistence.PersistenceBackingError.make("setMany", error), + makePersistenceError("setMany", error), ), ) } @@ -115,28 +95,16 @@ export const makeRedisBackingPersistence = Effect.gen(function* () { remove: (key) => redis .del(prefixed(key)) - .pipe( - Effect.mapError((error) => - Persistence.PersistenceBackingError.make("remove", error), - ), - ), + .pipe(Effect.mapError((error) => makePersistenceError("remove", error))), clear: Effect.gen(function* () { const keys = yield* redis .send("KEYS", [`${prefix}:*`]) - .pipe( - Effect.mapError((error) => - Persistence.PersistenceBackingError.make("clear", error), - ), - ) + .pipe(Effect.mapError((error) => makePersistenceError("clear", error))) if (keys.length > 0) { yield* redis .send("DEL", keys) - .pipe( - Effect.mapError((error) => - Persistence.PersistenceBackingError.make("clear", error), - ), - ) + .pipe(Effect.mapError((error) => makePersistenceError("clear", error))) } }), }) @@ -155,16 +123,14 @@ export const RedisBackingPersistenceLive = Layer.effect( ) /** - * Layer providing ResultPersistence using Redis backing. + * Layer providing Persistence using Redis backing. * Requires: Redis - * Provides: Persistence.ResultPersistence + * Provides: Persistence.Persistence */ -export const RedisResultPersistenceLive = Persistence.layerResult.pipe( - Layer.provide(RedisBackingPersistenceLive), -) +export const RedisResultPersistenceLive = Persistence.layer.pipe(Layer.provide(RedisBackingPersistenceLive)) /** * In-memory persistence layer for testing or fallback. - * Provides: Persistence.ResultPersistence + * Provides: Persistence.Persistence */ -export const MemoryResultPersistenceLive = Persistence.layerResultMemory +export const MemoryResultPersistenceLive = Persistence.layerMemory diff --git a/packages/integrations/package.json b/packages/integrations/package.json index 139ea516c..6fef1b363 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -20,7 +20,6 @@ "./rss": "./src/rss/index.ts" }, "dependencies": { - "@effect/platform": "catalog:effect", "@linear/sdk": "^73.0.0", "effect": "catalog:effect", "he": "^1.2.0", @@ -28,7 +27,6 @@ "rss-parser": "^3.13.0" }, "devDependencies": { - "@effect/language-service": "catalog:effect", "@types/bun": "1.3.9", "@types/he": "^1.2.3", "typescript": "^5.9.3" diff --git a/packages/integrations/src/craft/api-client.ts b/packages/integrations/src/craft/api-client.ts index 16bd1b8d9..e16e9882c 100644 --- a/packages/integrations/src/craft/api-client.ts +++ b/packages/integrations/src/craft/api-client.ts @@ -8,8 +8,8 @@ * and Bearer tokens instead of a single OAuth endpoint. */ -import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest } from "@effect/platform" -import { Duration, Effect, Schedule, Schema } from "effect" +import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { ServiceMap, Duration, Effect, Layer, Schedule, Schema } from "effect" // ============================================================================ // Configuration @@ -21,7 +21,7 @@ const DEFAULT_TIMEOUT = Duration.seconds(30) // Domain Schemas (exported for consumers) // ============================================================================ -export const CraftBlockType = Schema.Literal( +export const CraftBlockType = Schema.Literals([ "text", "line", "page", @@ -42,7 +42,7 @@ export const CraftBlockType = Schema.Literal( "urlBlock", "videoBlock", "cardBlock", -) +]) export type CraftBlockType = typeof CraftBlockType.Type export const CraftBlock = Schema.Struct({ @@ -116,21 +116,24 @@ export type CraftSpaceInfo = typeof CraftSpaceInfo.Type // Error Types // ============================================================================ -export class CraftApiError extends Schema.TaggedError()("CraftApiError", { +export class CraftApiError extends Schema.TaggedErrorClass()("CraftApiError", { message: Schema.String, status: Schema.optional(Schema.Number), cause: Schema.optional(Schema.Unknown), }) {} -export class CraftNotFoundError extends Schema.TaggedError()("CraftNotFoundError", { +export class CraftNotFoundError extends Schema.TaggedErrorClass()("CraftNotFoundError", { resourceType: Schema.String, resourceId: Schema.String, }) {} -export class CraftRateLimitError extends Schema.TaggedError()("CraftRateLimitError", { - message: Schema.String, - retryAfter: Schema.optional(Schema.Number), -}) {} +export class CraftRateLimitError extends Schema.TaggedErrorClass()( + "CraftRateLimitError", + { + message: Schema.String, + retryAfter: Schema.optional(Schema.Number), + }, +) {} // ============================================================================ // Internal Response Schemas @@ -191,7 +194,7 @@ const normalizeCraftItemsResponse = (raw: unknown): unknown[] => { * Retry schedule for transient Craft API errors. * Retries up to 3 times with exponential backoff (100ms, 200ms, 400ms) */ -const makeRetrySchedule = Schedule.exponential("100 millis").pipe(Schedule.intersect(Schedule.recurs(3))) +const makeRetrySchedule = Schedule.exponential("100 millis").pipe(Schedule.both(Schedule.recurs(3))) /** * Check if an error is retryable (rate limit or server error) @@ -224,9 +227,8 @@ const isRetryableError = (error: CraftApiError | CraftNotFoundError | CraftRateL * const documents = yield* CraftApiClient.listDocuments(baseUrl, accessToken) * ``` */ -export class CraftApiClient extends Effect.Service()("CraftApiClient", { - accessors: true, - effect: Effect.gen(function* () { +export class CraftApiClient extends ServiceMap.Service()("CraftApiClient", { + make: Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient /** @@ -308,22 +310,16 @@ export class CraftApiClient extends Effect.Service()("CraftApiCl return yield* response.json }).pipe( - Effect.catchTag("TimeoutException", () => + Effect.catchTag("TimeoutError", () => Effect.fail(new CraftApiError({ message: "Request timed out" })), ), - Effect.catchTag("RequestError", (error) => - Effect.fail( - new CraftApiError({ - message: `Network error: ${String(error)}`, - cause: error, - }), - ), - ), - Effect.catchTag("ResponseError", (error) => + Effect.catchTag("HttpClientError", (error) => Effect.fail( new CraftApiError({ - message: `Response error: ${String(error)}`, - status: error.response.status, + message: error.response + ? `Response error: ${String(error)}` + : `Network error: ${String(error)}`, + status: error.response?.status, cause: error, }), ), @@ -339,7 +335,7 @@ export class CraftApiClient extends Effect.Service()("CraftApiCl const client = makeClient(baseUrl, accessToken) return yield* executeRequest(client, "GET", "/connection").pipe( Effect.flatMap((raw) => - Schema.decodeUnknown(ConnectionInfoResponse)(raw).pipe( + Schema.decodeUnknownEffect(ConnectionInfoResponse)(raw).pipe( Effect.map(normalizeCraftConnectionInfo), Effect.mapError( (e) => @@ -367,8 +363,8 @@ export class CraftApiClient extends Effect.Service()("CraftApiCl const path = `/blocks?id=${encodeURIComponent(resolvedBlockId)}` const raw = yield* executeRequest(client, "GET", path) const normalizedBlocks = normalizeCraftItemsResponse(raw) - return yield* Schema.decodeUnknown(Schema.Array(CraftBlock))(normalizedBlocks).pipe( - Effect.catchAll(() => + return yield* Schema.decodeUnknownEffect(Schema.Array(CraftBlock))(normalizedBlocks).pipe( + Effect.catch(() => Effect.succeed( normalizedBlocks.length > 0 ? (normalizedBlocks as CraftBlock[]) @@ -495,9 +491,9 @@ export class CraftApiClient extends Effect.Service()("CraftApiCl const path = folderId ? `/documents?folderId=${encodeURIComponent(folderId)}` : "/documents" const raw = yield* executeRequest(client, "GET", path) const normalizedDocuments = normalizeCraftItemsResponse(raw) - return yield* Schema.decodeUnknown(Schema.Array(CraftDocument))(normalizedDocuments).pipe( - Effect.catchAll(() => Effect.succeed(normalizedDocuments as CraftDocument[])), - ) + return yield* Schema.decodeUnknownEffect(Schema.Array(CraftDocument))( + normalizedDocuments, + ).pipe(Effect.catch(() => Effect.succeed(normalizedDocuments as CraftDocument[]))) }).pipe( Effect.retry({ schedule: makeRetrySchedule, while: isRetryableError }), Effect.withSpan("CraftApiClient.listDocuments"), @@ -597,8 +593,8 @@ export class CraftApiClient extends Effect.Service()("CraftApiCl const client = makeClient(baseUrl, accessToken) const raw = yield* executeRequest(client, "GET", "/folders") const normalizedFolders = normalizeCraftItemsResponse(raw) - return yield* Schema.decodeUnknown(Schema.Array(CraftFolder))(normalizedFolders).pipe( - Effect.catchAll(() => Effect.succeed(normalizedFolders as CraftFolder[])), + return yield* Schema.decodeUnknownEffect(Schema.Array(CraftFolder))(normalizedFolders).pipe( + Effect.catch(() => Effect.succeed(normalizedFolders as CraftFolder[])), ) }).pipe( Effect.retry({ schedule: makeRetrySchedule, while: isRetryableError }), @@ -666,8 +662,8 @@ export class CraftApiClient extends Effect.Service()("CraftApiCl const path = `/tasks?scope=${resolvedScope}` const raw = yield* executeRequest(client, "GET", path) const normalizedTasks = normalizeCraftItemsResponse(raw) - return yield* Schema.decodeUnknown(Schema.Array(CraftTask))(normalizedTasks).pipe( - Effect.catchAll(() => Effect.succeed(normalizedTasks as CraftTask[])), + return yield* Schema.decodeUnknownEffect(Schema.Array(CraftTask))(normalizedTasks).pipe( + Effect.catch(() => Effect.succeed(normalizedTasks as CraftTask[])), ) }).pipe( Effect.retry({ schedule: makeRetrySchedule, while: isRetryableError }), @@ -736,9 +732,9 @@ export class CraftApiClient extends Effect.Service()("CraftApiCl const client = makeClient(baseUrl, accessToken) const raw = yield* executeRequest(client, "GET", "/collections") const normalizedCollections = normalizeCraftItemsResponse(raw) - return yield* Schema.decodeUnknown(Schema.Array(CraftCollection))(normalizedCollections).pipe( - Effect.catchAll(() => Effect.succeed(normalizedCollections as CraftCollection[])), - ) + return yield* Schema.decodeUnknownEffect(Schema.Array(CraftCollection))( + normalizedCollections, + ).pipe(Effect.catch(() => Effect.succeed(normalizedCollections as CraftCollection[]))) }).pipe( Effect.retry({ schedule: makeRetrySchedule, while: isRetryableError }), Effect.withSpan("CraftApiClient.listCollections"), @@ -870,5 +866,6 @@ export class CraftApiClient extends Effect.Service()("CraftApiCl addComments, } }), - dependencies: [FetchHttpClient.layer], -}) {} +}) { + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(FetchHttpClient.layer)) +} diff --git a/packages/integrations/src/discord/api-client.ts b/packages/integrations/src/discord/api-client.ts index 268abf189..b36f35356 100644 --- a/packages/integrations/src/discord/api-client.ts +++ b/packages/integrations/src/discord/api-client.ts @@ -1,5 +1,5 @@ -import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest } from "@effect/platform" -import { Duration, Effect, Schema } from "effect" +import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { ServiceMap, Duration, Effect, Layer, Result, Schema } from "effect" export const DiscordAccountInfo = Schema.Struct({ externalAccountId: Schema.String, @@ -31,14 +31,14 @@ const DiscordUserApiResponse = Schema.Struct({ id: Schema.String, username: Schema.String, global_name: Schema.optional(Schema.NullOr(Schema.String)), - discriminator: Schema.optionalWith(Schema.String, { default: () => "0" }), + discriminator: Schema.String.pipe(Schema.withDecodingDefaultKey(() => "0")), }) const DiscordGuildApiResponse = Schema.Struct({ id: Schema.String, name: Schema.String, icon: Schema.optional(Schema.NullOr(Schema.String)), - owner: Schema.optionalWith(Schema.Boolean, { default: () => false }), + owner: Schema.Boolean.pipe(Schema.withDecodingDefaultKey(() => false)), }) const DiscordWebhookCreateResponse = Schema.Struct({ @@ -59,10 +59,10 @@ const DiscordMessageCreateResponse = Schema.Struct({ }) const DiscordErrorApiResponse = Schema.Struct({ - message: Schema.optionalWith(Schema.String, { default: () => "Unknown Discord error" }), + message: Schema.String.pipe(Schema.withDecodingDefaultKey(() => "Unknown Discord error")), }) -export class DiscordApiError extends Schema.TaggedError()("DiscordApiError", { +export class DiscordApiError extends Schema.TaggedErrorClass()("DiscordApiError", { message: Schema.String, status: Schema.optional(Schema.Number), cause: Schema.optional(Schema.Unknown), @@ -87,9 +87,8 @@ const parseDiscordErrorMessage = (status: number, message: string): string => { const channelTypeIsMessageCapable = (type: number): boolean => type === 0 || type === 5 || type === 10 || type === 11 || type === 12 -export class DiscordApiClient extends Effect.Service()("DiscordApiClient", { - accessors: true, - effect: Effect.gen(function* () { +export class DiscordApiClient extends ServiceMap.Service()("DiscordApiClient", { + make: Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient const makeBearerClient = (token: string) => @@ -116,16 +115,16 @@ export class DiscordApiClient extends Effect.Service()("Discor status: number json: Effect.Effect }) { - const bodyResult = yield* response.json.pipe(Effect.either) - const message = - bodyResult._tag === "Right" - ? (() => { - const decoded = Schema.decodeUnknownSync(DiscordErrorApiResponse)( - bodyResult.right, - ) - return parseDiscordErrorMessage(response.status, decoded.message) - })() - : `Discord API request failed with status ${response.status}` + const bodyResult = yield* response.json.pipe(Effect.result) + const message = Result.isSuccess(bodyResult) + ? (() => { + const decoded = Schema.decodeUnknownSync(DiscordErrorApiResponse)(bodyResult.success) + return parseDiscordErrorMessage( + response.status, + decoded.message ?? "Unknown Discord error", + ) + })() + : `Discord API request failed with status ${response.status}` return yield* Effect.fail( new DiscordApiError({ @@ -145,7 +144,7 @@ export class DiscordApiClient extends Effect.Service()("Discor } const body = yield* response.json.pipe( - Effect.flatMap(Schema.decodeUnknown(DiscordUserApiResponse)), + Effect.flatMap(Schema.decodeUnknownEffect(DiscordUserApiResponse)), Effect.mapError( (cause) => new DiscordApiError({ @@ -175,7 +174,7 @@ export class DiscordApiClient extends Effect.Service()("Discor } const body = yield* response.json.pipe( - Effect.flatMap(Schema.decodeUnknown(Schema.Array(DiscordGuildApiResponse))), + Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(DiscordGuildApiResponse))), Effect.mapError( (cause) => new DiscordApiError({ @@ -208,7 +207,7 @@ export class DiscordApiClient extends Effect.Service()("Discor } const body = yield* response.json.pipe( - Effect.flatMap(Schema.decodeUnknown(Schema.Array(DiscordGuildChannelApiResponse))), + Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(DiscordGuildChannelApiResponse))), Effect.mapError( (cause) => new DiscordApiError({ @@ -267,7 +266,7 @@ export class DiscordApiClient extends Effect.Service()("Discor } const body = yield* response.json.pipe( - Effect.flatMap(Schema.decodeUnknown(DiscordMessageCreateResponse)), + Effect.flatMap(Schema.decodeUnknownEffect(DiscordMessageCreateResponse)), Effect.mapError( (cause) => new DiscordApiError({ @@ -303,7 +302,7 @@ export class DiscordApiClient extends Effect.Service()("Discor } const body = yield* response.json.pipe( - Effect.flatMap(Schema.decodeUnknown(DiscordWebhookCreateResponse)), + Effect.flatMap(Schema.decodeUnknownEffect(DiscordWebhookCreateResponse)), Effect.mapError( (cause) => new DiscordApiError({ @@ -366,7 +365,7 @@ export class DiscordApiClient extends Effect.Service()("Discor } const body = yield* response.json.pipe( - Effect.flatMap(Schema.decodeUnknown(DiscordMessageCreateResponse)), + Effect.flatMap(Schema.decodeUnknownEffect(DiscordMessageCreateResponse)), Effect.mapError( (cause) => new DiscordApiError({ @@ -519,7 +518,7 @@ export class DiscordApiClient extends Effect.Service()("Discor } const body = yield* response.json.pipe( - Effect.flatMap(Schema.decodeUnknown(DiscordMessageCreateResponse)), + Effect.flatMap(Schema.decodeUnknownEffect(DiscordMessageCreateResponse)), Effect.mapError( (cause) => new DiscordApiError({ @@ -548,5 +547,6 @@ export class DiscordApiClient extends Effect.Service()("Discor createThread, } }), - dependencies: [FetchHttpClient.layer], -}) {} +}) { + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(FetchHttpClient.layer)) +} diff --git a/packages/integrations/src/github/api-client.ts b/packages/integrations/src/github/api-client.ts index 84025ca94..96840ff07 100644 --- a/packages/integrations/src/github/api-client.ts +++ b/packages/integrations/src/github/api-client.ts @@ -1,5 +1,5 @@ -import { FetchHttpClient, HttpClient, HttpClientRequest } from "@effect/platform" -import { Duration, Effect, Schedule, Schema } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { ServiceMap, Duration, Effect, Layer, Schedule, Schema } from "effect" /** * GitHub PR URL patterns: @@ -38,7 +38,7 @@ export const GitHubPR = Schema.Struct({ number: Schema.Number, title: Schema.String, body: Schema.NullOr(Schema.String), - state: Schema.Literal("open", "closed"), + state: Schema.Literals(["open", "closed"]), draft: Schema.Boolean, merged: Schema.Boolean, author: Schema.NullOr(GitHubPRAuthor), @@ -97,14 +97,14 @@ export type GitHubAccountInfo = typeof GitHubAccountInfo.Type // ============================================================================ // Error for when GitHub API request fails -export class GitHubApiError extends Schema.TaggedError()("GitHubApiError", { +export class GitHubApiError extends Schema.TaggedErrorClass()("GitHubApiError", { message: Schema.String, status: Schema.optional(Schema.Number), cause: Schema.optional(Schema.Unknown), }) {} // Error for when PR is not found -export class GitHubPRNotFoundError extends Schema.TaggedError()( +export class GitHubPRNotFoundError extends Schema.TaggedErrorClass()( "GitHubPRNotFoundError", { owner: Schema.String, @@ -114,10 +114,13 @@ export class GitHubPRNotFoundError extends Schema.TaggedError()("GitHubRateLimitError", { - message: Schema.String, - retryAfter: Schema.optional(Schema.Number), // seconds until rate limit resets -}) {} +export class GitHubRateLimitError extends Schema.TaggedErrorClass()( + "GitHubRateLimitError", + { + message: Schema.String, + retryAfter: Schema.optional(Schema.Number), // seconds until rate limit resets + }, +) {} // ============================================================================ // GitHub API Response Schemas (internal, for validation) @@ -129,27 +132,24 @@ const GitHubPRApiResponse = Schema.Struct({ title: Schema.String, body: Schema.NullOr(Schema.String), state: Schema.String, - draft: Schema.optionalWith(Schema.Boolean, { default: () => false }), - merged: Schema.optionalWith(Schema.Boolean, { default: () => false }), + draft: Schema.Boolean.pipe(Schema.withDecodingDefaultKey(() => false)), + merged: Schema.Boolean.pipe(Schema.withDecodingDefaultKey(() => false)), user: Schema.NullOr( Schema.Struct({ login: Schema.String, avatar_url: Schema.optional(Schema.NullOr(Schema.String)), }), ), - additions: Schema.optionalWith(Schema.Number, { default: () => 0 }), - deletions: Schema.optionalWith(Schema.Number, { default: () => 0 }), + additions: Schema.Number.pipe(Schema.withDecodingDefaultKey(() => 0)), + deletions: Schema.Number.pipe(Schema.withDecodingDefaultKey(() => 0)), head: Schema.optional(Schema.Struct({ ref: Schema.String })), updated_at: Schema.optional(Schema.String), - labels: Schema.optionalWith( - Schema.Array( - Schema.Struct({ - name: Schema.String, - color: Schema.String, - }), - ), - { default: () => [] }, - ), + labels: Schema.Array( + Schema.Struct({ + name: Schema.String, + color: Schema.String, + }), + ).pipe(Schema.withDecodingDefaultKey(() => [])), }) // GitHub API repository owner response schema @@ -178,13 +178,13 @@ const GitHubRepositoriesApiResponse = Schema.Struct({ // GitHub API error response schema const GitHubErrorApiResponse = Schema.Struct({ - message: Schema.optionalWith(Schema.String, { default: () => "Unknown error" }), + message: Schema.String.pipe(Schema.withDecodingDefaultKey(() => "Unknown error")), }) // GitHub App info response schema const GitHubAppApiResponse = Schema.Struct({ id: Schema.Number, - name: Schema.optionalWith(Schema.String, { default: () => "GitHub App" }), + name: Schema.String.pipe(Schema.withDecodingDefaultKey(() => "GitHub App")), }) // ============================================================================ @@ -269,7 +269,7 @@ const DEFAULT_TIMEOUT = Duration.seconds(30) * Retries up to 3 times with exponential backoff (100ms, 200ms, 400ms) * only for rate limits (429) and server errors (5xx). */ -const makeRetrySchedule = Schedule.exponential("100 millis").pipe(Schedule.intersect(Schedule.recurs(3))) +const makeRetrySchedule = Schedule.exponential("100 millis").pipe(Schedule.both(Schedule.recurs(3))) /** * Check if an error is retryable (rate limit or server error) @@ -311,9 +311,8 @@ const isRetryableError = (error: GitHubApiError | GitHubRateLimitError | GitHubP * - Rate limit handling with retry-after header support * - Distributed tracing via Effect spans */ -export class GitHubApiClient extends Effect.Service()("GitHubApiClient", { - accessors: true, - effect: Effect.gen(function* () { +export class GitHubApiClient extends ServiceMap.Service()("GitHubApiClient", { + make: Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient /** @@ -377,8 +376,8 @@ export class GitHubApiClient extends Effect.Service()("GitHubAp // Handle other error status codes if (response.status >= 400) { const errorBody = yield* response.json.pipe( - Effect.flatMap(Schema.decodeUnknown(GitHubErrorApiResponse)), - Effect.catchAll((error) => + Effect.flatMap(Schema.decodeUnknownEffect(GitHubErrorApiResponse)), + Effect.catch((error) => Effect.logDebug(`Failed to parse GitHub error response: ${String(error)}`).pipe( Effect.as({ message: "Unknown error" }), ), @@ -390,7 +389,7 @@ export class GitHubApiClient extends Effect.Service()("GitHubAp // Parse successful response const prData = yield* response.json.pipe( - Effect.flatMap(Schema.decodeUnknown(GitHubPRApiResponse)), + Effect.flatMap(Schema.decodeUnknownEffect(GitHubPRApiResponse)), Effect.mapError( (error) => new GitHubApiError({ @@ -462,8 +461,8 @@ export class GitHubApiClient extends Effect.Service()("GitHubAp // Handle 403 which GitHub uses for rate limiting on unauthenticated requests if (response.status === 403) { const errorBody = yield* response.json.pipe( - Effect.flatMap(Schema.decodeUnknown(GitHubErrorApiResponse)), - Effect.catchAll(() => Effect.succeed({ message: "" })), + Effect.flatMap(Schema.decodeUnknownEffect(GitHubErrorApiResponse)), + Effect.catch(() => Effect.succeed({ message: "" })), ) if (errorBody.message.toLowerCase().includes("rate limit")) { return yield* Effect.fail( @@ -481,8 +480,8 @@ export class GitHubApiClient extends Effect.Service()("GitHubAp // Handle other error status codes if (response.status >= 400) { const errorBody = yield* response.json.pipe( - Effect.flatMap(Schema.decodeUnknown(GitHubErrorApiResponse)), - Effect.catchAll((error) => + Effect.flatMap(Schema.decodeUnknownEffect(GitHubErrorApiResponse)), + Effect.catch((error) => Effect.logDebug(`Failed to parse GitHub error response: ${String(error)}`).pipe( Effect.as({ message: "Unknown error" }), ), @@ -494,7 +493,7 @@ export class GitHubApiClient extends Effect.Service()("GitHubAp // Parse successful response const prData = yield* response.json.pipe( - Effect.flatMap(Schema.decodeUnknown(GitHubPRApiResponse)), + Effect.flatMap(Schema.decodeUnknownEffect(GitHubPRApiResponse)), Effect.mapError( (error) => new GitHubApiError({ @@ -559,8 +558,8 @@ export class GitHubApiClient extends Effect.Service()("GitHubAp // Handle error status codes if (response.status >= 400) { const errorBody = yield* response.json.pipe( - Effect.flatMap(Schema.decodeUnknown(GitHubErrorApiResponse)), - Effect.catchAll((error) => + Effect.flatMap(Schema.decodeUnknownEffect(GitHubErrorApiResponse)), + Effect.catch((error) => Effect.logDebug(`Failed to parse GitHub error response: ${String(error)}`).pipe( Effect.as({ message: "Unknown error" }), ), @@ -576,7 +575,7 @@ export class GitHubApiClient extends Effect.Service()("GitHubAp // Parse successful response const data = yield* response.json.pipe( - Effect.flatMap(Schema.decodeUnknown(GitHubRepositoriesApiResponse)), + Effect.flatMap(Schema.decodeUnknownEffect(GitHubRepositoriesApiResponse)), Effect.mapError( (error) => new GitHubApiError({ @@ -639,8 +638,8 @@ export class GitHubApiClient extends Effect.Service()("GitHubAp if (reposResponse.status >= 200 && reposResponse.status < 300) { const data = yield* reposResponse.json.pipe( - Effect.flatMap(Schema.decodeUnknown(GitHubRepositoriesApiResponse)), - Effect.catchAll((error) => + Effect.flatMap(Schema.decodeUnknownEffect(GitHubRepositoriesApiResponse)), + Effect.catch((error) => Effect.logDebug( `Failed to parse GitHub repositories response: ${String(error)}`, ).pipe(Effect.as({ total_count: 0, repositories: [] })), @@ -675,8 +674,8 @@ export class GitHubApiClient extends Effect.Service()("GitHubAp if (appResponse.status >= 200 && appResponse.status < 300) { const appData = yield* appResponse.json.pipe( - Effect.flatMap(Schema.decodeUnknown(GitHubAppApiResponse)), - Effect.catchAll((error) => + Effect.flatMap(Schema.decodeUnknownEffect(GitHubAppApiResponse)), + Effect.catch((error) => Effect.logDebug(`Failed to parse GitHub App response: ${String(error)}`).pipe( Effect.as({ id: 0, name: "GitHub App" }), ), @@ -699,22 +698,16 @@ export class GitHubApiClient extends Effect.Service()("GitHubAp // Apply error handling, retry, and spans to each method const wrappedFetchPR = (owner: string, repo: string, prNumber: number, accessToken: string) => fetchPR(owner, repo, prNumber, accessToken).pipe( - Effect.catchTag("TimeoutException", () => + Effect.catchTag("TimeoutError", () => Effect.fail(new GitHubApiError({ message: "Request timed out" })), ), - Effect.catchTag("RequestError", (error) => + Effect.catchTag("HttpClientError", (error) => Effect.fail( new GitHubApiError({ - message: `Network error: ${String(error)}`, - cause: error, - }), - ), - ), - Effect.catchTag("ResponseError", (error) => - Effect.fail( - new GitHubApiError({ - message: `Response error: ${String(error)}`, - status: error.response.status, + message: error.response + ? `Response error: ${String(error)}` + : `Network error: ${String(error)}`, + status: error.response?.status, cause: error, }), ), @@ -728,22 +721,16 @@ export class GitHubApiClient extends Effect.Service()("GitHubAp const wrappedFetchPRPublic = (owner: string, repo: string, prNumber: number) => fetchPRPublic(owner, repo, prNumber).pipe( - Effect.catchTag("TimeoutException", () => + Effect.catchTag("TimeoutError", () => Effect.fail(new GitHubApiError({ message: "Request timed out" })), ), - Effect.catchTag("RequestError", (error) => - Effect.fail( - new GitHubApiError({ - message: `Network error: ${String(error)}`, - cause: error, - }), - ), - ), - Effect.catchTag("ResponseError", (error) => + Effect.catchTag("HttpClientError", (error) => Effect.fail( new GitHubApiError({ - message: `Response error: ${String(error)}`, - status: error.response.status, + message: error.response + ? `Response error: ${String(error)}` + : `Network error: ${String(error)}`, + status: error.response?.status, cause: error, }), ), @@ -757,22 +744,16 @@ export class GitHubApiClient extends Effect.Service()("GitHubAp const wrappedFetchRepositories = (accessToken: string, page: number, perPage: number) => fetchRepositories(accessToken, page, perPage).pipe( - Effect.catchTag("TimeoutException", () => + Effect.catchTag("TimeoutError", () => Effect.fail(new GitHubApiError({ message: "Request timed out" })), ), - Effect.catchTag("RequestError", (error) => - Effect.fail( - new GitHubApiError({ - message: `Network error: ${String(error)}`, - cause: error, - }), - ), - ), - Effect.catchTag("ResponseError", (error) => + Effect.catchTag("HttpClientError", (error) => Effect.fail( new GitHubApiError({ - message: `Response error: ${String(error)}`, - status: error.response.status, + message: error.response + ? `Response error: ${String(error)}` + : `Network error: ${String(error)}`, + status: error.response?.status, cause: error, }), ), @@ -789,22 +770,16 @@ export class GitHubApiClient extends Effect.Service()("GitHubAp const wrappedGetAccountInfo = (accessToken: string) => getAccountInfo(accessToken).pipe( - Effect.catchTag("TimeoutException", () => + Effect.catchTag("TimeoutError", () => Effect.fail(new GitHubApiError({ message: "Request timed out" })), ), - Effect.catchTag("RequestError", (error) => + Effect.catchTag("HttpClientError", (error) => Effect.fail( new GitHubApiError({ - message: `Network error: ${String(error)}`, - cause: error, - }), - ), - ), - Effect.catchTag("ResponseError", (error) => - Effect.fail( - new GitHubApiError({ - message: `Response error: ${String(error)}`, - status: error.response.status, + message: error.response + ? `Response error: ${String(error)}` + : `Network error: ${String(error)}`, + status: error.response?.status, cause: error, }), ), @@ -826,8 +801,9 @@ export class GitHubApiClient extends Effect.Service()("GitHubAp getAccountInfo: wrappedGetAccountInfo, } }), - dependencies: [FetchHttpClient.layer], -}) {} +}) { + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(FetchHttpClient.layer)) +} // ============================================================================ // Legacy exports for backwards compatibility @@ -848,4 +824,4 @@ export const fetchGitHubPR = ( Effect.gen(function* () { const client = yield* GitHubApiClient return yield* client.fetchPR(owner, repo, prNumber, accessToken) - }).pipe(Effect.provide(GitHubApiClient.Default)) + }).pipe(Effect.provide(GitHubApiClient.layer)) diff --git a/packages/integrations/src/github/jwt-service.ts b/packages/integrations/src/github/jwt-service.ts index 4508521fe..6730c26c7 100644 --- a/packages/integrations/src/github/jwt-service.ts +++ b/packages/integrations/src/github/jwt-service.ts @@ -1,6 +1,6 @@ import { createPrivateKey } from "node:crypto" -import { FetchHttpClient, HttpClient, HttpClientRequest } from "@effect/platform" -import { Config, Effect, Redacted, Schema } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { ServiceMap, Config, Effect, Layer, Redacted, Schema } from "effect" import { SignJWT } from "jose" // ============================================================================ @@ -10,7 +10,7 @@ import { SignJWT } from "jose" /** * Error when JWT generation fails. */ -export class GitHubAppJWTError extends Schema.TaggedError()("GitHubAppJWTError", { +export class GitHubAppJWTError extends Schema.TaggedErrorClass()("GitHubAppJWTError", { message: Schema.String, cause: Schema.optional(Schema.Unknown), }) {} @@ -18,7 +18,7 @@ export class GitHubAppJWTError extends Schema.TaggedError()(" /** * Error when installation token generation fails. */ -export class GitHubInstallationTokenError extends Schema.TaggedError()( +export class GitHubInstallationTokenError extends Schema.TaggedErrorClass()( "GitHubInstallationTokenError", { installationId: Schema.String, @@ -61,7 +61,7 @@ const InstallationTokenApiResponse = Schema.Struct({ // GitHub API error response schema const GitHubErrorApiResponse = Schema.Struct({ - message: Schema.optionalWith(Schema.String, { default: () => "Unknown error" }), + message: Schema.String.pipe(Schema.withDecodingDefaultKey(() => "Unknown error")), }) // ============================================================================ @@ -157,9 +157,8 @@ const GITHUB_API_BASE_URL = "https://api.github.com" * // token.expiresAt is when it expires (1 hour from now) * ``` */ -export class GitHubAppJWTService extends Effect.Service()("GitHubAppJWTService", { - accessors: true, - effect: Effect.gen(function* () { +export class GitHubAppJWTService extends ServiceMap.Service()("GitHubAppJWTService", { + make: Effect.gen(function* () { // Load config once at service initialization // Use orDie since missing config is a fatal startup error const config = yield* loadGitHubAppConfig.pipe(Effect.orDie) @@ -209,8 +208,8 @@ export class GitHubAppJWTService extends Effect.Service()(" // Handle error status codes if (response.status >= 400) { const errorBody = yield* response.json.pipe( - Effect.flatMap(Schema.decodeUnknown(GitHubErrorApiResponse)), - Effect.catchAll((error) => + Effect.flatMap(Schema.decodeUnknownEffect(GitHubErrorApiResponse)), + Effect.catch((error) => Effect.logDebug(`Failed to parse GitHub error response: ${String(error)}`).pipe( Effect.as({ message: "Unknown error" }), ), @@ -227,7 +226,7 @@ export class GitHubAppJWTService extends Effect.Service()(" // Parse successful response const data = yield* response.json.pipe( - Effect.flatMap(Schema.decodeUnknown(InstallationTokenApiResponse)), + Effect.flatMap(Schema.decodeUnknownEffect(InstallationTokenApiResponse)), Effect.mapError( (error) => new GitHubInstallationTokenError({ @@ -243,21 +242,14 @@ export class GitHubAppJWTService extends Effect.Service()(" expiresAt: new Date(data.expires_at), } satisfies InstallationToken }).pipe( - Effect.catchTag("RequestError", (error) => + Effect.catchTag("HttpClientError", (error) => Effect.fail( new GitHubInstallationTokenError({ installationId, - message: `Network error: ${String(error)}`, - cause: error, - }), - ), - ), - Effect.catchTag("ResponseError", (error) => - Effect.fail( - new GitHubInstallationTokenError({ - installationId, - message: `Response error: ${String(error)}`, - status: error.response.status, + message: error.response + ? `Response error: ${String(error)}` + : `Network error: ${String(error)}`, + status: error.response?.status, cause: error, }), ), @@ -288,5 +280,6 @@ export class GitHubAppJWTService extends Effect.Service()(" getInstallationToken, } }), - dependencies: [FetchHttpClient.layer], -}) {} +}) { + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(FetchHttpClient.layer)) +} diff --git a/packages/integrations/src/github/payloads.ts b/packages/integrations/src/github/payloads.ts index cfa65f150..11fb36bc8 100644 --- a/packages/integrations/src/github/payloads.ts +++ b/packages/integrations/src/github/payloads.ts @@ -207,7 +207,7 @@ export type GitHubWebhookPayload = /** * GitHub event types supported by the integration. */ -export const GitHubEventType = Schema.Literal( +export const GitHubEventType = Schema.Literals([ "push", "pull_request", "issues", @@ -216,7 +216,7 @@ export const GitHubEventType = Schema.Literal( "workflow_run", "star", "milestone", -) +]) export type GitHubEventType = Schema.Schema.Type export const GitHubEventTypes = Schema.Array(GitHubEventType) diff --git a/packages/integrations/src/linear/api-client.ts b/packages/integrations/src/linear/api-client.ts index 077941dd2..25e575673 100644 --- a/packages/integrations/src/linear/api-client.ts +++ b/packages/integrations/src/linear/api-client.ts @@ -5,8 +5,8 @@ * retries, and proper error handling. */ -import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest } from "@effect/platform" -import { Duration, Effect, Layer, Option, Schedule, Schema } from "effect" +import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { ServiceMap, Duration, Effect, Layer, Option, Schedule, Schema } from "effect" // ============================================================================ // Configuration @@ -87,25 +87,28 @@ export type LinearAccountInfo = typeof LinearAccountInfo.Type // Error Types // ============================================================================ -export class LinearApiError extends Schema.TaggedError()("LinearApiError", { +export class LinearApiError extends Schema.TaggedErrorClass()("LinearApiError", { message: Schema.String, status: Schema.optional(Schema.Number), cause: Schema.optional(Schema.Unknown), }) {} -export class LinearRateLimitError extends Schema.TaggedError()("LinearRateLimitError", { - message: Schema.String, - retryAfter: Schema.optional(Schema.Number), -}) {} +export class LinearRateLimitError extends Schema.TaggedErrorClass()( + "LinearRateLimitError", + { + message: Schema.String, + retryAfter: Schema.optional(Schema.Number), + }, +) {} -export class LinearIssueNotFoundError extends Schema.TaggedError()( +export class LinearIssueNotFoundError extends Schema.TaggedErrorClass()( "LinearIssueNotFoundError", { issueId: Schema.String, }, ) {} -export class LinearTeamNotFoundError extends Schema.TaggedError()( +export class LinearTeamNotFoundError extends Schema.TaggedErrorClass()( "LinearTeamNotFoundError", { message: Schema.String, @@ -235,7 +238,7 @@ const GraphQLError = Schema.Struct({ }) const GetDefaultTeamResponse = Schema.Struct({ - data: Schema.optionalWith( + data: Schema.OptionFromOptional( Schema.Struct({ teams: Schema.Struct({ nodes: Schema.Array( @@ -246,36 +249,32 @@ const GetDefaultTeamResponse = Schema.Struct({ ), }), }), - { as: "Option" }, ), - errors: Schema.optionalWith(Schema.Array(GraphQLError), { as: "Option" }), + errors: Schema.OptionFromOptional(Schema.Array(GraphQLError)), }) const CreateIssueResponse = Schema.Struct({ - data: Schema.optionalWith( + data: Schema.OptionFromOptional( Schema.Struct({ issueCreate: Schema.Struct({ success: Schema.Boolean, - issue: Schema.optionalWith( + issue: Schema.OptionFromOptional( Schema.Struct({ id: Schema.String, identifier: Schema.String, title: Schema.String, url: Schema.String, - team: Schema.optionalWith( + team: Schema.OptionFromOptional( Schema.Struct({ name: Schema.String, }), - { as: "Option" }, ), }), - { as: "Option" }, ), }), }), - { as: "Option" }, ), - errors: Schema.optionalWith(Schema.Array(GraphQLError), { as: "Option" }), + errors: Schema.OptionFromOptional(Schema.Array(GraphQLError)), }) const GetIssueResponse = Schema.Struct({ @@ -322,13 +321,13 @@ const GetIssueResponse = Schema.Struct({ }), ), }), - null, + { onNoneEncoding: null }, ), - errors: Schema.OptionFromNullishOr(Schema.Array(GraphQLError), null), + errors: Schema.OptionFromNullishOr(Schema.Array(GraphQLError), { onNoneEncoding: null }), }) const ViewerResponse = Schema.Struct({ - data: Schema.optionalWith( + data: Schema.OptionFromOptional( Schema.Struct({ viewer: Schema.Struct({ id: Schema.String, @@ -342,9 +341,8 @@ const ViewerResponse = Schema.Struct({ ), }), }), - { as: "Option" }, ), - errors: Schema.optionalWith(Schema.Array(GraphQLError), { as: "Option" }), + errors: Schema.OptionFromOptional(Schema.Array(GraphQLError)), }) // ============================================================================ @@ -380,7 +378,7 @@ const parseLinearErrorMessage = (errorMessage: string): string => { * Retry schedule for transient Linear API errors. * Retries up to 3 times with exponential backoff (100ms, 200ms, 400ms) */ -const makeRetrySchedule = Schedule.exponential("100 millis").pipe(Schedule.intersect(Schedule.recurs(3))) +const makeRetrySchedule = Schedule.exponential("100 millis").pipe(Schedule.both(Schedule.recurs(3))) /** * Check if an error is retryable (rate limit or server error) @@ -418,9 +416,8 @@ const isRetryableError = ( * const issue = yield* client.fetchIssue("ENG-123", accessToken) * ``` */ -export class LinearApiClient extends Effect.Service()("LinearApiClient", { - accessors: true, - effect: Effect.gen(function* () { +export class LinearApiClient extends ServiceMap.Service()("LinearApiClient", { + make: Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient /** @@ -485,22 +482,16 @@ export class LinearApiClient extends Effect.Service()("LinearAp return yield* response.json as Effect.Effect }).pipe( // Map HTTP client errors to LinearApiError - Effect.catchTag("TimeoutException", () => + Effect.catchTag("TimeoutError", () => Effect.fail(new LinearApiError({ message: "Request timed out" })), ), - Effect.catchTag("RequestError", (error) => - Effect.fail( - new LinearApiError({ - message: `Network error: ${String(error)}`, - cause: error, - }), - ), - ), - Effect.catchTag("ResponseError", (error) => + Effect.catchTag("HttpClientError", (error) => Effect.fail( new LinearApiError({ - message: `Response error: ${String(error)}`, - status: error.response.status, + message: error.response + ? `Response error: ${String(error)}` + : `Network error: ${String(error)}`, + status: error.response?.status, cause: error, }), ), @@ -515,7 +506,7 @@ export class LinearApiClient extends Effect.Service()("LinearAp const client = makeAuthenticatedClient(accessToken) const rawResponse = yield* executeGraphQL(client, GET_DEFAULT_TEAM_QUERY) - const response = yield* Schema.decodeUnknown(GetDefaultTeamResponse)(rawResponse).pipe( + const response = yield* Schema.decodeUnknownEffect(GetDefaultTeamResponse)(rawResponse).pipe( Effect.mapError( (parseError) => new LinearApiError({ @@ -572,7 +563,7 @@ export class LinearApiClient extends Effect.Service()("LinearAp description: params.description || null, }) - const response = yield* Schema.decodeUnknown(CreateIssueResponse)(rawResponse).pipe( + const response = yield* Schema.decodeUnknownEffect(CreateIssueResponse)(rawResponse).pipe( Effect.mapError( (parseError) => new LinearApiError({ @@ -627,7 +618,7 @@ export class LinearApiClient extends Effect.Service()("LinearAp Effect.annotateLogs("rawResponse", JSON.stringify(rawResponse, null, 2)), ) - const response = yield* Schema.decodeUnknown(GetIssueResponse)(rawResponse).pipe( + const response = yield* Schema.decodeUnknownEffect(GetIssueResponse)(rawResponse).pipe( Effect.tapError((parseError) => Effect.logError("Linear API parse error").pipe( Effect.annotateLogs("parseError", String(parseError)), @@ -697,7 +688,7 @@ export class LinearApiClient extends Effect.Service()("LinearAp const client = makeAuthenticatedClient(accessToken) const rawResponse = yield* executeGraphQL(client, VIEWER_QUERY) - const response = yield* Schema.decodeUnknown(ViewerResponse)(rawResponse).pipe( + const response = yield* Schema.decodeUnknownEffect(ViewerResponse)(rawResponse).pipe( Effect.mapError( (parseError) => new LinearApiError({ @@ -743,5 +734,6 @@ export class LinearApiClient extends Effect.Service()("LinearAp getAccountInfo, } }), - dependencies: [FetchHttpClient.layer], -}) {} +}) { + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(FetchHttpClient.layer)) +} diff --git a/packages/rivet-effect/package.json b/packages/rivet-effect/package.json index 02cfcdab4..6a97682c5 100644 --- a/packages/rivet-effect/package.json +++ b/packages/rivet-effect/package.json @@ -15,8 +15,7 @@ "rivetkit": "2.1.6" }, "devDependencies": { - "@effect/language-service": "catalog:effect", - "@effect/vitest": "^0.27.0", + "@effect/vitest": "catalog:effect", "@types/bun": "1.3.9", "typescript": "^5.9.3" } diff --git a/packages/rivet-effect/src/action.ts b/packages/rivet-effect/src/action.ts index ef6fd025e..e0b0f5348 100644 --- a/packages/rivet-effect/src/action.ts +++ b/packages/rivet-effect/src/action.ts @@ -1,6 +1,5 @@ import { Effect } from "effect" import type { ActorContext, ActionContext } from "rivetkit" -import type { YieldWrap } from "effect/Utils" import { provideActorContext } from "./actor.ts" import { runPromise } from "./runtime.ts" @@ -47,11 +46,11 @@ export function effect< genFn: ( c: ActorContext, ...args: Args - ) => Generator>, AEff, never>, + ) => Generator, ): (c: ActionContext, ...args: Args) => AEff { return ((c, ...args) => { const gen = genFn(c, ...args) - const eff = Effect.gen>, AEff>(() => gen) + const eff = Effect.gen(() => gen) const withContext = provideActorContext(eff, c) return runPromise(withContext, c) }) as (c: ActionContext, ...args: Args) => AEff diff --git a/packages/rivet-effect/src/actor.ts b/packages/rivet-effect/src/actor.ts index 1f1feb3e5..acdbc20d5 100644 --- a/packages/rivet-effect/src/actor.ts +++ b/packages/rivet-effect/src/actor.ts @@ -1,29 +1,27 @@ -import { Cause, Context, Effect, Exit } from "effect" +import { Cause, Effect, Exit, ServiceMap } from "effect" import type { ActorContext } from "rivetkit" -import type { YieldWrap } from "effect/Utils" import { StatePersistenceError } from "./errors.ts" import { runPromise, runPromiseExit } from "./runtime.ts" type AnyActorContext = ActorContext /** - * Context.Tag for injecting Rivet's ActorContext into Effect pipelines. + * ServiceMap.Service for injecting Rivet's ActorContext into Effect pipelines. * - * Uses Context.Tag (not Effect.Service) because the actor context is an + * Uses ServiceMap.Service (not Effect.Service) because the actor context is an * externally-provided runtime resource injected by the Rivet framework, * not a service we construct via layers. */ -export class RivetActorContext extends Context.Tag("@hazel/rivet-effect/RivetActorContext")< - RivetActorContext, - AnyActorContext ->() {} +export class RivetActorContext extends ServiceMap.Service()( + "@hazel/rivet-effect/RivetActorContext", +) {} export const provideActorContext = ( - effect: Effect.Effect, + make: Effect.Effect, context: unknown, ): Effect.Effect> => Effect.provideService( - effect as Effect.Effect, + make as Effect.Effect, RivetActorContext, context as AnyActorContext, ) as Effect.Effect> @@ -152,10 +150,10 @@ const runEffectOnActorContext = (c: unknown, effect: Effect.Effect( c: ActorContext, - effect: Effect.Effect, + make: Effect.Effect, ): Effect.Effect => Effect.sync(() => { - const promise = runPromiseExit(effect, c).then((exit) => { + const promise = runPromiseExit(make, c).then((exit) => { if (Exit.isFailure(exit)) { c.log.error({ msg: "waitUntil effect failed", @@ -182,11 +180,11 @@ export const destroy = ( export function effect( genFn: ( c: ActorContext, - ) => Generator>, AEff, never>, + ) => Generator, ): (c: ActorContext) => Promise { return (c) => { const gen = genFn(c) - const eff = Effect.gen>, AEff>(() => gen) + const eff = Effect.gen(() => gen) return runEffectOnActorContext(c, eff) } } diff --git a/packages/rivet-effect/src/errors.ts b/packages/rivet-effect/src/errors.ts index b7b150868..442b62ead 100644 --- a/packages/rivet-effect/src/errors.ts +++ b/packages/rivet-effect/src/errors.ts @@ -1,6 +1,6 @@ import { Cause, Schema } from "effect" -export class RuntimeExecutionError extends Schema.TaggedError()( +export class RuntimeExecutionError extends Schema.TaggedErrorClass()( "RuntimeExecutionError", { message: Schema.String, @@ -16,7 +16,7 @@ export const makeRuntimeExecutionError = (operation: string, cause: Cause.Cause< cause: Cause.pretty(cause), }) -export class StatePersistenceError extends Schema.TaggedError()( +export class StatePersistenceError extends Schema.TaggedErrorClass()( "StatePersistenceError", { message: Schema.String, diff --git a/packages/rivet-effect/src/lifecycle.ts b/packages/rivet-effect/src/lifecycle.ts index 4f14d6ff7..7aed9780f 100644 --- a/packages/rivet-effect/src/lifecycle.ts +++ b/packages/rivet-effect/src/lifecycle.ts @@ -16,21 +16,20 @@ import type { WakeContext, WebSocketContext, } from "rivetkit" -import type { YieldWrap } from "effect/Utils" import { provideActorContext } from "./actor.ts" import { runPromise, runPromiseExit } from "./runtime.ts" -const runWithContext = (context: unknown, effect: Effect.Effect): Promise => - runPromise(provideActorContext(effect, context), context) +const runWithContext = (context: unknown, eff: Effect.Effect): Promise => + runPromise(provideActorContext(eff, context), context) const runWithContextExit = ( context: unknown, - effect: Effect.Effect, -): Promise> => runPromiseExit(provideActorContext(effect, context), context) + eff: Effect.Effect, +): Promise> => runPromiseExit(provideActorContext(eff, context), context) const runGeneratorWithContext = ( context: unknown, - gen: Generator>, A, never>, + gen: Generator, ): Promise => runWithContext( context, @@ -38,7 +37,7 @@ const runGeneratorWithContext = ( ) const makeAsyncLifecycle = ( - genFn: (context: C, ...args: Args) => Generator>, AEff, never>, + genFn: (context: C, ...args: Args) => Generator, ) => { return (context: C, ...args: Args): Promise => runGeneratorWithContext(context, genFn(context, ...args)) @@ -49,7 +48,7 @@ export namespace OnCreate { genFn: ( c: CreateContext, input: TInput, - ) => Generator>, AEff, never>, + ) => Generator, ): ((c: CreateContext, input: TInput) => Promise) => makeAsyncLifecycle(genFn) } @@ -58,7 +57,7 @@ export namespace OnWake { export const effect = ( genFn: ( c: WakeContext, - ) => Generator>, AEff, never>, + ) => Generator, ): ((c: WakeContext) => Promise) => makeAsyncLifecycle(genFn) } @@ -67,7 +66,7 @@ export namespace OnDestroy { export const effect = ( genFn: ( c: DestroyContext, - ) => Generator>, AEff, never>, + ) => Generator, ): ((c: DestroyContext) => Promise) => makeAsyncLifecycle(genFn) } @@ -76,7 +75,7 @@ export namespace OnSleep { export const effect = ( genFn: ( c: SleepContext, - ) => Generator>, AEff, never>, + ) => Generator, ): ((c: SleepContext) => Promise) => makeAsyncLifecycle(genFn) } @@ -86,7 +85,7 @@ export namespace OnStateChange { genFn: ( c: StateChangeContext, newState: TState, - ) => Generator>, void, never>, + ) => Generator, ): ( c: StateChangeContext, newState: TState, @@ -112,7 +111,7 @@ export namespace OnBeforeConnect { genFn: ( c: BeforeConnectContext, params: TConnParams, - ) => Generator>, AEff, never>, + ) => Generator, ): ((c: BeforeConnectContext, params: TConnParams) => Promise) => makeAsyncLifecycle(genFn) } @@ -122,7 +121,7 @@ export namespace OnConnect { genFn: ( c: ConnectContext, conn: Conn, - ) => Generator>, AEff, never>, + ) => Generator, ): (( c: ConnectContext, conn: Conn, @@ -134,7 +133,7 @@ export namespace OnDisconnect { genFn: ( c: DisconnectContext, conn: Conn, - ) => Generator>, AEff, never>, + ) => Generator, ): (( c: DisconnectContext, conn: Conn, @@ -146,7 +145,7 @@ export namespace CreateConnState { genFn: ( c: CreateConnStateContext, params: TConnParams, - ) => Generator>, TConnState, never>, + ) => Generator, ): (( c: CreateConnStateContext, params: TConnParams, @@ -160,7 +159,7 @@ export namespace OnBeforeActionResponse { name: string, args: unknown[], output: Out, - ) => Generator>, Out, never>, + ) => Generator, ): (( c: BeforeActionResponseContext, name: string, @@ -174,7 +173,7 @@ export namespace CreateState { genFn: ( c: CreateContext, input: TInput, - ) => Generator>, TState, never>, + ) => Generator, ): ((c: CreateContext, input: TInput) => Promise) => makeAsyncLifecycle(genFn) } @@ -184,7 +183,7 @@ export namespace CreateVars { genFn: ( c: CreateVarsContext, driverCtx: unknown, - ) => Generator>, TVars, never>, + ) => Generator, ): ((c: CreateVarsContext, driverCtx: unknown) => Promise) => makeAsyncLifecycle(genFn) } @@ -194,7 +193,7 @@ export namespace OnRequest { genFn: ( c: RequestContext, request: Request, - ) => Generator>, Response, never>, + ) => Generator, ): (( c: RequestContext, request: Request, @@ -206,7 +205,7 @@ export namespace OnWebSocket { genFn: ( c: WebSocketContext, websocket: UniversalWebSocket, - ) => Generator>, AEff, never>, + ) => Generator, ): (( c: WebSocketContext, websocket: UniversalWebSocket, diff --git a/packages/rivet-effect/src/runtime.test.ts b/packages/rivet-effect/src/runtime.test.ts index f5e1c3b24..945ec052f 100644 --- a/packages/rivet-effect/src/runtime.test.ts +++ b/packages/rivet-effect/src/runtime.test.ts @@ -1,4 +1,4 @@ -import { Cause, Effect, Exit, Option } from "effect" +import { Cause, Effect, Exit, Result } from "effect" import { describe, expect, it, vi } from "@effect/vitest" import type { AnyManagedRuntime } from "./runtime.ts" import { runPromise, runPromiseExit, setManagedRuntime } from "./runtime.ts" @@ -22,11 +22,11 @@ describe("@hazel/rivet-effect runtime", () => { expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { - const defect = Cause.dieOption(exit.cause) - expect(Option.isSome(defect)).toBe(true) - if (Option.isSome(defect)) { - expect((defect.value as any)?._tag).toBe("RuntimeExecutionError") - expect((defect.value as any)?.operation).toBe("runPromiseExit") + const defect = Cause.findDefect(exit.cause) + expect(Result.isSuccess(defect)).toBe(true) + if (Result.isSuccess(defect)) { + expect((defect.success as any)?._tag).toBe("RuntimeExecutionError") + expect((defect.success as any)?.operation).toBe("runPromiseExit") } } }) diff --git a/packages/rivet-effect/src/runtime.ts b/packages/rivet-effect/src/runtime.ts index 83c33b647..86a431493 100644 --- a/packages/rivet-effect/src/runtime.ts +++ b/packages/rivet-effect/src/runtime.ts @@ -1,4 +1,4 @@ -import { Cause, Effect, Exit, Layer, ManagedRuntime, Option } from "effect" +import { Cause, Effect, Exit, Layer, ManagedRuntime, Option, Result } from "effect" import { RuntimeExecutionError } from "./errors.ts" export type AnyManagedRuntime = ManagedRuntime.ManagedRuntime @@ -51,7 +51,7 @@ export const getManagedRuntime = (context: unknown): AnyManagedRuntime | undefin * Only works when `R = never` (no unsatisfied requirements). */ const runWithCurrentRuntime = (effect: Effect.Effect): Promise => - DefaultRuntime.runPromise(effect).catch((error) => + Effect.runPromise(effect).catch((error: any) => Promise.reject( new RuntimeExecutionError({ message: "Failed to execute effect with current runtime", @@ -77,14 +77,14 @@ const runExitWithCurrentRuntime = (effect: Effect.Effect): Pr ) const throwFromCause = (cause: Cause.Cause): never => { - const failure = Cause.failureOption(cause) + const failure = Cause.findErrorOption(cause) if (Option.isSome(failure)) { throw failure.value } - const defect = Cause.dieOption(cause) - if (Option.isSome(defect)) { - throw defect.value instanceof Error ? defect.value : new Error(String(defect.value)) + const defect = Cause.findDefect(cause) + if (Result.isSuccess(defect)) { + throw defect.success instanceof Error ? defect.success : new Error(String(defect.success)) } throw new Error(Cause.pretty(cause)) @@ -99,13 +99,13 @@ export const runPromise = (effect: Effect.Effect, context?: un }) export const runPromiseExit = ( - effect: Effect.Effect, + make: Effect.Effect, context?: unknown, ): Promise> => { const runtime = getManagedRuntime(context) const execution: Promise> = runtime - ? runtime.runPromiseExit(effect as Effect.Effect) - : runExitWithCurrentRuntime(effect as Effect.Effect) + ? runtime.runPromiseExit(make as Effect.Effect) + : runExitWithCurrentRuntime(make as Effect.Effect) return execution.catch((error) => Exit.die( diff --git a/packages/schema/package.json b/packages/schema/package.json index 9fffeb834..3b99943ae 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -8,11 +8,9 @@ ".": "./src/index.ts" }, "dependencies": { - "@effect/platform": "catalog:effect", "effect": "catalog:effect" }, "devDependencies": { - "@effect/language-service": "catalog:effect", "@types/bun": "1.3.9", "typescript": "^5.9.3" } diff --git a/packages/schema/src/avatar-url.ts b/packages/schema/src/avatar-url.ts index 71fcc479e..0568ca577 100644 --- a/packages/schema/src/avatar-url.ts +++ b/packages/schema/src/avatar-url.ts @@ -1,7 +1,7 @@ -import { HttpClient } from "@effect/platform" -import { Duration, Effect, Option, Schema } from "effect" +import { HttpClient } from "effect/unstable/http" +import { Duration, Effect, Option, Schema, SchemaGetter } from "effect" -export class InvalidAvatarUrlError extends Schema.TaggedError()( +export class InvalidAvatarUrlError extends Schema.TaggedErrorClass()( "InvalidAvatarUrlError", { message: Schema.String, @@ -19,67 +19,69 @@ export const validateImageUrl = Effect.fn("validateImageUrl")(function* (url: st .head(url) .pipe(Effect.scoped, Effect.timeout(Duration.seconds(5))) .pipe( - Effect.catchTag( - "TimeoutException", - () => + Effect.catchTag("TimeoutError", () => + Effect.fail( new InvalidAvatarUrlError({ message: "Avatar URL took too long to respond", url, }), + ), ), - Effect.catchTag( - "RequestError", - () => + Effect.catchTag("HttpClientError", (e) => + Effect.fail( new InvalidAvatarUrlError({ - message: "Avatar URL could not be reached", - url, - }), - ), - Effect.catchTag( - "ResponseError", - (e) => - new InvalidAvatarUrlError({ - message: `Avatar URL returned ${e.response.status} error`, + message: `Avatar URL request failed: ${e.message}`, url, }), + ), ), ) if (response.status >= 400) { - return yield* new InvalidAvatarUrlError({ - message: `Avatar URL returned ${response.status} error`, - url, - }) + return yield* Effect.fail( + new InvalidAvatarUrlError({ + message: `Avatar URL returned ${response.status} error`, + url, + }), + ) } - const contentType = Option.fromNullable(response.headers["content-type"]) + const contentType = Option.fromNullishOr(response.headers["content-type"]) const isImage = Option.match(contentType, { onNone: () => false, - onSome: (ct) => ct.startsWith("image/"), + onSome: (ct: string) => ct.startsWith("image/"), }) if (!isImage) { - return yield* new InvalidAvatarUrlError({ - message: "Avatar URL must point to an image", - url, - }) + return yield* Effect.fail( + new InvalidAvatarUrlError({ + message: "Avatar URL must point to an image", + url, + }), + ) } }) -export const AvatarUrl = Schema.String.pipe( - Schema.pattern(/^https?:\/\/.+/i, { - message: () => "Avatar URL must be a valid URL", +export const AvatarUrl = Schema.String.check( + Schema.isPattern(/^https?:\/\/.+/i, { + message: "Avatar URL must be a valid URL", }), - Schema.maxLength(2048), - Schema.filterEffect((url) => - validateImageUrl(url).pipe( - Effect.map(() => true), - Effect.catchAll((e) => Effect.succeed(e.message)), - ), - ), -).annotations({ - description: "A validated URL to an avatar image", - title: "Avatar URL", -}) +) + .check(Schema.isMaxLength(2048)) + .pipe( + Schema.decode({ + decode: SchemaGetter.checkEffect((url: string) => + validateImageUrl(url).pipe( + Effect.map(() => true as const), + Effect.catch((e: InvalidAvatarUrlError) => Effect.succeed(e.message)), + ), + ), + encode: SchemaGetter.passthrough(), + }), + ) + .annotate({ + description: "A validated URL to an avatar image", + title: "Avatar URL", + }) export type AvatarUrl = Schema.Schema.Type diff --git a/packages/schema/src/ids.ts b/packages/schema/src/ids.ts index 115f7dd78..a8fcc0b00 100644 --- a/packages/schema/src/ids.ts +++ b/packages/schema/src/ids.ts @@ -1,279 +1,313 @@ import { Schema } from "effect" -export const ChannelId = Schema.UUID.pipe(Schema.brand("@HazelChat/ChannelId")).annotations({ - description: "The ID of the channel where the message is posted", - title: "Channel ID", -}) +export const ChannelId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/ChannelId")) + .annotate({ + description: "The ID of the channel where the message is posted", + title: "Channel ID", + }) export type ChannelId = Schema.Schema.Type -export const ConnectConversationId = Schema.UUID.pipe( - Schema.brand("@HazelChat/ConnectConversationId"), -).annotations({ - description: "The ID of a Hazel Connect conversation", - title: "Connect Conversation ID", -}) +export const ConnectConversationId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/ConnectConversationId")) + .annotate({ + description: "The ID of a Hazel Connect conversation", + title: "Connect Conversation ID", + }) export type ConnectConversationId = Schema.Schema.Type -export const ConnectConversationChannelId = Schema.UUID.pipe( - Schema.brand("@HazelChat/ConnectConversationChannelId"), -).annotations({ - description: "The ID of a Hazel Connect conversation channel mount", - title: "Connect Conversation Channel ID", -}) +export const ConnectConversationChannelId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/ConnectConversationChannelId")) + .annotate({ + description: "The ID of a Hazel Connect conversation channel mount", + title: "Connect Conversation Channel ID", + }) export type ConnectConversationChannelId = Schema.Schema.Type -export const ConnectInviteId = Schema.UUID.pipe(Schema.brand("@HazelChat/ConnectInviteId")).annotations({ - description: "The ID of a Hazel Connect invite", - title: "Connect Invite ID", -}) +export const ConnectInviteId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/ConnectInviteId")) + .annotate({ + description: "The ID of a Hazel Connect invite", + title: "Connect Invite ID", + }) export type ConnectInviteId = Schema.Schema.Type -export const ConnectParticipantId = Schema.UUID.pipe( - Schema.brand("@HazelChat/ConnectParticipantId"), -).annotations({ - description: "The ID of a Hazel Connect participant projection", - title: "Connect Participant ID", -}) +export const ConnectParticipantId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/ConnectParticipantId")) + .annotate({ + description: "The ID of a Hazel Connect participant projection", + title: "Connect Participant ID", + }) export type ConnectParticipantId = Schema.Schema.Type -export const UserId = Schema.UUID.pipe(Schema.brand("@HazelChat/UserId")).annotations({ +export const UserId = Schema.String.check(Schema.isUUID()).pipe(Schema.brand("@HazelChat/UserId")).annotate({ description: "The ID of a user", title: "UserId ID", }) export type UserId = Schema.Schema.Type -export const BotId = Schema.UUID.pipe(Schema.brand("@HazelChat/BotId")).annotations({ +export const BotId = Schema.String.check(Schema.isUUID()).pipe(Schema.brand("@HazelChat/BotId")).annotate({ description: "The ID of a bot", title: "Bot ID", }) export type BotId = Schema.Schema.Type -export const MessageId = Schema.UUID.pipe(Schema.brand("@HazelChat/MessageId")).annotations({ - description: "The ID of the message being replied to", - title: "Reply To Message ID", -}) +export const MessageId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/MessageId")) + .annotate({ + description: "The ID of the message being replied to", + title: "Reply To Message ID", + }) export type MessageId = Schema.Schema.Type -export const MessageReactionId = Schema.UUID.pipe(Schema.brand("@HazelChat/MessageReactionId")).annotations({ - description: "The ID of the message reaction", - title: "Message Reaction ID", -}) +export const MessageReactionId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/MessageReactionId")) + .annotate({ + description: "The ID of the message reaction", + title: "Message Reaction ID", + }) export type MessageReactionId = Schema.Schema.Type -export const MessageAttachmentId = Schema.UUID.pipe( - Schema.brand("@HazelChat/MessageAttachmentId"), -).annotations({ - description: "The ID of the message attachment", - title: "Message Attachment ID", -}) +export const MessageAttachmentId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/MessageAttachmentId")) + .annotate({ + description: "The ID of the message attachment", + title: "Message Attachment ID", + }) export type MessageAttachmentId = Schema.Schema.Type -export const AttachmentId = Schema.UUID.pipe(Schema.brand("@HazelChat/AttachmentId")).annotations({ - description: "The ID of the attachment being replied to", - title: "Attachment ID", -}) +export const AttachmentId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/AttachmentId")) + .annotate({ + description: "The ID of the attachment being replied to", + title: "Attachment ID", + }) export type AttachmentId = Schema.Schema.Type -export const OrganizationId = Schema.UUID.pipe(Schema.brand("@HazelChat/OrganizationId")).annotations({ - description: "The ID of the organization", - title: "Organization ID", -}) +export const OrganizationId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/OrganizationId")) + .annotate({ + description: "The ID of the organization", + title: "Organization ID", + }) export type OrganizationId = Schema.Schema.Type -export const InvitationId = Schema.UUID.pipe(Schema.brand("@HazelChat/InvitationId")).annotations({ - description: "The ID of the invitation", - title: "Invitation ID", -}) +export const InvitationId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/InvitationId")) + .annotate({ + description: "The ID of the invitation", + title: "Invitation ID", + }) export type InvitationId = Schema.Schema.Type -export const PinnedMessageId = Schema.UUID.pipe(Schema.brand("@HazelChat/PinnedMessageId")).annotations({ - description: "The ID of the pinned message", - title: "Pinned Message ID", -}) +export const PinnedMessageId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/PinnedMessageId")) + .annotate({ + description: "The ID of the pinned message", + title: "Pinned Message ID", + }) export type PinnedMessageId = Schema.Schema.Type -export const NotificationId = Schema.UUID.pipe(Schema.brand("@HazelChat/NotificationId")).annotations({ - description: "The ID of the notification", - title: "Notification ID", -}) +export const NotificationId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/NotificationId")) + .annotate({ + description: "The ID of the notification", + title: "Notification ID", + }) export type NotificationId = Schema.Schema.Type -export const ChannelMemberId = Schema.UUID.pipe(Schema.brand("@HazelChat/ChannelMemberId")).annotations({ - description: "The ID of the channel member", - title: "Channel Member ID", -}) +export const ChannelMemberId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/ChannelMemberId")) + .annotate({ + description: "The ID of the channel member", + title: "Channel Member ID", + }) export type ChannelMemberId = Schema.Schema.Type -export const OrganizationMemberId = Schema.UUID.pipe( - Schema.brand("@HazelChat/OrganizationMemberId"), -).annotations({ - description: "The ID of the organization member", - title: "Organization Member ID", -}) +export const OrganizationMemberId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/OrganizationMemberId")) + .annotate({ + description: "The ID of the organization member", + title: "Organization Member ID", + }) export type OrganizationMemberId = Schema.Schema.Type -export const TypingIndicatorId = Schema.UUID.pipe(Schema.brand("@HazelChat/TypingIndicatorId")).annotations({ - description: "The ID of the typing indicator", - title: "Typing Indicator ID", -}) +export const TypingIndicatorId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/TypingIndicatorId")) + .annotate({ + description: "The ID of the typing indicator", + title: "Typing Indicator ID", + }) export type TypingIndicatorId = Schema.Schema.Type -export const UserPresenceStatusId = Schema.UUID.pipe( - Schema.brand("@HazelChat/UserPresenceStatusId"), -).annotations({ - description: "The ID of the user presence status", - title: "User Presence Status ID", -}) +export const UserPresenceStatusId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/UserPresenceStatusId")) + .annotate({ + description: "The ID of the user presence status", + title: "User Presence Status ID", + }) export type UserPresenceStatusId = Schema.Schema.Type -export const IntegrationConnectionId = Schema.UUID.pipe( - Schema.brand("@HazelChat/IntegrationConnectionId"), -).annotations({ - description: "The ID of an integration connection", - title: "Integration Connection ID", -}) +export const IntegrationConnectionId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/IntegrationConnectionId")) + .annotate({ + description: "The ID of an integration connection", + title: "Integration Connection ID", + }) export type IntegrationConnectionId = Schema.Schema.Type -export const SyncConnectionId = Schema.UUID.pipe(Schema.brand("@HazelChat/SyncConnectionId")).annotations({ - description: "The ID of a chat sync connection", - title: "Sync Connection ID", -}) +export const SyncConnectionId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/SyncConnectionId")) + .annotate({ + description: "The ID of a chat sync connection", + title: "Sync Connection ID", + }) export type SyncConnectionId = Schema.Schema.Type -export const ExternalChannelId = Schema.String.pipe(Schema.brand("@HazelChat/ExternalChannelId")).annotations( - { - description: "The external channel identifier from a synced provider", - title: "External Channel ID", - }, -) +export const ExternalChannelId = Schema.String.pipe(Schema.brand("@HazelChat/ExternalChannelId")).annotate({ + description: "The external channel identifier from a synced provider", + title: "External Channel ID", +}) export type ExternalChannelId = Schema.Schema.Type -export const ExternalMessageId = Schema.String.pipe(Schema.brand("@HazelChat/ExternalMessageId")).annotations( - { - description: "The external message identifier from a synced provider", - title: "External Message ID", - }, -) +export const ExternalMessageId = Schema.String.pipe(Schema.brand("@HazelChat/ExternalMessageId")).annotate({ + description: "The external message identifier from a synced provider", + title: "External Message ID", +}) export type ExternalMessageId = Schema.Schema.Type -export const ExternalWebhookId = Schema.String.pipe(Schema.brand("@HazelChat/ExternalWebhookId")).annotations( - { - description: "The external webhook identifier from a synced provider", - title: "External Webhook ID", - }, -) +export const ExternalWebhookId = Schema.String.pipe(Schema.brand("@HazelChat/ExternalWebhookId")).annotate({ + description: "The external webhook identifier from a synced provider", + title: "External Webhook ID", +}) export type ExternalWebhookId = Schema.Schema.Type -export const ExternalUserId = Schema.String.pipe(Schema.brand("@HazelChat/ExternalUserId")).annotations({ +export const ExternalUserId = Schema.String.pipe(Schema.brand("@HazelChat/ExternalUserId")).annotate({ description: "The external user identifier from a synced provider", title: "External User ID", }) export type ExternalUserId = Schema.Schema.Type -export const ExternalThreadId = Schema.String.pipe(Schema.brand("@HazelChat/ExternalThreadId")).annotations({ +export const ExternalThreadId = Schema.String.pipe(Schema.brand("@HazelChat/ExternalThreadId")).annotate({ description: "The external thread identifier from a synced provider", title: "External Thread ID", }) export type ExternalThreadId = Schema.Schema.Type -export const SyncChannelLinkId = Schema.UUID.pipe(Schema.brand("@HazelChat/SyncChannelLinkId")).annotations({ - description: "The ID of a chat sync channel link", - title: "Sync Channel Link ID", -}) +export const SyncChannelLinkId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/SyncChannelLinkId")) + .annotate({ + description: "The ID of a chat sync channel link", + title: "Sync Channel Link ID", + }) export type SyncChannelLinkId = Schema.Schema.Type -export const SyncMessageLinkId = Schema.UUID.pipe(Schema.brand("@HazelChat/SyncMessageLinkId")).annotations({ - description: "The ID of a chat sync message link", - title: "Sync Message Link ID", -}) +export const SyncMessageLinkId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/SyncMessageLinkId")) + .annotate({ + description: "The ID of a chat sync message link", + title: "Sync Message Link ID", + }) export type SyncMessageLinkId = Schema.Schema.Type -export const SyncEventReceiptId = Schema.UUID.pipe(Schema.brand("@HazelChat/SyncEventReceiptId")).annotations( - { +export const SyncEventReceiptId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/SyncEventReceiptId")) + .annotate({ description: "The ID of a chat sync event receipt", title: "Sync Event Receipt ID", - }, -) + }) export type SyncEventReceiptId = Schema.Schema.Type -export const IntegrationTokenId = Schema.UUID.pipe(Schema.brand("@HazelChat/IntegrationTokenId")).annotations( - { +export const IntegrationTokenId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/IntegrationTokenId")) + .annotate({ description: "The ID of an integration token record", title: "Integration Token ID", - }, -) + }) export type IntegrationTokenId = Schema.Schema.Type -export const MessageIntegrationLinkId = Schema.UUID.pipe( - Schema.brand("@HazelChat/MessageIntegrationLinkId"), -).annotations({ - description: "The ID of a message-integration link", - title: "Message Integration Link ID", -}) +export const MessageIntegrationLinkId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/MessageIntegrationLinkId")) + .annotate({ + description: "The ID of a message-integration link", + title: "Message Integration Link ID", + }) export type MessageIntegrationLinkId = Schema.Schema.Type -export const ChannelWebhookId = Schema.UUID.pipe(Schema.brand("@HazelChat/ChannelWebhookId")).annotations({ - description: "The ID of a channel webhook", - title: "Channel Webhook ID", -}) +export const ChannelWebhookId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/ChannelWebhookId")) + .annotate({ + description: "The ID of a channel webhook", + title: "Channel Webhook ID", + }) export type ChannelWebhookId = Schema.Schema.Type -export const ChannelIcon = Schema.String.pipe(Schema.brand("@HazelChat/ChannelIcon")).annotations({ +export const ChannelIcon = Schema.String.pipe(Schema.brand("@HazelChat/ChannelIcon")).annotate({ description: "An emoji icon for a channel", title: "Channel Icon", }) export type ChannelIcon = Schema.Schema.Type -export const GitHubSubscriptionId = Schema.UUID.pipe( - Schema.brand("@HazelChat/GitHubSubscriptionId"), -).annotations({ - description: "The ID of a GitHub subscription", - title: "GitHub Subscription ID", -}) +export const GitHubSubscriptionId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/GitHubSubscriptionId")) + .annotate({ + description: "The ID of a GitHub subscription", + title: "GitHub Subscription ID", + }) export type GitHubSubscriptionId = Schema.Schema.Type -export const BotCommandId = Schema.UUID.pipe(Schema.brand("@HazelChat/BotCommandId")).annotations({ - description: "The ID of a bot command", - title: "Bot Command ID", -}) +export const BotCommandId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/BotCommandId")) + .annotate({ + description: "The ID of a bot command", + title: "Bot Command ID", + }) export type BotCommandId = Schema.Schema.Type -export const BotInstallationId = Schema.UUID.pipe(Schema.brand("@HazelChat/BotInstallationId")).annotations({ - description: "The ID of a bot installation", - title: "Bot Installation ID", -}) +export const BotInstallationId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/BotInstallationId")) + .annotate({ + description: "The ID of a bot installation", + title: "Bot Installation ID", + }) export type BotInstallationId = Schema.Schema.Type -export const ChannelSectionId = Schema.UUID.pipe(Schema.brand("@HazelChat/ChannelSectionId")).annotations({ - description: "The ID of a channel section", - title: "Channel Section ID", -}) +export const ChannelSectionId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/ChannelSectionId")) + .annotate({ + description: "The ID of a channel section", + title: "Channel Section ID", + }) export type ChannelSectionId = Schema.Schema.Type -export const RssSubscriptionId = Schema.UUID.pipe(Schema.brand("@HazelChat/RssSubscriptionId")).annotations({ - description: "The ID of an RSS subscription", - title: "RSS Subscription ID", -}) +export const RssSubscriptionId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/RssSubscriptionId")) + .annotate({ + description: "The ID of an RSS subscription", + title: "RSS Subscription ID", + }) export type RssSubscriptionId = Schema.Schema.Type -export const IntegrationRequestId = Schema.UUID.pipe( - Schema.brand("@HazelChat/IntegrationRequestId"), -).annotations({ - description: "The ID of an integration request", - title: "Integration Request ID", -}) +export const IntegrationRequestId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/IntegrationRequestId")) + .annotate({ + description: "The ID of an integration request", + title: "Integration Request ID", + }) export type IntegrationRequestId = Schema.Schema.Type -export const CustomEmojiId = Schema.UUID.pipe(Schema.brand("@HazelChat/CustomEmojiId")).annotations({ - description: "The ID of a custom emoji", - title: "Custom Emoji ID", -}) +export const CustomEmojiId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/CustomEmojiId")) + .annotate({ + description: "The ID of a custom emoji", + title: "Custom Emoji ID", + }) export type CustomEmojiId = Schema.Schema.Type -export const MessageOutboxEventId = Schema.UUID.pipe( - Schema.brand("@HazelChat/MessageOutboxEventId"), -).annotations({ - description: "The ID of a message outbox event", - title: "Message Outbox Event ID", -}) +export const MessageOutboxEventId = Schema.String.check(Schema.isUUID()) + .pipe(Schema.brand("@HazelChat/MessageOutboxEventId")) + .annotate({ + description: "The ID of a message outbox event", + title: "Message Outbox Event ID", + }) export type MessageOutboxEventId = Schema.Schema.Type diff --git a/packages/schema/src/workos.ts b/packages/schema/src/workos.ts index bae246fe1..5e597f708 100644 --- a/packages/schema/src/workos.ts +++ b/packages/schema/src/workos.ts @@ -1,46 +1,46 @@ import { Schema } from "effect" -export const WorkOSUserId = Schema.NonEmptyTrimmedString.pipe( - Schema.brand("@HazelChat/WorkOSUserId"), -).annotations({ - description: "A WorkOS user identifier", - title: "WorkOS User ID", -}) +export const WorkOSUserId = Schema.Trimmed.check(Schema.isNonEmpty()) + .pipe(Schema.brand("@HazelChat/WorkOSUserId")) + .annotate({ + description: "A WorkOS user identifier", + title: "WorkOS User ID", + }) export type WorkOSUserId = Schema.Schema.Type -export const WorkOSOrganizationId = Schema.NonEmptyTrimmedString.pipe( - Schema.brand("@HazelChat/WorkOSOrganizationId"), -).annotations({ - description: "A WorkOS organization identifier", - title: "WorkOS Organization ID", -}) +export const WorkOSOrganizationId = Schema.Trimmed.check(Schema.isNonEmpty()) + .pipe(Schema.brand("@HazelChat/WorkOSOrganizationId")) + .annotate({ + description: "A WorkOS organization identifier", + title: "WorkOS Organization ID", + }) export type WorkOSOrganizationId = Schema.Schema.Type -export const WorkOSSessionId = Schema.NonEmptyTrimmedString.pipe( - Schema.brand("@HazelChat/WorkOSSessionId"), -).annotations({ - description: "A WorkOS session identifier", - title: "WorkOS Session ID", -}) +export const WorkOSSessionId = Schema.Trimmed.check(Schema.isNonEmpty()) + .pipe(Schema.brand("@HazelChat/WorkOSSessionId")) + .annotate({ + description: "A WorkOS session identifier", + title: "WorkOS Session ID", + }) export type WorkOSSessionId = Schema.Schema.Type -export const WorkOSInvitationId = Schema.NonEmptyTrimmedString.pipe( - Schema.brand("@HazelChat/WorkOSInvitationId"), -).annotations({ - description: "A WorkOS invitation identifier", - title: "WorkOS Invitation ID", -}) +export const WorkOSInvitationId = Schema.Trimmed.check(Schema.isNonEmpty()) + .pipe(Schema.brand("@HazelChat/WorkOSInvitationId")) + .annotate({ + description: "A WorkOS invitation identifier", + title: "WorkOS Invitation ID", + }) export type WorkOSInvitationId = Schema.Schema.Type -export const WorkOSClientId = Schema.NonEmptyTrimmedString.pipe( - Schema.brand("@HazelChat/WorkOSClientId"), -).annotations({ - description: "A WorkOS client identifier", - title: "WorkOS Client ID", -}) +export const WorkOSClientId = Schema.Trimmed.check(Schema.isNonEmpty()) + .pipe(Schema.brand("@HazelChat/WorkOSClientId")) + .annotate({ + description: "A WorkOS client identifier", + title: "WorkOS Client ID", + }) export type WorkOSClientId = Schema.Schema.Type -export const WorkOSRole = Schema.Literal("admin", "member", "owner") +export const WorkOSRole = Schema.Literals(["admin", "member", "owner"]) export type WorkOSRole = Schema.Schema.Type export class WorkOSJwtClaims extends Schema.Class("WorkOSJwtClaims")({ diff --git a/packages/setup/package.json b/packages/setup/package.json index 0fd5aa9db..2839f13ee 100644 --- a/packages/setup/package.json +++ b/packages/setup/package.json @@ -11,11 +11,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@effect/cli": "catalog:effect", - "@effect/platform": "catalog:effect", "@effect/platform-bun": "catalog:effect", - "@effect/printer": "catalog:effect", - "@effect/printer-ansi": "catalog:effect", "@hazel/db": "workspace:*", "@hazel/schema": "workspace:*", "@workos-inc/node": "^7.77.0", @@ -23,7 +19,6 @@ "picocolors": "^1.1.1" }, "devDependencies": { - "@effect/language-service": "catalog:effect", "@types/bun": "1.3.9", "typescript": "^5.9.3" } diff --git a/packages/setup/src/commands/bots.ts b/packages/setup/src/commands/bots.ts index 7f8cca87c..627813bee 100644 --- a/packages/setup/src/commands/bots.ts +++ b/packages/setup/src/commands/bots.ts @@ -1,4 +1,4 @@ -import { Command, Options, Prompt } from "@effect/cli" +import { Command, Flag, Prompt } from "effect/unstable/cli" import { Database, schema, isNull } from "@hazel/db" import type { BotId, BotInstallationId, OrganizationId, OrganizationMemberId, UserId } from "@hazel/schema" import { Console, Effect, Option, Redacted } from "effect" @@ -6,11 +6,11 @@ import { randomUUID } from "crypto" import pc from "picocolors" // CLI Options -const nameOption = Options.text("name").pipe(Options.withDescription("Bot name"), Options.optional) +const nameOption = Flag.string("name").pipe(Flag.withDescription("Bot name"), Flag.optional) -const orgOption = Options.text("org").pipe( - Options.withDescription("Organization ID to install bot in"), - Options.optional, +const orgOption = Flag.string("org").pipe( + Flag.withDescription("Organization ID to install bot in"), + Flag.optional, ) /** diff --git a/packages/setup/src/commands/certs.ts b/packages/setup/src/commands/certs.ts index 7f9a47e79..fa5631d8b 100644 --- a/packages/setup/src/commands/certs.ts +++ b/packages/setup/src/commands/certs.ts @@ -1,63 +1,64 @@ -import { Command, Prompt } from "@effect/cli" +import { Command, Prompt } from "effect/unstable/cli" import { Console, Effect } from "effect" import pc from "picocolors" import { CertManager } from "../services/cert-manager.ts" -export const certsCommand = Command.make("certs", {}, () => - Effect.gen(function* () { - yield* Console.log(`\n${pc.bold("HTTPS Certificate Setup")}\n`) +/** Shared certs setup logic, callable from both the certs subcommand and the main setup command. */ +export const certsSetupEffect = Effect.gen(function* () { + yield* Console.log(`\n${pc.bold("HTTPS Certificate Setup")}\n`) - const certs = yield* CertManager + const certs = yield* CertManager - // Check if certs already exist - const exists = yield* certs.certsExist() - if (exists) { - yield* Console.log(pc.green("\u2713") + " Certificates already exist at:") - yield* Console.log(pc.dim(` ${certs.certPath}`)) - yield* Console.log(pc.dim(` ${certs.keyPath}`)) + // Check if certs already exist + const exists = yield* certs.certsExist() + if (exists) { + yield* Console.log(pc.green("\u2713") + " Certificates already exist at:") + yield* Console.log(pc.dim(` ${certs.certPath}`)) + yield* Console.log(pc.dim(` ${certs.keyPath}`)) - const regenerate = yield* Prompt.confirm({ - message: "Regenerate certificates?", - initial: false, - }) - if (!regenerate) return - } + const regenerate = yield* Prompt.confirm({ + message: "Regenerate certificates?", + initial: false, + }) + if (!regenerate) return + } + + // Check mkcert installation + yield* Console.log(pc.cyan("\u2500\u2500\u2500 Checking mkcert \u2500\u2500\u2500")) + const hasMkcert = yield* certs.checkMkcert() - // Check mkcert installation - yield* Console.log(pc.cyan("\u2500\u2500\u2500 Checking mkcert \u2500\u2500\u2500")) - const hasMkcert = yield* certs.checkMkcert() - - if (!hasMkcert) { - yield* Console.log(pc.yellow("mkcert not found.")) - const install = yield* Prompt.confirm({ - message: "Install mkcert via Homebrew?", - initial: true, - }) - if (!install) { - yield* Console.log(pc.dim("Install manually: brew install mkcert")) - return - } - yield* certs.installMkcert() - yield* Console.log(pc.green("\u2713") + " mkcert installed") - } else { - yield* Console.log(pc.green("\u2713") + " mkcert found") + if (!hasMkcert) { + yield* Console.log(pc.yellow("mkcert not found.")) + const install = yield* Prompt.confirm({ + message: "Install mkcert via Homebrew?", + initial: true, + }) + if (!install) { + yield* Console.log(pc.dim("Install manually: brew install mkcert")) + return } + yield* certs.installMkcert() + yield* Console.log(pc.green("\u2713") + " mkcert installed") + } else { + yield* Console.log(pc.green("\u2713") + " mkcert found") + } - // Install CA - yield* Console.log(pc.cyan("\n\u2500\u2500\u2500 Installing Local CA \u2500\u2500\u2500")) - yield* Console.log(pc.dim("This may require your password...")) - yield* certs.installCA() - yield* Console.log(pc.green("\u2713") + " Local CA installed") + // Install CA + yield* Console.log(pc.cyan("\n\u2500\u2500\u2500 Installing Local CA \u2500\u2500\u2500")) + yield* Console.log(pc.dim("This may require your password...")) + yield* certs.installCA() + yield* Console.log(pc.green("\u2713") + " Local CA installed") - // Generate certificates - yield* Console.log(pc.cyan("\n\u2500\u2500\u2500 Generating Certificates \u2500\u2500\u2500")) - yield* certs.generateCerts() - yield* Console.log(pc.green("\u2713") + " Certificates generated") + // Generate certificates + yield* Console.log(pc.cyan("\n\u2500\u2500\u2500 Generating Certificates \u2500\u2500\u2500")) + yield* certs.generateCerts() + yield* Console.log(pc.green("\u2713") + " Certificates generated") - yield* Console.log(pc.green("\n\u2705 HTTPS setup complete!")) - yield* Console.log(pc.bold("\nCertificates at:")) - yield* Console.log(pc.dim(` ${certs.certPath}`)) - yield* Console.log(pc.dim(` ${certs.keyPath}`)) - yield* Console.log(pc.dim("\nRestart dev servers to use HTTPS.")) - }), -) + yield* Console.log(pc.green("\n\u2705 HTTPS setup complete!")) + yield* Console.log(pc.bold("\nCertificates at:")) + yield* Console.log(pc.dim(` ${certs.certPath}`)) + yield* Console.log(pc.dim(` ${certs.keyPath}`)) + yield* Console.log(pc.dim("\nRestart dev servers to use HTTPS.")) +}) + +export const certsCommand = Command.make("certs", {}, () => certsSetupEffect) diff --git a/packages/setup/src/commands/doctor.ts b/packages/setup/src/commands/doctor.ts index bf147e67d..a9f52e876 100644 --- a/packages/setup/src/commands/doctor.ts +++ b/packages/setup/src/commands/doctor.ts @@ -1,4 +1,4 @@ -import { Command } from "@effect/cli" +import { Command } from "effect/unstable/cli" import { Console, Effect } from "effect" import pc from "picocolors" import { Doctor, type CheckResult } from "../services/doctor.ts" diff --git a/packages/setup/src/commands/env.ts b/packages/setup/src/commands/env.ts index 3774c4d8e..93aeadcb8 100644 --- a/packages/setup/src/commands/env.ts +++ b/packages/setup/src/commands/env.ts @@ -1,4 +1,4 @@ -import { Command, Options, Prompt } from "@effect/cli" +import { Command, Flag, Prompt } from "effect/unstable/cli" import { Console, Effect, Redacted } from "effect" import pc from "picocolors" import { SecretGenerator } from "../services/secrets.ts" @@ -8,21 +8,21 @@ import { ENV_TEMPLATES, extractExistingConfig, maskSecret, type Config, type S3C import { promptWithExisting, getExistingValue } from "../prompts.ts" // CLI Options -export const skipValidation = Options.boolean("skip-validation").pipe( - Options.withDescription("Skip credential validation (API calls)"), - Options.withDefault(false), +export const skipValidation = Flag.boolean("skip-validation").pipe( + Flag.withDescription("Skip credential validation (API calls)"), + Flag.withDefault(false), ) -export const force = Options.boolean("force").pipe( - Options.withAlias("f"), - Options.withDescription("Overwrite existing .env files without prompting"), - Options.withDefault(false), +export const force = Flag.boolean("force").pipe( + Flag.withAlias("f"), + Flag.withDescription("Overwrite existing .env files without prompting"), + Flag.withDefault(false), ) -export const dryRun = Options.boolean("dry-run").pipe( - Options.withAlias("n"), - Options.withDescription("Show what would be done without writing files"), - Options.withDefault(false), +export const dryRun = Flag.boolean("dry-run").pipe( + Flag.withAlias("n"), + Flag.withDescription("Show what would be done without writing files"), + Flag.withDefault(false), ) export const envCommand = Command.make( @@ -72,9 +72,9 @@ export const envCommand = Command.make( const validator = yield* CredentialValidator const dbResult = yield* validator .validateDatabase("postgresql://user:password@localhost:5432/app") - .pipe(Effect.either) + .pipe(Effect.result) - if (dbResult._tag === "Left") { + if (dbResult._tag === "Failure") { yield* Console.log( pc.yellow("\u26A0\uFE0F Database not reachable.") + ` Run ${pc.cyan("`docker compose up -d`")} first.`, @@ -127,10 +127,10 @@ export const envCommand = Command.make( const validator = yield* CredentialValidator const result = yield* validator .validateWorkOS(workosApiKey, workosClientId) - .pipe(Effect.either) + .pipe(Effect.result) - if (result._tag === "Left") { - yield* Console.log(pc.red(`\u274C WorkOS validation failed: ${result.left.message}`)) + if (result._tag === "Failure") { + yield* Console.log(pc.red(`\u274C WorkOS validation failed: ${result.failure.message}`)) yield* Console.log(pc.dim("Please check your credentials and try again.")) return } diff --git a/packages/setup/src/commands/setup.ts b/packages/setup/src/commands/setup.ts index f7b767fdc..1a3a4ef79 100644 --- a/packages/setup/src/commands/setup.ts +++ b/packages/setup/src/commands/setup.ts @@ -1,4 +1,4 @@ -import { Command, Options, Prompt } from "@effect/cli" +import { Command, Flag, Prompt } from "effect/unstable/cli" import { Console, Effect, Redacted } from "effect" import pc from "picocolors" import { SecretGenerator } from "../services/secrets.ts" @@ -13,29 +13,29 @@ import { type Config, } from "../templates.ts" import { promptWithExisting, getExistingValue } from "../prompts.ts" -import { certsCommand } from "./certs.ts" +import { certsCommand, certsSetupEffect } from "./certs.ts" // CLI Options -const skipValidation = Options.boolean("skip-validation").pipe( - Options.withDescription("Skip credential validation (API calls)"), - Options.withDefault(false), +const skipValidation = Flag.boolean("skip-validation").pipe( + Flag.withDescription("Skip credential validation (API calls)"), + Flag.withDefault(false), ) -const force = Options.boolean("force").pipe( - Options.withAlias("f"), - Options.withDescription("Overwrite existing .env files without prompting"), - Options.withDefault(false), +const force = Flag.boolean("force").pipe( + Flag.withAlias("f"), + Flag.withDescription("Overwrite existing .env files without prompting"), + Flag.withDefault(false), ) -const dryRun = Options.boolean("dry-run").pipe( - Options.withAlias("n"), - Options.withDescription("Show what would be done without writing files"), - Options.withDefault(false), +const dryRun = Flag.boolean("dry-run").pipe( + Flag.withAlias("n"), + Flag.withDescription("Show what would be done without writing files"), + Flag.withDefault(false), ) -const skipDoctor = Options.boolean("skip-doctor").pipe( - Options.withDescription("Skip environment checks"), - Options.withDefault(false), +const skipDoctor = Flag.boolean("skip-doctor").pipe( + Flag.withDescription("Skip environment checks"), + Flag.withDefault(false), ) export const setupCommand = Command.make( @@ -46,7 +46,7 @@ export const setupCommand = Command.make( yield* Console.log(`\n${pc.bold("\u{1F33F} Hazel Local Development Setup")}\n`) // Run the certs setup - yield* certsCommand.handler({}) + yield* certsSetupEffect // Start Docker Compose after that yield* Console.log(pc.cyan("\u2500\u2500\u2500 Starting Docker Compose \u2500\u2500\u2500")) @@ -70,7 +70,7 @@ export const setupCommand = Command.make( error: undefined, } }).pipe( - Effect.catchAll((error) => + Effect.catch((error) => Effect.succeed({ ok: false, exitCode: null, @@ -187,9 +187,9 @@ export const setupCommand = Command.make( const validator = yield* CredentialValidator const dbResult = yield* validator .validateDatabase("postgresql://user:password@localhost:5432/app") - .pipe(Effect.either) + .pipe(Effect.result) - if (dbResult._tag === "Left") { + if (dbResult._tag === "Failure") { yield* Console.log( pc.yellow("\u26A0\uFE0F Database not reachable.") + ` Run ${pc.cyan("`docker compose up -d`")} first.`, @@ -242,10 +242,10 @@ export const setupCommand = Command.make( const validator = yield* CredentialValidator const result = yield* validator .validateWorkOS(workosApiKey, workosClientId) - .pipe(Effect.either) + .pipe(Effect.result) - if (result._tag === "Left") { - yield* Console.log(pc.red(`\u274C WorkOS validation failed: ${result.left.message}`)) + if (result._tag === "Failure") { + yield* Console.log(pc.red(`\u274C WorkOS validation failed: ${result.failure.message}`)) yield* Console.log(pc.dim("Please check your credentials and try again.")) return } @@ -569,7 +569,7 @@ export const setupCommand = Command.make( return proc.exitCode === 0 }, catch: () => false, - }).pipe(Effect.catchAll(() => Effect.succeed(false))) + }).pipe(Effect.catch(() => Effect.succeed(false))) if (dbPushResult) { yield* Console.log(pc.green("\n\u2713") + " Database schema pushed") diff --git a/packages/setup/src/index.ts b/packages/setup/src/index.ts index 08a24a562..1f8079e3c 100644 --- a/packages/setup/src/index.ts +++ b/packages/setup/src/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun -import { Command } from "@effect/cli" -import { BunContext, BunRuntime } from "@effect/platform-bun" +import { Command } from "effect/unstable/cli" +import { BunServices, BunRuntime } from "@effect/platform-bun" import { Effect, Layer } from "effect" import { existsSync, readFileSync } from "fs" import { resolve } from "path" @@ -41,22 +41,23 @@ const loadDatabaseUrl = () => { loadDatabaseUrl() -// Root command with subcommands -const rootCommand = setupCommand.pipe( - Command.withSubcommands([doctorCommand, envCommand, certsCommand, botsCommand]), +const ServicesLive = Layer.mergeAll( + SecretGenerator.layer, + CredentialValidator.layer, + EnvWriter.layer, + Doctor.layer, + CertManager.layer, ) -const cli = Command.run(rootCommand, { - name: "hazel-setup", - version: "0.0.1", -}) - -const ServicesLive = Layer.mergeAll( - SecretGenerator.Default, - CredentialValidator.Default, - EnvWriter.Default, - Doctor.Default, - CertManager.Default, +// Root command with subcommands, run via v4 CLI pattern +// Note: `any` in R position is due to @hazel/db Database types not yet migrated to v4 +const cli = setupCommand.pipe( + Command.withSubcommands([doctorCommand, envCommand, certsCommand, botsCommand]), + Command.run({ + version: "0.0.1", + }), + Effect.provide(ServicesLive), + Effect.provide(BunServices.layer), ) -cli(process.argv).pipe(Effect.provide(ServicesLive), Effect.provide(BunContext.layer), BunRuntime.runMain) +BunRuntime.runMain(cli as Effect.Effect) diff --git a/packages/setup/src/prompts.ts b/packages/setup/src/prompts.ts index 70269acb8..530496e21 100644 --- a/packages/setup/src/prompts.ts +++ b/packages/setup/src/prompts.ts @@ -1,4 +1,4 @@ -import { Prompt } from "@effect/cli" +import { Prompt } from "effect/unstable/cli" import { Effect, Redacted } from "effect" import type { EnvReadResult } from "./services/env-writer.ts" import { getEnvValues, maskSecret, type EnvValue } from "./templates.ts" diff --git a/packages/setup/src/services/cert-manager.ts b/packages/setup/src/services/cert-manager.ts index b6965be3e..a0dc49cc4 100644 --- a/packages/setup/src/services/cert-manager.ts +++ b/packages/setup/src/services/cert-manager.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect" +import { ServiceMap, Effect, Layer } from "effect" import { resolve } from "node:path" export interface CertPaths { @@ -16,9 +16,8 @@ const findRepoRoot = (): string => { return process.cwd() } -export class CertManager extends Effect.Service()("CertManager", { - accessors: true, - effect: Effect.succeed({ +export class CertManager extends ServiceMap.Service()("CertManager", { + make: Effect.succeed({ get certsDir() { return resolve(findRepoRoot(), "certs") }, @@ -51,7 +50,7 @@ export class CertManager extends Effect.Service()("CertManager", { return (await proc.exited) === 0 }, catch: () => new Error("mkcert check failed"), - }).pipe(Effect.catchAll(() => Effect.succeed(false))), + }).pipe(Effect.catch(() => Effect.succeed(false))), installMkcert: () => Effect.tryPromise({ @@ -106,4 +105,6 @@ export class CertManager extends Effect.Service()("CertManager", { catch: (e) => new Error(`Cert generation failed: ${e}`), }), }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/setup/src/services/doctor.ts b/packages/setup/src/services/doctor.ts index c126d8eaa..f9b068694 100644 --- a/packages/setup/src/services/doctor.ts +++ b/packages/setup/src/services/doctor.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect" +import { ServiceMap, Effect, Layer } from "effect" export interface CheckResult { name: string @@ -29,14 +29,13 @@ const checkContainer = (containerName: string, displayName: string): Effect.Effe }, catch: () => new Error("Check failed"), }).pipe( - Effect.catchAll(() => + Effect.catch(() => Effect.succeed({ name: displayName, status: "fail" as const, message: "Not running" }), ), ) -export class Doctor extends Effect.Service()("Doctor", { - accessors: true, - effect: Effect.succeed({ +export class Doctor extends ServiceMap.Service()("Doctor", { + make: Effect.succeed({ checkBun: (): Effect.Effect => Effect.tryPromise({ try: async () => { @@ -47,7 +46,7 @@ export class Doctor extends Effect.Service()("Doctor", { }, catch: () => new Error("Bun not found"), }).pipe( - Effect.catchAll(() => + Effect.catch(() => Effect.succeed({ name: "Bun", status: "fail" as const, @@ -66,7 +65,7 @@ export class Doctor extends Effect.Service()("Doctor", { }, catch: () => new Error("Docker not running"), }).pipe( - Effect.catchAll(() => + Effect.catch(() => Effect.succeed({ name: "Docker", status: "fail" as const, @@ -97,7 +96,7 @@ export class Doctor extends Effect.Service()("Doctor", { }, catch: () => new Error("Could not check containers"), }).pipe( - Effect.catchAll(() => + Effect.catch(() => Effect.succeed({ name: "Docker Compose", status: "warn" as const, @@ -130,4 +129,6 @@ export class Doctor extends Effect.Service()("Doctor", { return { environment, services } }), }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/setup/src/services/env-writer.ts b/packages/setup/src/services/env-writer.ts index 487ac1560..e13a1cb3e 100644 --- a/packages/setup/src/services/env-writer.ts +++ b/packages/setup/src/services/env-writer.ts @@ -1,4 +1,4 @@ -import { Console, Effect } from "effect" +import { ServiceMap, Console, Effect, Layer } from "effect" import { dirname } from "node:path" import { mkdir } from "node:fs/promises" @@ -46,9 +46,8 @@ const parseEnvContent = (content: string): Record => { return result } -export class EnvWriter extends Effect.Service()("EnvWriter", { - accessors: true, - effect: Effect.succeed({ +export class EnvWriter extends ServiceMap.Service()("EnvWriter", { + make: Effect.succeed({ writeEnvFile: (filePath: string, vars: Record, dryRun: boolean = false) => Effect.gen(function* () { const content = Object.entries(vars) @@ -135,4 +134,6 @@ export class EnvWriter extends Effect.Service()("EnvWriter", { return result }), }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/setup/src/services/secrets.ts b/packages/setup/src/services/secrets.ts index 18f7e58a0..5c7843aab 100644 --- a/packages/setup/src/services/secrets.ts +++ b/packages/setup/src/services/secrets.ts @@ -1,8 +1,7 @@ -import { Effect } from "effect" +import { ServiceMap, Effect, Layer } from "effect" -export class SecretGenerator extends Effect.Service()("SecretGenerator", { - accessors: true, - effect: Effect.succeed({ +export class SecretGenerator extends ServiceMap.Service()("SecretGenerator", { + make: Effect.succeed({ generatePassword: (length: number): string => { const bytes = new Uint8Array(length) crypto.getRandomValues(bytes) @@ -15,4 +14,6 @@ export class SecretGenerator extends Effect.Service()("SecretGe return Buffer.from(bytes).toString("base64") }, }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/setup/src/services/validators.ts b/packages/setup/src/services/validators.ts index 410b78ca3..786ef6399 100644 --- a/packages/setup/src/services/validators.ts +++ b/packages/setup/src/services/validators.ts @@ -1,4 +1,4 @@ -import { Data, Effect } from "effect" +import { ServiceMap, Data, Effect, Layer } from "effect" import { WorkOS } from "@workos-inc/node" import { SQL } from "bun" @@ -7,9 +7,8 @@ export class ValidationError extends Data.TaggedError("ValidationError")<{ message: string }> {} -export class CredentialValidator extends Effect.Service()("CredentialValidator", { - accessors: true, - effect: Effect.succeed({ +export class CredentialValidator extends ServiceMap.Service()("CredentialValidator", { + make: Effect.succeed({ validateWorkOS: (apiKey: string, _clientId: string) => Effect.tryPromise({ try: async () => { @@ -56,4 +55,6 @@ export class CredentialValidator extends Effect.Service()(" }), }), }), -}) {} +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/vitest.config.ts b/vitest.config.ts index b97c771ab..ad069f3a8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,7 +2,16 @@ import { defineConfig } from "vitest/config" export default defineConfig({ test: { - projects: ["packages/*", "apps/*", "libs/*", "!apps/bot-gateway"], + projects: [ + "packages/*", + "apps/backend", + "apps/cluster", + "apps/electric-proxy", + "apps/link-preview-worker", + "apps/web", + "libs/*", + "!apps/bot-gateway", + ], coverage: { reporter: ["text", "json-summary", "json"], reportOnFailure: true,