npm package: @hapnd/client
Description: TypeScript SDK for the Hapnd event sourcing platform
Latest version: 0.1.3

# @hapnd/client

TypeScript SDK for the [Hapnd](https://hapnd.dev) event sourcing platform.

Append events, query aggregate state, and let Hapnd handle projections, reducers, and all the infrastructure complexity — from any JavaScript runtime.

## Installation

```bash
npm install @hapnd/client
# or
pnpm add @hapnd/client
# or
yarn add @hapnd/client
```

**Requirements:** Node.js 18+, ESM only. No CommonJS support.

## Quick Start

```typescript
import { createHapndClient } from "@hapnd/client";

const hapnd = createHapndClient("hpnd_your_api_key");

// Append an event
const result = await hapnd
  .aggregate("order_123")
  .append("OrderPlaced", { customerId: "cust_456", total: 99.99 });

console.log(result.eventId); // evt_abc123
console.log(result.version); // 1
console.log(result.state); // computed state (if a reducer is bound)
```

## Full API

### Client Creation

```typescript
import { createHapndClient } from "@hapnd/client";

// Simple — just an API key
const hapnd = createHapndClient("hpnd_your_api_key");

// Or, With options
const hapnd = createHapndClient({
  apiKey: "hpnd_your_api_key",
  baseUrl: "https://hapnd-api.lightestnight.workers.dev", // default
  timeout: 30_000, // ms, default 30s

  resilience: {
    maxRetryAttempts: 3, // default
    retryDelay: 500, // base delay ms, default
    maxRetryDelay: 10_000, // max delay ms, default
    enableCircuitBreaker: true, // default
    circuitBreakerDuration: 30_000, // ms open before half-open
    circuitBreakerSamplingDuration: 60_000, // sliding window ms
    circuitBreakerFailureRatio: 0.5, // trip threshold (50%)
    circuitBreakerMinimumThroughput: 10, // min requests before tripping
  },
});

// Or if you'd prefer to disable all resilience (no retries, no circuit breaker)
const hapnd = createHapndClient({
  apiKey: "hpnd_your_api_key",
  resilience: false,
});
```

### Appending Events

The aggregate type is inferred from the ID using the `{type}_{id}` or `{type}-{id}` convention. Use `.withAggregateType()` for non-conforming IDs.

```typescript
// Single event — minimal
const result = await hapnd
  .aggregate("order_123")
  .append("OrderPlaced", { customerId: "cust_456" });

// Single event — with all options
const result = await hapnd
  .aggregate("order_123")
  .expectVersion(5) // optimistic concurrency
  .withCorrelation("corr_abc")
  .withCausation("evt_xyz")
  .withMetadata({ userId: "u_1" })
  .append("ItemAdded", { item: "Widget", price: 25.0 });

// Non-conforming ID — specify type explicitly
const result = await hapnd
  .aggregate("ORD-2024-00123")
  .withAggregateType("order")
  .append("OrderPlaced", { customerId: "cust_456" });
```

**`AppendResult`:**

```typescript
{
  eventId: string;       // unique event ID
  aggregateId: string;
  aggregateType: string;
  version: number;       // version after this event
  timestamp: string;     // ISO 8601
  state?: unknown;       // computed state if reducer is bound
}
```

### Batch Append

Atomically append multiple events in a single request:

```typescript
const result = await hapnd
  .aggregate("order_123")
  .expectVersion(5)
  .appendMany([
    { eventType: "ItemAdded", data: { item: "Widget", price: 25.0 } },
    { eventType: "ItemAdded", data: { item: "Gadget", price: 15.0 } },
  ]);
```

**`AppendManyResult`:**

```typescript
{
  aggregateId: string;
  aggregateType: string;
  startVersion: number;  // version of first event
  endVersion: number;    // version of last event
  timestamp: string;
  eventIds: string[];
  state?: unknown;
}
```

### Querying State

```typescript
interface OrderState {
  total: number;
  items: string[];
}

const state = await hapnd.aggregate("order_123").getState<OrderState>();

if (state === null) {
  // Aggregate doesn't exist, or no reducer is bound to this type
} else {
  console.log(state.version); // current version
  console.log(state.state.total); // typed state
}
```

**`AggregateState<T>`:**

```typescript
{
  aggregateId: string;
  aggregateType: string;
  version: number;
  state: T;
  lastModified: string; // ISO 8601
}
```

### Real-Time Subscriptions

Subscribe to projection state changes in real time over WebSocket:

```typescript
const subscription = hapnd.subscribe({
  projections: [
    {
      id: "proj_orders",
      onUpdate: async (update) => {
        console.log(`Order ${update.aggregateId} updated:`, update.state);
      },
    },
  ],
  onError: async (error) => {
    console.error(`Stream error on ${error.projectionId}:`, error.error);
    error.action.reconnect(); // resume from last successful sequence
  },
});
```

Subscribe to multiple projections simultaneously — each runs on its own WebSocket:

```typescript
const subscription = hapnd.subscribe({
  projections: [
    {
      id: "proj_orders",
      onUpdate: async (update) => {
        await updateDashboard(update.state);
      },
    },
    {
      id: "proj_inventory",
      onUpdate: async (update) => {
        await refreshStock(update.state);
      },
    },
  ],
  onError: async (error) => {
    console.error(`Stream ${error.projectionId} failed:`, error.error);
    error.action.reconnect();
  },
});
```

**Error handling:** When an `onUpdate` handler throws, the stream pauses and your `onError` handler is called. You must call exactly one action:

- `error.action.reconnect()` — resume from the last successfully processed sequence
- `error.action.stop()` — stop this projection's stream only; others continue
- `error.action.shutdown()` — close the entire subscription

If no action is called within 30 seconds, the stream is stopped automatically.

**Cleanup:**

```typescript
await subscription.close(); // gracefully closes all WebSocket connections
```

### Per-Request Cancellation

All terminal methods accept an optional `AbortSignal`:

```typescript
const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000);

const result = await hapnd
  .aggregate("order_123")
  .append(
    "OrderPlaced",
    { customerId: "cust_1" },
    { signal: controller.signal },
  );
```

### Aggregate ID Convention

| ID             | Inferred type                                |
| -------------- | -------------------------------------------- |
| `order_123`    | `order`                                      |
| `cart-abc`     | `cart`                                       |
| `CUSTOMER_456` | `customer`                                   |
| `ORD-2024-001` | `ord` (or use `.withAggregateType("order")`) |

Rules: first separator (`_` or `-`) wins; type portion normalised to lowercase; must start with a letter.

### Disposal

```typescript
hapnd.dispose();
// Aborts in-flight requests and resets circuit breaker state
```

## Error Handling

All errors extend `HapndError`. Use `instanceof` to handle specific cases:

```typescript
import {
  HapndConcurrencyError,
  HapndApiError,
  HapndCircuitOpenError,
  HapndNetworkError,
  HapndValidationError,
} from "@hapnd/client";

try {
  await hapnd
    .aggregate("order_123")
    .expectVersion(5)
    .append("ItemAdded", { item: "Widget", price: 25.0 });
} catch (err) {
  if (err instanceof HapndConcurrencyError) {
    // Version mismatch — reload and retry
    console.log(`Expected ${err.expectedVersion}, got ${err.actualVersion}`);
  } else if (err instanceof HapndCircuitOpenError) {
    // Circuit is open — back off
    console.log(`Retry after ${err.remainingDuration}ms`);
  } else if (err instanceof HapndNetworkError) {
    // DNS failure, timeout, connection refused, etc.
    console.log(`Network error: ${err.message}`);
  } else if (err instanceof HapndApiError) {
    // Server returned a non-2xx response
    console.log(
      `API error ${err.statusCode} (${err.errorCode}): ${err.message}`,
    );
  } else if (err instanceof HapndValidationError) {
    // Bad arguments before the request was made
    console.log(`Validation: ${err.message}`);
  }
}
```

### Error Class Reference

| Class                             | `errorCode`               | HTTP   | Description                       |
| --------------------------------- | ------------------------- | ------ | --------------------------------- |
| `HapndValidationError`            | `VALIDATION_ERROR`        | —      | Client-side validation failure    |
| `HapndApiError`                   | from server               | varies | Server returned an error response |
| `HapndConcurrencyError`           | `CONCURRENCY_CONFLICT`    | 409    | Version mismatch                  |
| `HapndAggregateTypeMismatchError` | `AGGREGATE_TYPE_MISMATCH` | 400    | Wrong type for existing aggregate |
| `HapndNetworkError`               | `NETWORK_ERROR`           | —      | Network failure or timeout        |
| `HapndCircuitOpenError`           | `CIRCUIT_OPEN`            | —      | Circuit breaker is open           |

## Resilience

### Retry

Built-in exponential backoff with full jitter. Retries on:

- Network failures
- HTTP 5xx (server errors)
- HTTP 429 (rate limited, honours `Retry-After` header)
- HTTP 408 (request timeout)

Does **not** retry on 4xx (except 429/408) or 409 (concurrency conflicts need business logic).

### Circuit Breaker

Sliding-window circuit breaker prevents cascading failures:

- **Closed** → requests pass through normally
- **Open** → all requests immediately rejected with `HapndCircuitOpenError`
- **Half-Open** → one probe request allowed; success closes the circuit, failure re-opens it

Configure via `resilience` options or disable with `resilience: false`.

## Edge Runtime Support

`@hapnd/client` uses only the Web Fetch API and standard Web APIs — no Node.js built-ins. It works in:

- Node.js 18+
- Cloudflare Workers
- Vercel Edge Functions
- Deno
- Bun

## Zero Dependencies

The SDK has no runtime dependencies. Everything — retry, circuit breaker, error handling, HTTP — is implemented from scratch using standard Web APIs.

## Links - Coming Soon.. a nice TODO!

- [Hapnd documentation](https://hapnd.dev/docs)
- [API reference](https://hapnd.dev/docs/api)
- [GitHub](https://github.com/lightest-night/hapnd)

## License

Apache 2.0 — see [LICENSE](./LICENSE).