Skip to content

Commit e957676

Browse files
cursoragentgeclos
andcommitted
fix: enable atomic transactions in multi-step use cases
Rewrites SqlClientLive to use closure-scoped transaction tracking with a promise-bridge pattern. The inner effect now runs in the parent fiber (via Effect.exit) instead of Effect.runPromiseExit, so all provided services (repositories, cache invalidators, etc.) remain available inside the transaction scope. Key changes: - SqlClientLive tracks activeTx via closure variable shared by query() and transaction() methods on the same instance - transaction() uses promise bridge: Drizzle callback signals tx ready, awaits effect completion, then commits or rolls back - query() checks activeTx at call time, reusing the transaction connection when one is active - Repositories captured at boundary still participate in transactions because query() reads activeTx dynamically, not at capture time Multi-step use cases now properly wrapped in sqlClient.transaction(): - createProjectUseCase: existsByName + existsBySlug + save - updateProjectUseCase: findById + existsByName + save - revokeApiKeyUseCase: findById + save + cache invalidation - changePlan: findActive + revokeBySubscription + saveMany + save Co-authored-by: Gerard <gerard@latitude.so>
1 parent 981e7c9 commit e957676

File tree

5 files changed

+226
-202
lines changed

5 files changed

+226
-202
lines changed

packages/domain/api-keys/src/use-cases/revoke-api-key.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ApiKeyId, NotFoundError, RepositoryError } from "@domain/shared"
1+
import { type ApiKeyId, type NotFoundError, type RepositoryError, SqlClient } from "@domain/shared"
22
import { Data, Effect, ServiceMap } from "effect"
33
import { revoke } from "../entities/api-key.ts"
44
import { ApiKeyRepository } from "../ports/api-key-repository.ts"
@@ -32,16 +32,22 @@ export class ApiKeyCacheInvalidator extends ServiceMap.Service<
3232

3333
export const revokeApiKeyUseCase = (input: RevokeApiKeyInput) =>
3434
Effect.gen(function* () {
35+
const sqlClient = yield* SqlClient
3536
const cacheInvalidator = yield* ApiKeyCacheInvalidator
36-
const repo = yield* ApiKeyRepository
3737

38-
const apiKey = yield* repo.findById(input.id)
39-
if (apiKey.deletedAt !== null) return yield* new ApiKeyAlreadyRevokedError({ id: input.id })
38+
return yield* sqlClient.transaction(
39+
Effect.gen(function* () {
40+
const repo = yield* ApiKeyRepository
4041

41-
const revokedApiKey = revoke(apiKey)
42-
yield* repo.save(revokedApiKey)
42+
const apiKey = yield* repo.findById(input.id)
43+
if (apiKey.deletedAt !== null) return yield* new ApiKeyAlreadyRevokedError({ id: input.id })
4344

44-
yield* cacheInvalidator.delete(revokedApiKey.tokenHash)
45+
const revokedApiKey = revoke(apiKey)
46+
yield* repo.save(revokedApiKey)
4547

46-
return revokedApiKey
48+
yield* cacheInvalidator.delete(revokedApiKey.tokenHash)
49+
50+
return revokedApiKey
51+
}),
52+
)
4753
})

packages/domain/projects/src/use-cases/create-project.ts

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ export type CreateProjectError =
6666
export const createProjectUseCase = (input: CreateProjectInput) =>
6767
Effect.gen(function* () {
6868
const trimmedName = input.name.trim()
69-
const { organizationId } = yield* SqlClient
69+
const sqlClient = yield* SqlClient
70+
const { organizationId } = sqlClient
7071

7172
if (!trimmedName || trimmedName.length === 0) {
7273
return yield* new InvalidProjectNameError({
@@ -107,27 +108,31 @@ export const createProjectUseCase = (input: CreateProjectInput) =>
107108
...(input.createdById !== undefined && { createdById: input.createdById }),
108109
})
109110

110-
const repo = yield* ProjectRepository
111-
112-
const nameExists = yield* repo.existsByName(trimmedName)
113-
if (nameExists) {
114-
return yield* new ProjectAlreadyExistsError({
115-
name: trimmedName,
116-
slug: trimmedSlug,
117-
organizationId,
118-
})
119-
}
120-
121-
const slugExists = yield* repo.existsBySlug(trimmedSlug)
122-
if (slugExists) {
123-
return yield* new ProjectAlreadyExistsError({
124-
name: trimmedName,
125-
slug: trimmedSlug,
126-
organizationId,
127-
})
128-
}
129-
130-
yield* repo.save(project)
131-
132-
return project
111+
return yield* sqlClient.transaction(
112+
Effect.gen(function* () {
113+
const repo = yield* ProjectRepository
114+
115+
const nameExists = yield* repo.existsByName(trimmedName)
116+
if (nameExists) {
117+
return yield* new ProjectAlreadyExistsError({
118+
name: trimmedName,
119+
slug: trimmedSlug,
120+
organizationId,
121+
})
122+
}
123+
124+
const slugExists = yield* repo.existsBySlug(trimmedSlug)
125+
if (slugExists) {
126+
return yield* new ProjectAlreadyExistsError({
127+
name: trimmedName,
128+
slug: trimmedSlug,
129+
organizationId,
130+
})
131+
}
132+
133+
yield* repo.save(project)
134+
135+
return project
136+
}),
137+
)
133138
})

packages/domain/projects/src/use-cases/update-project.ts

Lines changed: 49 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -45,54 +45,62 @@ export type UpdateProjectError =
4545

4646
export const updateProjectUseCase = (input: UpdateProjectInput) =>
4747
Effect.gen(function* () {
48-
const { organizationId } = yield* SqlClient
49-
const repo = yield* ProjectRepository
50-
const existingProject = yield* repo
51-
.findById(input.id)
52-
.pipe(
53-
Effect.catchTag("NotFoundError", () => Effect.fail(new ProjectNotFoundError({ id: input.id, organizationId }))),
54-
)
48+
const sqlClient = yield* SqlClient
49+
const { organizationId } = sqlClient
5550

56-
let nextName = existingProject.name
51+
return yield* sqlClient.transaction(
52+
Effect.gen(function* () {
53+
const repo = yield* ProjectRepository
54+
const existingProject = yield* repo
55+
.findById(input.id)
56+
.pipe(
57+
Effect.catchTag("NotFoundError", () =>
58+
Effect.fail(new ProjectNotFoundError({ id: input.id, organizationId })),
59+
),
60+
)
5761

58-
if (input.name !== undefined) {
59-
const trimmedName = input.name.trim()
62+
let nextName = existingProject.name
6063

61-
if (!trimmedName) {
62-
return yield* new InvalidProjectNameError({
63-
name: input.name,
64-
reason: "Name cannot be empty",
65-
})
66-
}
64+
if (input.name !== undefined) {
65+
const trimmedName = input.name.trim()
6766

68-
if (trimmedName.length > 256) {
69-
return yield* new InvalidProjectNameError({
70-
name: input.name,
71-
reason: "Name exceeds 256 characters",
72-
})
73-
}
67+
if (!trimmedName) {
68+
return yield* new InvalidProjectNameError({
69+
name: input.name,
70+
reason: "Name cannot be empty",
71+
})
72+
}
7473

75-
if (trimmedName !== existingProject.name) {
76-
const nameExists = yield* repo.existsByName(trimmedName)
77-
if (nameExists) {
78-
return yield* new InvalidProjectNameError({
79-
name: trimmedName,
80-
reason: "Project name already exists in this organization",
81-
})
82-
}
83-
}
74+
if (trimmedName.length > 256) {
75+
return yield* new InvalidProjectNameError({
76+
name: input.name,
77+
reason: "Name exceeds 256 characters",
78+
})
79+
}
80+
81+
if (trimmedName !== existingProject.name) {
82+
const nameExists = yield* repo.existsByName(trimmedName)
83+
if (nameExists) {
84+
return yield* new InvalidProjectNameError({
85+
name: trimmedName,
86+
reason: "Project name already exists in this organization",
87+
})
88+
}
89+
}
8490

85-
nextName = trimmedName
86-
}
91+
nextName = trimmedName
92+
}
8793

88-
const updatedProject: Project = {
89-
...existingProject,
90-
name: nextName,
91-
description: input.description !== undefined ? input.description : existingProject.description,
92-
updatedAt: new Date(),
93-
}
94+
const updatedProject: Project = {
95+
...existingProject,
96+
name: nextName,
97+
description: input.description !== undefined ? input.description : existingProject.description,
98+
updatedAt: new Date(),
99+
}
94100

95-
yield* repo.save(updatedProject)
101+
yield* repo.save(updatedProject)
96102

97-
return updatedProject
103+
return updatedProject
104+
}),
105+
)
98106
})

packages/domain/subscriptions/src/use-cases/change-plan.ts

Lines changed: 64 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { GrantId, NotFoundError, OrganizationId, RepositoryError } from "@domain/shared"
1+
import { type GrantId, type NotFoundError, type OrganizationId, type RepositoryError, SqlClient } from "@domain/shared"
22
import { Data, Effect } from "effect"
33
import { type Grant, type GrantType, createGrant } from "../entities/grant.ts"
44
import type { Plan } from "../entities/plan.ts"
@@ -77,63 +77,69 @@ export interface ChangePlanResult {
7777
*/
7878
export const changePlan = (input: ChangePlanInput) =>
7979
Effect.gen(function* () {
80-
const subscriptionRepository = yield* SubscriptionRepository
81-
const grantRepository = yield* GrantRepository
82-
83-
const subscription = yield* subscriptionRepository
84-
.findActive()
85-
.pipe(
86-
Effect.catchTag("NotFoundError", () =>
87-
Effect.fail(new NoActiveSubscriptionError({ organizationId: input.organizationId })),
88-
),
89-
)
90-
91-
if (subscription.plan === input.newPlan) {
92-
return yield* new SamePlanError({
93-
currentPlan: subscription.plan,
94-
requestedPlan: input.newPlan,
95-
})
96-
}
97-
98-
const previousGrants = yield* grantRepository.findBySubscriptionId(subscription.id)
99-
100-
yield* grantRepository.revokeBySubscription(subscription.id)
101-
102-
const grantConfigs: Array<{ type: GrantType; amount: number }> = [
103-
{ type: "seats", amount: 10 },
104-
{ type: "runs", amount: 1000 },
105-
{ type: "credits", amount: 500 },
106-
]
107-
108-
const grantIdByType: Record<GrantType, GrantId> = {
109-
seats: input.grantIds.seats,
110-
runs: input.grantIds.runs,
111-
credits: input.grantIds.credits,
112-
}
113-
114-
const newGrants = grantConfigs.map((config) =>
115-
createGrant({
116-
id: grantIdByType[config.type],
117-
organizationId: input.organizationId,
118-
subscriptionId: subscription.id,
119-
type: config.type,
120-
amount: config.amount,
80+
const sqlClient = yield* SqlClient
81+
82+
return yield* sqlClient.transaction(
83+
Effect.gen(function* () {
84+
const subscriptionRepository = yield* SubscriptionRepository
85+
const grantRepository = yield* GrantRepository
86+
87+
const subscription = yield* subscriptionRepository
88+
.findActive()
89+
.pipe(
90+
Effect.catchTag("NotFoundError", () =>
91+
Effect.fail(new NoActiveSubscriptionError({ organizationId: input.organizationId })),
92+
),
93+
)
94+
95+
if (subscription.plan === input.newPlan) {
96+
return yield* new SamePlanError({
97+
currentPlan: subscription.plan,
98+
requestedPlan: input.newPlan,
99+
})
100+
}
101+
102+
const previousGrants = yield* grantRepository.findBySubscriptionId(subscription.id)
103+
104+
yield* grantRepository.revokeBySubscription(subscription.id)
105+
106+
const grantConfigs: Array<{ type: GrantType; amount: number }> = [
107+
{ type: "seats", amount: 10 },
108+
{ type: "runs", amount: 1000 },
109+
{ type: "credits", amount: 500 },
110+
]
111+
112+
const grantIdByType: Record<GrantType, GrantId> = {
113+
seats: input.grantIds.seats,
114+
runs: input.grantIds.runs,
115+
credits: input.grantIds.credits,
116+
}
117+
118+
const newGrants = grantConfigs.map((config) =>
119+
createGrant({
120+
id: grantIdByType[config.type],
121+
organizationId: input.organizationId,
122+
subscriptionId: subscription.id,
123+
type: config.type,
124+
amount: config.amount,
125+
}),
126+
)
127+
128+
yield* grantRepository.saveMany(newGrants)
129+
130+
const updatedSubscription: Subscription = {
131+
...subscription,
132+
plan: input.newPlan,
133+
updatedAt: new Date(),
134+
}
135+
136+
yield* subscriptionRepository.save(updatedSubscription)
137+
138+
return {
139+
subscription: updatedSubscription,
140+
previousGrants,
141+
newGrants,
142+
}
121143
}),
122144
)
123-
124-
yield* grantRepository.saveMany(newGrants)
125-
126-
const updatedSubscription: Subscription = {
127-
...subscription,
128-
plan: input.newPlan,
129-
updatedAt: new Date(),
130-
}
131-
132-
yield* subscriptionRepository.save(updatedSubscription)
133-
134-
return {
135-
subscription: updatedSubscription,
136-
previousGrants,
137-
newGrants,
138-
}
139145
})

0 commit comments

Comments
 (0)