@pafi-dev/issuer-postgres 0.2.0 → 0.4.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.
@@ -3,10 +3,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm';
3
3
  /**
4
4
  * Single consolidated initial schema for `@pafi-dev/issuer-postgres`.
5
5
  *
6
- * Combines what gg56 split into two migrations (`InitialSchema` +
7
- * `AddPendingCredits`) plus the `user_op_hash` column, since this is
8
- * the v1.4-and-after baseline. Issuers adopting the SDK from scratch
9
- * apply this once.
6
+ * Issuers adopting the SDK from scratch apply this migration once;
7
+ * subsequent schema changes ship as follow-up migrations.
10
8
  *
11
9
  * Tables:
12
10
  * user_balances — off-chain point balance per (user, token)
@@ -40,6 +38,138 @@ declare class CreateRedemptionHistory1746230400001 implements MigrationInterface
40
38
  down(queryRunner: QueryRunner): Promise<void>;
41
39
  }
42
40
 
41
+ /**
42
+ * Adds a partial unique index on `ledger_journal` to enforce
43
+ * idempotency for indexer-driven reasons:
44
+ *
45
+ * UNIQUE (user_address, tx_hash, reason) WHERE tx_hash IS NOT NULL
46
+ *
47
+ * Last-line defense against double-deduct / double-credit when the
48
+ * mint or burn indexer replays the same on-chain event (reorg, pod
49
+ * restart, duplicate replica). Rows with `tx_hash IS NULL` (off-chain
50
+ * credits like AIRDROP, refunds) are unaffected by the partial
51
+ * predicate.
52
+ *
53
+ * Pre-flight: existing duplicate `(user_address, tx_hash, reason)`
54
+ * rows with non-null `tx_hash` will block index creation. Operators
55
+ * who suspect prior double-processing should reconcile manually
56
+ * before applying. The query below surfaces offenders:
57
+ *
58
+ * SELECT user_address, tx_hash, reason, COUNT(*)
59
+ * FROM ledger_journal
60
+ * WHERE tx_hash IS NOT NULL
61
+ * GROUP BY user_address, tx_hash, reason
62
+ * HAVING COUNT(*) > 1;
63
+ */
64
+ declare class AddJournalIdempotencyIndex1747500000000 implements MigrationInterface {
65
+ name: string;
66
+ up(queryRunner: QueryRunner): Promise<void>;
67
+ down(queryRunner: QueryRunner): Promise<void>;
68
+ }
69
+
70
+ /**
71
+ * Replaces the narrow `IDX_locked_mint_user_status` (3 cols:
72
+ * `user_address`, `token_address`, `status`) with three composite
73
+ * indexes that match the actual query shapes used by the SDK.
74
+ *
75
+ * Index rationale:
76
+ *
77
+ * `IDX_locked_mint_user_token_status_expires`
78
+ * Covers `sumPendingLocks` (called inside every `getBalance` +
79
+ * `lockForMinting`). The 4-col index lets Postgres push
80
+ * `expires_at > NOW()` into the index range scan instead of
81
+ * post-filtering rows from the heap. Without it, unswept
82
+ * expired locks cause a latency cliff under load.
83
+ *
84
+ * `IDX_locked_mint_user_token_amount_status`
85
+ * Covers `PointIndexer.pickMatchingLock` and the lock-resolution
86
+ * `findOne` inside `deductBalance`. The amount column is the
87
+ * selectivity-critical predicate (PENDING locks for the same
88
+ * user/token often share a token but differ by amount).
89
+ *
90
+ * `IDX_locked_mint_pending_expires` (partial)
91
+ * Covers the `markExpiredLocks` sweep. Partial predicate
92
+ * `WHERE status = 'PENDING'` keeps the index small — terminal
93
+ * rows (`MINTED` / `EXPIRED` / `FAILED`) drop out automatically.
94
+ *
95
+ * --- Operational notes ---
96
+ *
97
+ * `CREATE INDEX CONCURRENTLY` runs without a write lock on the table
98
+ * — safe on production traffic — but cannot run inside a transaction
99
+ * block. `transaction = false` disables TypeORM's per-migration
100
+ * transaction wrapping. Trade-off: if this migration partially
101
+ * fails, one or more indexes may be left in an INVALID state
102
+ * (`pg_index.indisvalid = false`). Recovery:
103
+ *
104
+ * -- find half-built indexes
105
+ * SELECT i.relname AS index_name
106
+ * FROM pg_index x
107
+ * JOIN pg_class i ON i.oid = x.indexrelid
108
+ * JOIN pg_class t ON t.oid = x.indrelid
109
+ * WHERE t.relname = 'locked_mint_requests' AND NOT x.indisvalid;
110
+ *
111
+ * -- drop and rerun the migration
112
+ * DROP INDEX CONCURRENTLY IF EXISTS "<index_name>";
113
+ *
114
+ * On large tables (10M+ rows) each `CREATE INDEX CONCURRENTLY` can
115
+ * take several minutes — it makes two table passes plus a final
116
+ * synchronisation. Plan accordingly.
117
+ */
118
+ declare class AddLockedMintCompositeIndexes1747600000000 implements MigrationInterface {
119
+ name: string;
120
+ /**
121
+ * CONCURRENTLY index DDL cannot run inside a transaction. Tell
122
+ * TypeORM to issue these statements directly.
123
+ */
124
+ transaction: false;
125
+ up(queryRunner: QueryRunner): Promise<void>;
126
+ down(queryRunner: QueryRunner): Promise<void>;
127
+ }
128
+
129
+ /**
130
+ * Audit PACI5-7 — Tx-hash idempotency tuple collapses multi-token mints
131
+ * into a single debit.
132
+ *
133
+ * The original idempotency index (`UQ_ledger_journal_user_tx_reason`,
134
+ * shipped in `1747500000000-AddJournalIdempotencyIndex`) was
135
+ *
136
+ * UNIQUE (user_address, tx_hash, reason) WHERE tx_hash IS NOT NULL
137
+ *
138
+ * which omits `token_address`. When a single transaction mints two
139
+ * different PointTokens for the same user (legitimate batched
140
+ * EIP-7702 claim, or Pimlico bundler grouping two single-mint
141
+ * UserOps into one bundle), the second indexer's "already processed?"
142
+ * lookup matches the first token's journal row and the debit is
143
+ * silently skipped — yet `PointIndexer.finalize` still flips the
144
+ * second lock to MINTED. Result: on-chain mint with no off-chain
145
+ * debit → off-chain balance stays spendable → user can re-mint until
146
+ * the issuer cap is exhausted.
147
+ *
148
+ * This migration replaces the tuple with the corrected one:
149
+ *
150
+ * UNIQUE (user_address, token_address, tx_hash, reason)
151
+ * WHERE tx_hash IS NOT NULL
152
+ *
153
+ * The `deductBalance` "already" lookup is updated to include
154
+ * `token_address` in the same release.
155
+ *
156
+ * Pre-flight: the new index is strictly more permissive than the old
157
+ * one (adds a column to the key, never removes), so no existing row
158
+ * pair that was unique under the old index can collide under the new
159
+ * one. Safe to apply on populated databases without reconciliation.
160
+ *
161
+ * Down migration restores the original (vulnerable) index — intended
162
+ * only for emergency rollback. Operators rolling back must also
163
+ * revert the `deductBalance` lookup in `postgresPointLedger.ts`;
164
+ * otherwise the application would query a four-column tuple against
165
+ * a three-column index and idempotency would fail open.
166
+ */
167
+ declare class FixIdempotencyAddTokenAddress1747700000000 implements MigrationInterface {
168
+ name: string;
169
+ up(queryRunner: QueryRunner): Promise<void>;
170
+ down(queryRunner: QueryRunner): Promise<void>;
171
+ }
172
+
43
173
  /**
44
174
  * All shipped migrations in chronological order. Drop into TypeORM's
45
175
  * `migrations` config:
@@ -51,6 +181,6 @@ declare class CreateRedemptionHistory1746230400001 implements MigrationInterface
51
181
  * migrations: [...PAFI_MIGRATIONS, ...yourCustomMigrations],
52
182
  * });
53
183
  */
