Realtime Feature State

The @honua/sdk-js/realtime entrypoint defines the SDK-side contract for live operational layers. Apps subscribe once to a RealtimeFeatureTransport and consume normalized RealtimeFeatureEvent values through RealtimeFeatureState; they do not branch on SSE, WebSocket, or delta polling protocols in map, table, or detail code.

The full versioned contract — including plan identity, explicit authority state, and cross-scope resume rejection — is ratified in the snapshot/delta/cursor/resume/plan-identity contract decision and exercised by test/fixtures/realtime/snapshot-delta-cursor-resume-contract.v1.json.

Subscription Identity

A RealtimeSubscriptionRequest identifies the logical live stream with sourceId, optional layerId, where, fields, spatialFilter, and optional caller-owned requestId. Use the same identity when reconnecting the same UI state. Non-identity values such as metadata, signal, and tracing fields must not change replay semantics.

Use realtimeSubscriptionKey(request) when a runtime needs a stable client key for one source/layer/filter subscription:

const request = {
  requestId: "incident-ops",
  sourceId: "incidents",
  layerId: "active-incidents",
  where: "status <> 'resolved'",
  fields: ["id", "status", "severity"],
  mode: "snapshot-then-delta",
};

const key = realtimeSubscriptionKey(request);

Cursors And Checkpoints

Events may carry eventId, sequence, cursor, watermark, timestamp, deltaToken, or a normalized checkpoint. The reducer copies those values into state and exposes realtimeResumeCheckpoint(state) so callers can resume where the backend supports it:

const store = createRealtimeFeatureStore();

store.connect(transport, {
  sourceId: "incidents",
  mode: "snapshot-then-delta",
  resumeFrom: savedCheckpoint,
});

const checkpoint = realtimeResumeCheckpoint(store.state);

Cursor, watermark, timestamp, sequence, and delta-token support is transport-dependent. A transport declares its contract with capabilities.resumeModes, for example ["cursor", "timestamp", "delta-token"].

Event Model

The reducer treats sequence as stream-wide ordering. Duplicate eventId values and events with a sequence less than or equal to lastSequence are ignored and counted in ignoredEventCount. This keeps map/table/detail state stable when a reconnect replays recent events.

Lifecycle Semantics

Connection state is visible on state.status:

Use store.checkStale({ staleAfterMs, now }) from the app's timer policy. Recoverable errors keep the store usable and move it to reconnecting; terminal error events set terminalError: true and leave the last good feature state available for read-only rendering.

Tombstones And Replay

Deletes remove the live record and write a tombstone keyed by sourceId:id. Tombstones allow detail panels, table selections, popups, and linked exploration state to drop archived features even when the delete arrived during replay. A replacement snapshot clears tombstones; an append snapshot or delta only clears tombstones for features that are upserted again.

Map, Table, And Detail Helpers

Use the projection helpers to keep app code protocol-neutral:

const mapFeatures = selectRealtimeFeatures(store.state, { sourceId: "incidents" });
const tableRows = selectRealtimeFeatureRecords(store.state, {
  sourceId: "incidents",
  sort: (left, right) => left.receivedAt - right.receivedAt,
});
const detail = selectRealtimeDetail(store.state, selectedId, { sourceId: "incidents" });
const tombstones = selectRealtimeFeatureTombstones(store.state, { sourceId: "incidents" });

Use reconcileRealtimeSelection(view, state) with an ExplorationViewController to remove deleted or missing features from shared map/table/detail selection.

honua-server Preset

honua-server exposes live feature changes at /api/v1/streaming/features. That endpoint expects serviceId= / layers= query params (not the default sourceId= / layerId=) and emits its own feature-change envelopes. The honuaServerRealtimePreset packages the matching encodeRequest and decodeEvent hooks so consumers do not re-write the adapter:

import {
  createRealtimeServerSentEventsTransport,
  honuaServerRealtimePreset,
} from "@honua/sdk-js/realtime";

const transport = createRealtimeServerSentEventsTransport({
  url: "https://honua.example/api/v1/streaming/features",
  ...honuaServerRealtimePreset(),
});

