Skip to content

Commit 7ffba20

Browse files
authored
Merge pull request #399 from Nursca/feat/stream
2 parents d16912b + ea23cfe commit 7ffba20

10 files changed

Lines changed: 282 additions & 42 deletions

File tree

backend/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"@prisma/adapter-pg": "^7.4.1",
2626
"@stellar/stellar-sdk": "^14.5.0",
2727
"cors": "^2.8.6",
28-
"dotenv": "^17.3.1",
28+
"dotenv": "^17.4.2",
2929
"express": "^5.2.1",
3030
"express-rate-limit": "^8.2.1",
3131
"ioredis": "^5.3.2",
@@ -53,4 +53,4 @@
5353
"typescript": "^5.9.3",
5454
"vitest": "^2.1.8"
5555
}
56-
}
56+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- AlterTable
2+
ALTER TABLE "Stream" ADD COLUMN "isPaused" BOOLEAN NOT NULL DEFAULT false;
3+
ALTER TABLE "Stream" ADD COLUMN "pausedAt" INTEGER;
4+
ALTER TABLE "Stream" ADD COLUMN "totalPausedDuration" INTEGER NOT NULL DEFAULT 0;
5+
6+
-- CreateIndex
7+
CREATE INDEX "Stream_isPaused_idx" ON "Stream"("isPaused");

backend/prisma/schema.prisma

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@ model Stream {
3636
withdrawnAmount String // Total withdrawn amount (i128)
3737
startTime Int // Unix timestamp when stream started
3838
lastUpdateTime Int // Unix timestamp of last update
39+
endTime Int? // Unix timestamp when stream ends
3940
isActive Boolean @default(true)
41+
isPaused Boolean @default(false)
42+
pausedAt Int? // Unix timestamp when paused
43+
totalPausedDuration Int @default(0) // Accumulated paused duration in seconds
4044
createdAt DateTime @default(now())
4145
updatedAt DateTime @updatedAt
4246
@@ -49,6 +53,7 @@ model Stream {
4953
@@index([recipient])
5054
@@index([streamId])
5155
@@index([isActive])
56+
@@index([isPaused])
5257
}
5358

5459
// IndexerState model - tracks the last processed ledger/cursor for the Soroban event worker
@@ -63,7 +68,7 @@ model IndexerState {
6368
model StreamEvent {
6469
id String @id @default(uuid())
6570
streamId Int // Reference to on-chain stream ID
66-
eventType String // EventType: "CREATED", "TOPPED_UP", "WITHDRAWN", "CANCELLED", "COMPLETED"
71+
eventType String // EventType: "CREATED", "TOPPED_UP", "WITHDRAWN", "CANCELLED", "COMPLETED", "PAUSED", "RESUMED"
6772
amount String? // Amount involved in the event (for top-ups, withdrawals)
6873
transactionHash String // Stellar transaction hash
6974
ledgerSequence Int // Ledger sequence number

backend/src/controllers/stream.controller.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export const createStream = async (req: Request, res: Response) => {
6767
depositedAmount,
6868
withdrawnAmount: "0",
6969
startTime: parseInt(startTime),
70+
endTime: parseInt(startTime) + Number(BigInt(depositedAmount) / BigInt(ratePerSecond)),
7071
lastUpdateTime: parseInt(startTime)
7172
}
7273
});
@@ -113,18 +114,18 @@ export const listStreams = async (req: Request, res: Response) => {
113114
switch (status) {
114115
case 'active':
115116
where.isActive = true;
117+
where.isPaused = false;
116118
break;
117119
case 'cancelled':
118120
where.isActive = false;
119-
// Additional check for cancelled events could be added here
121+
where.events = { some: { eventType: 'CANCELLED' } };
120122
break;
121123
case 'completed':
122124
where.isActive = false;
123-
// Additional check for completed events could be added here
125+
where.events = { some: { eventType: 'COMPLETED' } };
124126
break;
125127
case 'paused':
126-
where.isActive = false;
127-
// Additional check for paused events could be added here
128+
where.isPaused = true;
128129
break;
129130
}
130131
}
@@ -137,9 +138,9 @@ export const listStreams = async (req: Request, res: Response) => {
137138
const parsedOffset = typeof offset === 'string' ? (Number.parseInt(offset, 10) || 0) : 0;
138139

139140
// Validate sort field
140-
const validSortFields = ['createdAt', 'startTime', 'lastUpdateTime', 'depositedAmount'];
141+
const validSortFields = ['createdAt', 'startTime', 'lastUpdateTime', 'depositedAmount', 'endTime'];
141142
const sortField = validSortFields.includes(typeof sort === 'string' ? sort : 'createdAt')
142-
? (sort as 'createdAt' | 'startTime' | 'lastUpdateTime' | 'depositedAmount')
143+
? (sort as 'createdAt' | 'startTime' | 'lastUpdateTime' | 'depositedAmount' | 'endTime')
143144
: 'createdAt';
144145

145146
// Validate order
@@ -320,8 +321,12 @@ export const getStreamClaimableAmount = async (req: Request, res: Response) => {
320321
ratePerSecond: true,
321322
depositedAmount: true,
322323
withdrawnAmount: true,
324+
startTime: true,
323325
lastUpdateTime: true,
324326
isActive: true,
327+
isPaused: true,
328+
pausedAt: true,
329+
totalPausedDuration: true,
325330
updatedAt: true,
326331
},
327332
});
@@ -400,8 +405,12 @@ export const getUserStreamSummary = async (req: Request, res: Response) => {
400405
ratePerSecond: true,
401406
depositedAmount: true,
402407
withdrawnAmount: true,
408+
startTime: true,
403409
lastUpdateTime: true,
404410
isActive: true,
411+
isPaused: true,
412+
pausedAt: true,
413+
totalPausedDuration: true,
405414
updatedAt: true,
406415
},
407416
}),