54
- declare const PAFI_MIGRATIONS: readonly [typeof InitialSchema1700000000000, typeof CreateRedemptionHistory1746230400001];
184
+ declare const PAFI_MIGRATIONS: readonly [typeof InitialSchema1700000000000, typeof CreateRedemptionHistory1746230400001, typeof AddJournalIdempotencyIndex1747500000000, typeof AddLockedMintCompositeIndexes1747600000000, typeof FixIdempotencyAddTokenAddress1747700000000];
55
185
 
56
- export { CreateRedemptionHistory1746230400001, InitialSchema1700000000000, PAFI_MIGRATIONS };
186
+ export { AddJournalIdempotencyIndex1747500000000, AddLockedMintCompositeIndexes1747600000000, CreateRedemptionHistory1746230400001, FixIdempotencyAddTokenAddress1747700000000, InitialSchema1700000000000, PAFI_MIGRATIONS };
@@ -3,10 +3,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm';
3
3
  /**
4
4
  * Single consolidated initial schema for `@pafi-dev/issuer-postgres`.
5
5
  *
6
- * Combines what gg56 split into two migrations (`InitialSchema` +
7
- * `AddPendingCredits`) plus the `user_op_hash` column, since this is
8
- * the v1.4-and-after baseline. Issuers adopting the SDK from scratch
9
- * apply this once.
6
+ * Issuers adopting the SDK from scratch apply this migration once;
7
+ * subsequent schema changes ship as follow-up migrations.
10
8
  *
11
9
  * Tables:
12
10
  * user_balances — off-chain point balance per (user, token)
@@ -40,6 +38,138 @@ declare class CreateRedemptionHistory1746230400001 implements MigrationInterface
40
38
  down(queryRunner: QueryRunner): Promise<void>;
41
39
  }
42
40
 
41
+ /**
42
+ * Adds a partial unique index on `ledger_journal` to enforce
43
+ * idempotency for indexer-driven reasons:
44
+ *
45
+ * UNIQUE (user_address, tx_hash, reason) WHERE tx_hash IS NOT NULL
46
+ *
47
+ * Last-line defense against double-deduct / double-credit when the
48
+ * mint or burn indexer replays the same on-chain event (reorg, pod
49
+ * restart, duplicate replica). Rows with `tx_hash IS NULL` (off-chain
50
+ * credits like AIRDROP, refunds) are unaffected by the partial
51
+ * predicate.
52
+ *
53
+ * Pre-flight: existing duplicate `(user_address, tx_hash, reason)`
54
+ * rows with non-null `tx_hash` will block index creation. Operators
55
+ * who suspect prior double-processing should reconcile manually
56
+ * before applying. The query below surfaces offenders:
57
+ *
58
+ * SELECT user_address, tx_hash, reason, COUNT(*)
59
+ * FROM ledger_journal
60
+ * WHERE tx_hash IS NOT NULL
61
+ * GROUP BY user_address, tx_hash, reason
62
+ * HAVING COUNT(*) > 1;
63
+ */
64
+ declare class AddJournalIdempotencyIndex1747500000000 implements MigrationInterface {
65
+ name: string;
66
+ up(queryRunner: QueryRunner): Promise<void>;
67
+ down(queryRunner: QueryRunner): Promise<void>;
68
+ }
69
+
70
+ /**
71
+ * Replaces the narrow `IDX_locked_mint_user_status` (3 cols:
72
+ * `user_address`, `token_address`, `status`) with three composite
73
+ * indexes that match the actual query shapes used by the SDK.
74
+ *
75
+ * Index rationale:
76
+ *
77
+ * `IDX_locked_mint_user_token_status_expires`
78
+ * Covers `sumPendingLocks` (called inside every `getBalance` +
79
+ * `lockForMinting`). The 4-col index lets Postgres push
80
+ * `expires_at > NOW()` into the index range scan instead of
81
+ * post-filtering rows from the heap. Without it, unswept
82
+ * expired locks cause a latency cliff under load.
83
+ *
84
+ * `IDX_locked_mint_user_token_amount_status`
85
+ * Covers `PointIndexer.pickMatchingLock` and the lock-resolution
86
+ * `findOne` inside `deductBalance`. The amount column is the
87
+ * selectivity-critical predicate (PENDING locks for the same
88
+ * user/token often share a token but differ by amount).
89
+ *
90
+ * `IDX_locked_mint_pending_expires` (partial)
91
+ * Covers the `markExpiredLocks` sweep. Partial predicate
92
+ * `WHERE status = 'PENDING'` keeps the index small — terminal
93
+ * rows (`MINTED` / `EXPIRED` / `FAILED`) drop out automatically.
94
+ *
95
+ * --- Operational notes ---
96
+ *
97
+ * `CREATE INDEX CONCURRENTLY` runs without a write lock on the table
98
+ * — safe on production traffic — but cannot run inside a transaction
99
+ * block. `transaction = false` disables TypeORM's per-migration
100
+ * transaction wrapping. Trade-off: if this migration partially
101
+ * fails, one or more indexes may be left in an INVALID state
102
+ * (`pg_index.indisvalid = false`). Recovery:
103
+ *
104
+ * -- find half-built indexes
105
+ * SELECT i.relname AS index_name
106
+ * FROM pg_index x
107
+ * JOIN pg_class i ON i.oid = x.indexrelid
108
+ * JOIN pg_class t ON t.oid = x.indrelid
109
+ * WHERE t.relname = 'locked_mint_requests' AND NOT x.indisvalid;
110
+ *
111
+ * -- drop and rerun the migration
112
+ * DROP INDEX CONCURRENTLY IF EXISTS "<index_name>";
113
+ *
114
+ * On large tables (10M+ rows) each `CREATE INDEX CONCURRENTLY` can
115
+ * take several minutes — it makes two table passes plus a final
116
+ * synchronisation. Plan accordingly.
117
+ */
118
+ declare class AddLockedMintCompositeIndexes1747600000000 implements MigrationInterface {
119
+ name: string;
120
+ /**
121
+ * CONCURRENTLY index DDL cannot run inside a transaction. Tell
122
+ * TypeORM to issue these statements directly.
123
+ */
124
+ transaction: false;
125
+ up(queryRunner: QueryRunner): Promise<void>;
126
+ down(queryRunner: QueryRunner): Promise<void>;
127
+ }
128
+
129
+ /**
130
+ * Audit PACI5-7 — Tx-hash idempotency tuple collapses multi-token mints
131
+ * into a single debit.
132
+ *
133
+ * The original idempotency index (`UQ_ledger_journal_user_tx_reason`,
134
+ * shipped in `1747500000000-AddJournalIdempotencyIndex`) was
135
+ *
136
+ * UNIQUE (user_address, tx_hash, reason) WHERE tx_hash IS NOT NULL
137
+ *
138
+ * which omits `token_address`. When a single transaction mints two
139
+ * different PointTokens for the same user (legitimate batched
140
+ * EIP-7702 claim, or Pimlico bundler grouping two single-mint
141
+ * UserOps into one bundle), the second indexer's "already processed?"
142
+ * lookup matches the first token's journal row and the debit is
143
+ * silently skipped — yet `PointIndexer.finalize` still flips the
144
+ * second lock to MINTED. Result: on-chain mint with no off-chain
145
+ * debit → off-chain balance stays spendable → user can re-mint until
146
+ * the issuer cap is exhausted.
147
+ *
148
+ * This migration replaces the tuple with the corrected one:
149
+ *
150
+ * UNIQUE (user_address, token_address, tx_hash, reason)
151
+ * WHERE tx_hash IS NOT NULL
152
+ *
153
+ * The `deductBalance` "already" lookup is updated to include
154
+ * `token_address` in the same release.
155
+ *
156
+ * Pre-flight: the new index is strictly more permissive than the old
157
+ * one (adds a column to the key, never removes), so no existing row
158
+ * pair that was unique under the old index can collide under the new
159
+ * one. Safe to apply on populated databases without reconciliation.
160
+ *
161
+ * Down migration restores the original (vulnerable) index — intended
162
+ * only for emergency rollback. Operators rolling back must also
163
+ * revert the `deductBalance` lookup in `postgresPointLedger.ts`;
164
+ * otherwise the application would query a four-column tuple against
165
+ * a three-column index and idempotency would fail open.
166
+ */
167
+ declare class FixIdempotencyAddTokenAddress1747700000000 implements MigrationInterface {
168
+ name: string;
169
+ up(queryRunner: QueryRunner): Promise<void>;
170
+ down(queryRunner: QueryRunner): Promise<void>;
171
+ }
172
+
43
173
  /**
44
174
  * All shipped migrations in chronological order. Drop into TypeORM's
45
175
  * `migrations` config:
@@ -51,6 +181,6 @@ declare class CreateRedemptionHistory1746230400001 implements MigrationInterface
51
181
  * migrations: [...PAFI_MIGRATIONS, ...yourCustomMigrations],
52
182
  * });
53
183
  */
54
- declare const PAFI_MIGRATIONS: readonly [typeof InitialSchema1700000000000, typeof CreateRedemptionHistory1746230400001];
184
+ declare const PAFI_MIGRATIONS: readonly [typeof InitialSchema1700000000000, typeof CreateRedemptionHistory1746230400001, typeof AddJournalIdempotencyIndex1747500000000, typeof AddLockedMintCompositeIndexes1747600000000, typeof FixIdempotencyAddTokenAddress1747700000000];
55
185
 
56
- export { CreateRedemptionHistory1746230400001, InitialSchema1700000000000, PAFI_MIGRATIONS };
186
+ export { AddJournalIdempotencyIndex1747500000000, AddLockedMintCompositeIndexes1747600000000, CreateRedemptionHistory1746230400001, FixIdempotencyAddTokenAddress1747700000000, InitialSchema1700000000000, PAFI_MIGRATIONS };
@@ -130,13 +130,109 @@ var CreateRedemptionHistory1746230400001 = class {
130
130
  }
131
131
  };
132
132
 
133
+ // src/migrations/1747500000000-AddJournalIdempotencyIndex.ts
134
+ var AddJournalIdempotencyIndex1747500000000 = class {
135
+ name = "AddJournalIdempotencyIndex1747500000000";
136
+ async up(queryRunner) {
137
+ await queryRunner.query(`
138
+ CREATE UNIQUE INDEX IF NOT EXISTS "UQ_ledger_journal_user_tx_reason"
139
+ ON "ledger_journal" ("user_address", "tx_hash", "reason")
140
+ WHERE "tx_hash" IS NOT NULL
141
+ `);
142
+ }
143
+ async down(queryRunner) {
144
+ await queryRunner.query(
145
+ `DROP INDEX IF EXISTS "UQ_ledger_journal_user_tx_reason"`
146
+ );
147
+ }
148
+ };
149
+
150
+ // src/migrations/1747600000000-AddLockedMintCompositeIndexes.ts
151
+ var AddLockedMintCompositeIndexes1747600000000 = class {
152
+ name = "AddLockedMintCompositeIndexes1747600000000";
153
+ /**
154
+ * CONCURRENTLY index DDL cannot run inside a transaction. Tell
155
+ * TypeORM to issue these statements directly.
156
+ */
157
+ transaction = false;
158
+ async up(queryRunner) {
159
+ await queryRunner.query(`
160
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS
161
+ "IDX_locked_mint_user_token_status_expires"
162
+ ON "locked_mint_requests"
163
+ ("user_address", "token_address", "status", "expires_at")
164
+ `);
165
+ await queryRunner.query(`
166
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS
167
+ "IDX_locked_mint_user_token_amount_status"
168
+ ON "locked_mint_requests"
169
+ ("user_address", "token_address", "amount", "status")
170
+ `);
171
+ await queryRunner.query(`
172
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS
173
+ "IDX_locked_mint_pending_expires"
174
+ ON "locked_mint_requests" ("expires_at")
175
+ WHERE "status" = 'PENDING'
176
+ `);
177
+ await queryRunner.query(
178
+ `DROP INDEX CONCURRENTLY IF EXISTS "IDX_locked_mint_user_status"`
179
+ );
180
+ }
181
+ async down(queryRunner) {
182
+ await queryRunner.query(`
183
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_locked_mint_user_status"
184
+ ON "locked_mint_requests" ("user_address", "token_address", "status")
185
+ `);
186
+ await queryRunner.query(
187
+ `DROP INDEX CONCURRENTLY IF EXISTS "IDX_locked_mint_pending_expires"`
188
+ );
189
+ await queryRunner.query(
190
+ `DROP INDEX CONCURRENTLY IF EXISTS "IDX_locked_mint_user_token_amount_status"`
191
+ );
192
+ await queryRunner.query(
193
+ `DROP INDEX CONCURRENTLY IF EXISTS "IDX_locked_mint_user_token_status_expires"`
194
+ );
195
+ }
196
+ };
197
+
198
+ // src/migrations/1747700000000-FixIdempotencyAddTokenAddress.ts
199
+ var FixIdempotencyAddTokenAddress1747700000000 = class {
200
+ name = "FixIdempotencyAddTokenAddress1747700000000";
201
+ async up(queryRunner) {
202
+ await queryRunner.query(
203
+ `DROP INDEX IF EXISTS "UQ_ledger_journal_user_tx_reason"`
204
+ );
205
+ await queryRunner.query(`
206
+ CREATE UNIQUE INDEX IF NOT EXISTS "UQ_ledger_journal_user_token_tx_reason"
207
+ ON "ledger_journal" ("user_address", "token_address", "tx_hash", "reason")
208
+ WHERE "tx_hash" IS NOT NULL
209
+ `);
210
+ }
211
+ async down(queryRunner) {
212
+ await queryRunner.query(
213
+ `DROP INDEX IF EXISTS "UQ_ledger_journal_user_token_tx_reason"`
214
+ );
215
+ await queryRunner.query(`
216
+ CREATE UNIQUE INDEX IF NOT EXISTS "UQ_ledger_journal_user_tx_reason"
217
+ ON "ledger_journal" ("user_address", "tx_hash", "reason")
218
+ WHERE "tx_hash" IS NOT NULL
219
+ `);
220
+ }
221
+ };
222
+
133
223
  // src/migrations/index.ts
