@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/README.md +37 -10
- package/dist/entities/index.cjs +25 -2
- package/dist/entities/index.cjs.map +1 -1
- package/dist/entities/index.d.cts +39 -2
- package/dist/entities/index.d.ts +39 -2
- package/dist/entities/index.js +25 -2
- package/dist/entities/index.js.map +1 -1
- package/dist/index.cjs +203 -31
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +203 -31
- package/dist/index.js.map +1 -1
- package/dist/migrations/index.cjs +100 -1
- package/dist/migrations/index.cjs.map +1 -1
- package/dist/migrations/index.d.cts +136 -6
- package/dist/migrations/index.d.ts +136 -6
- package/dist/migrations/index.js +97 -1
- package/dist/migrations/index.js.map +1 -1
- package/package.json +16 -5
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)(
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
501
|
-
|
|
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 },
|
|
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
|
-
|
|
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 = {
|