diff --git a/.changeset/healthy-transactions-join.md b/.changeset/healthy-transactions-join.md new file mode 100644 index 0000000000..ea46cc796e --- /dev/null +++ b/.changeset/healthy-transactions-join.md @@ -0,0 +1,7 @@ +--- +"@croco/customer-health-drizzle": patch +"@croco/customer-health-core": patch +--- + +Join caller-owned transactions when storing customer health transitions and defer their external +events until the transaction commits. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f6ca57f6e..51a03afd87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -662,6 +662,8 @@ jobs: - 'packages/auth-drizzle/**' - 'packages/credits-core/**' - 'packages/credits-drizzle/**' + - 'packages/customer-health-core/**' + - 'packages/customer-health-drizzle/**' - 'packages/engagement-core/**' - 'packages/engagement-drizzle/**' - 'packages/entitlements-core/**' @@ -1495,6 +1497,7 @@ jobs: - name: Verify persistence concurrency against PostgreSQL env: CREDITS_POSTGRES_URL: postgresql://postgres:postgres@127.0.0.1:5432/croco_membership + CUSTOMER_HEALTH_POSTGRES_URL: postgresql://postgres:postgres@127.0.0.1:5432/croco_membership ENGAGEMENT_POSTGRES_URL: postgresql://postgres:postgres@127.0.0.1:5432/croco_membership ENTITLEMENTS_POSTGRES_URL: postgresql://postgres:postgres@127.0.0.1:5432/croco_membership EXECUTION_POSTGRES_URL: postgresql://postgres:postgres@127.0.0.1:5432/croco_membership @@ -1503,6 +1506,8 @@ jobs: run: | pnpm build --filter=@croco/credits-drizzle... pnpm --filter @croco/credits-drizzle test:postgres + pnpm build --filter=@croco/customer-health-drizzle... + pnpm --filter @croco/customer-health-drizzle test:postgres pnpm build --filter=@croco/engagement-drizzle... pnpm --filter @croco/engagement-drizzle test:postgres pnpm build --filter=@croco/entitlements-drizzle... diff --git a/packages/customer-health-core/README.md b/packages/customer-health-core/README.md index 2f22a479ff..5de6d58cc8 100644 --- a/packages/customer-health-core/README.md +++ b/packages/customer-health-core/README.md @@ -122,6 +122,9 @@ const score = await service.calculateAndStore("tenant-1", profile); 시도하고, 계속 커밋되지 않으면 `HealthTransitionPersistenceRetryExhaustedProblem`을 던집니다. 별도 복구 작업에서는 `publishPendingEvents(tenantId)`를 호출할 수 있습니다. +저장소가 호출자 트랜잭션에 참여하면 `calculateAndStore`는 커밋 전 외부 발행을 건너뛰고 이벤트 의도를 +pending 상태로 남깁니다. 호출자 커밋 후 `publishPendingEvents` 또는 별도 outbox worker로 발행하세요. + ### 추세 분석 ```typescript diff --git a/packages/customer-health-core/src/libs/CustomerHealthService.ts b/packages/customer-health-core/src/libs/CustomerHealthService.ts index f98e42da71..f6f839fd8a 100644 --- a/packages/customer-health-core/src/libs/CustomerHealthService.ts +++ b/packages/customer-health-core/src/libs/CustomerHealthService.ts @@ -44,10 +44,10 @@ export class CustomerHealthService { score.tenantId = tenantId; const previous = await this.store.findLatest(tenantId); - await this.persistTransition(score, previous); + const eventPublicationDeferred = await this.persistTransition(score, previous); const eventPublisher = this.getEventPublisher(); - if (eventPublisher) { + if (eventPublisher && !eventPublicationDeferred) { const intents = await this.store.listPendingEventIntents(tenantId, 100); await this.publishEventIntents(intents, eventPublisher); } @@ -58,14 +58,14 @@ export class CustomerHealthService { private async persistTransition( score: TenantHealthScore, initialPrevious: TenantHealthScore | null, - ): Promise { + ): Promise { let previous = initialPrevious; for (let attempt = 1; attempt <= MAX_TRANSITION_PERSISTENCE_ATTEMPTS; attempt += 1) { this.applyPreviousScore(score, previous); const eventIntents = createHealthTransitionEventIntents(previous, score); const commit = await this.store.saveTransition(score, previous, eventIntents); - if (commit.committed) return; + if (commit.committed) return commit.eventPublicationDeferred === true; previous = commit.latest; if (attempt < MAX_TRANSITION_PERSISTENCE_ATTEMPTS) { diff --git a/packages/customer-health-core/src/libs/InMemoryHealthScoreStore.ts b/packages/customer-health-core/src/libs/InMemoryHealthScoreStore.ts index 51b61a3d0c..19b1d7b431 100644 --- a/packages/customer-health-core/src/libs/InMemoryHealthScoreStore.ts +++ b/packages/customer-health-core/src/libs/InMemoryHealthScoreStore.ts @@ -3,6 +3,7 @@ import { cloneTenantHealthScore } from "./healthScoreSnapshot"; import { HealthScoreStore } from "./interfaces"; import { cloneHealthTransitionEventIntent } from "./eventIntent"; import type { HealthTransitionEventIntent } from "./eventIntent"; +import type { HealthTransitionCommitResult } from "./interfaces"; import type { TenantHealthScore, TrendPeriod } from "./types"; import { HealthEventIntentConflictProblem } from "./problems/HealthProblems"; @@ -16,10 +17,7 @@ export class InMemoryHealthScoreStore extends HealthScoreStore { score: TenantHealthScore, previous: TenantHealthScore | null, eventIntents: readonly HealthTransitionEventIntent[], - ): Promise< - | { readonly committed: true } - | { readonly committed: false; readonly latest: TenantHealthScore | null } - > { + ): Promise { const { tenantId } = score; const history = this.store.get(tenantId) ?? []; const latest = history.at(-1) ?? null; diff --git a/packages/customer-health-core/src/libs/interfaces.ts b/packages/customer-health-core/src/libs/interfaces.ts index 8bbf68c042..0e19a9404b 100644 --- a/packages/customer-health-core/src/libs/interfaces.ts +++ b/packages/customer-health-core/src/libs/interfaces.ts @@ -9,6 +9,14 @@ import type { TrendPeriod, } from "./types"; +export type HealthTransitionCommitResult = + | { + readonly committed: true; + /** The transition joined a caller-owned transaction and its events must remain pending. */ + readonly eventPublicationDeferred?: true; + } + | { readonly committed: false; readonly latest: TenantHealthScore | null }; + export abstract class SignalProvider { static readonly token = new Token("SignalProvider"); abstract readonly category: SignalCategory; @@ -17,14 +25,17 @@ export abstract class SignalProvider { export abstract class HealthScoreStore { static readonly token = new Token("HealthScoreStore"); + /** + * Persists one optimistic transition and assigns its CAS version to `score`. + * A version assigned inside a caller-owned transaction is provisional until that transaction + * commits. Discard affected snapshots after rollback or an unknown transaction outcome, then + * reload the latest committed score before retrying. + */ abstract saveTransition( score: TenantHealthScore, previous: TenantHealthScore | null, eventIntents: readonly HealthTransitionEventIntent[], - ): Promise< - | { readonly committed: true } - | { readonly committed: false; readonly latest: TenantHealthScore | null } - >; + ): Promise; abstract listPendingEventIntents( tenantId: string, limit?: number, diff --git a/packages/customer-health-core/src/libs/types.ts b/packages/customer-health-core/src/libs/types.ts index aae6be6dd7..ed35a3e019 100644 --- a/packages/customer-health-core/src/libs/types.ts +++ b/packages/customer-health-core/src/libs/types.ts @@ -56,6 +56,11 @@ export type HealthScoreProfile = { export type TenantHealthScore = { tenantId: string; + /** + * Optimistic concurrency token assigned by the store. A token obtained inside a caller-owned + * transaction is provisional until commit and must not be reused after rollback or an unknown + * transaction outcome. + */ transitionVersion?: string; overallScore: number; status: HealthStatus; diff --git a/packages/customer-health-core/src/tests/CustomerHealthService.spec.ts b/packages/customer-health-core/src/tests/CustomerHealthService.spec.ts index 7b85a083d8..7367c1d67d 100644 --- a/packages/customer-health-core/src/tests/CustomerHealthService.spec.ts +++ b/packages/customer-health-core/src/tests/CustomerHealthService.spec.ts @@ -11,6 +11,7 @@ import { HealthScoreStore, HealthSignalRegistry, } from "../libs/interfaces"; +import type { HealthTransitionCommitResult } from "../libs/interfaces"; import type { HealthScoreProfile, HealthSignal, SignalCategory } from "../libs/types"; class MockSignalProvider implements HealthSignalRegistry { @@ -34,6 +35,15 @@ class MockSignalProvider implements HealthSignalRegistry { } } +class DeferredPublicationHealthScoreStore extends InMemoryHealthScoreStore { + override async saveTransition( + ...args: Parameters + ): Promise { + const result = await super.saveTransition(...args); + return result.committed ? { ...result, eventPublicationDeferred: true } : result; + } +} + describe("CustomerHealthService", () => { let service!: CustomerHealthService; let store!: InMemoryHealthScoreStore; @@ -273,6 +283,32 @@ describe("CustomerHealthService", () => { }); }); + it("keeps joined-transaction events pending until publication runs after commit", async () => { + const deferredStore = new DeferredPublicationHealthScoreStore(); + const profile: HealthScoreProfile = { + id: "profile-1", + name: "Default Profile", + weights: { usage: 1, business: 1, engagement: 1 }, + thresholds: { healthy: 80, atRisk: 60 }, + }; + mockRegistry.addProvider("usage", [healthSignal(90, "2026-03-15T10:00:00Z")]); + service = new CustomerHealthService(mockRegistry, deferredStore, calculator); + await service.calculateAndStore("tenant-1", profile); + + mockRegistry = new MockSignalProvider(); + mockRegistry.addProvider("usage", [healthSignal(50, "2026-03-15T11:00:00Z")]); + service = new CustomerHealthService(mockRegistry, deferredStore, calculator); + + await service.calculateAndStore("tenant-1", profile); + + expect(mockEventPublisher.publishIdempotently).not.toHaveBeenCalled(); + await expect(deferredStore.listPendingEventIntents("tenant-1")).resolves.toHaveLength(2); + + await expect(service.publishPendingEvents("tenant-1")).resolves.toBe(2); + expect(mockEventPublisher.publishIdempotently).toHaveBeenCalledTimes(2); + await expect(deferredStore.listPendingEventIntents("tenant-1")).resolves.toHaveLength(0); + }); + it("should retry the persisted transition without deriving events from the stored score", async () => { const profile: HealthScoreProfile = { id: "profile-1", diff --git a/packages/customer-health-drizzle/README.md b/packages/customer-health-drizzle/README.md index 9fbd9b4b56..1f202438d3 100644 --- a/packages/customer-health-drizzle/README.md +++ b/packages/customer-health-drizzle/README.md @@ -5,7 +5,7 @@ ## 설치 ```bash -pnpm add @croco/customer-health-drizzle @croco/customer-health-core drizzle-orm +pnpm add @croco/customer-health-drizzle @croco/customer-health-core @croco/tx-core @croco/tx-drizzle drizzle-orm ``` ## 사용법 @@ -18,9 +18,12 @@ import { DrizzleHealthSignalRegistry, MeteringSignalProvider, } from "@croco/customer-health-drizzle"; +import { TxManager } from "@croco/tx-core"; +import { createDrizzleTxAdapter } from "@croco/tx-drizzle"; await addHealthEventIntents(db); -const scoreStore = new DrizzleHealthScoreStore(db); +const txManager = new TxManager(createDrizzleTxAdapter(db)); +const scoreStore = new DrizzleHealthScoreStore(db, txManager); const usageProvider = new MeteringSignalProvider(usageStorage); const billingProvider = new BillingSignalProvider(subscriptionStorage); const registry = new DrizzleHealthSignalRegistry(usageProvider, billingProvider); @@ -85,6 +88,14 @@ COMMIT; - `findHistory(tenantId, limit)`, 최근 점수 이력을 조회합니다. - `findHistoryByPeriod(tenantId, period, startDate, endDate)`, 기간별 이력을 조회합니다. +`saveTransition`이 호출자 트랜잭션에 참여하면 결과에 `eventPublicationDeferred: true`가 포함됩니다. +이때 이벤트 의도는 커밋 전 외부로 발행하지 않으며, 호출자 커밋 후 `publishPendingEvents` 또는 outbox worker가 +발행해야 합니다. + +호출자 트랜잭션 안에서 `saveTransition`이 점수에 부여한 `transitionVersion`은 같은 트랜잭션의 후속 CAS에 사용할 +수 있지만, 호출자 트랜잭션이 커밋되기 전까지는 잠정 값입니다. 트랜잭션이나 savepoint가 롤백되었거나 결과를 +확정할 수 없다면 해당 점수 스냅샷을 버리고 `findLatest`로 커밋된 최신 점수를 다시 읽은 뒤 재시도하세요. + ### 신호 제공자 - `BillingSignalProvider`, 구독 상태를 business 신호로 변환합니다. diff --git a/packages/customer-health-drizzle/package.json b/packages/customer-health-drizzle/package.json index bb0a7f11fb..8692f71a1c 100644 --- a/packages/customer-health-drizzle/package.json +++ b/packages/customer-health-drizzle/package.json @@ -32,8 +32,9 @@ "scripts": { "build": "tsup src/index.ts --format esm,cjs --minify --clean --dts", "lint": "oxlint .", - "test": "vitest run", + "test": "vitest run --exclude src/tests/DrizzleHealthScoreStorePostgres.spec.ts", "test:evidence": "pnpm run test --maxWorkers=1 --reporter=json --outputFile=.turbo/croco-test-evidence.json", + "test:postgres": "vitest run src/tests/DrizzleHealthScoreStorePostgres.spec.ts", "typecheck": "tsc --noEmit", "docs:api:model": "node --experimental-strip-types ../docs/scripts/generate-package-api-model.mts" }, @@ -41,11 +42,14 @@ "@croco/customer-health-core": "workspace:*", "@croco/framework-context": "workspace:*", "@croco/problems-core": "workspace:*", + "@croco/tx-core": "workspace:*", "@croco/tx-drizzle": "workspace:*" }, "devDependencies": { "@croco/testing": "workspace:*", + "@types/pg": "8.20.0", "drizzle-orm": "catalog:", + "pg": "8.22.0", "reflect-metadata": "^0.2.2" }, "peerDependencies": { diff --git a/packages/customer-health-drizzle/src/libs/DrizzleHealthScoreStore.ts b/packages/customer-health-drizzle/src/libs/DrizzleHealthScoreStore.ts index b4c2f35744..fc2588772e 100644 --- a/packages/customer-health-drizzle/src/libs/DrizzleHealthScoreStore.ts +++ b/packages/customer-health-drizzle/src/libs/DrizzleHealthScoreStore.ts @@ -1,11 +1,16 @@ import type { HealthSignal, + HealthTransitionCommitResult, HealthTransitionEventIntent, TenantHealthScore, TrendPeriod, } from "@croco/customer-health-core"; import { HealthScoreStore } from "@croco/customer-health-core"; import { Component, Inject, Token } from "@croco/framework-context"; +// Runtime value required for constructor metadata. +// oxlint-disable-next-line typescript/consistent-type-imports +import { TxManager } from "@croco/tx-core"; +import type { DrizzleDb } from "@croco/tx-drizzle"; import { and, asc, desc, eq, gte, isNull, lte, sql } from "drizzle-orm"; import type { NodePgDatabase } from "drizzle-orm/node-postgres"; import { tenantHealthEventIntents, tenantHealthScores } from "./schema"; @@ -14,7 +19,7 @@ import { HealthTransitionSequenceMissingProblem } from "./problems/DrizzleHealth /** * 건강 점수 저장소에서 사용하는 Drizzle 클라이언트 타입입니다. */ -export type DrizzleHealthClient = NodePgDatabase>; +export type DrizzleHealthClient = DrizzleDb & NodePgDatabase>; /** * 건강 점수 저장소용 Drizzle 클라이언트 주입 토큰입니다. @@ -43,9 +48,12 @@ type StoredHealthSignal = Omit & { @Component() export class DrizzleHealthScoreStore extends HealthScoreStore { /** - * Drizzle 클라이언트를 받아 저장소를 초기화합니다. + * Drizzle 클라이언트와 트랜잭션 매니저를 받아 저장소를 초기화합니다. */ - constructor(@Inject(DRIZZLE_TOKEN) private readonly db: DrizzleHealthClient) { + constructor( + @Inject(DRIZZLE_TOKEN) private readonly db: DrizzleHealthClient, + private readonly txManager: TxManager, + ) { super(); } @@ -56,13 +64,15 @@ export class DrizzleHealthScoreStore extends HealthScoreStore { score: TenantHealthScore, previous: TenantHealthScore | null, eventIntents: readonly HealthTransitionEventIntent[], - ): Promise< - | { readonly committed: true } - | { readonly committed: false; readonly latest: TenantHealthScore | null } - > { - return this.db.transaction(async (tx) => { - await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${score.tenantId}, 0))`); - const rows = await tx + ): Promise { + const eventPublicationDeferred = this.txManager.isInTransaction(); + let transitionVersion: string | undefined; + const commit: HealthTransitionCommitResult = await this.txManager.run(async () => { + const client = this.getClient(); + await client.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${score.tenantId}, 0))`, + ); + const rows = await client .select() .from(tenantHealthScores) .where(eq(tenantHealthScores.tenantId, score.tenantId)) @@ -74,15 +84,15 @@ export class DrizzleHealthScoreStore extends HealthScoreStore { return { committed: false, latest }; } - const insertedRows = await tx + const insertedRows = await client .insert(tenantHealthScores) .values(score) .returning({ transitionSequence: tenantHealthScores.transitionSequence }); const inserted = insertedRows[0]; - if (inserted) score.transitionVersion = String(inserted.transitionSequence); + if (inserted) transitionVersion = String(inserted.transitionSequence); if (eventIntents.length > 0) { if (!inserted) throw new HealthTransitionSequenceMissingProblem(); - await tx.insert(tenantHealthEventIntents).values( + await client.insert(tenantHealthEventIntents).values( eventIntents.map((intent, intentOrder) => ({ eventId: intent.eventId, tenantId: intent.tenantId, @@ -93,8 +103,14 @@ export class DrizzleHealthScoreStore extends HealthScoreStore { })), ); } - return { committed: true }; + return eventPublicationDeferred + ? { committed: true, eventPublicationDeferred: true } + : { committed: true }; }); + if (commit.committed && transitionVersion !== undefined) { + score.transitionVersion = transitionVersion; + } + return commit; } async listPendingEventIntents( @@ -102,7 +118,7 @@ export class DrizzleHealthScoreStore extends HealthScoreStore { limit = 100, ): Promise { if (!Number.isInteger(limit) || limit <= 0) return []; - const rows = await this.db + const rows = await this.getClient() .select() .from(tenantHealthEventIntents) .where( @@ -125,7 +141,7 @@ export class DrizzleHealthScoreStore extends HealthScoreStore { } async markEventIntentPublished(eventId: string): Promise { - await this.db + await this.getClient() .update(tenantHealthEventIntents) .set({ publishedAt: new Date() }) .where( @@ -140,7 +156,7 @@ export class DrizzleHealthScoreStore extends HealthScoreStore { * 테넌트의 최신 건강 점수를 조회합니다. */ async findLatest(tenantId: string): Promise { - const result = await this.db + const result = await this.getClient() .select() .from(tenantHealthScores) .where(eq(tenantHealthScores.tenantId, tenantId)) @@ -154,7 +170,7 @@ export class DrizzleHealthScoreStore extends HealthScoreStore { * 테넌트의 건강 점수 이력을 최신순으로 조회합니다. */ async findHistory(tenantId: string, limit: number): Promise { - const results = await this.db + const results = await this.getClient() .select() .from(tenantHealthScores) .where(eq(tenantHealthScores.tenantId, tenantId)) @@ -172,7 +188,7 @@ export class DrizzleHealthScoreStore extends HealthScoreStore { startDate: Date, endDate: Date, ): Promise { - const results = await this.db + const results = await this.getClient() .select() .from(tenantHealthScores) .where( @@ -186,6 +202,10 @@ export class DrizzleHealthScoreStore extends HealthScoreStore { return (results as TenantHealthScoreRow[]).map((row) => this.mapToTenantHealthScore(row)); } + private getClient(): DrizzleHealthClient { + return this.txManager.getClient() ?? this.db; + } + private mapToTenantHealthScore(row: TenantHealthScoreRow): TenantHealthScore { return { tenantId: row.tenantId, diff --git a/packages/customer-health-drizzle/src/tests/DrizzleHealthScoreStore.spec.ts b/packages/customer-health-drizzle/src/tests/DrizzleHealthScoreStore.spec.ts index 4edb4a8602..3bb1ed255a 100644 --- a/packages/customer-health-drizzle/src/tests/DrizzleHealthScoreStore.spec.ts +++ b/packages/customer-health-drizzle/src/tests/DrizzleHealthScoreStore.spec.ts @@ -4,6 +4,7 @@ import { type HealthTransitionEventIntent, type TenantHealthScore, } from "@croco/customer-health-core"; +import type { TxManager } from "@croco/tx-core"; import { describe, expect, it, vi } from "vitest"; import { DrizzleHealthScoreStore } from "../libs/DrizzleHealthScoreStore"; import type { DrizzleHealthClient } from "../libs/DrizzleHealthScoreStore"; @@ -11,7 +12,7 @@ import { tenantHealthEventIntents, tenantHealthScores } from "../libs/schema"; describe("DrizzleHealthScoreStore", () => { const conformance = createHealthScoreStoreConformanceSuite({ - createStore: () => new DrizzleHealthScoreStore(createStatefulClient()), + createStore: () => createStore(createStatefulClient()), }); for (const testCase of conformance.cases) { @@ -26,7 +27,7 @@ describe("DrizzleHealthScoreStore", () => { const transaction = createTransaction([], (table: unknown) => ({ values: table === tenantHealthScores ? scoreValues : intentValues, })); - const store = new DrizzleHealthScoreStore({ transaction } as unknown as DrizzleHealthClient); + const store = createStore({ transaction } as unknown as DrizzleHealthClient); const score = createScore(50, "critical", "2026-03-15T11:00:00Z"); const statusIntent: HealthTransitionEventIntent = { eventId: "event-1", @@ -78,12 +79,62 @@ describe("DrizzleHealthScoreStore", () => { ]); }); + it("joins the active transaction for the advisory lock and transition writes", async () => { + const returning = vi.fn().mockResolvedValue([{ transitionSequence: BigInt(1) }]); + const values = vi.fn().mockReturnValue({ returning }); + const txClient = createTransactionClient([], vi.fn().mockReturnValue({ values })); + const fallbackTransaction = vi.fn(() => { + throw new Error("fallback transaction used"); + }); + const txManager = { + getClient: vi.fn().mockReturnValue(txClient), + isInTransaction: vi.fn().mockReturnValue(true), + run: vi.fn(async (operation: () => Promise) => operation()), + } as unknown as TxManager; + const store = new DrizzleHealthScoreStore( + { transaction: fallbackTransaction } as unknown as DrizzleHealthClient, + txManager, + ); + const score = createScore(85, "healthy", "2026-03-15T10:00:00Z"); + + await expect(store.saveTransition(score, null, [])).resolves.toEqual({ + committed: true, + eventPublicationDeferred: true, + }); + + expect(score.transitionVersion).toBe("1"); + expect(txClient.execute).toHaveBeenCalledTimes(1); + expect(txClient.insert).toHaveBeenCalledWith(tenantHealthScores); + expect(fallbackTransaction).not.toHaveBeenCalled(); + }); + + it("does not assign a transition version when the transaction fails", async () => { + const returning = vi.fn().mockResolvedValue([{ transitionSequence: BigInt(1) }]); + const values = vi.fn().mockReturnValue({ returning }); + const txClient = createTransactionClient([], vi.fn().mockReturnValue({ values })); + const transactionFailure = new Error("transaction commit failed"); + const txManager = { + getClient: vi.fn().mockReturnValue(txClient), + isInTransaction: vi.fn().mockReturnValue(false), + run: vi.fn(async (operation: () => Promise) => { + await operation(); + throw transactionFailure; + }), + } as unknown as TxManager; + const store = new DrizzleHealthScoreStore({} as DrizzleHealthClient, txManager); + const score = createScore(85, "healthy", "2026-03-15T10:00:00Z"); + + await expect(store.saveTransition(score, null, [])).rejects.toBe(transactionFailure); + + expect(score.transitionVersion).toBeUndefined(); + }); + it("does not create an intent insert for a no-event transition", async () => { const returning = vi.fn().mockResolvedValue([{ transitionSequence: BigInt(1) }]); const values = vi.fn().mockReturnValue({ returning }); const insert = vi.fn().mockReturnValue({ values }); const transaction = createTransaction([], insert); - const store = new DrizzleHealthScoreStore({ transaction } as unknown as DrizzleHealthClient); + const store = createStore({ transaction } as unknown as DrizzleHealthClient); const result = await store.saveTransition( createScore(85, "healthy", "2026-03-15T10:00:00Z"), @@ -109,7 +160,7 @@ describe("DrizzleHealthScoreStore", () => { orderByExpression = value; }, ); - const store = new DrizzleHealthScoreStore({ transaction } as unknown as DrizzleHealthClient); + const store = createStore({ transaction } as unknown as DrizzleHealthClient); const result = await store.saveTransition( createScore(50, "critical", "2026-03-15T12:00:00Z"), @@ -124,18 +175,23 @@ describe("DrizzleHealthScoreStore", () => { it("loads pending intents in committed transition and declaration order", async () => { const orderBy = vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }); - const db = { + const txClient = { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ orderBy }), }), }), } as unknown as DrizzleHealthClient; - const store = new DrizzleHealthScoreStore(db); + const fallbackSelect = vi.fn(); + const store = createStore( + { select: fallbackSelect } as unknown as DrizzleHealthClient, + txClient, + ); await store.listPendingEventIntents("tenant-1"); expect(orderBy).toHaveBeenCalledTimes(1); + expect(fallbackSelect).not.toHaveBeenCalled(); const ordering = orderBy.mock.calls[0]; expect(ordering).toHaveLength(2); expect(containsQueryChunk(ordering?.[0], tenantHealthEventIntents.transitionSequence)).toBe( @@ -143,29 +199,78 @@ describe("DrizzleHealthScoreStore", () => { ); expect(containsQueryChunk(ordering?.[1], tenantHealthEventIntents.intentOrder)).toBe(true); }); + + it("marks event intents through the active transaction client", async () => { + const where = vi.fn().mockResolvedValue(undefined); + const set = vi.fn().mockReturnValue({ where }); + const update = vi.fn().mockReturnValue({ set }); + const fallbackUpdate = vi.fn(); + const store = createStore( + { update: fallbackUpdate } as unknown as DrizzleHealthClient, + { update } as unknown as DrizzleHealthClient, + ); + + await store.markEventIntentPublished("event-1"); + + expect(update).toHaveBeenCalledWith(tenantHealthEventIntents); + expect(set).toHaveBeenCalledWith({ publishedAt: expect.any(Date) }); + expect(where).toHaveBeenCalledTimes(1); + expect(fallbackUpdate).not.toHaveBeenCalled(); + }); }); +function createStore( + db: DrizzleHealthClient, + activeClient: DrizzleHealthClient | null = null, +): DrizzleHealthScoreStore { + const getClient = vi.fn().mockReturnValue(activeClient); + const txManager = { + getClient, + isInTransaction: vi.fn().mockReturnValue(activeClient !== null), + run: vi.fn(async (operation: () => Promise) => { + if (activeClient) return operation(); + return db.transaction(async (tx) => { + getClient.mockReturnValue(tx); + try { + return await operation(); + } finally { + getClient.mockReturnValue(null); + } + }); + }), + } as unknown as TxManager; + return new DrizzleHealthScoreStore(db, txManager); +} + function createTransaction( latestRows: readonly unknown[], insert: ReturnType | ((table: unknown) => unknown), onOrderBy?: (value: unknown) => void, ) { return vi.fn(async (run: (tx: DrizzleHealthClient) => Promise) => - run({ - execute: vi.fn().mockResolvedValue(undefined), - select: vi.fn().mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - orderBy: vi.fn((value: unknown) => { - onOrderBy?.(value); - return { limit: vi.fn().mockResolvedValue(latestRows) }; - }), + run(createTransactionClient(latestRows, insert, onOrderBy)), + ); +} + +function createTransactionClient( + latestRows: readonly unknown[], + insert: ReturnType | ((table: unknown) => unknown), + onOrderBy?: (value: unknown) => void, +): DrizzleHealthClient { + return { + execute: vi.fn().mockResolvedValue(undefined), + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + orderBy: vi.fn((value: unknown) => { + onOrderBy?.(value); + return { limit: vi.fn().mockResolvedValue(latestRows) }; }), }), }), - insert, - } as unknown as DrizzleHealthClient), - ); + }), + insert, + } as unknown as DrizzleHealthClient; } function createStatefulClient(): DrizzleHealthClient { diff --git a/packages/customer-health-drizzle/src/tests/DrizzleHealthScoreStorePostgres.spec.ts b/packages/customer-health-drizzle/src/tests/DrizzleHealthScoreStorePostgres.spec.ts new file mode 100644 index 0000000000..04701ed924 --- /dev/null +++ b/packages/customer-health-drizzle/src/tests/DrizzleHealthScoreStorePostgres.spec.ts @@ -0,0 +1,229 @@ +import "reflect-metadata"; +import { + CustomerHealthEventPublisher, + CustomerHealthService, + HealthScoreCalculator, +} from "@croco/customer-health-core"; +import type { + HealthScoreProfile, + HealthSignalRegistry, + TenantHealthScore, +} from "@croco/customer-health-core"; +import { Container } from "@croco/framework-context"; +import { TxManager } from "@croco/tx-core"; +import { createDrizzleTxAdapter } from "@croco/tx-drizzle"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { type DrizzleHealthClient, DrizzleHealthScoreStore } from "../libs/DrizzleHealthScoreStore"; + +const connectionString = process.env.CUSTOMER_HEALTH_POSTGRES_URL ?? ""; + +describe.skipIf(connectionString.length === 0)( + "DrizzleHealthScoreStore PostgreSQL transactions", + () => { + let pool!: Pool; + let store!: DrizzleHealthScoreStore; + let txManager!: TxManager; + + beforeAll(async () => { + pool = new Pool({ connectionString, max: 4 }); + const client = drizzle(pool) as unknown as DrizzleHealthClient; + txManager = new TxManager(createDrizzleTxAdapter(client)); + store = new DrizzleHealthScoreStore(client, txManager); + + await pool.query(` + CREATE TABLE IF NOT EXISTS tenant_health_scores ( + transition_sequence bigserial PRIMARY KEY, + tenant_id text NOT NULL, + overall_score double precision NOT NULL, + status text NOT NULL, + category_scores jsonb NOT NULL, + signals jsonb NOT NULL, + trend text NOT NULL, + previous_score double precision, + calculated_at timestamp NOT NULL + ) + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS tenant_health_event_intents ( + event_id text PRIMARY KEY, + tenant_id text NOT NULL, + transition_sequence bigint NOT NULL, + intent_order integer NOT NULL, + occurred_at timestamp with time zone NOT NULL, + data jsonb NOT NULL, + published_at timestamp with time zone, + created_at timestamp with time zone NOT NULL DEFAULT now() + ) + `); + }); + + beforeEach(async () => { + Container.reset(); + await pool.query( + "TRUNCATE TABLE tenant_health_event_intents, tenant_health_scores RESTART IDENTITY", + ); + }); + + afterAll(async () => { + await pool.end(); + }); + + it("rolls back a transition with the caller transaction", async () => { + const rollback = new Error("rollback caller transaction"); + const score = createScore("tenant-rollback"); + + await expect( + txManager.run(async () => { + await expect(store.saveTransition(score, null, [])).resolves.toEqual({ + committed: true, + eventPublicationDeferred: true, + }); + await expect(store.findLatest(score.tenantId)).resolves.toMatchObject({ + tenantId: score.tenantId, + }); + expect(score.transitionVersion).toBe("1"); + throw rollback; + }), + ).rejects.toBe(rollback); + + expect(score.transitionVersion).toBe("1"); + await expect(store.findLatest(score.tenantId)).resolves.toBeNull(); + + const retry = createScore(score.tenantId, 70, "at_risk", "2026-09-22T01:00:00.000Z"); + await expect(store.saveTransition(retry, score, [])).resolves.toEqual({ + committed: false, + latest: null, + }); + await expect(store.saveTransition(retry, null, [])).resolves.toEqual({ committed: true }); + }); + + it("keeps ambient transition versions usable through and after commit", async () => { + const first = createScore("tenant-chain", 82.5, "healthy", "2026-09-22T00:00:00.000Z"); + const second = createScore("tenant-chain", 72, "at_risk", "2026-09-22T01:00:00.000Z"); + + await txManager.run(async () => { + await expect(store.saveTransition(first, null, [])).resolves.toEqual({ + committed: true, + eventPublicationDeferred: true, + }); + await expect(store.saveTransition(second, first, [])).resolves.toEqual({ + committed: true, + eventPublicationDeferred: true, + }); + }); + + expect(first.transitionVersion).toBe("1"); + expect(second.transitionVersion).toBe("2"); + + const third = createScore("tenant-chain", 62, "at_risk", "2026-09-22T02:00:00.000Z"); + await expect(store.saveTransition(third, second, [])).resolves.toEqual({ committed: true }); + expect(third.transitionVersion).toBe("3"); + }); + + it("commits a transition in its own transaction outside an ambient transaction", async () => { + const score = createScore("tenant-independent"); + + await expect(store.saveTransition(score, null, [])).resolves.toEqual({ committed: true }); + + await expect(store.findLatest(score.tenantId)).resolves.toMatchObject({ + tenantId: score.tenantId, + overallScore: score.overallScore, + }); + }); + + it("holds the tenant advisory lock on the ambient transaction connection", async () => { + const score = createScore("tenant-lock"); + + await txManager.run(async () => { + await store.saveTransition(score, null, []); + const contender = await pool.connect(); + try { + const result = await contender.query<{ acquired: boolean }>( + "SELECT pg_try_advisory_xact_lock(hashtextextended($1, 0)) AS acquired", + [score.tenantId], + ); + expect(result.rows[0]?.acquired).toBe(false); + } finally { + contender.release(); + } + }); + }); + + it("does not publish transition events before the caller transaction commits", async () => { + const profile: HealthScoreProfile = { + id: "profile-1", + name: "Default Profile", + weights: { usage: 1, business: 0, engagement: 0 }, + thresholds: { healthy: 80, atRisk: 60 }, + }; + const publisher = { + publishIdempotently: vi.fn().mockResolvedValue(undefined), + } as unknown as CustomerHealthEventPublisher; + Container.set(CustomerHealthEventPublisher.token, publisher); + await new CustomerHealthService( + createSignalRegistry(90), + store, + new HealthScoreCalculator(), + ).calculateAndStore("tenant-events", profile); + vi.mocked(publisher.publishIdempotently).mockClear(); + const rollback = new Error("rollback event transition"); + + await expect( + txManager.run(async () => { + await new CustomerHealthService( + createSignalRegistry(50), + store, + new HealthScoreCalculator(), + ).calculateAndStore("tenant-events", profile); + expect(publisher.publishIdempotently).not.toHaveBeenCalled(); + throw rollback; + }), + ).rejects.toBe(rollback); + + expect(publisher.publishIdempotently).not.toHaveBeenCalled(); + await expect(store.findLatest("tenant-events")).resolves.toMatchObject({ + overallScore: 90, + }); + await expect(store.listPendingEventIntents("tenant-events")).resolves.toEqual([]); + }); + }, +); + +function createSignalRegistry(value: number): HealthSignalRegistry { + return { + getProviders: () => [ + { + category: "usage", + collect: async () => [ + { + category: "usage", + name: "api_calls", + value, + weight: 1, + rawValue: value, + collectedAt: new Date("2026-09-22T00:00:00.000Z"), + }, + ], + }, + ], + } as HealthSignalRegistry; +} + +function createScore( + tenantId: string, + overallScore = 82.5, + status: TenantHealthScore["status"] = "healthy", + calculatedAt = "2026-09-22T00:00:00.000Z", +): TenantHealthScore { + return { + tenantId, + overallScore, + status, + categoryScores: { usage: overallScore, business: overallScore, engagement: overallScore }, + signals: [], + trend: "stable", + calculatedAt: new Date(calculatedAt), + }; +} diff --git a/packages/customer-health-drizzle/src/tests/DrizzleProviderConformance.spec.ts b/packages/customer-health-drizzle/src/tests/DrizzleProviderConformance.spec.ts index 9fcd2f8201..30a05f1aa9 100644 --- a/packages/customer-health-drizzle/src/tests/DrizzleProviderConformance.spec.ts +++ b/packages/customer-health-drizzle/src/tests/DrizzleProviderConformance.spec.ts @@ -1,9 +1,10 @@ import { getTableColumns } from "drizzle-orm"; import { describe, expect, it, vi } from "vitest"; +import type { TenantHealthScore } from "@croco/customer-health-core"; import { ProblemFactory } from "@croco/problems-core"; import { createDrizzleProviderConformanceSuite } from "@croco/testing/drizzle"; -import { DrizzleHealthIndicator } from "@croco/tx-drizzle"; -import type { TenantHealthScore } from "@croco/customer-health-core"; +import { TxManager } from "@croco/tx-core"; +import { createDrizzleTxAdapter, DrizzleHealthIndicator } from "@croco/tx-drizzle"; import { DrizzleHealthScoreStore } from "../libs/DrizzleHealthScoreStore"; import { tenantHealthEventIntents, tenantHealthScores } from "../libs/schema"; @@ -25,6 +26,20 @@ const createHealthScoreRow = (tenantId: string, overallScore: number) => ({ calculatedAt: new Date("2026-01-01T00:00:00.000Z"), }); +const createHealthScore = (tenantId: string, overallScore: number): TenantHealthScore => ({ + tenantId, + overallScore, + status: "healthy", + categoryScores: { + usage: overallScore, + business: overallScore, + engagement: overallScore, + }, + signals: [], + trend: "stable", + calculatedAt: new Date("2026-01-01T00:00:00.000Z"), +}); + function collectSqlParamValues(value: unknown): unknown[] { if (!value || typeof value !== "object" || !("queryChunks" in value)) { return []; @@ -98,11 +113,12 @@ function createSelectClient(rowsByTenant: ReadonlyMap): Drizz function createRoundTripClient(): DrizzleHealthClient { let storedRows: unknown[] = []; + let transactionRows: unknown[] | null = null; const select = vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ orderBy: vi.fn().mockReturnValue({ - limit: vi.fn(async () => storedRows), + limit: vi.fn(async () => transactionRows ?? storedRows), }), }), }), @@ -112,7 +128,9 @@ function createRoundTripClient(): DrizzleHealthClient { select, insert: vi.fn().mockReturnValue({ values: vi.fn((row: unknown) => { - storedRows = [{ transitionSequence: BigInt(1), ...(row as object) }]; + const nextRows = [{ transitionSequence: BigInt(1), ...(row as object) }]; + if (transactionRows) transactionRows = nextRows; + else storedRows = nextRows; return { returning: vi.fn().mockResolvedValue([{ transitionSequence: BigInt(1) }]), }; @@ -121,13 +139,28 @@ function createRoundTripClient(): DrizzleHealthClient { }; return { - transaction: vi.fn(async (callback: (tx: typeof transactionClient) => unknown) => - callback(transactionClient), - ), + transaction: vi.fn(async (callback: (tx: typeof transactionClient) => unknown) => { + transactionRows = structuredClone(storedRows); + try { + const result = await callback(transactionClient); + storedRows = transactionRows; + return result; + } finally { + transactionRows = null; + } + }), select, } as unknown as DrizzleHealthClient; } +function createStore(db: DrizzleHealthClient) { + const txManager = new TxManager(createDrizzleTxAdapter(db)); + return { + store: new DrizzleHealthScoreStore(db, txManager), + txManager, + }; +} + describe("customer-health-drizzle provider conformance", () => { it.each( createDrizzleProviderConformanceSuite({ @@ -183,12 +216,43 @@ describe("customer-health-drizzle provider conformance", () => { }, transaction: { participation: { - supported: false, - reason: "DrizzleHealthScoreStore accepts a direct Drizzle client and no TxManager.", + supported: true, + checks: [ + { + name: "uses the active transaction for the advisory lock and transition write", + run: async () => { + const db = createRoundTripClient(); + const { store, txManager } = createStore(db); + + await txManager.run(() => + store.saveTransition(createHealthScore("tenant-active", 84), null, []), + ); + + expect(db.transaction).toHaveBeenCalledTimes(1); + }, + }, + ], }, rollback: { - supported: false, - reason: "Rollback is owned by the app-level Drizzle transaction boundary.", + supported: true, + checks: [ + { + name: "rolls back a transition with its caller transaction", + run: async () => { + const db = createRoundTripClient(); + const { store, txManager } = createStore(db); + const rollback = new Error("rollback"); + + await expect( + txManager.run(async () => { + await store.saveTransition(createHealthScore("tenant-rollback", 73), null, []); + throw rollback; + }), + ).rejects.toBe(rollback); + await expect(store.findLatest("tenant-rollback")).resolves.toBeNull(); + }, + }, + ], }, }, tenantIsolation: { @@ -197,7 +261,7 @@ describe("customer-health-drizzle provider conformance", () => { { name: "loads the latest health score through the tenant-scoped lookup", run: async () => { - const store = new DrizzleHealthScoreStore( + const { store } = createStore( createSelectClient( new Map([ ["tenant-a", [createHealthScoreRow("tenant-a", 92)]], @@ -228,7 +292,7 @@ describe("customer-health-drizzle provider conformance", () => { previousScore: 81.25, calculatedAt: new Date("2026-01-01T00:00:00.000Z"), }; - const store = new DrizzleHealthScoreStore(createRoundTripClient()); + const { store } = createStore(createRoundTripClient()); await store.saveTransition(score, null, []); const reloaded = await store.findLatest("tenant-fractional"); @@ -246,7 +310,7 @@ describe("customer-health-drizzle provider conformance", () => { { name: "returns null when no tenant health score exists", run: async () => { - const store = new DrizzleHealthScoreStore(createSelectClient(new Map())); + const { store } = createStore(createSelectClient(new Map())); await expect(store.findLatest("tenant-missing")).resolves.toBeNull(); }, diff --git a/packages/docs/src/content/docs/api/customer-health-core/src/classes/HealthScoreStore.md b/packages/docs/src/content/docs/api/customer-health-core/src/classes/HealthScoreStore.md index 83a55c7d38..094a35c3b3 100644 --- a/packages/docs/src/content/docs/api/customer-health-core/src/classes/HealthScoreStore.md +++ b/packages/docs/src/content/docs/api/customer-health-core/src/classes/HealthScoreStore.md @@ -130,7 +130,12 @@ title: "HealthScoreStore" ### saveTransition() -> `abstract` **saveTransition**(`score`, `previous`, `eventIntents`): `Promise`\<\{ `committed`: `true`; \} \| \{ `committed`: `false`; `latest`: [`TenantHealthScore`](/api/customer-health-core/src/type-aliases/tenanthealthscore/) \| `null`; \}\> +> `abstract` **saveTransition**(`score`, `previous`, `eventIntents`): `Promise`\<[`HealthTransitionCommitResult`](/api/customer-health-core/src/type-aliases/healthtransitioncommitresult/)\> + +Persists one optimistic transition and assigns its CAS version to `score`. +A version assigned inside a caller-owned transaction is provisional until that transaction +commits. Discard affected snapshots after rollback or an unknown transaction outcome, then +reload the latest committed score before retrying. #### Parameters @@ -148,4 +153,4 @@ readonly [`HealthTransitionEventIntent`](/api/customer-health-core/src/type-alia #### Returns -`Promise`\<\{ `committed`: `true`; \} \| \{ `committed`: `false`; `latest`: [`TenantHealthScore`](/api/customer-health-core/src/type-aliases/tenanthealthscore/) \| `null`; \}\> +`Promise`\<[`HealthTransitionCommitResult`](/api/customer-health-core/src/type-aliases/healthtransitioncommitresult/)\> diff --git a/packages/docs/src/content/docs/api/customer-health-core/src/classes/InMemoryHealthScoreStore.md b/packages/docs/src/content/docs/api/customer-health-core/src/classes/InMemoryHealthScoreStore.md index 842228858f..b08c0513b0 100644 --- a/packages/docs/src/content/docs/api/customer-health-core/src/classes/InMemoryHealthScoreStore.md +++ b/packages/docs/src/content/docs/api/customer-health-core/src/classes/InMemoryHealthScoreStore.md @@ -157,7 +157,12 @@ title: "InMemoryHealthScoreStore" ### saveTransition() -> **saveTransition**(`score`, `previous`, `eventIntents`): `Promise`\<\{ `committed`: `true`; \} \| \{ `committed`: `false`; `latest`: [`TenantHealthScore`](/api/customer-health-core/src/type-aliases/tenanthealthscore/) \| `null`; \}\> +> **saveTransition**(`score`, `previous`, `eventIntents`): `Promise`\<[`HealthTransitionCommitResult`](/api/customer-health-core/src/type-aliases/healthtransitioncommitresult/)\> + +Persists one optimistic transition and assigns its CAS version to `score`. +A version assigned inside a caller-owned transaction is provisional until that transaction +commits. Discard affected snapshots after rollback or an unknown transaction outcome, then +reload the latest committed score before retrying. #### Parameters @@ -175,7 +180,7 @@ readonly [`HealthTransitionEventIntent`](/api/customer-health-core/src/type-alia #### Returns -`Promise`\<\{ `committed`: `true`; \} \| \{ `committed`: `false`; `latest`: [`TenantHealthScore`](/api/customer-health-core/src/type-aliases/tenanthealthscore/) \| `null`; \}\> +`Promise`\<[`HealthTransitionCommitResult`](/api/customer-health-core/src/type-aliases/healthtransitioncommitresult/)\> #### Overrides diff --git a/packages/docs/src/content/docs/api/customer-health-core/src/type-aliases/HealthTransitionCommitResult.md b/packages/docs/src/content/docs/api/customer-health-core/src/type-aliases/HealthTransitionCommitResult.md new file mode 100644 index 0000000000..d1be64b0c6 --- /dev/null +++ b/packages/docs/src/content/docs/api/customer-health-core/src/type-aliases/HealthTransitionCommitResult.md @@ -0,0 +1,30 @@ +--- +editUrl: false +next: false +prev: false +title: "HealthTransitionCommitResult" +--- + +> **HealthTransitionCommitResult** = \{ `committed`: `true`; `eventPublicationDeferred?`: `true`; \} \| \{ `committed`: `false`; `latest`: [`TenantHealthScore`](/api/customer-health-core/src/type-aliases/tenanthealthscore/) \| `null`; \} + +## Union Members + +### Type Literal + +\{ `committed`: `true`; `eventPublicationDeferred?`: `true`; \} + +#### committed + +> `readonly` **committed**: `true` + +#### eventPublicationDeferred? + +> `readonly` `optional` **eventPublicationDeferred?**: `true` + +The transition joined a caller-owned transaction and its events must remain pending. + +--- + +### Type Literal + +\{ `committed`: `false`; `latest`: [`TenantHealthScore`](/api/customer-health-core/src/type-aliases/tenanthealthscore/) \| `null`; \} diff --git a/packages/docs/src/content/docs/api/customer-health-core/src/type-aliases/TenantHealthScore.md b/packages/docs/src/content/docs/api/customer-health-core/src/type-aliases/TenantHealthScore.md index 9595e5e467..9e8edce0ba 100644 --- a/packages/docs/src/content/docs/api/customer-health-core/src/type-aliases/TenantHealthScore.md +++ b/packages/docs/src/content/docs/api/customer-health-core/src/type-aliases/TenantHealthScore.md @@ -55,6 +55,10 @@ title: "TenantHealthScore" > `optional` **transitionVersion?**: `string` +Optimistic concurrency token assigned by the store. A token obtained inside a caller-owned +transaction is provisional until commit and must not be reused after rollback or an unknown +transaction outcome. + --- ### trend diff --git a/packages/docs/src/content/docs/api/customer-health-drizzle/src/classes/DrizzleHealthScoreStore.md b/packages/docs/src/content/docs/api/customer-health-drizzle/src/classes/DrizzleHealthScoreStore.md index 2c6291ccef..40d10bbdc7 100644 --- a/packages/docs/src/content/docs/api/customer-health-drizzle/src/classes/DrizzleHealthScoreStore.md +++ b/packages/docs/src/content/docs/api/customer-health-drizzle/src/classes/DrizzleHealthScoreStore.md @@ -15,9 +15,9 @@ title: "DrizzleHealthScoreStore" ### Constructor -> **new DrizzleHealthScoreStore**(`db`): `DrizzleHealthScoreStore` +> **new DrizzleHealthScoreStore**(`db`, `txManager`): `DrizzleHealthScoreStore` -Drizzle 클라이언트를 받아 저장소를 초기화합니다. +Drizzle 클라이언트와 트랜잭션 매니저를 받아 저장소를 초기화합니다. #### Parameters @@ -25,6 +25,10 @@ Drizzle 클라이언트를 받아 저장소를 초기화합니다. [`DrizzleHealthClient`](/api/customer-health-drizzle/src/type-aliases/drizzlehealthclient/) +##### txManager + +[`TxManager`](/api/tx-core/src/classes/txmanager/)\<[`DrizzleHealthClient`](/api/customer-health-drizzle/src/type-aliases/drizzlehealthclient/)\> + #### Returns `DrizzleHealthScoreStore` @@ -173,7 +177,7 @@ Drizzle 클라이언트를 받아 저장소를 초기화합니다. ### saveTransition() -> **saveTransition**(`score`, `previous`, `eventIntents`): `Promise`\<\{ `committed`: `true`; \} \| \{ `committed`: `false`; `latest`: [`TenantHealthScore`](/api/customer-health-core/src/type-aliases/tenanthealthscore/) \| `null`; \}\> +> **saveTransition**(`score`, `previous`, `eventIntents`): `Promise`\<[`HealthTransitionCommitResult`](/api/customer-health-core/src/type-aliases/healthtransitioncommitresult/)\> 계산된 건강 점수를 저장합니다. @@ -193,7 +197,7 @@ readonly [`HealthTransitionEventIntent`](/api/customer-health-core/src/type-alia #### Returns -`Promise`\<\{ `committed`: `true`; \} \| \{ `committed`: `false`; `latest`: [`TenantHealthScore`](/api/customer-health-core/src/type-aliases/tenanthealthscore/) \| `null`; \}\> +`Promise`\<[`HealthTransitionCommitResult`](/api/customer-health-core/src/type-aliases/healthtransitioncommitresult/)\> #### Overrides diff --git a/packages/docs/src/content/docs/api/customer-health-drizzle/src/type-aliases/DrizzleHealthClient.md b/packages/docs/src/content/docs/api/customer-health-drizzle/src/type-aliases/DrizzleHealthClient.md index a93598d819..3b710fb987 100644 --- a/packages/docs/src/content/docs/api/customer-health-drizzle/src/type-aliases/DrizzleHealthClient.md +++ b/packages/docs/src/content/docs/api/customer-health-drizzle/src/type-aliases/DrizzleHealthClient.md @@ -5,6 +5,6 @@ prev: false title: "DrizzleHealthClient" --- -> **DrizzleHealthClient** = `NodePgDatabase`\<`Record`\<`string`, `never`\>\> +> **DrizzleHealthClient** = [`DrizzleDb`](/api/tx-drizzle/src/interfaces/drizzledb/) & `NodePgDatabase`\<`Record`\<`string`, `never`\>\> 건강 점수 저장소에서 사용하는 Drizzle 클라이언트 타입입니다. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3669de81b4..e346f22bf1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1111,6 +1111,9 @@ importers: '@croco/problems-core': specifier: workspace:* version: link:../problems-core + '@croco/tx-core': + specifier: workspace:* + version: link:../tx-core '@croco/tx-drizzle': specifier: workspace:* version: link:../tx-drizzle @@ -1118,9 +1121,15 @@ importers: '@croco/testing': specifier: workspace:* version: link:../testing + '@types/pg': + specifier: 8.20.0 + version: 8.20.0 drizzle-orm: specifier: 'catalog:' - version: 0.45.2(@cloudflare/workers-types@4.20260316.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(@upstash/redis@1.36.1)(better-sqlite3@11.10.0)(kysely@0.28.17)(pg@8.20.0) + version: 0.45.2(@cloudflare/workers-types@4.20260316.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(@upstash/redis@1.36.1)(better-sqlite3@11.10.0)(kysely@0.28.17)(pg@8.22.0) + pg: + specifier: 8.22.0 + version: 8.22.0 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 diff --git a/public-api-surface.snapshot.json b/public-api-surface.snapshot.json index 7b89abe1be..c8f212fa1c 100644 --- a/public-api-surface.snapshot.json +++ b/public-api-surface.snapshot.json @@ -8407,6 +8407,12 @@ "source": "./libs/types", "declarationKind": "type" }, + { + "name": "HealthTransitionCommitResult", + "exportKind": "declaration", + "source": "./libs/interfaces", + "declarationKind": "type" + }, { "name": "HealthTrend", "exportKind": "declaration", diff --git a/scripts/tests/ci-workflow.spec.ts b/scripts/tests/ci-workflow.spec.ts index d6f056a789..11bb9ef62d 100644 --- a/scripts/tests/ci-workflow.spec.ts +++ b/scripts/tests/ci-workflow.spec.ts @@ -884,6 +884,9 @@ describe("CI verification profile contract", () => { expect(REAL_RESOURCE_JOB).toContain( "CREDITS_POSTGRES_URL: postgresql://postgres:postgres@127.0.0.1:5432/croco_membership", ); + expect(REAL_RESOURCE_JOB).toContain( + "CUSTOMER_HEALTH_POSTGRES_URL: postgresql://postgres:postgres@127.0.0.1:5432/croco_membership", + ); expect(REAL_RESOURCE_JOB).toContain( "ENTITLEMENTS_POSTGRES_URL: postgresql://postgres:postgres@127.0.0.1:5432/croco_membership", ); @@ -897,6 +900,10 @@ describe("CI verification profile contract", () => { "MEMBERSHIP_POSTGRES_URL: postgresql://postgres:postgres@127.0.0.1:5432/croco_membership", ); expect(REAL_RESOURCE_JOB).toContain("pnpm build --filter=@croco/credits-drizzle..."); + expect(REAL_RESOURCE_JOB).toContain("pnpm build --filter=@croco/customer-health-drizzle..."); + expect(REAL_RESOURCE_JOB).toContain( + "pnpm --filter @croco/customer-health-drizzle test:postgres", + ); expect(REAL_RESOURCE_JOB).toContain( "METERING_POSTGRES_URL: postgresql://postgres:postgres@127.0.0.1:5432/croco_membership", ); @@ -927,6 +934,11 @@ describe("CI verification profile contract", () => { expect(WORKFLOW).toContain(" - 'packages/credits-drizzle/src/**'"); }); + it("routes customer health persistence changes to the real PostgreSQL transaction suite", () => { + expect(WORKFLOW).toContain(" - 'packages/customer-health-core/**'"); + expect(WORKFLOW).toContain(" - 'packages/customer-health-drizzle/**'"); + }); + it("routes metering persistence changes to the real PostgreSQL round-trip suite", () => { expect(WORKFLOW).toContain(" - 'packages/metering-drizzle/**'"); }); diff --git a/scripts/workflow-verification-contract.mts b/scripts/workflow-verification-contract.mts index 6583044082..17f3aac896 100644 --- a/scripts/workflow-verification-contract.mts +++ b/scripts/workflow-verification-contract.mts @@ -53,6 +53,7 @@ export const ACTIONS_ONLY_WORKFLOW_COMMAND_ALLOWLIST = [ "pnpm audit:prod", "pnpm build --filter=@croco/auth-drizzle...", "pnpm build --filter=@croco/credits-drizzle...", + "pnpm build --filter=@croco/customer-health-drizzle...", "pnpm build --filter=@croco/engagement-drizzle...", "pnpm build --filter=@croco/entitlements-drizzle...", "pnpm build --filter=@croco/execution-drizzle...", @@ -66,6 +67,7 @@ export const ACTIONS_ONLY_WORKFLOW_COMMAND_ALLOWLIST = [ "pnpm turbo run test", "pnpm --filter @croco/auth-drizzle exec vitest run src/tests/DrizzleApiKeyStore.postgres.spec.ts", "pnpm --filter @croco/credits-drizzle test:postgres", + "pnpm --filter @croco/customer-health-drizzle test:postgres", "pnpm --filter @croco/engagement-drizzle test:postgres", "pnpm --filter @croco/entitlements-drizzle test:postgres", "pnpm --filter @croco/execution-drizzle test:postgres", diff --git a/test-inventory.json b/test-inventory.json index 1f98975cd3..fc11ec6cdc 100644 --- a/test-inventory.json +++ b/test-inventory.json @@ -1238,6 +1238,12 @@ "qualifiers": [], "owner": "@croco/customer-health-drizzle" }, + { + "path": "packages/customer-health-drizzle/src/tests/DrizzleHealthScoreStorePostgres.spec.ts", + "lane": "live", + "qualifiers": [], + "owner": "@croco/customer-health-drizzle" + }, { "path": "packages/customer-health-drizzle/src/tests/DrizzleProviderConformance.spec.ts", "lane": "fast",