@pafi-dev/issuer-postgres 0.2.0 → 0.3.0

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.
package/dist/index.cjs CHANGED
@@ -108,7 +108,22 @@ __decorateClass([
108
108
  ], LockedMintEntity.prototype, "userOpHash", 2);
109
109
  LockedMintEntity = __decorateClass([
110
110
  (0, import_typeorm.Entity)({ name: "locked_mint_requests" }),
111
- (0, import_typeorm.Index)(["userAddress", "status"])
111
+ (0, import_typeorm.Index)("IDX_locked_mint_user_token_status_expires", [
112
+ "userAddress",
113
+ "tokenAddress",
114
+ "status",
115
+ "expiresAt"
116
+ ]),
117
+ (0, import_typeorm.Index)("IDX_locked_mint_user_token_amount_status", [
118
+ "userAddress",
119
+ "tokenAddress",
120
+ "amount",
121
+ "status"
122
+ ]),
123
+ (0, import_typeorm.Index)("IDX_locked_mint_pending_expires", ["expiresAt"], {
124
+ where: `"status" = 'PENDING'`
125
+ }),
126
+ (0, import_typeorm.Index)("IDX_locked_mint_user_op_hash", ["userOpHash"])
112
127
  ], LockedMintEntity);
113
128
 
114
129
  // src/entities/pending-credit.entity.ts
@@ -261,11 +276,22 @@ __decorateClass([
261
276
  ], LedgerJournalEntity.prototype, "createdAt", 2);
262
277
  LedgerJournalEntity = __decorateClass([
263
278
  (0, import_typeorm4.Entity)({ name: "ledger_journal" }),
264
- (0, import_typeorm4.Index)(["userAddress", "createdAt"])
279
+ (0, import_typeorm4.Index)(["userAddress", "createdAt"]),
280
+ (0, import_typeorm4.Index)(
281
+ "UQ_ledger_journal_user_token_tx_reason",
282
+ ["userAddress", "tokenAddress", "txHash", "reason"],
283
+ {
284
+ unique: true,
285
+ where: '"tx_hash" IS NOT NULL'
286
+ }
287
+ )
265
288
  ], LedgerJournalEntity);
266
289
 
267
290
  // src/postgresPointLedger.ts
268
291
  var RETRIABLE_PG_CODES = /* @__PURE__ */ new Set(["40P01", "40001"]);
292
+ var UNIQUE_VIOLATION = "23505";
293
+ var JOURNAL_IDEMPOTENCY_CONSTRAINT = "UQ_ledger_journal_user_token_tx_reason";
294
+ var LEGACY_JOURNAL_IDEMPOTENCY_CONSTRAINT = "UQ_ledger_journal_user_tx_reason";
269
295
  function isRetriablePgError(err) {
270
296
  const e = err;
271
297
  if (!e) return false;
@@ -275,6 +301,18 @@ function isRetriablePgError(err) {
275
301
  }
276
302
  return false;
277
303
  }
304
+ function isJournalIdempotencyViolation(err) {
305
+ const e = err;
306
+ if (!e) return false;
307
+ const code = e.code ?? e.driverError?.code;
308
+ if (code !== UNIQUE_VIOLATION) return false;
309
+ const constraint = e.constraint ?? e.driverError?.constraint;
310
+ if (constraint === JOURNAL_IDEMPOTENCY_CONSTRAINT || constraint === LEGACY_JOURNAL_IDEMPOTENCY_CONSTRAINT) {
311
+ return true;
312
+ }
313
+ const message = e.message ?? e.driverError?.message ?? "";
314
+ return message.includes(JOURNAL_IDEMPOTENCY_CONSTRAINT) || message.includes(LEGACY_JOURNAL_IDEMPOTENCY_CONSTRAINT);
315
+ }
278
316
  async function withDeadlockRetry(dataSource, fn, maxAttempts = 3) {
279
317
  let attempt = 0;
280
318
  let delayMs = 25;
@@ -470,43 +508,72 @@ var PostgresPointLedger = class {
470
508
  throw new Error("deductBalance: amount must be positive");
471
509
  }
472
510
  const { user, token } = normalize(userAddress, tokenAddress);
473
- await withDeadlockRetry(this.dataSource, async (tx) => {
474
- const balance = await tx.getRepository(UserBalanceEntity).createQueryBuilder("balance").setLock("pessimistic_write").where("balance.user_address = :user", { user }).andWhere("balance.token_address = :token", { token }).getOne();
475
- if (!balance || balance.balance < amount) {
476
- throw new Error(
477
- `Cannot deduct ${amount} from balance ${balance?.balance ?? 0n}`
511
+ try {
512
+ await withDeadlockRetry(this.dataSource, async (tx) => {
513
+ const balance = await tx.getRepository(UserBalanceEntity).createQueryBuilder("balance").setLock("pessimistic_write").where("balance.user_address = :user", { user }).andWhere("balance.token_address = :token", { token }).getOne();
514
+ const already = await tx.getRepository(LedgerJournalEntity).findOne({
515
+ where: {
516
+ userAddress: user,
517
+ tokenAddress: token,
518
+ txHash,
519
+ reason: "MINT_CONFIRMED"
520
+ }
521
+ });
522
+ if (already) {
523
+ this.logger?.debug?.(
524
+ `deductBalance: idempotent skip tx=${txHash} user=${user} token=${token}`
525
+ );
526
+ return;
527
+ }
528
+ if (!balance || balance.balance < amount) {
529
+ throw new Error(
530
+ `Cannot deduct ${amount} from balance ${balance?.balance ?? 0n}`
531
+ );
532
+ }
533
+ await tx.getRepository(UserBalanceEntity).update(
534
+ { userAddress: user, tokenAddress: token },
535
+ { balance: balance.balance - amount }
478
536
  );
479
- }
480
- await tx.getRepository(UserBalanceEntity).update(
481
- { userAddress: user, tokenAddress: token },
482
- { balance: balance.balance - amount }
483
- );
484
- await tx.getRepository(LedgerJournalEntity).insert({
485
- userAddress: user,
486
- tokenAddress: token,
487
- delta: -amount,
488
- reason: "MINT_CONFIRMED",
489
- txHash
490
- });
491
- const match = await tx.getRepository(LockedMintEntity).findOne({
492
- where: {
537
+ await tx.getRepository(LedgerJournalEntity).insert({
493
538
  userAddress: user,
494
539
  tokenAddress: token,
495
- amount,
496
- status: "PENDING"
497
- },
498
- order: { createdAt: "ASC" }
540
+ delta: -amount,
541
+ reason: "MINT_CONFIRMED",
542
+ txHash
543
+ });
544
+ const match = await tx.getRepository(LockedMintEntity).findOne({
545
+ where: {
546
+ userAddress: user,
547
+ tokenAddress: token,
548
+ amount,
549
+ status: "PENDING"
550
+ },
551
+ order: { createdAt: "ASC" }
552
+ });
553
+ if (match) {
554
+ await tx.getRepository(LockedMintEntity).update({ id: match.id }, { status: "MINTED", txHash });
555
+ }
499
556
  });
500
- if (match) {
501
- await tx.getRepository(LockedMintEntity).update({ id: match.id }, { status: "MINTED", txHash });
557
+ } catch (err) {
558
+ if (isJournalIdempotencyViolation(err)) {
559
+ this.logger?.debug?.(
560
+ `deductBalance: idempotent (concurrent race) tx=${txHash} user=${user}`
561
+ );
562
+ return;
502
563
  }
503
- });
564
+ throw err;
565
+ }
504
566
  this.logger?.log?.(`deduct ${user}[${token}] -${amount} tx=${txHash}`);
505
567
  }
506
568
  async updateMintStatus(lockId, status, txHash) {
507
569
  const update = { status };
508
570
  if (txHash) update.txHash = txHash;
509
- await this.dataSource.getRepository(LockedMintEntity).update({ id: lockId }, update);
571
+ const result = await this.dataSource.getRepository(LockedMintEntity).createQueryBuilder().update().set(update).where("id = :id", { id: lockId }).andWhere("status = :pending", { pending: "PENDING" }).execute();
572
+ if ((result.affected ?? 0) === 0) {
573
+ this.logger?.debug?.(
574
+ `updateMintStatus: lock ${lockId} not in PENDING (terminal state), no-op`
575
+ );
576
+ }
510
577
  }
511
578
  async bindMintUserOpHash(lockId, userOpHash) {
512
579
  await this.dataSource.getRepository(LockedMintEntity).update({ id: lockId }, { userOpHash });
@@ -538,7 +605,7 @@ var PostgresPointLedger = class {
538
605
  return row.id;
539
606
  }
540
607
  async resolveCreditByBurnTx(lockId, txHash) {
541
- await withDeadlockRetry(this.dataSource, async (tx) => {
608
+ const run = async () => withDeadlockRetry(this.dataSource, async (tx) => {
542
609
  const credit = await tx.getRepository(PendingCreditEntity).createQueryBuilder("credit").setLock("pessimistic_write").where("credit.id = :id", { id: lockId }).getOne();
543
610
  if (!credit) {
544
611
  throw new Error(
@@ -591,6 +658,18 @@ var PostgresPointLedger = class {
591
658
  { status: "RESOLVED", txHash, resolvedAt: /* @__PURE__ */ new Date() }
592
659
  );
593
660
  });
661
+ try {
662
+ await run();
663
+ } catch (err) {
664
+ if (isJournalIdempotencyViolation(err)) {
665
+ this.logger?.debug?.(
666
+ `resolveCreditByBurnTx: concurrent race on tx=${txHash} lock=${lockId}, replaying via sibling defense`
667
+ );
668
+ await run();
669
+ } else {
670
+ throw err;
671
+ }
672
+ }
594
673
  this.logger?.log?.(`resolve pending credit ${lockId} tx=${txHash}`);
595
674
  }
596
675
  /**
@@ -941,10 +1020,103 @@ var CreateRedemptionHistory1746230400001 = class {
941
1020
  }
942
1021
  };
943
1022
 
1023
+ // src/migrations/1747500000000-AddJournalIdempotencyIndex.ts
1024
+ var AddJournalIdempotencyIndex1747500000000 = class {
1025
+ name = "AddJournalIdempotencyIndex1747500000000";
1026
+ async up(queryRunner) {
1027
+ await queryRunner.query(`
1028
+ CREATE UNIQUE INDEX IF NOT EXISTS "UQ_ledger_journal_user_tx_reason"
1029
+ ON "ledger_journal" ("user_address", "tx_hash", "reason")
1030
+ WHERE "tx_hash" IS NOT NULL
1031
+ `);
1032
+ }
1033
+ async down(queryRunner) {
1034
+ await queryRunner.query(
1035
+ `DROP INDEX IF EXISTS "UQ_ledger_journal_user_tx_reason"`
1036
+ );
1037
+ }
1038
+ };
1039
+
1040
+ // src/migrations/1747600000000-AddLockedMintCompositeIndexes.ts
1041
+ var AddLockedMintCompositeIndexes1747600000000 = class {
1042
+ name = "AddLockedMintCompositeIndexes1747600000000";
1043
+ /**
1044
+ * CONCURRENTLY index DDL cannot run inside a transaction. Tell
1045
+ * TypeORM to issue these statements directly.
1046
+ */
1047
+ transaction = false;
1048
+ async up(queryRunner) {
1049
+ await queryRunner.query(`
1050
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS
1051
+ "IDX_locked_mint_user_token_status_expires"
1052
+ ON "locked_mint_requests"
1053
+ ("user_address", "token_address", "status", "expires_at")
1054
+ `);
1055
+ await queryRunner.query(`
1056
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS
1057
+ "IDX_locked_mint_user_token_amount_status"
1058
+ ON "locked_mint_requests"
1059
+ ("user_address", "token_address", "amount", "status")
1060
+ `);
1061
+ await queryRunner.query(`
1062
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS
1063
+ "IDX_locked_mint_pending_expires"
1064
+ ON "locked_mint_requests" ("expires_at")
1065
+ WHERE "status" = 'PENDING'
1066
+ `);
1067
+ await queryRunner.query(
1068
+ `DROP INDEX CONCURRENTLY IF EXISTS "IDX_locked_mint_user_status"`
1069
+ );
1070
+ }
1071
+ async down(queryRunner) {
1072
+ await queryRunner.query(`
1073
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_locked_mint_user_status"
1074
+ ON "locked_mint_requests" ("user_address", "token_address", "status")
1075
+ `);
1076
+ await queryRunner.query(
1077
+ `DROP INDEX CONCURRENTLY IF EXISTS "IDX_locked_mint_pending_expires"`
1078
+ );
1079
+ await queryRunner.query(
1080
+ `DROP INDEX CONCURRENTLY IF EXISTS "IDX_locked_mint_user_token_amount_status"`
1081
+ );
1082
+ await queryRunner.query(
1083
+ `DROP INDEX CONCURRENTLY IF EXISTS "IDX_locked_mint_user_token_status_expires"`
1084
+ );
1085
+ }
1086
+ };
1087
+
1088
+ // src/migrations/1747700000000-FixIdempotencyAddTokenAddress.ts
1089
+ var FixIdempotencyAddTokenAddress1747700000000 = class {
1090
+ name = "FixIdempotencyAddTokenAddress1747700000000";
1091
+ async up(queryRunner) {
1092
+ await queryRunner.query(
1093
+ `DROP INDEX IF EXISTS "UQ_ledger_journal_user_tx_reason"`
1094
+ );
1095
+ await queryRunner.query(`
1096
+ CREATE UNIQUE INDEX IF NOT EXISTS "UQ_ledger_journal_user_token_tx_reason"
1097
+ ON "ledger_journal" ("user_address", "token_address", "tx_hash", "reason")
1098
+ WHERE "tx_hash" IS NOT NULL
1099
+ `);
1100
+ }
1101
+ async down(queryRunner) {
1102
+ await queryRunner.query(
1103
+ `DROP INDEX IF EXISTS "UQ_ledger_journal_user_token_tx_reason"`
1104
+ );
1105
+ await queryRunner.query(`
1106
+ CREATE UNIQUE INDEX IF NOT EXISTS "UQ_ledger_journal_user_tx_reason"
1107
+ ON "ledger_journal" ("user_address", "tx_hash", "reason")
1108
+ WHERE "tx_hash" IS NOT NULL
1109
+ `);
1110
+ }
1111
+ };
1112
+
944
1113
  // src/migrations/index.ts
945
1114
  var PAFI_MIGRATIONS = [
946
1115
  InitialSchema1700000000000,
947
- CreateRedemptionHistory1746230400001
1116
+ CreateRedemptionHistory1746230400001,
1117
+ AddJournalIdempotencyIndex1747500000000,
1118
+ AddLockedMintCompositeIndexes1747600000000,
1119
+ FixIdempotencyAddTokenAddress1747700000000
948
1120
  ];
949
1121
  // Annotate the CommonJS export names for ESM import in node:
950
1122
  0 && (module.exports = {