From df56301311cc262e21a7a5dea4b355da18550975 Mon Sep 17 00:00:00 2001 From: 0-sayed Date: Sun, 12 Apr 2026 06:38:05 +0200 Subject: [PATCH 01/16] feat: add ledger_totals table and wallets.fractional_balance column Adds fractional_balance integer column to wallets (with non-negative CHECK) to support centi-cent royalty accrual. Adds ledger_totals table keyed by ledger_type for O(1) report lookups. Migration includes backfill from existing ledger rows. --- drizzle/0001_modern_rogue.sql | 12 + drizzle/meta/0001_snapshot.json | 468 ++++++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/common/database/schema.ts | 11 + 4 files changed, 498 insertions(+) create mode 100644 drizzle/0001_modern_rogue.sql create mode 100644 drizzle/meta/0001_snapshot.json diff --git a/drizzle/0001_modern_rogue.sql b/drizzle/0001_modern_rogue.sql new file mode 100644 index 0000000..cbc466d --- /dev/null +++ b/drizzle/0001_modern_rogue.sql @@ -0,0 +1,12 @@ +CREATE TABLE "ledger_totals" ( + "type" "ledger_type" PRIMARY KEY NOT NULL, + "total" bigint DEFAULT 0 NOT NULL +); +--> statement-breakpoint +ALTER TABLE "wallets" ADD COLUMN "fractional_balance" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "wallets" ADD CONSTRAINT "wallets_fractional_balance_non_negative" CHECK ("wallets"."fractional_balance" >= 0); + +-- Backfill running totals from existing ledger data +INSERT INTO ledger_totals (type, total) +SELECT type, COALESCE(SUM(amount), 0) FROM ledger GROUP BY type +ON CONFLICT (type) DO NOTHING; \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..c77a40c --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,468 @@ +{ + "id": "31d2e6f2-3e9e-42b1-ad72-2d9d2c0855ae", + "prevId": "cedfa5bc-7b7d-4f53-b7ef-699262bbc408", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ledger": { + "name": "ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "wallet_id": { + "name": "wallet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "ledger_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "ledger_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "purchase_id": { + "name": "purchase_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ledger_wallet_id_wallets_id_fk": { + "name": "ledger_wallet_id_wallets_id_fk", + "tableFrom": "ledger", + "tableTo": "wallets", + "columnsFrom": [ + "wallet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "ledger_purchase_id_purchases_id_fk": { + "name": "ledger_purchase_id_purchases_id_fk", + "tableFrom": "ledger", + "tableTo": "purchases", + "columnsFrom": [ + "purchase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ledger_amount_positive": { + "name": "ledger_amount_positive", + "value": "\"ledger\".\"amount\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.ledger_totals": { + "name": "ledger_totals", + "schema": "", + "columns": { + "type": { + "name": "type", + "type": "ledger_type", + "typeSchema": "public", + "primaryKey": true, + "notNull": true + }, + "total": { + "name": "total", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.purchases": { + "name": "purchases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "buyer_wallet_id": { + "name": "buyer_wallet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_wallet_id": { + "name": "author_wallet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_price": { + "name": "item_price", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "purchase_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "purchases_idempotency_key_idx": { + "name": "purchases_idempotency_key_idx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "purchases_buyer_wallet_id_wallets_id_fk": { + "name": "purchases_buyer_wallet_id_wallets_id_fk", + "tableFrom": "purchases", + "tableTo": "wallets", + "columnsFrom": [ + "buyer_wallet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "purchases_author_wallet_id_wallets_id_fk": { + "name": "purchases_author_wallet_id_wallets_id_fk", + "tableFrom": "purchases", + "tableTo": "wallets", + "columnsFrom": [ + "author_wallet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "purchases_item_price_positive": { + "name": "purchases_item_price_positive", + "value": "\"purchases\".\"item_price\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "report_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "requested_by": { + "name": "requested_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reports_requested_by_users_id_fk": { + "name": "reports_requested_by_users_id_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": [ + "requested_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallets": { + "name": "wallets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "fractional_balance": { + "name": "fractional_balance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallets_user_id_idx": { + "name": "wallets_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallets_user_id_users_id_fk": { + "name": "wallets_user_id_users_id_fk", + "tableFrom": "wallets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "wallets_balance_non_negative": { + "name": "wallets_balance_non_negative", + "value": "\"wallets\".\"balance\" >= 0" + }, + "wallets_fractional_balance_non_negative": { + "name": "wallets_fractional_balance_non_negative", + "value": "\"wallets\".\"fractional_balance\" >= 0" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.ledger_direction": { + "name": "ledger_direction", + "schema": "public", + "values": [ + "credit", + "debit" + ] + }, + "public.ledger_type": { + "name": "ledger_type", + "schema": "public", + "values": [ + "deposit", + "purchase", + "royalty_author", + "royalty_platform" + ] + }, + "public.purchase_status": { + "name": "purchase_status", + "schema": "public", + "values": [ + "pending", + "completed", + "failed" + ] + }, + "public.report_status": { + "name": "report_status", + "schema": "public", + "values": [ + "queued", + "processing", + "completed", + "failed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index ae99514..85e449c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1772260136654, "tag": "0000_known_ironclad", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1775968666133, + "tag": "0001_modern_rogue", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/common/database/schema.ts b/src/common/database/schema.ts index 8d1189c..5112883 100644 --- a/src/common/database/schema.ts +++ b/src/common/database/schema.ts @@ -3,6 +3,7 @@ import { uuid, text, integer, + bigint, timestamp, pgEnum, uniqueIndex, @@ -50,6 +51,7 @@ export const wallets = pgTable( .references(() => users.id) .notNull(), balance: integer('balance').notNull().default(0), + fractionalBalance: integer('fractional_balance').notNull().default(0), updatedAt: timestamp('updated_at') .defaultNow() .notNull() @@ -58,6 +60,10 @@ export const wallets = pgTable( (table) => [ uniqueIndex('wallets_user_id_idx').on(table.userId), check('wallets_balance_non_negative', sql`${table.balance} >= 0`), + check( + 'wallets_fractional_balance_non_negative', + sql`${table.fractionalBalance} >= 0`, + ), ], ); @@ -108,3 +114,8 @@ export const reports = pgTable('reports', { createdAt: timestamp('created_at').defaultNow().notNull(), completedAt: timestamp('completed_at'), }); + +export const ledgerTotals = pgTable('ledger_totals', { + type: ledgerTypeEnum('type').primaryKey(), + total: bigint('total', { mode: 'number' }).notNull().default(0), +}); From 9fb1bcfa30b0576a8c9e4fdfff8095d681eb01d0 Mon Sep 17 00:00:00 2001 From: 0-sayed Date: Sun, 12 Apr 2026 06:41:12 +0200 Subject: [PATCH 02/16] docs: document bigint mode:number safety bound in ledger_totals schema --- drizzle/0001_modern_rogue.sql | 2 +- src/common/database/schema.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/drizzle/0001_modern_rogue.sql b/drizzle/0001_modern_rogue.sql index cbc466d..e6dc100 100644 --- a/drizzle/0001_modern_rogue.sql +++ b/drizzle/0001_modern_rogue.sql @@ -9,4 +9,4 @@ ALTER TABLE "wallets" ADD CONSTRAINT "wallets_fractional_balance_non_negative" C -- Backfill running totals from existing ledger data INSERT INTO ledger_totals (type, total) SELECT type, COALESCE(SUM(amount), 0) FROM ledger GROUP BY type -ON CONFLICT (type) DO NOTHING; \ No newline at end of file +ON CONFLICT (type) DO NOTHING; diff --git a/src/common/database/schema.ts b/src/common/database/schema.ts index 5112883..eb85f1b 100644 --- a/src/common/database/schema.ts +++ b/src/common/database/schema.ts @@ -117,5 +117,6 @@ export const reports = pgTable('reports', { export const ledgerTotals = pgTable('ledger_totals', { type: ledgerTypeEnum('type').primaryKey(), + // mode:'number' is safe: max expected total ~100B << Number.MAX_SAFE_INTEGER (~9e15) total: bigint('total', { mode: 'number' }).notNull().default(0), }); From 723607790b9c97a9ff70aa9f532cc42fbe924404 Mon Sep 17 00:00:00 2001 From: 0-sayed Date: Sun, 12 Apr 2026 06:43:32 +0200 Subject: [PATCH 03/16] feat: replace ledger GROUP BY scan with O(1) ledger_totals SELECT --- src/reports/reports.processor.spec.ts | 31 +++++++++++++++++++++++---- src/reports/reports.processor.ts | 26 ++++++++-------------- 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/src/reports/reports.processor.spec.ts b/src/reports/reports.processor.spec.ts index e9bfa0c..a57e4c4 100644 --- a/src/reports/reports.processor.spec.ts +++ b/src/reports/reports.processor.spec.ts @@ -9,10 +9,10 @@ describe('ReportsProcessor', () => { let mockDb: any; const mockAggregates = { - totalDeposited: '15000', - totalPurchaseVolume: '8000', - totalRoyaltiesPaid: '5600', - platformRevenue: '2400', + totalDeposited: 15000, + totalPurchaseVolume: 8000, + totalRoyaltiesPaid: 5600, + platformRevenue: 2400, generatedAt: expect.any(String), }; @@ -33,6 +33,29 @@ describe('ReportsProcessor', () => { processor = module.get(ReportsProcessor); }); + it('reads totals from ledger_totals (O(1) — no GROUP BY scan)', async () => { + const totalsRows = [ + { type: 'deposit', total: 15000 }, + { type: 'purchase', total: 8000 }, + { type: 'royalty_author', total: 5600 }, + { type: 'royalty_platform', total: 2400 }, + ]; + // Override from() to resolve with totals rows for this test + mockDb.from.mockResolvedValueOnce(totalsRows); + + const result = await (processor as any).aggregate(); + + expect(result).toEqual({ + totalDeposited: 15000, + totalPurchaseVolume: 8000, + totalRoyaltiesPaid: 5600, + platformRevenue: 2400, + generatedAt: expect.any(String), + }); + // groupBy must not have been called — we no longer scan the ledger + expect(mockDb.groupBy).toBeUndefined(); + }); + it('transitions report from queued to processing to completed with payload', async () => { jest.spyOn(processor as any, 'aggregate').mockResolvedValue(mockAggregates); diff --git a/src/reports/reports.processor.ts b/src/reports/reports.processor.ts index f2c8da7..9d37db5 100644 --- a/src/reports/reports.processor.ts +++ b/src/reports/reports.processor.ts @@ -2,7 +2,7 @@ import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Inject, Logger } from '@nestjs/common'; import { Job } from 'bullmq'; -import { eq, sum } from 'drizzle-orm'; +import { eq } from 'drizzle-orm'; import type { AppDatabase } from '../common/database/db.module'; import { DB } from '../common/database/db.module'; import * as schema from '../common/database/schema'; @@ -56,28 +56,20 @@ export class ReportsProcessor extends WorkerHost { } } - // Single GROUP BY query inside a repeatable-read transaction so all sums - // reflect a consistent ledger snapshot in one round-trip. + // O(1) read from ledger_totals (maintained by triggers) inside a + // repeatable-read transaction for a consistent snapshot. private async aggregate() { return this.db.transaction( async (tx) => { - const rows = await tx - .select({ - type: schema.ledger.type, - total: sum(schema.ledger.amount), - }) - .from(schema.ledger) - .groupBy(schema.ledger.type); + const rows = await tx.select().from(schema.ledgerTotals); - const byType = Object.fromEntries( - rows.map((r) => [r.type, r.total ?? '0']), - ); + const byType = Object.fromEntries(rows.map((r) => [r.type, r.total])); return { - totalDeposited: byType['deposit'] ?? '0', - totalPurchaseVolume: byType['purchase'] ?? '0', - totalRoyaltiesPaid: byType['royalty_author'] ?? '0', - platformRevenue: byType['royalty_platform'] ?? '0', + totalDeposited: byType['deposit'] ?? 0, + totalPurchaseVolume: byType['purchase'] ?? 0, + totalRoyaltiesPaid: byType['royalty_author'] ?? 0, + platformRevenue: byType['royalty_platform'] ?? 0, generatedAt: new Date().toISOString(), }; }, From 1a02ddeb69a31d7c7bace551301ed7717dfb9ba7 Mon Sep 17 00:00:00 2001 From: 0-sayed Date: Sun, 12 Apr 2026 06:46:47 +0200 Subject: [PATCH 04/16] test: assert from() called with ledgerTotals in aggregate test --- src/reports/reports.processor.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/reports/reports.processor.spec.ts b/src/reports/reports.processor.spec.ts index a57e4c4..e96d1be 100644 --- a/src/reports/reports.processor.spec.ts +++ b/src/reports/reports.processor.spec.ts @@ -3,6 +3,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Job } from 'bullmq'; import { ReportsProcessor } from './reports.processor'; import { DB } from '../common/database/db.module'; +import * as schema from '../common/database/schema'; describe('ReportsProcessor', () => { let processor: ReportsProcessor; @@ -53,6 +54,7 @@ describe('ReportsProcessor', () => { generatedAt: expect.any(String), }); // groupBy must not have been called — we no longer scan the ledger + expect(mockDb.from).toHaveBeenCalledWith(schema.ledgerTotals); expect(mockDb.groupBy).toBeUndefined(); }); From 1c19577df4126c71489df10a322fd7967fd8cb73 Mon Sep 17 00:00:00 2001 From: 0-sayed Date: Sun, 12 Apr 2026 06:48:32 +0200 Subject: [PATCH 05/16] feat: maintain ledger_totals running total on deposit --- src/wallets/wallets.service.spec.ts | 18 ++++++++++++++++++ src/wallets/wallets.service.ts | 6 ++++++ 2 files changed, 24 insertions(+) diff --git a/src/wallets/wallets.service.spec.ts b/src/wallets/wallets.service.spec.ts index 3d8e8b3..7380f9a 100644 --- a/src/wallets/wallets.service.spec.ts +++ b/src/wallets/wallets.service.spec.ts @@ -2,6 +2,7 @@ import { Test } from '@nestjs/testing'; import { WalletsService } from './wallets.service'; import { DB } from '../common/database/db.module'; import { BadRequestException, NotFoundException } from '@nestjs/common'; +import * as schema from '../common/database/schema'; const mockWallet = { id: 'wallet-1', @@ -88,5 +89,22 @@ describe('WalletsService', () => { expect(result.balance).toBe(5100); expect(tx.insert).toHaveBeenCalled(); }); + + it('updates ledger_totals for deposit within the same transaction', async () => { + const updatedWallet = { ...mockWallet, balance: 5100 }; + const tx = makeTx({ + selectResult: [mockWallet], + updateResult: [updatedWallet], + }); + mockDb.transaction.mockImplementation((cb: (tx: MockTx) => unknown) => + cb(tx), + ); + + await service.deposit('user-1', 'wallet-1', 100); + + // First update: wallet balance. Second update: ledger_totals. + expect(tx.update).toHaveBeenCalledTimes(2); + expect(tx.update).toHaveBeenNthCalledWith(2, schema.ledgerTotals); + }); }); }); diff --git a/src/wallets/wallets.service.ts b/src/wallets/wallets.service.ts index 4a33852..b77d8b2 100644 --- a/src/wallets/wallets.service.ts +++ b/src/wallets/wallets.service.ts @@ -61,6 +61,12 @@ export class WalletsService { amount, }); + // Update running total — same transaction, always consistent + await tx + .update(schema.ledgerTotals) + .set({ total: sql`${schema.ledgerTotals.total} + ${amount}` }) + .where(eq(schema.ledgerTotals.type, 'deposit')); + return updated; }); } From 799609de23a87fa0be2e9acbb6e7c274ddfdeb4f Mon Sep 17 00:00:00 2001 From: 0-sayed Date: Sun, 12 Apr 2026 06:52:03 +0200 Subject: [PATCH 06/16] fix: use upsert for ledger_totals to handle missing rows; fix mockWallet fixture --- src/wallets/wallets.service.spec.ts | 12 ++++++++---- src/wallets/wallets.service.ts | 11 +++++++---- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/wallets/wallets.service.spec.ts b/src/wallets/wallets.service.spec.ts index 7380f9a..998a91f 100644 --- a/src/wallets/wallets.service.spec.ts +++ b/src/wallets/wallets.service.spec.ts @@ -8,6 +8,7 @@ const mockWallet = { id: 'wallet-1', userId: 'user-1', balance: 5000, + fractionalBalance: 0, updatedAt: new Date(), }; @@ -33,7 +34,9 @@ function makeTx( }), }), insert: jest.fn().mockReturnValue({ - values: jest.fn().mockResolvedValue(undefined), + values: jest.fn().mockReturnValue({ + onConflictDoUpdate: jest.fn().mockResolvedValue(undefined), + }), }), }; } @@ -102,9 +105,10 @@ describe('WalletsService', () => { await service.deposit('user-1', 'wallet-1', 100); - // First update: wallet balance. Second update: ledger_totals. - expect(tx.update).toHaveBeenCalledTimes(2); - expect(tx.update).toHaveBeenNthCalledWith(2, schema.ledgerTotals); + // update: wallet balance only. insert: ledger entry + ledger_totals upsert. + expect(tx.update).toHaveBeenCalledTimes(1); + expect(tx.insert).toHaveBeenCalledTimes(2); + expect(tx.insert).toHaveBeenNthCalledWith(2, schema.ledgerTotals); }); }); }); diff --git a/src/wallets/wallets.service.ts b/src/wallets/wallets.service.ts index b77d8b2..38e9c85 100644 --- a/src/wallets/wallets.service.ts +++ b/src/wallets/wallets.service.ts @@ -61,11 +61,14 @@ export class WalletsService { amount, }); - // Update running total — same transaction, always consistent + // Update running total — upsert ensures correctness even on fresh databases await tx - .update(schema.ledgerTotals) - .set({ total: sql`${schema.ledgerTotals.total} + ${amount}` }) - .where(eq(schema.ledgerTotals.type, 'deposit')); + .insert(schema.ledgerTotals) + .values({ type: 'deposit', total: amount }) + .onConflictDoUpdate({ + target: schema.ledgerTotals.type, + set: { total: sql`${schema.ledgerTotals.total} + ${amount}` }, + }); return updated; }); From b0b90e3258fe9fcf7d6d22919fc6c039cd02481a Mon Sep 17 00:00:00 2001 From: 0-sayed Date: Sun, 12 Apr 2026 07:01:53 +0200 Subject: [PATCH 07/16] feat: add centi-cent accrual to eliminate systematic author under-payment --- src/purchases/purchases.service.spec.ts | 146 ++++++++++++++++++++++-- src/purchases/purchases.service.ts | 47 +++++--- 2 files changed, 167 insertions(+), 26 deletions(-) diff --git a/src/purchases/purchases.service.spec.ts b/src/purchases/purchases.service.spec.ts index 08ef1b1..ec104b1 100644 --- a/src/purchases/purchases.service.spec.ts +++ b/src/purchases/purchases.service.spec.ts @@ -45,22 +45,88 @@ async function createTestService(mockDb: ReturnType) { return module.get(PurchasesService); } -describe('PurchasesService — royalty calculation', () => { - it('gives author floor(price * 70 / 100) and platform the remainder', () => { +describe('PurchasesService — centi-cent accrual', () => { + it('gives author floor + 0 sweep when accrual stays below 100 centi-cents', () => { const price = 99; - const authorCut = Math.floor((price * 70) / 100); - const platformCut = price - authorCut; - expect(authorCut).toBe(69); + const AUTHOR_ROYALTY_PERCENT = 70; + const CENTI_CENTS = 100; + const fractionalBalance = 0; + + const exactNumerator = price * AUTHOR_ROYALTY_PERCENT; // 6930 + const authorFloorCents = Math.floor(exactNumerator / 100); // 69 + const remainderCentiCents = exactNumerator % 100; // 30 + const newFractional = fractionalBalance + remainderCentiCents; // 30 + const sweepCents = Math.floor(newFractional / CENTI_CENTS); // 0 + const leftoverCenti = newFractional % CENTI_CENTS; // 30 + const totalAuthorCents = authorFloorCents + sweepCents; // 69 + const platformCut = price - totalAuthorCents; // 30 + + expect(totalAuthorCents).toBe(69); expect(platformCut).toBe(30); - expect(authorCut + platformCut).toBe(price); + expect(totalAuthorCents + platformCut).toBe(price); + expect(leftoverCenti).toBe(30); }); - it('is exact on round numbers', () => { + it('sweeps 1 cent when accumulated centi-cents cross 100', () => { + const price = 99; + const AUTHOR_ROYALTY_PERCENT = 70; + const CENTI_CENTS = 100; + const fractionalBalance = 80; // author already has 80 centi-cents + + const exactNumerator = price * AUTHOR_ROYALTY_PERCENT; // 6930 + const authorFloorCents = Math.floor(exactNumerator / 100); // 69 + const remainderCentiCents = exactNumerator % 100; // 30 + const newFractional = fractionalBalance + remainderCentiCents; // 110 + const sweepCents = Math.floor(newFractional / CENTI_CENTS); // 1 + const leftoverCenti = newFractional % CENTI_CENTS; // 10 + const totalAuthorCents = authorFloorCents + sweepCents; // 70 + const platformCut = price - totalAuthorCents; // 29 + + expect(totalAuthorCents).toBe(70); + expect(platformCut).toBe(29); + expect(totalAuthorCents + platformCut).toBe(price); + expect(leftoverCenti).toBe(10); + }); + + it('produces zero royalty_author cents for itemPrice=1 with no accumulated accrual', () => { + const price = 1; + const AUTHOR_ROYALTY_PERCENT = 70; + const CENTI_CENTS = 100; + const fractionalBalance = 0; + + const exactNumerator = price * AUTHOR_ROYALTY_PERCENT; // 70 + const authorFloorCents = Math.floor(exactNumerator / CENTI_CENTS); // 0 + const remainderCentiCents = exactNumerator % CENTI_CENTS; // 70 + const newFractional = fractionalBalance + remainderCentiCents; // 70 + const sweepCents = Math.floor(newFractional / CENTI_CENTS); // 0 + const leftoverCenti = newFractional % CENTI_CENTS; // 70 + const totalAuthorCents = authorFloorCents + sweepCents; // 0 + const platformCut = price - totalAuthorCents; // 1 + + expect(totalAuthorCents).toBe(0); // no cents owed to author yet + expect(platformCut).toBe(1); + expect(totalAuthorCents + platformCut).toBe(price); + expect(leftoverCenti).toBe(70); // accrued — will be swept later + }); + + it('has zero remainder on round amounts', () => { const price = 100; - const authorCut = Math.floor((price * 70) / 100); - const platformCut = price - authorCut; - expect(authorCut).toBe(70); + const AUTHOR_ROYALTY_PERCENT = 70; + const CENTI_CENTS = 100; + const fractionalBalance = 0; + + const exactNumerator = price * AUTHOR_ROYALTY_PERCENT; // 7000 + const authorFloorCents = Math.floor(exactNumerator / 100); // 70 + const remainderCentiCents = exactNumerator % 100; // 0 + const newFractional = fractionalBalance + remainderCentiCents; // 0 + const sweepCents = Math.floor(newFractional / CENTI_CENTS); // 0 + const leftoverCenti = newFractional % CENTI_CENTS; // 0 + const totalAuthorCents = authorFloorCents + sweepCents; // 70 + const platformCut = price - totalAuthorCents; // 30 + + expect(totalAuthorCents).toBe(70); expect(platformCut).toBe(30); + expect(leftoverCenti).toBe(0); }); }); @@ -303,3 +369,63 @@ describe('PurchasesService — concurrent duplicate key (DrizzleQueryError wrapp ).rejects.toThrow(ConflictException); }); }); + +describe('PurchasesService — happy path accrual integration', () => { + let service: PurchasesService; + let mockDb: ReturnType; + let mockTx: ReturnType; + + beforeEach(async () => { + mockTx = createMockTx(); + mockDb = createMockDb(mockTx); + mockDb.where.mockResolvedValue([]); // no existing purchase + service = await createTestService(mockDb); + }); + + it('sets fractionalBalance to leftover centi-cents in the author wallet update', async () => { + // author has 80 centi-cents; itemPrice=99 adds 30 centi-cents => 110 => sweep 1, leftover 10 + mockTx.for.mockResolvedValue([ + { + id: 'wallet-buyer', + userId: 'user-1', + balance: 5000, + fractionalBalance: 0, + }, + { + id: 'wallet-author', + userId: 'author-user', + balance: 0, + fractionalBalance: 80, + }, + { + id: 'platform-wallet-id', + userId: 'platform-user', + balance: 0, + fractionalBalance: 0, + }, + ]); + mockTx.returning.mockResolvedValueOnce([ + { + id: 'purchase-1', + idempotencyKey: 'key-1', + status: 'completed', + buyerWalletId: 'wallet-buyer', + authorWalletId: 'wallet-author', + itemPrice: 99, + createdAt: new Date(), + }, + ]); + + await service.purchase({ + idempotencyKey: 'key-1', + buyerWalletId: 'wallet-buyer', + authorWalletId: 'wallet-author', + itemPrice: 99, + requestUserId: 'user-1', + }); + + // set calls: [0] buyer, [1] author (has fractionalBalance), [2] platform + const authorSetArgs = mockTx.set.mock.calls[1][0]; + expect(authorSetArgs.fractionalBalance).toBe(10); + }); +}); diff --git a/src/purchases/purchases.service.ts b/src/purchases/purchases.service.ts index f8e5dbc..231eb44 100644 --- a/src/purchases/purchases.service.ts +++ b/src/purchases/purchases.service.ts @@ -15,6 +15,7 @@ import { DB } from '../common/database/db.module'; import * as schema from '../common/database/schema'; const AUTHOR_ROYALTY_PERCENT = 70; +const CENTI_CENTS = 100; const PG_UNIQUE_VIOLATION = '23505'; const PG_DEADLOCK = '40P01'; @@ -102,6 +103,7 @@ export class PurchasesService { id: schema.wallets.id, userId: schema.wallets.userId, balance: schema.wallets.balance, + fractionalBalance: schema.wallets.fractionalBalance, }) .from(schema.wallets) .where(inArray(schema.wallets.id, walletIds)) @@ -142,10 +144,18 @@ export class PurchasesService { throw new BadRequestException('Platform wallet does not exist'); } - const authorCut = Math.floor( - (dto.itemPrice * AUTHOR_ROYALTY_PERCENT) / 100, - ); - const platformCut = dto.itemPrice - authorCut; + // Centi-cent accrual — eliminates systematic author under-payment at scale + const exactNumerator = dto.itemPrice * AUTHOR_ROYALTY_PERCENT; + const authorFloorCents = Math.floor(exactNumerator / CENTI_CENTS); + const remainderCentiCents = exactNumerator % CENTI_CENTS; + + const newFractional = + authorWallet.fractionalBalance + remainderCentiCents; + const sweepCents = Math.floor(newFractional / CENTI_CENTS); + const leftoverCenti = newFractional % CENTI_CENTS; + + const totalAuthorCents = authorFloorCents + sweepCents; + const platformCut = dto.itemPrice - totalAuthorCents; const now = new Date(); // Deduct from buyer @@ -157,11 +167,12 @@ export class PurchasesService { }) .where(eq(schema.wallets.id, dto.buyerWalletId)); - // Credit author + // Credit author (with centi-cent sweep if accrual crossed 100) await tx .update(schema.wallets) .set({ - balance: sql`${schema.wallets.balance} + ${authorCut}`, + balance: sql`${schema.wallets.balance} + ${totalAuthorCents}`, + fractionalBalance: leftoverCenti, updatedAt: now, }) .where(eq(schema.wallets.id, dto.authorWalletId)); @@ -187,30 +198,34 @@ export class PurchasesService { }) .returning(); - // Ledger entries - await tx.insert(schema.ledger).values([ + // Ledger entries — filter out zero-amount entries to satisfy CHECK (amount > 0) + const ledgerEntries = [ { walletId: dto.buyerWalletId, - type: 'purchase', - direction: 'debit', + type: 'purchase' as const, + direction: 'debit' as const, amount: dto.itemPrice, purchaseId: purchase.id, }, { walletId: dto.authorWalletId, - type: 'royalty_author', - direction: 'credit', - amount: authorCut, + type: 'royalty_author' as const, + direction: 'credit' as const, + amount: totalAuthorCents, purchaseId: purchase.id, }, { walletId: this.platformWalletId, - type: 'royalty_platform', - direction: 'credit', + type: 'royalty_platform' as const, + direction: 'credit' as const, amount: platformCut, purchaseId: purchase.id, }, - ]); + ].filter((e) => e.amount > 0); + + if (ledgerEntries.length > 0) { + await tx.insert(schema.ledger).values(ledgerEntries); + } return purchase; }); From dc16a654bcd9d60d72f7ab4edf22d67108b9212d Mon Sep 17 00:00:00 2001 From: 0-sayed Date: Sun, 12 Apr 2026 07:05:50 +0200 Subject: [PATCH 08/16] feat: maintain ledger_totals running totals on purchase --- src/purchases/purchases.service.spec.ts | 50 +++++++++++++++++++++++++ src/purchases/purchases.service.ts | 27 +++++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/purchases/purchases.service.spec.ts b/src/purchases/purchases.service.spec.ts index ec104b1..80413f3 100644 --- a/src/purchases/purchases.service.spec.ts +++ b/src/purchases/purchases.service.spec.ts @@ -8,6 +8,7 @@ import { ForbiddenException, NotFoundException, } from '@nestjs/common'; +import * as schema from '../common/database/schema'; function createMockTx() { return { @@ -20,6 +21,7 @@ function createMockTx() { set: jest.fn().mockReturnThis(), insert: jest.fn().mockReturnThis(), values: jest.fn().mockReturnThis(), + onConflictDoUpdate: jest.fn().mockReturnThis(), returning: jest.fn(), }; } @@ -428,4 +430,52 @@ describe('PurchasesService — happy path accrual integration', () => { const authorSetArgs = mockTx.set.mock.calls[1][0]; expect(authorSetArgs.fractionalBalance).toBe(10); }); + + it('upserts ledger_totals for purchase, royalty_author, and royalty_platform', async () => { + mockTx.for.mockResolvedValue([ + { + id: 'wallet-buyer', + userId: 'user-1', + balance: 5000, + fractionalBalance: 0, + }, + { + id: 'wallet-author', + userId: 'author-user', + balance: 0, + fractionalBalance: 0, + }, + { + id: 'platform-wallet-id', + userId: 'platform-user', + balance: 0, + fractionalBalance: 0, + }, + ]); + mockTx.returning.mockResolvedValueOnce([ + { + id: 'purchase-1', + idempotencyKey: 'key-1', + status: 'completed', + buyerWalletId: 'wallet-buyer', + authorWalletId: 'wallet-author', + itemPrice: 1000, + createdAt: new Date(), + }, + ]); + + await service.purchase({ + idempotencyKey: 'key-1', + buyerWalletId: 'wallet-buyer', + authorWalletId: 'wallet-author', + itemPrice: 1000, + requestUserId: 'user-1', + }); + + // ledger_totals upserts: insert called with schema.ledgerTotals for each of 3 types + const insertCalls = mockTx.insert.mock.calls.map((c: unknown[]) => c[0]); + expect( + insertCalls.filter((t: unknown) => t === schema.ledgerTotals), + ).toHaveLength(3); + }); }); diff --git a/src/purchases/purchases.service.ts b/src/purchases/purchases.service.ts index 231eb44..80257bd 100644 --- a/src/purchases/purchases.service.ts +++ b/src/purchases/purchases.service.ts @@ -227,6 +227,33 @@ export class PurchasesService { await tx.insert(schema.ledger).values(ledgerEntries); } + // Update running totals — same transaction, always consistent + await tx + .insert(schema.ledgerTotals) + .values({ type: 'purchase', total: dto.itemPrice }) + .onConflictDoUpdate({ + target: schema.ledgerTotals.type, + set: { + total: sql`${schema.ledgerTotals.total} + ${dto.itemPrice}`, + }, + }); + await tx + .insert(schema.ledgerTotals) + .values({ type: 'royalty_author', total: totalAuthorCents }) + .onConflictDoUpdate({ + target: schema.ledgerTotals.type, + set: { + total: sql`${schema.ledgerTotals.total} + ${totalAuthorCents}`, + }, + }); + await tx + .insert(schema.ledgerTotals) + .values({ type: 'royalty_platform', total: platformCut }) + .onConflictDoUpdate({ + target: schema.ledgerTotals.type, + set: { total: sql`${schema.ledgerTotals.total} + ${platformCut}` }, + }); + return purchase; }); } catch (error) { From 257d07a74c5663d0462d9ed99e64f07c608819ed Mon Sep 17 00:00:00 2001 From: 0-sayed Date: Sun, 12 Apr 2026 07:11:09 +0200 Subject: [PATCH 09/16] feat: add global RedisModule providing ioredis REDIS_CLIENT token Adds a @Global() NestJS module that provisions a REDIS_CLIENT (ioredis) injection token, reading REDIS_HOST/REDIS_PORT from ConfigService. Used as the edge cache for idempotency checks in PurchasesService (Task 7). Also adds redis.module.ts to knip entry array so REDIS_CLIENT export is recognized as an infrastructure token, not dead code. --- knip.json | 3 ++- package.json | 1 + pnpm-lock.yaml | 26 ++++++++++++++++++++++++++ src/app.module.ts | 2 ++ src/common/redis/redis.module.ts | 27 +++++++++++++++++++++++++++ 5 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 src/common/redis/redis.module.ts diff --git a/knip.json b/knip.json index fe5bb05..f74acd7 100644 --- a/knip.json +++ b/knip.json @@ -2,7 +2,8 @@ "entry": [ "src/main.ts", "test/**/*.e2e-spec.ts", - "src/common/database/db.module.ts" + "src/common/database/db.module.ts", + "src/common/redis/redis.module.ts" ], "ignoreDependencies": [ "@nestjs/bullmq", diff --git a/package.json b/package.json index 6581c4f..a79d2bb 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "class-validator": "^0.15.1", "dotenv": "^17.3.1", "drizzle-orm": "^0.45.1", + "ioredis": "^5.10.1", "nestjs-pino": "^4.6.0", "pino": "^10.3.1", "pino-http": "^11.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba4bae6..0b5132b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: drizzle-orm: specifier: ^0.45.1 version: 0.45.1(postgres@3.4.8) + ioredis: + specifier: ^5.10.1 + version: 5.10.1 nestjs-pino: specifier: ^4.6.0 version: 4.6.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2) @@ -730,6 +733,9 @@ packages: '@ioredis/commands@1.5.0': resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==} + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -2471,6 +2477,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ioredis@5.10.1: + resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} + engines: {node: '>=12.22.0'} + ioredis@5.9.3: resolution: {integrity: sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA==} engines: {node: '>=12.22.0'} @@ -4293,6 +4303,8 @@ snapshots: '@ioredis/commands@1.5.0': {} + '@ioredis/commands@1.5.1': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -6122,6 +6134,20 @@ snapshots: inherits@2.0.4: {} + ioredis@5.10.1: + dependencies: + '@ioredis/commands': 1.5.1 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ioredis@5.9.3: dependencies: '@ioredis/commands': 1.5.0 diff --git a/src/app.module.ts b/src/app.module.ts index fc53e02..10c994e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,6 +2,7 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { BullModule } from '@nestjs/bullmq'; import { LoggerModule } from './common/logger/logger.module'; +import { RedisModule } from './common/redis/redis.module'; import { DbModule } from './common/database/db.module'; import { CorrelationIdMiddleware } from './common/middleware/correlation-id.middleware'; import { HealthController } from './health/health.controller'; @@ -28,6 +29,7 @@ import { ReportsModule } from './reports/reports.module'; inject: [ConfigService], }), LoggerModule, + RedisModule, DbModule, PurchasesModule, WalletsModule, diff --git a/src/common/redis/redis.module.ts b/src/common/redis/redis.module.ts new file mode 100644 index 0000000..c6744d4 --- /dev/null +++ b/src/common/redis/redis.module.ts @@ -0,0 +1,27 @@ +import { Global, Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; + +export const REDIS_CLIENT = 'REDIS_CLIENT'; + +@Global() +@Module({ + providers: [ + { + provide: REDIS_CLIENT, + useFactory: (config: ConfigService) => { + const port = Number(config.get('REDIS_PORT', '6379')); + if (Number.isNaN(port)) { + throw new Error('REDIS_PORT must be a valid number'); + } + return new Redis({ + host: config.get('REDIS_HOST', 'localhost'), + port, + }); + }, + inject: [ConfigService], + }, + ], + exports: [REDIS_CLIENT], +}) +export class RedisModule {} From 06f7d23dd6f5df7cf96ae718e98db5e9db465a7b Mon Sep 17 00:00:00 2001 From: 0-sayed Date: Sun, 12 Apr 2026 07:18:59 +0200 Subject: [PATCH 10/16] feat: add Redis SETNX edge cache for idempotency in PurchasesService Short-circuits repeated requests by checking Redis before the DB query. On cache miss or Redis unavailability, falls through to the existing DB path. --- src/purchases/purchases.service.spec.ts | 201 +++++++++++++++++++++++- src/purchases/purchases.service.ts | 116 +++++++++++++- 2 files changed, 315 insertions(+), 2 deletions(-) diff --git a/src/purchases/purchases.service.spec.ts b/src/purchases/purchases.service.spec.ts index 80413f3..5cedb4c 100644 --- a/src/purchases/purchases.service.spec.ts +++ b/src/purchases/purchases.service.spec.ts @@ -2,6 +2,7 @@ import { Test } from '@nestjs/testing'; import { PostgresError } from 'postgres'; import { PurchasesService } from './purchases.service'; import { DB } from '../common/database/db.module'; +import { REDIS_CLIENT } from '../common/redis/redis.module'; import { BadRequestException, ConflictException, @@ -35,12 +36,24 @@ function createMockDb(mockTx: ReturnType) { }; } -async function createTestService(mockDb: ReturnType) { +function createMockRedis() { + return { + set: jest.fn().mockResolvedValue('OK'), // default: NX succeeds (new request) + get: jest.fn(), + }; +} + +async function createTestService( + mockDb: ReturnType, + mockRedis?: Record, +) { + const redis = mockRedis ?? createMockRedis(); const module = await Test.createTestingModule({ providers: [ PurchasesService, { provide: DB, useValue: mockDb }, { provide: 'PLATFORM_WALLET_ID', useValue: 'platform-wallet-id' }, + { provide: REDIS_CLIENT, useValue: redis }, ], }).compile(); @@ -479,3 +492,189 @@ describe('PurchasesService — happy path accrual integration', () => { ).toHaveLength(3); }); }); + +describe('PurchasesService — Redis idempotency cache', () => { + // Use a fixed ISO string so JSON.stringify → JSON.parse → new Date() is deterministic + const createdAtIso = new Date('2026-01-01T00:00:00.000Z').toISOString(); + const cachedPurchaseJson = { + id: 'purchase-1', + idempotencyKey: 'key-1', + status: 'completed' as const, + buyerWalletId: 'wallet-buyer', + authorWalletId: 'wallet-author', + itemPrice: 1000, + createdAt: createdAtIso, + }; + // The service re-hydrates createdAt to a Date when returning from cache + const cachedPurchase = { + ...cachedPurchaseJson, + createdAt: new Date(createdAtIso), + }; + + const purchaseDto = { + idempotencyKey: 'key-1', + buyerWalletId: 'wallet-buyer', + authorWalletId: 'wallet-author', + itemPrice: 1000, + requestUserId: 'user-1', + }; + + it('Redis hit — returns cached purchase (happy path)', async () => { + const mockTx = createMockTx(); + const mockDb = createMockDb(mockTx); + const mockRedis = createMockRedis(); + + // NX returns null — key already exists + mockRedis.set.mockResolvedValue(null); + mockRedis.get.mockResolvedValue(JSON.stringify(cachedPurchaseJson)); + // Ownership check — buyer wallet belongs to user-1 + mockDb.where.mockResolvedValueOnce([{ userId: 'user-1' }]); + + const service = await createTestService(mockDb, mockRedis); + const result = await service.purchase(purchaseDto); + + expect(result).toEqual(cachedPurchase); + expect(mockDb.transaction).not.toHaveBeenCalled(); + }); + + it('Redis "processing" → 409 Conflict', async () => { + const mockTx = createMockTx(); + const mockDb = createMockDb(mockTx); + const mockRedis = createMockRedis(); + + mockRedis.set.mockResolvedValue(null); + mockRedis.get.mockResolvedValue('processing'); + + const service = await createTestService(mockDb, mockRedis); + + await expect(service.purchase(purchaseDto)).rejects.toThrow( + ConflictException, + ); + }); + + it('Redis null after GET (eviction race) → falls through to DB', async () => { + const mockTx = createMockTx(); + const mockDb = createMockDb(mockTx); + const mockRedis = createMockRedis(); + + // NX returns null — key exists, but GET also returns null (evicted) + mockRedis.set.mockResolvedValue(null); + mockRedis.get.mockResolvedValue(null); + // DB idempotency check: no existing purchase + mockDb.where.mockResolvedValue([]); + // Transaction throws stub to keep test short + mockDb.transaction = jest.fn().mockRejectedValue(new Error('stub')); + + const service = await createTestService(mockDb, mockRedis); + + await expect(service.purchase(purchaseDto)).rejects.toThrow('stub'); + expect(mockDb.transaction).toHaveBeenCalled(); + }); + + it('Redis unavailable → falls through to DB', async () => { + const mockTx = createMockTx(); + const mockDb = createMockDb(mockTx); + const mockRedis = createMockRedis(); + + mockRedis.set.mockRejectedValue(new Error('Redis connection refused')); + // DB idempotency check: no existing purchase + mockDb.where.mockResolvedValue([]); + // Transaction stub + mockDb.transaction = jest.fn().mockRejectedValue(new Error('stub')); + + const service = await createTestService(mockDb, mockRedis); + + await expect(service.purchase(purchaseDto)).rejects.toThrow('stub'); + expect(mockDb.from).toHaveBeenCalled(); // DB was consulted + }); + + it('Redis cache payload drift → 409 Conflict', async () => { + const mockTx = createMockTx(); + const mockDb = createMockDb(mockTx); + const mockRedis = createMockRedis(); + + const driftedPurchase = { ...cachedPurchaseJson, itemPrice: 999 }; + mockRedis.set.mockResolvedValue(null); + mockRedis.get.mockResolvedValue(JSON.stringify(driftedPurchase)); + + const service = await createTestService(mockDb, mockRedis); + + await expect(service.purchase(purchaseDto)).rejects.toThrow( + new ConflictException( + 'Idempotency key already used with different purchase parameters', + ), + ); + }); + + it('Redis cache ownership check → 403 Forbidden', async () => { + const mockTx = createMockTx(); + const mockDb = createMockDb(mockTx); + const mockRedis = createMockRedis(); + + mockRedis.set.mockResolvedValue(null); + mockRedis.get.mockResolvedValue(JSON.stringify(cachedPurchaseJson)); + // Ownership check — buyer wallet belongs to other-user + mockDb.where.mockResolvedValueOnce([{ userId: 'other-user' }]); + + const service = await createTestService(mockDb, mockRedis); + + await expect(service.purchase(purchaseDto)).rejects.toThrow( + ForbiddenException, + ); + }); + + it('Successful purchase populates Redis cache', async () => { + const mockTx = createMockTx(); + const mockDb = createMockDb(mockTx); + const mockRedis = createMockRedis(); + + // NX returns OK — new request + mockRedis.set.mockResolvedValue('OK'); + // DB idempotency check: no existing purchase + mockDb.where.mockResolvedValue([]); + + const purchase = { + id: 'purchase-1', + idempotencyKey: 'key-1', + status: 'completed', + buyerWalletId: 'wallet-buyer', + authorWalletId: 'wallet-author', + itemPrice: 1000, + createdAt: new Date(), + }; + + mockTx.for.mockResolvedValue([ + { + id: 'wallet-buyer', + userId: 'user-1', + balance: 5000, + fractionalBalance: 0, + }, + { + id: 'wallet-author', + userId: 'author-user', + balance: 0, + fractionalBalance: 0, + }, + { + id: 'platform-wallet-id', + userId: 'platform-user', + balance: 0, + fractionalBalance: 0, + }, + ]); + mockTx.returning.mockResolvedValueOnce([purchase]); + + const service = await createTestService(mockDb, mockRedis); + const result = await service.purchase(purchaseDto); + + expect(result).toEqual(purchase); + // Second redis.set call should cache the purchase result + expect(mockRedis.set).toHaveBeenCalledTimes(2); + const secondCall = mockRedis.set.mock.calls[1]; + expect(secondCall[0]).toBe('idempotency:key-1'); + expect(secondCall[1]).toBe(JSON.stringify(purchase)); + expect(secondCall[2]).toBe('EX'); + expect(secondCall[3]).toBe(86400); + }); +}); diff --git a/src/purchases/purchases.service.ts b/src/purchases/purchases.service.ts index 80257bd..e4ae6e9 100644 --- a/src/purchases/purchases.service.ts +++ b/src/purchases/purchases.service.ts @@ -6,18 +6,22 @@ import { HttpStatus, Inject, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; import { PostgresError } from 'postgres'; import { asc, eq, inArray, sql } from 'drizzle-orm'; +import type { Redis } from 'ioredis'; import type { AppDatabase } from '../common/database/db.module'; import { DB } from '../common/database/db.module'; +import { REDIS_CLIENT } from '../common/redis/redis.module'; import * as schema from '../common/database/schema'; const AUTHOR_ROYALTY_PERCENT = 70; const CENTI_CENTS = 100; const PG_UNIQUE_VIOLATION = '23505'; const PG_DEADLOCK = '40P01'; +const IDEMPOTENCY_TTL = 86400; interface PurchaseDto { idempotencyKey: string; @@ -29,9 +33,12 @@ interface PurchaseDto { @Injectable() export class PurchasesService { + private readonly logger = new Logger(PurchasesService.name); + constructor( @Inject(DB) private db: AppDatabase, @Inject('PLATFORM_WALLET_ID') private platformWalletId: string, + @Inject(REDIS_CLIENT) private redis: Redis, ) {} async purchase(dto: PurchaseDto) { @@ -42,6 +49,87 @@ export class PurchasesService { ); } + const redisKey = `idempotency:${dto.idempotencyKey}`; + + // Redis SETNX edge-cache — short-circuit before hitting the DB + try { + const nx = await this.redis.set( + redisKey, + 'processing', + 'EX', + IDEMPOTENCY_TTL, + 'NX', + ); + + if (nx === null) { + // Key exists in Redis — cached result or in-flight + const cached = await this.redis.get(redisKey); + + if (cached === 'processing') { + throw new ConflictException( + 'Purchase with this idempotency key is still in flight', + ); + } + + if (cached !== null) { + // cached is a JSON-serialized purchase result + const parsed = JSON.parse(cached) as { + id: string; + idempotencyKey: string; + status: 'pending' | 'completed' | 'failed'; + buyerWalletId: string; + authorWalletId: string; + itemPrice: number; + createdAt: string; + }; + // Re-hydrate createdAt to a Date to match the DB return shape + const cachedPurchase = { + ...parsed, + createdAt: new Date(parsed.createdAt), + }; + + // Payload drift check + if ( + cachedPurchase.buyerWalletId !== dto.buyerWalletId || + cachedPurchase.authorWalletId !== dto.authorWalletId || + cachedPurchase.itemPrice !== dto.itemPrice + ) { + throw new ConflictException( + 'Idempotency key already used with different purchase parameters', + ); + } + + // Ownership check (same as DB path) + const [buyerWallet] = await this.db + .select({ userId: schema.wallets.userId }) + .from(schema.wallets) + .where(eq(schema.wallets.id, cachedPurchase.buyerWalletId)); + + if (!buyerWallet || buyerWallet.userId !== dto.requestUserId) { + throw new ForbiddenException( + 'Buyer wallet does not belong to the authenticated user', + ); + } + + return cachedPurchase; + } + + // cached === null: key was evicted between NX check and GET (eviction race) + // Fall through to DB path to re-validate + } + + // nx === 'OK' — new request, fall through to DB check + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + // Redis unavailable — log warning, fall through to DB + this.logger.warn( + { err: error }, + 'Redis unavailable, falling through to DB path', + ); + } + // Idempotency check — outside transaction (read-only) const [existing] = await this.db .select({ @@ -79,6 +167,17 @@ export class PurchasesService { 'Buyer wallet does not belong to the authenticated user', ); } + // Populate Redis on DB cold-start hit + try { + await this.redis.set( + redisKey, + JSON.stringify(existing), + 'EX', + IDEMPOTENCY_TTL, + ); + } catch { + /* Redis unavailable — ignore */ + } return existing; } throw new ConflictException( @@ -86,8 +185,9 @@ export class PurchasesService { ); } + let result: typeof schema.purchases.$inferSelect; try { - return await this.db.transaction(async (tx) => { + result = await this.db.transaction(async (tx) => { // Lock all three wallets in a single query. The .orderBy(asc(...)) // below is what enforces consistent lock acquisition order in // PostgreSQL, eliminating the circular wait condition that causes @@ -281,5 +381,19 @@ export class PurchasesService { } throw error; } + + // Cache after successful commit + try { + await this.redis.set( + redisKey, + JSON.stringify(result), + 'EX', + IDEMPOTENCY_TTL, + ); + } catch { + /* Redis unavailable — ignore */ + } + + return result; } } From b139d615e976b6a51ca7c6f29a09fd87b946a215 Mon Sep 17 00:00:00 2001 From: 0-sayed Date: Sun, 12 Apr 2026 07:24:48 +0200 Subject: [PATCH 11/16] fix: delete Redis sentinel when purchase transaction fails Clear the 'processing' sentinel in the catch block of the DB transaction so clients can retry after failures (insufficient funds, DB errors, etc.) instead of receiving a permanent 409 for 24 hours. --- src/purchases/purchases.service.spec.ts | 23 +++++++++++++++++++++++ src/purchases/purchases.service.ts | 7 +++++++ 2 files changed, 30 insertions(+) diff --git a/src/purchases/purchases.service.spec.ts b/src/purchases/purchases.service.spec.ts index 5cedb4c..3e6e616 100644 --- a/src/purchases/purchases.service.spec.ts +++ b/src/purchases/purchases.service.spec.ts @@ -40,6 +40,7 @@ function createMockRedis() { return { set: jest.fn().mockResolvedValue('OK'), // default: NX succeeds (new request) get: jest.fn(), + del: jest.fn().mockResolvedValue(1), }; } @@ -623,6 +624,28 @@ describe('PurchasesService — Redis idempotency cache', () => { ); }); + it('clears the processing sentinel when the transaction fails', async () => { + const mockTx = createMockTx(); + const mockDb = createMockDb(mockTx); + const mockRedis = createMockRedis(); + + // NX returns 'OK' — new request, sentinel is set + mockRedis.set.mockResolvedValue('OK'); + // DB idempotency check: no existing purchase + mockDb.where.mockResolvedValue([]); + // Transaction throws a non-PG error (e.g., insufficient funds) + mockDb.transaction = jest + .fn() + .mockRejectedValue(new BadRequestException('Insufficient funds')); + + const service = await createTestService(mockDb, mockRedis); + + await expect(service.purchase(purchaseDto)).rejects.toThrow( + BadRequestException, + ); + expect(mockRedis.del).toHaveBeenCalledWith('idempotency:key-1'); + }); + it('Successful purchase populates Redis cache', async () => { const mockTx = createMockTx(); const mockDb = createMockDb(mockTx); diff --git a/src/purchases/purchases.service.ts b/src/purchases/purchases.service.ts index e4ae6e9..5dbaafa 100644 --- a/src/purchases/purchases.service.ts +++ b/src/purchases/purchases.service.ts @@ -357,6 +357,13 @@ export class PurchasesService { return purchase; }); } catch (error) { + // Delete the 'processing' sentinel so clients can retry after a failed transaction + try { + await this.redis.del(redisKey); + } catch { + /* Redis unavailable — ignore */ + } + // Drizzle may wrap the raw PostgresError in a DrizzleQueryError, so we // check both the error itself and its cause for the pg error code. const pgError = From 6d2574a45cbf9de9eb706ca79fdcfd58fcaf5080 Mon Sep 17 00:00:00 2001 From: 0-sayed Date: Sun, 12 Apr 2026 07:28:21 +0200 Subject: [PATCH 12/16] docs: add ADR-008/009/010 and update architecture docs for scale improvements --- docs/adr/ADR-003-integer-cents.md | 4 ++ .../adr/ADR-005-platform-royalty-remainder.md | 4 +- .../ADR-008-running-totals-for-reporting.md | 32 +++++++++++++++ docs/adr/ADR-009-redis-idempotency-cache.md | 34 ++++++++++++++++ .../ADR-010-centi-cent-fractional-accrual.md | 40 +++++++++++++++++++ docs/architecture.md | 32 ++++++++++++--- 6 files changed, 139 insertions(+), 7 deletions(-) create mode 100644 docs/adr/ADR-008-running-totals-for-reporting.md create mode 100644 docs/adr/ADR-009-redis-idempotency-cache.md create mode 100644 docs/adr/ADR-010-centi-cent-fractional-accrual.md diff --git a/docs/adr/ADR-003-integer-cents.md b/docs/adr/ADR-003-integer-cents.md index 3428a17..56f8961 100644 --- a/docs/adr/ADR-003-integer-cents.md +++ b/docs/adr/ADR-003-integer-cents.md @@ -24,3 +24,7 @@ The only special case is royalty splits where division produces remainders (e.g. - All monetary values are integers in the codebase — no decimal types anywhere - API accepts and returns cent values (documented in API contract) - Division for royalty splits requires explicit rounding strategy (see ADR-005) + +## Addendum (2026-04-12) + +Centi-cents (1/100 of a cent, stored as `wallets.fractional_balance`) extend this principle for sub-cent royalty accrual. See ADR-010. diff --git a/docs/adr/ADR-005-platform-royalty-remainder.md b/docs/adr/ADR-005-platform-royalty-remainder.md index d2d90d5..de65979 100644 --- a/docs/adr/ADR-005-platform-royalty-remainder.md +++ b/docs/adr/ADR-005-platform-royalty-remainder.md @@ -1,8 +1,10 @@ # ADR-005: Platform Receives Royalty Remainder -**Status:** Accepted +**Status:** Superseded by ADR-010 **Date:** 2026-02-26 +> **Superseded:** This ADR describes the original floor-only rounding strategy. It has been replaced by ADR-010 (centi-cent accrual), which eliminates the systematic under-payment of authors. The platform still receives the remainder after sweep, but over time the amounts converge to the nominal 30%. + ## Context Royalty splits (70% author, 30% platform) applied to integer cent values can produce remainders. For example: 70% of 99 cents = 69.3 cents — not a valid integer. A rounding strategy must be defined. diff --git a/docs/adr/ADR-008-running-totals-for-reporting.md b/docs/adr/ADR-008-running-totals-for-reporting.md new file mode 100644 index 0000000..8a4752b --- /dev/null +++ b/docs/adr/ADR-008-running-totals-for-reporting.md @@ -0,0 +1,32 @@ +# ADR-008: Running Totals for Reporting + +**Status:** Accepted +**Date:** 2026-04-12 + +## Context + +`ReportsProcessor.aggregate()` ran a `GROUP BY` over the entire `ledger` table inside a `REPEATABLE READ` transaction. At 100M+ rows this causes a full table scan, consumes memory proportional to result size, and holds a long-running transaction on the operational database. + +## Decision + +A new `ledger_totals` table maintains pre-aggregated running sums per ledger type: + +```sql +type ledger_type PRIMARY KEY +total BIGINT NOT NULL DEFAULT 0 +``` + +The table is updated inside the same transaction as every ledger insert using `INSERT ... ON CONFLICT DO UPDATE`. `ReportsProcessor` replaces the `GROUP BY` with a single `SELECT * FROM ledger_totals` (4 rows, no scan). + +## Reasoning + +- The running total is always consistent with the ledger because it is updated in the same transaction. No eventual consistency. +- O(1) read path replaces O(N) scan. +- `BIGINT` is chosen because the sum of 100M+ cent-denominated values can exceed INT4's ~2.1 billion max. +- Upsert (`INSERT ... ON CONFLICT DO UPDATE`) is used rather than bare `UPDATE` to handle fresh databases where the row may not yet exist. + +## Consequences + +- Reporting is O(1) regardless of ledger size. +- Write path has a small additional upsert per transaction (negligible vs. ledger insert cost). +- Migration includes a backfill from existing ledger data. diff --git a/docs/adr/ADR-009-redis-idempotency-cache.md b/docs/adr/ADR-009-redis-idempotency-cache.md new file mode 100644 index 0000000..8e90890 --- /dev/null +++ b/docs/adr/ADR-009-redis-idempotency-cache.md @@ -0,0 +1,34 @@ +# ADR-009: Redis Idempotency Cache + +**Status:** Accepted +**Date:** 2026-04-12 + +## Context + +Every purchase request hit Postgres with a `SELECT WHERE idempotency_key = ?` before the transaction. Under high retry volume this is unnecessary latency and DB load for requests that already have a known result. + +## Decision + +Redis SETNX edge cache. Before the DB check: + +``` +SET idempotency: 'processing' EX 86400 NX +``` + +- If NX returns `null` (key exists): read the cached value and return or `409`. +- If NX returns `'OK'` (new request): proceed to DB. +- Cache the result after commit. +- Delete sentinel on transaction failure so clients can retry. + +## Reasoning + +- Redis is a latency shortcut — the DB remains the correctness backstop. The `UNIQUE` constraint on `purchases.idempotency_key` and the existing DB SELECT are untouched. +- Any request that slips past Redis (eviction, cold start, Redis unavailable) falls through to the DB path. +- The `'processing'` sentinel must be deleted on transaction failure — otherwise recoverable failures (e.g., 402 insufficient funds) would block client retries for 24 hours. +- TTL of 86400s (24 hours) covers any reasonable client retry window. + +## Consequences + +- Repeated requests with a completed purchase are served from Redis without hitting Postgres. +- Redis unavailability degrades gracefully (falls through to DB). +- A separate `RedisModule` is required because BullMQ does not expose its internal ioredis instance via NestJS DI. diff --git a/docs/adr/ADR-010-centi-cent-fractional-accrual.md b/docs/adr/ADR-010-centi-cent-fractional-accrual.md new file mode 100644 index 0000000..f150130 --- /dev/null +++ b/docs/adr/ADR-010-centi-cent-fractional-accrual.md @@ -0,0 +1,40 @@ +# ADR-010: Centi-cent Fractional Accrual + +**Status:** Accepted +**Date:** 2026-04-12 +**Supersedes:** ADR-005 + +## Context + +`Math.floor(price * 70 / 100)` always floors the author royalty, giving every sub-cent remainder to the platform. Over millions of microtransactions this is a systematic wealth transfer away from authors. + +Example over three 99-cent transactions: author should receive 3 × 69.3 = 207.9 cents but actually receives 3 × 69 = 207 cents; platform over-receives ~1 cent per 3 transactions. + +## Decision + +Per-author centi-cent accrual. Track each author wallet's fractional royalty remainder at centi-cent precision (1/100 of a cent, stored as integer 0–99 in `wallets.fractional_balance`). When accumulated centi-cents reach 100, sweep one whole cent into the author's balance within the same transaction. + +```typescript +const exactNumerator = itemPrice * AUTHOR_ROYALTY_PERCENT; // e.g. 99*70 = 6930 +const authorFloorCents = Math.floor(exactNumerator / 100); // 69 +const remainderCentiCents = exactNumerator % 100; // 30 +const newFractional = authorWallet.fractionalBalance + remainderCentiCents; +const sweepCents = Math.floor(newFractional / 100); // whole cents to sweep +const leftoverCenti = newFractional % 100; // stored back +const totalAuthorCents = authorFloorCents + sweepCents; // actual credit +const platformCut = itemPrice - totalAuthorCents; // always = itemPrice +``` + +## Reasoning + +- Money conservation holds — `totalAuthorCents + platformCut === itemPrice` always. +- Stays within ADR-003's integer-only philosophy — centi-cents are a finer-grained integer unit. +- The `SELECT FOR UPDATE` on the author wallet row means `fractionalBalance` can be set directly (no SQL arithmetic needed — we hold the lock). +- No background job required. + +## Consequences + +- Author wallets gain a `fractional_balance` column (0–99). +- Authors receive the correct economic share over time rather than systematically losing sub-cent remainders. +- Platform receives slightly less on average (closer to the nominal 30%). +- Ledger entries record `totalAuthorCents` and `platformCut` (actual money moved, not nominal percentages). diff --git a/docs/architecture.md b/docs/architecture.md index c3cf387..8385c45 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,7 +10,8 @@ Wallet is a digital wallet API that manages integer-cent balances, purchase tran graph LR Client -->|HTTP| API[NestJS API :3000] API -->|Drizzle ORM| PG[(PostgreSQL 16)] - API -->|BullMQ| Redis[(Redis 7)] + API -->|ioredis - idempotency cache| Redis[(Redis 7)] + API -->|BullMQ| Redis Redis -->|Job| Worker[Report Processor] Worker -->|Drizzle ORM| PG ``` @@ -84,6 +85,13 @@ erDiagram jsonb result timestamp completed_at } + + ledger_totals { + enum type PK "ledger_type" + bigint total "running sum" + } + + ledger ||--o{ ledger_totals : "aggregated into" ``` ## Concurrency & Transactions @@ -97,17 +105,26 @@ Each deposit runs inside a transaction that locks the wallet row with `SELECT FO A purchase involves three wallets: buyer, author, and platform. All three are locked in a single query ordered by `id ASC` (`FOR UPDATE`) to enforce consistent lock acquisition and prevent deadlocks. Within the same transaction: 1. Buyer balance is decremented by the item price -2. Author receives `floor(price * 70 / 100)` (author royalty) -3. Platform receives the remainder (`price - authorCut`) +2. Author receives floor royalty plus any accrued centi-cents that crossed 100 (see ADR-010) +3. Platform receives the remainder (`price - totalAuthorCents`) 4. A purchase record and three ledger entries are inserted If PostgreSQL detects a deadlock (`40P01`), the service catches it and returns `409 Conflict` with a retry hint. ### Idempotency -Each purchase carries a client-owned `Idempotency-Key` header (UUID). Before entering the transaction, the service checks for an existing purchase with that key: +Each purchase carries a client-owned `Idempotency-Key` header (UUID). Before entering the transaction, the service checks a Redis edge cache then the database: + +**Redis SETNX (fast path):** `SET idempotency: 'processing' EX 86400 NX` + +- NX returns `null` (key exists): read cached value — return completed purchase or `409` +- NX returns `'OK'` (new request): proceed to DB check below +- Redis unavailable: log warning, fall through to DB (degraded but not broken) +- Transaction failure: sentinel is deleted so clients can retry + +**DB check (cold-start / Redis miss):** -- **Completed + same payload** — returns the cached result (safe replay) +- **Completed + same payload** — populate Redis, return cached result (safe replay) - **Completed + different payload** — `409 Conflict` (payload drift) - **Still in flight** — `409 Conflict` @@ -118,7 +135,7 @@ The `idempotency_key` column has a unique constraint — concurrent inserts with Financial reports are generated asynchronously: 1. `POST /reports/financial` — inserts a report row with status `queued`, enqueues a BullMQ job, returns `{ jobId, status }` -2. **ReportsProcessor** (BullMQ worker) picks up the job, sets status to `processing`, runs an aggregation query inside a `REPEATABLE READ` transaction, and stores the JSONB result +2. **ReportsProcessor** (BullMQ worker) picks up the job, sets status to `processing`, reads pre-aggregated totals from `ledger_totals` (O(1), no table scan), and stores the JSONB result 3. `GET /reports/financial/:jobId` — polls the report status and result, scoped to the requesting user If Redis is down when enqueuing, the report is immediately marked `failed` rather than left orphaned in `queued`. @@ -134,6 +151,9 @@ Architectural decisions are recorded as ADRs: - [ADR-005: Platform Receives Royalty Remainder](adr/ADR-005-platform-royalty-remainder.md) - [ADR-006: BullMQ for Async Report Generation](adr/ADR-006-bullmq-async-reports.md) - [ADR-007: Idempotency Key Owned by Client](adr/ADR-007-client-owned-idempotency-key.md) +- [ADR-008: Running Totals for Reporting](adr/ADR-008-running-totals-for-reporting.md) +- [ADR-009: Redis Idempotency Cache](adr/ADR-009-redis-idempotency-cache.md) +- [ADR-010: Centi-cent Fractional Accrual](adr/ADR-010-centi-cent-fractional-accrual.md) ## Security From 817a8e3da13c5813f47e2c5ec1b06402f9557eef Mon Sep 17 00:00:00 2001 From: 0-sayed Date: Sun, 12 Apr 2026 07:51:02 +0200 Subject: [PATCH 13/16] fix: guard zero-amount ledger_totals upserts and platform wallet update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Skip royalty_author and royalty_platform ledger_totals upserts when the respective amount is 0, mirroring the existing ledger entry filter - Skip platform wallet UPDATE when platformCut is 0 to avoid spurious updatedAt touches when no money moves to the platform - Wrap JSON.parse of Redis cached value in try/catch; malformed values fall through to the DB path instead of throwing a 500 - Add comment explaining why all transaction errors clear the Redis sentinel (including business-rule failures like 402/403/404) - Fix reports.processor.spec.ts groupBy assertion from toBeUndefined() tautology to not.toHaveBeenCalled() — the old assertion always passed regardless of implementation --- src/purchases/purchases.service.ts | 151 +++++++++++++++----------- src/reports/reports.processor.spec.ts | 3 +- 2 files changed, 92 insertions(+), 62 deletions(-) diff --git a/src/purchases/purchases.service.ts b/src/purchases/purchases.service.ts index 5dbaafa..d29aa61 100644 --- a/src/purchases/purchases.service.ts +++ b/src/purchases/purchases.service.ts @@ -73,45 +73,60 @@ export class PurchasesService { if (cached !== null) { // cached is a JSON-serialized purchase result - const parsed = JSON.parse(cached) as { - id: string; - idempotencyKey: string; - status: 'pending' | 'completed' | 'failed'; - buyerWalletId: string; - authorWalletId: string; - itemPrice: number; - createdAt: string; - }; - // Re-hydrate createdAt to a Date to match the DB return shape - const cachedPurchase = { - ...parsed, - createdAt: new Date(parsed.createdAt), - }; - - // Payload drift check - if ( - cachedPurchase.buyerWalletId !== dto.buyerWalletId || - cachedPurchase.authorWalletId !== dto.authorWalletId || - cachedPurchase.itemPrice !== dto.itemPrice - ) { - throw new ConflictException( - 'Idempotency key already used with different purchase parameters', + let parsed: + | { + id: string; + idempotencyKey: string; + status: 'pending' | 'completed' | 'failed'; + buyerWalletId: string; + authorWalletId: string; + itemPrice: number; + createdAt: string; + } + | undefined; + try { + parsed = JSON.parse(cached) as typeof parsed; + } catch { + // Malformed cache value — fall through to DB path + this.logger.warn( + { key: redisKey }, + 'Malformed Redis cache value, falling through to DB path', ); } - // Ownership check (same as DB path) - const [buyerWallet] = await this.db - .select({ userId: schema.wallets.userId }) - .from(schema.wallets) - .where(eq(schema.wallets.id, cachedPurchase.buyerWalletId)); - - if (!buyerWallet || buyerWallet.userId !== dto.requestUserId) { - throw new ForbiddenException( - 'Buyer wallet does not belong to the authenticated user', - ); + if (parsed !== undefined) { + // Re-hydrate createdAt to a Date to match the DB return shape + const cachedPurchase = { + ...parsed, + createdAt: new Date(parsed.createdAt), + }; + + // Payload drift check + if ( + cachedPurchase.buyerWalletId !== dto.buyerWalletId || + cachedPurchase.authorWalletId !== dto.authorWalletId || + cachedPurchase.itemPrice !== dto.itemPrice + ) { + throw new ConflictException( + 'Idempotency key already used with different purchase parameters', + ); + } + + // Ownership check (same as DB path) + const [buyerWallet] = await this.db + .select({ userId: schema.wallets.userId }) + .from(schema.wallets) + .where(eq(schema.wallets.id, cachedPurchase.buyerWalletId)); + + if (!buyerWallet || buyerWallet.userId !== dto.requestUserId) { + throw new ForbiddenException( + 'Buyer wallet does not belong to the authenticated user', + ); + } + + return cachedPurchase; } - - return cachedPurchase; + // parsed === undefined: malformed JSON — fall through to DB path } // cached === null: key was evicted between NX check and GET (eviction race) @@ -277,14 +292,17 @@ export class PurchasesService { }) .where(eq(schema.wallets.id, dto.authorWalletId)); - // Credit platform - await tx - .update(schema.wallets) - .set({ - balance: sql`${schema.wallets.balance} + ${platformCut}`, - updatedAt: now, - }) - .where(eq(schema.wallets.id, this.platformWalletId)); + // Credit platform (only when platform receives something — platformCut is 0 + // when totalAuthorCents equals itemPrice, which requires a full centi-cent sweep) + if (platformCut > 0) { + await tx + .update(schema.wallets) + .set({ + balance: sql`${schema.wallets.balance} + ${platformCut}`, + updatedAt: now, + }) + .where(eq(schema.wallets.id, this.platformWalletId)); + } // Record purchase const [purchase] = await tx @@ -327,7 +345,9 @@ export class PurchasesService { await tx.insert(schema.ledger).values(ledgerEntries); } - // Update running totals — same transaction, always consistent + // Update running totals — same transaction, always consistent. + // Mirror the ledger entry filter: only upsert when money actually moved, + // keeping ledger_totals.total in sync with the sum of actual ledger entries. await tx .insert(schema.ledgerTotals) .values({ type: 'purchase', total: dto.itemPrice }) @@ -337,27 +357,36 @@ export class PurchasesService { total: sql`${schema.ledgerTotals.total} + ${dto.itemPrice}`, }, }); - await tx - .insert(schema.ledgerTotals) - .values({ type: 'royalty_author', total: totalAuthorCents }) - .onConflictDoUpdate({ - target: schema.ledgerTotals.type, - set: { - total: sql`${schema.ledgerTotals.total} + ${totalAuthorCents}`, - }, - }); - await tx - .insert(schema.ledgerTotals) - .values({ type: 'royalty_platform', total: platformCut }) - .onConflictDoUpdate({ - target: schema.ledgerTotals.type, - set: { total: sql`${schema.ledgerTotals.total} + ${platformCut}` }, - }); + if (totalAuthorCents > 0) { + await tx + .insert(schema.ledgerTotals) + .values({ type: 'royalty_author', total: totalAuthorCents }) + .onConflictDoUpdate({ + target: schema.ledgerTotals.type, + set: { + total: sql`${schema.ledgerTotals.total} + ${totalAuthorCents}`, + }, + }); + } + if (platformCut > 0) { + await tx + .insert(schema.ledgerTotals) + .values({ type: 'royalty_platform', total: platformCut }) + .onConflictDoUpdate({ + target: schema.ledgerTotals.type, + set: { + total: sql`${schema.ledgerTotals.total} + ${platformCut}`, + }, + }); + } return purchase; }); } catch (error) { - // Delete the 'processing' sentinel so clients can retry after a failed transaction + // Delete the 'processing' sentinel so clients can retry after a failed transaction. + // This applies to ALL errors — including business-rule failures (insufficient funds, + // 403, 404) — because no data has been committed. Preserving the sentinel would + // block retries for 24 h on deterministic errors, which is the wrong behaviour. try { await this.redis.del(redisKey); } catch { diff --git a/src/reports/reports.processor.spec.ts b/src/reports/reports.processor.spec.ts index e96d1be..5eee0ca 100644 --- a/src/reports/reports.processor.spec.ts +++ b/src/reports/reports.processor.spec.ts @@ -22,6 +22,7 @@ describe('ReportsProcessor', () => { select: jest.fn().mockReturnThis(), from: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), update: jest.fn().mockReturnThis(), set: jest.fn().mockReturnThis(), transaction: jest.fn((cb) => cb(mockDb)), @@ -55,7 +56,7 @@ describe('ReportsProcessor', () => { }); // groupBy must not have been called — we no longer scan the ledger expect(mockDb.from).toHaveBeenCalledWith(schema.ledgerTotals); - expect(mockDb.groupBy).toBeUndefined(); + expect(mockDb.groupBy).not.toHaveBeenCalled(); }); it('transitions report from queued to processing to completed with payload', async () => { From 3a29967d82d4db615e1f68c3214718a5b4cab36b Mon Sep 17 00:00:00 2001 From: 0-sayed Date: Sun, 12 Apr 2026 08:38:19 +0200 Subject: [PATCH 14/16] fix: resolve all PR #12 review items - RedisModule: implement OnModuleDestroy to close the ioredis client on shutdown, preventing TCP connection leaks (matches DbModule pattern) - PurchasesService: fix stale 'processing' sentinel and blind DEL bugs: - track sentinelSet flag so only the request that set the sentinel deletes it (prevents clobbering another request's cached result) - wrap DB idempotency check in try/catch with conditional sentinel cleanup so errors before the transaction don't leave stale sentinels - skip sentinel DEL on PG_UNIQUE_VIOLATION (concurrent winner may have already cached the completed-purchase JSON) - add 3 targeted tests covering all three sentinel-ownership scenarios - schema: add wallets_fractional_balance_lt_100 CHECK constraint and generate migration 0002_stormy_sasquatch.sql - deps: update @nestjs/core, @nestjs/common, @nestjs/platform-express, @nestjs/swagger, @nestjs/testing to 11.1.18 and drizzle-orm to 0.45.2; add pnpm overrides for handlebars, flatted, lodash, smol-toml, serialize-javascript, multer, picomatch, and brace-expansion to resolve all 30 moderate+ audit vulnerabilities - knip: remove @nestjs/bullmq and bullmq from ignoreDependencies now that updated packages resolve them correctly --- drizzle/0002_stormy_sasquatch.sql | 1 + drizzle/meta/0002_snapshot.json | 472 ++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + knip.json | 2 - package.json | 27 +- pnpm-lock.yaml | 313 ++++++++-------- src/common/database/schema.ts | 4 + src/common/redis/redis.module.ts | 10 +- src/purchases/purchases.service.spec.ts | 78 ++++ src/purchases/purchases.service.ts | 112 +++--- 10 files changed, 810 insertions(+), 216 deletions(-) create mode 100644 drizzle/0002_stormy_sasquatch.sql create mode 100644 drizzle/meta/0002_snapshot.json diff --git a/drizzle/0002_stormy_sasquatch.sql b/drizzle/0002_stormy_sasquatch.sql new file mode 100644 index 0000000..008d759 --- /dev/null +++ b/drizzle/0002_stormy_sasquatch.sql @@ -0,0 +1 @@ +ALTER TABLE "wallets" ADD CONSTRAINT "wallets_fractional_balance_lt_100" CHECK ("wallets"."fractional_balance" < 100); \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..60fe6f3 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,472 @@ +{ + "id": "d68f7eb7-498e-4072-b0d6-e77a3ed6fbfe", + "prevId": "31d2e6f2-3e9e-42b1-ad72-2d9d2c0855ae", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ledger": { + "name": "ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "wallet_id": { + "name": "wallet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "ledger_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "ledger_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "purchase_id": { + "name": "purchase_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ledger_wallet_id_wallets_id_fk": { + "name": "ledger_wallet_id_wallets_id_fk", + "tableFrom": "ledger", + "tableTo": "wallets", + "columnsFrom": [ + "wallet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "ledger_purchase_id_purchases_id_fk": { + "name": "ledger_purchase_id_purchases_id_fk", + "tableFrom": "ledger", + "tableTo": "purchases", + "columnsFrom": [ + "purchase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ledger_amount_positive": { + "name": "ledger_amount_positive", + "value": "\"ledger\".\"amount\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.ledger_totals": { + "name": "ledger_totals", + "schema": "", + "columns": { + "type": { + "name": "type", + "type": "ledger_type", + "typeSchema": "public", + "primaryKey": true, + "notNull": true + }, + "total": { + "name": "total", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.purchases": { + "name": "purchases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "buyer_wallet_id": { + "name": "buyer_wallet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_wallet_id": { + "name": "author_wallet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_price": { + "name": "item_price", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "purchase_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "purchases_idempotency_key_idx": { + "name": "purchases_idempotency_key_idx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "purchases_buyer_wallet_id_wallets_id_fk": { + "name": "purchases_buyer_wallet_id_wallets_id_fk", + "tableFrom": "purchases", + "tableTo": "wallets", + "columnsFrom": [ + "buyer_wallet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "purchases_author_wallet_id_wallets_id_fk": { + "name": "purchases_author_wallet_id_wallets_id_fk", + "tableFrom": "purchases", + "tableTo": "wallets", + "columnsFrom": [ + "author_wallet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "purchases_item_price_positive": { + "name": "purchases_item_price_positive", + "value": "\"purchases\".\"item_price\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "report_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "requested_by": { + "name": "requested_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reports_requested_by_users_id_fk": { + "name": "reports_requested_by_users_id_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": [ + "requested_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallets": { + "name": "wallets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "fractional_balance": { + "name": "fractional_balance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallets_user_id_idx": { + "name": "wallets_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallets_user_id_users_id_fk": { + "name": "wallets_user_id_users_id_fk", + "tableFrom": "wallets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "wallets_balance_non_negative": { + "name": "wallets_balance_non_negative", + "value": "\"wallets\".\"balance\" >= 0" + }, + "wallets_fractional_balance_non_negative": { + "name": "wallets_fractional_balance_non_negative", + "value": "\"wallets\".\"fractional_balance\" >= 0" + }, + "wallets_fractional_balance_lt_100": { + "name": "wallets_fractional_balance_lt_100", + "value": "\"wallets\".\"fractional_balance\" < 100" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.ledger_direction": { + "name": "ledger_direction", + "schema": "public", + "values": [ + "credit", + "debit" + ] + }, + "public.ledger_type": { + "name": "ledger_type", + "schema": "public", + "values": [ + "deposit", + "purchase", + "royalty_author", + "royalty_platform" + ] + }, + "public.purchase_status": { + "name": "purchase_status", + "schema": "public", + "values": [ + "pending", + "completed", + "failed" + ] + }, + "public.report_status": { + "name": "report_status", + "schema": "public", + "values": [ + "queued", + "processing", + "completed", + "failed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 85e449c..3e0a984 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1775968666133, "tag": "0001_modern_rogue", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1775975526670, + "tag": "0002_stormy_sasquatch", + "breakpoints": true } ] } \ No newline at end of file diff --git a/knip.json b/knip.json index f74acd7..b1f6f41 100644 --- a/knip.json +++ b/knip.json @@ -6,8 +6,6 @@ "src/common/redis/redis.module.ts" ], "ignoreDependencies": [ - "@nestjs/bullmq", - "bullmq", "@eslint/eslintrc", "pino-pretty", "source-map-support", diff --git a/package.json b/package.json index a79d2bb..4659116 100644 --- a/package.json +++ b/package.json @@ -48,16 +48,16 @@ }, "dependencies": { "@nestjs/bullmq": "^11.0.4", - "@nestjs/common": "^11.0.1", - "@nestjs/config": "^4.0.3", - "@nestjs/core": "^11.0.1", - "@nestjs/platform-express": "^11.0.1", - "@nestjs/swagger": "^11.2.6", + "@nestjs/common": "^11.1.18", + "@nestjs/config": "^4.0.4", + "@nestjs/core": "^11.1.18", + "@nestjs/platform-express": "^11.1.18", + "@nestjs/swagger": "^11.2.7", "bullmq": "^5.70.1", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "dotenv": "^17.3.1", - "drizzle-orm": "^0.45.1", + "drizzle-orm": "^0.45.2", "ioredis": "^5.10.1", "nestjs-pino": "^4.6.0", "pino": "^10.3.1", @@ -71,7 +71,7 @@ "@eslint/js": "^9.18.0", "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", - "@nestjs/testing": "^11.0.1", + "@nestjs/testing": "^11.1.18", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^22.10.7", @@ -102,10 +102,19 @@ "unrs-resolver" ], "overrides": { - "serialize-javascript": "^7.0.0", + "serialize-javascript": ">=7.0.5", "ajv@>=8": "8.18.0", "esbuild": ">=0.25.0", - "multer": ">=2.1.0" + "multer": ">=2.1.1", + "handlebars": ">=4.7.9", + "flatted": ">=3.4.2", + "lodash": ">=4.18.0", + "smol-toml": ">=1.6.1", + "picomatch@^2": ">=2.3.2", + "picomatch@^4": ">=4.0.4", + "brace-expansion@^1": "^1.1.13", + "brace-expansion@^2": "^2.0.3", + "brace-expansion@^5": "^5.0.5" } }, "jest": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b5132b..488e468 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,10 +5,19 @@ settings: excludeLinksFromLockfile: false overrides: - serialize-javascript: ^7.0.0 + serialize-javascript: '>=7.0.5' ajv@>=8: 8.18.0 esbuild: '>=0.25.0' - multer: '>=2.1.0' + multer: '>=2.1.1' + handlebars: '>=4.7.9' + flatted: '>=3.4.2' + lodash: '>=4.18.0' + smol-toml: '>=1.6.1' + picomatch@^2: '>=2.3.2' + picomatch@^4: '>=4.0.4' + brace-expansion@^1: ^1.1.13 + brace-expansion@^2: ^2.0.3 + brace-expansion@^5: ^5.0.5 importers: @@ -16,22 +25,22 @@ importers: dependencies: '@nestjs/bullmq': specifier: ^11.0.4 - version: 11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(bullmq@5.70.1) + version: 11.0.4(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(bullmq@5.70.1) '@nestjs/common': - specifier: ^11.0.1 - version: 11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + specifier: ^11.1.18 + version: 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/config': - specifier: ^4.0.3 - version: 4.0.3(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + specifier: ^4.0.4 + version: 4.0.4(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) '@nestjs/core': - specifier: ^11.0.1 - version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + specifier: ^11.1.18 + version: 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': - specifier: ^11.0.1 - version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) + specifier: ^11.1.18 + version: 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18) '@nestjs/swagger': - specifier: ^11.2.6 - version: 11.2.6(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) + specifier: ^11.2.7 + version: 11.2.7(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) bullmq: specifier: ^5.70.1 version: 5.70.1 @@ -45,14 +54,14 @@ importers: specifier: ^17.3.1 version: 17.3.1 drizzle-orm: - specifier: ^0.45.1 - version: 0.45.1(postgres@3.4.8) + specifier: ^0.45.2 + version: 0.45.2(postgres@3.4.8) ioredis: specifier: ^5.10.1 version: 5.10.1 nestjs-pino: specifier: ^4.6.0 - version: 4.6.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2) + version: 4.6.0(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2) pino: specifier: ^10.3.1 version: 10.3.1 @@ -82,8 +91,8 @@ importers: specifier: ^11.0.0 version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) '@nestjs/testing': - specifier: ^11.0.1 - version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-express@11.1.14) + specifier: ^11.1.18 + version: 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(@nestjs/platform-express@11.1.18) '@types/express': specifier: ^5.0.0 version: 5.0.6 @@ -346,8 +355,8 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} - '@borewit/text-codec@0.2.1': - resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==} + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} @@ -921,8 +930,8 @@ packages: '@swc/core': optional: true - '@nestjs/common@11.1.14': - resolution: {integrity: sha512-IN/tlqd7Nl9gl6f0jsWEuOrQDaCI9vHzxv0fisHysfBQzfQIkqlv5A7w4Qge02BUQyczXT9HHPgHtWHCxhjRng==} + '@nestjs/common@11.1.18': + resolution: {integrity: sha512-0sLq8Z+TIjLnz1Tqp0C/x9BpLbqpt1qEu0VcH4/fkE0y3F5JxhfK1AdKQ/SPbKhKgwqVDoY4gS8GQr2G6ujaWg==} peerDependencies: class-transformer: '>=0.4.1' class-validator: '>=0.13.2' @@ -934,14 +943,14 @@ packages: class-validator: optional: true - '@nestjs/config@4.0.3': - resolution: {integrity: sha512-FQ3M3Ohqfl+nHAn5tp7++wUQw0f2nAk+SFKe8EpNRnIifPqvfJP6JQxPKtFLMOHbyer4X646prFG4zSRYEssQQ==} + '@nestjs/config@4.0.4': + resolution: {integrity: sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==} peerDependencies: '@nestjs/common': ^10.0.0 || ^11.0.0 rxjs: ^7.1.0 - '@nestjs/core@11.1.14': - resolution: {integrity: sha512-7OXPPMoDr6z+5NkoQKu4hOhfjz/YYqM3bNilPqv1WVFWrzSmuNXxvhbX69YMmNmRYascPXiwESqf5jJdjKXEww==} + '@nestjs/core@11.1.18': + resolution: {integrity: sha512-wR3DtGyk/LUAiPtbXDuWJJwVkWElKBY0sqnTzf9d4uM3+X18FRZhK7WFc47czsIGOdWuRsMeLYV+1Z9dO4zDEQ==} engines: {node: '>= 20'} peerDependencies: '@nestjs/common': ^11.0.0 @@ -958,12 +967,12 @@ packages: '@nestjs/websockets': optional: true - '@nestjs/mapped-types@2.1.0': - resolution: {integrity: sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==} + '@nestjs/mapped-types@2.1.1': + resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==} peerDependencies: '@nestjs/common': ^10.0.0 || ^11.0.0 class-transformer: ^0.4.0 || ^0.5.0 - class-validator: ^0.13.0 || ^0.14.0 + class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0 reflect-metadata: ^0.1.12 || ^0.2.0 peerDependenciesMeta: class-transformer: @@ -971,8 +980,8 @@ packages: class-validator: optional: true - '@nestjs/platform-express@11.1.14': - resolution: {integrity: sha512-Fs+/j+mBSBSXErOQJ/YdUn/HqJGSJ4pGfiJyYOyz04l42uNVnqEakvu1kXLbxMabR6vd6/h9d6Bi4tso9p7o4Q==} + '@nestjs/platform-express@11.1.18': + resolution: {integrity: sha512-s6GdHMTa3qx0fJewR74Xa30ysPHfBEqxIwZ7BGSTLoAEQ1vTP24urNl+b6+s49NFLEIOyeNho5fN/9/I17QlOw==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 @@ -982,8 +991,8 @@ packages: peerDependencies: typescript: '>=4.8.2' - '@nestjs/swagger@11.2.6': - resolution: {integrity: sha512-oiXOxMQqDFyv1AKAqFzSo6JPvMEs4uA36Eyz/s2aloZLxUjcLfUMELSLSNQunr61xCPTpwEOShfmO7NIufKXdA==} + '@nestjs/swagger@11.2.7': + resolution: {integrity: sha512-+e1KWSyZMAQeyZ8nbQSvm3fhzqdxxBNQENvpjO2dVyD7KJmLTTQyXpRb1nM5O04oFdDTUtG3SHMl4+e+zgCK2A==} peerDependencies: '@fastify/static': ^8.0.0 || ^9.0.0 '@nestjs/common': ^11.0.1 @@ -999,8 +1008,8 @@ packages: class-validator: optional: true - '@nestjs/testing@11.1.14': - resolution: {integrity: sha512-cQxX0ronsTbpfHz8/LYOVWXxoTxv6VoxrnuZoQaVX7QV2PSMqxWE7/9jSQR0GcqAFUEmFP34c6EJqfkjfX/k4Q==} + '@nestjs/testing@11.1.18': + resolution: {integrity: sha512-frzwNlpBgtAzI3hp/qo57DZoRO4RMTH1wST3QUYEhRTHyfPkLpzkWz3jV/mhApXjD0yT56Ptlzn6zuYPLh87Lw==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 @@ -1643,14 +1652,14 @@ packages: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} - brace-expansion@5.0.4: - resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} engines: {node: 18 || 20 || >=22} braces@3.0.3: @@ -1929,20 +1938,20 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} - dotenv@17.2.3: - resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} - engines: {node: '>=12'} - dotenv@17.3.1: resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} engines: {node: '>=12'} + dotenv@17.4.1: + resolution: {integrity: sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==} + engines: {node: '>=12'} + drizzle-kit@0.31.9: resolution: {integrity: sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==} hasBin: true - drizzle-orm@0.45.1: - resolution: {integrity: sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==} + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} peerDependencies: '@aws-sdk/client-rds-data': '>=3' '@cloudflare/workers-types': '>=4' @@ -2255,7 +2264,7 @@ packages: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} peerDependencies: - picomatch: ^3 || ^4 + picomatch: '>=4.0.4' peerDependenciesMeta: picomatch: optional: true @@ -2264,8 +2273,8 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} - file-type@21.3.0: - resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==} + file-type@21.3.4: + resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} engines: {node: '>=20'} fill-range@7.1.1: @@ -2288,8 +2297,8 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} @@ -2407,8 +2416,8 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - handlebars@4.7.8: - resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} engines: {node: '>=0.4.7'} hasBin: true @@ -2792,8 +2801,8 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash@4.17.23: - resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} log-symbols@4.1.0: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} @@ -2914,8 +2923,8 @@ packages: msgpackr@1.11.5: resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==} - multer@2.1.0: - resolution: {integrity: sha512-TBm6j41rxNohqawsxlsWsNNh/VdV4QFXcBvRcPhXaA05EZ79z0qJ2bQFpync6JBoHTeNY5Q1JpG7AlTjdlfAEA==} + multer@2.1.1: + resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==} engines: {node: '>= 10.16.0'} mute-stream@2.0.0: @@ -3059,8 +3068,8 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} - path-to-regexp@8.3.0: - resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} @@ -3069,16 +3078,8 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - - picomatch@4.0.2: - resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} - engines: {node: '>=12'} - - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} pino-abstract-transport@3.0.0: @@ -3270,8 +3271,8 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} - serialize-javascript@7.0.3: - resolution: {integrity: sha512-h+cZ/XXarqDgCjo+YSyQU/ulDEESGGf8AMK9pPNmhNSl/FzPl6L8pMp1leca5z6NuG6tvV/auC8/43tmovowww==} + serialize-javascript@7.0.5: + resolution: {integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==} engines: {node: '>=20.0.0'} serve-static@2.2.1: @@ -3316,8 +3317,8 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} - smol-toml@1.6.0: - resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==} + smol-toml@1.6.1: + resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} sonic-boom@4.2.1: @@ -3406,8 +3407,8 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} - strtok3@10.3.4: - resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} engines: {node: '>=18'} superagent@10.3.0: @@ -3426,8 +3427,8 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} - swagger-ui-dist@5.31.0: - resolution: {integrity: sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==} + swagger-ui-dist@5.32.2: + resolution: {integrity: sha512-t6Ns52nS8LU2hqi0+rezMjFO1ZrCsCrnommXrU7Nfrg2va2dWahdvM6TuSwzdHpG29v6BHJyU1c/UWFhgVZzVQ==} symbol-observable@4.0.0: resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} @@ -3751,7 +3752,7 @@ snapshots: ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) jsonc-parser: 3.3.1 - picomatch: 4.0.2 + picomatch: 4.0.4 rxjs: 7.8.1 source-map: 0.7.4 optionalDependencies: @@ -3762,7 +3763,7 @@ snapshots: ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) jsonc-parser: 3.3.1 - picomatch: 4.0.2 + picomatch: 4.0.4 rxjs: 7.8.1 source-map: 0.7.4 optionalDependencies: @@ -3989,7 +3990,7 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} - '@borewit/text-codec@0.2.1': {} + '@borewit/text-codec@0.2.2': {} '@colors/colors@1.5.0': optional: true @@ -4568,17 +4569,17 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': + '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 - '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(bullmq@5.70.1)': + '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(bullmq@5.70.1)': dependencies: - '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18) + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) bullmq: 5.70.1 tslib: 2.8.1 @@ -4608,9 +4609,9 @@ snapshots: - uglify-js - webpack-cli - '@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - file-type: 21.3.0 + file-type: 21.3.4 iterare: 1.2.1 load-esm: 1.0.3 reflect-metadata: 0.2.2 @@ -4623,44 +4624,44 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/config@4.0.3(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': + '@nestjs/config@4.0.4(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - dotenv: 17.2.3 + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + dotenv: 17.4.1 dotenv-expand: 12.0.3 - lodash: 4.17.23 + lodash: 4.18.1 rxjs: 7.8.2 - '@nestjs/core@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/core@11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nuxt/opencollective': 0.4.1 fast-safe-stringify: 2.1.1 iterare: 1.2.1 - path-to-regexp: 8.3.0 + path-to-regexp: 8.4.2 reflect-metadata: 0.2.2 rxjs: 7.8.2 tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/platform-express': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) + '@nestjs/platform-express': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18) - '@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)': + '@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 optionalDependencies: class-transformer: 0.5.1 class-validator: 0.15.1 - '@nestjs/platform-express@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': + '@nestjs/platform-express@11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) cors: 2.8.6 express: 5.2.1 - multer: 2.1.0 - path-to-regexp: 8.3.0 + multer: 2.1.1 + path-to-regexp: 8.4.2 tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -4676,28 +4677,28 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/swagger@11.2.6(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)': + '@nestjs/swagger@11.2.7(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)': dependencies: '@microsoft/tsdoc': 0.16.0 - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.1(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) js-yaml: 4.1.1 - lodash: 4.17.23 - path-to-regexp: 8.3.0 + lodash: 4.18.1 + path-to-regexp: 8.4.2 reflect-metadata: 0.2.2 - swagger-ui-dist: 5.31.0 + swagger-ui-dist: 5.32.2 optionalDependencies: class-transformer: 0.5.1 class-validator: 0.15.1 - '@nestjs/testing@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-express@11.1.14)': + '@nestjs/testing@11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(@nestjs/platform-express@11.1.18)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-express': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) + '@nestjs/platform-express': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18) '@noble/hashes@1.8.0': {} @@ -5246,7 +5247,7 @@ snapshots: anymatch@3.1.3: dependencies: normalize-path: 3.0.0 - picomatch: 2.3.1 + picomatch: 4.0.4 append-field@1.0.0: {} @@ -5346,16 +5347,16 @@ snapshots: transitivePeerDependencies: - supports-color - brace-expansion@1.1.12: + brace-expansion@1.1.14: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.2: + brace-expansion@2.1.0: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.4: + brace-expansion@5.0.5: dependencies: balanced-match: 4.0.4 @@ -5592,10 +5593,10 @@ snapshots: dotenv@16.6.1: {} - dotenv@17.2.3: {} - dotenv@17.3.1: {} + dotenv@17.4.1: {} + drizzle-kit@0.31.9: dependencies: '@drizzle-team/brocli': 0.10.2 @@ -5605,7 +5606,7 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.45.1(postgres@3.4.8): + drizzle-orm@0.45.2(postgres@3.4.8): optionalDependencies: postgres: 3.4.8 @@ -5890,18 +5891,18 @@ snapshots: dependencies: walk-up-path: 4.0.0 - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 - file-type@21.3.0: + file-type@21.3.4: dependencies: '@tokenizer/inflate': 0.4.1 - strtok3: 10.3.4 + strtok3: 10.3.5 token-types: 6.1.2 uint8array-extras: 1.5.0 transitivePeerDependencies: @@ -5934,10 +5935,10 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.3.3 + flatted: 3.4.2 keyv: 4.5.4 - flatted@3.3.3: {} + flatted@3.4.2: {} foreground-child@3.3.1: dependencies: @@ -6070,7 +6071,7 @@ snapshots: graceful-fs@4.2.11: {} - handlebars@4.7.8: + handlebars@4.7.9: dependencies: minimist: 1.2.8 neo-async: 2.6.2 @@ -6497,7 +6498,7 @@ snapshots: chalk: 4.1.2 ci-info: 4.4.0 graceful-fs: 4.2.11 - picomatch: 4.0.3 + picomatch: 4.0.4 jest-validate@30.2.0: dependencies: @@ -6598,8 +6599,8 @@ snapshots: minimist: 1.2.8 oxc-resolver: 11.19.1 picocolors: 1.1.1 - picomatch: 4.0.3 - smol-toml: 1.6.0 + picomatch: 4.0.4 + smol-toml: 1.6.1 strip-json-comments: 5.0.3 typescript: 5.9.3 zod: 4.3.6 @@ -6635,7 +6636,7 @@ snapshots: lodash.merge@4.6.2: {} - lodash@4.17.23: {} + lodash@4.18.1: {} log-symbols@4.1.0: dependencies: @@ -6687,7 +6688,7 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 4.0.4 mime-db@1.52.0: {} @@ -6707,15 +6708,15 @@ snapshots: minimatch@10.2.4: dependencies: - brace-expansion: 5.0.4 + brace-expansion: 5.0.5 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.12 + brace-expansion: 1.1.14 minimatch@9.0.9: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 2.1.0 minimist@1.2.8: {} @@ -6739,7 +6740,7 @@ snapshots: optionalDependencies: msgpackr-extract: 3.0.3 - multer@2.1.0: + multer@2.1.1: dependencies: append-field: 1.0.0 busboy: 1.6.0 @@ -6756,9 +6757,9 @@ snapshots: neo-async@2.6.2: {} - nestjs-pino@4.6.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2): + nestjs-pino@4.6.0(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2): dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) pino: 10.3.1 pino-http: 11.0.0 rxjs: 7.8.2 @@ -6767,7 +6768,7 @@ snapshots: node-emoji@1.11.0: dependencies: - lodash: 4.17.23 + lodash: 4.18.1 node-gyp-build-optional-packages@5.2.2: dependencies: @@ -6895,17 +6896,13 @@ snapshots: lru-cache: 11.2.6 minipass: 7.1.3 - path-to-regexp@8.3.0: {} + path-to-regexp@8.4.2: {} path-type@4.0.0: {} picocolors@1.1.1: {} - picomatch@2.3.1: {} - - picomatch@4.0.2: {} - - picomatch@4.0.3: {} + picomatch@4.0.4: {} pino-abstract-transport@3.0.0: dependencies: @@ -7054,7 +7051,7 @@ snapshots: depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 - path-to-regexp: 8.3.0 + path-to-regexp: 8.4.2 transitivePeerDependencies: - supports-color @@ -7111,7 +7108,7 @@ snapshots: transitivePeerDependencies: - supports-color - serialize-javascript@7.0.3: {} + serialize-javascript@7.0.5: {} serve-static@2.2.1: dependencies: @@ -7164,7 +7161,7 @@ snapshots: slash@3.0.0: {} - smol-toml@1.6.0: {} + smol-toml@1.6.1: {} sonic-boom@4.2.1: dependencies: @@ -7239,7 +7236,7 @@ snapshots: strip-json-comments@5.0.3: {} - strtok3@10.3.4: + strtok3@10.3.5: dependencies: '@tokenizer/token': 0.3.0 @@ -7273,7 +7270,7 @@ snapshots: dependencies: has-flag: 4.0.0 - swagger-ui-dist@5.31.0: + swagger-ui-dist@5.32.2: dependencies: '@scarf/scarf': 1.4.0 @@ -7290,7 +7287,7 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 - serialize-javascript: 7.0.3 + serialize-javascript: 7.0.5 terser: 5.46.0 webpack: 5.104.1(esbuild@0.25.12) optionalDependencies: @@ -7315,8 +7312,8 @@ snapshots: tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 tmpl@1.0.5: {} @@ -7328,7 +7325,7 @@ snapshots: token-types@6.1.2: dependencies: - '@borewit/text-codec': 0.2.1 + '@borewit/text-codec': 0.2.2 '@tokenizer/token': 0.3.0 ieee754: 1.2.1 @@ -7340,7 +7337,7 @@ snapshots: dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 - handlebars: 4.7.8 + handlebars: 4.7.9 jest: 30.2.0(@types/node@22.19.13)(esbuild-register@3.6.0(esbuild@0.25.12))(ts-node@10.9.2(@types/node@22.19.13)(typescript@5.9.3)) json5: 2.2.3 lodash.memoize: 4.1.2 diff --git a/src/common/database/schema.ts b/src/common/database/schema.ts index eb85f1b..588f010 100644 --- a/src/common/database/schema.ts +++ b/src/common/database/schema.ts @@ -64,6 +64,10 @@ export const wallets = pgTable( 'wallets_fractional_balance_non_negative', sql`${table.fractionalBalance} >= 0`, ), + check( + 'wallets_fractional_balance_lt_100', + sql`${table.fractionalBalance} < 100`, + ), ], ); diff --git a/src/common/redis/redis.module.ts b/src/common/redis/redis.module.ts index c6744d4..ccc817e 100644 --- a/src/common/redis/redis.module.ts +++ b/src/common/redis/redis.module.ts @@ -1,4 +1,4 @@ -import { Global, Module } from '@nestjs/common'; +import { Global, Inject, Module, OnModuleDestroy } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import Redis from 'ioredis'; @@ -24,4 +24,10 @@ export const REDIS_CLIENT = 'REDIS_CLIENT'; ], exports: [REDIS_CLIENT], }) -export class RedisModule {} +export class RedisModule implements OnModuleDestroy { + constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {} + + async onModuleDestroy() { + await this.redis.quit(); + } +} diff --git a/src/purchases/purchases.service.spec.ts b/src/purchases/purchases.service.spec.ts index 3e6e616..45056f0 100644 --- a/src/purchases/purchases.service.spec.ts +++ b/src/purchases/purchases.service.spec.ts @@ -624,6 +624,84 @@ describe('PurchasesService — Redis idempotency cache', () => { ); }); + it('deletes owned sentinel when DB idempotency check throws ConflictException before transaction', async () => { + const mockTx = createMockTx(); + const mockDb = createMockDb(mockTx); + const mockRedis = createMockRedis(); + + // NX returns 'OK' — this request set the sentinel + mockRedis.set.mockResolvedValue('OK'); + // DB idempotency check: existing completed purchase with different payload → payload drift + mockDb.where.mockResolvedValueOnce([ + { + id: 'purchase-1', + idempotencyKey: 'key-1', + status: 'completed', + buyerWalletId: 'wallet-buyer', + authorWalletId: 'wallet-author', + itemPrice: 999, // differs from purchaseDto.itemPrice (1000) → drift + }, + ]); + + const service = await createTestService(mockDb, mockRedis); + + await expect(service.purchase(purchaseDto)).rejects.toThrow( + ConflictException, + ); + // Sentinel must be cleaned up even though the throw happened before the transaction + expect(mockRedis.del).toHaveBeenCalledWith('idempotency:key-1'); + }); + + it('does NOT delete sentinel when this request did not set it (NX=null, eviction race, DB conflict)', async () => { + const mockTx = createMockTx(); + const mockDb = createMockDb(mockTx); + const mockRedis = createMockRedis(); + + // NX returns null — another request's sentinel exists; eviction race: GET returns null + mockRedis.set.mockResolvedValue(null); + mockRedis.get.mockResolvedValue(null); + // DB idempotency check: finds pending purchase + mockDb.where.mockResolvedValueOnce([ + { id: 'p-1', idempotencyKey: 'key-1', status: 'pending' }, + ]); + + const service = await createTestService(mockDb, mockRedis); + + await expect(service.purchase(purchaseDto)).rejects.toThrow( + ConflictException, + ); + // We did NOT set the sentinel — must not del it + expect(mockRedis.del).not.toHaveBeenCalled(); + }); + + it('does NOT delete sentinel on PG_UNIQUE_VIOLATION (concurrent winner may have cached result)', async () => { + const pgError = Object.assign(new PostgresError('duplicate key'), { + code: '23505', + severity_local: 'ERROR', + severity: 'ERROR', + }); + const drizzleQueryError = Object.assign( + new Error('Failed query: insert...'), + { cause: pgError }, + ); + + const mockTx = createMockTx(); + const mockDb = createMockDb(mockTx); + mockDb.where.mockResolvedValue([]); // no existing purchase + mockDb.transaction = jest.fn().mockRejectedValue(drizzleQueryError); + + const mockRedis = createMockRedis(); + mockRedis.set.mockResolvedValue('OK'); // this request set the sentinel + + const service = await createTestService(mockDb, mockRedis); + + await expect(service.purchase(purchaseDto)).rejects.toThrow( + ConflictException, + ); + // The concurrent winner may have already set the cached result — do NOT del + expect(mockRedis.del).not.toHaveBeenCalled(); + }); + it('clears the processing sentinel when the transaction fails', async () => { const mockTx = createMockTx(); const mockDb = createMockDb(mockTx); diff --git a/src/purchases/purchases.service.ts b/src/purchases/purchases.service.ts index d29aa61..872fe58 100644 --- a/src/purchases/purchases.service.ts +++ b/src/purchases/purchases.service.ts @@ -51,6 +51,11 @@ export class PurchasesService { const redisKey = `idempotency:${dto.idempotencyKey}`; + // Tracks whether THIS request set the 'processing' sentinel. Only the + // request that set it should delete it — prevents clobbering another + // request's sentinel or cached result. + let sentinelSet = false; + // Redis SETNX edge-cache — short-circuit before hitting the DB try { const nx = await this.redis.set( @@ -60,6 +65,7 @@ export class PurchasesService { IDEMPOTENCY_TTL, 'NX', ); + sentinelSet = nx === 'OK'; if (nx === null) { // Key exists in Redis — cached result or in-flight @@ -160,44 +166,57 @@ export class PurchasesService { .where(eq(schema.purchases.idempotencyKey, dto.idempotencyKey)); if (existing) { - if (existing.status === 'completed') { - // Payload drift check — same key must have same intent - if ( - existing.buyerWalletId !== dto.buyerWalletId || - existing.authorWalletId !== dto.authorWalletId || - existing.itemPrice !== dto.itemPrice - ) { - throw new ConflictException( - 'Idempotency key already used with different purchase parameters', - ); - } - // Ownership check — prevent unauthorized access to purchase records via - // replayed idempotency keys belonging to another user's transaction - const [buyerWallet] = await this.db - .select({ userId: schema.wallets.userId }) - .from(schema.wallets) - .where(eq(schema.wallets.id, existing.buyerWalletId)); - if (!buyerWallet || buyerWallet.userId !== dto.requestUserId) { - throw new ForbiddenException( - 'Buyer wallet does not belong to the authenticated user', - ); + // Wrap so any throw here cleans up the sentinel we set (if we set it). + // Without this, stale 'processing' sentinels block retries for 24 h. + try { + if (existing.status === 'completed') { + // Payload drift check — same key must have same intent + if ( + existing.buyerWalletId !== dto.buyerWalletId || + existing.authorWalletId !== dto.authorWalletId || + existing.itemPrice !== dto.itemPrice + ) { + throw new ConflictException( + 'Idempotency key already used with different purchase parameters', + ); + } + // Ownership check — prevent unauthorized access to purchase records via + // replayed idempotency keys belonging to another user's transaction + const [buyerWallet] = await this.db + .select({ userId: schema.wallets.userId }) + .from(schema.wallets) + .where(eq(schema.wallets.id, existing.buyerWalletId)); + if (!buyerWallet || buyerWallet.userId !== dto.requestUserId) { + throw new ForbiddenException( + 'Buyer wallet does not belong to the authenticated user', + ); + } + // Populate Redis on DB cold-start hit + try { + await this.redis.set( + redisKey, + JSON.stringify(existing), + 'EX', + IDEMPOTENCY_TTL, + ); + } catch { + /* Redis unavailable — ignore */ + } + return existing; } - // Populate Redis on DB cold-start hit - try { - await this.redis.set( - redisKey, - JSON.stringify(existing), - 'EX', - IDEMPOTENCY_TTL, - ); - } catch { - /* Redis unavailable — ignore */ + throw new ConflictException( + 'Purchase with this idempotency key is still in flight', + ); + } catch (error) { + if (sentinelSet) { + try { + await this.redis.del(redisKey); + } catch { + /* Redis unavailable — ignore */ + } } - return existing; + throw error; } - throw new ConflictException( - 'Purchase with this idempotency key is still in flight', - ); } let result: typeof schema.purchases.$inferSelect; @@ -383,16 +402,6 @@ export class PurchasesService { return purchase; }); } catch (error) { - // Delete the 'processing' sentinel so clients can retry after a failed transaction. - // This applies to ALL errors — including business-rule failures (insufficient funds, - // 403, 404) — because no data has been committed. Preserving the sentinel would - // block retries for 24 h on deterministic errors, which is the wrong behaviour. - try { - await this.redis.del(redisKey); - } catch { - /* Redis unavailable — ignore */ - } - // Drizzle may wrap the raw PostgresError in a DrizzleQueryError, so we // check both the error itself and its cause for the pg error code. const pgError = @@ -401,6 +410,19 @@ export class PurchasesService { : error instanceof Error && error.cause instanceof PostgresError ? error.cause : null; + + // Only delete the sentinel if this request set it AND the error is not a + // PG_UNIQUE_VIOLATION. On unique_violation a concurrent request won the race + // and may have already written the completed-purchase JSON to Redis — a blind + // DEL would clobber that cached result and force the next replay to hit the DB. + if (sentinelSet && pgError?.code !== PG_UNIQUE_VIOLATION) { + try { + await this.redis.del(redisKey); + } catch { + /* Redis unavailable — ignore */ + } + } + // 23505: unique_violation — concurrent duplicate idempotency key if (pgError?.code === PG_UNIQUE_VIOLATION) { throw new ConflictException( From d40e5afeae38bff763d0bd6ce9b402c460f36325 Mon Sep 17 00:00:00 2001 From: 0-sayed Date: Sun, 12 Apr 2026 08:46:39 +0200 Subject: [PATCH 15/16] refactor: consolidate migration 0002 into 0001 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the wallets_fractional_balance_lt_100 CHECK constraint from the separate migration (0002_stormy_sasquatch) into the original migration (0001_modern_rogue) alongside the fractional_balance column and its non-negative check. No functional change — both constraints are applied atomically in a single migration step now. --- drizzle/0001_modern_rogue.sql | 3 +- drizzle/0002_stormy_sasquatch.sql | 1 - drizzle/meta/0001_snapshot.json | 6 +- drizzle/meta/0002_snapshot.json | 472 ------------------------------ drizzle/meta/_journal.json | 7 - 5 files changed, 7 insertions(+), 482 deletions(-) delete mode 100644 drizzle/0002_stormy_sasquatch.sql delete mode 100644 drizzle/meta/0002_snapshot.json diff --git a/drizzle/0001_modern_rogue.sql b/drizzle/0001_modern_rogue.sql index e6dc100..7ac722d 100644 --- a/drizzle/0001_modern_rogue.sql +++ b/drizzle/0001_modern_rogue.sql @@ -4,7 +4,8 @@ CREATE TABLE "ledger_totals" ( ); --> statement-breakpoint ALTER TABLE "wallets" ADD COLUMN "fractional_balance" integer DEFAULT 0 NOT NULL;--> statement-breakpoint -ALTER TABLE "wallets" ADD CONSTRAINT "wallets_fractional_balance_non_negative" CHECK ("wallets"."fractional_balance" >= 0); +ALTER TABLE "wallets" ADD CONSTRAINT "wallets_fractional_balance_non_negative" CHECK ("wallets"."fractional_balance" >= 0);--> statement-breakpoint +ALTER TABLE "wallets" ADD CONSTRAINT "wallets_fractional_balance_lt_100" CHECK ("wallets"."fractional_balance" < 100); -- Backfill running totals from existing ledger data INSERT INTO ledger_totals (type, total) diff --git a/drizzle/0002_stormy_sasquatch.sql b/drizzle/0002_stormy_sasquatch.sql deleted file mode 100644 index 008d759..0000000 --- a/drizzle/0002_stormy_sasquatch.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "wallets" ADD CONSTRAINT "wallets_fractional_balance_lt_100" CHECK ("wallets"."fractional_balance" < 100); \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json index c77a40c..a234232 100644 --- a/drizzle/meta/0001_snapshot.json +++ b/drizzle/meta/0001_snapshot.json @@ -411,6 +411,10 @@ "wallets_fractional_balance_non_negative": { "name": "wallets_fractional_balance_non_negative", "value": "\"wallets\".\"fractional_balance\" >= 0" + }, + "wallets_fractional_balance_lt_100": { + "name": "wallets_fractional_balance_lt_100", + "value": "\"wallets\".\"fractional_balance\" < 100" } }, "isRLSEnabled": false @@ -465,4 +469,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json deleted file mode 100644 index 60fe6f3..0000000 --- a/drizzle/meta/0002_snapshot.json +++ /dev/null @@ -1,472 +0,0 @@ -{ - "id": "d68f7eb7-498e-4072-b0d6-e77a3ed6fbfe", - "prevId": "31d2e6f2-3e9e-42b1-ad72-2d9d2c0855ae", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.ledger": { - "name": "ledger", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "wallet_id": { - "name": "wallet_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "type": { - "name": "type", - "type": "ledger_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "direction": { - "name": "direction", - "type": "ledger_direction", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "amount": { - "name": "amount", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "purchase_id": { - "name": "purchase_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "ledger_wallet_id_wallets_id_fk": { - "name": "ledger_wallet_id_wallets_id_fk", - "tableFrom": "ledger", - "tableTo": "wallets", - "columnsFrom": [ - "wallet_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "ledger_purchase_id_purchases_id_fk": { - "name": "ledger_purchase_id_purchases_id_fk", - "tableFrom": "ledger", - "tableTo": "purchases", - "columnsFrom": [ - "purchase_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "ledger_amount_positive": { - "name": "ledger_amount_positive", - "value": "\"ledger\".\"amount\" > 0" - } - }, - "isRLSEnabled": false - }, - "public.ledger_totals": { - "name": "ledger_totals", - "schema": "", - "columns": { - "type": { - "name": "type", - "type": "ledger_type", - "typeSchema": "public", - "primaryKey": true, - "notNull": true - }, - "total": { - "name": "total", - "type": "bigint", - "primaryKey": false, - "notNull": true, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.purchases": { - "name": "purchases", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "idempotency_key": { - "name": "idempotency_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "buyer_wallet_id": { - "name": "buyer_wallet_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "author_wallet_id": { - "name": "author_wallet_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "item_price": { - "name": "item_price", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "purchase_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'pending'" - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "purchases_idempotency_key_idx": { - "name": "purchases_idempotency_key_idx", - "columns": [ - { - "expression": "idempotency_key", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "purchases_buyer_wallet_id_wallets_id_fk": { - "name": "purchases_buyer_wallet_id_wallets_id_fk", - "tableFrom": "purchases", - "tableTo": "wallets", - "columnsFrom": [ - "buyer_wallet_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "purchases_author_wallet_id_wallets_id_fk": { - "name": "purchases_author_wallet_id_wallets_id_fk", - "tableFrom": "purchases", - "tableTo": "wallets", - "columnsFrom": [ - "author_wallet_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "purchases_item_price_positive": { - "name": "purchases_item_price_positive", - "value": "\"purchases\".\"item_price\" > 0" - } - }, - "isRLSEnabled": false - }, - "public.reports": { - "name": "reports", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "status": { - "name": "status", - "type": "report_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'queued'" - }, - "result": { - "name": "result", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "requested_by": { - "name": "requested_by", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "completed_at": { - "name": "completed_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "reports_requested_by_users_id_fk": { - "name": "reports_requested_by_users_id_fk", - "tableFrom": "reports", - "tableTo": "users", - "columnsFrom": [ - "requested_by" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.users": { - "name": "users", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.wallets": { - "name": "wallets", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "balance": { - "name": "balance", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "fractional_balance": { - "name": "fractional_balance", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "wallets_user_id_idx": { - "name": "wallets_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "wallets_user_id_users_id_fk": { - "name": "wallets_user_id_users_id_fk", - "tableFrom": "wallets", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "wallets_balance_non_negative": { - "name": "wallets_balance_non_negative", - "value": "\"wallets\".\"balance\" >= 0" - }, - "wallets_fractional_balance_non_negative": { - "name": "wallets_fractional_balance_non_negative", - "value": "\"wallets\".\"fractional_balance\" >= 0" - }, - "wallets_fractional_balance_lt_100": { - "name": "wallets_fractional_balance_lt_100", - "value": "\"wallets\".\"fractional_balance\" < 100" - } - }, - "isRLSEnabled": false - } - }, - "enums": { - "public.ledger_direction": { - "name": "ledger_direction", - "schema": "public", - "values": [ - "credit", - "debit" - ] - }, - "public.ledger_type": { - "name": "ledger_type", - "schema": "public", - "values": [ - "deposit", - "purchase", - "royalty_author", - "royalty_platform" - ] - }, - "public.purchase_status": { - "name": "purchase_status", - "schema": "public", - "values": [ - "pending", - "completed", - "failed" - ] - }, - "public.report_status": { - "name": "report_status", - "schema": "public", - "values": [ - "queued", - "processing", - "completed", - "failed" - ] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 3e0a984..85e449c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,13 +15,6 @@ "when": 1775968666133, "tag": "0001_modern_rogue", "breakpoints": true - }, - { - "idx": 2, - "version": "7", - "when": 1775975526670, - "tag": "0002_stormy_sasquatch", - "breakpoints": true } ] } \ No newline at end of file From f6d8ee41dc4bf9338fb001bbcb09fcf842561502 Mon Sep 17 00:00:00 2001 From: 0-sayed Date: Sun, 12 Apr 2026 08:59:55 +0200 Subject: [PATCH 16/16] fix: delete sentinel when post-commit or cold-start cache write fails When redis.set fails after a successful DB commit or on a DB cold-start hit, the 'processing' sentinel was silently left in Redis for 24h. Subsequent idempotent replays would hit the stale sentinel and get a false 409 'still in flight' instead of the completed purchase result. Fix: in both catch blocks, delete the sentinel when sentinelSet=true so retries fall through to the DB path rather than hitting stale state. --- src/purchases/purchases.service.spec.ts | 85 +++++++++++++++++++++++++ src/purchases/purchases.service.ts | 20 +++++- 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/src/purchases/purchases.service.spec.ts b/src/purchases/purchases.service.spec.ts index 45056f0..0d7a6db 100644 --- a/src/purchases/purchases.service.spec.ts +++ b/src/purchases/purchases.service.spec.ts @@ -778,4 +778,89 @@ describe('PurchasesService — Redis idempotency cache', () => { expect(secondCall[2]).toBe('EX'); expect(secondCall[3]).toBe(86400); }); + + it('deletes owned sentinel when post-commit cache write fails', async () => { + const mockTx = createMockTx(); + const mockDb = createMockDb(mockTx); + const mockRedis = createMockRedis(); + + // First set: NX sentinel → OK; second set: post-commit cache write → fails + mockRedis.set.mockResolvedValueOnce('OK'); + mockRedis.set.mockRejectedValueOnce(new Error('Redis down')); + // DB idempotency check: no existing purchase + mockDb.where.mockResolvedValue([]); + + const purchase = { + id: 'purchase-1', + idempotencyKey: 'key-1', + status: 'completed', + buyerWalletId: 'wallet-buyer', + authorWalletId: 'wallet-author', + itemPrice: 1000, + createdAt: new Date(), + }; + + mockTx.for.mockResolvedValue([ + { + id: 'wallet-buyer', + userId: 'user-1', + balance: 5000, + fractionalBalance: 0, + }, + { + id: 'wallet-author', + userId: 'author-user', + balance: 0, + fractionalBalance: 0, + }, + { + id: 'platform-wallet-id', + userId: 'platform-user', + balance: 0, + fractionalBalance: 0, + }, + ]); + mockTx.returning.mockResolvedValueOnce([purchase]); + + const service = await createTestService(mockDb, mockRedis); + const result = await service.purchase(purchaseDto); + + // Purchase still returned — commit succeeded + expect(result).toEqual(purchase); + // Sentinel must be deleted so retries fall through to DB rather than getting stale 409 + expect(mockRedis.del).toHaveBeenCalledWith('idempotency:key-1'); + }); + + it('deletes owned sentinel when DB cold-start cache write fails', async () => { + const mockTx = createMockTx(); + const mockDb = createMockDb(mockTx); + const mockRedis = createMockRedis(); + + // First set: NX sentinel → OK; second set: cold-start cache write → fails + mockRedis.set.mockResolvedValueOnce('OK'); + mockRedis.set.mockRejectedValueOnce(new Error('Redis down')); + // DB idempotency check: finds existing completed purchase + mockDb.where + .mockResolvedValueOnce([ + { + id: 'purchase-1', + idempotencyKey: 'key-1', + status: 'completed', + buyerWalletId: 'wallet-buyer', + authorWalletId: 'wallet-author', + itemPrice: 1000, + createdAt: new Date(), + }, + ]) + // Ownership check: wallet belongs to the requesting user + .mockResolvedValueOnce([{ userId: 'user-1' }]); + + const service = await createTestService(mockDb, mockRedis); + const existing = await service.purchase(purchaseDto); + + // Result returned normally — DB path succeeded + expect(existing).toBeDefined(); + // Sentinel must be deleted so retries fall through to DB rather than getting stale 409 + expect(mockRedis.del).toHaveBeenCalledWith('idempotency:key-1'); + }); }); diff --git a/src/purchases/purchases.service.ts b/src/purchases/purchases.service.ts index 872fe58..1811edd 100644 --- a/src/purchases/purchases.service.ts +++ b/src/purchases/purchases.service.ts @@ -200,7 +200,15 @@ export class PurchasesService { IDEMPOTENCY_TTL, ); } catch { - /* Redis unavailable — ignore */ + // Cache write failed — delete our sentinel so retries fall through to DB + // rather than hitting a stale 'processing' key and getting a false 409 + if (sentinelSet) { + try { + await this.redis.del(redisKey); + } catch { + /* ignore */ + } + } } return existing; } @@ -449,7 +457,15 @@ export class PurchasesService { IDEMPOTENCY_TTL, ); } catch { - /* Redis unavailable — ignore */ + // Cache write failed — delete our sentinel so retries fall through to DB + // rather than hitting a stale 'processing' key and getting a false 409 + if (sentinelSet) { + try { + await this.redis.del(redisKey); + } catch { + /* ignore */ + } + } } return result;