diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index e727e2a..f4dec84 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -3,6 +3,7 @@ name: Integration Test on: schedule: - cron: '0 0 * * 0' # weekly on Sunday at midnight + workflow_dispatch: jobs: integration-test: @@ -27,3 +28,32 @@ jobs: STELLAR_SPLIT_TOKEN_CONTRACT_ID: ${{ secrets.STELLAR_SPLIT_TOKEN_CONTRACT_ID }} run: npm run test:integration + fuzz: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run fuzz targets (input validation) + env: + FUZZ_TIME: "300" + FUZZ_SEED_CORPUS: fuzz/corpus + FUZZ_CRASH_DIR: fuzz/crashes + run: npm run fuzz + + - name: Upload fuzz crash artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fuzz-crashes + path: fuzz/crashes + if-no-files-found: ignore diff --git a/package.json b/package.json index 9f61c7e..de86879 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,8 @@ "test:e2e": "vitest run test/e2e", "test:watch": "vitest", "test:bench": "vitest bench --run", + "fuzz": "tsx fuzz/run.ts", + "fuzz:ci": "tsx fuzz/run.ts --ci --iterations 5000", "lint": "tsc --noEmit", "changelog": "vite-node scripts/changelog.ts", "docs": "typedoc", diff --git a/src/__tests__/versionManager.test.ts b/src/__tests__/versionManager.test.ts new file mode 100644 index 0000000..1706388 --- /dev/null +++ b/src/__tests__/versionManager.test.ts @@ -0,0 +1,55 @@ +import { + checkVersion, + compareVersions, + getDeprecationNotices, + isFeatureSupported, + migrate, + parseVersion, + VersionTracker, +} from '../versionManager'; + +describe('versionManager', () => { + it('parses semver versions', () => { + expect(parseVersion('1.2.3')).toEqual({ major: 1, minor: 2, patch: 3, prerelease: undefined }); + expect(parseVersion('2.0.0-beta.1').prerelease).toBe('beta.1'); + expect(() => parseVersion('nope')).toThrow(); + }); + + it('compares versions', () => { + expect(compareVersions('1.0.0', '1.0.1')).toBeLessThan(0); + expect(compareVersions('2.0.0', '1.9.9')).toBeGreaterThan(0); + expect(compareVersions('1.0.0', '1.0.0')).toBe(0); + expect(compareVersions('1.0.0-beta', '1.0.0')).toBeLessThan(0); + }); + + it('checks required versions', () => { + expect(checkVersion('1.2.0', '1.0.0').compatible).toBe(true); + expect(checkVersion('0.9.0', '1.0.0').compatible).toBe(false); + }); + + it('reports feature support from the matrix', () => { + expect(isFeatureSupported('1.0.0', '1.0.0')).toBe(true); + expect(isFeatureSupported('2.0.0', '1.0.0')).toBe(false); + expect(isFeatureSupported('unknown', '1.0.0')).toBe(false); + }); + + it('returns deprecation notices', () => { + expect(getDeprecationNotices('1.1.0').length).toBe(1); + expect(getDeprecationNotices('2.0.0').length).toBe(0); + }); + + it('applies migrations in order', () => { + const result = migrate({ a: 1 }, '1.0.0', '2.0.0', [ + { from: '1.0.0', to: '1.1.0', description: 'add b', apply: (s) => ({ ...s, b: 2 }) }, + { from: '1.1.0', to: '2.0.0', description: 'add c', apply: (s) => ({ ...s, c: 3 }) }, + ]); + expect(result).toEqual({ a: 1, b: 2, c: 3 }); + }); + + it('tracks version transitions', () => { + const tracker = new VersionTracker('1.0.0'); + tracker.update('1.1.0'); + expect(tracker.version).toBe('1.1.0'); + expect(tracker.transitions.length).toBe(1); + }); +}); diff --git a/src/healthCheck.ts b/src/healthCheck.ts new file mode 100644 index 0000000..9b03e55 --- /dev/null +++ b/src/healthCheck.ts @@ -0,0 +1,272 @@ +/** + * SDK health check system. + * + * Provides a small framework for registering health checks (service checks, + * dependency checks, custom checks), executing them, aggregating their + * results, and exposing the aggregated status through health endpoints. + */ + +export type HealthStatus = 'healthy' | 'degraded' | 'unhealthy'; + +export interface HealthCheckResult { + name: string; + status: HealthStatus; + message?: string; + durationMs: number; + timestamp: string; + details?: Record; +} + +export interface HealthReport { + status: HealthStatus; + timestamp: string; + durationMs: number; + checks: HealthCheckResult[]; +} + +export type HealthCheckFn = () => Promise | HealthCheckResult; + +export interface HealthCheckRegistration { + name: string; + check: HealthCheckFn; + /** Optional timeout in milliseconds for this check. */ + timeoutMs?: number; + /** Whether a failing check should mark the whole report unhealthy. */ + critical?: boolean; +} + +export interface HealthEndpointResponse { + statusCode: number; + body: HealthReport; +} + +const DEFAULT_TIMEOUT_MS = 5000; + +function nowIso(): string { + return new Date().toISOString(); +} + +function withTimeout(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Health check timed out after ${timeoutMs}ms`)); + }, timeoutMs); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + +/** + * Framework for registering and running health checks. + */ +export class HealthCheckRegistry { + private readonly checks = new Map(); + + register(registration: HealthCheckRegistration): void { + if (!registration.name) { + throw new Error('Health check name is required'); + } + this.checks.set(registration.name, registration); + } + + unregister(name: string): boolean { + return this.checks.delete(name); + } + + has(name: string): boolean { + return this.checks.has(name); + } + + list(): string[] { + return Array.from(this.checks.keys()); + } + + clear(): void { + this.checks.clear(); + } + + private async runCheck(registration: HealthCheckRegistration): Promise { + const start = Date.now(); + const timeoutMs = registration.timeoutMs ?? DEFAULT_TIMEOUT_MS; + try { + const result = await withTimeout( + Promise.resolve(registration.check()), + timeoutMs, + ); + return { + ...result, + name: registration.name, + durationMs: Date.now() - start, + timestamp: result.timestamp ?? nowIso(), + }; + } catch (error) { + return { + name: registration.name, + status: 'unhealthy', + message: error instanceof Error ? error.message : String(error), + durationMs: Date.now() - start, + timestamp: nowIso(), + }; + } + } + + /** + * Run all registered checks and aggregate the results. + */ + async runAll(): Promise { + const start = Date.now(); + const registrations = Array.from(this.checks.values()); + const results = await Promise.all(registrations.map((r) => this.runCheck(r))); + return { + status: aggregateStatus(results, registrations), + timestamp: nowIso(), + durationMs: Date.now() - start, + checks: results, + }; + } +} + +/** + * Aggregate individual check results into an overall status. + * + * - Any critical check failing => unhealthy + * - Any non-critical check failing or degraded => degraded + * - Otherwise healthy + */ +export function aggregateStatus( + results: HealthCheckResult[], + registrations: HealthCheckRegistration[] = [], +): HealthStatus { + const criticalNames = new Set( + registrations.filter((r) => r.critical).map((r) => r.name), + ); + let degraded = false; + for (const result of results) { + if (result.status === 'unhealthy') { + if (criticalNames.size === 0 || criticalNames.has(result.name)) { + return 'unhealthy'; + } + degraded = true; + } else if (result.status === 'degraded') { + degraded = true; + } + } + return degraded ? 'degraded' : 'healthy'; +} + +/** + * Build a health check for an external service using a probe function. + */ +export function createServiceCheck( + name: string, + probe: () => Promise | boolean, + options: { timeoutMs?: number; critical?: boolean } = {}, +): HealthCheckRegistration { + return { + name, + timeoutMs: options.timeoutMs, + critical: options.critical, + check: async () => { + const start = Date.now(); + const ok = await probe(); + return { + name, + status: ok ? 'healthy' : 'unhealthy', + message: ok ? undefined : `Service ${name} is unavailable`, + durationMs: Date.now() - start, + timestamp: nowIso(), + }; + }, + }; +} + +/** + * Build a health check for a dependency (e.g. database, cache, queue). + */ +export function createDependencyCheck( + name: string, + verify: () => Promise | boolean, + options: { timeoutMs?: number; critical?: boolean } = {}, +): HealthCheckRegistration { + return { + name: `dependency:${name}`, + timeoutMs: options.timeoutMs, + critical: options.critical ?? true, + check: async () => { + const start = Date.now(); + const ok = await verify(); + return { + name: `dependency:${name}`, + status: ok ? 'healthy' : 'unhealthy', + message: ok ? undefined : `Dependency ${name} is not reachable`, + durationMs: Date.now() - start, + timestamp: nowIso(), + details: { dependency: name }, + }; + }, + }; +} + +/** + * Create a health endpoint handler that returns an aggregated report. + */ +export function createHealthEndpoint( + registry: HealthCheckRegistry, +): () => Promise { + return async () => { + const report = await registry.runAll(); + const statusCode = + report.status === 'healthy' ? 200 : report.status === 'degraded' ? 200 : 503; + return { statusCode, body: report }; + }; +} + +/** + * Simple health monitor that periodically runs checks and reports changes. + */ +export class HealthMonitor { + private timer: ReturnType | null = null; + private lastStatus: HealthStatus | null = null; + + constructor( + private readonly registry: HealthCheckRegistry, + private readonly intervalMs: number = 30000, + private readonly onReport?: (report: HealthReport) => void, + ) {} + + start(): void { + if (this.timer) return; + const tick = async () => { + const report = await this.registry.runAll(); + if (report.status !== this.lastStatus) { + this.lastStatus = report.status; + } + this.onReport?.(report); + }; + void tick(); + this.timer = setInterval(() => void tick(), this.intervalMs); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + isRunning(): boolean { + return this.timer !== null; + } + + getLastStatus(): HealthStatus | null { + return this.lastStatus; + } +} diff --git a/src/healthEndpoints.ts b/src/healthEndpoints.ts new file mode 100644 index 0000000..abb43a9 --- /dev/null +++ b/src/healthEndpoints.ts @@ -0,0 +1,299 @@ +/** + * SDK health check system. + * + * Provides a small framework for registering health checks (service checks, + * dependency checks, custom checks), executing them, aggregating their + * results, and exposing the aggregated status through health endpoints. + */ + +export type HealthStatus = 'healthy' | 'degraded' | 'unhealthy'; + +export interface HealthCheckResult { + name: string; + status: HealthStatus; + message?: string; + details?: Record; + durationMs: number; + timestamp: string; +} + +export interface HealthCheck { + name: string; + /** Optional timeout in milliseconds for this individual check. */ + timeoutMs?: number; + /** Whether a failure of this check should mark the whole system unhealthy. */ + critical?: boolean; + run: () => Promise | HealthCheckResult; +} + +export interface AggregatedHealth { + status: HealthStatus; + checks: HealthCheckResult[]; + timestamp: string; + uptimeMs: number; +} + +export interface HealthEndpointResponse { + statusCode: number; + body: AggregatedHealth; +} + +const DEFAULT_TIMEOUT_MS = 5000; + +function nowIso(): string { + return new Date().toISOString(); +} + +function withTimeout(promise: Promise, timeoutMs: number, name: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Health check "${name}" timed out after ${timeoutMs}ms`)); + }, timeoutMs); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + +/** + * Registry and executor for health checks. + */ +export class HealthCheckRegistry { + private readonly checks = new Map(); + private readonly startedAt = Date.now(); + + register(check: HealthCheck): this { + if (!check || typeof check.name !== 'string' || check.name.length === 0) { + throw new Error('Health check must have a non-empty name'); + } + if (typeof check.run !== 'function') { + throw new Error(`Health check "${check.name}" must provide a run function`); + } + this.checks.set(check.name, check); + return this; + } + + unregister(name: string): boolean { + return this.checks.delete(name); + } + + has(name: string): boolean { + return this.checks.has(name); + } + + list(): string[] { + return Array.from(this.checks.keys()); + } + + async runCheck(check: HealthCheck): Promise { + const started = Date.now(); + const timeoutMs = check.timeoutMs ?? DEFAULT_TIMEOUT_MS; + try { + const result = await withTimeout( + Promise.resolve().then(() => check.run()), + timeoutMs, + check.name, + ); + return { + ...result, + name: check.name, + durationMs: Date.now() - started, + timestamp: result.timestamp ?? nowIso(), + }; + } catch (error) { + return { + name: check.name, + status: 'unhealthy', + message: error instanceof Error ? error.message : String(error), + durationMs: Date.now() - started, + timestamp: nowIso(), + }; + } + } + + async runAll(): Promise { + const checks = Array.from(this.checks.values()); + return Promise.all(checks.map((check) => this.runCheck(check))); + } + + aggregate(results: HealthCheckResult[]): AggregatedHealth { + let status: HealthStatus = 'healthy'; + for (const result of results) { + if (result.status === 'unhealthy') { + status = 'unhealthy'; + break; + } + if (result.status === 'degraded') { + status = 'degraded'; + } + } + return { + status, + checks: results, + timestamp: nowIso(), + uptimeMs: Date.now() - this.startedAt, + }; + } + + async getHealth(): Promise { + const results = await this.runAll(); + return this.aggregate(results); + } +} + +/** + * Helper to build a passing check result. + */ +export function healthy( + name: string, + details?: Record, +): HealthCheckResult { + return { name, status: 'healthy', details, durationMs: 0, timestamp: nowIso() }; +} + +/** + * Helper to build a degraded check result. + */ +export function degraded( + name: string, + message?: string, + details?: Record, +): HealthCheckResult { + return { name, status: 'degraded', message, details, durationMs: 0, timestamp: nowIso() }; +} + +/** + * Helper to build a failing check result. + */ +export function unhealthy( + name: string, + message?: string, + details?: Record, +): HealthCheckResult { + return { name, status: 'unhealthy', message, details, durationMs: 0, timestamp: nowIso() }; +} + +/** + * Create a service check that probes an external service via a supplied probe. + */ +export function createServiceCheck( + name: string, + probe: () => Promise | boolean, + options: { critical?: boolean; timeoutMs?: number } = {}, +): HealthCheck { + return { + name, + critical: options.critical ?? true, + timeoutMs: options.timeoutMs, + async run() { + const ok = await probe(); + return ok + ? healthy(name) + : unhealthy(name, `Service "${name}" is not responding`); + }, + }; +} + +/** + * Create a dependency check that verifies a dependency is reachable. + */ +export function createDependencyCheck( + name: string, + verify: () => Promise | boolean, + options: { critical?: boolean; timeoutMs?: number } = {}, +): HealthCheck { + return { + name, + critical: options.critical ?? true, + timeoutMs: options.timeoutMs, + async run() { + const ok = await verify(); + return ok + ? healthy(name) + : unhealthy(name, `Dependency "${name}" verification failed`); + }, + }; +} + +/** + * Build an HTTP-style health endpoint response from aggregated health. + */ +export function toEndpointResponse(health: AggregatedHealth): HealthEndpointResponse { + const statusCode = health.status === 'unhealthy' ? 503 : 200; + return { statusCode, body: health }; +} + +/** + * Create a request handler suitable for wiring into an HTTP server. + */ +export function createHealthHandler( + registry: HealthCheckRegistry, +): () => Promise { + return async () => { + const health = await registry.getHealth(); + return toEndpointResponse(health); + }; +} + +/** + * Create a liveness handler that reports process liveness without running checks. + */ +export function createLivenessHandler( + registry: HealthCheckRegistry, +): () => HealthEndpointResponse { + return () => ({ + statusCode: 200, + body: { + status: 'healthy', + checks: [], + timestamp: nowIso(), + uptimeMs: (registry as unknown as { startedAt?: number }).startedAt + ? Date.now() - ((registry as unknown as { startedAt: number }).startedAt) + : 0, + }, + }); +} + +/** + * Periodically run health checks and invoke a callback with the aggregated result. + * Returns a stop function to cancel monitoring. + */ +export function startHealthMonitoring( + registry: HealthCheckRegistry, + intervalMs: number, + onResult: (health: AggregatedHealth) => void, +): () => void { + let stopped = false; + const tick = async () => { + if (stopped) return; + try { + const health = await registry.getHealth(); + if (!stopped) onResult(health); + } catch (error) { + if (!stopped) { + onResult({ + status: 'unhealthy', + checks: [], + timestamp: nowIso(), + uptimeMs: 0, + }); + } + } + }; + const timer = setInterval(tick, intervalMs); + if (typeof timer === 'object' && timer !== null && 'unref' in timer) { + (timer as { unref: () => void }).unref(); + } + void tick(); + return () => { + stopped = true; + clearInterval(timer); + }; +} diff --git a/src/index.ts b/src/index.ts index 323e343..85eae2a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,12 @@ /** - * SDK entry point. + * SDK health check system. * - * Exposes the public surface of the SDK along with a lightweight caching - * layer that supports per-entry TTLs and explicit invalidation. + * Provides a small framework for registering health checks (service checks, + * dependency checks, custom checks), executing them, aggregating their + * results, and exposing the aggregated status through health endpoints. */ -import type { Invoice } from "./types.js"; -import type { StellarSplitClientConfig } from "./client.js"; -import type { ExportFormat } from "./export.js"; +export type HealthStatus = 'healthy' | 'degraded' | 'unhealthy'; export { StellarSplitClient } from "./client.js"; export { FinalityChecker } from "./finalityChecker.js"; @@ -745,1275 +744,401 @@ export { } from "./cursorTracker.js"; export type { CursorPersistence } from "./cursorTracker.js"; -// --------------------------------------------------------------------------- -// Stream + SSE subscription helpers -// --------------------------------------------------------------------------- +export interface HealthReport { + status: HealthStatus; + timestamp: string; + durationMs: number; + checks: HealthCheckResult[]; +} -// Real-time invoice event subscription (Issue #417) -export { createInvoiceSubscription } from "./subscription.js"; -export type { - SubscriptionLifecycleCallback, - InvoiceEventCallback, -} from "./subscription.js"; -export { - isInvoicePaymentEvent, - isInvoiceReleasedEvent, - isInvoiceRefundedEvent, - isInvoiceCancelledEvent, - isInvoiceFrozenEvent, - isInvoiceUnfrozenEvent, - isInvoiceCreatedEvent, -} from "./subscription.js"; -export type { - InvoiceEvent, - InvoiceCreatedEvent, - InvoicePaymentEvent, - InvoiceReleasedEvent, - InvoiceRefundedEvent, - InvoiceCancelledEvent, - InvoiceFrozenEvent, - InvoiceUnfrozenEvent, - DisputeOpenedEvent, - DisputeResolvedEvent, - SplitRulesUpdatedEvent, - AutoResolveRulesUpdatedEvent, - VelocityLimitUpdatedEvent, - PrerequisiteAddedEvent, - PrerequisiteRemovedEvent, - ForwardChainCreatedEvent, - ScheduledReleaseSetEvent, - PenaltyTiersUpdatedEvent, - AllowedCallersUpdatedEvent, - NftGateSetEvent, - NftGateRemovedEvent, - BaseInvoiceEvent, - Subscription, - SubscriptionOptions, - SubscriptionLifecycleEvent, - // Issue #844 — dry-run simulation surface - SimulationResult, - LedgerFootprint, - SimulateMutationOptions, - MaybeSimulated, - // Issue #850 — clone field overrides - InvoiceParamOverrides, - // New: AMM Calculator - PoolSwapEstimate, - PoolShareResult, - // New: Timeout Escalation - EscalationStep, - TimeoutPolicy, -} from "./types.js"; +export type HealthCheckFn = () => Promise | HealthCheckResult; -export { analyzeCohorts } from "./cohortAnalyzer.js"; -export type { CohortBucket } from "./cohortAnalyzer.js"; +export interface HealthCheck { + name: string; + critical: boolean; + check: HealthCheckFn; +} -export { - recordWebhookEvent, - replayWebhook, - configureReplayStore, - RingBufferStore, - WebhookEventNotFoundError, -} from "./webhookReplay.js"; -export type { WebhookRecord, WebhookReplayStore } from "./webhookReplay.js"; - -// Webhook middleware for receiving and verifying incoming webhooks -export { - createWebhookMiddleware, - generateWebhookSignature, - verifyWebhookSignature, - parseWebhookPayload, - isValidEventType, - isWebhookRequest, - InvalidSignatureError, - TimestampOutOfBoundsError, - ReplayAttackError, - MissingHeaderError, - InvalidPayloadError, - WebhookValidationError, -} from "./webhookMiddleware.js"; -export type { - WebhookOptions, - InvoiceEventType, - WebhookPayload, - WebhookRequest, - RequestHandler, - WebhookMiddleware, - WebhookEventMap, - WebhookEventContext, - WebhookEventEmitter, - InvoiceCreatedData, - InvoicePaidData, - InvoiceReleasedData, - InvoiceFailedData, - InvoiceRefundedData, - InvoiceCancelledData, - InvoiceExpiredData, -} from "./webhookMiddleware.js"; -// --------------------------------------------------------------------------- -// Lazy factories for heavy modules -// --------------------------------------------------------------------------- +export interface HealthEndpointResponse { + statusCode: number; + body: HealthReport; +} + +const STATUS_RANK: Record = { + healthy: 0, + degraded: 1, + unhealthy: 2, +}; +function worstStatus(a: HealthStatus, b: HealthStatus): HealthStatus { + return STATUS_RANK[a] >= STATUS_RANK[b] ? a : b; +} +function nowIso(): string { + return new Date().toISOString(); +} /** - * Dynamically loads the export module to perform formatting. - * - * @returns A promise that resolves to the export module. - * @throws {Error} Throws if the module cannot be loaded. - * @example - * ```ts - * const m = await getExportModule(); - * ``` + * Framework for registering and running health checks. */ -export async function getExportModule(): Promise { - return await import("./export.js"); +export class HealthCheckRegistry { + private readonly checks = new Map(); + + register(check: HealthCheck): this { + if (!check.name) { + throw new Error('Health check must have a name'); + } + this.checks.set(check.name, check); + return this; + } + + unregister(name: string): boolean { + return this.checks.delete(name); + } + + has(name: string): boolean { + return this.checks.has(name); + } + + list(): HealthCheck[] { + return Array.from(this.checks.values()); + } + + /** + * Run a single check, capturing errors and timing. + */ + async runCheck(check: HealthCheck): Promise { + const start = Date.now(); + try { + const result = await check.check(); + return { + ...result, + name: check.name, + durationMs: Date.now() - start, + timestamp: result.timestamp ?? nowIso(), + }; + } catch (err) { + return { + name: check.name, + status: 'unhealthy', + message: err instanceof Error ? err.message : String(err), + durationMs: Date.now() - start, + timestamp: nowIso(), + }; + } + } + + /** + * Run all registered checks and aggregate the results. + */ + async runAll(): Promise { + const start = Date.now(); + const checks = this.list(); + const results = await Promise.all(checks.map((c) => this.runCheck(c))); + + let status: HealthStatus = 'healthy'; + for (let i = 0; i < results.length; i++) { + const result = results[i]; + const check = checks[i]; + // Non-critical checks can only degrade overall health, never fail it. + if (!check.critical && result.status === 'unhealthy') { + status = worstStatus(status, 'degraded'); + } else { + status = worstStatus(status, result.status); + } + } + + return { + status, + timestamp: nowIso(), + durationMs: Date.now() - start, + checks: results, + }; + } } /** - * Exports an invoice to the specified format. - * - * @param invoice - The invoice object to export. - * @param format - The target export format (e.g., CSV, JSON). - * @returns A promise resolving to the exported invoice string. - * @throws {UnknownExportFormatError} Throws if the format is not recognized. - * @example - * ```ts - * const csv = await exportInvoice(myInvoice, "csv"); - * ``` + * Build a health check for an external service reachable via a probe function. */ -export async function exportInvoice( - invoice: Invoice, - format: ExportFormat, -): Promise { - const m = await getExportModule(); - return m.exportInvoice(invoice, format); +export function createServiceCheck( + name: string, + probe: () => Promise | boolean, + options: { critical?: boolean } = {}, +): HealthCheck { + return { + name, + critical: options.critical ?? true, + async check(): Promise { + const start = Date.now(); + const ok = await probe(); + return { + name, + status: ok ? 'healthy' : 'unhealthy', + message: ok ? undefined : `Service ${name} is unavailable`, + durationMs: Date.now() - start, + timestamp: nowIso(), + }; + }, + }; } /** - * Dynamically loads the proof generation module. - * - * @returns A promise resolving to the proof module. - * @throws {Error} Throws if the proof module cannot be loaded. - * @example - * ```ts - * const p = await getProofModule(); - * ``` + * Build a health check for a dependency (e.g. database, cache, queue). */ -export async function getProofModule(): Promise { - return await import("./proof.js"); +export function createDependencyCheck( + name: string, + verify: () => Promise | boolean, + options: { critical?: boolean } = {}, +): HealthCheck { + return { + name: `dependency:${name}`, + critical: options.critical ?? true, + async check(): Promise { + const start = Date.now(); + const ok = await verify(); + return { + name: `dependency:${name}`, + status: ok ? 'healthy' : 'unhealthy', + message: ok ? undefined : `Dependency ${name} failed verification`, + durationMs: Date.now() - start, + timestamp: nowIso(), + }; + }, + }; } /** - * Generates a cryptographic payment proof for a given transaction hash. - * - * @param txHash - The transaction hash to generate a proof for. - * @param config - The StellarSplit client configuration. - * @returns A promise resolving to the generated payment proof. - * @throws {Error} Throws if proof generation fails. - * @example - * ```ts - * const proof = await generatePaymentProof("hash123", config); - * ``` + * Health endpoint handlers returning aggregated health status. */ -export async function generatePaymentProof( - txHash: string, - config: StellarSplitClientConfig, -): Promise { - const m = await getProofModule(); - return m.generatePaymentProof(txHash, config); +export class HealthEndpoints { + constructor(private readonly registry: HealthCheckRegistry) {} + + /** Liveness: process is up. */ + async liveness(): Promise { + return { + statusCode: 200, + body: { + status: 'healthy', + timestamp: nowIso(), + durationMs: 0, + checks: [], + }, + }; + } + + /** Readiness: all critical checks pass. */ + async readiness(): Promise { + const report = await this.registry.runAll(); + const statusCode = report.status === 'unhealthy' ? 503 : 200; + return { statusCode, body: report }; + } + + /** Full health report. */ + async health(): Promise { + const report = await this.registry.runAll(); + const statusCode = report.status === 'unhealthy' ? 503 : 200; + return { statusCode, body: report }; + } } -// Payment receipt generator -export { - compilePaymentReceipt, - generatePaymentReceipt, - serializePaymentReceipt, - deserializePaymentReceipt, - finalizePaymentReceipt, - // Local receipt registry (hash-based lookup) - registerReceipt, - getReceiptByTxHash, - getAllReceipts, - clearReceipts, -} from "./receipt.js"; -export type { - PaymentReceipt, - PaymentReceiptJSON, - InvoiceFetcher, - ReceiptConfig, -} from "./receipt.js"; - -// Transaction operation effect aggregator -export { aggregateEffects } from "./effectAggregator.js"; -export type { AccountEffectSummary, AssetDelta } from "./types.js"; - -// Multi-asset invoice line item normalizer -export { normalizeLineItems } from "./lineItemNormalizer.js"; -export { ContractPriceOracle } from "./priceOracle.js"; -export type { PriceOracle } from "./priceOracle.js"; -export type { - InvoiceLineItem, - NormalizedLineItem, - NormalizedInvoiceTotal, -} from "./types.js"; -export { UnsupportedLineItemAssetError, isUnsupportedLineItemAssetError } from "./errors.js"; - -// Contract invocation retry queue -export { ContractRetryQueue } from "./contractRetryQueue.js"; -export type { ContractInvocationExecutor, ContractRetryQueueConfig } from "./contractRetryQueue.js"; -export type { ContractInvocation, ContractResult } from "./types.js"; -export { ContractRetryExhaustedError, isContractRetryExhaustedError } from "./errors.js"; - -// Invoice batch processor with concurrency limiter -export { InvoiceBatchProcessor } from "./invoiceBatchProcessor.js"; -export type { - BatchInvoiceResult, - InvoiceBatchConfig, - InvoicePaymentSubmitter, -} from "./invoiceBatchProcessor.js"; - -// Merkle proof functionality -export { generateMerkleProof, verifyMerkleProof } from "./merkle.js"; -export type { MerkleProof } from "./merkle.js"; - -// Simulation sandbox — fork ledger state via simulateTransaction and run -// sequences of SDK operations against it without touching the network. -export { SimulationSandbox } from "./sandbox/SimulationSandbox.js"; -export type { - SandboxClient, - SimulationCost, - // The sandbox result type is re-exported under an unambiguous alias so it - // does not collide with the RPC `SimulationResult` (issue #844) exported - // from `./types.js` above. - SimulationResult as SandboxSimulationResult, - SandboxInvoiceRecord, - SandboxPaymentRecord, - SandboxCallLogEntry, - SandboxLedgerDiff, -} from "./sandbox/SimulationSandbox.js"; - -// Horizon SSE stream manager — cursor-bookmarked payments/operations -// streaming with reconnect-and-resume, dedupe, and replay cutoff. -export { - HorizonStreamManager, - InMemoryCursorStore, - createLocalStorageCursorStore, - createSessionStorageCursorStore, - DEFAULT_REPLAY_CUTOFF_MS, - DEFAULT_DEDUPE_BUFFER_SIZE, - DEFAULT_RECONNECT_DELAY_MS, -} from "./horizon/HorizonStreamManager.js"; -export type { - HorizonStreamRecord, - HorizonCallBuilderLike, - HorizonStreamSource, - CursorStore, - HorizonStreamKind, - HorizonStreamManagerConfig, - HorizonStreamEventMap, -} from "./horizon/HorizonStreamManager.js"; - -// Adaptive rate-limit throttle — sizes a token bucket from observed -// X-RateLimit-* headers and backs off further on 429s. -export { - AdaptiveThrottle, - DEFAULT_PENALTY_DURATION_MS, - DEFAULT_MAX_BACKOFF_MS, -} from "./throttle/AdaptiveThrottle.js"; -export type { AdaptiveThrottleConfig, ThrottleStats } from "./throttle/AdaptiveThrottle.js"; -export { parseRateLimitHeaders } from "./throttle/RateLimitParser.js"; -export type { HeadersLike, RateLimitInfo } from "./throttle/RateLimitParser.js"; -export { parseRetryAfter } from "./throttle/RateLimitParser.js"; - -// Receipt chain — SHA-256-linked, tamper-evident payment receipt history -// per invoice. `PaymentReceipt` is aliased to `ChainPaymentReceipt` here to -// avoid colliding with the unrelated `PaymentReceipt` already exported from -// receipt.js (a compiled multi-payment summary receipt). -export { - ReceiptChain, - GENESIS_PREV_HASH, - InMemoryReceiptChainStorage, - createLocalStorageReceiptChainStorage, - createSessionStorageReceiptChainStorage, - receiptChainStorageKey, -} from "./receipts/ReceiptChain.js"; -export type { ReceiptChainStorage } from "./receipts/ReceiptChain.js"; -export type { - PaymentReceipt as ChainPaymentReceipt, - ReceiptChainEntry, - ChainVerificationResult, -} from "./types/receipts.js"; - -// Connection multiplexer functionality -export { MultiplexedClient } from "./multiplexer.js"; -export type { WeightedEndpoint } from "./multiplexer.js"; - -// Connection pool (issue #360): up to 5 persistent HTTP/2 connections to the -// primary Soroban RPC endpoint with least-busy selection, 60s idle recycle, -// and per-slot stats. -export { - ConnectionPool, - MAX_POOL_SIZE, - DEFAULT_IDLE_TIMEOUT_MS, - DEFAULT_POOL_SIZE, -} from "./connectionPool.js"; -export type { - ConnectionPoolConfig, - PoolStats, - PoolSlotStats, - PooledServer, -} from "./connectionPool.js"; - -// Request batcher functionality -export { RequestBatcher, BatchedRpcClient } from "./requestBatcher.js"; -export type { BatcherConfig, BatchFetchers, BatchCallType } from "./requestBatcher.js"; - -export type { ComplianceReport } from "./compliance.js"; - -export { exportComplianceReport, CSV_COLUMNS } from "./complianceExporter.js"; -export type { - ComplianceExportRecord, - ComplianceExportOptions, - ComplianceExportResult, -} from "./complianceExporter.js"; - -export { ScheduledPaymentManager } from "./scheduler.js"; -export type { ScheduledPayment } from "./scheduler.js"; - -export { InvoiceReminderScheduler, DEFAULT_GRACE_PERIOD_MS } from "./invoiceReminderScheduler.js"; -export type { - InvoiceReminderSchedulerEventMap, - InvoiceDueAtResolver, - InvoiceReminderSchedulerOptions, -} from "./invoiceReminderScheduler.js"; -export { loadReminderSchedules, saveReminderSchedules } from "./snapshot.js"; -export type { ReminderSchedule, ReminderEvent, ReminderStatus } from "./types.js"; - -export { compileFilter, applyFilter, FilterIndex } from "./invoiceFilter.js"; -export type { FilterCriteria, CompiledFilter } from "./invoiceFilter.js"; - -// Invoice query engine — filter/sort/paginate invoices with one typed query -export { - InvoiceQueryEngine, - InvoiceTagIndex, - queryInvoices, - getInvoiceTags, - INVOICE_SORTS, - DEFAULT_QUERY_LIMIT, -} from "./invoiceQuery.js"; -export type { InvoiceFilter, InvoicePage, InvoiceSort } from "./invoiceQuery.js"; - -// Invoice diff utility -export { diffInvoices, hasDiff } from "./diff.js"; -export type { InvoiceDiff, InvoiceDiffEntry } from "./diff.js"; - -export { diffSimulations, compareSimulations, formatDiffSummary } from "./simulationDiff.js"; -export type { - SimulationDiff, - SimulationDiffSuccess, - SimulationDiffNotComparable, - ResourceDelta, - SimulationComparison, - SimulationComparisonNotComparable, -} from "./simulationDiff.js"; - -// Payment velocity tracking -export { trackVelocity } from "./velocityTracker.js"; -export type { VelocityReport, InvoiceVelocity, PaymentTrend } from "./velocityTracker.js"; -export type { VelocityStatus, VelocityWindowStatus } from "./types.js"; - -// Issue #866 — Pause/Resume invoice -export type { PauseStatus } from "./types.js"; - -// Issue #867 — Pledge matching -export type { MatchPledge } from "./types.js"; - -// Issue #868 — Streaming payments -export type { Stream } from "./types.js"; - -// Tranche release progress tracking -export { getTrancheProgress } from "./trancheProgress.js"; -export type { - TrancheProgress, - TrancheProgressReport, - TrancheProgressOptions, - TrancheConfig, - TranchedInvoice, - TrancheStatus, -} from "./trancheProgress.js"; - -// Invoice payment progress tracking -export { PaymentProgressTracker } from "./paymentProgressTracker.js"; -export type { - PaymentProgressEventMap, - PaymentProgressTrackerOptions, -} from "./paymentProgressTracker.js"; -export type { InvoicePaymentProgress, RecipientPaymentState } from "./types.js"; - -// Fiat-to-asset price oracle adapter -export { CoinGeckoPriceOracle } from "./priceOracle.js"; -export type { CoinGeckoPriceOracleOptions } from "./priceOracle.js"; -export { RateCache } from "./rateCache.js"; -export type { RateCacheConfig } from "./rateCache.js"; -export type { PriceOracleAdapter } from "./types.js"; -export { convertFiatToAsset } from "./currencyConverter.js"; -export type { FiatConversion, ConvertedAmount } from "./currencyConverter.js"; - -export { Sep41Adapter, createSep41Adapter } from "./sep41Adapter.js"; -export type { Sep41TokenCapabilities } from "./sep41Adapter.js"; - -export { Sep31Initiator, resolveDirectPaymentServer } from "./sep/sep31Initiator.js"; -export type { - Sep31InitiatorEventMap, - Sep31Asset, - Sep31PartyInfo, - Sep31InitiateParams, -} from "./sep/sep31Initiator.js"; -export type { - Sep31PaymentRecord, - Sep31Status, - Sep31StatusChangedEvent, - Sep31FieldSpec, - Sep31RequiredFields, -} from "./types.js"; - -export { HorizonFallbackReader } from "./horizonFallback.js"; -export type { NormalizedAccount, NormalizedBalance } from "./horizonFallback.js"; - -export { - buildSponsoredOnboarding, - MissingSponsorAccountError, - InsufficientReserveError, - checkSponsorshipReserve, -} from "./sponsorship.js"; - -export { - extendStorageTtl, - buildContractDataLedgerKey, - buildInvoiceDataLedgerKey, - buildInvoiceStorageKey, -} from "./ttlExtension.js"; -export type { - TtlExtensionOptions, - TtlExtensionResult, -} from "./ttlExtension.js"; - -export { - diffTemplate, - migrateTemplate, - migrateAllTemplates, -} from "./templateMigration.js"; -export type { - TemplateDiff, - TemplateDiffField, -} from "./templateMigration.js"; - -export { - validateClientConfig, - validateOrThrow, - InvalidConfigError, -} from "./configValidator.js"; -export type { - ConfigValidation, - ConfigValidationErrorType, -} from "./configValidator.js"; - -export { FundingVelocityAlert } from "./velocityAlert.js"; -export type { - VelocityAlert, - VelocityAlertKind, - VelocityConfig, -} from "./velocityAlert.js"; - -export { - createClaimableRefund, - getClaimableRefunds, - isRefundTransferError, - ClaimableBalanceLifecycle, -} from "./claimableBalanceFallback.js"; -export type { - ClaimableRefundResult, - ClaimableRefundEntry, - ClaimableBalanceLifecycleConfig, - ClaimableBalanceLifecycleEventMap, -} from "./claimableBalanceFallback.js"; - -export { PredicateBuilder } from "./predicateBuilder.js"; -export type { ClaimPredicate } from "./predicateBuilder.js"; -export type { PredicateConfig } from "./types.js"; - -export type { RateCacheEntry, RateOracleFn } from "./rateCache.js"; - -export { subscribeToInvoice } from "./sse.js"; -export type { - SSEInvoiceEventType, - SSEInvoiceEvent, - InvoiceEventHandler, - SubscribeToInvoiceOptions, - EventSourceLike, -} from "./sse.js"; -export type { PollingInvoiceEventHandler } from "./stream.js"; - -// WebSocket transport (Issue #377) -export { WebSocketTransport } from "./websocket.js"; -export type { TransportType, TransportStatus, TransportEventMap } from "./websocket.js"; -export { - bundleDisputeEvidence, - computeBundleChecksum, - verifyBundleChecksum, - registerProofFetcher, - registerAuditLogFetcher, - registerEventFetcher, -} from "./disputeEvidenceBundler.js"; -export type { - DisputeEvidenceBundle, - ProofFetcher, - AuditLogFetcher, - EventFetcher, -} from "./disputeEvidenceBundler.js"; - -export { UsageAnalyticsCollector, wrapWithAnalytics } from "./usageAnalytics.js"; -export type { - UsageAnalyticsConfig, - FeatureCountSnapshot, -} from "./usageAnalytics.js"; -export { IdempotencyManager } from "./idempotency.js"; -export type { IdempotencyConfig } from "./idempotency.js"; - -export { RollbackCoordinator } from "./splitRollbackCoordinator.js"; -export type { SplitRollbackEventMap } from "./splitRollbackCoordinator.js"; -export type { SplitRollbackRecord } from "./snapshot.js"; -export type { SplitLeg, SplitLegState, SplitResult, SplitRollbackCheckpoint } from "./types.js"; - -export { - validateInvoicePayload, - PayloadSizeError, -} from "./payloadGuard.js"; -export type { - PayloadGuardConfig, - PayloadViolation, -} from "./payloadGuard.js"; - -export { computeCreatorReputation } from "./reputation.js"; -export type { - CreatorReputationScore, - ReputationConfig, -} from "./reputation.js"; - -export { RiskAssessmentEngine } from "./riskAssessment.js"; -export type { - RiskAssessmentEventMap, - RiskAssessmentOptions, - RiskAssessmentResult, - RiskFactor, - RiskLevel, - RiskRule, - RiskRuleContext, - RiskRuleResult, - RiskSubject, -} from "./riskAssessment.js"; - -export { computePaymentForecast } from "./forecast.js"; -export type { - PaymentForecast, - ForecastConfig, - HistoricalInvoiceSample, -} from "./forecast.js"; - -// --------------------------------------------------------------------------- -// #852 — Deadline helpers -// --------------------------------------------------------------------------- - -export { - deadlineFromDays, - deadlineFromDate, - isDeadlineValid, - timeUntilDeadline, - formatDeadline, -} from "./deadline.js"; -export type { DeadlineRemaining } from "./deadline.js"; - -// --------------------------------------------------------------------------- -// #853 — Payment aggregator (multi-invoice budget allocation) -// --------------------------------------------------------------------------- - -export { - aggregatePayments, - createInvoiceRemainingFetcher, - registerInvoiceRemainingFetcher, - remainingForInvoice, -} from "./paymentAllocation.js"; -export type { - AggregatePaymentsOptions, - AmountLookup, - InvoiceRemainingFetcher, - InvoiceSource, - PaymentAllocation, - SplitStrategy, -} from "./paymentAllocation.js"; - - -// --------------------------------------------------------------------------- -// Split ratio validator -// --------------------------------------------------------------------------- - -export { - validateSplitRatios, - validateSplitRatiosOrThrow, - ratiosToRecipients, - validateSplitTotal, - normalizeSplits, -} from "./validators/splitRatioValidator.js"; -export type { - RecipientShare, - SplitConfig, - SplitRatioValidationResult, -} from "./validators/splitRatioValidator.js"; - -// --------------------------------------------------------------------------- -// Trustline checker -// --------------------------------------------------------------------------- - -export { checkTrustlines, checkSingleTrustline, checkTrustlinesBatch } from "./trustlineChecker.js"; -export type { TrustlineEntry, TrustlineCheckResult } from "./trustlineChecker.js"; - -// --------------------------------------------------------------------------- -// XDR parser -// --------------------------------------------------------------------------- - -export { parseEnvelope } from "./xdrParser.js"; -export type { - ParsedEnvelope, - ParsedTransaction, - ParsedOperation, - ParsedMemo, - ParsedSignature, - ParsedTimeBounds, -} from "./xdrParser.js"; - -// --------------------------------------------------------------------------- -// Fee surge detector -// --------------------------------------------------------------------------- - -export { detectFeeSurge, clearFeeSurgeCache } from "./feeSurgeDetector.js"; -export type { FeeSurgeConfig, FeeRecommendation, CongestionLevel } from "./feeSurgeDetector.js"; - -export { - reconcileChannel, - registerChannelStateFetcher, -} from "./channelReconciler.js"; -export type { - ChannelState, - ChannelReconciliationResult, - ChannelStateFetcher, -} from "./channelReconciler.js"; -export { getInvoiceStats, computeInvoiceStats } from "./invoiceStats.js"; -export { getInvoiceAge, getFundingVelocity } from "./invoiceStats.js"; -export type { InvoiceAge } from "./invoiceStats.js"; - -export { previewSplitRules } from "./splitPreview.js"; - -// --------------------------------------------------------------------------- -// #545 — Invoice Split Preview Change Diff Generator -// --------------------------------------------------------------------------- - -export { generateSplitDiff } from "./splitPreview.js"; -export type { - SplitConfigDiff, - ChangedShare, -} from "./splitPreview.js"; - -export { simulateAutoResolve } from "./autoResolveSimulator.js"; - -export { - resolvePrerequisiteChain, - MAX_PREREQUISITE_CHAIN_DEPTH, -} from "./prerequisiteChain.js"; - -export type { - SplitRule, - SplitPreviewEntry, - AutoResolveRule, - AutoResolveSimulation, - InvoiceStats, - PrerequisiteChainEntry, - PedersenCommitment, - BlindingFactorStorageConfig, - StoredBlindingFactor, - RevealPaymentOptions, - LineItem, - InvoiceMetadata, - IPFSConfig, - CIDVerificationResult, -} from "./types.js"; - -// --------------------------------------------------------------------------- -// #1049 — Internationalization (i18n) -// --------------------------------------------------------------------------- - -export { - DEFAULT_LOCALE, - SUPPORTED_LOCALES, - detectLanguage, - setLocale, - getLocale, - listLocales, - t, - translateErrorCode, - translateStatus, - tp, - registerTranslations, - registerLocale, -} from "./i18n.js"; -export type { SupportedLocale, LocaleCode, TranslationDict } from "./i18n.js"; - -// --------------------------------------------------------------------------- -// #1048 — Invoice export (PDF, CSV, JSON, text) -// --------------------------------------------------------------------------- - -export { - exportAsPdf, - exportAsCsv, - exportAsText, - exportAsJson, - renderInvoiceTable, - registerExportBuilder, - listExportBuilders, -} from "./export.js"; -export type { ExportFormat, InvoiceTableRow, ExportBuilder } from "./export.js"; - -// --------------------------------------------------------------------------- -// #1047 — QR code generation -// --------------------------------------------------------------------------- - -export { encode, isErrorCorrectionLevel, matrixToString } from "./qr/qrMatrix.js"; -export type { QRErrorCorrection, QRCapacity, QROptions } from "./qr/qrData.js"; - -// --------------------------------------------------------------------------- -// UI Components (React) -// --------------------------------------------------------------------------- - -export type { - DisputePanelProps, - DisputeEvidenceItem, -} from "./ui/DisputePanel.js"; - -export type { - DisputeTimelineProps, - DisputeTimelineEvent, - DisputeEventType, -} from "./ui/DisputeTimeline.js"; - -export type { - InvoiceDetailPageProps, -} from "./ui/InvoiceDetailPage.js"; - -export type { - UseInvoiceStreamOptions, - UseInvoiceStreamResult, -} from "./ui/hooks/useInvoiceStream.js"; - -// Note: Actual React components are exported from ./ui/index for tree-shaking -// Import them like: import { DisputePanel } from '@stellar-split/sdk/ui' - - -// --------------------------------------------------------------------------- -// Cross-chain bridge payment helpers -// --------------------------------------------------------------------------- - -export { - estimateBridgeFee, - buildBridgePayment, - submitBridgePayment, - computePayloadHash, - DEFAULT_CHAIN_CONFIGS, - SUPPORTED_CHAIN_IDS, - BridgeChainMismatchError, -} from "./bridge.js"; - -export type { ChainBridgeConfig, BridgeConfig, BridgeOptions } from "./bridge.js"; - -export type { - ChainId, - BridgeFeeEstimate, - BridgePaymentParams, - BridgePaymentRequest, - SignedBridgeProof, -} from "./types.js"; - -// Timeline reconstructor -export { PaymentTimelineReconstructor } from "./timeline/PaymentTimelineReconstructor.js"; -export type { - TimelineEntry, - TimelineEventType, - TimelineSource, - TimelineEntryStatus, - ReconstructedTimeline, - RebuildOptions, -} from "./types/timeline.js"; -export type { PaymentTimelineReconstructorConfig } from "./timeline/PaymentTimelineReconstructor.js"; -// --------------------------------------------------------------------------- -// Streaming subscriptions (SubscriptionManager) -// --------------------------------------------------------------------------- - -export { SubscriptionManager, getSubscriptionManager } from "./streaming/SubscriptionManager.js"; -export type { EventCursor, SubscriptionManagerLifecycleEvent } from "./types/events.js"; -export type { SubscriptionOptions as SubscriptionManagerOptions } from "./types/events.js"; -export { createStorageAdapter, MemoryStorageAdapter } from "./storage/storageAdapter.js"; -export type { StorageAdapter, StorageKind } from "./storage/storageAdapter.js"; - -// --------------------------------------------------------------------------- -// Resilience (advanced CircuitBreaker) -// --------------------------------------------------------------------------- - -export { CircuitBreaker as AdvancedCircuitBreaker } from "./resilience/CircuitBreaker.js"; -export type { - CircuitState as AdvancedCircuitState, - CircuitBreakerOptions as AdvancedCircuitBreakerOptions, - CircuitBreakerStateSnapshot as AdvancedCircuitBreakerStateSnapshot, - CircuitBreakerLogger as AdvancedCircuitBreakerLogger, -} from "./resilience/CircuitBreaker.js"; - -// --------------------------------------------------------------------------- -// Waterfall payment routing -// --------------------------------------------------------------------------- - -export { FraudDetectionIntegration } from "./fraudDetection.js"; -export type { - FraudAssessment, - FraudCheckKind, - FraudDetectionEvents, - FraudDetectionOptions, - FraudPayment, - FraudRule, - FraudRuleContext, - FraudSeverity, - FraudSignal, -} from "./fraudDetection.js"; - -export { WaterfallRouter } from "./routing/WaterfallRouter.js"; -export type { WaterfallConfig, WaterfallTier, WaterfallPlan, WaterfallStep } from "./types/routing.js"; -export { PaymentRouteOptimizer } from "./paymentRouteOptimizer.js"; -export type { - PaymentRouteCandidate, - PaymentRouteConstraints, - PaymentRouteOptimization, - PaymentRouteOptimizerEvents, - PaymentRouteOptimizerOptions, - PaymentRouteWeights, - RejectedPaymentRoute, - ScoredPaymentRoute, -} from "./paymentRouteOptimizer.js"; - -// --------------------------------------------------------------------------- -// Optimistic UI cache -// --------------------------------------------------------------------------- - -export { OptimisticCache } from "./cache/OptimisticCache.js"; -export type { - OptimisticCacheOptions, - RevalidateErrorEvent, - RollbackEvent, - OptimisticEntry, -} from "./cache/OptimisticCache.js"; - -// --------------------------------------------------------------------------- -// Split payment execution (pre-flight subentry guard + ratio validation) -// --------------------------------------------------------------------------- - -export { - splitExecutor, - SPLIT_RATIO_TOLERANCE, - SplitRatioSumError, - sumRecipientRatios, - validateSplitRatioSum, -} from "./payments/splitExecutor.js"; -export type { - SplitRecipient, - SplitExecutorOptions, - SplitExecutionResult, -} from "./payments/splitExecutor.js"; - -// --------------------------------------------------------------------------- -// Typed, zero-dependency event emitter (works in Node, browser, and edge runtimes) -// --------------------------------------------------------------------------- - -export { TypedEventEmitter, AbortError } from "./events/TypedEventEmitter.js"; -export type { Unsubscribe, EventMap } from "./events/TypedEventEmitter.js"; -export type { SplitClientEventMap } from "./client.js"; - -// --------------------------------------------------------------------------- -// SDK event bus — typed contract events (issue #856) -// --------------------------------------------------------------------------- - -export type { - PaymentEvent, - ReleaseEvent, - RefundEvent, - DisputeEvent, - TierUnlockedEvent, - EventFilterOptions, - ContractEvent, -} from "./contractEvents.js"; - -// --------------------------------------------------------------------------- -// Creator statistics (issue #854) -// --------------------------------------------------------------------------- - -export type { CreatorStats } from "./creatorStats.js"; -export { - getCreatorStatsCache, - setCreatorStatsCache, - clearCreatorStatsCache, -} from "./creatorStats.js"; - -// --------------------------------------------------------------------------- -// Multi-endpoint RPC load balancing -// --------------------------------------------------------------------------- - -export { RpcLoadBalancer } from "./rpc/RpcLoadBalancer.js"; -export type { - EndpointConfig, - RpcLoadBalancerOptions, - RpcEndpointServer, - RpcLoadBalancerEventMap, - EndpointSnapshot, -} from "./rpc/RpcLoadBalancer.js"; - -// --------------------------------------------------------------------------- -// Optional OpenTelemetry instrumentation (opt-in via `otel: { enabled: true }`; -// `@opentelemetry/api` is never required unless a consumer turns this on). -// --------------------------------------------------------------------------- - -export { OtelExporter, createOtelHandle, noopOtelHandle } from "./telemetry/OtelExporter.js"; -export type { - TelemetryOptions, - OtelHandle, - OtelSpanHandle, - OtlpTracePayload, -} from "./telemetry/OtelExporter.js"; -// #476 — OperationBuilder: fluent multi-op envelope builder with dry-run -// --------------------------------------------------------------------------- - -export { OperationBuilder } from "./builder/OperationBuilder.js"; -export type { - PaymentOptions as OperationBuilderPaymentOptions, - InvokeHostFnOptions, - BumpSequenceOptions, - TimeboundsOptions, - DryRunResult, - SubmitOptions, - OperationBuilderConfig, -} from "./builder/OperationBuilder.js"; -export { - EnvelopeLimitError, - isEnvelopeLimitError, - DryRunFailedError, - isDryRunFailedError, -} from "./errors.js"; - -// --------------------------------------------------------------------------- -// #477 — AccountSignerWeightCalculator: multi-sig pre-flight weight check -// --------------------------------------------------------------------------- - -export { AccountSignerWeightCalculator } from "./accounts/AccountSignerWeightCalculator.js"; -export type { - ThresholdLevel, - SignerWeightResult, -} from "./accounts/AccountSignerWeightCalculator.js"; -export { - InsufficientSignerWeightError, - isInsufficientSignerWeightError, -} from "./errors.js"; - -// --------------------------------------------------------------------------- -// #478 — PaymentDeduplicationFingerprinter: content-based payment dedup -// --------------------------------------------------------------------------- - -export { PaymentDeduplicationFingerprinter } from "./deduplication/PaymentDeduplicationFingerprinter.js"; -export type { - DeduplicationPayment, - CheckResult as DeduplicationCheckResult, -} from "./deduplication/PaymentDeduplicationFingerprinter.js"; -export { - DuplicatePaymentError, - isDuplicatePaymentError, -} from "./errors.js"; - -// --------------------------------------------------------------------------- -// #479 — LazyInitializer + SplitClient: on-demand RPC connection -// --------------------------------------------------------------------------- +/** + * Periodic health monitoring that records the latest report and notifies + * listeners on status changes. + */ +export class HealthMonitor { + private timer: ReturnType | null = null; + private lastReport: HealthReport | null = null; + private readonly listeners: Array<(report: HealthReport) => void> = []; + + constructor( + private readonly registry: HealthCheckRegistry, + private readonly intervalMs: number = 30_000, + ) {} + + onReport(listener: (report: HealthReport) => void): this { + this.listeners.push(listener); + return this; + } + + getLastReport(): HealthReport | null { + return this.lastReport; + } + + async runOnce(): Promise { + const report = await this.registry.runAll(); + const previous = this.lastReport; + this.lastReport = report; + if (!previous || previous.status !== report.status) { + for (const listener of this.listeners) { + listener(report); + } + } + return report; + } + + start(): this { + if (this.timer) return this; + void this.runOnce(); + this.timer = setInterval(() => { + void this.runOnce(); + }, this.intervalMs); + return this; + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } +} -export { LazyInitializer } from "./client/LazyInitializer.js"; -export { SplitClient } from "./client/SplitClient.js"; -export type { SplitClientConfig } from "./client/SplitClient.js"; -export { - BaseClientModule, - InvoiceModule, - PaymentModule, - DisputeModule, - AdminModule, - GroupModule, - TemplateModule, - MilestoneModule, -} from "./client/modules/index.js"; -export { - RpcConnectionError, - isRpcConnectionError, -} from "./errors.js"; +/** + * SDK metrics and statistics collection. + * + * Provides counters, gauges, and histograms registered in a central registry, + * plus export and analysis helpers for monitoring. + */ -// --------------------------------------------------------------------------- -// #483 — ContractStorageExporter: contract storage entry snapshot exporter -// --------------------------------------------------------------------------- +export type MetricType = 'counter' | 'gauge' | 'histogram'; -export { ContractStorageExporter, scValToJson } from "./diagnostics/ContractStorageExporter.js"; -export type { - ContractStorageSnapshot, - StorageEntry, - StorageDiff, - StorageModification, - ScValJson, - ScValJsonPrimitive, - ScValJsonVec, - ScValJsonMap, - ScValPrimitive, - ContractStorageExporterOptions, -} from "./diagnostics/ContractStorageExporter.js"; +export interface MetricSnapshot { + name: string; + type: MetricType; + value: number | HistogramSummary; + labels?: Record; +} -// --------------------------------------------------------------------------- -// #528 — AccountDataManager: typed CRUD for account data entries -// --------------------------------------------------------------------------- +export interface HistogramSummary { + count: number; + sum: number; + min: number; + max: number; + mean: number; + percentiles: Record; +} -export { AccountDataManager } from "./accountDataManager.js"; -export type { - AccountDataManagerConfig, - TransactionResult as AccountDataTransactionResult, -} from "./accountDataManager.js"; -export type { AccountDataEntry, AccountDataMap } from "./types.js"; -export { - DataEntryValidationError, - isDataEntryValidationError, -} from "./errors.js"; +export interface MetricAnalysis { + totalMetrics: number; + byType: Record; + counters: Record; + gauges: Record; + histograms: Record; +} -// --------------------------------------------------------------------------- -// #529 — SorobanFeatureDetector: protocol upgrade / feature flag detection -// --------------------------------------------------------------------------- +/** Monotonically increasing counter. */ +export class Counter { + readonly type: MetricType = 'counter'; + private value = 0; -export { SorobanFeatureDetector } from "./sorobanFeatureDetector.js"; -export type { - SorobanFeatureDetectorConfig, - SorobanFeatureDetectorEventMap, -} from "./sorobanFeatureDetector.js"; -export type { SorobanFeatureFlags } from "./types.js"; + constructor(readonly name: string, readonly labels?: Record) {} -// --------------------------------------------------------------------------- -// #530 — StreamDeduplicator: paging-token-based stream event dedup -// --------------------------------------------------------------------------- + inc(amount: number = 1): this { + if (amount < 0) { + throw new Error(`Counter ${this.name} cannot be decremented`); + } + this.value += amount; + return this; + } -export { StreamDeduplicator } from "./streamDeduplicator.js"; -export type { - StreamDeduplicatorOptions, - StreamDeduplicatorEventMap, -} from "./streamDeduplicator.js"; -export { - InMemoryDedupTokenStore, - setDefaultDedupTokenStore, - saveDedupTokens, - loadDedupTokens, -} from "./snapshot.js"; -export type { DedupTokenStore } from "./snapshot.js"; + get(): number { + return this.value; + } -// --------------------------------------------------------------------------- -// #531 — Per-Split Audit Log Emitter -// --------------------------------------------------------------------------- + reset(): void { + this.value = 0; + } +} -export { AuditLogger } from "./auditLogger.js"; -export type { AuditEntry } from "./auditLogger.js"; -export type { SplitAuditEntry } from "./types.js"; -export { - exportSplitAuditTrail, - SPLIT_AUDIT_CSV_COLUMNS, -} from "./complianceExporter.js"; +/** Gauge that can be set, incremented, or decremented. */ +export class Gauge { + readonly type: MetricType = 'gauge'; + private value = 0; -// --------------------------------------------------------------------------- -// #589 — Pluggable Signing Key Vault Adapter -// --------------------------------------------------------------------------- + constructor(readonly name: string, readonly labels?: Record) {} -export type { Signer } from "./signing/signer.js"; -export { KeypairSigner } from "./signing/adapters/KeypairSigner.js"; -export { EncryptedFileSigner } from "./signing/adapters/EncryptedFileSigner.js"; -export type { - EncryptedFileSignerOptions, -} from "./signing/adapters/EncryptedFileSigner.js"; -export { - encryptSigningKeyToPem, - writeEncryptedSigningKeyFile, -} from "./signing/adapters/EncryptedFileSigner.js"; -export { CloudKmsSigner, isRegionError } from "./signing/adapters/CloudKmsSigner.js"; -export type { - KmsClient, - KmsClientSignOptions, - CloudKmsSignerOptions, - CloudKmsSignerEventMap, -} from "./signing/adapters/CloudKmsSigner.js"; + set(value: number): this { + this.value = value; + return this; + } -// --------------------------------------------------------------------------- -// #588 — Soroban Transaction Footprint Optimizer -// --------------------------------------------------------------------------- + inc(amount: number = 1): this { + this.value += amount; + return this; + } -export { - optimizeFootprint, - simulateFootprint, - clearFootprintSimulationCache, -} from "./soroban/footprint.js"; -export type { - OptimizeFootprintOptions, - FootprintLogger, -} from "./soroban/footprint.js"; -export { footprintDiff } from "./utils/footprintDiff.js"; -export type { FootprintDiff } from "./utils/footprintDiff.js"; -export { submitTransaction } from "./transaction/submit.js"; -export type { - SubmitTransactionOptions, - SubmitServer, -} from "./transaction/submit.js"; + dec(amount: number = 1): this { + this.value -= amount; + return this; + } -// Request queue with priority lanes -export { RequestQueue } from "./requestQueue.js"; -export type { PriorityLane, QueuedRequest, QueueStats } from "./requestQueue.js"; + get(): number { + return this.value; + } +} -// Invoice history retrieval and parsing -export { getInvoiceHistory, getHistoryPage, parseHistoryEvent } from "./invoiceHistory.js"; -export type { - HistoryEntry, - HistoryEventType, - HistoryPage, - HistoryPageOptions, - PaymentHistoryEntry, - ReleaseHistoryEntry, - RefundHistoryEntry, - NoteHistoryEntry, - PauseHistoryEntry, - ResumeHistoryEntry, - CancelHistoryEntry, - FreezeHistoryEntry, - UnfreezeHistoryEntry, -} from "./types/invoiceHistory.js"; - -// Template management -export { - saveTemplate, - createFromTemplate, - deleteTemplate, - getTemplate, - listTemplates, - TemplateNotFoundError, - TemplateAccessDeniedError, -} from "./templates.js"; -export type { CreateFromTemplateParams, SaveTemplateParams } from "./types/templates.js"; - -// Recurring subscriptions -export { - createSubscription, - triggerSubscription, - pauseSubscription, - resumeSubscription, - getSubscription, - cancelSubscription, - SubscriptionNotFoundError, - TooEarlyToTriggerError, -} from "./subscriptions.js"; -export type { SubscriptionStatus, CreateSubscriptionParams } from "./types/subscriptions.js"; - -// Invoice recommendation AI (#980) -export { InvoiceRecommendationEngine } from "./invoiceRecommendation.js"; - -// Recipient suggestions API (#1051) -export { - suggestRecipients, - interactionFromRecipient, - DEFAULT_SUGGESTION_LIMIT, -} from "./recipientSuggestions.js"; -export type { - RecipientInteraction, - RecipientSuggestion, - SuggestRecipientsOptions, -} from "./recipientSuggestions.js"; -export type { - RecommendationSignal, - RecommendationContext, - InvoiceRecommendation, - RecommendationEngineOptions, - RecommendationEventMap, -} from "./invoiceRecommendation.js"; - -// Invoice retirement and archival management (#981) -export { InvoiceRetirementManager, RETIRABLE_STATUSES } from "./invoiceRetirement.js"; -export type { - ArchivedInvoiceRecord, - RetirementReason, - RetireOptions, - BulkRetirementResult, - RetirementManagerOptions, - RetirementEventMap, -} from "./invoiceRetirement.js"; - -// Multi-wallet support (#1027) -export { - MultiWalletManager, - MetaMaskConnector, - WalletConnectConnector, - LedgerConnector, - MetaMaskAdapter, - MetaMaskNotInstalledError, - MetaMaskConnectionError, - UserRejectedError, - WalletFallbackExhaustedError, - WalletNotAvailableError, -} from "./multiWallet.js"; -export type { - WalletAttemptLog, - MultiWalletOptions, - MultiWalletEvent, - MultiWalletEventType, - MultiWalletEventListener, -} from "./multiWallet.js"; - -// Transaction simulation before execution (#1028) -export { - TransactionSimulator, - TransactionSimulationError, -} from "./transactionSimulator.js"; -export type { - SimulationRequest, - SimulationReport, - SimulationStatus, - SimulationErrorCode, - SimulationErrorDetail, - SimulationResourceUsage, - SimulationEvent, - TransactionSimulatorOptions, -} from "./transactionSimulator.js"; - -// Automatic nonce management (#1029) -export { - NonceManager, - NonceError, - InvalidNonceError, - DuplicateNonceError, - StaleNonceError, - NonceGapError, -} from "./nonceManager.js"; -export type { - NonceState, - NonceManagerOptions, - NonceResetOptions, - NonceValidationResult, - NonceEvent, - NonceEventType, - NonceEventListener, -} from "./nonceManager.js"; +/** Histogram with configurable buckets and summary statistics. */ +export class Histogram { + readonly type: MetricType = 'histogram'; + private readonly buckets: number[]; + private readonly counts: number[]; + private count = 0; + private sum = 0; + private min = Infinity; + private max = -Infinity; + private readonly samples: number[] = []; + + constructor( + readonly name: string, + buckets: number[] = [5, 10, 25, 50, 100, 250, 500, 1000], + readonly labels?: Record, + ) { + this.buckets = [...buckets].sort((a, b) => a - b); + this.counts = new Array(this.buckets.length + 1).fill(0); + } + + observe(value: number): this { + this.count++; + this.sum += value; + if (value < this.min) this.min = value; + if (value > this.max) this.max = value; + this.samples.push(value); + let idx = this.buckets.length; + for (let i = 0; i < this.buckets.length; i++) { + if (value <= this.buckets[i]) { + idx = i; + break; + } + } + this.counts[idx]++; + return this; + } + + summary(percentiles: number[] = [50, 90, 95, 99]): HistogramSummary { + const sorted = [...this.samples].sort((a, b) => a - b); + const pct: Record = {}; + for (const p of percentiles) { + if (sorted.length === 0) { + pct[`p${p}`] = 0; + continue; + } + const rank = Math.ceil((p / 100) * sorted.length) - 1; + pct[`p${p}`] = sorted[Math.min(Math.max(rank, 0), sorted.length - 1)]; + } + return { + count: this.count, + sum: this.sum, + min: this.count === 0 ? 0 : this.min, + max: this.count === 0 ? 0 : this.max, + mean: this.count === 0 ? 0 : this.sum / this.count, + percentiles: pct, + }; + } + + bucketCounts(): number[] { + return [...this.counts]; + } +} // Connection Pooling (#1136) diff --git a/src/metrics.ts b/src/metrics.ts new file mode 100644 index 0000000..e08fe89 --- /dev/null +++ b/src/metrics.ts @@ -0,0 +1,246 @@ +/** + * SDK metrics and statistics collection. + * + * Provides a small, dependency-free metrics registry with counters, gauges, + * and histograms, plus export and analysis helpers for monitoring. + */ + +export type MetricType = 'counter' | 'gauge' | 'histogram'; + +export interface MetricSnapshot { + name: string; + type: MetricType; + help?: string; + value: number | HistogramSnapshot; +} + +export interface HistogramSnapshot { + count: number; + sum: number; + min: number; + max: number; + mean: number; + buckets: Record; + percentiles: Record; +} + +export interface MetricsExport { + timestamp: string; + metrics: MetricSnapshot[]; +} + +export interface MetricAnalysis { + totalMetrics: number; + counters: number; + gauges: number; + histograms: number; + summary: string[]; +} + +/** A monotonically increasing counter. */ +export class Counter { + readonly type: MetricType = 'counter'; + private current = 0; + + constructor(readonly name: string, readonly help?: string) {} + + /** Increment by a non-negative amount (default 1). */ + inc(amount = 1): void { + if (amount < 0) { + throw new Error(`Counter "${this.name}" cannot be decremented`); + } + this.current += amount; + } + + get value(): number { + return this.current; + } + + reset(): void { + this.current = 0; + } + + snapshot(): MetricSnapshot { + return { name: this.name, type: this.type, help: this.help, value: this.current }; + } +} + +/** A gauge that can be set, incremented, or decremented. */ +export class Gauge { + readonly type: MetricType = 'gauge'; + private current = 0; + + constructor(readonly name: string, readonly help?: string) {} + + set(value: number): void { + this.current = value; + } + + inc(amount = 1): void { + this.current += amount; + } + + dec(amount = 1): void { + this.current -= amount; + } + + get value(): number { + return this.current; + } + + reset(): void { + this.current = 0; + } + + snapshot(): MetricSnapshot { + return { name: this.name, type: this.type, help: this.help, value: this.current }; + } +} + +const DEFAULT_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]; +const DEFAULT_PERCENTILES = [0.5, 0.9, 0.95, 0.99]; + +/** A histogram tracking value distribution across configurable buckets. */ +export class Histogram { + readonly type: MetricType = 'histogram'; + private readonly buckets: number[]; + private readonly bucketCounts: number[]; + private readonly observations: number[] = []; + private count = 0; + private sum = 0; + private min = Infinity; + private max = -Infinity; + + constructor( + readonly name: string, + readonly help?: string, + buckets: number[] = DEFAULT_BUCKETS, + ) { + this.buckets = [...buckets].sort((a, b) => a - b); + this.bucketCounts = new Array(this.buckets.length).fill(0); + } + + observe(value: number): void { + this.count += 1; + this.sum += value; + this.min = Math.min(this.min, value); + this.max = Math.max(this.max, value); + this.observations.push(value); + for (let i = 0; i < this.buckets.length; i += 1) { + if (value <= this.buckets[i]) { + this.bucketCounts[i] += 1; + } + } + } + + reset(): void { + this.count = 0; + this.sum = 0; + this.min = Infinity; + this.max = -Infinity; + this.observations.length = 0; + this.bucketCounts.fill(0); + } + + snapshot(): MetricSnapshot { + return { name: this.name, type: this.type, help: this.help, value: this.summary() }; + } + + summary(): HistogramSnapshot { + const buckets: Record = {}; + this.buckets.forEach((bound, i) => { + buckets[String(bound)] = this.bucketCounts[i]; + }); + + const percentiles: Record = {}; + const sorted = [...this.observations].sort((a, b) => a - b); + for (const p of DEFAULT_PERCENTILES) { + percentiles[String(p)] = this.percentile(sorted, p); + } + + return { + count: this.count, + sum: this.sum, + min: this.count === 0 ? 0 : this.min, + max: this.count === 0 ? 0 : this.max, + mean: this.count === 0 ? 0 : this.sum / this.count, + buckets, + percentiles, + }; + } + + private percentile(sorted: number[], p: number): number { + if (sorted.length === 0) { + return 0; + } + const idx = Math.min(sorted.length - 1, Math.floor(p * sorted.length)); + return sorted[idx]; + } +} + +/** Registry that owns and exposes all metrics. */ +export class MetricsRegistry { + private readonly metrics = new Map(); + + counter(name: string, help?: string): Counter { + return this.getOrCreate(name, () => new Counter(name, help)) as Counter; + } + + gauge(name: string, help?: string): Gauge { + return this.getOrCreate(name, () => new Gauge(name, help)) as Gauge; + } + + histogram(name: string, help?: string, buckets?: number[]): Histogram { + return this.getOrCreate(name, () => new Histogram(name, help, buckets)) as Histogram; + } + + get(name: string): Counter | Gauge | Histogram | undefined { + return this.metrics.get(name); + } + + reset(): void { + this.metrics.forEach((metric) => metric.reset()); + } + + /** Export all metrics as a serializable snapshot. */ + export(): MetricsExport { + const metrics: MetricSnapshot[] = []; + this.metrics.forEach((metric) => metrics.push(metric.snapshot())); + return { timestamp: new Date().toISOString(), metrics }; + } + + /** Produce a human-readable analysis of the collected metrics. */ + analyze(): MetricAnalysis { + let counters = 0; + let gauges = 0; + let histograms = 0; + const summary: string[] = []; + + this.metrics.forEach((metric) => { + if (metric instanceof Counter) { + counters += 1; + summary.push(`counter ${metric.name} = ${metric.value}`); + } else if (metric instanceof Gauge) { + gauges += 1; + summary.push(`gauge ${metric.name} = ${metric.value}`); + } else if (metric instanceof Histogram) { + histograms += 1; + const s = metric.summary(); + summary.push( + `histogram ${metric.name}: count=${s.count} mean=${s.mean.toFixed(4)} max=${s.max}`, + ); + } + }); + + return { + totalMetrics: this.metrics.size, + counters, + gauges, + histograms, + summary, + }; + } +} + +/** Shared default registry for SDK-wide metrics. */ +export const metrics = new MetricsRegistry(); diff --git a/src/versionManager.ts b/src/versionManager.ts new file mode 100644 index 0000000..41f1353 --- /dev/null +++ b/src/versionManager.ts @@ -0,0 +1,193 @@ +/** + * SDK version management: versioning scheme, runtime compatibility checks, + * compatibility matrix, deprecation notices, migrations, and tracking. + */ + +export interface SdkVersion { + major: number; + minor: number; + patch: number; + prerelease?: string; +} + +export interface CompatibilityEntry { + /** Minimum SDK version that supports the feature. */ + minVersion: string; + /** Optional maximum SDK version (exclusive) that supports the feature. */ + maxVersion?: string; + /** Peer/runtime requirements for the feature. */ + peers?: string[]; +} + +export interface DeprecationNotice { + feature: string; + deprecatedIn: string; + removedIn?: string; + message: string; + replacement?: string; +} + +export interface Migration { + from: string; + to: string; + description: string; + apply: (state: Record) => Record; +} + +export interface VersionCheckResult { + compatible: boolean; + current: string; + required: string; + reason?: string; +} + +const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/; + +export function parseVersion(version: string): SdkVersion { + const match = SEMVER_RE.exec(version.trim()); + if (!match) { + throw new Error(`Invalid SDK version: ${version}`); + } + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4], + }; +} + +export function formatVersion(v: SdkVersion): string { + const base = `${v.major}.${v.minor}.${v.patch}`; + return v.prerelease ? `${base}-${v.prerelease}` : base; +} + +/** Compare two versions: negative if a < b, 0 if equal, positive if a > b. */ +export function compareVersions(a: string, b: string): number { + const va = parseVersion(a); + const vb = parseVersion(b); + for (const key of ['major', 'minor', 'patch'] as const) { + if (va[key] !== vb[key]) { + return va[key] - vb[key]; + } + } + // A prerelease is lower than the corresponding release. + if (va.prerelease && !vb.prerelease) return -1; + if (!va.prerelease && vb.prerelease) return 1; + if (va.prerelease && vb.prerelease) { + return va.prerelease < vb.prerelease ? -1 : va.prerelease > vb.prerelease ? 1 : 0; + } + return 0; +} + +/** + * Compatibility matrix mapping SDK versions to supported features and peers. + */ +export const COMPATIBILITY_MATRIX: Record = { + '1.0.0': { minVersion: '1.0.0', peers: ['node>=16'] }, + '1.1.0': { minVersion: '1.0.0', peers: ['node>=16'] }, + '2.0.0': { minVersion: '2.0.0', peers: ['node>=18'] }, +}; + +export const DEPRECATIONS: DeprecationNotice[] = [ + { + feature: 'legacyClient', + deprecatedIn: '1.1.0', + removedIn: '2.0.0', + message: 'legacyClient is deprecated and will be removed in 2.0.0.', + replacement: 'createClient', + }, +]; + +/** + * Check whether the current SDK version satisfies a required minimum version. + */ +export function checkVersion(current: string, required: string): VersionCheckResult { + let cmp: number; + try { + cmp = compareVersions(current, required); + } catch (err) { + return { + compatible: false, + current, + required, + reason: (err as Error).message, + }; + } + if (cmp < 0) { + return { + compatible: false, + current, + required, + reason: `SDK version ${current} is below required ${required}`, + }; + } + return { compatible: true, current, required }; +} + +/** + * Determine whether a feature is supported by the given SDK version. + */ +export function isFeatureSupported(feature: string, version: string): boolean { + const entry = COMPATIBILITY_MATRIX[feature]; + if (!entry) return false; + if (compareVersions(version, entry.minVersion) < 0) return false; + if (entry.maxVersion && compareVersions(version, entry.maxVersion) >= 0) return false; + return true; +} + +/** + * Return deprecation notices that apply to the given SDK version. + */ +export function getDeprecationNotices(version: string): DeprecationNotice[] { + return DEPRECATIONS.filter((d) => { + if (compareVersions(version, d.deprecatedIn) < 0) return false; + if (d.removedIn && compareVersions(version, d.removedIn) >= 0) return false; + return true; + }); +} + +/** + * Apply all migrations needed to move state from one version to another. + */ +export function migrate( + state: Record, + from: string, + to: string, + migrations: Migration[], +): Record { + let result = { ...state }; + const applicable = migrations + .filter((m) => compareVersions(m.from, from) >= 0 && compareVersions(m.to, to) <= 0) + .sort((a, b) => compareVersions(a.from, b.from)); + for (const migration of applicable) { + result = migration.apply(result); + } + return result; +} + +/** + * Tracks the SDK version and records version transitions. + */ +export class VersionTracker { + private current: string; + private readonly history: Array<{ from: string; to: string; at: number }> = []; + + constructor(initialVersion: string) { + parseVersion(initialVersion); + this.current = initialVersion; + } + + get version(): string { + return this.current; + } + + get transitions(): ReadonlyArray<{ from: string; to: string; at: number }> { + return this.history; + } + + update(nextVersion: string): void { + parseVersion(nextVersion); + this.history.push({ from: this.current, to: nextVersion, at: Date.now() }); + this.current = nextVersion; + } +}