fix(rewards): replace in-memory stores with Prisma persistence - #15
Merged
merlik787-droi merged 1 commit intoAug 19, 2026
Merged
Conversation
Replaces the four module-level in-memory stores (claimedRewards, transactions, referralCodes, pendingWithdrawals) in RewardService with durable Prisma reads and writes. RewardService is now stateless — constructing a new instance in any process sees the same data. Schema changes (migration 20260819000002_persist_reward_service): - Transaction: add moduleId (String?), stellarTxHash (String?), completedAt (DateTime?) to hold the full reward ledger durably - RewardClaim: new model with @@unique([userId, moduleId]) providing a database-level double-claim guard across replicas Service changes: - claimReward: atomic RewardClaim.create (P2002 → already-claimed error), pending Transaction row persisted BEFORE Stellar call (outbox pattern), status flipped to completed/failed after payout - processWithdrawal: same outbox pattern — pending row first, then Stellar, then completed/failed with stellarTxHash - getBalance, getTransactionHistory: derived from Prisma rows - hasAlreadyClaimed: reads RewardClaim table (async) - _resetState() test hook: removed entirely - registerReferralCode: removed (referral codes are tracked via the Referral/ReferralCode models managed by referral.controller) Controller changes: - reward.controller.ts: await the now-async getBalance, getTransactionHistory, hasSufficientBalance service methods Test changes: - reward.service.test.ts: fully rewritten to mock Prisma client; covers outbox ordering, P2002 double-claim, Stellar failure path, balance derivation, pagination, and withdrawal patterns - reward.controller.test.ts: mockReturnValue → mockResolvedValue for async service methods; async flush added where asyncHandler's floating promise needs draining Documentation: - docs/ROADMAP.md: marks in-memory-store replacement item complete Closes Kqirox#5
7 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #5
Replaces all four module-level in-memory stores in
RewardServicewith durable Prisma reads and writes.RewardServiceis now completely stateless — constructing a new instance in any process sees the same persisted data. The most important design decision is the outbox pattern: apendingTransaction row is persisted to Postgres before calling the Stellar network, so a crash between payment submission and row commit leaves a recoverablependingrecord rather than lost state.Why
The codebase contained four module-level mutable stores:
Every process restart discarded all reward history. Two replicas serving the same user could both pass the
assertNotAlreadyClaimedcheck (a Map lookup in each process) and both trigger Stellar payouts for the same module. The module-completion path already wrote durableprisma.transactionrows, but the rewards HTTP surface read only from the in-memory store — these were two completely separate ledgers that never converged.What was built
src/services/reward.service.tsclaimRewardandprocessWithdrawal;_resetState()removedsrc/controllers/reward.controller.tsawaitadded togetBalance,getTransactionHistory,hasSufficientBalance(now async)prisma/schema.prismaTransactiongainsmoduleId,stellarTxHash,completedAt; newRewardClaimmodel with@@unique([userId, moduleId])prisma/migrations/20260819000002_persist_reward_service/migration.sqlALTER TABLE transactions ADD COLUMN+CREATE TABLE reward_claimswith unique constrainttests/unit/reward.service.test.tstests/unit/reward.controller.test.tsmockReturnValue→mockResolvedValuefor async methods; async flush added for asyncHandler timingdocs/ROADMAP.mdIntegration changes outside module
src/controllers/reward.controller.ts— three synchronous service calls changed toawait; unavoidable because the service API is now async.prisma/schema.prisma— schema extended; requirespnpm db:migrateon deploy.Acceptance criteria coverage
claimRewardis visible ingetTransactionHistoryandgetBalanceafter a freshRewardServiceinstance is constructed in the same process (tests/unit/reward.service.test.ts—getBalanceandgetTransactionHistorysuites read from mocked Prisma rows written byclaimReward)claimRewardcalls for the same(userId, moduleId)produce exactly one payout and one recorded transaction — enforced at the database level by@@unique([userId, moduleId])onRewardClaim; P2002 path tested in "claimReward – double-claim prevention" suiteRewardServiceinstance reads the same rowsprocessWithdrawalrecords the transaction aspendingbefore submitting the Stellar payment, and flips it tocompletedorfailedwith thestellarTxHash(tests/unit/reward.service.test.ts— "creates a pending Transaction before calling Stellar" and "flips status to completed with stellarTxHash" tests)tests/unit/reward.service.test.tsusesvi.mock('../../src/config/database', ...);vi.clearAllMocks()inbeforeEachis sufficient isolation)_resetState()test hook is removed (src/services/reward.service.ts— method deleted entirely)docs/ROADMAP.mdmarks the in-memory-store item as completeDeliberately deferred
pendingrow recoverable by a reconciliation job, but the scheduler/worker loop is out of scope for this issue.registerReferralCodewas removed because the project already hasReferralCode/ReferralPrisma models managed byreferral.controller.ts. Wiring rewards to that table is a follow-on.walletAddresslookup mechanism that doesn't yet exist.Test plan
pnpm test:ci— 279/279 passing (32 new tests inreward.service.test.ts)pnpm build(tsc) — no type errorspnpm lint— no new errors or warningsDATABASE_URL=... npx prisma generate— Prisma client generated with newRewardClaimmodel and extendedTransactionEnv vars / Notes
No new env vars. Migration must be applied before deploy:
The migration adds columns with
IF NOT EXISTSand creates a new table — safe to apply to a live database. Existing transaction rows retain their data; the new columns default toNULL.