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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
6 changes: 3 additions & 3 deletions .context/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
```
Expand Down
75 changes: 43 additions & 32 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>()("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>()("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
Expand All @@ -237,7 +240,7 @@ export class MyService extends Context.Tag("MyService")<
/* shape */
}
>() {
static Default = Layer.effect(
static layer = Layer.effect(
this,
Effect.gen(function* () {
/* ... */
Expand All @@ -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>()("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>()("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>()("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>()("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
Expand Down
8 changes: 8 additions & 0 deletions apps/backend/.artifacts/chat-sync/chat-sync-diagnostics.jsonl
Original file line number Diff line number Diff line change
@@ -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"}
8 changes: 1 addition & 7 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand All @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions apps/backend/scripts/create-bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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) => {
Expand Down
18 changes: 11 additions & 7 deletions apps/backend/scripts/rebuild-channel-access.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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 },
)

Expand All @@ -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`,
Expand All @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions apps/backend/scripts/reset-all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down
4 changes: 2 additions & 2 deletions apps/backend/scripts/seed-internal-bots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) => {
Expand Down
14 changes: 7 additions & 7 deletions apps/backend/scripts/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/scripts/test-mock-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions apps/backend/src/http.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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),
Expand Down
Loading
Loading