Shared database package using Drizzle ORM and PostgreSQL with Effect-TS integration.
- Drizzle ORM: Type-safe database queries with PostgreSQL
- Effect-TS Integration: Functional error handling and dependency management
- Automatic Transaction Context: Transaction propagation via Effect Context
- Policy-Based Authorization: Row-level security with policy enforcement
- Schema Validation: Input validation using Effect Schema
The database package provides automatic transaction context propagation using Effect's Context system. This eliminates the need to manually pass transaction clients through repository method calls.
When you create a transaction using Database.transaction(), the transaction client is automatically made available to all repository methods and queries within that transaction via Effect's Context system.
TransactionContext Service
The transaction context is provided through a service:
export interface TransactionService {
readonly execute: TxFn
}
export class TransactionContext extends Effect.Tag("TransactionContext")<
TransactionContext,
TransactionService
>() {}import { Database } from "@hazel/db"
import { Effect } from "effect"
const db = yield * Database.Database
const result =
yield *
db.transaction(
Effect.gen(function* () {
// All repository methods automatically use the transaction context
const user = yield* UserRepo.insert({ name: "Alice" })
const org = yield* OrganizationRepo.insert({ userId: user.id })
// No need to pass transaction client!
const txid = yield* generateTransactionId()
return { user, org, txid }
}),
)// ❌ Old approach - verbose and error-prone
yield *
db.transaction(
Effect.fnUntraced(function* (tx) {
const user = yield* UserRepo.insert({ name: "Alice" }, tx)
const org = yield* OrganizationRepo.insert({ userId: user.id }, tx)
const txid = yield* generateTransactionId(tx)
return { user, org, txid }
}),
)// ✅ New approach - clean and automatic
yield *
db.transaction(
Effect.gen(function* () {
const user = yield* UserRepo.insert({ name: "Alice" })
const org = yield* OrganizationRepo.insert({ userId: user.id })
const txid = yield* generateTransactionId()
return { user, org, txid }
}),
)You can still pass an explicit transaction client to create subtransactions or override the context:
yield *
db.transaction(
Effect.gen(function* () {
const user = yield* UserRepo.insert({ name: "Alice" })
// Get the transaction context
const txContext = yield* Database.TransactionContext
// Create a subtransaction by explicitly passing a different tx
const customTx = createCustomTransaction()
const org = yield* OrganizationRepo.insert(
{ userId: user.id },
customTx, // Explicit override
)
return { user, org }
}),
)Repository methods check for transaction clients in this order:
- Explicit
txparameter (highest priority) - for subtransactions - TransactionContext from Effect Context - automatic propagation
- Regular database client - fallback when no transaction
The transaction context is provided automatically in three places:
1. Database.makeQuery and makeQueryWithSchema
Effect.gen(function* () {
// 1. Check explicit tx parameter
if (tx) return yield* queryFn(tx, input)
// 2. Check TransactionContext from Effect Context
const maybeCtx = yield* Effect.serviceOption(TransactionContext)
if (Option.isSome(maybeCtx)) {
return yield* queryFn(maybeCtx.value.execute, input)
}
// 3. Fall back to regular execute
return yield* queryFn(execute, input)
})2. Database.transaction
const transaction = <T, E, R>(effect: Effect.Effect<T, E, R>) =>
Effect.gen(function* () {
// Transaction is provided to the effect automatically
const withContext = effect.pipe(Effect.provideService(TransactionContext, { execute: txWrapper }))
return yield* withContext
})3. generateTransactionId
Effect.gen(function* () {
// Check explicit tx first, then context, then fail
let txExecutor = tx
if (!txExecutor) {
const maybeCtx = yield* Effect.serviceOption(Database.TransactionContext)
if (Option.isSome(maybeCtx)) {
txExecutor = maybeCtx.value.execute
}
}
if (!txExecutor) {
return yield* Effect.die("Must be called within a transaction")
}
return yield* txExecutor((client) => client.execute(`SELECT pg_current_xact_id()::xid::text as txid`))
})import { Repository, schema } from "@hazel/db"
import { User } from "@hazel/domain/models"
import { Effect } from "effect"
export class UserRepo extends Effect.Service<UserRepo>()("UserRepo", {
accessors: true,
effect: Effect.gen(function* () {
const baseRepo = yield* Repository.makeRepository(
schema.usersTable,
{ insert: User.Insert, update: User.Update },
{
idColumn: "id",
name: "User",
},
)
return baseRepo
}),
dependencies: [DatabaseLive],
}) {}All repositories include these methods:
insert(data, tx?): Insert a new recordinsertVoid(data, tx?): Insert without returningupdate(data, tx?): Update a recordupdateVoid(data, tx?): Update without returningfindById(id, tx?): Find by ID (returnsOption<T>)deleteById(id, tx?): Delete by IDwith(id, fn): Execute function with record or fail
export class ChannelMemberRepo extends Effect.Service<ChannelMemberRepo>()("ChannelMemberRepo", {
accessors: true,
effect: Effect.gen(function* () {
const baseRepo = yield* Repository.makeRepository(/*...*/)
const db = yield* Database.Database
// Custom method with automatic transaction support
const findByChannelAndUser = (channelId: ChannelId, userId: UserId, tx?: TxFn) =>
db.makeQuery(
(execute, data) =>
execute((client) =>
client
.select()
.from(schema.channelMembersTable)
.where(
and(
eq(schema.channelMembersTable.channelId, data.channelId),
eq(schema.channelMembersTable.userId, data.userId),
),
),
),
policyRequire("ChannelMember", "select"),
)({ channelId, userId }, tx)
return {
...baseRepo,
findByChannelAndUser,
}
}),
dependencies: [DatabaseLive],
}) {}The database layer integrates with the authorization system to enforce row-level security policies.
import { policyRequire, policyUse } from "@hazel/effect-lib"
// Policy enforcement in repositories
const insert = (data, tx?) =>
db.makeQueryWithSchema(
schema.insert,
(execute, input) => execute((client) => client.insert(table).values(input)),
policyRequire("User", "create"), // Require policy check
)(data, tx)
// Policy usage in route handlers
yield * UserRepo.insert(data).pipe(policyUse(UserPolicy.canCreate(organizationId)))The database layer provides typed errors:
export class DatabaseError extends Data.TaggedError("DatabaseError")<{
readonly type: "unique_violation" | "foreign_key_violation" | "connection_error"
readonly cause: postgres.PostgresError
}>
// Remap database errors in routes
yield* db.transaction(effect).pipe(
withRemapDbErrors("User", "create")
)Database configuration is provided via Effect Layer:
import { Database } from "@hazel/db"
export const DatabaseLive = Layer.unwrapEffect(
Effect.gen(function* () {
return Database.layer({
url: Redacted.make(process.env.DATABASE_URL),
ssl: true,
})
}),
)✅ Type Safety: Full TypeScript type inference across all queries ✅ Automatic Transactions: No manual transaction threading required ✅ Error Handling: Typed database errors with Effect-TS ✅ Authorization: Built-in policy enforcement ✅ Testability: Easy to mock via Effect's dependency injection ✅ Composability: Combine multiple repository operations safely ✅ Clean Code: Reduced boilerplate in route handlers