backend/src/services/claimable.service.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,12 @@ export interface ClaimableStreamState {
66
ratePerSecond: string;
77
depositedAmount: string;
88
withdrawnAmount: string;
9+
startTime: number;
910
lastUpdateTime: number;
1011
isActive: boolean;
12+
isPaused: boolean;
13+
pausedAt: number | null;
14+
totalPausedDuration: number;
1115
updatedAt?: Date;
1216
}
1317

@@ -60,8 +64,12 @@ function getStateFingerprint(stream: ClaimableStreamState): string {
6064
stream.ratePerSecond,
6165
stream.depositedAmount,
6266
stream.withdrawnAmount,
67+
stream.startTime,
6368
stream.lastUpdateTime,
6469
stream.isActive ? '1' : '0',
70+
stream.isPaused ? '1' : '0',
71+
stream.pausedAt ?? 'null',
72+
stream.totalPausedDuration,
6573
].join(':');
6674
}
6775

@@ -106,9 +114,20 @@ export class ClaimableAmountService {
106114
};
107115
}
108116

109-
const streamLastUpdate = BigInt(Math.max(0, stream.lastUpdateTime));
117+
const streamStart = BigInt(Math.max(0, stream.startTime));
110118
const nowTs = BigInt(Math.max(0, calculatedAt));
111-
const elapsed = nowTs > streamLastUpdate ? nowTs - streamLastUpdate : 0n;
119+
let elapsed = nowTs > streamStart ? nowTs - streamStart : 0n;
120+
121+
const pastPausedDuration = BigInt(Math.max(0, stream.totalPausedDuration));
122+
elapsed = elapsed > pastPausedDuration ? elapsed - pastPausedDuration : 0n;
123+
124+
if (stream.isPaused && stream.pausedAt !== null) {
125+
const currentPauseStart = BigInt(Math.max(0, stream.pausedAt));
126+
if (nowTs > currentPauseStart) {
127+
const currentPauseDuration = nowTs - currentPauseStart;
128+
elapsed = elapsed > currentPauseDuration ? elapsed - currentPauseDuration : 0n;
129+
}
130+
}
112131

