diff --git a/src/application/UnitOfWork.ts b/src/application/UnitOfWork.ts new file mode 100644 index 0000000..2987d89 --- /dev/null +++ b/src/application/UnitOfWork.ts @@ -0,0 +1,7 @@ +/** + * 一連の操作をまとめて扱うためのインターフェース + * 具体的な実装(DBトランザクション等)はInfrastructure層が担当する + */ +export interface UnitOfWork { + run(fn: () => Promise): Promise; +} diff --git a/src/application/index.ts b/src/application/index.ts index 4008668..d86cc99 100644 --- a/src/application/index.ts +++ b/src/application/index.ts @@ -1,4 +1,5 @@ export * from "./dto"; +export * from "./UnitOfWork"; export * from "./usecase/member"; export * from "./usecase/event"; export * from "./usecase/eventParticipation"; diff --git a/src/application/usecase/base.ts b/src/application/usecase/base.ts index 9704b84..c6f3eac 100644 --- a/src/application/usecase/base.ts +++ b/src/application/usecase/base.ts @@ -1,8 +1,32 @@ +import type { UnitOfWork } from "../UnitOfWork"; + /** * ユースケースの基底抽象クラス * TInputはユースケースへの入力型 * TOutputはユースケースからの出力型 */ export abstract class IUseCase { + /** + * ユースケースのビジネスロジックを実行する + * トランザクションなしで実行されるため、読み取り専用のUseCaseはこちらを使う + * 複数のDB書き込みを安全にまとめたい場合は run() を使うこと + */ abstract execute(input: Input): Promise; + + /** + * UnitOfWorkで囲んでexecuteを実行する + * トランザクション内で全てのDB操作がまとめて成功/失敗する + * 複数の集約をまたぐ書き込み操作がある場合に使用する + * + * 注意: + * - execute()内で外部API呼び出し(Discord API等)を行うと、 + * その応答待ちの間トランザクションが開きっぱなしになる + * 外部連携はトランザクション完了後に行うこと + * - トランザクションがロールバックされてもメモリ上のドメインオブジェクトは + * 元に戻らない。execute()内で取得・変更したドメインオブジェクトを + * ロールバック後に再利用しないこと + */ + async run(input: Input, unitOfWork: UnitOfWork): Promise { + return unitOfWork.run(() => this.execute(input)); + } } diff --git a/src/infrastructure/drizzle/DrizzleDiscordAccountRepository.ts b/src/infrastructure/drizzle/DrizzleDiscordAccountRepository.ts index 8fe843c..d71c4f2 100644 --- a/src/infrastructure/drizzle/DrizzleDiscordAccountRepository.ts +++ b/src/infrastructure/drizzle/DrizzleDiscordAccountRepository.ts @@ -8,7 +8,7 @@ import { discordId, memberId, } from "#domain"; -import { getDb } from "./client"; +import { getClient } from "./client"; import { discordAccountDomainEvents, discordAccounts } from "./schema"; import { serializeDiscordAccountEventPayload } from "./serializeDiscordAccountEvent"; @@ -20,7 +20,7 @@ function toDomain(row: DiscordAccountRow): DiscordAccount { export class DrizzleDiscordAccountRepository implements DiscordAccountRepository { async findByDiscordId(id: DiscordId): Promise { - const db = getDb(); + const db = getClient(); const row = await db.query.discordAccounts.findFirst({ where: eq(discordAccounts.discordId, id as string), }); @@ -29,7 +29,7 @@ export class DrizzleDiscordAccountRepository implements DiscordAccountRepository } async findByMemberId(id: MemberId): Promise { - const db = getDb(); + const db = getClient(); const rows = await db.query.discordAccounts.findMany({ where: eq(discordAccounts.memberId, id as string), }); @@ -37,13 +37,13 @@ export class DrizzleDiscordAccountRepository implements DiscordAccountRepository } async findAll(): Promise { - const db = getDb(); + const db = getClient(); const rows = await db.query.discordAccounts.findMany(); return rows.map(toDomain); } async save(account: DiscordAccount): Promise { - const db = getDb(); + const db = getClient(); const now = new Date().toISOString(); const events = account.getDomainEvents(); @@ -80,7 +80,7 @@ export class DrizzleDiscordAccountRepository implements DiscordAccountRepository } async delete(id: DiscordId): Promise { - const db = getDb(); + const db = getClient(); await db.delete(discordAccounts).where(eq(discordAccounts.discordId, id as string)); } } diff --git a/src/infrastructure/drizzle/DrizzleEventRepository.ts b/src/infrastructure/drizzle/DrizzleEventRepository.ts index 9451f0d..432c098 100644 --- a/src/infrastructure/drizzle/DrizzleEventRepository.ts +++ b/src/infrastructure/drizzle/DrizzleEventRepository.ts @@ -14,7 +14,7 @@ import { exhibitId, memberId, } from "#domain"; -import { type DrizzleDb, getDb } from "./client"; +import { type DrizzleClient, getClient } from "./client"; import { events, exhibits, lightningTalks, memberEvents, memberExhibits } from "./schema"; // ============================================================================ @@ -101,7 +101,7 @@ export class DrizzleEventRepository implements EventRepository { // ========================================================================== private async persistEvent(event: Event): Promise { - const db = getDb(); + const db = getClient(); const snapshot = event.toSnapshot(); const now = new Date().toISOString(); const dateStr = snapshot.date instanceof Date ? snapshot.date.toISOString() : snapshot.date; @@ -143,7 +143,7 @@ export class DrizzleEventRepository implements EventRepository { } private async deleteObsoleteExhibits( - db: DrizzleDb, + db: DrizzleClient, eventId: EventId, keptExhibitIds: ExhibitId[], ): Promise { @@ -164,7 +164,7 @@ export class DrizzleEventRepository implements EventRepository { } private async upsertExhibit( - db: DrizzleDb, + db: DrizzleClient, eventId: EventId, ex: ReturnType["exhibits"][number], ): Promise { @@ -220,7 +220,7 @@ export class DrizzleEventRepository implements EventRepository { } private async syncMemberEvents( - db: DrizzleDb, + db: DrizzleClient, eventId: EventId, memberIds: MemberId[], ): Promise { @@ -241,7 +241,7 @@ export class DrizzleEventRepository implements EventRepository { } private async syncMemberExhibits( - db: DrizzleDb, + db: DrizzleClient, exhibitId: ExhibitId, memberIds: MemberId[], ): Promise { @@ -266,7 +266,7 @@ export class DrizzleEventRepository implements EventRepository { // ========================================================================== async findById(id: EventId): Promise { - const db = getDb(); + const db = getClient(); const record = await db.query.events.findFirst({ where: eq(events.id, id), with: { @@ -285,7 +285,7 @@ export class DrizzleEventRepository implements EventRepository { } async findByParticipantMemberId(memberId: MemberId): Promise { - const db = getDb(); + const db = getClient(); const participations = await db .select({ eventId: memberEvents.eventId }) @@ -312,7 +312,7 @@ export class DrizzleEventRepository implements EventRepository { } async findByExhibitId(exhibitId: ExhibitId): Promise { - const db = getDb(); + const db = getClient(); const exhibit = await db .select({ eventId: exhibits.eventId }) @@ -325,7 +325,7 @@ export class DrizzleEventRepository implements EventRepository { } async findAll(): Promise { - const db = getDb(); + const db = getClient(); const records = await db.query.events.findMany({ with: { memberEvents: true, @@ -346,7 +346,7 @@ export class DrizzleEventRepository implements EventRepository { } async delete(eventId: EventId): Promise { - const db = getDb(); + const db = getClient(); const exhibitRecords = await db .select({ id: exhibits.id }) diff --git a/src/infrastructure/drizzle/DrizzleKarteRepository.ts b/src/infrastructure/drizzle/DrizzleKarteRepository.ts index 86fc967..0366285 100644 --- a/src/infrastructure/drizzle/DrizzleKarteRepository.ts +++ b/src/infrastructure/drizzle/DrizzleKarteRepository.ts @@ -21,7 +21,7 @@ import { type Resolution, type SupportRecord, } from "#domain"; -import { getDb } from "./client"; +import { getClient } from "./client"; import { karteAssignees, kartes } from "./schema"; // ============================================================================ @@ -250,7 +250,7 @@ function consultedAtToPrecision(r: Recorded): ConsultedAt["precisio export class DrizzleKarteRepository implements KarteRepository { async findById(id: KarteId): Promise { - const db = getDb(); + const db = getClient(); const row = await db.query.kartes.findFirst({ where: eq(kartes.id, id as string), with: { @@ -263,7 +263,7 @@ export class DrizzleKarteRepository implements KarteRepository { } async findAll(): Promise { - const db = getDb(); + const db = getClient(); const rows = await db.query.kartes.findMany({ with: { karteAssignees: true, @@ -274,7 +274,7 @@ export class DrizzleKarteRepository implements KarteRepository { } async save(karte: Karte): Promise { - const db = getDb(); + const db = getClient(); const clientCols = clientToColumns(karte.client); const resCols = resolutionToColumns(karte.supportRecord.resolution); diff --git a/src/infrastructure/drizzle/DrizzleMemberRepository.ts b/src/infrastructure/drizzle/DrizzleMemberRepository.ts index 7f77ec7..144c8d1 100644 --- a/src/infrastructure/drizzle/DrizzleMemberRepository.ts +++ b/src/infrastructure/drizzle/DrizzleMemberRepository.ts @@ -16,7 +16,7 @@ import { type MemberRepository, type Recorded, } from "#domain"; -import { getDb } from "./client"; +import { getClient } from "./client"; import { memberDomainEvents, members } from "./schema"; import { serializeMemberEventPayload } from "./serializeMemberEvent"; @@ -100,7 +100,7 @@ function toInsertValues(member: Member): MemberInsert { export class DrizzleMemberRepository implements MemberRepository { async findById(id: MemberId): Promise { - const db = getDb(); + const db = getClient(); const row = await db.query.members.findFirst({ where: eq(members.id, id as string), }); @@ -109,7 +109,7 @@ export class DrizzleMemberRepository implements MemberRepository { } async findByEmail(email: UniversityEmail): Promise { - const db = getDb(); + const db = getClient(); const row = await db.query.members.findFirst({ where: eq(members.email, email.getValue()), }); @@ -118,13 +118,13 @@ export class DrizzleMemberRepository implements MemberRepository { } async findAll(): Promise { - const db = getDb(); + const db = getClient(); const rows = await db.query.members.findMany(); return rows.map(toDomain); } async save(member: Member): Promise { - const db = getDb(); + const db = getClient(); const values = toInsertValues(member); const events = member.getDomainEvents(); diff --git a/src/infrastructure/drizzle/DrizzleUnitOfWork.ts b/src/infrastructure/drizzle/DrizzleUnitOfWork.ts new file mode 100644 index 0000000..0c3bd27 --- /dev/null +++ b/src/infrastructure/drizzle/DrizzleUnitOfWork.ts @@ -0,0 +1,12 @@ +import type { UnitOfWork } from "#application/UnitOfWork"; +import { runInTransaction } from "./client"; + +/** + * UnitOfWorkのDrizzle実装 + * AsyncLocalStorageを利用して、トランザクションを処理の流れ全体で共有する + */ +export class DrizzleUnitOfWork implements UnitOfWork { + async run(fn: () => Promise): Promise { + return runInTransaction(fn); + } +} diff --git a/src/infrastructure/drizzle/client.ts b/src/infrastructure/drizzle/client.ts index 14fa1e5..af83232 100644 --- a/src/infrastructure/drizzle/client.ts +++ b/src/infrastructure/drizzle/client.ts @@ -1,24 +1,54 @@ /// +import { AsyncLocalStorage } from "node:async_hooks"; +import type { PgDatabase } from "drizzle-orm/pg-core"; import { drizzle } from "drizzle-orm/postgres-js"; +import type { PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js/session"; import postgres, { type Sql } from "postgres"; import * as schema from "./schema"; -let client: Sql | null = null; +export type DrizzleClient = PgDatabase; -function getClient(): Sql { - if (!client) { +let sqlClient: Sql | null = null; + +function getSqlClient(): Sql { + if (!sqlClient) { const connectionString = process.env.DATABASE_URL; if (!connectionString) { throw new Error("DATABASE_URL environment variable is not set"); } // Supabase の Transaction pool mode は prepared statement をサポートしないため無効化 - client = postgres(connectionString, { prepare: false }); + sqlClient = postgres(connectionString, { prepare: false }); } - return client; + return sqlClient; +} + +function createClient(): DrizzleClient { + return drizzle(getSqlClient(), { schema }); } -export function getDb() { - return drizzle(getClient(), { schema }); +const transactionContext = new AsyncLocalStorage(); + +/** + * Drizzleクライアントを取得する + * トランザクション中であればそのトランザクションを返し、 + * そうでなければ新しいクライアントを返す + */ +export function getClient(): DrizzleClient { + const tx = transactionContext.getStore(); + if (tx) return tx; + return createClient(); } -export type DrizzleDb = ReturnType; +/** + * トランザクション内で処理を実行する + * すでにトランザクション中であればそのまま実行する(ネストしない) + */ +export function runInTransaction(fn: () => Promise): Promise { + if (transactionContext.getStore()) { + return fn(); + } + const db = createClient(); + return db.transaction(async (tx) => { + return transactionContext.run(tx, fn); + }); +} diff --git a/src/infrastructure/drizzle/index.ts b/src/infrastructure/drizzle/index.ts index a84e17e..8c90ff3 100644 --- a/src/infrastructure/drizzle/index.ts +++ b/src/infrastructure/drizzle/index.ts @@ -2,3 +2,4 @@ export { DrizzleDiscordAccountRepository } from "./DrizzleDiscordAccountReposito export { DrizzleEventRepository } from "./DrizzleEventRepository"; export { DrizzleKarteRepository } from "./DrizzleKarteRepository"; export { DrizzleMemberRepository } from "./DrizzleMemberRepository"; +export { DrizzleUnitOfWork } from "./DrizzleUnitOfWork";