Skip to content

Commit f935da3

Browse files
authored
Merge pull request #33 from Temi-suwa18/fix/escrow-fk-integrity-and-sponsor-dashboard
fix(escrow): FK integrity + sponsor dashboard figures survive parent deletion
2 parents 19521f6 + 73b8665 commit f935da3

17 files changed

Lines changed: 968 additions & 34 deletions

README.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,12 +262,29 @@ Unit tests cover critical domains including:
262262
- `src/github/webhook-signature.util.spec.ts` — GitHub webhook HMAC-SHA256 signature verification.
263263
- `src/github/github-webhooks.service.spec.ts` — webhook-to-escrow release logic.
264264
- `src/bounties/bounties.service.spec.ts` — bounty core management.
265+
- `src/sponsors/sponsors.service.spec.ts` — sponsor dashboard aggregate queries (budgetLocked/totalSpend read the Escrow/Payment ledger directly).
266+
- `src/database/escrow-fk-integrity.integration.spec.ts`**integration** test against a real Postgres (requires `DATABASE_URL`, not mocked): the exactly-one-parent CHECK constraint on `escrows`, and that sponsor dashboard figures survive a parent bounty/milestone being deleted.
265267

266-
`DATABASE_SYNCHRONIZE=true` (set in development) will auto-create tables from entities for fast local iteration. A real migration workflow (`typeorm migration:generate`) is recommended before deploying to production (see Roadmap).
268+
`DATABASE_SYNCHRONIZE=true` (set in development) will auto-create tables from entities for fast local iteration. Real deployments should run migrations instead — see `src/database/migrations/` and the `migration:*` npm scripts below.
269+
270+
### Migrations
271+
272+
Schema changes are tracked as TypeORM migrations under `src/database/migrations/`, driven by the `DataSource` in `src/database/data-source.ts`:
273+
274+
```bash
275+
# Generate a migration from entity changes (requires DATABASE_URL pointed at a real DB to diff against)
276+
npm run migration:generate -- src/database/migrations/SomeChange
277+
278+
# Run all pending migrations
279+
npm run migration:run
280+
281+
# Revert the most recently applied migration
282+
npm run migration:revert
283+
```
267284

268285
## Roadmap
269286

270-
- [ ] Wire up TypeORM migrations (currently relies on `synchronize` for local dev only).
287+
- [x] ~~Wire up TypeORM migrations (currently relies on `synchronize` for local dev only).~~ See `src/database/migrations/` and the Migrations section above.
271288
- [ ] Move GitHub sync from a static PAT to a GitHub App installation-token flow for multi-org, least-privilege access.
272289
- [ ] Deploy the real escrow contract from `mergefi-contracts` and drop the Soroban dry-run fallback.
273290
- [ ] Replace the single `TREASURY_SECRET` signer with a proper signing service (KMS / multi-sig) before handling real funds.

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@
1717
"test:watch": "jest --watch",
1818
"test:cov": "jest --coverage",
1919
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
20-
"test:e2e": "jest --config ./test/jest-e2e.json"
20+
"test:e2e": "jest --config ./test/jest-e2e.json",
21+
"typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts",
22+
"migration:generate": "npm run typeorm -- migration:generate",
23+
"migration:create": "typeorm-ts-node-commonjs migration:create",
24+
"migration:run": "npm run typeorm -- migration:run",
25+
"migration:revert": "npm run typeorm -- migration:revert"
2126
},
2227
"dependencies": {
2328
"@nestjs/common": "^11.0.1",

src/bounties/bounties.service.spec.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,17 @@ describe('BountiesService', () => {
6161
status: BountyStatus.OPEN,
6262
amount: '100',
6363
asset: AssetType.USDC,
64+
sponsorId: 'sponsor-1',
6465
});
6566

6667
const bounty = await service.fund('b1', 'GFUNDER');
6768

6869
expect(escrowService.fund).toHaveBeenCalledWith(
69-
expect.objectContaining({ bountyId: 'b1', funderAddress: 'GFUNDER' }),
70+
expect.objectContaining({
71+
bountyId: 'b1',
72+
funderAddress: 'GFUNDER',
73+
sponsorId: 'sponsor-1',
74+
}),
7075
);
7176
expect(bounty.status).toBe(BountyStatus.FUNDED);
7277
});

src/bounties/bounties.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export class BountiesService {
4545
asset: bounty.asset,
4646
funderAddress,
4747
bountyId: bounty.id,
48+
sponsorId: bounty.sponsorId,
4849
});
4950

5051
bounty.escrow = escrow;

src/common/entities/bounty.entity.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,12 @@ export class Bounty {
6767
@Column({ type: 'timestamptz', nullable: true })
6868
deadline: Date | null;
6969

70+
// cascade excludes 'remove'/'soft-remove': removing a Bounty entity via the
71+
// ORM must not also remove its Escrow — the escrow row (and the funds it
72+
// represents) must outlive the bounty record. See #27.
7073
@OneToOne(() => Escrow, (escrow) => escrow.bounty, {
7174
nullable: true,
72-
cascade: true,
75+
cascade: ['insert', 'update'],
7376
})
7477
escrow: Escrow | null;
7578

src/common/entities/escrow.entity.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
Check,
23
Column,
34
CreateDateColumn,
45
Entity,
@@ -20,15 +21,39 @@ import { AssetType, EscrowStatus } from '../enums';
2021
* The actual funds custody lives in the deployed escrow contract on Stellar;
2122
* this table mirrors state so the API can serve fast reads and so we have an
2223
* audit trail independent of Horizon/RPC availability.
24+
*
25+
* The parent link (bounty/milestone/maintenancePool) is `onDelete: 'SET
26+
* NULL'`, not CASCADE: deleting a bounty/milestone must never delete the
27+
* escrow row (and, transitively, its payments) out from under real,
28+
* possibly still-LOCKED, funds. See #27 — an escrow whose parent was
29+
* deleted stays a first-class, still-queryable ledger row, orphaned but
30+
* intact, attributable to its sponsor via the denormalized `sponsorId`
31+
* below (which survives independently of the parent row).
32+
*
33+
* The CHECK constraint below only enforces *at most one* parent, not
34+
* *exactly* one: `ON DELETE SET NULL` nulls out an escrow's only parent
35+
* column, which is precisely the state an orphaned-by-deletion escrow ends
36+
* up in (0 of 3 set) — a CHECK requiring exactly one would make that very
37+
* SET NULL fail with a constraint violation the moment it fires. "Exactly
38+
* one at creation" is enforced instead where it belongs: application-side,
39+
* in EscrowService.fund (see assertExactlyOneParent).
2340
*/
2441
@Entity('escrows')
42+
@Check(
43+
'CHK_escrow_at_most_one_parent',
44+
`(
45+
(CASE WHEN "bountyId" IS NOT NULL THEN 1 ELSE 0 END) +
46+
(CASE WHEN "milestoneId" IS NOT NULL THEN 1 ELSE 0 END) +
47+
(CASE WHEN "maintenancePoolId" IS NOT NULL THEN 1 ELSE 0 END)
48+
) <= 1`,
49+
)
2550
export class Escrow {
2651
@PrimaryGeneratedColumn('uuid')
2752
id: string;
2853

2954
@OneToOne(() => Bounty, (bounty) => bounty.escrow, {
3055
nullable: true,
31-
onDelete: 'CASCADE',
56+
onDelete: 'SET NULL',
3257
})
3358
@JoinColumn()
3459
bounty: Bounty | null;
@@ -38,7 +63,7 @@ export class Escrow {
3863

3964
@OneToOne(() => Milestone, (milestone) => milestone.escrow, {
4065
nullable: true,
41-
onDelete: 'CASCADE',
66+
onDelete: 'SET NULL',
4267
})
4368
@JoinColumn()
4469
milestone: Milestone | null;
@@ -48,14 +73,25 @@ export class Escrow {
4873

4974
@OneToOne(() => MaintenancePool, (pool) => pool.escrow, {
5075
nullable: true,
51-
onDelete: 'CASCADE',
76+
onDelete: 'SET NULL',
5277
})
5378
@JoinColumn()
5479
maintenancePool: MaintenancePool | null;
5580

5681
@Column({ type: 'varchar', nullable: true })
5782
maintenancePoolId: string | null;
5883

84+
/**
85+
* Denormalized sponsor identity, captured from the parent bounty/milestone
86+
* at fund time. Sponsor-dashboard aggregates (src/sponsors/sponsors.service.ts)
87+
* read this column directly rather than joining through bounty/milestone,
88+
* so a locked or spent escrow is still correctly attributed to its sponsor
89+
* even after the parent record is deleted (#27). Null for
90+
* maintenance-pool escrows, which aren't sponsor-attributed the same way.
91+
*/
92+
@Column({ type: 'varchar', nullable: true })
93+
sponsorId: string | null;
94+
5995
/** Deployed Soroban contract ID this escrow instance is held by. */
6096
@Column({ type: 'varchar', nullable: true })
6197
contractId: string | null;

src/common/entities/payment.entity.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@ export class Payment {
1717
@PrimaryGeneratedColumn('uuid')
1818
id: string;
1919

20-
@ManyToOne(() => Escrow, (escrow) => escrow.payments, { onDelete: 'CASCADE' })
20+
// RESTRICT, not CASCADE: a Payment is a record of money that actually
21+
// moved. Deleting its parent Escrow must never silently delete that
22+
// payout record too — the database refuses the delete instead. See #27.
23+
@ManyToOne(() => Escrow, (escrow) => escrow.payments, {
24+
onDelete: 'RESTRICT',
25+
})
2126
@JoinColumn()
2227
escrow: Escrow;
2328

src/database/data-source.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import 'reflect-metadata';
2+
import { DataSource } from 'typeorm';
3+
import { entities } from '../common/entities/typeorm-entities';
4+
5+
/**
6+
* TypeORM CLI DataSource — used only for `migration:generate`/`migration:run`/
7+
* `migration:revert` (see package.json scripts). The running application
8+
* connects via `TypeOrmModule.forRootAsync` in src/app.module.ts instead;
9+
* the two are kept in sync by importing the same `entities` list.
10+
*
11+
* Before this, the app relied entirely on `synchronize` and had no migration
12+
* history at all (#27) — every schema change (including the exactly-one-
13+
* parent CHECK constraint this DataSource ships the first migration for)
14+
* now goes through a reviewable, revertible migration file instead.
15+
*/
16+
export const AppDataSource = new DataSource({
17+
type: 'postgres',
18+
url:
19+
process.env.DATABASE_URL ??
20+
'postgresql://postgres:postgres@localhost:5432/mergefi',
21+
entities,
22+
migrations: [__dirname + '/migrations/*{.ts,.js}'],
23+
migrationsTableName: 'migrations',
24+
synchronize: false,
25+
logging: process.env.DATABASE_LOGGING === 'true',
26+
});

0 commit comments

Comments
 (0)