@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.js CHANGED
@@ -79,7 +79,22 @@ __decorateClass([
79
79
  ], LockedMintEntity.prototype, "userOpHash", 2);
80
80
  LockedMintEntity = __decorateClass([
81
81
  Entity({ name: "locked_mint_requests" }),
82
- Index(["userAddress", "status"])
82
+ Index("IDX_locked_mint_user_token_status_expires", [
83
+ "userAddress",
84
+ "tokenAddress",
85
+ "status",
86
+ "expiresAt"
87
+ ]),
88
+ Index("IDX_locked_mint_user_token_amount_status", [
89
+ "userAddress",
90
+ "tokenAddress",
91
+ "amount",
92
+ "status"
93
+ ]),
94
+ Index("IDX_locked_mint_pending_expires", ["expiresAt"], {
95
+ where: `"status" = 'PENDING'`
96
+ }),
97
+ Index("IDX_locked_mint_user_op_hash", ["userOpHash"])
83
98
  ], LockedMintEntity);
84
99
 
85
100
  // src/entities/pending-credit.entity.ts
@@ -244,11 +259,22 @@ __decorateClass([
244
259
  ], LedgerJournalEntity.prototype, "createdAt", 2);
245
260
  LedgerJournalEntity = __decorateClass([
246
261
  Entity4({ name: "ledger_journal" }),
247
- Index3(["userAddress", "createdAt"])
262
+ Index3(["userAddress", "createdAt"]),
263
+ Index3(
264
+ "UQ_ledger_journal_user_token_tx_reason",
265
+ ["userAddress", "tokenAddress", "txHash", "reason"],
266
+ {
267
+ unique: true,
268
+ where: '"tx_hash" IS NOT NULL'
269
+ }
270
+ )
248
271
  ], LedgerJournalEntity);
249
272
 
250
273
  // src/postgresPointLedger.ts
251
274
  var RETRIABLE_PG_CODES = /* @__PURE__ */ new Set(["40P01", "40001"]);
275
+ var UNIQUE_VIOLATION = "23505";
276
+ var JOURNAL_IDEMPOTENCY_CONSTRAINT = "UQ_ledger_journal_user_token_tx_reason";
277
+ var LEGACY_JOURNAL_IDEMPOTENCY_CONSTRAINT = "UQ_ledger_journal_user_tx_reason";
252
278
  function isRetriablePgError(err) {
253
279
  const e = err;
254
280
  if (!e) return false;
@@ -258,6 +284,18 @@ function isRetriablePgError(err) {
258
284
  }
259
285
  return false;
260
286
  }
