@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.
@@ -1,9 +1,9 @@
1
1
  import Database from 'better-sqlite3';
2
- import { randomUUID } from 'crypto';
3
- import { Storage, Session } from './types.ts';
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 session management
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 sessions (
28
- code TEXT PRIMARY KEY,
29
- origin TEXT NOT NULL,
30
- topic TEXT NOT NULL,
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 idx_expires_at ON sessions(expires_at);
41
- CREATE INDEX IF NOT EXISTS idx_origin_topic ON sessions(origin, topic);
42
- CREATE INDEX IF NOT EXISTS idx_origin_topic_expires ON sessions(origin, topic, expires_at);
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
- * Starts periodic cleanup of expired sessions
48
- */
49
- private startCleanupInterval(): void {
50
- // Run cleanup every minute
51
- setInterval(() => {
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
- * Generates a unique code using UUID
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
- async createSession(origin: string, topic: string, info: string, offer: string, expiresAt: number): Promise<string> {
66
- // Validate info length
67
- if (info.length > 1024) {
68
- throw new Error('Info string must be 1024 characters or less');
69
- }
70
-
71
- let code: string;
72
- let attempts = 0;
73
- const maxAttempts = 10;
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
- // Try to generate a unique code
76
- do {
77
- code = this.generateCode();
78
- attempts++;
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
- if (attempts > maxAttempts) {
81
- throw new Error('Failed to generate unique session code');
82
- }
69
+ // Enable foreign keys
70
+ this.db.pragma('foreign_keys = ON');
71
+ }
83
72
 
84
- try {
85
- const stmt = this.db.prepare(`
86
- INSERT INTO sessions (code, origin, topic, info, offer, created_at, expires_at)
87
- VALUES (?, ?, ?, ?, ?, ?, ?)
88
- `);
89
-
90
- stmt.run(code, origin, topic, info, offer, Date.now(), expiresAt);
91
- break;
92
- } catch (err: any) {
93
- // If unique constraint failed, try again
94
- if (err.code === 'SQLITE_CONSTRAINT_PRIMARYKEY') {
95
- continue;
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
- throw err;
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
- } while (true);
126
+ });
100
127
 
101
- return code;
128
+ transaction(offersWithIds);
129
+ return created;
102
130
  }
103
131
 
104
- async listSessionsByTopic(origin: string, topic: string): Promise<Session[]> {
105
- const stmt = this.db.prepare(`
106
- SELECT * FROM sessions
107
- WHERE origin = ? AND topic = ? AND expires_at > ? AND answer IS NULL
108
- ORDER BY created_at DESC
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 rows = stmt.all(origin, topic, Date.now()) as any[];
140
+ const params: any[] = [topic, Date.now()];
112
141
 
113
- return rows.map(row => ({
114
- code: row.code,
115
- origin: row.origin,
116
- topic: row.topic,
117
- info: row.info,
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
- async listTopics(origin: string, page: number, limit: number): Promise<{
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
- // Get paginated topics
150
- const stmt = this.db.prepare(`
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
- const rows = stmt.all(origin, Date.now(), safeLimit, offset) as any[];
153
+ return Promise.all(rows.map(row => this.rowToOffer(row)));
154
+ }
160
155
 
161
- const topics = rows.map(row => ({
162
- topic: row.topic,
163
- count: row.count,
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
- return {
167
- topics,
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 getSession(code: string, origin: string): Promise<Session | null> {
167
+ async getOfferById(offerId: string): Promise<Offer | null> {
178
168
  const stmt = this.db.prepare(`
179
- SELECT * FROM sessions WHERE code = ? AND origin = ? AND expires_at > ?
169
+ SELECT * FROM offers
170
+ WHERE id = ? AND expires_at > ?
180
171
  `);
181
172
 
182
- const row = stmt.get(code, origin, Date.now()) as any;
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 updateSession(code: string, origin: string, update: Partial<Session>): Promise<void> {
203
- const current = await this.getSession(code, origin);
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
- if (!current) {
206
- throw new Error('Session not found or origin mismatch');
207
- }
188
+ const result = stmt.run(offerId, ownerPeerId);
189
+ return result.changes > 0;
190
+ }
208
191
 
209
- const updates: string[] = [];
210
- const values: any[] = [];
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
- if (update.answer !== undefined) {
213
- updates.push('answer = ?');
214
- values.push(update.answer);
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 (update.offerCandidates !== undefined) {
218
- updates.push('offer_candidates = ?');
219
- values.push(JSON.stringify(update.offerCandidates));
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 (update.answerCandidates !== undefined) {
223
- updates.push('answer_candidates = ?');
224
- values.push(JSON.stringify(update.answerCandidates));
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
- if (updates.length === 0) {
228
- return;
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
- values.push(code);
232
- values.push(origin);
246
+ return { success: true };
247
+ }
233
248
 
249
+ async getAnsweredOffers(offererPeerId: string): Promise<Offer[]> {
234
250
  const stmt = this.db.prepare(`
235
- UPDATE sessions SET ${updates.join(', ')} WHERE code = ? AND origin = ?
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.run(...values);
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 deleteSession(code: string): Promise<void> {
242
- const stmt = this.db.prepare('DELETE FROM sessions WHERE code = ?');
243
- stmt.run(code);
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
- async cleanup(): Promise<void> {
247
- const stmt = this.db.prepare('DELETE FROM sessions WHERE expires_at <= ?');
248
- const result = stmt.run(Date.now());
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
- if (result.changes > 0) {
251
- console.log(`Cleaned up ${result.changes} expired session(s)`);
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
  }