v0.5.0: Service discovery and FQN format refactoring

- Changed service FQN format: service:version@username (colon instead of @)
- Added service discovery: direct lookup, random selection, paginated queries
- Updated parseServiceFqn to handle optional username for discovery
- Removed UUID privacy layer (service_index table)
- Updated storage interface with discovery methods (discoverServices, getRandomService, getServiceByFqn)
- Removed deprecated methods (getServiceByUuid, queryService, listServicesForUsername, findServicesByName, touchUsername, batchCreateServices)
- Updated API routes: /services/:fqn with three modes (direct, random, paginated)
- Changed offer/answer/ICE routes to offer-specific: /services/:fqn/offers/:offerId/*
- Added extracted fields to services table (service_name, version, username) for efficient discovery
- Created migration 0007 to update schema and migrate existing data
- Added discovery indexes for performance

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-09 22:22:37 +01:00
parent b446adaee4
commit 8111cb9cec
5 changed files with 407 additions and 436 deletions

View File

@@ -192,31 +192,32 @@ export function validateUsername(username: string): { valid: boolean; error?: st
}
/**
* Validates service FQN format (service-name@version)
* Service name: reverse domain notation (com.example.service)
* Validates service FQN format (service:version@username or service:version)
* Service name: lowercase alphanumeric with dots/dashes (e.g., chat, file-share, com.example.chat)
* Version: semantic versioning (1.0.0, 2.1.3-beta, etc.)
* Username: optional, lowercase alphanumeric with dashes
*/
export function validateServiceFqn(fqn: string): { valid: boolean; error?: string } {
if (typeof fqn !== 'string') {
return { valid: false, error: 'Service FQN must be a string' };
}
// Split into service name and version
const parts = fqn.split('@');
if (parts.length !== 2) {
return { valid: false, error: 'Service FQN must be in format: service-name@version' };
// Parse the FQN
const parsed = parseServiceFqn(fqn);
if (!parsed) {
return { valid: false, error: 'Service FQN must be in format: service:version[@username]' };
}
const [serviceName, version] = parts;
const { serviceName, version, username } = parsed;
// Validate service name (reverse domain notation)
const serviceNameRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
// Validate service name (alphanumeric with dots/dashes)
const serviceNameRegex = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
if (!serviceNameRegex.test(serviceName)) {
return { valid: false, error: 'Service name must be reverse domain notation (e.g., com.example.service)' };
return { valid: false, error: 'Service name must be lowercase alphanumeric with optional dots/dashes' };
}
if (serviceName.length < 3 || serviceName.length > 128) {
return { valid: false, error: 'Service name must be 3-128 characters' };
if (serviceName.length < 1 || serviceName.length > 128) {
return { valid: false, error: 'Service name must be 1-128 characters' };
}
// Validate version (semantic versioning)
@@ -225,6 +226,14 @@ export function validateServiceFqn(fqn: string): { valid: boolean; error?: strin
return { valid: false, error: 'Version must be semantic versioning (e.g., 1.0.0, 2.1.3-beta)' };
}
// Validate username if present
if (username) {
const usernameCheck = validateUsername(username);
if (!usernameCheck.valid) {
return usernameCheck;
}
}
return { valid: true };
}
@@ -270,15 +279,41 @@ export function isVersionCompatible(requested: string, available: string): boole
}
/**
* Parse service FQN into service name and version
* Parse service FQN into components
* Formats supported:
* - service:version@username (e.g., "chat:1.0.0@alice")
* - service:version (e.g., "chat:1.0.0") for discovery
*/
export function parseServiceFqn(fqn: string): { serviceName: string; version: string } | null {
const parts = fqn.split('@');
if (parts.length !== 2) return null;
export function parseServiceFqn(fqn: string): { serviceName: string; version: string; username: string | null } | null {
if (!fqn || typeof fqn !== 'string') return null;
// Check if username is present
const atIndex = fqn.lastIndexOf('@');
let serviceVersion: string;
let username: string | null = null;
if (atIndex > 0) {
// Format: service:version@username
serviceVersion = fqn.substring(0, atIndex);
username = fqn.substring(atIndex + 1);
} else {
// Format: service:version (no username)
serviceVersion = fqn;
}
// Split service:version
const colonIndex = serviceVersion.indexOf(':');
if (colonIndex <= 0) return null; // No colon or colon at start
const serviceName = serviceVersion.substring(0, colonIndex);
const version = serviceVersion.substring(colonIndex + 1);
if (!serviceName || !version) return null;
return {
serviceName: parts[0],
version: parts[1],
serviceName,
version,
username,
};
}