mirror of
https://github.com/xtr-dev/rondevu-server.git
synced 2025-12-12 03:43:23 +00:00
Refactor: Consolidate service/offer architecture
## Breaking Changes
### Server
- Services can now have multiple offers instead of single offer
- POST /users/:username/services accepts `offers` array instead of `sdp`
- GET /users/:username/services/:fqn returns `offers` array in response
- GET /services/:uuid returns `offers` array in response
- Database schema: removed `offer_id` from services table, added `service_id` to offers table
- Added `batchCreateServices()` and `getOffersForService()` methods
### Client
- `PublishServiceOptions` interface: `offers` array instead of `sdp` string
- `Service` interface: `offers` array instead of `offerId` and `sdp`
- `ServiceRequest` interface: `offers` array instead of `sdp`
- RondevuSignaler.setOffer() sends offers array to server
- Updated to extract offerId from first offer in service response
## New Features
- Support for multiple simultaneous offers per service (connection pooling)
- Batch service creation endpoint for reduced server load
- Proper one-to-many relationship between services and offers
## Implementation Details
### Server Changes (src/storage/)
- sqlite.ts: Added service_id column to offers, removed offer_id from services
- d1.ts: Updated to match new interface
- types.ts: Updated interfaces for Service, Offer, CreateServiceRequest
- app.ts: Updated all service endpoints to handle offers array
### Client Changes (src/)
- api.ts: Added OfferRequest and ServiceOffer interfaces
- rondevu-service.ts: Updated PublishServiceOptions to use offers array
- rondevu-signaler.ts: Updated to send/receive offers array
## Migration Notes
- No backwards compatibility - this is a breaking change
- Services published with old API will not work with new server
- Clients must update to new API to work with updated server
🤖 Generated with Claude Code
This commit is contained in:
@@ -401,6 +401,7 @@ export class D1Storage implements Storage {
|
||||
async createService(request: CreateServiceRequest): Promise<{
|
||||
service: Service;
|
||||
indexUuid: string;
|
||||
offers: Offer[];
|
||||
}> {
|
||||
const serviceId = crypto.randomUUID();
|
||||
const indexUuid = crypto.randomUUID();
|
||||
@@ -408,13 +409,12 @@ export class D1Storage implements Storage {
|
||||
|
||||
// Insert service
|
||||
await this.db.prepare(`
|
||||
INSERT INTO services (id, username, service_fqn, offer_id, created_at, expires_at, is_public, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO services (id, username, service_fqn, created_at, expires_at, is_public, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).bind(
|
||||
serviceId,
|
||||
request.username,
|
||||
request.serviceFqn,
|
||||
request.offerId,
|
||||
now,
|
||||
request.expiresAt,
|
||||
request.isPublic ? 1 : 0,
|
||||
@@ -434,6 +434,13 @@ export class D1Storage implements Storage {
|
||||
request.expiresAt
|
||||
).run();
|
||||
|
||||
// Create offers with serviceId
|
||||
const offerRequests = request.offers.map(offer => ({
|
||||
...offer,
|
||||
serviceId,
|
||||
}));
|
||||
const offers = await this.createOffers(offerRequests);
|
||||
|
||||
// Touch username to extend expiry
|
||||
await this.touchUsername(request.username);
|
||||
|
||||
@@ -442,16 +449,43 @@ export class D1Storage implements Storage {
|
||||
id: serviceId,
|
||||
username: request.username,
|
||||
serviceFqn: request.serviceFqn,
|
||||
offerId: request.offerId,
|
||||
createdAt: now,
|
||||
expiresAt: request.expiresAt,
|
||||
isPublic: request.isPublic || false,
|
||||
metadata: request.metadata,
|
||||
},
|
||||
indexUuid,
|
||||
offers,
|
||||
};
|
||||
}
|
||||
|
||||
async batchCreateServices(requests: CreateServiceRequest[]): Promise<Array<{
|
||||
service: Service;
|
||||
indexUuid: string;
|
||||
offers: Offer[];
|
||||
}>> {
|
||||
const results = [];
|
||||
for (const request of requests) {
|
||||
const result = await this.createService(request);
|
||||
results.push(result);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async getOffersForService(serviceId: string): Promise<Offer[]> {
|
||||
const result = await this.db.prepare(`
|
||||
SELECT * FROM offers
|
||||
WHERE service_id = ? AND expires_at > ?
|
||||
ORDER BY created_at ASC
|
||||
`).bind(serviceId, Date.now()).all();
|
||||
|
||||
if (!result.results) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.results.map(row => this.rowToOffer(row as any));
|
||||
}
|
||||
|
||||
async getServiceById(serviceId: string): Promise<Service | null> {
|
||||
const result = await this.db.prepare(`
|
||||
SELECT * FROM services
|
||||
@@ -560,7 +594,6 @@ export class D1Storage implements Storage {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
serviceFqn: row.service_fqn,
|
||||
offerId: row.offer_id,
|
||||
createdAt: row.created_at,
|
||||
expiresAt: row.expires_at,
|
||||
isPublic: row.is_public === 1,
|
||||
|
||||
@@ -40,6 +40,7 @@ export class SQLiteStorage implements Storage {
|
||||
CREATE TABLE IF NOT EXISTS offers (
|
||||
id TEXT PRIMARY KEY,
|
||||
peer_id TEXT NOT NULL,
|
||||
service_id TEXT,
|
||||
sdp TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
@@ -47,10 +48,12 @@ export class SQLiteStorage implements Storage {
|
||||
secret TEXT,
|
||||
answerer_peer_id TEXT,
|
||||
answer_sdp TEXT,
|
||||
answered_at INTEGER
|
||||
answered_at INTEGER,
|
||||
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_offers_peer ON offers(peer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_offers_service ON offers(service_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
|
||||
CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id);
|
||||
@@ -84,25 +87,22 @@ export class SQLiteStorage implements Storage {
|
||||
CREATE INDEX IF NOT EXISTS idx_usernames_expires ON usernames(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_usernames_public_key ON usernames(public_key);
|
||||
|
||||
-- Services table
|
||||
-- Services table (one service can have multiple offers)
|
||||
CREATE TABLE IF NOT EXISTS services (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
service_fqn TEXT NOT NULL,
|
||||
offer_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
is_public INTEGER NOT NULL DEFAULT 0,
|
||||
metadata TEXT,
|
||||
FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE,
|
||||
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE,
|
||||
UNIQUE(username, service_fqn)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_services_username ON services(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_services_fqn ON services(service_fqn);
|
||||
CREATE INDEX IF NOT EXISTS idx_services_expires ON services(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_services_offer ON services(offer_id);
|
||||
|
||||
-- Service index table (privacy layer)
|
||||
CREATE TABLE IF NOT EXISTS service_index (
|
||||
@@ -139,8 +139,8 @@ export class SQLiteStorage implements Storage {
|
||||
// Use transaction for atomic creation
|
||||
const transaction = this.db.transaction((offersWithIds: (CreateOfferRequest & { id: string })[]) => {
|
||||
const offerStmt = this.db.prepare(`
|
||||
INSERT INTO offers (id, peer_id, sdp, created_at, expires_at, last_seen, secret)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO offers (id, peer_id, service_id, sdp, created_at, expires_at, last_seen, secret)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const offer of offersWithIds) {
|
||||
@@ -150,6 +150,7 @@ export class SQLiteStorage implements Storage {
|
||||
offerStmt.run(
|
||||
offer.id,
|
||||
offer.peerId,
|
||||
offer.serviceId || null,
|
||||
offer.sdp,
|
||||
now,
|
||||
offer.expiresAt,
|
||||
@@ -160,6 +161,7 @@ export class SQLiteStorage implements Storage {
|
||||
created.push({
|
||||
id: offer.id,
|
||||
peerId: offer.peerId,
|
||||
serviceId: offer.serviceId || undefined,
|
||||
sdp: offer.sdp,
|
||||
createdAt: now,
|
||||
expiresAt: offer.expiresAt,
|
||||
@@ -426,23 +428,31 @@ export class SQLiteStorage implements Storage {
|
||||
async createService(request: CreateServiceRequest): Promise<{
|
||||
service: Service;
|
||||
indexUuid: string;
|
||||
offers: Offer[];
|
||||
}> {
|
||||
const serviceId = randomUUID();
|
||||
const indexUuid = randomUUID();
|
||||
const now = Date.now();
|
||||
|
||||
// Create offers with serviceId
|
||||
const offerRequests: CreateOfferRequest[] = request.offers.map(offer => ({
|
||||
...offer,
|
||||
serviceId,
|
||||
}));
|
||||
|
||||
const offers = await this.createOffers(offerRequests);
|
||||
|
||||
const transaction = this.db.transaction(() => {
|
||||
// Insert service
|
||||
// Insert service (no offer_id column anymore)
|
||||
const serviceStmt = this.db.prepare(`
|
||||
INSERT INTO services (id, username, service_fqn, offer_id, created_at, expires_at, is_public, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO services (id, username, service_fqn, created_at, expires_at, is_public, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
serviceStmt.run(
|
||||
serviceId,
|
||||
request.username,
|
||||
request.serviceFqn,
|
||||
request.offerId,
|
||||
now,
|
||||
request.expiresAt,
|
||||
request.isPublic ? 1 : 0,
|
||||
@@ -475,16 +485,31 @@ export class SQLiteStorage implements Storage {
|
||||
id: serviceId,
|
||||
username: request.username,
|
||||
serviceFqn: request.serviceFqn,
|
||||
offerId: request.offerId,
|
||||
createdAt: now,
|
||||
expiresAt: request.expiresAt,
|
||||
isPublic: request.isPublic || false,
|
||||
metadata: request.metadata,
|
||||
},
|
||||
indexUuid,
|
||||
offers,
|
||||
};
|
||||
}
|
||||
|
||||
async batchCreateServices(requests: CreateServiceRequest[]): Promise<Array<{
|
||||
service: Service;
|
||||
indexUuid: string;
|
||||
offers: Offer[];
|
||||
}>> {
|
||||
const results = [];
|
||||
|
||||
for (const request of requests) {
|
||||
const result = await this.createService(request);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async getServiceById(serviceId: string): Promise<Service | null> {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT * FROM services
|
||||
@@ -576,6 +601,7 @@ export class SQLiteStorage implements Storage {
|
||||
return {
|
||||
id: row.id,
|
||||
peerId: row.peer_id,
|
||||
serviceId: row.service_id || undefined,
|
||||
sdp: row.sdp,
|
||||
createdAt: row.created_at,
|
||||
expiresAt: row.expires_at,
|
||||
@@ -595,11 +621,24 @@ export class SQLiteStorage implements Storage {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
serviceFqn: row.service_fqn,
|
||||
offerId: row.offer_id,
|
||||
createdAt: row.created_at,
|
||||
expiresAt: row.expires_at,
|
||||
isPublic: row.is_public === 1,
|
||||
metadata: row.metadata || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all offers for a service
|
||||
*/
|
||||
async getOffersForService(serviceId: string): Promise<Offer[]> {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT * FROM offers
|
||||
WHERE service_id = ? AND expires_at > ?
|
||||
ORDER BY created_at ASC
|
||||
`);
|
||||
|
||||
const rows = stmt.all(serviceId, Date.now()) as any[];
|
||||
return rows.map(row => this.rowToOffer(row));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
export interface Offer {
|
||||
id: string;
|
||||
peerId: string;
|
||||
serviceId?: string; // Optional link to service (null for standalone offers)
|
||||
sdp: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
@@ -33,6 +34,7 @@ export interface IceCandidate {
|
||||
export interface CreateOfferRequest {
|
||||
id?: string;
|
||||
peerId: string;
|
||||
serviceId?: string; // Optional link to service
|
||||
sdp: string;
|
||||
expiresAt: number;
|
||||
secret?: string;
|
||||
@@ -61,13 +63,12 @@ export interface ClaimUsernameRequest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a published service
|
||||
* Represents a published service (can have multiple offers)
|
||||
*/
|
||||
export interface Service {
|
||||
id: string; // UUID v4
|
||||
username: string;
|
||||
serviceFqn: string; // com.example.chat@1.0.0
|
||||
offerId: string; // Links to offers table
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
isPublic: boolean;
|
||||
@@ -75,15 +76,22 @@ export interface Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to create a service
|
||||
* Request to create a single service
|
||||
*/
|
||||
export interface CreateServiceRequest {
|
||||
username: string;
|
||||
serviceFqn: string;
|
||||
offerId: string;
|
||||
expiresAt: number;
|
||||
isPublic?: boolean;
|
||||
metadata?: string;
|
||||
offers: CreateOfferRequest[]; // Multiple offers per service
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to create multiple services in batch
|
||||
*/
|
||||
export interface BatchCreateServicesRequest {
|
||||
services: CreateServiceRequest[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,15 +242,34 @@ export interface Storage {
|
||||
// ===== Service Management =====
|
||||
|
||||
/**
|
||||
* Creates a new service
|
||||
* @param request Service creation request
|
||||
* @returns Created service with generated ID and index UUID
|
||||
* Creates a new service with offers
|
||||
* @param request Service creation request (includes offers)
|
||||
* @returns Created service with generated ID, index UUID, and created offers
|
||||
*/
|
||||
createService(request: CreateServiceRequest): Promise<{
|
||||
service: Service;
|
||||
indexUuid: string;
|
||||
offers: Offer[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Creates multiple services with offers in batch
|
||||
* @param requests Array of service creation requests
|
||||
* @returns Array of created services with IDs, UUIDs, and offers
|
||||
*/
|
||||
batchCreateServices(requests: CreateServiceRequest[]): Promise<Array<{
|
||||
service: Service;
|
||||
indexUuid: string;
|
||||
offers: Offer[];
|
||||
}>>;
|
||||
|
||||
/**
|
||||
* Gets all offers for a service
|
||||
* @param serviceId Service ID
|
||||
* @returns Array of offers for the service
|
||||
*/
|
||||
getOffersForService(serviceId: string): Promise<Offer[]>;
|
||||
|
||||
/**
|
||||
* Gets a service by its service ID
|
||||
* @param serviceId Service ID
|
||||
|
||||
Reference in New Issue
Block a user