@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.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(
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
484
|
-
|
|
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 },
|
|
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
|
-
|
|
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,
|