113132
const ratePerSecond = parseI128(stream.ratePerSecond, 'ratePerSecond');
114133
const depositedAmount = parseI128(stream.depositedAmount, 'depositedAmount');

backend/src/services/sse.service.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ interface SSEClient {
77
res: Response;
88
subscriptions: Set<string>;
99
ip: string;
10+
lastActivityAt: number;
1011
}
1112

1213
const MAX_CONNECTIONS_PER_IP = 5;
@@ -24,6 +25,37 @@ class SSEService {
2425
private readonly ipConnectionCounts: Map<string, number> = new Map();
2526
private shuttingDown = false;
2627
private perIpPeakConnections = 0;
28+
private heartbeatInterval?: NodeJS.Timeout;
29+
30+
constructor() {
31+
this.startHeartbeat();
32+
}
33+
34+
private startHeartbeat(): void {
35+
this.heartbeatInterval = setInterval(() => {
36+
const now = Date.now();
37+
const timeoutMs = 5 * 60 * 1000;
38+
39+
for (const [clientId, client] of this.clients.entries()) {
40+
if (now - client.lastActivityAt > timeoutMs) {
41+
logger.info(`[SSEService] Connection timed out: ${clientId}, ip: ${client.ip}`);
42+
try {
43+
client.res.end();
44+
} catch (err) {
45+
// ignore
46+
}
47+
continue;
48+
}
49+
50+
try {
51+
client.res.write(': keep-alive\n\n');
52+
logger.debug(`[SSEService] Heartbeat sent: ${clientId}`);
53+
} catch (err) {
54+
// ignore
55+
}
56+
}
57+
}, 30 * 1000);
58+
}
2759

2860
private readonly maxConnections: number = (() => {
2961
const parsed = Number.parseInt(process.env.MAX_SSE_CONNECTIONS ?? '10000', 10);
@@ -88,11 +120,12 @@ class SSEService {
88120
res,
89121
subscriptions: new Set(subscriptions),
90122
ip,
123+
lastActivityAt: Date.now(),
91124
};
92125

93126
this.clients.set(clientId, client);
94127
logger.info(
95-
`SSE client connected: ${clientId}, ip: ${ip}, subscriptions: ${subscriptions.join(', ')}`
128+
`[SSEService] Connection opened: ${clientId}, ip: ${ip}, subscriptions: ${subscriptions.join(', ')}`
96129
);
97130

98131
res.on('close', () => {
@@ -113,15 +146,19 @@ class SSEService {
113146
this.ipConnectionCounts.set(client.ip, currentIpCount - 1);
114147
}
115148

116-
logger.info(`SSE client disconnected: ${clientId}, ip: ${client.ip}`);
149+
logger.info(`[SSEService] Connection closed: ${clientId}, ip: ${client.ip}`);
117150
}
118151

119152
sendReconnectToAll(): void {
120153
this.shuttingDown = true;
154+
if (this.heartbeatInterval) {
155+
clearInterval(this.heartbeatInterval);
156+
}
121157
const message = 'event: reconnect\ndata: {}\n\n';
122158
for (const client of this.clients.values()) {
123159
try {
124160
client.res.write(message);
161+
client.lastActivityAt = Date.now();
125162
} catch {
126163
// ignore write errors during shutdown
127164
}
@@ -133,7 +170,12 @@ class SSEService {
133170
const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
134171
for (const client of this.clients.values()) {
135172
if (!filter || filter(client)) {
136-
client.res.write(message);
173+
try {
174+
client.res.write(message);
175+
client.lastActivityAt = Date.now();
176+
} catch (err) {
177+
// ignore write errors
178+
}
137179
}
138180
}
139181
}

0 commit comments

Comments
 (0)