TypeScript SDK
Hapnd TypeScript SDK
Section titled “Hapnd TypeScript SDK”Official TypeScript SDK for Hapnd, the event sourcing platform built for engineers. Append events, query aggregate state, subscribe to real-time projection updates with a fluent immutable API, zero runtime dependencies, and compatibility with any JavaScript runtime that supports the Fetch API.
Installation
Section titled “Installation”npm install @hapnd/client- Node.js 18+ for aggregate operations (append, state queries)
- Node.js 22+ for subscriptions (requires global
WebSocket)
Quick Start
Section titled “Quick Start”import { createHapndClient } from "@hapnd/client";
const hapnd = createHapndClient("sk_live_your_api_key");
const result = await hapnd.aggregate("order_001") .append("OrderCreated", { customerId: "customer_123" });
console.log(`EventId: ${result.eventId}, Version: ${result.version}`);Creating a Client
Section titled “Creating a Client”Simple
Section titled “Simple”const hapnd = createHapndClient("sk_live_your_api_key");With Options
Section titled “With Options”const hapnd = createHapndClient({ apiKey: "sk_live_your_api_key", baseUrl: "https://hapnd-api.lightestnight.workers.dev", timeout: 60_000, resilience: { maxRetryAttempts: 5, enableCircuitBreaker: true, },});Configuration Options
Section titled “Configuration Options”| Option | Type | Default | Description |
|---|---|---|---|
apiKey | string | (required) | Your Hapnd API key |
baseUrl | string | https://hapnd-api.lightestnight.workers.dev | API base URL |
timeout | number | 30_000 | Request timeout in milliseconds |
resilience | ResilienceOptions | false | Enabled with defaults | Retry and circuit breaker config. Set to false to disable. |
Resilience Defaults
Section titled “Resilience Defaults”| Option | Default | Description |
|---|---|---|
maxRetryAttempts | 3 | Maximum retry attempts after the initial request |
retryDelay | 500 | Base delay in ms for the first retry |
maxRetryDelay | 10_000 | Maximum delay in ms between retries |
enableCircuitBreaker | true | Enable the circuit breaker |
circuitBreakerDuration | 30_000 | How long the circuit stays open before half-open (ms) |
circuitBreakerSamplingDuration | 60_000 | Sliding window for failure tracking (ms) |
circuitBreakerFailureRatio | 0.5 | Failure ratio threshold that trips the circuit |
circuitBreakerMinimumThroughput | 10 | Minimum requests before the circuit can trip |
Disposing
Section titled “Disposing”hapnd.dispose();Aborts all in-flight requests, resets resilience state, and releases resources. Do not use the client after calling dispose().
Appending Events
Section titled “Appending Events”Single Event
Section titled “Single Event”const result = await hapnd.aggregate("order_123") .append("ItemAdded", { item: "Widget", price: 25.00 });
console.log(result.eventId); // "evt_abc123"console.log(result.aggregateId); // "order_123"console.log(result.aggregateType); // "order"console.log(result.version); // 1console.log(result.timestamp); // "2025-01-15T10:30:00Z"console.log(result.state); // computed state or undefinedAppendResult properties:
| Property | Type | Description |
|---|---|---|
eventId | string | Unique identifier for the appended event |
aggregateId | string | The aggregate this event was appended to |
aggregateType | string | The type of the aggregate |
version | number | Version of the aggregate after this event |
timestamp | string | ISO 8601 timestamp |
state | unknown | Computed state if a reducer is bound, else undefined |
Batch Append
Section titled “Batch Append”const result = await hapnd.aggregate("order_123") .appendMany([ { eventType: "ItemAdded", data: { item: "Widget", price: 25.00 } }, { eventType: "ItemAdded", data: { item: "Gadget", price: 15.00 } }, ]);
console.log(`Appended ${result.eventIds.length} events (versions ${result.startVersion}-${result.endVersion})`);All events in a batch are stored atomically; either all succeed or none do.
AppendManyResult properties:
| Property | Type | Description |
|---|---|---|
aggregateId | string | The aggregate these events were appended to |
aggregateType | string | The type of the aggregate |
startVersion | number | Version after the first event in the batch |
endVersion | number | Version after the last event in the batch |
timestamp | string | ISO 8601 timestamp |
eventIds | string[] | Unique identifiers for each event, in order |
state | unknown | Computed state if a reducer is bound |
Request Options
Section titled “Request Options”All terminal methods accept an optional AbortSignal for cancellation:
const controller = new AbortController();
const result = await hapnd.aggregate("order_123") .append("ItemAdded", { item: "Widget", price: 25.00 }, { signal: controller.signal });Aggregates
Section titled “Aggregates”Aggregate Type Inference
Section titled “Aggregate Type Inference”The SDK infers the aggregate type from the ID using the {type}_{id} or {type}-{id} convention:
| Aggregate ID | Inferred Type |
|---|---|
order_123 | order |
cart-abc | cart |
CUSTOMER_456 | customer |
Override when your IDs don’t follow this convention:
await hapnd.aggregate("ORD-2024-00123") .withAggregateType("order") .append("OrderCreated", { customerId: "customer_456" });The parseAggregateType utility is also exported for direct use:
import { parseAggregateType } from "@hapnd/client";
const type = parseAggregateType("order_123"); // "order"Optimistic Concurrency
Section titled “Optimistic Concurrency”import { HapndConcurrencyError } from "@hapnd/client";
const current = await hapnd.aggregate("order_123").getState<OrderState>();
try { await hapnd.aggregate("order_123") .expectVersion(current.version) .append("ItemAdded", { item: "Widget", price: 25.00 });} catch (err) { if (err instanceof HapndConcurrencyError) { console.log(`Expected ${err.expectedVersion}, actual ${err.actualVersion}`); // Reload state and retry }}Querying State
Section titled “Querying State”interface OrderState { total: number; items: string[]; itemCount: number;}
const aggregate = await hapnd.aggregate("order_123").getState<OrderState>();
if (aggregate !== null) { console.log(`Version: ${aggregate.version}`); console.log(`Total: ${aggregate.state.total}`); console.log(`Last Modified: ${aggregate.lastModified}`);}Returns AggregateState<T> | null, where null indicates the aggregate does not exist or no reducer is bound.
Distributed Tracing
Section titled “Distributed Tracing”Correlation IDs
Section titled “Correlation IDs”const correlationId = crypto.randomUUID();
await hapnd.aggregate("order_123") .withCorrelation(correlationId) .append("OrderCreated", { customerId: "customer_456" });
await hapnd.aggregate("inventory_widget") .withCorrelation(correlationId) .append("StockReserved", { orderId: "order_123", quantity: 1 });Causation IDs
Section titled “Causation IDs”const orderResult = await hapnd.aggregate("order_123") .append("OrderCreated", { customerId: "customer_456" });
await hapnd.aggregate("notification_123") .withCausation(orderResult.eventId) .append("OrderConfirmationSent", { customerId: "customer_456" });Metadata
Section titled “Metadata”await hapnd.aggregate("order_123") .withMetadata({ userId: "user_456", ipAddress: "192.168.1.1", userAgent: "MyApp/1.0", }) .append("OrderCreated", { customerId: "customer_456" });Immutable Builder
Section titled “Immutable Builder”The TypeScript builder is immutable; each chaining method returns a new AggregateBuilder instance. The original builder is unchanged. This is different from the .NET SDK, where the builder is mutable.
const base = hapnd.aggregate("order_123").withCorrelation("corr_abc");
// base is NOT modified; each call creates a new builderconst v1 = base.expectVersion(1);const v2 = base.expectVersion(2);
// v1 and v2 are independent buildersawait v1.append("ItemAdded", { item: "Widget", price: 25.00 });await v2.append("ItemAdded", { item: "Gadget", price: 15.00 });Configuration methods (return new AggregateBuilder): withAggregateType, expectVersion, withCorrelation, withCausation, withMetadata
Terminal methods (execute HTTP request): append, appendMany, getState
const result = await hapnd.aggregate("order_123") .withAggregateType("order") .expectVersion(3) .withCorrelation("corr_abc") .withCausation("evt_previous") .withMetadata({ userId: "user_456", source: "web" }) .append("ItemAdded", { item: "Widget", price: 25.00 });Querying Projection State
Section titled “Querying Projection State”Query the current state of a projection directly by its state key.
const state = await hapnd.getProjectionState<OrderSummary>("proj_abc123", "customer-123");
if (state) { console.log(`Total spend: ${state.state.totalSpend}`); console.log(`Last updated: ${state.updatedAt}`);}Returns null if the projection or state key doesn’t exist.
If the projection is still catching up on historical events, a HapndProjectionCatchingUpError is thrown:
import { HapndProjectionCatchingUpError } from "@hapnd/client";
try { const state = await hapnd.getProjectionState<OrderSummary>("proj_abc123", "customer-123");} catch (err) { if (err instanceof HapndProjectionCatchingUpError) { console.log(`Projection ${err.projectionId} is still processing historical events`); }}Real-Time Subscriptions
Section titled “Real-Time Subscriptions”The TypeScript SDK uses a config object pattern for subscriptions (not a builder pattern). This is intentionally different from the .NET SDK.
Basic Usage
Section titled “Basic Usage”const subscription = hapnd.subscribe({ projections: [ { id: "proj_orders", onUpdate: async (update) => { console.log(`Order ${update.aggregateId} updated to version ${update.version}`); console.log(`State:`, update.state); }, }, ], onError: (error) => { console.error(`Stream ${error.projectionId} failed:`, error.error.message); error.action.reconnect(); },});
// Later, graceful shutdown:await subscription.close();Multiple Projections
Section titled “Multiple Projections”Each projection gets its own WebSocket connection:
const subscription = hapnd.subscribe({ projections: [ { id: "proj_orders", onUpdate: async (update) => { console.log(`Order total: ${update.state.total}`); }, }, { id: "proj_inventory", onUpdate: async (update) => { console.log(`Stock level: ${update.state.stockLevel}`); }, }, ], onError: (error) => { console.error(`Stream ${error.projectionId} failed`); error.action.reconnect(); },});Error Handling
Section titled “Error Handling”The onError handler is required; hapnd.subscribe() throws HapndValidationError if it is missing or not a function.
The error handler receives a StreamError with an action object. Call exactly one method:
onError: (error) => { switch (error.projectionId) { case "proj_critical": // Non-recoverable, shut everything down error.action.shutdown(); break; case "proj_optional": // Not essential, stop just this stream error.action.stop(); break; default: // Transient, reconnect and retry error.action.reconnect(); break; }}| Action | Effect |
|---|---|
error.action.reconnect() | Resume the stream from the last good sequence with backoff |
error.action.stop() | Stop this projection’s stream only; others continue |
error.action.shutdown() | Shut down the entire subscription, closing all streams |
Safeguards:
- If the error handler itself throws, the failing stream stops automatically
- If no action is called within 30 seconds, the stream stops automatically
- Calling multiple actions on the same error throws an
Error - Sequence advances only after the
onUpdatehandler completes successfully; on reconnect, the server resends from the last acknowledged position
ProjectionUpdate Properties
Section titled “ProjectionUpdate Properties”| Property | Type | Description |
|---|---|---|
projectionId | string | The projection that produced this update |
aggregateId | string | The aggregate whose state changed |
aggregateType | string | The aggregate type |
version | number | Aggregate version after this change |
state | TState | The computed state |
sequence | number | Server-assigned sequence number for resumption |
timestamp | string | ISO 8601 timestamp |
Reconnection
Section titled “Reconnection”Automatic reconnection with exponential backoff and jitter:
- Base delay: 1 second
- Maximum delay: 30 seconds
- Jitter: 75%–125% of calculated delay
- Resets to base delay after a successful connection
- Uses
?afterSequence=query parameter for resumption from last acknowledged sequence
Resilience
Section titled “Resilience”Exponential backoff with jitter. Automatically retries transient failures:
Retried: Network errors, timeouts, 5xx server errors, 429 (rate limit), 408 (request timeout)
Not retried: 4xx client errors, 409 (concurrency conflict), cancellation
Circuit Breaker
Section titled “Circuit Breaker”Prevents cascading failures by temporarily rejecting requests. Transitions: Closed → Open → Half-Open → Closed.
Configuration
Section titled “Configuration”const hapnd = createHapndClient({ apiKey: "sk_live_your_api_key", resilience: { maxRetryAttempts: 5, retryDelay: 200, maxRetryDelay: 5_000, enableCircuitBreaker: true, circuitBreakerDuration: 30_000, circuitBreakerSamplingDuration: 60_000, circuitBreakerFailureRatio: 0.5, circuitBreakerMinimumThroughput: 10, },});Disabling
Section titled “Disabling”const hapnd = createHapndClient({ apiKey: "sk_live_your_api_key", resilience: false,});Error Handling
Section titled “Error Handling”import { HapndValidationError, HapndConcurrencyError, HapndAggregateTypeMismatchError, HapndApiError, HapndNetworkError, HapndCircuitOpenError,} from "@hapnd/client";
try { await hapnd.aggregate("order_123") .expectVersion(5) .append("ItemAdded", { item: "Widget", price: 25.00 });} catch (err) { if (err instanceof HapndValidationError) { // Client-side validation failure (before any network request) console.log(`Validation: ${err.message}`); } else if (err instanceof HapndConcurrencyError) { // 409, version mismatch console.log(`Expected ${err.expectedVersion}, actual ${err.actualVersion}`); } else if (err instanceof HapndAggregateTypeMismatchError) { // 400, aggregate type conflict console.log(`Expected '${err.expectedType}', actual '${err.actualType}'`); } else if (err instanceof HapndCircuitOpenError) { // Circuit breaker is open console.log(`Retry after ${err.remainingDuration}ms`); } else if (err instanceof HapndApiError) { // Server returned an error (varies by status code) console.log(`API error ${err.statusCode}: ${err.message}`); } else if (err instanceof HapndNetworkError) { // Network failure (DNS, connection refused, timeout) console.log(`Network error: ${err.message}`); }}| Error Class | HTTP Status | Description |
|---|---|---|
HapndValidationError | N/A | Client-side validation failure before request |
HapndConcurrencyError | 409 | Optimistic concurrency version mismatch |
HapndAggregateTypeMismatchError | 400 | Aggregate type conflicts with existing events |
HapndApiError | Varies | Server returned an error response |
HapndNetworkError | N/A | Network-level failure (DNS, timeout, etc.) |
HapndCircuitOpenError | N/A | Circuit breaker is open, requests rejected |
Edge Runtime Support
Section titled “Edge Runtime Support”The SDK works in any JavaScript runtime with a global fetch implementation:
- Node.js 18+ - aggregate operations (append, state queries)
- Node.js 22+ - aggregate operations + subscriptions (requires global
WebSocket) - Cloudflare Workers - full support
- Deno - full support
- Bun - full support
Zero Dependencies
Section titled “Zero Dependencies”The SDK has zero runtime dependencies. All functionality, including HTTP client, retry logic, circuit breaker, and WebSocket management, is implemented from scratch using standard Web APIs (fetch, AbortController, WebSocket).
API Endpoints
Section titled “API Endpoints”| Endpoint | Method | SDK Method |
|---|---|---|
/events | POST | .append() |
/events/batch | POST | .appendMany() |
/aggregate-types/{type}/{id}/state | GET | .getState() |
/projections/{id}/stream | WebSocket | .subscribe() |
Full Example
Section titled “Full Example”import { createHapndClient, HapndConcurrencyError, type AppendResult, type AggregateState, type HapndSubscription,} from "@hapnd/client";
interface OrderState { total: number; items: string[]; itemCount: number; customerId: string | null;}
const hapnd = createHapndClient("sk_live_your_api_key");
async function createOrder(customerId: string): Promise<AppendResult> { const orderId = `order_${crypto.randomUUID()}`;
return hapnd.aggregate(orderId) .expectVersion(0) .withCorrelation(crypto.randomUUID()) .append("OrderCreated", { customerId });}
async function addItem(orderId: string, item: string, price: number, expectedVersion: number): Promise<void> { try { await hapnd.aggregate(orderId) .expectVersion(expectedVersion) .append("ItemAdded", { item, price }); } catch (err) { if (err instanceof HapndConcurrencyError) { const current = await hapnd.aggregate(orderId).getState<OrderState>(); if (current !== null) { await hapnd.aggregate(orderId) .expectVersion(current.version) .append("ItemAdded", { item, price }); } } else { throw err; } }}
async function getOrder(orderId: string): Promise<OrderState | null> { const aggregate = await hapnd.aggregate(orderId).getState<OrderState>(); return aggregate?.state ?? null;}
// Subscribe to order updatesconst subscription: HapndSubscription = hapnd.subscribe({ projections: [ { id: "proj_orders", onUpdate: async (update) => { const state = update.state as OrderState; console.log(`Order ${update.aggregateId} updated: total=${state.total}, items=${state.itemCount}`); }, }, ], onError: (error) => { console.error(`Stream ${error.projectionId} error at sequence ${error.sequence}:`, error.error.message); error.action.reconnect(); },});
// Run some operationsconst order = await createOrder("customer_456");await addItem(order.aggregateId, "Widget", 25.00, order.version);await addItem(order.aggregateId, "Gadget", 15.00, order.version + 1);
const finalState = await getOrder(order.aggregateId);console.log("Final state:", finalState);
// Clean shutdownawait subscription.close();hapnd.dispose();Exports
Section titled “Exports”import { // Factory + client interface createHapndClient, type HapndClient,
// Fluent builder AggregateBuilder,
// Error hierarchy HapndError, HapndValidationError, HapndApiError, HapndConcurrencyError, HapndAggregateTypeMismatchError, HapndNetworkError, HapndCircuitOpenError,
// Public types type HapndClientOptions, type ResilienceOptions, type AppendResult, type AppendManyResult, type AggregateState, type BatchEvent, type RequestOptions,
// Subscription types type SubscriptionOptions, type ProjectionHandler, type ProjectionUpdate, type StreamError, type StreamErrorAction, type HapndSubscription,
// Utility parseAggregateType,} from "@hapnd/client";License & Links
Section titled “License & Links”Licensed under Apache 2.0.