287
+ function isJournalIdempotencyViolation(err) {
288
+ const e = err;
289
+ if (!e) return false;
290
+ const code = e.code ?? e.driverError?.code;
291
+ if (code !== UNIQUE_VIOLATION) return false;
292
+ const constraint = e.constraint ?? e.driverError?.constraint;
293
+ if (constraint === JOURNAL_IDEMPOTENCY_CONSTRAINT || constraint === LEGACY_JOURNAL_IDEMPOTENCY_CONSTRAINT) {
294
+ return true;
295
+ }
296
+ const message = e.message ?? e.driverError?.message ?? "";
297
+ return message.includes(JOURNAL_IDEMPOTENCY_CONSTRAINT) || message.includes(LEGACY_JOURNAL_IDEMPOTENCY_CONSTRAINT);
298
+ }
261
299
  async function withDeadlockRetry(dataSource, fn, maxAttempts = 3) {
262
300
  let attempt = 0;
263
301
  let delayMs = 25;
@@ -453,43 +491,72 @@ var PostgresPointLedger = class {
453
491
  throw new Error("deductBalance: amount must be positive");
454
492
  }
455
493
  const { user, token } = normalize(userAddress, tokenAddress);
456
- await withDeadlockRetry(this.dataSource, async (tx) => {
457
- const balance = await tx.getRepository(UserBalanceEntity).createQueryBuilder("balance").setLock("pessimistic_write").where("balance.user_address = :user", { user }).andWhere("balance.token_address = :token", { token }).getOne();
458
- if (!balance || balance.balance < amount) {
459
- throw new Error(
460
- `Cannot deduct ${amount} from balance ${balance?.balance ?? 0n}`
494
+ try {
495
+ await withDeadlockRetry(this.dataSource, async (tx) => {
496
+ const balance = await tx.getRepository(UserBalanceEntity).createQueryBuilder("balance").setLock("pessimistic_write").where("balance.user_address = :user", { user }).andWhere("balance.token_address = :token", { token }).getOne();
497
+ const already = await tx.getRepository(LedgerJournalEntity).findOne({
498
+ where: {
499
+ userAddress: user,
500
+ tokenAddress: token,
501
+ txHash,
502
+ reason: "MINT_CONFIRMED"
503
+ }
504
+ });
505
+ if (already) {
506
+ this.logger?.debug?.(
507
+ `deductBalance: idempotent skip tx=${txHash} user=${user} token=${token}`
508
+ );
509
+ return;
510
+ }
511
+ if (!balance || balance.balance < amount) {
512
+ throw new Error(
513
+ `Cannot deduct ${amount} from balance ${balance?.balance ?? 0n}`
514
+ );
515
+ }
516
+ await tx.getRepository(UserBalanceEntity).update(
517
+ { userAddress: user, tokenAddress: token },
518
+ { balance: balance.balance - amount }
461
519
  );
462
- }
463
- await tx.getRepository(UserBalanceEntity).update(
464
- { userAddress: user, tokenAddress: token },
465
- { balance: balance.balance - amount }
466
- );
467
- await tx.getRepository(LedgerJournalEntity).insert({
468
- userAddress: user,
469
- tokenAddress: token,
470
- delta: -amount,
471
- reason: "MINT_CONFIRMED",
472
- txHash
473
- });
474
- const match = await tx.getRepository(LockedMintEntity).findOne({
475
- where: {
520
+ await tx.getRepository(LedgerJournalEntity).insert({
476
521
  userAddress: user,
477
522
  tokenAddress: token,
478
- amount,
479
- status: "PENDING"
480
- },
481
- order: { createdAt: "ASC" }
523
+ delta: -amount,
524
+ reason: "MINT_CONFIRMED",
525
+ txHash
526
+ });
527
+ const match = await tx.getRepository(LockedMintEntity).findOne({
528
+ where: {
529
+ userAddress: user,
530
+ tokenAddress: token,
531
+ amount,
532
+ status: "PENDING"
533
+ },
534
+ order: { createdAt: "ASC" }
535
+ });
536
+ if (match) {
537
+ await tx.getRepository(LockedMintEntity).update({ id: match.id }, { status: "MINTED", txHash });
538
+ }
482
539
  });
483
- if (match) {
484
- await tx.getRepository(LockedMintEntity).update({ id: match.id }, { status: "MINTED", txHash });
540
+ } catch (err) {
541
+ if (isJournalIdempotencyViolation(err)) {
542
+ this.logger?.debug?.(
543
+ `deductBalance: idempotent (concurrent race) tx=${txHash} user=${user}`
544
+ );
545
+ return;
485
546
  }
486
- });
547
+ throw err;
548
+ }
487
549
  this.logger?.log?.(`deduct ${user}[${token}] -${amount} tx=${txHash}`);
488
550
  }
489
551
  async updateMintStatus(lockId, status, txHash) {
490
552
  const update = { status };
491
553
  if (txHash) update.txHash = txHash;
492
- await this.dataSource.getRepository(LockedMintEntity).update({ id: lockId }, update);
554
+ const result = await this.dataSource.getRepository(LockedMintEntity).createQueryBuilder().update().set(update).where("id = :id", { id: lockId }).andWhere("status = :pending", { pending: "PENDING" }).execute();
555
+ if ((result.affected ?? 0) === 0) {
556
+ this.logger?.debug?.(
557
+ `updateMintStatus: lock ${lockId} not in PENDING (terminal state), no-op`
558
+ );
559
+ }
493
560
  }
494
561
  async bindMintUserOpHash(lockId, userOpHash) {
495
562
  await this.dataSource.getRepository(LockedMintEntity).update({ id: lockId }, { userOpHash });
@@ -521,7 +588,7 @@ var PostgresPointLedger = class {
521
588
  return row.id;
522
589
  }
523
590
  async resolveCreditByBurnTx(lockId, txHash) {
524
- await withDeadlockRetry(this.dataSource, async (tx) => {
591
+ const run = async () => withDeadlockRetry(this.dataSource, async (tx) => {
525
592
  const credit = await tx.getRepository(PendingCreditEntity).createQueryBuilder("credit").setLock("pessimistic_write").where("credit.id = :id", { id: lockId }).getOne();
526
593
  if (!credit) {
527
594
  throw new Error(
@@ -574,6 +641,18 @@ var PostgresPointLedger = class {
574
641
  { status: "RESOLVED", txHash, resolvedAt: /* @__PURE__ */ new Date() }
575
642
  );
576
643
  });
644
+ try {
645
+ await run();
646
+ } catch (err) {
647
+ if (isJournalIdempotencyViolation(err)) {
648
+ this.logger?.debug?.(
649
+ `resolveCreditByBurnTx: concurrent race on tx=${txHash} lock=${lockId}, replaying via sibling defense`
650
+ );
651
+ await run();
652
+ } else {
653
+ throw err;
654
+ }
655
+ }
577
656
  this.logger?.log?.(`resolve pending credit ${lockId} tx=${txHash}`);
578
657
  }
579
658
  /**
@@ -930,10 +1009,103 @@ var CreateRedemptionHistory1746230400001 = class {
930
1009
  }
931
1010
  };
932
1011
 
1012
+ // src/migrations/1747500000000-AddJournalIdempotencyIndex.ts
1013
+ var AddJournalIdempotencyIndex1747500000000 = class {
1014
+ name = "AddJournalIdempotencyIndex1747500000000";
1015
+ async up(queryRunner) {
1016
+ await queryRunner.query(`
1017
+ CREATE UNIQUE INDEX IF NOT EXISTS "UQ_ledger_journal_user_tx_reason"
1018
+ ON "ledger_journal" ("user_address", "tx_hash", "reason")
1019
+ WHERE "tx_hash" IS NOT NULL
1020
+ `);
1021
+ }
1022
+ async down(queryRunner) {
1023
+ await queryRunner.query(
1024
+ `DROP INDEX IF EXISTS "UQ_ledger_journal_user_tx_reason"`
1025
+ );
1026
+ }
1027
+ };
1028
+
1029
+ // src/migrations/1747600000000-AddLockedMintCompositeIndexes.ts
1030
+ var AddLockedMintCompositeIndexes1747600000000 = class {
1031
+ name = "AddLockedMintCompositeIndexes1747600000000";
1032
+ /**
1033
+ * CONCURRENTLY index DDL cannot run inside a transaction. Tell
1034
+ * TypeORM to issue these statements directly.
1035
+ */
1036
+ transaction = false;
1037
+ async up(queryRunner) {
1038
+ await queryRunner.query(`
1039
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS
1040
+ "IDX_locked_mint_user_token_status_expires"
1041
+ ON "locked_mint_requests"
1042
+ ("user_address", "token_address", "status", "expires_at")
1043
+ `);
1044
+ await queryRunner.query(`
1045
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS
1046
+ "IDX_locked_mint_user_token_amount_status"
1047
+ ON "locked_mint_requests"
1048
+ ("user_address", "token_address", "amount", "status")
1049
+ `);
1050
+ await queryRunner.query(`
1051
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS
1052
+ "IDX_locked_mint_pending_expires"
1053
+ ON "locked_mint_requests" ("expires_at")
1054
+ WHERE "status" = 'PENDING'
1055
+ `);
1056
+ await queryRunner.query(
1057
+ `DROP INDEX CONCURRENTLY IF EXISTS "IDX_locked_mint_user_status"`
1058
+ );
1059
+ }
1060
+ async down(queryRunner) {
1061
+ await queryRunner.query(`
1062
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_locked_mint_user_status"
1063
+ ON "locked_mint_requests" ("user_address", "token_address", "status")
1064
+ `);
1065
+ await queryRunner.query(
1066
+ `DROP INDEX CONCURRENTLY IF EXISTS "IDX_locked_mint_pending_expires"`
1067
+ );
1068
+ await queryRunner.query(
1069
+ `DROP INDEX CONCURRENTLY IF EXISTS "IDX_locked_mint_user_token_amount_status"`
1070
+ );
1071
+ await queryRunner.query(
1072
+ `DROP INDEX CONCURRENTLY IF EXISTS "IDX_locked_mint_user_token_status_expires"`
1073
+ );
1074
+ }
1075
+ };
1076
+
1077
+ // src/migrations/1747700000000-FixIdempotencyAddTokenAddress.ts
1078
+ var FixIdempotencyAddTokenAddress1747700000000 = class {
1079
+ name = "FixIdempotencyAddTokenAddress1747700000000";
1080
+ async up(queryRunner) {
1081
+ await queryRunner.query(
1082
+ `DROP INDEX IF EXISTS "UQ_ledger_journal_user_tx_reason"`
1083
+ );
1084
+ await queryRunner.query(`
1085
+ CREATE UNIQUE INDEX IF NOT EXISTS "UQ_ledger_journal_user_token_tx_reason"
1086
+ ON "ledger_journal" ("user_address", "token_address", "tx_hash", "reason")
1087
+ WHERE "tx_hash" IS NOT NULL
1088
+ `);
1089
+ }
1090
+ async down(queryRunner) {
1091
+ await queryRunner.query(
1092
+ `DROP INDEX IF EXISTS "UQ_ledger_journal_user_token_tx_reason"`
1093
+ );
1094
+ await queryRunner.query(`
1095
+ CREATE UNIQUE INDEX IF NOT EXISTS "UQ_ledger_journal_user_tx_reason"
1096
+ ON "ledger_journal" ("user_address", "tx_hash", "reason")
1097
+ WHERE "tx_hash" IS NOT NULL
1098
+ `);
1099
+ }
1100
+ };
1101
+
933
1102
  // src/migrations/index.ts
934
1103
  var PAFI_MIGRATIONS = [
935
1104
  InitialSchema1700000000000,
936
- CreateRedemptionHistory1746230400001
1105
+ CreateRedemptionHistory1746230400001,
1106
+ AddJournalIdempotencyIndex1747500000000,
1107
+ AddLockedMintCompositeIndexes1747600000000,
1108
+ FixIdempotencyAddTokenAddress1747700000000
937
1109
  ];
938
1110
  export {
939
1111
  CreateRedemptionHistory1746230400001,