Or use the convenience factory, which appends the default streaming path to a server origin:

import { createHonuaServerRealtimeSubscription } from "@honua/sdk-js/realtime";

const transport = createHonuaServerRealtimeSubscription({
  baseUrl: "https://honua.example",
});

store.connect(transport, { sourceId: "incidents", layerId: "0", mode: "snapshot-then-delta" });

The preset decodes honua-server feature-change envelopes ({ op: "insert" | "update" | "delete", featureId, feature, ... }, batched under changes or inlined) into SDK delta events, carrying serviceId through as the event sourceId. Status, heartbeat, and error envelopes that already use the SDK vocabulary pass through unchanged. The default sourceId= / layerId= encoder remains the transport default; the preset is opt-in.

Bounded, Resumable Transports (#557)

sse.ts and websocket.ts are raw wire adapters: they open exactly one connection per subscribe() call, decode the default JSON event vocabulary (or a custom encodeRequest/decodeEvent pair, as with the honua-server preset), and never reconnect on their own. createResumableRealtimeTransport wraps either one (or a custom RealtimeFeatureTransport) with the resumable delivery gate, reconnect ownership, a heartbeat timeout, and redacted telemetry — closing the "automatic SSE/WebSocket reconnection" gap called out in the resume doc.

import {
  createResumableServerSentEventsTransport,
  createRealtimeFeatureStore,
} from "@honua/sdk-js/realtime";

const transport = createResumableServerSentEventsTransport(
  { url: "https://honua.example/api/v1/streaming/features" },
  {
    context: {
      kind: "honua.realtime-resume-context",
      version: 1,
      sourceId: "incidents",
      queryFingerprint: acceptedPlan.fingerprint,
      sourceVersion: "incident-snapshot-v7",
      schemaVersion: "incident-schema-v3",
      authorizationScopeFingerprint: aclFingerprint,
    },
    checkpointStore: durableCheckpointStore,
    heartbeatTimeoutMs: 30_000,
    reconnect: { maxAttempts: 8, baseDelayMs: 250, maxDelayMs: 30_000 },
    onTelemetry: (telemetry) => reportRealtimeTelemetry(telemetry),
  },
);

const store = createRealtimeFeatureStore();
store.connect(transport, { sourceId: "incidents", mode: "snapshot-then-delta" });

createResumableWebSocketTransport is the same shape over websocket.ts. The wrapped transport still satisfies RealtimeFeatureTransport, so it composes with createRealtimeFeatureStore.connect(...) exactly like a raw adapter — the store never has to know reconnect is happening underneath it.

Behavior:

createOdataDeltaTransport (src/realtime/odata-delta.ts) is the delta polling adapter the "Adapter Expectations" section below anticipated. OData delta links are a pull change feed, not a socket, so this adapter is deliberately honest about that instead of dressing polling up as a live stream:

import { createOdataDeltaTransport, createRealtimeFeatureStore } from "@honua/sdk-js/realtime";

interface Incident {
  readonly Id: number;
  readonly Status: string;
}

const transport = createOdataDeltaTransport<Incident>({
  url: "https://honua.example/odata/Incidents",
  pollIntervalMs: 15_000,
  entityId: (entity) => entity.Id as number,
  initialQuery: { filter: "Status ne 'closed'" },
  onPoll: (telemetry) => reportPullFreshness(telemetry), // { polledAt, nextPollAt, intervalMs, changed, … }
});

const store = createRealtimeFeatureStore<Incident>();
store.connect(transport, { sourceId: "incidents", mode: "snapshot-then-delta" });

Behavior:

Adapter Expectations

SSE adapters should emit snapshot or delta after open, heartbeat for server keepalives, status: "reconnecting" before retry, and error only when the stream cannot recover. WebSocket adapters should use the same event vocabulary for server messages and close codes. Delta polling adapters should emit delta batches, preserve server ordering, and pass cursor/timestamp/delta-token checkpoints through checkpoint — see createOdataDeltaTransport above for the concrete OData v4 implementation.

Metadata and schemas can use platform metadata caching. Live feature state should be driven by checkpoint semantics rather than a long-lived feature-result cache.