@moneypot/hub 1.16.0 → 1.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,21 +2,19 @@ import * as pg from "pg";
2
2
  import stream from "node:stream";
3
3
  import { DbCasino, DbExperience, DbSession, DbTransferStatusKind, DbUser, DbWithdrawal } from "./types.js";
4
4
  import { TransferStatusKind } from "../__generated__/graphql.js";
5
+ import { PgClientInTransaction } from "./transaction.js";
5
6
  export * from "../hash-chain/db-hash-chain.js";
6
7
  export * from "./types.js";
7
8
  export * from "./public.js";
8
9
  export * from "./util.js";
9
10
  export declare function getPgClient(connectionString: string): InstanceType<typeof pg.Client>;
11
+ export * from "./transaction.js";
10
12
  export interface QueryExecutor {
11
13
  query<T extends pg.QueryResultRow = any>(queryText: string, values?: any[]): Promise<pg.QueryResult<T>>;
12
14
  }
13
15
  export declare class UserFriendlyError extends Error {
14
16
  constructor(userFriendlyMessage: string);
15
17
  }
16
- export type PgClientInTransaction = pg.PoolClient & {
17
- _inTransaction: true;
18
- };
19
- export declare function withPgPoolTransaction<T>(pool: pg.Pool, callback: (_pgClient: PgClientInTransaction) => Promise<T>, retryCount?: number, maxRetries?: number): Promise<T>;
20
18
  export declare function userFromActiveSessionKey(pgClient: QueryExecutor, sessionKey: string): Promise<{
21
19
  user: DbUser;
22
20
  sessionId: DbSession["id"];
@@ -2,9 +2,9 @@ import * as pg from "pg";
2
2
  import stream from "node:stream";
3
3
  import { exactlyOneRow, maybeOneRow } from "./util.js";
4
4
  import { logger } from "../logger.js";
5
- import { setTimeout } from "node:timers/promises";
6
5
  import { assert } from "tsafe";
7
6
  import { insertAuditLog } from "../audit-log.js";
7
+ import { withPgPoolTransaction } from "./transaction.js";
8
8
  export * from "../hash-chain/db-hash-chain.js";
9
9
  export * from "./types.js";
10
10
  export * from "./public.js";
@@ -13,61 +13,13 @@ export function getPgClient(connectionString) {
13
13
  const client = new pg.Client({ connectionString });
14
14
  return client;
15
15
  }
16
+ export * from "./transaction.js";
16
17
  export class UserFriendlyError extends Error {
17
18
  constructor(userFriendlyMessage) {
18
19
  super(userFriendlyMessage);
19
20
  this.name = "UserFriendlyError";
20
21
  }
21
22
  }
22
- const PG_ERROR_CODE = {
23
- deadlock: "40P01",
24
- serializationFailure: "40001",
25
- };
26
- export async function withPgPoolTransaction(pool, callback, retryCount = 0, maxRetries = 3) {
27
- let pgClient = null;
28
- try {
29
- pgClient = await pool.connect();
30
- await pgClient.query("begin isolation level serializable");
31
- Object.defineProperty(pgClient, "_inTransaction", {
32
- value: true,
33
- writable: false,
34
- enumerable: false,
35
- configurable: false,
36
- });
37
- const result = await callback(pgClient);
38
- await pgClient.query("commit");
39
- return result;
40
- }
41
- catch (error) {
42
- if (pgClient) {
43
- try {
44
- await pgClient.query("rollback");
45
- }
46
- catch (rollbackError) {
47
- logger.error(error, "Original error");
48
- logger.error(rollbackError, "Rollback failed");
49
- pgClient.release(true);
50
- pgClient = null;
51
- throw error;
52
- }
53
- if (retryCount < maxRetries &&
54
- error instanceof pg.DatabaseError &&
55
- (error.code === PG_ERROR_CODE.deadlock ||
56
- error.code === PG_ERROR_CODE.serializationFailure)) {
57
- const backoffMs = Math.min(100 * Math.pow(2, retryCount), 2000);
58
- logger.warn(`Retrying transaction in ${Math.floor(backoffMs)}ms (attempt ${retryCount + 2}/${maxRetries + 1}) due to pg error code ${error.code}: ${error.message}`);
59
- await setTimeout(backoffMs);
60
- return withPgPoolTransaction(pool, callback, retryCount + 1, maxRetries);
61
- }
62
- }
63
- throw error;
64
- }
65
- finally {
66
- if (pgClient) {
67
- pgClient.release();
68
- }
69
- }
70
- }
71
23
  export async function userFromActiveSessionKey(pgClient, sessionKey) {
72
24
  const result = await pgClient
73
25
  .query(`
@@ -186,10 +138,10 @@ export async function insertDeposit(pool, o) {
186
138
  if (o.amount <= 0) {
187
139
  throw new UserFriendlyError("amount must be positive");
188
140
  }
189
- return withPgPoolTransaction(pool, async (client) => {
141
+ return withPgPoolTransaction(pool, async (pgClient) => {
190
142
  let dbDeposit;
191
143
  try {
192
- dbDeposit = await client
144
+ dbDeposit = await pgClient
193
145
  .query(`
194
146
  INSERT INTO hub.deposit(casino_id, mp_transfer_id, user_id, experience_id, amount, currency_key)
195
147
  VALUES ($1, $2, $3, $4, $5, $6)
@@ -214,19 +166,35 @@ export async function insertDeposit(pool, o) {
214
166
  }
215
167
  throw e;
216
168
  }
217
- const dbBalance = await client
169
+ await pgClient.query(`
170
+ INSERT INTO hub.balance (casino_id, user_id, experience_id, currency_key, amount)
171
+ VALUES ($1, $2, $3, $4, 0)
172
+ ON CONFLICT (casino_id, user_id, experience_id, currency_key) DO NOTHING
173
+ `, [o.casinoId, o.userId, o.experienceId, o.currency]);
174
+ const dbLockedBalance = await pgClient
218
175
  .query(`
219
- insert into hub.balance(casino_id, user_id, experience_id, currency_key, amount)
220
- values ($1, $2, $3, $4, $5)
221
- on conflict (casino_id, user_id, experience_id, currency_key) do update
222
- set amount = balance.amount + excluded.amount
223
- returning id, amount
224
- `, [o.casinoId, o.userId, o.experienceId, o.currency, o.amount])
176
+ SELECT id, amount
177
+ FROM hub.balance
178
+ WHERE casino_id = $1
179
+ AND user_id = $2
180
+ AND experience_id = $3
181
+ AND currency_key = $4
182
+
183
+ FOR UPDATE
184
+ `, [o.casinoId, o.userId, o.experienceId, o.currency])
185
+ .then(exactlyOneRow);
186
+ const dbBalanceAfterUpdate = await pgClient
187
+ .query(`
188
+ UPDATE hub.balance
189
+ SET amount = amount + $1
190
+ WHERE id = $2
191
+ RETURNING id, amount
192
+ `, [o.amount, dbLockedBalance.id])
225
193
  .then(exactlyOneRow);
226
- await insertAuditLog(client, "player-balance", {
227
- balanceId: dbBalance.id,
228
- balanceOld: dbBalance.amount - o.amount,
229
- balanceNew: dbBalance.amount,
194
+ await insertAuditLog(pgClient, "player-balance", {
195
+ balanceId: dbLockedBalance.id,
196
+ balanceOld: dbLockedBalance.amount,
197
+ balanceNew: dbBalanceAfterUpdate.amount,
230
198
  balanceDelta: o.amount,
231
199
  action: "hub:deposit",
232
200
  refType: "hub.deposit",
@@ -235,7 +203,6 @@ export async function insertDeposit(pool, o) {
235
203
  });
236
204
  }
237
205
  export async function processWithdrawal(pgClient, { casinoId, mpTransferId, userId, experienceId, amount, currency, status, }) {
238
- assert(pgClient._inTransaction, "pgClient must be in transaction");
239
206
  await pgClient.query(`
240
207
  INSERT INTO hub.withdrawal (
241
208
  casino_id,
@@ -0,0 +1,17 @@
1
+ import * as pg from "pg";
2
+ declare const PgClientInTransactionBrand: unique symbol;
3
+ export type PgClientInTransaction = pg.PoolClient & {
4
+ readonly [PgClientInTransactionBrand]: true;
5
+ };
6
+ export declare function isInTransaction(pgClient: pg.PoolClient): pgClient is PgClientInTransaction;
7
+ export declare function assertInTransaction(pgClient: pg.PoolClient): asserts pgClient is PgClientInTransaction;
8
+ export declare function getIsolationLevel(pgClient: PgClientInTransaction): IsolationLevel | null;
9
+ export declare const IsolationLevel: {
10
+ readonly READ_COMMITTED: "READ COMMITTED";
11
+ readonly REPEATABLE_READ: "REPEATABLE READ";
12
+ readonly SERIALIZABLE: "SERIALIZABLE";
13
+ };
14
+ export type IsolationLevel = (typeof IsolationLevel)[keyof typeof IsolationLevel];
15
+ export declare function withPgPoolTransaction<T>(pool: pg.Pool, callback: (pgClient: PgClientInTransaction) => Promise<T>, retryCount?: number, maxRetries?: number): Promise<T>;
16
+ export declare function withPgPoolTransaction<T>(pool: pg.Pool, isolationLevel: IsolationLevel, callback: (pgClient: PgClientInTransaction) => Promise<T>, retryCount?: number, maxRetries?: number): Promise<T>;
17
+ export {};
@@ -0,0 +1,93 @@
1
+ import * as pg from "pg";
2
+ import { logger } from "../logger.js";
3
+ import { setTimeout } from "node:timers/promises";
4
+ const PgClientInTransactionBrand = Symbol("PgClientInTransaction");
5
+ const PG_ERROR_CODE = {
6
+ deadlock: "40P01",
7
+ serializationFailure: "40001",
8
+ };
9
+ const SIDECAR = new WeakMap();
10
+ export function isInTransaction(pgClient) {
11
+ return SIDECAR.has(pgClient);
12
+ }
13
+ export function assertInTransaction(pgClient) {
14
+ if (!isInTransaction(pgClient)) {
15
+ throw new Error("Must be called inside a transaction");
16
+ }
17
+ }
18
+ export function getIsolationLevel(pgClient) {
19
+ return SIDECAR.get(pgClient) ?? null;
20
+ }
21
+ export const IsolationLevel = {
22
+ READ_COMMITTED: "READ COMMITTED",
23
+ REPEATABLE_READ: "REPEATABLE READ",
24
+ SERIALIZABLE: "SERIALIZABLE",
25
+ };
26
+ export async function withPgPoolTransaction(pool, callbackOrIsolationLevel, callbackOrRetryCount, retryCountOrMaxRetries = 0, maxRetries = 3) {
27
+ let callback;
28
+ let isolationLevel = IsolationLevel.SERIALIZABLE;
29
+ let retryCount = 0;
30
+ if (typeof callbackOrIsolationLevel === "function") {
31
+ callback = callbackOrIsolationLevel;
32
+ if (typeof callbackOrRetryCount === "number") {
33
+ retryCount = callbackOrRetryCount;
34
+ maxRetries = retryCountOrMaxRetries;
35
+ }
36
+ }
37
+ else {
38
+ isolationLevel = callbackOrIsolationLevel;
39
+ callback = callbackOrRetryCount;
40
+ retryCount = retryCountOrMaxRetries;
41
+ }
42
+ let pgClient = null;
43
+ try {
44
+ pgClient = await pool.connect();
45
+ if (isolationLevel === IsolationLevel.READ_COMMITTED) {
46
+ await pgClient.query("BEGIN");
47
+ }
48
+ else {
49
+ await pgClient.query(`BEGIN ISOLATION LEVEL ${isolationLevel}`);
50
+ }
51
+ SIDECAR.set(pgClient, isolationLevel);
52
+ assertInTransaction(pgClient);
53
+ const result = await callback(pgClient);
54
+ await pgClient.query("COMMIT");
55
+ return result;
56
+ }
57
+ catch (error) {
58
+ if (pgClient) {
59
+ try {
60
+ await pgClient.query("ROLLBACK");
61
+ }
62
+ catch (rollbackError) {
63
+ logger.error(error, "Original error");
64
+ logger.error(rollbackError, "Rollback failed");
65
+ SIDECAR.delete(pgClient);
66
+ pgClient.release(true);
67
+ pgClient = null;
68
+ throw error;
69
+ }
70
+ if (retryCount < maxRetries &&
71
+ error instanceof pg.DatabaseError &&
72
+ (error.code === PG_ERROR_CODE.deadlock ||
73
+ error.code === PG_ERROR_CODE.serializationFailure)) {
74
+ const backoffMs = Math.min(100 * Math.pow(2, retryCount), 2000);
75
+ logger.warn(`Retrying transaction in ${Math.floor(backoffMs)}ms (attempt ${retryCount + 2}/${maxRetries + 1}) due to pg error code ${error.code}: ${error.message}`);
76
+ await setTimeout(backoffMs);
77
+ if (isolationLevel === IsolationLevel.READ_COMMITTED) {
78
+ return withPgPoolTransaction(pool, callback, retryCount + 1, maxRetries);
79
+ }
80
+ else {
81
+ return withPgPoolTransaction(pool, isolationLevel, callback, retryCount + 1, maxRetries);
82
+ }
83
+ }
84
+ }
85
+ throw error;
86
+ }
87
+ finally {
88
+ if (pgClient) {
89
+ SIDECAR.delete(pgClient);
90
+ pgClient.release();
91
+ }
92
+ }
93
+ }
@@ -3,7 +3,6 @@ import { exactlyOneRow, maybeOneRow, } from "../db/index.js";
3
3
  import { assert } from "tsafe";
4
4
  import { logger } from "../logger.js";
5
5
  export async function dbLockHubHashChain(pgClient, { userId, experienceId, casinoId, hashChainId, }) {
6
- assert(pgClient._inTransaction, "dbLockHubHashChain must be called in a transaction");
7
6
  const hashChain = await pgClient
8
7
  .query(`
9
8
  SELECT *
@@ -32,7 +31,6 @@ export async function dbInsertHubHash(pgClient, { hashChainId, kind, digest, ite
32
31
  metadata,
33
32
  digest: Buffer.from(digest).toString("base64"),
34
33
  }, "[dbInsertHubHash] Inserting hash");
35
- assert(pgClient._inTransaction, "dbInsertHash must be called in a transaction");
36
34
  if (kind === DbHashKind.INTERMEDIATE) {
37
35
  assert(typeof clientSeed === "string", "clientSeed must be provided for INTERMEDIATE hashes");
38
36
  }
@@ -2,9 +2,7 @@ import { logger } from "../logger.js";
2
2
  import { getPreimageHash } from "./get-hash.js";
3
3
  import { DbHashKind } from "../db/types.js";
4
4
  import { dbInsertHubHash } from "../db/index.js";
5
- import { assert } from "tsafe";
6
5
  export async function dbRevealHashChain(pgClient, { hashChainId, }) {
7
- assert(pgClient._inTransaction, "dbRevealHashChain must be called in a transaction");
8
6
  logger.debug({ hashChainId }, "Revealing hash chain");
9
7
  const preimageHashResult = await getPreimageHash({
10
8
  hashChainId,
@@ -1,4 +1,3 @@
1
- import { assert } from "tsafe";
2
1
  var LockNamespace;
3
2
  (function (LockNamespace) {
4
3
  LockNamespace[LockNamespace["MP_TAKE_REQUEST"] = 1] = "MP_TAKE_REQUEST";
@@ -26,15 +25,12 @@ async function acquireAdvisoryLock(pgClient, namespace, hash) {
26
25
  }
27
26
  export const PgAdvisoryLock = {
28
27
  forMpTakeRequestProcessing: async (pgClient, params) => {
29
- assert(pgClient._inTransaction, "pgClient must be in a transaction");
30
28
  await acquireAdvisoryLock(pgClient, LockNamespace.MP_TAKE_REQUEST, createHashKey(params.mpTakeRequestId, params.casinoId));
31
29
  },
32
30
  forNewHashChain: async (pgClient, params) => {
33
- assert(pgClient._inTransaction, "pgClient must be in a transaction");
34
31
  await acquireAdvisoryLock(pgClient, LockNamespace.NEW_HASH_CHAIN, createHashKey(params.userId, params.experienceId, params.casinoId));
35
32
  },
36
33
  forChatPlayerAction: async (pgClient, params) => {
37
- assert(pgClient._inTransaction, "pgClient must be in a transaction");
38
34
  await acquireAdvisoryLock(pgClient, LockNamespace.CHAT_PLAYER_ACTION, createHashKey(params.userId, params.experienceId, params.casinoId));
39
35
  },
40
36
  forChatModManagement: async (pgClient, params) => {
@@ -4,7 +4,6 @@ import { assert } from "tsafe";
4
4
  import { isUuid } from "../util.js";
5
5
  import { createGraphqlClient } from "../graphql-client.js";
6
6
  import { GET_CURRENCIES } from "../graphql-queries.js";
7
- import { processWithdrawalRequests } from "../process-withdrawal-request.js";
8
7
  import { processTakeRequests } from "../take-request/process-take-request.js";
9
8
  import * as jwtService from "../services/jwt-service.js";
10
9
  import { dbGetCasinoById, dbGetCasinoSecretById } from "../db/internal.js";
@@ -230,11 +229,6 @@ async function processTransfersUntilEmpty({ afterCursor, graphqlClient, casinoIn
230
229
  let hasNextPage = true;
231
230
  const timeout = (ms) => new Promise((res) => setTimeout(res, ms));
232
231
  while (hasNextPage && !signal.aborted) {
233
- await processWithdrawalRequests({
234
- casinoId: casinoInfo.id,
235
- graphqlClient,
236
- pool,
237
- });
238
232
  if (signal.aborted) {
239
233
  logger.info(`[processTransfersUntilEmpty] Aborted by graceful shutdown.`);
240
234
  break;
@@ -107,6 +107,21 @@ export async function processTransfer({ casinoId, controllerId, transfer, graphq
107
107
  }
108
108
  logger.debug(data, "MP_CLAIM_TRANSFER response");
109
109
  if (data.claimTransfer?.result.__typename !== "ClaimTransferSuccess") {
110
+ if (data.claimTransfer?.result.__typename ===
111
+ "InvalidTransferStatus" &&
112
+ data.claimTransfer?.result.currentStatus ===
113
+ TransferStatusKind.Completed) {
114
+ logger.info(`Transfer ${transfer.id} already claimed (status: COMPLETED), skipping claim but attempting deposit insert`);
115
+ await db.insertDeposit(pool, {
116
+ casinoId,
117
+ mpTransferId: transfer.id,
118
+ userId: dbSender.id,
119
+ experienceId: dbExperience.id,
120
+ amount: transfer.amount,
121
+ currency: currency.id,
122
+ });
123
+ return;
124
+ }
110
125
  throw new Error(`Failed to claim transfer: ${JSON.stringify(data.claimTransfer)}`);
111
126
  }
112
127
  await db.insertDeposit(pool, {
@@ -243,7 +243,6 @@ export async function processSingleTakeRequest({ mpTakeRequestId, mpTakeRequest,
243
243
  });
244
244
  }
245
245
  async function createAndProcessNewTakeRequest({ pgClient, mpTakeRequest, casinoId, graphqlClient, }) {
246
- assert(pgClient._inTransaction, "pgClient must be in a transaction");
247
246
  assert(mpTakeRequest.status === MpTakeRequestStatus.Pending);
248
247
  const { dbUser, dbExperience, dbCurrency, dbBalance } = await loadRequiredEntities(pgClient, {
249
248
  type: "mpId",
@@ -327,7 +326,6 @@ async function createAndProcessNewTakeRequest({ pgClient, mpTakeRequest, casinoI
327
326
  });
328
327
  }
329
328
  async function processExistingTakeRequest({ pgClient, takeRequest, casinoId, graphqlClient, }) {
330
- assert(pgClient._inTransaction, "pgClient must be in a transaction");
331
329
  switch (takeRequest.status) {
332
330
  case LocalTakeRequestStatus.PENDING:
333
331
  logger.info(`[processExistingTakeRequest] Take request ${takeRequest.id} in PENDING state`);
@@ -371,7 +369,6 @@ async function processExistingTakeRequest({ pgClient, takeRequest, casinoId, gra
371
369
  return null;
372
370
  }
373
371
  async function attemptTransfer({ pgClient, takeRequestId, mpTakeRequestId, mpExperienceId, mpUserId, amount, currencyKey, graphqlClient, }) {
374
- assert(pgClient._inTransaction, "pgClient must be in a transaction");
375
372
  try {
376
373
  const transferResult = await graphqlClient.request(MP_TRANSFER_TAKE_REQUEST, {
377
374
  mpTakeRequestId,
@@ -592,20 +589,18 @@ export async function completeTransfer({ mpTakeRequestId, takeRequestId, mpTrans
592
589
  logger.warn({ mpTransferId, takeRequestId }, `[completeTransfer] Transfer was already refunded. This should never happen.`);
593
590
  break;
594
591
  }
595
- const dbBalanceAfterUpdate = await pgClient
592
+ const dbLockedBalance = await pgClient
596
593
  .query({
597
594
  text: `
598
- UPDATE hub.balance
599
- SET amount = amount + $1
600
- WHERE user_id = $2
601
- AND experience_id = $3
602
- AND casino_id = $4
603
- AND currency_key = $5
604
-
605
- RETURNING id, amount
595
+ SELECT id, amount
596
+ FROM hub.balance
597
+ WHERE user_id = $1
598
+ AND experience_id = $2
599
+ AND casino_id = $3
600
+ AND currency_key = $4
601
+ FOR UPDATE
606
602
  `,
607
603
  values: [
608
- takeRequestData.reserved_amount,
609
604
  takeRequestData.user_id,
610
605
  takeRequestData.experience_id,
611
606
  takeRequestData.casino_id,
@@ -613,10 +608,18 @@ export async function completeTransfer({ mpTakeRequestId, takeRequestId, mpTrans
613
608
  ],
614
609
  })
615
610
  .then(exactlyOneRow);
611
+ await pgClient.query({
612
+ text: `
613
+ UPDATE hub.balance
614
+ SET amount = amount + $1
615
+ WHERE id = $2
616
+ `,
617
+ values: [takeRequestData.reserved_amount, dbLockedBalance.id],
618
+ });
616
619
  await insertAuditLog(pgClient, "player-balance", {
617
- balanceId: dbBalanceAfterUpdate.id,
618
- balanceOld: dbBalanceAfterUpdate.amount - takeRequestData.reserved_amount,
619
- balanceNew: dbBalanceAfterUpdate.amount,
620
+ balanceId: dbLockedBalance.id,
621
+ balanceOld: dbLockedBalance.amount,
622
+ balanceNew: dbLockedBalance.amount + takeRequestData.reserved_amount,
620
623
  balanceDelta: takeRequestData.reserved_amount,
621
624
  action: "hub:take_request:refund",
622
625
  refType: "hub.take_request",
@@ -740,7 +743,6 @@ async function processStuckRequests({ casinoId, graphqlClient, abortSignal, pool
740
743
  }
741
744
  async function loadRequiredEntities(pgClient, params) {
742
745
  const { type, currencyKey, casinoId } = params;
743
- assert(pgClient._inTransaction, "pgClient must be in a transaction");
744
746
  const dbCurrency = await pgClient
745
747
  .query({
746
748
  text: `
@@ -839,7 +841,6 @@ async function loadRequiredEntities(pgClient, params) {
839
841
  return { dbCurrency, dbUser, dbExperience, dbBalance };
840
842
  }
841
843
  async function rejectMpTakeRequest(pgClient, graphqlClient, mpTakeRequestId, takeRequestId) {
842
- assert(pgClient._inTransaction, "pgClient must be in a transaction");
843
844
  try {
844
845
  logger.info(`[rejectMpTakeRequest] Rejecting take request ${mpTakeRequestId}`);
845
846
  const rejectResult = await graphqlClient.request(MP_REJECT_TAKE_REQUEST, {
@@ -916,7 +917,6 @@ async function rejectMpTakeRequest(pgClient, graphqlClient, mpTakeRequestId, tak
916
917
  }
917
918
  }
918
919
  async function updateTakeRequestStatus(pgClient, takeRequestId, status) {
919
- assert(pgClient._inTransaction, "pgClient must be in a transaction");
920
920
  return pgClient.query({
921
921
  text: `
922
922
  UPDATE hub.take_request
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moneypot/hub",
3
- "version": "1.16.0",
3
+ "version": "1.16.1",
4
4
  "author": "moneypot.com",
5
5
  "homepage": "https://moneypot.com/hub",
6
6
  "keywords": [