134
224
  var PAFI_MIGRATIONS = [
135
225
  InitialSchema1700000000000,
136
- CreateRedemptionHistory1746230400001
226
+ CreateRedemptionHistory1746230400001,
227
+ AddJournalIdempotencyIndex1747500000000,
228
+ AddLockedMintCompositeIndexes1747600000000,
229
+ FixIdempotencyAddTokenAddress1747700000000
137
230
  ];
138
231
  export {
232
+ AddJournalIdempotencyIndex1747500000000,
233
+ AddLockedMintCompositeIndexes1747600000000,
139
234
  CreateRedemptionHistory1746230400001,
235
+ FixIdempotencyAddTokenAddress1747700000000,
140
236
  InitialSchema1700000000000,
141
237
  PAFI_MIGRATIONS
142
238
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/migrations/1700000000000-InitialSchema.ts","../../src/migrations/1746230400001-CreateRedemptionHistory.ts","../../src/migrations/index.ts"],"sourcesContent":["import type { MigrationInterface, QueryRunner } from \"typeorm\";\n\n/**\n * Single consolidated initial schema for `@pafi-dev/issuer-postgres`.\n *\n * Combines what gg56 split into two migrations (`InitialSchema` +\n * `AddPendingCredits`) plus the `user_op_hash` column, since this is\n * the v1.4-and-after baseline. Issuers adopting the SDK from scratch\n * apply this once.\n *\n * Tables:\n * user_balances — off-chain point balance per (user, token)\n * locked_mint_requests — reservations during mint flow\n * pending_credits — reserved credits during burn/redeem flow\n * ledger_journal — append-only audit trail of every delta\n * indexer_cursors — PointIndexer / BurnIndexer block cursors\n *\n * Issuer-specific extensions (campaign rules, KYC tables, custom\n * scenarios) belong in a follow-up migration — never edit this file\n * in place once it ships.\n */\nexport class InitialSchema1700000000000 implements MigrationInterface {\n name = \"InitialSchema1700000000000\";\n\n public async up(queryRunner: QueryRunner): Promise<void> {\n await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS \"pgcrypto\"`);\n\n // ─── user_balances ──────────────────────────────────────────────\n await queryRunner.query(`\n CREATE TABLE \"user_balances\" (\n \"user_address\" varchar(42) NOT NULL,\n \"token_address\" varchar(42) NOT NULL,\n \"balance\" numeric(78, 0) NOT NULL DEFAULT 0,\n \"updated_at\" TIMESTAMP NOT NULL DEFAULT now(),\n CONSTRAINT \"PK_user_balances\" PRIMARY KEY (\"user_address\", \"token_address\")\n )\n `);\n\n // ─── locked_mint_requests ───────────────────────────────────────\n await queryRunner.query(`\n CREATE TABLE \"locked_mint_requests\" (\n \"id\" uuid NOT NULL DEFAULT gen_random_uuid(),\n \"user_address\" varchar(42) NOT NULL,\n \"token_address\" varchar(42) NOT NULL,\n \"amount\" numeric(78, 0) NOT NULL,\n \"status\" varchar(16) NOT NULL DEFAULT 'PENDING',\n \"created_at\" TIMESTAMP NOT NULL DEFAULT now(),\n \"expires_at\" TIMESTAMP WITH TIME ZONE NOT NULL,\n \"tx_hash\" varchar(66),\n \"user_op_hash\" varchar(66),\n CONSTRAINT \"PK_locked_mint_requests\" PRIMARY KEY (\"id\")\n )\n `);\n await queryRunner.query(`\n CREATE INDEX \"IDX_locked_mint_user_status\"\n ON \"locked_mint_requests\" (\"user_address\", \"token_address\", \"status\")\n `);\n await queryRunner.query(`\n CREATE INDEX \"IDX_locked_mint_user_op_hash\"\n ON \"locked_mint_requests\" (\"user_op_hash\")\n `);\n\n // ─── pending_credits ────────────────────────────────────────────\n await queryRunner.query(`\n CREATE TABLE \"pending_credits\" (\n \"id\" uuid NOT NULL DEFAULT gen_random_uuid(),\n \"user_address\" varchar(42) NOT NULL,\n \"token_address\" varchar(42) NOT NULL,\n \"amount\" numeric(78, 0) NOT NULL,\n \"status\" varchar(16) NOT NULL DEFAULT 'PENDING',\n \"tx_hash\" varchar(66),\n \"user_op_hash\" varchar(66),\n \"created_at\" TIMESTAMP NOT NULL DEFAULT now(),\n \"expires_at\" TIMESTAMP WITH TIME ZONE NOT NULL,\n \"resolved_at\" TIMESTAMP WITH TIME ZONE,\n CONSTRAINT \"PK_pending_credits\" PRIMARY KEY (\"id\")\n )\n `);\n await queryRunner.query(`\n CREATE INDEX \"IDX_pending_credits_user_status\"\n ON \"pending_credits\" (\"user_address\", \"token_address\", \"status\")\n `);\n await queryRunner.query(`\n CREATE INDEX \"IDX_pending_credits_tx_hash\"\n ON \"pending_credits\" (\"tx_hash\")\n `);\n await queryRunner.query(`\n CREATE INDEX \"IDX_pending_credits_user_op_hash\"\n ON \"pending_credits\" (\"user_op_hash\")\n `);\n\n // ─── ledger_journal ─────────────────────────────────────────────\n await queryRunner.query(`\n CREATE TABLE \"ledger_journal\" (\n \"id\" uuid NOT NULL DEFAULT gen_random_uuid(),\n \"user_address\" varchar(42) NOT NULL,\n \"token_address\" varchar(42) NOT NULL,\n \"delta\" numeric(78, 0) NOT NULL,\n \"reason\" varchar(128) NOT NULL,\n \"tx_hash\" varchar(66),\n \"created_at\" TIMESTAMP NOT NULL DEFAULT now(),\n CONSTRAINT \"PK_ledger_journal\" PRIMARY KEY (\"id\")\n )\n `);\n await queryRunner.query(`\n CREATE INDEX \"IDX_ledger_journal_user_created\"\n ON \"ledger_journal\" (\"user_address\", \"token_address\", \"created_at\")\n `);\n\n // ─── indexer_cursors ────────────────────────────────────────────\n await queryRunner.query(`\n CREATE TABLE \"indexer_cursors\" (\n \"id\" varchar(64) NOT NULL,\n \"next_block\" numeric(78, 0) NOT NULL,\n \"updated_at\" TIMESTAMP NOT NULL DEFAULT now(),\n CONSTRAINT \"PK_indexer_cursors\" PRIMARY KEY (\"id\")\n )\n `);\n }\n\n public async down(queryRunner: QueryRunner): Promise<void> {\n await queryRunner.query(`DROP TABLE \"indexer_cursors\"`);\n await queryRunner.query(`DROP INDEX \"IDX_ledger_journal_user_created\"`);\n await queryRunner.query(`DROP TABLE \"ledger_journal\"`);\n await queryRunner.query(`DROP INDEX \"IDX_pending_credits_user_op_hash\"`);\n await queryRunner.query(`DROP INDEX \"IDX_pending_credits_tx_hash\"`);\n await queryRunner.query(`DROP INDEX \"IDX_pending_credits_user_status\"`);\n await queryRunner.query(`DROP TABLE \"pending_credits\"`);\n await queryRunner.query(`DROP INDEX \"IDX_locked_mint_user_op_hash\"`);\n await queryRunner.query(`DROP INDEX \"IDX_locked_mint_user_status\"`);\n await queryRunner.query(`DROP TABLE \"locked_mint_requests\"`);\n await queryRunner.query(`DROP TABLE \"user_balances\"`);\n }\n}\n","import { MigrationInterface, QueryRunner } from \"typeorm\";\n\n/**\n * Adds the `redemption_history` table — append-only log of successful\n * redemption initiates, indexed by (user, time) for the daily-limit\n * SUM() query.\n *\n * This is INDEPENDENT from the main InitialSchema (different timestamp,\n * later than 1700000000000). Issuers who already deployed InitialSchema\n * apply this on top.\n */\nexport class CreateRedemptionHistory1746230400001 implements MigrationInterface {\n name = \"CreateRedemptionHistory1746230400001\";\n\n public async up(queryRunner: QueryRunner): Promise<void> {\n await queryRunner.query(`\n CREATE TABLE IF NOT EXISTS redemption_history (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n user_address varchar(42) NOT NULL,\n token_address varchar(42),\n amount_pt numeric(78, 0) NOT NULL,\n created_at_unix_sec bigint NOT NULL,\n reservation_id varchar(64),\n row_created_at timestamptz NOT NULL DEFAULT NOW(),\n CONSTRAINT redemption_history_amount_positive CHECK (amount_pt > 0)\n )\n `);\n\n await queryRunner.query(`\n CREATE INDEX IF NOT EXISTS idx_redemption_history_user_created\n ON redemption_history (user_address, created_at_unix_sec DESC)\n `);\n }\n\n public async down(queryRunner: QueryRunner): Promise<void> {\n await queryRunner.query(\n `DROP INDEX IF EXISTS idx_redemption_history_user_created`,\n );\n await queryRunner.query(`DROP TABLE IF EXISTS redemption_history`);\n }\n}\n","export { InitialSchema1700000000000 } from \"./1700000000000-InitialSchema\";\nexport { CreateRedemptionHistory1746230400001 } from \"./1746230400001-CreateRedemptionHistory\";\n\nimport { InitialSchema1700000000000 } from \"./1700000000000-InitialSchema\";\nimport { CreateRedemptionHistory1746230400001 } from \"./1746230400001-CreateRedemptionHistory\";\n\n/**\n * All shipped migrations in chronological order. Drop into TypeORM's\n * `migrations` config:\n *\n * import { PAFI_MIGRATIONS } from \"@pafi-dev/issuer-postgres/migrations\";\n *\n * new DataSource({\n * entities: [...PAFI_ENTITIES, ...yourCustomEntities],\n * migrations: [...PAFI_MIGRATIONS, ...yourCustomMigrations],\n * });\n */\nexport const PAFI_MIGRATIONS = [\n InitialSchema1700000000000,\n CreateRedemptionHistory1746230400001,\n] as const;\n"],"mappings":";AAqBO,IAAM,6BAAN,MAA+D;AAAA,EACpE,OAAO;AAAA,EAEP,MAAa,GAAG,aAAyC;AACvD,UAAM,YAAY,MAAM,2CAA2C;AAGnE,UAAM,YAAY,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,KAQvB;AAGD,UAAM,YAAY,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,KAavB;AACD,UAAM,YAAY,MAAM;AAAA;AAAA;AAAA,KAGvB;AACD,UAAM,YAAY,MAAM;AAAA;AAAA;AAAA,KAGvB;AAGD,UAAM,YAAY,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,KAcvB;AACD,UAAM,YAAY,MAAM;AAAA;AAAA;AAAA,KAGvB;AACD,UAAM,YAAY,MAAM;AAAA;AAAA;AAAA,KAGvB;AACD,UAAM,YAAY,MAAM;AAAA;AAAA;AAAA,KAGvB;AAGD,UAAM,YAAY,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,KAWvB;AACD,UAAM,YAAY,MAAM;AAAA;AAAA;AAAA,KAGvB;AAGD,UAAM,YAAY,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,KAOvB;AAAA,EACH;AAAA,EAEA,MAAa,KAAK,aAAyC;AACzD,UAAM,YAAY,MAAM,8BAA8B;AACtD,UAAM,YAAY,MAAM,8CAA8C;AACtE,UAAM,YAAY,MAAM,6BAA6B;AACrD,UAAM,YAAY,MAAM,+CAA+C;AACvE,UAAM,YAAY,MAAM,0CAA0C;AAClE,UAAM,YAAY,MAAM,8CAA8C;AACtE,UAAM,YAAY,MAAM,8BAA8B;AACtD,UAAM,YAAY,MAAM,2CAA2C;AACnE,UAAM,YAAY,MAAM,0CAA0C;AAClE,UAAM,YAAY,MAAM,mCAAmC;AAC3D,UAAM,YAAY,MAAM,4BAA4B;AAAA,EACtD;AACF;;;AC1HO,IAAM,uCAAN,MAAyE;AAAA,EAC9E,OAAO;AAAA,EAEP,MAAa,GAAG,aAAyC;AACvD,UAAM,YAAY,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,KAWvB;AAED,UAAM,YAAY,MAAM;AAAA;AAAA;AAAA,KAGvB;AAAA,EACH;AAAA,EAEA,MAAa,KAAK,aAAyC;AACzD,UAAM,YAAY;AAAA,MAChB;AAAA,IACF;AACA,UAAM,YAAY,MAAM,yCAAyC;AAAA,EACnE;AACF;;;ACvBO,IAAM,kBAAkB;AAAA,EAC7B;AAAA,EACA;AACF;","names":[]}
1
+ {"version":3,"sources":["../../src/migrations/1700000000000-InitialSchema.ts","../../src/migrations/1746230400001-CreateRedemptionHistory.ts","../../src/migrations/1747500000000-AddJournalIdempotencyIndex.ts","../../src/migrations/1747600000000-AddLockedMintCompositeIndexes.ts","../../src/migrations/1747700000000-FixIdempotencyAddTokenAddress.ts","../../src/migrations/index.ts"],"sourcesContent":["import type { MigrationInterface, QueryRunner } from \"typeorm\";\n\n/**\n * Single consolidated initial schema for `@pafi-dev/issuer-postgres`.\n *\n * Issuers adopting the SDK from scratch apply this migration once;\n * subsequent schema changes ship as follow-up migrations.\n *\n * Tables:\n * user_balances — off-chain point balance per (user, token)\n * locked_mint_requests — reservations during mint flow\n * pending_credits — reserved credits during burn/redeem flow\n * ledger_journal — append-only audit trail of every delta\n * indexer_cursors — PointIndexer / BurnIndexer block cursors\n *\n * Issuer-specific extensions (campaign rules, KYC tables, custom\n * scenarios) belong in a follow-up migration — never edit this file\n * in place once it ships.\n */\nexport class InitialSchema1700000000000 implements MigrationInterface {\n name = \"InitialSchema1700000000000\";\n\n public async up(queryRunner: QueryRunner): Promise<void> {\n await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS \"pgcrypto\"`);\n\n // ─── user_balances ──────────────────────────────────────────────\n await queryRunner.query(`\n CREATE TABLE \"user_balances\" (\n \"user_address\" varchar(42) NOT NULL,\n \"token_address\" varchar(42) NOT NULL,\n \"balance\" numeric(78, 0) NOT NULL DEFAULT 0,\n \"updated_at\" TIMESTAMP NOT NULL DEFAULT now(),\n CONSTRAINT \"PK_user_balances\" PRIMARY KEY (\"user_address\", \"token_address\")\n )\n `);\n\n // ─── locked_mint_requests ───────────────────────────────────────\n await queryRunner.query(`\n CREATE TABLE \"locked_mint_requests\" (\n \"id\" uuid NOT NULL DEFAULT gen_random_uuid(),\n \"user_address\" varchar(42) NOT NULL,\n \"token_address\" varchar(42) NOT NULL,\n \"amount\" numeric(78, 0) NOT NULL,\n \"status\" varchar(16) NOT NULL DEFAULT 'PENDING',\n \"created_at\" TIMESTAMP NOT NULL DEFAULT now(),\n \"expires_at\" TIMESTAMP WITH TIME ZONE NOT NULL,\n \"tx_hash\" varchar(66),\n \"user_op_hash\" varchar(66),\n CONSTRAINT \"PK_locked_mint_requests\" PRIMARY KEY (\"id\")\n )\n `);\n await queryRunner.query(`\n CREATE INDEX \"IDX_locked_mint_user_status\"\n ON \"locked_mint_requests\" (\"user_address\", \"token_address\", \"status\")\n `);\n await queryRunner.query(`\n CREATE INDEX \"IDX_locked_mint_user_op_hash\"\n ON \"locked_mint_requests\" (\"user_op_hash\")\n `);\n\n // ─── pending_credits ────────────────────────────────────────────\n await queryRunner.query(`\n CREATE TABLE \"pending_credits\" (\n \"id\" uuid NOT NULL DEFAULT gen_random_uuid(),\n \"user_address\" varchar(42) NOT NULL,\n \"token_address\" varchar(42) NOT NULL,\n \"amount\" numeric(78, 0) NOT NULL,\n \"status\" varchar(16) NOT NULL DEFAULT 'PENDING',\n \"tx_hash\" varchar(66),\n \"user_op_hash\" varchar(66),\n \"created_at\" TIMESTAMP NOT NULL DEFAULT now(),\n \"expires_at\" TIMESTAMP WITH TIME ZONE NOT NULL,\n \"resolved_at\" TIMESTAMP WITH TIME ZONE,\n CONSTRAINT \"PK_pending_credits\" PRIMARY KEY (\"id\")\n )\n `);\n await queryRunner.query(`\n CREATE INDEX \"IDX_pending_credits_user_status\"\n ON \"pending_credits\" (\"user_address\", \"token_address\", \"status\")\n `);\n await queryRunner.query(`\n CREATE INDEX \"IDX_pending_credits_tx_hash\"\n ON \"pending_credits\" (\"tx_hash\")\n `);\n await queryRunner.query(`\n CREATE INDEX \"IDX_pending_credits_user_op_hash\"\n ON \"pending_credits\" (\"user_op_hash\")\n `);\n\n // ─── ledger_journal ─────────────────────────────────────────────\n await queryRunner.query(`\n CREATE TABLE \"ledger_journal\" (\n \"id\" uuid NOT NULL DEFAULT gen_random_uuid(),\n \"user_address\" varchar(42) NOT NULL,\n \"token_address\" varchar(42) NOT NULL,\n \"delta\" numeric(78, 0) NOT NULL,\n \"reason\" varchar(128) NOT NULL,\n \"tx_hash\" varchar(66),\n \"created_at\" TIMESTAMP NOT NULL DEFAULT now(),\n CONSTRAINT \"PK_ledger_journal\" PRIMARY KEY (\"id\")\n )\n `);\n await queryRunner.query(`\n CREATE INDEX \"IDX_ledger_journal_user_created\"\n ON \"ledger_journal\" (\"user_address\", \"token_address\", \"created_at\")\n `);\n\n // ─── indexer_cursors ────────────────────────────────────────────\n await queryRunner.query(`\n CREATE TABLE \"indexer_cursors\" (\n \"id\" varchar(64) NOT NULL,\n \"next_block\" numeric(78, 0) NOT NULL,\n \"updated_at\" TIMESTAMP NOT NULL DEFAULT now(),\n CONSTRAINT \"PK_indexer_cursors\" PRIMARY KEY (\"id\")\n )\n `);\n }\n\n public async down(queryRunner: QueryRunner): Promise<void> {\n await queryRunner.query(`DROP TABLE \"indexer_cursors\"`);\n await queryRunner.query(`DROP INDEX \"IDX_ledger_journal_user_created\"`);\n await queryRunner.query(`DROP TABLE \"ledger_journal\"`);\n await queryRunner.query(`DROP INDEX \"IDX_pending_credits_user_op_hash\"`);\n await queryRunner.query(`DROP INDEX \"IDX_pending_credits_tx_hash\"`);\n await queryRunner.query(`DROP INDEX \"IDX_pending_credits_user_status\"`);\n await queryRunner.query(`DROP TABLE \"pending_credits\"`);\n await queryRunner.query(`DROP INDEX \"IDX_locked_mint_user_op_hash\"`);\n await queryRunner.query(`DROP INDEX \"IDX_locked_mint_user_status\"`);\n await queryRunner.query(`DROP TABLE \"locked_mint_requests\"`);\n await queryRunner.query(`DROP TABLE \"user_balances\"`);\n }\n}\n","import { MigrationInterface, QueryRunner } from \"typeorm\";\n\n/**\n * Adds the `redemption_history` table — append-only log of successful\n * redemption initiates, indexed by (user, time) for the daily-limit\n * SUM() query.\n *\n * This is INDEPENDENT from the main InitialSchema (different timestamp,\n * later than 1700000000000). Issuers who already deployed InitialSchema\n * apply this on top.\n */\nexport class CreateRedemptionHistory1746230400001 implements MigrationInterface {\n name = \"CreateRedemptionHistory1746230400001\";\n\n public async up(queryRunner: QueryRunner): Promise<void> {\n await queryRunner.query(`\n CREATE TABLE IF NOT EXISTS redemption_history (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n user_address varchar(42) NOT NULL,\n token_address varchar(42),\n amount_pt numeric(78, 0) NOT NULL,\n created_at_unix_sec bigint NOT NULL,\n reservation_id varchar(64),\n row_created_at timestamptz NOT NULL DEFAULT NOW(),\n CONSTRAINT redemption_history_amount_positive CHECK (amount_pt > 0)\n )\n `);\n\n await queryRunner.query(`\n CREATE INDEX IF NOT EXISTS idx_redemption_history_user_created\n ON redemption_history (user_address, created_at_unix_sec DESC)\n `);\n }\n\n public async down(queryRunner: QueryRunner): Promise<void> {\n await queryRunner.query(\n `DROP INDEX IF EXISTS idx_redemption_history_user_created`,\n );\n await queryRunner.query(`DROP TABLE IF EXISTS redemption_history`);\n }\n}\n","import type { MigrationInterface, QueryRunner } from \"typeorm\";\n\n/**\n * Adds a partial unique index on `ledger_journal` to enforce\n * idempotency for indexer-driven reasons:\n *\n * UNIQUE (user_address, tx_hash, reason) WHERE tx_hash IS NOT NULL\n *\n * Last-line defense against double-deduct / double-credit when the\n * mint or burn indexer replays the same on-chain event (reorg, pod\n * restart, duplicate replica). Rows with `tx_hash IS NULL` (off-chain\n * credits like AIRDROP, refunds) are unaffected by the partial\n * predicate.\n *\n * Pre-flight: existing duplicate `(user_address, tx_hash, reason)`\n * rows with non-null `tx_hash` will block index creation. Operators\n * who suspect prior double-processing should reconcile manually\n * before applying. The query below surfaces offenders:\n *\n * SELECT user_address, tx_hash, reason, COUNT(*)\n * FROM ledger_journal\n * WHERE tx_hash IS NOT NULL\n * GROUP BY user_address, tx_hash, reason\n * HAVING COUNT(*) > 1;\n */\nexport class AddJournalIdempotencyIndex1747500000000\n implements MigrationInterface\n{\n name = \"AddJournalIdempotencyIndex1747500000000\";\n\n public async up(queryRunner: QueryRunner): Promise<void> {\n await queryRunner.query(`\n CREATE UNIQUE INDEX IF NOT EXISTS \"UQ_ledger_journal_user_tx_reason\"\n ON \"ledger_journal\" (\"user_address\", \"tx_hash\", \"reason\")\n WHERE \"tx_hash\" IS NOT NULL\n `);\n }\n\n public async down(queryRunner: QueryRunner): Promise<void> {\n await queryRunner.query(\n `DROP INDEX IF EXISTS \"UQ_ledger_journal_user_tx_reason\"`,\n );\n }\n}\n","import type { MigrationInterface, QueryRunner } from \"typeorm\";\n\n/**\n * Replaces the narrow `IDX_locked_mint_user_status` (3 cols:\n * `user_address`, `token_address`, `status`) with three composite\n * indexes that match the actual query shapes used by the SDK.\n *\n * Index rationale:\n *\n * `IDX_locked_mint_user_token_status_expires`\n * Covers `sumPendingLocks` (called inside every `getBalance` +\n * `lockForMinting`). The 4-col index lets Postgres push\n * `expires_at > NOW()` into the index range scan instead of\n * post-filtering rows from the heap. Without it, unswept\n * expired locks cause a latency cliff under load.\n *\n * `IDX_locked_mint_user_token_amount_status`\n * Covers `PointIndexer.pickMatchingLock` and the lock-resolution\n * `findOne` inside `deductBalance`. The amount column is the\n * selectivity-critical predicate (PENDING locks for the same\n * user/token often share a token but differ by amount).\n *\n * `IDX_locked_mint_pending_expires` (partial)\n * Covers the `markExpiredLocks` sweep. Partial predicate\n * `WHERE status = 'PENDING'` keeps the index small — terminal\n * rows (`MINTED` / `EXPIRED` / `FAILED`) drop out automatically.\n *\n * --- Operational notes ---\n *\n * `CREATE INDEX CONCURRENTLY` runs without a write lock on the table\n * — safe on production traffic — but cannot run inside a transaction\n * block. `transaction = false` disables TypeORM's per-migration\n * transaction wrapping. Trade-off: if this migration partially\n * fails, one or more indexes may be left in an INVALID state\n * (`pg_index.indisvalid = false`). Recovery:\n *\n * -- find half-built indexes\n * SELECT i.relname AS index_name\n * FROM pg_index x\n * JOIN pg_class i ON i.oid = x.indexrelid\n * JOIN pg_class t ON t.oid = x.indrelid\n * WHERE t.relname = 'locked_mint_requests' AND NOT x.indisvalid;\n *\n * -- drop and rerun the migration\n * DROP INDEX CONCURRENTLY IF EXISTS \"<index_name>\";\n *\n * On large tables (10M+ rows) each `CREATE INDEX CONCURRENTLY` can\n * take several minutes — it makes two table passes plus a final\n * synchronisation. Plan accordingly.\n */\nexport class AddLockedMintCompositeIndexes1747600000000\n implements MigrationInterface\n{\n name = \"AddLockedMintCompositeIndexes1747600000000\";\n\n /**\n * CONCURRENTLY index DDL cannot run inside a transaction. Tell\n * TypeORM to issue these statements directly.\n */\n transaction = false as const;\n\n public async up(queryRunner: QueryRunner): Promise<void> {\n // Order matters: create the replacement indexes BEFORE dropping\n // the narrow one, so concurrent reads always have at least one\n // usable index throughout the migration.\n await queryRunner.query(`\n CREATE INDEX CONCURRENTLY IF NOT EXISTS\n \"IDX_locked_mint_user_token_status_expires\"\n ON \"locked_mint_requests\"\n (\"user_address\", \"token_address\", \"status\", \"expires_at\")\n `);\n\n await queryRunner.query(`\n CREATE INDEX CONCURRENTLY IF NOT EXISTS\n \"IDX_locked_mint_user_token_amount_status\"\n ON \"locked_mint_requests\"\n (\"user_address\", \"token_address\", \"amount\", \"status\")\n `);\n\n await queryRunner.query(`\n CREATE INDEX CONCURRENTLY IF NOT EXISTS\n \"IDX_locked_mint_pending_expires\"\n ON \"locked_mint_requests\" (\"expires_at\")\n WHERE \"status\" = 'PENDING'\n `);\n\n // The old `(user_address, token_address, status)` index is a\n // strict prefix of the new 4-col index, so dropping it is safe.\n await queryRunner.query(\n `DROP INDEX CONCURRENTLY IF EXISTS \"IDX_locked_mint_user_status\"`,\n );\n }\n\n public async down(queryRunner: QueryRunner): Promise<void> {\n // Restore the original narrow index first, then drop the new\n // ones — same \"always have a usable index\" invariant in reverse.\n await queryRunner.query(`\n CREATE INDEX CONCURRENTLY IF NOT EXISTS \"IDX_locked_mint_user_status\"\n ON \"locked_mint_requests\" (\"user_address\", \"token_address\", \"status\")\n `);\n await queryRunner.query(\n `DROP INDEX CONCURRENTLY IF EXISTS \"IDX_locked_mint_pending_expires\"`,\n );\n await queryRunner.query(\n `DROP INDEX CONCURRENTLY IF EXISTS \"IDX_locked_mint_user_token_amount_status\"`,\n );\n await queryRunner.query(\n `DROP INDEX CONCURRENTLY IF EXISTS \"IDX_locked_mint_user_token_status_expires\"`,\n );\n }\n}\n","import type { MigrationInterface, QueryRunner } from \"typeorm\";\n\n/**\n * Audit PACI5-7 — Tx-hash idempotency tuple collapses multi-token mints\n * into a single debit.\n *\n * The original idempotency index (`UQ_ledger_journal_user_tx_reason`,\n * shipped in `1747500000000-AddJournalIdempotencyIndex`) was\n *\n * UNIQUE (user_address, tx_hash, reason) WHERE tx_hash IS NOT NULL\n *\n * which omits `token_address`. When a single transaction mints two\n * different PointTokens for the same user (legitimate batched\n * EIP-7702 claim, or Pimlico bundler grouping two single-mint\n * UserOps into one bundle), the second indexer's \"already processed?\"\n * lookup matches the first token's journal row and the debit is\n * silently skipped — yet `PointIndexer.finalize` still flips the\n * second lock to MINTED. Result: on-chain mint with no off-chain\n * debit → off-chain balance stays spendable → user can re-mint until\n * the issuer cap is exhausted.\n *\n * This migration replaces the tuple with the corrected one:\n *\n * UNIQUE (user_address, token_address, tx_hash, reason)\n * WHERE tx_hash IS NOT NULL\n *\n * The `deductBalance` \"already\" lookup is updated to include\n * `token_address` in the same release.\n *\n * Pre-flight: the new index is strictly more permissive than the old\n * one (adds a column to the key, never removes), so no existing row\n * pair that was unique under the old index can collide under the new\n * one. Safe to apply on populated databases without reconciliation.\n *\n * Down migration restores the original (vulnerable) index — intended\n * only for emergency rollback. Operators rolling back must also\n * revert the `deductBalance` lookup in `postgresPointLedger.ts`;\n * otherwise the application would query a four-column tuple against\n * a three-column index and idempotency would fail open.\n */\nexport class FixIdempotencyAddTokenAddress1747700000000\n implements MigrationInterface\n{\n name = \"FixIdempotencyAddTokenAddress1747700000000\";\n\n public async up(queryRunner: QueryRunner): Promise<void> {\n await queryRunner.query(\n `DROP INDEX IF EXISTS \"UQ_ledger_journal_user_tx_reason\"`,\n );\n await queryRunner.query(`\n CREATE UNIQUE INDEX IF NOT EXISTS \"UQ_ledger_journal_user_token_tx_reason\"\n ON \"ledger_journal\" (\"user_address\", \"token_address\", \"tx_hash\", \"reason\")\n WHERE \"tx_hash\" IS NOT NULL\n `);\n }\n\n public async down(queryRunner: QueryRunner): Promise<void> {\n await queryRunner.query(\n `DROP INDEX IF EXISTS \"UQ_ledger_journal_user_token_tx_reason\"`,\n );\n await queryRunner.query(`\n CREATE UNIQUE INDEX IF NOT EXISTS \"UQ_ledger_journal_user_tx_reason\"\n ON \"ledger_journal\" (\"user_address\", \"tx_hash\", \"reason\")\n WHERE \"tx_hash\" IS NOT NULL\n `);\n }\n}\n","export { InitialSchema1700000000000 } from \"./1700000000000-InitialSchema\";\nexport { CreateRedemptionHistory1746230400001 } from \"./1746230400001-CreateRedemptionHistory\";\nexport { AddJournalIdempotencyIndex1747500000000 } from \"./1747500000000-AddJournalIdempotencyIndex\";\nexport { AddLockedMintCompositeIndexes1747600000000 } from \"./1747600000000-AddLockedMintCompositeIndexes\";\nexport { FixIdempotencyAddTokenAddress1747700000000 } from \"./1747700000000-FixIdempotencyAddTokenAddress\";\n\nimport { InitialSchema1700000000000 } from \"./1700000000000-InitialSchema\";\nimport { CreateRedemptionHistory1746230400001 } from \"./1746230400001-CreateRedemptionHistory\";\nimport { AddJournalIdempotencyIndex1747500000000 } from \"./1747500000000-AddJournalIdempotencyIndex\";\nimport { AddLockedMintCompositeIndexes1747600000000 } from \"./1747600000000-AddLockedMintCompositeIndexes\";\nimport { FixIdempotencyAddTokenAddress1747700000000 } from \"./1747700000000-FixIdempotencyAddTokenAddress\";\n\n/**\n * All shipped migrations in chronological order. Drop into TypeORM's\n * `migrations` config:\n *\n * import { PAFI_MIGRATIONS } from \"@pafi-dev/issuer-postgres/migrations\";\n *\n * new DataSource({\n * entities: [...PAFI_ENTITIES, ...yourCustomEntities],\n * migrations: [...PAFI_MIGRATIONS, ...yourCustomMigrations],\n * });\n */\nexport const PAFI_MIGRATIONS = [\n InitialSchema1700000000000,\n CreateRedemptionHistory1746230400001,\n AddJournalIdempotencyIndex1747500000000,\n AddLockedMintCompositeIndexes1747600000000,\n FixIdempotencyAddTokenAddress1747700000000,\n] as const;\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,7 +1,16 @@
1
1
  {
2
2
  "name": "@pafi-dev/issuer-postgres",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Postgres-backed IPointLedger implementation for @pafi-dev/issuer — TypeORM entities + migrations + base service issuers can drop in directly",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/Pacific-Finance-Lab/pafi-sdk.git",
8
+ "directory": "packages/issuer-postgres"
9
+ },
10
+ "homepage": "https://github.com/Pacific-Finance-Lab/pafi-sdk/tree/main/packages/issuer-postgres#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/Pacific-Finance-Lab/pafi-sdk/issues"
13
+ },
5
14
  "type": "module",
6
15
  "main": "./dist/index.cjs",
7
16
  "module": "./dist/index.js",
@@ -42,25 +51,27 @@
42
51
  "dist"
43
52
  ],
44
53
  "dependencies": {
45
- "@pafi-dev/issuer": "0.8.0"
54
+ "@pafi-dev/issuer": "0.34.0"
46
55
  },
47
56
  "peerDependencies": {
48
57
  "typeorm": "^0.3.0",
49
58
  "viem": "^2.0.0"
50
59
  },
51
60
  "devDependencies": {
61
+ "@vitest/coverage-v8": "^2.1.0",
62
+ "reflect-metadata": "^0.1.14",
52
63
  "tsup": "^8.0.0",
53
- "typescript": "^5.5.0",
54
64
  "typeorm": "^0.3.20",
65
+ "typescript": "^5.5.0",
55
66
  "viem": "^2.21.0",
56
- "vitest": "^2.0.0",
57
- "reflect-metadata": "^0.1.14"
67
+ "vitest": "^2.0.0"
58
68
  },
59
69
  "license": "Apache-2.0",
60
70
  "scripts": {
61
71
  "build": "tsup",
62
72
  "test": "vitest run --passWithNoTests",
63
73
  "test:watch": "vitest",
74
+ "test:cov": "vitest run --coverage --passWithNoTests",
64
75
  "typecheck": "tsc --noEmit"
65
76
  }
66
77
  }