@xtr-dev/rondevu-server 0.0.1 → 0.1.1
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/API.md +39 -9
- package/CLAUDE.md +47 -0
- package/README.md +144 -187
- package/build.js +12 -0
- package/dist/index.js +799 -266
- package/dist/index.js.map +4 -4
- package/migrations/0001_add_peer_id.sql +21 -0
- package/migrations/0002_remove_topics.sql +22 -0
- package/migrations/0003_remove_origin.sql +29 -0
- package/migrations/0004_add_secret.sql +4 -0
- package/migrations/schema.sql +18 -0
- package/package.json +4 -3
- package/src/app.ts +421 -127
- package/src/bloom.ts +66 -0
- package/src/config.ts +27 -2
- package/src/crypto.ts +149 -0
- package/src/index.ts +28 -12
- package/src/middleware/auth.ts +51 -0
- package/src/storage/d1.ts +394 -0
- package/src/storage/hash-id.ts +37 -0
- package/src/storage/sqlite.ts +323 -178
- package/src/storage/types.ts +128 -54
- package/src/worker.ts +51 -16
- package/wrangler.toml +45 -0
- package/DEPLOYMENT.md +0 -346
- package/src/storage/kv.ts +0 -241
package/src/storage/sqlite.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import Database from 'better-sqlite3';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { Storage, Offer, IceCandidate, CreateOfferRequest, TopicInfo } from './types.ts';
|
|
3
|
+
import { generateOfferHash } from './hash-id.ts';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* SQLite storage adapter for
|
|
6
|
+
* SQLite storage adapter for topic-based offer management
|
|
7
7
|
* Supports both file-based and in-memory databases
|
|
8
8
|
*/
|
|
9
9
|
export class SQLiteStorage implements Storage {
|
|
@@ -16,243 +16,388 @@ export class SQLiteStorage implements Storage {
|
|
|
16
16
|
constructor(path: string = ':memory:') {
|
|
17
17
|
this.db = new Database(path);
|
|
18
18
|
this.initializeDatabase();
|
|
19
|
-
this.startCleanupInterval();
|
|
20
19
|
}
|
|
21
20
|
|
|
22
21
|
/**
|
|
23
|
-
* Initializes database schema
|
|
22
|
+
* Initializes database schema with new topic-based structure
|
|
24
23
|
*/
|
|
25
24
|
private initializeDatabase(): void {
|
|
26
25
|
this.db.exec(`
|
|
27
|
-
CREATE TABLE IF NOT EXISTS
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
info TEXT NOT NULL CHECK(length(info) <= 1024),
|
|
32
|
-
offer TEXT NOT NULL,
|
|
33
|
-
answer TEXT,
|
|
34
|
-
offer_candidates TEXT NOT NULL DEFAULT '[]',
|
|
35
|
-
answer_candidates TEXT NOT NULL DEFAULT '[]',
|
|
26
|
+
CREATE TABLE IF NOT EXISTS offers (
|
|
27
|
+
id TEXT PRIMARY KEY,
|
|
28
|
+
peer_id TEXT NOT NULL,
|
|
29
|
+
sdp TEXT NOT NULL,
|
|
36
30
|
created_at INTEGER NOT NULL,
|
|
37
|
-
expires_at INTEGER NOT NULL
|
|
31
|
+
expires_at INTEGER NOT NULL,
|
|
32
|
+
last_seen INTEGER NOT NULL,
|
|
33
|
+
secret TEXT,
|
|
34
|
+
answerer_peer_id TEXT,
|
|
35
|
+
answer_sdp TEXT,
|
|
36
|
+
answered_at INTEGER
|
|
38
37
|
);
|
|
39
38
|
|
|
40
|
-
CREATE INDEX IF NOT EXISTS
|
|
41
|
-
CREATE INDEX IF NOT EXISTS
|
|
42
|
-
CREATE INDEX IF NOT EXISTS
|
|
43
|
-
|
|
44
|
-
}
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_offers_peer ON offers(peer_id);
|
|
40
|
+
CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at);
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id);
|
|
45
43
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
this.cleanup().catch(err => {
|
|
53
|
-
console.error('Cleanup error:', err);
|
|
54
|
-
});
|
|
55
|
-
}, 60000);
|
|
56
|
-
}
|
|
44
|
+
CREATE TABLE IF NOT EXISTS offer_topics (
|
|
45
|
+
offer_id TEXT NOT NULL,
|
|
46
|
+
topic TEXT NOT NULL,
|
|
47
|
+
PRIMARY KEY (offer_id, topic),
|
|
48
|
+
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
|
|
49
|
+
);
|
|
57
50
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
*/
|
|
61
|
-
private generateCode(): string {
|
|
62
|
-
return randomUUID();
|
|
63
|
-
}
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_topics_topic ON offer_topics(topic);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_topics_offer ON offer_topics(offer_id);
|
|
64
53
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
54
|
+
CREATE TABLE IF NOT EXISTS ice_candidates (
|
|
55
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
56
|
+
offer_id TEXT NOT NULL,
|
|
57
|
+
peer_id TEXT NOT NULL,
|
|
58
|
+
role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
|
|
59
|
+
candidate TEXT NOT NULL, -- JSON: RTCIceCandidateInit object
|
|
60
|
+
created_at INTEGER NOT NULL,
|
|
61
|
+
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
|
|
62
|
+
);
|
|
74
63
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
64
|
+
CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id);
|
|
65
|
+
CREATE INDEX IF NOT EXISTS idx_ice_peer ON ice_candidates(peer_id);
|
|
66
|
+
CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at);
|
|
67
|
+
`);
|
|
79
68
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
69
|
+
// Enable foreign keys
|
|
70
|
+
this.db.pragma('foreign_keys = ON');
|
|
71
|
+
}
|
|
83
72
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
73
|
+
async createOffers(offers: CreateOfferRequest[]): Promise<Offer[]> {
|
|
74
|
+
const created: Offer[] = [];
|
|
75
|
+
|
|
76
|
+
// Generate hash-based IDs for all offers first
|
|
77
|
+
const offersWithIds = await Promise.all(
|
|
78
|
+
offers.map(async (offer) => ({
|
|
79
|
+
...offer,
|
|
80
|
+
id: offer.id || await generateOfferHash(offer.sdp, offer.topics),
|
|
81
|
+
}))
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Use transaction for atomic creation
|
|
85
|
+
const transaction = this.db.transaction((offersWithIds: (CreateOfferRequest & { id: string })[]) => {
|
|
86
|
+
const offerStmt = this.db.prepare(`
|
|
87
|
+
INSERT INTO offers (id, peer_id, sdp, created_at, expires_at, last_seen, secret)
|
|
88
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
89
|
+
`);
|
|
90
|
+
|
|
91
|
+
const topicStmt = this.db.prepare(`
|
|
92
|
+
INSERT INTO offer_topics (offer_id, topic)
|
|
93
|
+
VALUES (?, ?)
|
|
94
|
+
`);
|
|
95
|
+
|
|
96
|
+
for (const offer of offersWithIds) {
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
|
|
99
|
+
// Insert offer
|
|
100
|
+
offerStmt.run(
|
|
101
|
+
offer.id,
|
|
102
|
+
offer.peerId,
|
|
103
|
+
offer.sdp,
|
|
104
|
+
now,
|
|
105
|
+
offer.expiresAt,
|
|
106
|
+
now,
|
|
107
|
+
offer.secret || null
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Insert topics
|
|
111
|
+
for (const topic of offer.topics) {
|
|
112
|
+
topicStmt.run(offer.id, topic);
|
|
96
113
|
}
|
|
97
|
-
|
|
114
|
+
|
|
115
|
+
created.push({
|
|
116
|
+
id: offer.id,
|
|
117
|
+
peerId: offer.peerId,
|
|
118
|
+
sdp: offer.sdp,
|
|
119
|
+
topics: offer.topics,
|
|
120
|
+
createdAt: now,
|
|
121
|
+
expiresAt: offer.expiresAt,
|
|
122
|
+
lastSeen: now,
|
|
123
|
+
secret: offer.secret,
|
|
124
|
+
});
|
|
98
125
|
}
|
|
99
|
-
}
|
|
126
|
+
});
|
|
100
127
|
|
|
101
|
-
|
|
128
|
+
transaction(offersWithIds);
|
|
129
|
+
return created;
|
|
102
130
|
}
|
|
103
131
|
|
|
104
|
-
async
|
|
105
|
-
|
|
106
|
-
SELECT
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
132
|
+
async getOffersByTopic(topic: string, excludePeerIds?: string[]): Promise<Offer[]> {
|
|
133
|
+
let query = `
|
|
134
|
+
SELECT DISTINCT o.*
|
|
135
|
+
FROM offers o
|
|
136
|
+
INNER JOIN offer_topics ot ON o.id = ot.offer_id
|
|
137
|
+
WHERE ot.topic = ? AND o.expires_at > ?
|
|
138
|
+
`;
|
|
110
139
|
|
|
111
|
-
const
|
|
140
|
+
const params: any[] = [topic, Date.now()];
|
|
112
141
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
offer: row.offer,
|
|
119
|
-
answer: row.answer || undefined,
|
|
120
|
-
offerCandidates: JSON.parse(row.offer_candidates),
|
|
121
|
-
answerCandidates: JSON.parse(row.answer_candidates),
|
|
122
|
-
createdAt: row.created_at,
|
|
123
|
-
expiresAt: row.expires_at,
|
|
124
|
-
}));
|
|
125
|
-
}
|
|
142
|
+
if (excludePeerIds && excludePeerIds.length > 0) {
|
|
143
|
+
const placeholders = excludePeerIds.map(() => '?').join(',');
|
|
144
|
+
query += ` AND o.peer_id NOT IN (${placeholders})`;
|
|
145
|
+
params.push(...excludePeerIds);
|
|
146
|
+
}
|
|
126
147
|
|
|
127
|
-
|
|
128
|
-
topics: Array<{ topic: string; count: number }>;
|
|
129
|
-
pagination: {
|
|
130
|
-
page: number;
|
|
131
|
-
limit: number;
|
|
132
|
-
total: number;
|
|
133
|
-
hasMore: boolean;
|
|
134
|
-
};
|
|
135
|
-
}> {
|
|
136
|
-
// Ensure limit doesn't exceed 1000
|
|
137
|
-
const safeLimit = Math.min(Math.max(1, limit), 1000);
|
|
138
|
-
const safePage = Math.max(1, page);
|
|
139
|
-
const offset = (safePage - 1) * safeLimit;
|
|
140
|
-
|
|
141
|
-
// Get total count of topics
|
|
142
|
-
const countStmt = this.db.prepare(`
|
|
143
|
-
SELECT COUNT(DISTINCT topic) as total
|
|
144
|
-
FROM sessions
|
|
145
|
-
WHERE origin = ? AND expires_at > ? AND answer IS NULL
|
|
146
|
-
`);
|
|
147
|
-
const { total } = countStmt.get(origin, Date.now()) as any;
|
|
148
|
+
query += ' ORDER BY o.last_seen DESC';
|
|
148
149
|
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
SELECT topic, COUNT(*) as count
|
|
152
|
-
FROM sessions
|
|
153
|
-
WHERE origin = ? AND expires_at > ? AND answer IS NULL
|
|
154
|
-
GROUP BY topic
|
|
155
|
-
ORDER BY topic ASC
|
|
156
|
-
LIMIT ? OFFSET ?
|
|
157
|
-
`);
|
|
150
|
+
const stmt = this.db.prepare(query);
|
|
151
|
+
const rows = stmt.all(...params) as any[];
|
|
158
152
|
|
|
159
|
-
|
|
153
|
+
return Promise.all(rows.map(row => this.rowToOffer(row)));
|
|
154
|
+
}
|
|
160
155
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
156
|
+
async getOffersByPeerId(peerId: string): Promise<Offer[]> {
|
|
157
|
+
const stmt = this.db.prepare(`
|
|
158
|
+
SELECT * FROM offers
|
|
159
|
+
WHERE peer_id = ? AND expires_at > ?
|
|
160
|
+
ORDER BY last_seen DESC
|
|
161
|
+
`);
|
|
165
162
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
pagination: {
|
|
169
|
-
page: safePage,
|
|
170
|
-
limit: safeLimit,
|
|
171
|
-
total,
|
|
172
|
-
hasMore: offset + topics.length < total,
|
|
173
|
-
},
|
|
174
|
-
};
|
|
163
|
+
const rows = stmt.all(peerId, Date.now()) as any[];
|
|
164
|
+
return Promise.all(rows.map(row => this.rowToOffer(row)));
|
|
175
165
|
}
|
|
176
166
|
|
|
177
|
-
async
|
|
167
|
+
async getOfferById(offerId: string): Promise<Offer | null> {
|
|
178
168
|
const stmt = this.db.prepare(`
|
|
179
|
-
SELECT * FROM
|
|
169
|
+
SELECT * FROM offers
|
|
170
|
+
WHERE id = ? AND expires_at > ?
|
|
180
171
|
`);
|
|
181
172
|
|
|
182
|
-
const row = stmt.get(
|
|
173
|
+
const row = stmt.get(offerId, Date.now()) as any;
|
|
183
174
|
|
|
184
175
|
if (!row) {
|
|
185
176
|
return null;
|
|
186
177
|
}
|
|
187
178
|
|
|
188
|
-
return
|
|
189
|
-
code: row.code,
|
|
190
|
-
origin: row.origin,
|
|
191
|
-
topic: row.topic,
|
|
192
|
-
info: row.info,
|
|
193
|
-
offer: row.offer,
|
|
194
|
-
answer: row.answer || undefined,
|
|
195
|
-
offerCandidates: JSON.parse(row.offer_candidates),
|
|
196
|
-
answerCandidates: JSON.parse(row.answer_candidates),
|
|
197
|
-
createdAt: row.created_at,
|
|
198
|
-
expiresAt: row.expires_at,
|
|
199
|
-
};
|
|
179
|
+
return this.rowToOffer(row);
|
|
200
180
|
}
|
|
201
181
|
|
|
202
|
-
async
|
|
203
|
-
const
|
|
182
|
+
async deleteOffer(offerId: string, ownerPeerId: string): Promise<boolean> {
|
|
183
|
+
const stmt = this.db.prepare(`
|
|
184
|
+
DELETE FROM offers
|
|
185
|
+
WHERE id = ? AND peer_id = ?
|
|
186
|
+
`);
|
|
204
187
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
188
|
+
const result = stmt.run(offerId, ownerPeerId);
|
|
189
|
+
return result.changes > 0;
|
|
190
|
+
}
|
|
208
191
|
|
|
209
|
-
|
|
210
|
-
const
|
|
192
|
+
async deleteExpiredOffers(now: number): Promise<number> {
|
|
193
|
+
const stmt = this.db.prepare('DELETE FROM offers WHERE expires_at < ?');
|
|
194
|
+
const result = stmt.run(now);
|
|
195
|
+
return result.changes;
|
|
196
|
+
}
|
|
211
197
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
198
|
+
async answerOffer(
|
|
199
|
+
offerId: string,
|
|
200
|
+
answererPeerId: string,
|
|
201
|
+
answerSdp: string,
|
|
202
|
+
secret?: string
|
|
203
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
204
|
+
// Check if offer exists and is not expired
|
|
205
|
+
const offer = await this.getOfferById(offerId);
|
|
206
|
+
|
|
207
|
+
if (!offer) {
|
|
208
|
+
return {
|
|
209
|
+
success: false,
|
|
210
|
+
error: 'Offer not found or expired'
|
|
211
|
+
};
|
|
215
212
|
}
|
|
216
213
|
|
|
217
|
-
if
|
|
218
|
-
|
|
219
|
-
|
|
214
|
+
// Verify secret if offer is protected
|
|
215
|
+
if (offer.secret && offer.secret !== secret) {
|
|
216
|
+
return {
|
|
217
|
+
success: false,
|
|
218
|
+
error: 'Invalid or missing secret'
|
|
219
|
+
};
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
-
if
|
|
223
|
-
|
|
224
|
-
|
|
222
|
+
// Check if offer already has an answerer
|
|
223
|
+
if (offer.answererPeerId) {
|
|
224
|
+
return {
|
|
225
|
+
success: false,
|
|
226
|
+
error: 'Offer already answered'
|
|
227
|
+
};
|
|
225
228
|
}
|
|
226
229
|
|
|
227
|
-
|
|
228
|
-
|
|
230
|
+
// Update offer with answer
|
|
231
|
+
const stmt = this.db.prepare(`
|
|
232
|
+
UPDATE offers
|
|
233
|
+
SET answerer_peer_id = ?, answer_sdp = ?, answered_at = ?
|
|
234
|
+
WHERE id = ? AND answerer_peer_id IS NULL
|
|
235
|
+
`);
|
|
236
|
+
|
|
237
|
+
const result = stmt.run(answererPeerId, answerSdp, Date.now(), offerId);
|
|
238
|
+
|
|
239
|
+
if (result.changes === 0) {
|
|
240
|
+
return {
|
|
241
|
+
success: false,
|
|
242
|
+
error: 'Offer already answered (race condition)'
|
|
243
|
+
};
|
|
229
244
|
}
|
|
230
245
|
|
|
231
|
-
|
|
232
|
-
|
|
246
|
+
return { success: true };
|
|
247
|
+
}
|
|
233
248
|
|
|
249
|
+
async getAnsweredOffers(offererPeerId: string): Promise<Offer[]> {
|
|
234
250
|
const stmt = this.db.prepare(`
|
|
235
|
-
|
|
251
|
+
SELECT * FROM offers
|
|
252
|
+
WHERE peer_id = ? AND answerer_peer_id IS NOT NULL AND expires_at > ?
|
|
253
|
+
ORDER BY answered_at DESC
|
|
236
254
|
`);
|
|
237
255
|
|
|
238
|
-
stmt.
|
|
256
|
+
const rows = stmt.all(offererPeerId, Date.now()) as any[];
|
|
257
|
+
return Promise.all(rows.map(row => this.rowToOffer(row)));
|
|
239
258
|
}
|
|
240
259
|
|
|
241
|
-
async
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
260
|
+
async addIceCandidates(
|
|
261
|
+
offerId: string,
|
|
262
|
+
peerId: string,
|
|
263
|
+
role: 'offerer' | 'answerer',
|
|
264
|
+
candidates: any[]
|
|
265
|
+
): Promise<number> {
|
|
266
|
+
const stmt = this.db.prepare(`
|
|
267
|
+
INSERT INTO ice_candidates (offer_id, peer_id, role, candidate, created_at)
|
|
268
|
+
VALUES (?, ?, ?, ?, ?)
|
|
269
|
+
`);
|
|
245
270
|
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
271
|
+
const baseTimestamp = Date.now();
|
|
272
|
+
const transaction = this.db.transaction((candidates: any[]) => {
|
|
273
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
274
|
+
stmt.run(
|
|
275
|
+
offerId,
|
|
276
|
+
peerId,
|
|
277
|
+
role,
|
|
278
|
+
JSON.stringify(candidates[i]), // Store full object as JSON
|
|
279
|
+
baseTimestamp + i // Ensure unique timestamps to avoid "since" filtering issues
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
transaction(candidates);
|
|
285
|
+
return candidates.length;
|
|
286
|
+
}
|
|
249
287
|
|
|
250
|
-
|
|
251
|
-
|
|
288
|
+
async getIceCandidates(
|
|
289
|
+
offerId: string,
|
|
290
|
+
targetRole: 'offerer' | 'answerer',
|
|
291
|
+
since?: number
|
|
292
|
+
): Promise<IceCandidate[]> {
|
|
293
|
+
let query = `
|
|
294
|
+
SELECT * FROM ice_candidates
|
|
295
|
+
WHERE offer_id = ? AND role = ?
|
|
296
|
+
`;
|
|
297
|
+
|
|
298
|
+
const params: any[] = [offerId, targetRole];
|
|
299
|
+
|
|
300
|
+
if (since !== undefined) {
|
|
301
|
+
query += ' AND created_at > ?';
|
|
302
|
+
params.push(since);
|
|
252
303
|
}
|
|
304
|
+
|
|
305
|
+
query += ' ORDER BY created_at ASC';
|
|
306
|
+
|
|
307
|
+
const stmt = this.db.prepare(query);
|
|
308
|
+
const rows = stmt.all(...params) as any[];
|
|
309
|
+
|
|
310
|
+
return rows.map(row => ({
|
|
311
|
+
id: row.id,
|
|
312
|
+
offerId: row.offer_id,
|
|
313
|
+
peerId: row.peer_id,
|
|
314
|
+
role: row.role,
|
|
315
|
+
candidate: JSON.parse(row.candidate), // Parse JSON back to object
|
|
316
|
+
createdAt: row.created_at,
|
|
317
|
+
}));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async getTopics(limit: number, offset: number, startsWith?: string): Promise<{
|
|
321
|
+
topics: TopicInfo[];
|
|
322
|
+
total: number;
|
|
323
|
+
}> {
|
|
324
|
+
const now = Date.now();
|
|
325
|
+
|
|
326
|
+
// Build WHERE clause for startsWith filter
|
|
327
|
+
const whereClause = startsWith
|
|
328
|
+
? 'o.expires_at > ? AND ot.topic LIKE ?'
|
|
329
|
+
: 'o.expires_at > ?';
|
|
330
|
+
|
|
331
|
+
const startsWithPattern = startsWith ? `${startsWith}%` : null;
|
|
332
|
+
|
|
333
|
+
// Get total count of topics with active offers
|
|
334
|
+
const countQuery = `
|
|
335
|
+
SELECT COUNT(DISTINCT ot.topic) as count
|
|
336
|
+
FROM offer_topics ot
|
|
337
|
+
INNER JOIN offers o ON ot.offer_id = o.id
|
|
338
|
+
WHERE ${whereClause}
|
|
339
|
+
`;
|
|
340
|
+
|
|
341
|
+
const countStmt = this.db.prepare(countQuery);
|
|
342
|
+
const countParams = startsWith ? [now, startsWithPattern] : [now];
|
|
343
|
+
const countRow = countStmt.get(...countParams) as any;
|
|
344
|
+
const total = countRow.count;
|
|
345
|
+
|
|
346
|
+
// Get topics with peer counts (paginated)
|
|
347
|
+
const topicsQuery = `
|
|
348
|
+
SELECT
|
|
349
|
+
ot.topic,
|
|
350
|
+
COUNT(DISTINCT o.peer_id) as active_peers
|
|
351
|
+
FROM offer_topics ot
|
|
352
|
+
INNER JOIN offers o ON ot.offer_id = o.id
|
|
353
|
+
WHERE ${whereClause}
|
|
354
|
+
GROUP BY ot.topic
|
|
355
|
+
ORDER BY active_peers DESC, ot.topic ASC
|
|
356
|
+
LIMIT ? OFFSET ?
|
|
357
|
+
`;
|
|
358
|
+
|
|
359
|
+
const topicsStmt = this.db.prepare(topicsQuery);
|
|
360
|
+
const topicsParams = startsWith
|
|
361
|
+
? [now, startsWithPattern, limit, offset]
|
|
362
|
+
: [now, limit, offset];
|
|
363
|
+
const rows = topicsStmt.all(...topicsParams) as any[];
|
|
364
|
+
|
|
365
|
+
const topics = rows.map(row => ({
|
|
366
|
+
topic: row.topic,
|
|
367
|
+
activePeers: row.active_peers,
|
|
368
|
+
}));
|
|
369
|
+
|
|
370
|
+
return { topics, total };
|
|
253
371
|
}
|
|
254
372
|
|
|
255
373
|
async close(): Promise<void> {
|
|
256
374
|
this.db.close();
|
|
257
375
|
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Helper method to convert database row to Offer object with topics
|
|
379
|
+
*/
|
|
380
|
+
private async rowToOffer(row: any): Promise<Offer> {
|
|
381
|
+
// Get topics for this offer
|
|
382
|
+
const topicStmt = this.db.prepare(`
|
|
383
|
+
SELECT topic FROM offer_topics WHERE offer_id = ?
|
|
384
|
+
`);
|
|
385
|
+
|
|
386
|
+
const topicRows = topicStmt.all(row.id) as any[];
|
|
387
|
+
const topics = topicRows.map(t => t.topic);
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
id: row.id,
|
|
391
|
+
peerId: row.peer_id,
|
|
392
|
+
sdp: row.sdp,
|
|
393
|
+
topics,
|
|
394
|
+
createdAt: row.created_at,
|
|
395
|
+
expiresAt: row.expires_at,
|
|
396
|
+
lastSeen: row.last_seen,
|
|
397
|
+
secret: row.secret || undefined,
|
|
398
|
+
answererPeerId: row.answerer_peer_id || undefined,
|
|
399
|
+
answerSdp: row.answer_sdp || undefined,
|
|
400
|
+
answeredAt: row.answered_at || undefined,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
258
403
|
}
|