Simplify API: remove topics, rename session→offer

- Remove topic-based grouping and discovery
- Rename sessions to offers for clarity
- Simplify to just POST /offer, POST /answer, POST /poll
- Add version to health endpoint
- Update database schema (sessions→offers table)
- Reduce offer timeout to 1 minute
- Server version: 0.0.1

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-12 23:17:38 +01:00
parent 84c60320fa
commit 28ef5eb1d1
9 changed files with 143 additions and 383 deletions

View File

@@ -1,4 +1,4 @@
import { Storage, Session } from './types.ts';
import { Storage, Offer } from './types.ts';
// Generate a UUID v4
function generateUUID(): string {
@@ -6,7 +6,7 @@ function generateUUID(): string {
}
/**
* D1 storage adapter for session management using Cloudflare D1
* D1 storage adapter for offer management using Cloudflare D1
*/
export class D1Storage implements Storage {
private db: D1Database;
@@ -25,10 +25,9 @@ export class D1Storage implements Storage {
*/
async initializeDatabase(): Promise<void> {
await this.db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
CREATE TABLE IF NOT EXISTS offers (
code TEXT PRIMARY KEY,
origin TEXT NOT NULL,
topic TEXT NOT NULL,
peer_id TEXT NOT NULL CHECK(length(peer_id) <= 1024),
offer TEXT NOT NULL,
answer TEXT,
@@ -38,113 +37,13 @@ export class D1Storage implements Storage {
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_expires_at ON sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_origin_topic ON sessions(origin, topic);
CREATE INDEX IF NOT EXISTS idx_origin_topic_expires ON sessions(origin, topic, expires_at);
CREATE INDEX IF NOT EXISTS idx_offers_expires_at ON offers(expires_at);
CREATE INDEX IF NOT EXISTS idx_offers_origin ON offers(origin);
`);
}
async listTopics(origin: string, page: number = 1, limit: number = 100): Promise<{
topics: Array<{ topic: string; count: number }>;
pagination: {
page: number;
limit: number;
total: number;
hasMore: boolean;
};
}> {
// Clamp limit to maximum of 1000
const effectiveLimit = Math.min(limit, 1000);
const offset = (page - 1) * effectiveLimit;
try {
// Get total count of topics for this origin
const countResult = await this.db.prepare(`
SELECT COUNT(DISTINCT topic) as total
FROM sessions
WHERE origin = ? AND expires_at > ? AND answer IS NULL
`).bind(origin, Date.now()).first();
const total = countResult ? Number(countResult.total) : 0;
// Get paginated topics
const result = await this.db.prepare(`
SELECT topic, COUNT(*) as count
FROM sessions
WHERE origin = ? AND expires_at > ? AND answer IS NULL
GROUP BY topic
ORDER BY topic ASC
LIMIT ? OFFSET ?
`).bind(origin, Date.now(), effectiveLimit, offset).all();
// D1 returns results in the results array, or empty array if no results
if (!result.results) {
console.error('[D1] listTopics: No results property in response:', result);
return {
topics: [],
pagination: {
page,
limit: effectiveLimit,
total: 0,
hasMore: false,
},
};
}
const topics = result.results.map((row: any) => ({
topic: row.topic,
count: Number(row.count),
}));
return {
topics,
pagination: {
page,
limit: effectiveLimit,
total,
hasMore: offset + topics.length < total,
},
};
} catch (error) {
console.error('[D1] listTopics error:', error);
throw error;
}
}
async listSessionsByTopic(origin: string, topic: string): Promise<Session[]> {
try {
const result = await this.db.prepare(`
SELECT * FROM sessions
WHERE origin = ? AND topic = ? AND expires_at > ? AND answer IS NULL
ORDER BY created_at DESC
`).bind(origin, topic, Date.now()).all();
if (!result.results) {
console.error('[D1] listSessionsByTopic: No results property in response:', result);
return [];
}
return result.results.map((row: any) => ({
code: row.code,
origin: row.origin,
topic: row.topic,
peerId: row.peer_id,
offer: row.offer,
answer: row.answer || undefined,
offerCandidates: JSON.parse(row.offer_candidates),
answerCandidates: JSON.parse(row.answer_candidates),
createdAt: row.created_at,
expiresAt: row.expires_at,
}));
} catch (error) {
console.error('[D1] listSessionsByTopic error:', error);
throw error;
}
}
async createSession(
async createOffer(
origin: string,
topic: string,
peerId: string,
offer: string,
expiresAt: number,
@@ -160,21 +59,21 @@ export class D1Storage implements Storage {
attempts++;
if (attempts > maxAttempts) {
throw new Error('Failed to generate unique session code');
throw new Error('Failed to generate unique offer code');
}
try {
await this.db.prepare(`
INSERT INTO sessions (code, origin, topic, peer_id, offer, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).bind(code, origin, topic, peerId, offer, Date.now(), expiresAt).run();
INSERT INTO offers (code, origin, peer_id, offer, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
`).bind(code, origin, peerId, offer, Date.now(), expiresAt).run();
break;
} catch (err: any) {
// If unique constraint failed with custom code, throw error
if (err.message?.includes('UNIQUE constraint failed')) {
if (customCode) {
throw new Error(`Session code '${customCode}' already exists`);
throw new Error(`Offer code '${customCode}' already exists`);
}
// Try again with new generated code
continue;
@@ -186,10 +85,10 @@ export class D1Storage implements Storage {
return code;
}
async getSession(code: string, origin: string): Promise<Session | null> {
async getOffer(code: string, origin: string): Promise<Offer | null> {
try {
const result = await this.db.prepare(`
SELECT * FROM sessions
SELECT * FROM offers
WHERE code = ? AND origin = ? AND expires_at > ?
`).bind(code, origin, Date.now()).first();
@@ -202,7 +101,6 @@ export class D1Storage implements Storage {
return {
code: row.code,
origin: row.origin,
topic: row.topic,
peerId: row.peer_id,
offer: row.offer,
answer: row.answer || undefined,
@@ -212,17 +110,17 @@ export class D1Storage implements Storage {
expiresAt: row.expires_at,
};
} catch (error) {
console.error('[D1] getSession error:', error);
console.error('[D1] getOffer error:', error);
throw error;
}
}
async updateSession(code: string, origin: string, update: Partial<Session>): Promise<void> {
// Verify session exists and origin matches
const current = await this.getSession(code, origin);
async updateOffer(code: string, origin: string, update: Partial<Offer>): Promise<void> {
// Verify offer exists and origin matches
const current = await this.getOffer(code, origin);
if (!current) {
throw new Error('Session not found or origin mismatch');
throw new Error('Offer not found or origin mismatch');
}
// Build update query dynamically based on what fields are being updated
@@ -253,7 +151,7 @@ export class D1Storage implements Storage {
// D1 provides strong consistency, so this update is atomic and immediately visible
const query = `
UPDATE sessions
UPDATE offers
SET ${updates.join(', ')}
WHERE code = ? AND origin = ?
`;
@@ -261,22 +159,22 @@ export class D1Storage implements Storage {
await this.db.prepare(query).bind(...values).run();
}
async deleteSession(code: string): Promise<void> {
async deleteOffer(code: string): Promise<void> {
await this.db.prepare(`
DELETE FROM sessions WHERE code = ?
DELETE FROM offers WHERE code = ?
`).bind(code).run();
}
async cleanupExpiredSessions(): Promise<number> {
async cleanupExpiredOffers(): Promise<number> {
const result = await this.db.prepare(`
DELETE FROM sessions WHERE expires_at <= ?
DELETE FROM offers WHERE expires_at <= ?
`).bind(Date.now()).run();
return result.meta.changes || 0;
}
async cleanup(): Promise<void> {
await this.cleanupExpiredSessions();
await this.cleanupExpiredOffers();
}
async close(): Promise<void> {