@honua/sdk-js reference guide

This long-form guide collects the runnable demos, server compatibility contract, subpath entrypoints, protocol-specific cookbooks, MapLibre runtime, request/auth bridge, migration CLI, and other reference material that used to live in the project README. Skim the README for the 60-second tour and the docs/ directory for topic-specific docs.

Contents

Getting set up

Core surfaces

Protocol cookbooks

Migration tooling

Demo apps in depth

The committed quickstart app uses the same queryFeatures() request shape shown in the README quickstart and fails fast when compatibility is unsupported, the query returns no features, or none of the returned records survive conversion into the rendered point, line, or polygon geometry buckets.

Each example README documents its own env surface, network contract, browser telemetry hooks, run lanes, accepted data contracts, live-query narrowing rules, preprocessing rules, and browser diagnostics. The shared seeded Honua Cloud demo contract lives in docs/honua-cloud-demo-services.md, with the machine-readable manifest at examples/cloud-demo-services.json and env template at examples/cloud-demo.env.example. Use npm run test:cloud-demo:config to validate the config/docs shape without live credentials, and npm run test:cloud-demo:staging for the credential-gated cloud smoke scaffold. For the Cesium spike specifically, window.__cesiumRoutePlaybackDone is the completion signal on both success and failure, window.__cesiumRoutePlaybackError is failure-only, and window.__cesiumRoutePlaybackResult is populated only on success. Its queryRequest summary echoes fixtures/source-manifest.json#query in fixture mode and the bounded live queryFeatures() request in live mode; both describe the same outFields=["*"], outSr=4326, returnGeometry=true, and extraParams={ outSr: 4326, returnZ: true } query shape, while routeId and routeIdField remain post-query selectors instead of extra server filters, routeIdField is authoritative when configured, matching normalizes numeric route ids to strings, the success summary leaves routeId as null when the selected feature has no route-id attribute, and fixture mode keeps compatibilitySupported plus requestDurationMs as null.

The kepler example README documents the fixture metadata manifest, required dataset IDs and fields, the default incident status and replay-window filters, browser readiness/error plus replay-harness hooks, refresh stdout JSON, and the runtime style override env vars used by the demo.

Deterministic local review flows from this repo:

# Node.js 20.19.0+ at the repo root
npm install

# Run either mock server in its own terminal/session.
npm run demo:quickstart:mock
# or
npm run demo:25d:mock
# or
npm run demo:geocoding:mock

The quickstart command prints quickstartMockUrl=http://127.0.0.1:PORT, the 2.5D command prints story25dMockUrl=http://127.0.0.1:PORT, and the geocoding command prints geocodingMockUrl=http://127.0.0.1:PORT.

Live Honua flow for the 2.5D demo:

cp examples/storytelling-25d-map/.env.example examples/storytelling-25d-map/.env
npm run demo:25d

Use the linked example READMEs for the 2.5D collection contract, the Cesium live-query URL parameters, custom routeIdField matching and multi-route selection rules, terrain fallback behavior, and smoke-test globals.

Server Compatibility Baseline

Use the server compatibility contract before enabling admin/control-plane flows:

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

const client = new HonuaClient({ baseUrl: "https://your-honua-server.com" });

const status = await client.checkCompatibility();
if (!status.supported) {
  throw new Error(
    `Server ${status.compatibility?.serverVersion ?? "unknown"} is unsupported. ` +
      `Minimum: ${HonuaClient.minimumSupportedServerVersion}. ` +
      `Reasons: ${status.reasons.join("; ")}`,
  );
}

if (await client.supportsFeature("manifestApply")) {
  console.log("Manifest apply workflows are available on this server.");
}

const compatibilityContract = await client.getCompatibility();
console.log(compatibilityContract.metadataSchemas);

checkCompatibility() reports support status, supportsFeature() gates coarse capabilities from data.compatibility.features, and getCompatibility() returns the parsed data.compatibility object from GET /api/v1/admin/capabilities.

The capabilities endpoint still needs to return a JSON object with success plus data.compatibility. The parsed compatibility object must include serverVersion, releaseChannel, controlPlaneApi.major/basePath/deprecated, metadataSchemas[] entries with version and deprecated, and the boolean features map. Top-level extras such as metadataApiVersions and resourceKinds may be present on the endpoint response, but they are not returned by getCompatibility().

This SDK baseline currently expects:


Initial JavaScript SDK scaffold for the JS-first migration phase (#324).

This package currently provides:

Entrypoints

Prefer subpath entrypoints to keep Honua-first and migration layers separate:

The root entrypoint (@honua/sdk-js) remains available as an aggregate export for compatibility. Surfaces documented as subpath-only, including @honua/sdk-js/generated-app, should be imported from their named subpath.

Shared Client Contract And Exploration

HonuaFeatureLayer, HonuaMapService, and HonuaOgcFeatureCollection continue to be the runtime classes. For cross-protocol code — exploration views, visual builders, and server packaging — the SDK also exposes a protocol-neutral contract and exploration state module that wrap (not replace) those classes.

import { createDataset } from "@honua/sdk-js/contract";
import { createExplorationContext } from "@honua/sdk-js/exploration";
import { HonuaClient } from "@honua/sdk-js/honua";

const client = new HonuaClient({ baseUrl: "https://your-honua-server.example" });

const dataset = createDataset({
  id: "parcels",
  client,
  sources: [
    {
      id: "parcels-fs",
      protocol: "geoservices-feature-service",
      locator: { url: "https://your-honua-server.example", serviceId: "parcels", layerId: 0 },
      capabilities: new Set(["query", "queryExtent", "queryObjectIds"]),
    },
  ],
});

const parcels = dataset.source("parcels-fs")!;
const result = await parcels.query({ pagination: { limit: 100 } });
const { extent } = await parcels.queryExtent();

const ctx = createExplorationContext({
  datasetId: dataset.id,
  sourceIds: dataset.sourceIds(),
  preset: "mapDriven",
});
ctx.bind({ id: "map", role: "map" });
if (extent) ctx.dispatch({ kind: "set-extent", extent, viewId: "map" });
console.log(`Loaded ${result.features.length} features`);

Capability misses throw HonuaCapabilityNotSupportedError (under strict policy). Exploration misuses throw HonuaExplorationContextError. Both are in the HonuaError union and pass isHonuaError(e).

Standalone Data-To-Map Bridge

mountSource(map, source, options) from @honua/sdk-js/map (@experimental) mounts any contract Source as a live, styled MapLibre layer set with no MapPackage and no Honua server — the standalone slice of the kernel ADR's connection.mount(target) contract. The bridge selects bounded GeoJSON materialization or the dynamic query-tile path from declared capabilities plus a result-size heuristic, applies geometry-appropriate default styling, wires optional click popups and hover feature-state, and returns one disposable handle whose setFilter()/refresh() diff-update in place.

import * as maplibregl from "maplibre-gl";
import { connect } from "@honua/sdk-js";
import { mountSource } from "@honua/sdk-js/map";

const data = await connect({ endpoint: FEATURE_LAYER_URL, protocol: "auto", authorizationScopeFingerprint: "public" });
const mounted = await mountSource(map, data.source(), {
  popup: { factory: () => new maplibregl.Popup() },
  hover: true,
  fitBounds: true,
});
console.log(mounted.diagnostics.strategy, mounted.diagnostics.reasons);
mounted.dispose();

MapLibre GL JS Runtime For MapPackage

@honua/sdk-js/runtime binds a server-produced MapPackage (from honua-io/honua-server#731) to a caller-provided maplibre-gl.Map. It composes the style from mapSpec + styleRefs[] + theme tokens, projects sourceBindings[] through the @honua/sdk-js/contract adapters, and exposes an operational API that honua-io/honua-sdk-js#22 and #29 build on. The runtime never instantiates the map (maplibre-gl stays a peer dependency) and never issues edit writes.

import * as maplibregl from "maplibre-gl";
import { HonuaClient } from "@honua/sdk-js/honua";
import { loadMapPackage } from "@honua/sdk-js/runtime";

const map = new maplibregl.Map({ container: "map", style: { version: 8, sources: {}, layers: [] } });
const runtime = await loadMapPackage(pkg, map, {
  client: new HonuaClient({ baseUrl: "https://your-honua-server.example" }),
  popupFactory: () => new maplibregl.Popup(),
});

runtime.setLayerVisibility("parcels-fill", true);
runtime.bindPopup("parcels-fill");
await runtime.updatePackage(nextPkg);  // diffs by stable ids, falls back to setStyle on structural change
runtime.dispose();

Binding failures throw HonuaMapPackageError with a stage of "load" | "update" | "style-compose" | "source-bind" | "view" | "popup" | "dispose" under the opt-in sourceErrorPolicy: "fail-fast". Under the default "tolerant" policy a single per-source binding failure does not abort the load — the failed source is dropped from the composed style along with any layer that referenced it, the runtime emits a source-error event for the failed source, and a source-bind telemetry error span fires. Remaining sources keep rendering. Query-time adapter errors (HonuaCapabilityNotSupportedError, HonuaHttpError, adapter-specific classes) are not wrapped — they surface on the per-Source promises from runtime.dataset and through the shared HonuaClient interceptor chain; consumers fanning a query across the dataset can broadcast a per-source rejection through runtime.reportSourceError(sourceId, error) to fold it into the same source-error channel.

For mixed-protocol compositions (parcels FeatureServer + WMS basemap + STAC overlay + OData operational layer in one map), use intersectCapabilities from @honua/sdk-js/contract to compute the weakest capability set across participating sources before fanning a call out, and rely on Result.degraded[] entries (now carrying optional sourceId) for per-source attribution. Full composition guide: docs/composition.md.

Generated App Preview Runtime

@honua/sdk-js/generated-app is the browser-safe proof slice for generated operations dashboards. It projects canonical BuildSpec, AppPackage.manifest_artifact, and MapPackage inputs into a versioned honua_generated_app_manifest.v1 manifest with the operations-dashboard.v1 profile, then binds the generated map/table or list/count/chart/filter widgets through @honua/sdk-js/runtime and the shared ExplorationContext.

import { previewGeneratedApp } from "@honua/sdk-js/generated-app";

const preview = await previewGeneratedApp(
  { appPackage, mapPackage },
  {
    mapFactory: () => ({ map }),
    mapLoadOptions: { client },
  },
);

if (preview.status === "ready") {
  renderDashboard(preview.model);
} else {
  renderPreviewErrors(preview.errors);
}

The preview response is a discriminated union: ready responses include the resolved manifest, runtime handle, render model, and errors: []; error responses include serializable HonuaGeneratedAppDiagnostic objects and never throw from previewGeneratedApp. Call loadGeneratedAppRuntime directly when the host wants exceptions. Full contract reference: docs/generated-app-runtime.md. For map-backed manifests, the host must provide a MapPackage, mapFactory, and mapLoadOptions; when manifest.mapPackageId is present, the supplied MapPackage.mapPackageId must match before map construction. Map filter bindings use the widget layerId with manifest.bindings.layerId as fallback, and failed initial feature loads dispose partially loaded map resources before returning an error result.

Install

npm install @honua/sdk-js

Local development

Repo-root installs for local development and the checked-in browser demos currently require Node.js 20.19.0 or newer because the current demo/dev toolchain dependencies set that patch-level floor. The published @honua/sdk-js package itself remains on the documented Node 20+ runtime contract.

npm install

Storytelling demo loops:

npm run demo:quickstart:mock
# or, with examples/maplibre-quickstart/.env configured:
npm run demo:quickstart

# advanced storytelling demo:
npm run demo:25d:mock
# or, with examples/storytelling-25d-map/.env configured:
npm run demo:25d

Advanced analytics demo loop:

npm run demo:kepler:install
npm run demo:kepler:dev

Open http://127.0.0.1:4175 to view the fixture-first operations replay story. The committed fixture lives under examples/kepler-analytics/public/data, and maintainers can refresh it from a live Honua environment with:

npm run demo:kepler:refresh-fixture

The repo-root refresh wrapper builds the SDK before delegating to the example-local script. Set HONUA_DEMO_BASE_URL and any optional auth or service override env vars described in the example README before running it.

Imagery and Terrain demo loop:

npm run demo:imagery-cog:mock
# or, with examples/imagery-cog-quickstart/.env configured:
npm run demo:imagery-cog
# opt-in pinned public STAC/COG semantic evidence (no browser credentials):
HONUA_COG_LIVE_ENABLED=true npm run evidence:cog:live

The imagery-terrain, maui-3d, and wms-overlay site projections now share this implementation. Its fixture lane mounts a bounded direct COG window beside published imagery and Terrain-RGB; the scheduled lane validates a real pinned COG through semantic inspection and bounded decoding. Gallery status remains a planned golden candidate until the complete packed, browser, accessibility, console, responsive, screenshot, performance, and live receipt set is reviewed.

Node backend quickstart loop:

npm run demo:node-backend:mock
# in another terminal:
npm run demo:node-backend

Verify

npm run typecheck
npm run build
npm run demo:quickstart:typecheck
npm run demo:25d:typecheck
npm run demo:node-backend:typecheck
npm run demo:unified-ops:typecheck
npm run demo:quickstart:test
npx vitest run test/node-backend-quickstart.test.ts
npx vitest run test/unified-ops-workspace.test.ts
npx vitest run test/cesium-route-playback.test.ts
npx vitest run test/storytelling-25d-config.test.ts test/storytelling-25d-data.test.ts
npm test
npm run test:playwright:quickstart
npm run test:playwright:unified-ops
npx playwright test test/playwright/cesium-route-playback.spec.mjs
npm run test:playwright:25d
npm run test:playwright
npm run demo:kepler:smoke
HONUA_FIRST_MAP_LIVE_ENABLED=true npm run evidence:first-map:live # scheduled anonymous public evidence
HONUA_LIVE_CONFORMANCE_ENABLED=true npm run evidence:live-conformance # scheduled public reference services — see docs/live-conformance.md
npm run test:integration # connect-only; requires HONUA_INTEGRATION_BASE_URL — see docs/integration-tests.md
npm run scan:arcgis -- ../../path/to/arcgis-app
npm run migrate:arcgis -- ../../path/to/arcgis-app --write --report migration-report.json
npm run report:migration:real-samples
npm run gate:migration:real-samples
npm run gate:migration:demo-target
npm run matrix:runtime
npm run build:split-packages

npm run test:playwright and npm run demo:kepler:smoke now bootstrap the isolated examples/kepler-analytics dependencies automatically when that package has not been installed yet.

Split Package Artifacts

Generate publish-ready split packages under dist/packages/:

@honua/honua-migrate is built and published from the honua-migrate repository. See the transition policy.

npm run build:split-packages
npm run verify:split-packages

Create local tarballs for all split packages:

npm run pack:split-packages

CI publish workflow:

Release tags are strictly self-verifiable (honua-io/honua-sdk-js#768): checking one out on a pristine clone and running npm run samples:generate and npm run samples:verify must succeed with no HONUA_DERIVED_ARTIFACTS_RELAX relaxation. Release Please's version-bump commit never satisfies that on its own -- it edits package.json and the version-stamped fixtures declared in release-please-config.json, all inside the evidence-neutral source digest (scripts/sample-gate-receipt.mjs), which re-stales every sample gate receipt, and the derived sample artifacts it carries were generated before the bump, so they still self-report the previous version. The release therefore cuts the tag on a commit that is already sealed rather than sealing it afterwards (honua-io/honua-sdk-js#1350) -- a tag cannot be moved once its GitHub Release is published, because immutable releases protect it. In order:

See the decision record for why the tag can never be repaired after the fact.

Re-running trusted CI for an existing Release Please PR

A release PR that reports mergeable_state=blocked is usually missing its required checks rather than a review. See the review-policy decision record for how bot-authored release PRs are meant to satisfy branch protection, and why the Copilot review rule is currently disabled rather than bypassed.

The release-please-ci job dispatches canonical CI when Release Please creates or refreshes its PR, and release-please-disposition publishes the required JS SDK and MCP SDK checks only after that exact run succeeds. If the PR already existed before that path ran (or a transient run must be replaced), re-run the trusted Release Please workflow on trunk; do not dispatch ci.yml directly, because that run alone cannot publish the required checks:

release_pr=1234
gh pr view "$release_pr" --json headRefName \
  --jq 'select(.headRefName == "release-please--branches--trunk") | .headRefName' \
  | grep -qx release-please--branches--trunk

dispatched_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
gh workflow run release-please.yml --ref trunk
release_run=""
for _ in {1..12}; do
  release_run=$(gh run list --workflow release-please.yml --event workflow_dispatch \
    --branch trunk --limit 10 --json databaseId,createdAt \
    --jq "map(select(.createdAt >= \"$dispatched_at\"))[0].databaseId // empty")
  test -z "$release_run" || break
  sleep 5
done
test -n "$release_run"
gh run watch "$release_run" --exit-status

This path runs Release Please first, so it may legitimately refresh the release branch. The trusted refresh job then discovers the canonical PR and head, dispatches ci.yml with both exact identities, waits for that named run, and publishes the two checks from the disposition job. Re-read headRefOid and the check rollup before merging. Never substitute a direct ci.yml dispatch, an alternate release branch, or locally rebuilt evidence: none can complete the trusted check-publication path.

npm run release:seal:check is the gate that proves a commit is sealed (every gate receipt bound to this tree, derived artifacts stamping this release version). It runs in both Publish JS SDK Packages and First Map Release Smoke, and it fails closed if a relaxation signal is set. Use --tag js-sdk-v<version> to also require that the tag resolves to the verified commit, and npm run release:seal:test for its unit tests.

Request/Auth Bridge

import { HonuaClient } from "@honua/sdk-js";
import { createArcGisTokenInterceptor, createEsriRequestInterceptors } from "@honua/sdk-js/esri-compat";

const client = new HonuaClient({
  baseUrl: "https://example.test",
  interceptors: [
    ...createEsriRequestInterceptors([
      {
        urls: "/rest/services/default",
        before: (params) => {
          params.requestOptions.headers = {
            ...(params.requestOptions.headers ?? {}),
            "X-Migrated-By": "honua",
          };
        },
      },
    ]),
    createArcGisTokenInterceptor({
      applyTo: "/rest/services/default",
      mode: "query",
      getToken: async () => "arcgis-token-value",
    }),
  ],
});

For first-party Honua auth, prefer an auth provider over storing long-lived secrets in SDK configuration. The provider owns secure storage, refresh, and revocation; the SDK keeps only an in-memory cache and refreshes before expiresAt enters the configured skew window.

const client = new HonuaClient({
  baseUrl: "https://example.test",
  authRefreshSkewMs: 60_000,
  auth: {
    async getCredentials({ reason }) {
      const session = await authStore.getFreshSession({ force: reason !== "initial" });
      return {
        bearerToken: session.accessToken,
        expiresAt: session.expiresAt,
      };
    },
    async revokeCredentials(credentials) {
      if (credentials.bearerToken) {
        await authStore.revoke(credentials.bearerToken);
      }
    },
  },
});

await client.refreshAuthCredentials(); // force a key/token rotation probe
await client.revokeAuthCredentials(); // revoke cached credentials and clear SDK memory

Do not persist bearer tokens, API keys, or refresh tokens inside the SDK instance. Store them in the platform credential store or your server-side session layer, return short-lived credentials from auth.getCredentials, and use clearAuthCredentials() on logout or account switch. If a request fails with 401/403, call refreshAuthCredentials("unauthorized") after your app has handled any sign-in prompt, then retry the failed operation explicitly. Network, timeout, and retry behavior still flows through the same timeoutMs, retry, and interceptor pipeline.

OGC API Features (Honua-first)

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

const client = new HonuaClient({ baseUrl: "https://example.test" });
const ogc = client.ogcFeatures();

const collections = await ogc.collections();
const parcels = ogc.collection("0");
const items = await parcels.items({ limit: 100, filter: "status = 'active'" });
const allItems = await parcels.itemsAll({ pageSize: 500, maxPages: 20 });
const feature = await parcels.item({ featureId: "123" });

OGC API Tiles, Maps, Processes, STAC

The first-party OGC client covers the OGC conformance areas that operator apps need. Everything is exposed through canonical Honua types — OGC conformance class identifiers stay internal.

import { HonuaClient } from "@honua/sdk-js/honua";
import type { IJobRun } from "@honua/sdk-js/honua";

const client = new HonuaClient({ baseUrl: "https://example.test" });

// OGC API Tiles — vector or raster over the canonical collection-tile route
const tile = await client.ogcTiles()
  .tileset("parcels", "WebMercatorQuad")
  .tile({ tileMatrix: 5, tileRow: 9, tileCol: 12 });

// OGC API Maps — server-rendered map images
const map = await client.ogcMaps().map({
  width: 1024, height: 1024,
  bbox: [-122, 37, -120, 38],
  collections: ["parcels", "roads"],
});

// OGC API Processes — async job execution mapped onto canonical IJobRun
const job: IJobRun = await client.ogcProcesses().execute({
  processId: "buffer",
  inputs: { feature: someGeoJson, distance: 500 },
  mode: "async",
});
const { outputs } = await job.results();

// STAC API — cross-collection search, also available through Source.query()
const search = await client.stac().search({
  bbox: [-122, 37, -120, 38],
  collections: ["sentinel-2"],
  filter: "cloud_cover < 10",
  filterLang: "cql2-text",
});

STAC in the browser bundle

HonuaStacSearch is part of the browser-safe /honua surface — import it (or call client.stac()) instead of hand-writing fetch against STAC endpoints. It is exported from the @honua/sdk-js/honua subpath, so it bundles with esbuild/Vite/Rollup like the rest of the client:

// Browser app (esbuild / Vite / Rollup). No Node-only imports.
import { HonuaClient, HonuaStacSearch } from "@honua/sdk-js/honua";

const client = new HonuaClient({ baseUrl: "https://example.test" });

// Either construct it directly…
const stac = new HonuaStacSearch({ client });
// …or get the same instance from the client:
const sameStac = client.stac();

const landing = await stac.landing();
const items = await stac.search({
  bbox: [-122, 37, -120, 38],
  collections: ["sentinel-2"],
  datetime: "2026-01-01/2026-06-01",
});

// `searchAll` paginates for you (bounded by `maxPages`):
for await (const item of stac.searchStream({ collections: ["sentinel-2"] })) {
  // …
}

Static STAC discovery also exposes evidence-classified COG asset candidates. The experimental @honua/sdk-js/cog subpath accepts those candidates for bounded metadata and pixel-window range reads through a caller-injected decoder. Its opt-in MapLibre bridge selects bounded viewport/overview windows and emits a native image source without importing a raster or renderer peer; it never guesses COG support from a filename suffix or falls back to a whole-file download.

See docs/ogc-api.md for the full developer reference, docs/shared-client-contract.md for the canonical Source / IJobRun model, and docs/protocol-capability-matrix.md for capability coverage.

WFS 2.0

import { createDataset, PROTOCOL_DEFAULT_CAPABILITIES } from "@honua/sdk-js/contract";
import { HonuaClient } from "@honua/sdk-js/honua";

const client = new HonuaClient({ baseUrl: "https://server.honua.io" });
const dataset = createDataset({
  id: "parcels",
  client,
  sources: [
    {
      id: "parcels-wfs",
      protocol: "wfs",
      locator: {
        url: "https://server.honua.io/wfs",
        typeName: "parcels:lot",
        // Required for applyEdits when typeName is namespace-qualified;
        // bound on <wfs:Transaction xmlns:parcels="…"> so the server can
        // resolve the schema element. Falls back to a synthetic URN
        // when omitted.
        featureNamespace: "http://parcels.example.com/ns",
      },
      capabilities: PROTOCOL_DEFAULT_CAPABILITIES.wfs,
    },
  ],
});

const wfs = dataset.source("parcels-wfs")!;
// Deprecated WFS-native compatibility text retained for migrations.
const result = await wfs.query({ where: "STATE = 'CA' AND ACRES > 10" });
const ids = await wfs.queryObjectIds({ where: "STATUS = 'ACTIVE'" });

// Stored-query discovery + execution stays under the typed escape hatch.
const root = wfs.protocol("wfs")!.root;
const storedQueries = await root.storedQueries();

The deprecated, source-native Query.where migration field compiles to FES 2.0 (comparison, IN, BETWEEN, LIKE, IS NULL, boolean combinators); Query.spatialFilter becomes a KVP bbox= for envelope-only requests or a <fes:Filter> otherwise. Filters that exceed the GET budget switch to POST GetFeature with the <fes:Filter> body. applyEdits builds a single <wfs:Transaction> (<wfs:Insert> / <wfs:Update> / <wfs:Delete>) and surfaces per-handle <fes:ResourceId> IDs onto EditOutcome.id. GeoJSON is preferred over GML through OperationsMetadata negotiation; if the server only advertises GML the canonical surface throws HonuaCapabilityNotSupportedError and points callers at Source.protocol("wfs") for the raw payload. Full reference: docs/wfs.md.

OData v4

import { createDataset, PROTOCOL_DEFAULT_CAPABILITIES } from "@honua/sdk-js/contract";
import { HonuaClient } from "@honua/sdk-js/honua";

const client = new HonuaClient({ baseUrl: "https://server.honua.io" });
const dataset = createDataset({
  id: "parcels",
  client,
  sources: [
    {
      id: "parcels-odata",
      protocol: "odata",
      // Pass `entitySet` directly — or pass `layerId` and let the
      // adapter derive `Layers(<layerId>)/Features` for layer-scoped
      // server bindings.
      locator: { url: "https://server.honua.io/odata", entitySet: "Parcels" },
      capabilities: PROTOCOL_DEFAULT_CAPABILITIES.odata,
    },
  ],
});

const parcels = dataset.source("parcels-odata")!;
// Deprecated OData-native compatibility text retained for migrations.
const result = await parcels.query({ where: "STATE = 'CA' AND ACRES > 10" });
const ids = await parcels.queryObjectIds({ where: "STATUS = 'ACTIVE'" });

// Dialect-specific operations stay behind the typed escape hatch.
const odata = parcels.protocol("odata")!;
const meta = await odata.metadata();
// `apply` accepts a literal OData v4 `$apply` transformation string
// per OData v4 §5.1.4 — `groupby((<dim>),aggregate(<expr>))` is the
// canonical form for SQL-style group-by + sum.
const aggregated = await odata.apply(
  "groupby((STATE),aggregate(ACRES with sum as SumAcres))",
);

Lossless OData writes are deliberately opt-in on the OData source factory. The default remains the original JSON.stringify body and key formatter. Choose writeEncoding: "lossless-json" when edit values must follow their declared EDM types exactly:

import { PROTOCOL_DEFAULT_CAPABILITIES, odataSource } from "@honua/sdk-js/contract";
import { HonuaClient } from "@honua/sdk-js/honua";

const client = new HonuaClient({ baseUrl: "https://server.honua.io" });
const exactParcels = odataSource<Record<string, unknown>>(
  {
    id: "parcels-odata",
    protocol: "odata",
    locator: { url: "https://server.honua.io/odata", entitySet: "Parcels" },
    capabilities: PROTOCOL_DEFAULT_CAPABILITIES.odata,
  },
  client,
  "strict",
  { writeEncoding: "lossless-json" },
);

await exactParcels.applyEdits({
  adds: [{ attributes: { ObjectId: 9_223_372_036_854_775_807n, AssessedValue: "1234567890.125" } }],
  updates: [{ id: "9223372036854775807", attributes: { AssessedValue: "1234567890.250" } }],
});

The adapter reuses its single-flight $metadata snapshot to validate and encode Edm.Int64, Edm.Decimal, special Edm.Single/Edm.Double values, nested complex values, collections, and typed single/composite keys. Bodies that contain an exact integer or decimal use application/json;IEEE754Compatible=true; ordinary bodies keep application/json. The same preflight projection feeds independent edits and JSON $batch parts. Nesting, per-container breadth, and the aggregate value graph are hard-bounded. Invalid values—including braced or malformed GUID keys—fail locally before any edit request, and diagnostics retain only a bounded property path, never the rejected value or request body.

The lossless codec is loaded lazily on the first opted-in edit; sources that keep the default legacy encoding do not load its runtime. For a single-key update, feature.id and the corresponding body attribute may both be present only when metadata encoding proves they identify the same row. A mismatch fails the whole envelope before transport. Composite updates must carry every named key component in attributes and omit the ambiguous scalar feature.id. The unchanged generic deletes: FeatureId[] contract cannot represent named composite components, so lossless composite deletes fail locally; use the native typed OData surface until the generic edit contract gains structured feature identity.

The deprecated, source-native Query.where migration field accepts SQL-92 / OData $filter text; the adapter rewrites the documented intersection (IS NULLeq null, <>ne, =eq, plus the SQL comparison operators >= / <= / > / <ge / le / gt / lt) and rejects operators the parity matrix marks unsupported (has, in, any, all, cast, isof). Query.outFields splits onto $select for plain field names and $expand for navigation paths — ["Owner.name"] lowers to $expand=Owner($select=name) so related properties round-trip through the canonical request envelope. Query.spatialFilter translates to geo.intersects / geo.distance against the geometry column resolved from SourceDescriptor.schema.fields (typed esriFieldTypeGeometry) first, then the lazy $metadata probe. applyEdits routes adds → POST, updates → PATCH /<entitySet>(<key>) with the full canonical body (PUT is unsupported per the parity matrix), deletes → DELETE /<entitySet>(<key>). When EditEnvelope.rollbackOnFailure: true and $metadata advertises Capabilities.BatchSupported, all operations collapse into one $batch request with a shared atomicityGroup. OData is the first adapter to lazily fetch service $metadata and intersect declared capabilities against the server's Capabilities.* annotations — see docs/protocol-capability-matrix.md for the rule and docs/decisions/odata-library-selection.md for the runtime-library posture.

Mixed Esri + OGC in one app

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

const client = new HonuaClient({ baseUrl: "https://example.test" });

const parcelsLayer = client.service("transport").featureLayer(0);
const parcelsOgc = client.ogcFeatures().collection("parcels");

const [features, items] = await Promise.all([
  // Low-level GeoServices calls use native ArcGIS SQL, not contract `Query.where`.
  parcelsLayer.queryFeatures({ where: "status = 'active'", outFields: ["OBJECTID"] }),
  parcelsOgc.items({ limit: 50 }),
]);

MapServer query helpers

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

const client = new HonuaClient({
  baseUrl: "https://example.test",
  timeoutMs: 15000,
  retry: { maxRetries: 2, retryStatuses: [429, 503] },
});

const mapLayer = client.mapLayer("basemap", 4);
const allMapLayerFeatures = await mapLayer.queryFeaturesAll({ pageSize: 2000, maxPages: 25 });

const mapService = client.mapService("basemap");
const allServiceLayerFeatures = await mapService.queryLayerFeaturesAll({
  layerId: 4,
  pageSize: 2000,
  maxPages: 25,
});

const related = await mapService.queryLayerRelatedRecords({
  layerId: 4,
  relationshipId: 1,
  objectIds: [1001, 1002],
});

const mapLayerRelated = await mapLayer.queryRelatedFeatures({
  relationshipId: 1,
  objectIds: [1001],
});

Streaming Pagination

import { FeatureLayerCompat, CompatEventBus } from "@honua/sdk-js/esri-compat";

const layer = new FeatureLayerCompat({
  url: "https://example.test/rest/services/transport/FeatureServer/0",
});

// Collect all pages in one call
const allFeatures = await layer.queryFeaturesAll({ pageSize: 500, maxPages: 50 });

// Stream pages one at a time with an async generator
for await (const page of layer.queryFeaturesStream({ pageSize: 500 })) {
  console.log(`Received ${page.length} features`);
}

Event Lifecycle (.on)

import { FeatureLayerCompat, CompatEventBus } from "@honua/sdk-js/esri-compat";

const eventBus = new CompatEventBus();
const layer = new FeatureLayerCompat({
  url: "https://example.test/rest/services/transport/FeatureServer/0",
  eventBus,
});

// Listen for edit completions
const handle = layer.on("edits", (result) => {
  console.log("Edits applied:", result);
});

await layer.applyEdits({ adds: [{ attributes: { name: "New" } }] });

// Clean up when done
handle.remove();

TimeSlider Integration

import { FeatureLayerCompat, TimeSliderCompat, CompatEventBus } from "@honua/sdk-js/esri-compat";

const eventBus = new CompatEventBus();

const layer = new FeatureLayerCompat({
  url: "https://example.test/rest/services/events/FeatureServer/0",
  eventBus,
});

const slider = new TimeSliderCompat({
  eventBus,
  timeExtent: { start: new Date("2024-01-01"), end: new Date("2024-06-01") },
  stops: { interval: { value: 1, unit: "days" } },
});

// Connect slider to layer — time extent changes auto-filter queries
const connection = slider.connectLayer(layer);

// Esri compatibility APIs retain raw ArcGIS SQL during migration.
// Queries now include the time parameter automatically.
const features = await layer.queryFeatures({ where: "1=1" });

// Disconnect when done
connection.remove();

Migration CLI

Install @honua/honua-migrate and use honua-js-migrate. The SDK-local npm scripts remain temporary compatibility forwarders; see the transition policy.

# Scan only
npx honua-js-migrate scan ./src --report scan-report.json

# Widget-usage inventory + ArcGIS 6.0 readiness report (classic widgets are removed
# at 6.0, as early as Q1 2027). Detects ESM imports, AMD require([...]) arrays, and
# dynamic $arcgis.import(...) specifiers; per-widget dispositions come from the
# generated docs/widget-survival-guide.md (source: src/migration/widget-dispositions.ts).
npx honua-js-migrate widgets ./src                     # human table
npx honua-js-migrate widgets ./src --json              # machine-readable
npx honua-js-migrate widgets ./src --markdown          # shareable report
npx honua-js-migrate widgets ./src --gate 80           # exit 2 if automated share < 80%
npx honua-js-migrate widgets ./src --report widget-readiness.json

# Safe codemod (dry run)
npx honua-js-migrate codemod ./src --report migration-report.json

# Safe codemod (write changes)
npx honua-js-migrate codemod ./src --write --report migration-report.json

# Safe codemod (explicit honua target alias)
npx honua-js-migrate codemod ./src --target honua --write --report migration-report.json

# Safe codemod (write changes targeting esri-leaflet for supported subset)
npx honua-js-migrate codemod ./src --target esri-leaflet --write --report migration-report.json

# Safe codemod (write + inline TODO annotations for manual sites)
npx honua-js-migrate codemod ./src --write --annotate-todos --report migration-report.json

# Emit parity matrix JSON (for docs/CI dashboards)
npx honua-js-migrate matrix --report parity-matrix.json

# Emit runtime parity matrix JSON (for JS API capability tracking)
npx honua-js-migrate runtime-matrix --report runtime-parity-matrix.json

# Generate readiness metrics for bundled complex real-sample fixtures
npx honua-js-migrate fixtures --report reports/real-sample-metrics.json

# Inspect the fixture-only Esri sample migration corpus helpers from tests/docs
# See docs/esri-sample-corpus.md and test/fixtures/esri-sample-corpus/manifest.json

# Enforce strict readiness gates for bundled real-sample fixtures
npx honua-js-migrate fixtures --fail-on-manual --fail-on-unhandled --fail-on-blocked --max-manual-ratio 0 --max-manual-intervention-ratio 0 --report reports/real-sample-metrics.json

# Enforce strict readiness gates for the demo target fixture only
npx honua-js-migrate fixtures --target honua --fixtures esri-demo-feature-table-relates-app --fail-on-manual --fail-on-unhandled --fail-on-blocked --max-manual-ratio 0 --max-manual-intervention-ratio 0 --report reports/demo-featuretable-primary-metrics.json

# Limit fixture metrics to a subset and esri-leaflet target mode
npx honua-js-migrate fixtures --target esri-leaflet --fixtures esri-demo-feature-table-popup-interaction-app --report reports/demo-featuretable-fallback-esri-leaflet-metrics.json

# Gate in CI (non-zero exit if migration constraints fail)
npx honua-js-migrate codemod ./src --fail-on-manual --fail-on-unhandled --fail-on-blocked --max-manual-ratio 0.2 --max-manual-intervention-ratio 0.3

# Compare source vs target service fidelity for one layer
npx honua-js-migrate reconcile --source-base-url https://source.example --source-service-id parcels --target-base-url https://target.example --target-service-id parcels --layer-id 0 --sample-size 200 --report reconcile-report.json

# Content inventory scan from ArcGIS Online/Portal
npx honua-js-migrate content scan --portal https://org.maps.arcgis.com --report ./content/scan.json

# Content export (WebMaps + hosted layers)
npx honua-js-migrate content export --portal https://org.maps.arcgis.com --output-dir ./export --acknowledge-mutations --report ./content/export.json

# Content import into Honua admin endpoint
npx honua-js-migrate content import --source ./export --target https://honua.example.com --admin-api-key $HONUA_ADMIN_API_KEY --acknowledge-mutations --report ./content/import.json

# Content reconcile using export + import reports
npx honua-js-migrate content reconcile --source ./export --report ./content/reconcile.json

# Convert a WebMap JSON export into Honua style config and rewrite portal URLs
npx honua-js-migrate content-webmap --input ./export/webmap.json --output ./export/webmap.honua.json --source-url-prefix https://org.maps.arcgis.com --target-url-prefix https://honua.example.com --report ./export/webmap.report.json

Migration Admin Scanner

HonuaClient.scanMigrationSource() wraps the stable admin scan endpoint for source-system migration planning:

import { HonuaClient } from "@honua/sdk-js/honua";
import type { MigrationSourceInventoryArtifact } from "@honua/sdk-js/honua";

const client = new HonuaClient({
  baseUrl: "https://honua.example.com",
  apiKey: process.env.HONUA_ADMIN_API_KEY,
});

const inventory: MigrationSourceInventoryArtifact = await client.scanMigrationSource({
  sourceKind: "geoservices",
  sourceUrl: "https://source.example.com/arcgis/rest/services/Parcels/FeatureServer",
  timeoutSeconds: 30,
});

if (inventory.scanCompleteness.status === "failed") {
  // HTTP 200 means Honua produced an artifact; completeness is the planning gate.
}

Set exportJson: true to request POST /api/v1/admin/import/scan?export=json; the SDK still parses the JSON artifact. The SDK also exports public artifact constants and TypeScript shapes for source inventory, migration manifest, parity evidence pack, cutover readiness, and readiness attestations. Manifest, parity, and readiness artifacts are modeled for client-side review workflows only; the SDK does not assume server routes for those artifacts.

Esri Sample Corpus (#206)

The first paired sample-app/service corpus slice is fixture-only and documented in docs/esri-sample-corpus.md. The curated manifest at test/fixtures/esri-sample-corpus/manifest.json records Esri sample source URLs as metadata only, license/terms notes, skip reasons, and expected service/Portal references. PR CI uses Honua-owned snippets and must not contact live Esri services, vendor Esri sample code, or commit Esri service data.

FeatureTable Demo Lane (#327)

Primary target:

Fallback target:

Pinned attribution metadata is tracked in test/fixtures/DEMO_TARGETS.md and fixture-local ATTRIBUTION.md files.

Runbook (codemod-only CI reproducible path):

# 1) Run primary demo lane migration (honua target)
npm run demo:migration:featuretable

# 2) Validate primary lane readiness gates and report metrics
npm run gate:migration:demo-target

# 3) Validate fallback lane deterministic esri-leaflet mapping
npm run gate:migration:esri-leaflet-target

Report metrics to capture:

Quick metric extract:

node -e 'const r=require("./reports/demo-featuretable-codemod-report.json"); console.log({elapsedMs:r.elapsedMs, manualRewriteRatio:r.migration.manualRewriteMetric.ratio, manualInterventionCount:r.migration.manualInterventionMetric.numerator, manualRewriteCount:r.migration.manualRewriteMetric.numerator});'

The codemod is intentionally conservative: