# @honua/sdk-js — full documentation corpus > One TypeScript/JavaScript geospatial client for Esri GeoServices, OGC API (Features/Tiles/Maps/Processes), STAC, WMS/WMTS, WFS 2.0, and OData v4 — a single protocol-neutral Dataset → Source → Query → Result contract, a MapLibre-first map runtime, and a drop-in ArcGIS migration codemod. This file concatenates the documents indexed in `llms.txt` for single-fetch ingestion. --- # File: README.md # Honua JS SDK [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/honua-io/honua-sdk-js/badge)](https://scorecard.dev/viewer/?uri=github.com/honua-io/honua-sdk-js) [![npm](https://img.shields.io/npm/v/@honua/sdk-js?color=2b6cb0&label=%40honua%2Fsdk-js)](https://www.npmjs.com/package/@honua/sdk-js) [![types](https://img.shields.io/npm/types/@honua/sdk-js?color=3178c6)](https://www.npmjs.com/package/@honua/sdk-js) [![license](https://img.shields.io/badge/license-Apache--2.0-blue)](./LICENSE) [![node](https://img.shields.io/node/v/@honua/sdk-js?color=43853d)](./package.json) [![docs](https://img.shields.io/badge/docs-honua--io.github.io-2b6cb0)](https://honua-io.github.io/honua-sdk-js/) > One geospatial client for GeoServices, OGC APIs, WMS/WMTS/WFS, STAC, and OData — > with first-class TypeScript, a MapLibre runtime, and a drop-in ArcGIS migration path. **Release status: beta.** The 20-entrypoint stable tier is frozen and guarded by an API-surface gate; remaining pre-1.0 work is hardening, not surface change. See [`docs/decisions/scope-split-and-1.0.md`](./docs/decisions/scope-split-and-1.0.md). The complete inventory also contains 7 experimental subpaths and 18 deprecated compatibility subpaths. [`config/public-surface.json`](./config/public-surface.json) is the machine-readable source consumed by CI, API reporting, TypeDoc, and downstream documentation projection. `@honua/sdk-js` is the JavaScript / TypeScript client for the [Honua](https://github.com/honua-io) geospatial platform. It speaks the open protocols your data already uses (Esri GeoServices, OGC API Features / Tiles / Maps / Processes, STAC, WMS, WMTS, WFS 2.0, OData v4), exposes a single protocol-neutral `Dataset` → `Source` → `Query` → `Result` contract on top of them, and ships a MapLibre-first map runtime plus an Esri compatibility layer so existing ArcGIS apps can migrate file-by-file. 📚 **Hosted docs:** [honua-io.github.io/honua-sdk-js](https://honua-io.github.io/honua-sdk-js/) — quickstart, the full guide corpus, the [TypeDoc API reference](https://honua-io.github.io/honua-sdk-js/api/), and the [demo gallery](https://honua-io.github.io/honua-sdk-js/gallery.html). - **Protocol-neutral.** One `Source.query(...)` call works against GeoServices, OGC, WFS, OData and friends. Capability misses throw `HonuaCapabilityNotSupportedError` instead of returning empty results. - **TypeScript first.** `strict` + `verbatimModuleSyntax`, exported types for every public symbol, declaration maps, and JSDoc on the public client surface. - **Migrate, don't rewrite.** `FeatureLayerCompat`, `MapImageLayerCompat`, `MapViewCompat`, `SceneViewCompat`, `WebMapCompat`, and a safe codemod (`honua-migrate`) keep existing ArcGIS code running while you cut over. - **Open runtime.** `loadMapPackage(...)` + `HonuaMapRuntime` render a Honua `MapPackage` on MapLibre GL JS. Cesium, kepler.gl, and OGC web-map sources are first-class. > **What this is (and is not).** `@honua/sdk-js` is a typed geospatial *service client* and > migration toolkit — it is **not a rendering engine**. 2D rendering rides MapLibre GL JS and 3D > rides Cesium, so the honest comparisons are the service-client libraries, not the renderers: > > - vs **`@esri/arcgis-rest-js`** — that's Esri's own client for Esri services only. Honua speaks > GeoServices *plus* OGC API / WFS / WMS / WMTS / STAC / OData under one typed contract, with a > capability model that throws instead of returning silently-empty results. > - vs **`esri-leaflet`** — dormant (last release 2025) and Leaflet-bound. Honua's esri-compat + > `honua-migrate` codemod is an actively maintained migration path that targets MapLibre. > - vs **`openlayers` / `maplibre-gl` directly** — pick those when you need a renderer and are > happy hand-rolling service calls; pick Honua *on top of* MapLibre when you want the typed > client, the ArcGIS migration path, or the server-authored `MapPackage` runtime. > > The protocol clients work against **any** standards-speaking server — an existing ArcGIS > Server/Online endpoint, any OGC API implementation, a STAC catalog. A > [Honua Server](https://github.com/honua-io/honua-server) adds the server-authored `MapPackage`, > realtime, and AI surfaces, but it is the upgrade path, not the entry fee. **When to use it > standalone:** if your data already sits behind an ArcGIS Server / ArcGIS Online endpoint, an OGC > API Features server (pygeoapi, ldproxy, GeoServer OGC API), a WFS 2.0 server, a STAC API or static > catalog, or an OData v4 service, use it today as a typed client and `esri-leaflet` successor — no > server needed (the OGC API Features and STAC lanes discover the raw endpoint layout from the > landing page; WFS follows the capabilities DCP URLs; set `locator.layout` for non-facade servers — > see the [server-optional quickstart](./docs/standalone-quickstart.md) and the > [backend-agnostic capability matrix](./docs/standalone-capability-matrix.md)). Reach for a Honua > Server when you need authored map packages, realtime, collaboration, MCP/AI, or the OGC API Tiles / > Maps / Processes / Records families (still facade-bound today). ```bash npm install @honua/sdk-js ``` ### Build-less / CDN usage For static sites, prototypes, or CSP-strict pages that can't run a bundler, a prebuilt browser bundle is published under `dist/browser/`. Drop in the minified IIFE build and use the global `window.HonuaSDK`: ```html ``` Or, for native ES module imports via an ESM CDN: ```html ``` The runtime peers (`maplibre-gl`, `cesium`, `@bufbuild/*`, `@connectrpc/*`) are kept external — load them yourself when you need map rendering or gRPC transport. See [`docs/browser-bundle.md`](./docs/browser-bundle.md) for details. ## Bundle size Small and honest about size: every subpath entrypoint carries a min+gzip byte budget that CI enforces on every PR (`npm run verify:bundle-budgets`), so drift fails the build instead of shipping. Sizes are measured the way a consumer builds — esbuild `--bundle --minify`, runtime peers external. A tree-shake guard proves that importing a single symbol from the root doesn't drag the whole SDK in. | Entrypoint (gzip) | Size | | --- | ---: | | `@honua/sdk-js/geocoding` | 1.9 KiB | | `@honua/sdk-js/expr` | 2.4 KiB | | `@honua/sdk-js/webmap` | 5.9 KiB | | `@honua/sdk-js/style` | 8.3 KiB | | `@honua/sdk-js/map` | 16.2 KiB | | `@honua/sdk-js` (root) | 108.3 KiB | | `{ HonuaClient }` only (tree-shake guard) | 47.2 KiB | Full per-entrypoint table (min + gzip, generated, not hand-written): [`docs/bundle-sizes.md`](./docs/bundle-sizes.md). Refresh it with `npm run report:bundle-sizes`. ## 60-second quickstart **No Honua server required.** The first block below runs against a *public* Esri GeoServices endpoint — no API key, no account, no infrastructure. The canonical surface is protocol-neutral: build a `Dataset` over one or more `Source`s, then call `queryAll()` (or `query()` / `stream()`). ```ts doc-test=compile import { createDataset, PROTOCOL_DEFAULT_CAPABILITIES } from "@honua/sdk-js/contract"; import { HonuaClient } from "@honua/sdk-js/honua"; // A public Esri Living Atlas FeatureServer — nothing of Honua's is running. const client = new HonuaClient({ baseUrl: "https://services.arcgis.com/P3ePLMYs2RVChkJx/arcgis", }); const dataset = createDataset({ id: "states", client, sources: [ { id: "apportionment", protocol: "geoservices-feature-service", locator: { url: "https://services.arcgis.com/P3ePLMYs2RVChkJx/arcgis", serviceId: "2020_Census_State_Apportionment", layerId: 0, }, capabilities: PROTOCOL_DEFAULT_CAPABILITIES["geoservices-feature-service"], }, ], }); const states = dataset.source("apportionment")!; const result = await states.queryAll({ where: "Seats_2020 > 10", outFields: ["NAME", "Total_Pop_2020", "Seats_2020"], returnGeometry: true, pagination: { limit: 100 }, }); console.log(`Loaded ${result.features.length} states`); ``` The same code works against any GeoServices, OGC API Features, WFS, OData, or STAC endpoint. Migrating from `esri-leaflet`? The raw GeoServices shape and the `esri-compat` drop-in point at `services.arcgis.com`-style URLs unchanged: ```ts doc-test=skip reason="partial excerpt requires application host context" const { features } = await client.queryFeatures({ serviceId: "2020_Census_State_Apportionment", layerId: 0, where: "1=1", outFields: ["*"], returnGeometry: true, resultRecordCount: 25, }); ``` Run the complete standalone app locally — public endpoint in, MapLibre map out: ```bash npm install npm run demo:standalone:mock # deterministic fixture lane (what CI runs) npm run demo:standalone # live lane against the public Esri endpoint ``` See [`docs/standalone-quickstart.md`](./docs/standalone-quickstart.md) for the guided server-optional walkthrough, [`docs/standalone-capability-matrix.md`](./docs/standalone-capability-matrix.md) for the backend-agnostic vs server-enhanced breakdown, and [`examples/standalone-quickstart/`](./examples/standalone-quickstart/README.md) for the committed source. ### Add a Honua Server A [Honua Server](https://github.com/honua-io/honua-server) is the **upgrade path**, not the entry fee. It unlocks server-authored `MapPackage`s (`loadMapPackage()`), realtime subscriptions, collaboration / saved maps, and the MCP + AI surfaces. Point the same code at a local server (`docker compose up` in a honua-server checkout), and gate production reads on the compatibility check: ```ts doc-test=skip reason="partial excerpt requires application host context" const { supported, reasons } = await client.checkCompatibility(); if (!supported) throw new Error(`Unsupported Honua server: ${reasons.join("; ")}`); ``` The server-connected lane is the [`maplibre-quickstart`](./examples/maplibre-quickstart/README.md) example (`npm run demo:quickstart:mock`); see [`docs/quickstart.md`](./docs/quickstart.md) and [`docs/quickstart-troubleshooting.md`](./docs/quickstart-troubleshooting.md). ## Command-line client (`honua`) Installing the SDK also installs a first-class **`honua` CLI** — the same querying and catalog browsing without writing code. It wraps the SDK (no raw HTTP, no URL-encoding, no `f=json`), prints readable tables by default, and adds `--json` / `--format geojson` for machine output. ```bash npm i -g @honua/sdk-js # or: npx @honua/sdk-js honua export HONUA_BASE_URL=https://demo.honua.io # anonymous reads on the public demo honua services # list published services honua layers maui-parcels # list a service's layers honua query maui-parcels/1 --count honua query maui-parcels/1 --where "tmk_txt LIKE '2%'" --limit 5 honua query maui-parcels/1 --bbox -156.7,20.7,-156.3,21.0 --format geojson honua stac collections honua geocode "1 Honolulu Pl, HI" honua map export maui-parcels --bbox -156.7,20.7,-156.3,21.0 --size 800x600 -o maui.png ``` Authentication resolves from `--api-key`, `HONUA_API_KEY`, or a saved `honua login`. Run `honua --help` for the full command surface. This is the recommended replacement for `curl` in docs and demos. ## What you can build The versioned [SDK sample catalog](./docs/generated/sample-catalog.md) tracks all 27 executable examples: 10 flagship, 5 recipe, 8 advanced, and 4 reference. It is the source of truth for support, fixture/live modes, provenance, validation, and the honua.io projection. ## Mental model: `Dataset` → `Source` → `Query` → `Result` Every Honua SDK — JavaScript, Python, .NET — speaks the same canonical vocabulary. A `Dataset` groups one or more `Source`s. Each `Source` accepts a protocol-neutral `Query` and returns a protocol-neutral `Result`. Operations the canonical surface does not cover stay reachable through the typed `source.protocol(...)` escape hatch. Method casing differs by language (`queryAll()` / `query_all()` / `QueryAllAsync()`), the semantics do not. Capability misses throw `HonuaCapabilityNotSupportedError` (under the default `strict` policy) rather than silently returning empty results. See the [60-second quickstart](#60-second-quickstart) above for the runnable shape; the cross-language semantics, protocol/capability identifiers, language-binding tables, and backwards-compatibility policy live in: - [`docs/sdk-surface-alignment.md`](./docs/sdk-surface-alignment.md) — cross-language naming + semver - [`docs/shared-client-contract.md`](./docs/shared-client-contract.md) — contract design - [`docs/protocol-capability-matrix.md`](./docs/protocol-capability-matrix.md) — what each protocol supports ## Documentation - [`docs/generated/learning-paths.md`](./docs/generated/learning-paths.md) — task-oriented progression backed by runnable examples and checked SDK imports - [`docs/quickstart.md`](./docs/quickstart.md) — guided quickstart walkthrough - [`docs/guide.md`](./docs/guide.md) — long-form reference (server compatibility, subpath entrypoints, OGC / WFS / OData cookbooks, MapLibre runtime, migration CLI, request/auth bridge) - [`docs/errors.md`](./docs/errors.md) — error class reference + retry policy - [`docs/shared-client-contract.md`](./docs/shared-client-contract.md) — `Dataset` / `Source` / `Query` / `Result` design - [`docs/protocol-capability-matrix.md`](./docs/protocol-capability-matrix.md) — what each protocol supports - [`docs/sdk-surface-alignment.md`](./docs/sdk-surface-alignment.md) — cross-language naming & semver policy - [`docs/maplibre-runtime.md`](./docs/maplibre-runtime.md) — `loadMapPackage()` / `HonuaMapRuntime` - [`docs/react.md`](./docs/react.md) — React bindings (`@honua/react`): provider, hooks, and map components - [`docs/geometry.md`](./docs/geometry.md) — `@honua/sdk-js/geometry` curated turf/proj4 ops (buffer/area/measure/simplify/reproject) + the `geometryEngine` compat shim - [`docs/studio-package-contracts.md`](./docs/studio-package-contracts.md) — Studio package-family projections, validation envelope, capability manifest (`@honua/app-platform/studio`) - [`docs/features/README.md`](./docs/features/README.md) — capability snapshot - [`docs/docs-samples-ownership.md`](./docs/docs-samples-ownership.md) — SDK/site ownership boundary for versioned docs and executable samples - [`docs/documentation-snippets.md`](./docs/documentation-snippets.md) — supported code-fence validation and explicit pseudocode directives - [`INSTALL.md`](./INSTALL.md) — install + subpath entrypoint table Run `npm run docs:learning:verify` from a fresh checkout to build the SDK and validate learning-path metadata, internal links, generated Markdown, and runtime imports. CI reuses its existing build with the internal `npm run docs:learning:check` command, then separately compiles every selected example through `npm run docs:learning:typecheck`. Run `npm run docs:snippets:verify` to build the public declarations and validate all supported JavaScript and TypeScript documentation fences. ## AI assistants Coding agents (Claude Code, Cursor, and compatible assistants) can discover and correctly use this SDK: - **[`llms.txt`](./llms.txt)** — a curated [llms.txt](https://llmstxt.org/) index of the docs, plus **[`llms-full.txt`](./llms-full.txt)** with the full corpus concatenated for single-fetch ingestion. Both are generated from `docs/` + `README.md` + entrypoint JSDoc by `npm run docs:llms` (freshness-checked in CI via `npm run verify:llms`). - **Agent skills** under [`skills/`](./skills/README.md) — `honua-sdk-quickstart`, `honua-arcgis-migration`, and `honua-mcp-setup` load procedural instructions into Claude Code and compatible agents. See [`skills/README.md`](./skills/README.md) for installation. - **MCP server** — [`@honua/mcp-server`](./mcp/README.md) is the **platform-free** geospatial MCP server: point `honua-mcp` at **any** public ArcGIS FeatureServer or OGC API endpoint (no Honua server required) and it exposes discovery, query, and analysis tools to assistants over the Model Context Protocol. Tools that need a Honua-only surface degrade gracefully with a structured "not available on this target" result. A Honua deployment's richer `/mcp` catalog is the upgrade path via `honua-mcp-proxy`. - **Context7** — [`context7.json`](./context7.json) registers the library so [Context7](https://context7.com) serves current docs to coding agents; the submission steps are in [`skills/README.md`](./skills/README.md). ## Stability and versioning - The SDK follows [Semantic Versioning](https://semver.org/). The public contract is the set of symbols reachable from the documented subpath entrypoints in [`INSTALL.md`](./INSTALL.md). - Symbols marked `@experimental` in JSDoc may change in any minor release. The full table of stable and experimental subpaths lives in [`INSTALL.md`](./INSTALL.md). The short version: - **Stable** (semver-protected): `@honua/sdk-js`, `@honua/sdk-js/browser`, `@honua/sdk-js/honua`, `@honua/sdk-js/auth`, `@honua/sdk-js/contract`, `@honua/sdk-js/esri-compat`, `@honua/sdk-js/migration`, `@honua/sdk-js/runtime`, `@honua/sdk-js/expr`, `@honua/sdk-js/webmap`, `@honua/sdk-js/geocoding`, `@honua/sdk-js/exploration`, `@honua/sdk-js/interactions`, `@honua/sdk-js/filter-registry`, `@honua/sdk-js/style`, `@honua/sdk-js/map`, `@honua/sdk-js/realtime`, `@honua/sdk-js/react`, `@honua/sdk-js/geometry`, `@honua/sdk-js/cli`. - **Experimental** (subpath-only — not re-exported from the root barrels): `/agent-tools`, `/agent-safety`, `/geoparquet`, `/query-planner`, `/plugin`, `/deckgl`, `/offline`. - **Application-platform surfaces** (`/app`, `/app-controller`, `/app-workspace`, `/scene-workspace`, `/collaboration`, `/control-plane`, `/replica-sync`, `/share`, `/operate`, `/generated-app`, `/studio`, `/controls`, `/web-components`, `/operator`, `/operator/*`) have **moved to the separate `@honua/app-platform` package**; the old 18 deprecated compatibility subpaths remain through `0.1.x` and are removed in `0.2.0`. The `/console` entrypoint was removed outright (no shim). ## Support and lifecycle We publish a lifecycle because a library you build on should tell you what it promises. `esri-leaflet` never did, and "will this break under me?" is the question that decides adoption. - **Today (pre-1.0, `0.x`).** The **stable tier** — the subpath entrypoints listed under "Stable subpath entrypoints" in [`INSTALL.md`](./INSTALL.md) — is where we invest compatibility effort, and it is guarded in CI by a public-API report (`npm run verify:api-report`): no symbol leaves or changes shape by accident. While we are on `0.x` a minor _may_ still change a stable symbol, but only as a reviewed, called-out change — never silently. Symbols marked `@experimental` in JSDoc may change in any minor. - **At 1.0.** The stable tier freezes under [Semantic Versioning](https://semver.org/): breaking or removing a stable symbol requires a major version; minors are additive. Major versions are coordinated across the Honua SDK family (JavaScript, Python, .NET) so one semver line describes the contract on every platform. - **Application-platform surfaces move separately.** App-shell, builder, and hosted-product entrypoints are being extracted to a separate `@honua/app-platform` package that versions at its own pre-1.0 cadence, so the client SDK can reach a frozen 1.0 without waiting on them. Their old `@honua/sdk-js/*` subpaths remain as `@deprecated` re-export shims through `0.1.x` and are removed in `0.2.0`. See [`docs/decisions/scope-split-and-1.0.md`](./docs/decisions/scope-split-and-1.0.md). - **What we don't promise.** No security-backport window or LTS branch pre-1.0; fixes land on the current line. We will publish that policy when we cut 1.0. ## More guides Long-form reference material now lives in [`docs/guide.md`](./docs/guide.md): - Demo apps in depth (each example's env contract, browser hooks, run lanes) - Server compatibility baseline + `checkCompatibility()` contract - Subpath entrypoints and the `@experimental` tier - Protocol cookbooks — OGC Features / Tiles / Maps / Processes / STAC, WMS / WMTS, WFS 2.0, OData v4 - Mixed Esri + OGC composition, streaming pagination, event lifecycle - MapLibre `MapPackage` runtime + Generated App preview runtime - Request/Auth bridge (interceptors, ArcGIS token + esri-request interop) - `honua-migrate` CLI, admin scanner, parity matrix, sample-corpus harness Protocol-specific deep dives also live alongside the guide: see [`docs/wfs.md`](./docs/wfs.md), [`docs/ogc-api.md`](./docs/ogc-api.md), [`docs/maplibre-runtime.md`](./docs/maplibre-runtime.md), [`docs/webmap-json-compatibility.md`](./docs/webmap-json-compatibility.md), [`docs/protocol-capability-matrix.md`](./docs/protocol-capability-matrix.md), and [`docs/migration-punch-list.md`](./docs/migration-punch-list.md). ## Contributing This SDK ships from a single repository. The shipping package is `@honua/sdk-js`; all subpath entrypoints in [`INSTALL.md`](./INSTALL.md) live under that name. See [`AGENTS.md`](./AGENTS.md) for contributor instructions and the Specifica issue format used for backlog items. ## License [Apache 2.0](./LICENSE) --- # File: INSTALL.md # Installing the Honua JavaScript SDK The Honua JavaScript SDK ships as a single npm package — **`@honua/sdk-js`** — with multiple subpath entrypoints. The core client, the Esri compatibility layer, the migration helpers, and the protocol-neutral contract are all reachable from this one install. ## Stable subpath entrypoints Subpaths covered by the SDK's semver contract. Symbols reachable from these entrypoints are stable across minor versions. | Subpath | What it gives you | |---------|-------------------| | `@honua/sdk-js` | Default barrel — re-exports the most common stable symbols | | `@honua/sdk-js/browser` | Prebuilt browser ESM build of the default barrel (same API as `@honua/sdk-js`) | | `@honua/sdk-js/honua` | `HonuaClient` (the raw GeoServices/OGC client) | | `@honua/sdk-js/auth` | OAuth2/PKCE, client credentials, static providers, and credential stores | | `@honua/sdk-js/contract` | Protocol-neutral `Dataset` / `Source` / `Query` / `Result` + `createDataset` | | `@honua/sdk-js/esri-compat` | Esri ArcGIS JS-API compatibility layer for migration | | `@honua/sdk-js/migration` | Programmatic migration helpers (codemod runner, scan reports) | | `@honua/sdk-js/runtime` | MapLibre `MapPackage` runtime (`loadMapPackage`, `HonuaMapRuntime`) | | `@honua/sdk-js/expr` | Honua expression builder | | `@honua/sdk-js/webmap` | WebMap JSON load/save helpers | | `@honua/sdk-js/geocoding` | Geocoding adapters | | `@honua/sdk-js/exploration` | Linked-view exploration state + presets | | `@honua/sdk-js/interactions` | Hit-test + pointer normalization + chart/map bindings | | `@honua/sdk-js/filter-registry` | Shared filter clause registry + projections | | `@honua/sdk-js/style` | Honua style spec + source parsers/validators | | `@honua/sdk-js/map` | `HonuaMap` programmatic map container | | `@honua/sdk-js/realtime` | Realtime transport adapters (SSE) — a client-transport primitive | | `@honua/sdk-js/react` | React provider, hooks, and map components (optional `react` / `react-dom` peers; published standalone as `@honua/react`) | | `@honua/sdk-js/geometry` | Curated turf/proj4 client-side geometry ops (buffer/area/simplify/reproject) | | `@honua/sdk-js/cli` | Programmatic `run()` entrypoint for the `honua` command-line client | ## Experimental subpath entrypoints Subpaths marked `@experimental` in JSDoc. Useful today; the shape may change in any minor release prior to `1.0.0`. **The experimental subpaths are subpath-only — they are not re-exported from `@honua/sdk-js` or `@honua/sdk-js/honua`** so a default-barrel import never pulls them in. | Subpath | What it gives you | |---------|-------------------| | `@honua/sdk-js/agent-tools` | Agent-facing JSON Schema tool definitions (MCP/OpenAI compatible). Stays in the stable package (the in-repo `@honua/mcp-server` depends on it) but its symbols remain `@experimental` — not semver-frozen — while the AI surface settles. | | `@honua/sdk-js/agent-safety` | Bounded runtime validation, effect budgets, signed approval envelopes, authenticated single-use consumption, context revalidation, and deterministic execution-receipt verification for JSON-compatible plans. | | `@honua/sdk-js/geoparquet` | GeoParquet / DuckDB-WASM–backed protocol-neutral `Source`; the optional DuckDB peer loads lazily. | | `@honua/sdk-js/query-planner` | Deterministic query IR, side-effect-free explain plans, GeoServices compilation, and explicitly bounded local execution. | | `@honua/sdk-js/plugin` | Versioned, data-only plugin manifests plus deterministic compatibility and authority-boundary certification reports. | | `@honua/sdk-js/deckgl` | Bounded, zero-copy typed-array projection into an optional deck.gl peer, with stable picking identity and deterministic disposal. | | `@honua/sdk-js/offline` | [Versioned downloadable-region manifests](./docs/offline-regions.md) plus storage-neutral quota, integrity, cancellation, and atomic commit contracts. | ## Deprecated compatibility entrypoints These temporary `@honua/sdk-js` shims were introduced in `0.1.0-beta.0` when the application platform moved. They remain available throughout `0.1.x` and are removed in `0.2.0`; new code must import the replacement directly. | Deprecated subpath | Replacement | Remove in | |--------------------|-------------|-----------| | `@honua/sdk-js/app` | `@honua/app-platform/app` | `0.2.0` | | `@honua/sdk-js/app-controller` | `@honua/app-platform/app-controller` | `0.2.0` | | `@honua/sdk-js/app-workspace` | `@honua/app-platform/app-workspace` | `0.2.0` | | `@honua/sdk-js/scene-workspace` | `@honua/app-platform/scene-workspace` | `0.2.0` | | `@honua/sdk-js/collaboration` | `@honua/app-platform/collaboration` | `0.2.0` | | `@honua/sdk-js/control-plane` | `@honua/app-platform/control-plane` | `0.2.0` | | `@honua/sdk-js/replica-sync` | `@honua/app-platform/replica-sync` | `0.2.0` | | `@honua/sdk-js/share` | `@honua/app-platform/share` | `0.2.0` | | `@honua/sdk-js/operate` | `@honua/app-platform/operate` | `0.2.0` | | `@honua/sdk-js/generated-app` | `@honua/app-platform/generated-app` | `0.2.0` | | `@honua/sdk-js/studio` | `@honua/app-platform/studio` | `0.2.0` | | `@honua/sdk-js/operator` | `@honua/app-platform/operator` | `0.2.0` | | `@honua/sdk-js/operator/controllers` | `@honua/app-platform/operator/controllers` | `0.2.0` | | `@honua/sdk-js/operator/workspace` | `@honua/app-platform/operator/workspace` | `0.2.0` | | `@honua/sdk-js/operator/theming` | `@honua/app-platform/operator/theming` | `0.2.0` | | `@honua/sdk-js/operator/i18n` | `@honua/app-platform/operator/i18n` | `0.2.0` | | `@honua/sdk-js/controls` | `@honua/app-platform/controls` | `0.2.0` | | `@honua/sdk-js/web-components` | `@honua/app-platform/web-components` | `0.2.0` | ## Application-platform entrypoints (`@honua/app-platform`) App-shell, app-builder, and hosted-product surfaces have moved out of the client SDK into the separate **`@honua/app-platform`** package, which versions at its own pre-1.0 cadence so `@honua/sdk-js` can reach a frozen 1.0 without waiting on them (see [`docs/decisions/scope-split-and-1.0.md`](./docs/decisions/scope-split-and-1.0.md)). | `@honua/app-platform` subpath | What it gives you | |-------------------------------|-------------------| | `@honua/app-platform/app` | App bootstrap helper for browser shells | | `@honua/app-platform/app-controller` | `HonuaController` — renderer-neutral app controller | | `@honua/app-platform/app-workspace` | Framework-neutral workspace state orchestration | | `@honua/app-platform/scene-workspace` | 3D scene workspace + MapLibre/Cesium adapters (optional `cesium` peer) | | `@honua/app-platform/collaboration` | Saved-map collaboration client | | `@honua/app-platform/control-plane` | Hosted-product / admin client | | `@honua/app-platform/replica-sync` | Offline-replica sync client | | `@honua/app-platform/share` | Embed-token + DCAT sharing helpers | | `@honua/app-platform/operate` | Operations/observability client | | `@honua/app-platform/generated-app` | Manifest projection + preview runtime for generated apps | | `@honua/app-platform/studio` | Studio package-family projections, validation/preview envelopes, capability manifest, publish/share/embed contracts (MCP/QGIS-safe) | | `@honua/app-platform/controls` | Native UI control kit (``) for MapLibre maps | | `@honua/app-platform/web-components` | Framework-neutral custom elements | | `@honua/app-platform/operator` | Operator-native chat/plan-review/approval controllers | | `@honua/app-platform/operator/controllers` | Framework-neutral controllers behind `/operator` | | `@honua/app-platform/operator/workspace` | Operator workspace state container | | `@honua/app-platform/operator/theming` | Operator design-system theme provider + tokens | | `@honua/app-platform/operator/i18n` | Operator message catalog + resolution | During the transition, the deprecated imports listed above keep working through `0.1.x`; they are removed in `0.2.0`. Update imports to `@honua/app-platform/`. The `@honua/sdk-js/console` entrypoint was removed outright (its projection helpers are owned by the `@honua/console` application) and has no shim. ## Prerequisites - Node.js 20 or later - A running Honua Server instance (for runtime queries) ## Install ```bash npm install @honua/sdk-js ``` ### Optional peer dependencies A few integration paths are gated behind **optional peer dependencies** so a Node-only or REST-only consumer never pays the install cost: | Integration | Peer to install | |-------------|-----------------| | MapLibre `MapPackage` runtime (`@honua/sdk-js/runtime`) | `npm install maplibre-gl` | | deck.gl binary projection (`@honua/sdk-js/deckgl`) | `npm install @deck.gl/layers` | | Cesium 3D adapters (`@honua/app-platform/scene-workspace`) | `npm install cesium` | | gRPC-Web transport (`new HonuaClient({ transport: "grpc-web" })`) | `npm install @connectrpc/connect @connectrpc/connect-web @bufbuild/protobuf` | | Geometry ops (`@honua/sdk-js/geometry`) | `npm install proj4 @turf/buffer @turf/area …` (only the ops you import) — or use the `@honua/geometry` split package | | Migration API (`@honua/sdk-js/migration`) | `npm install typescript` | If you stay on the default REST transport with no MapLibre/Cesium scene work, no extra installs are required. ## Quick Start ```typescript doc-test=compile import { HonuaClient } from "@honua/sdk-js/honua"; const client = new HonuaClient({ baseUrl: "https://your-honua-server.com", }); const compatibility = await client.checkCompatibility(); if (!compatibility.supported) { throw new Error( `Unsupported Honua server. Minimum supported version: ${HonuaClient.minimumSupportedServerVersion}. ` + `Reasons: ${compatibility.reasons.join("; ")}`, ); } const result = await client.queryFeatures({ serviceId: "natural-earth", layerId: 0, where: "1=1", returnGeometry: true, outFields: ["*"], outSr: 4326, resultRecordCount: 25, }); const featureCount = result.features?.length ?? 0; console.log(`Found ${featureCount} feature(s)`); ``` `checkCompatibility()` reads the parsed `data.compatibility` contract from `GET /api/v1/admin/capabilities`. For a runnable browser example from this repo, including the renderable-geometry checks used by the committed MapLibre quickstart, see [`examples/maplibre-quickstart/README.md`](./examples/maplibre-quickstart/README.md). ## Canonical Contract And Exploration The SDK exposes a protocol-neutral client contract and exploration state module that wrap the existing `HonuaFeatureLayer` / `HonuaMapService` / `HonuaOgcFeatureCollection` classes. These are reachable via the `@honua/sdk-js/contract` and `@honua/sdk-js/exploration` subpaths: - [`docs/shared-client-contract.md`](./docs/shared-client-contract.md) — `Dataset`, `Source`, `Capabilities`, `Query`, `Result`, `MapBinding`, and `createDataset(...)`. - [`docs/exploration-context.md`](./docs/exploration-context.md) — `createExplorationContext(...)` with linked-view presets (`globalLinked`, `mapDriven`, `gridDriven`, `chartDriven`, `decoupled`). - [`docs/protocol-capability-matrix.md`](./docs/protocol-capability-matrix.md) — capability coverage per protocol (`geoservices-feature-service`, `geoservices-map-service`, `geoservices-image-service`, `geoservices-geometry-service`, `geoservices-gp-service`, `ogc-features`, `ogc-tiles`, `ogc-maps`, `stac`, `wfs`, `wms`, `odata`). - [`docs/wfs.md`](./docs/wfs.md) — first-party WFS 2.0 adapter, FES filter translation, content-type negotiation, transactions, stored queries, and the `Source.protocol("wfs")` escape hatch. - [`docs/source-binding-alignment.md`](./docs/source-binding-alignment.md) — round-trip mapping between `SourceDescriptor` and the server `SourceBinding` document. - [`docs/maplibre-runtime.md`](./docs/maplibre-runtime.md) — `loadMapPackage(...)` and `HonuaMapRuntime` for the MapLibre GL JS-first `MapPackage` runtime. ## Esri Migration The migration helpers live behind the `@honua/sdk-js/migration` subpath. They power the same codemod that the standalone CLI runs: ```typescript doc-test=compile import { runEsriCompatCodemod, scanArcGisUsage } from "@honua/sdk-js/migration"; const report = scanArcGisUsage("./src"); const migration = runEsriCompatCodemod({ rootDir: "./src", write: true }); ``` ## Version Policy - **Pre-release** (`-alpha.*`, `-beta.*`): Published to npm with `@alpha` / `@beta` dist-tags. - **Stable** (`1.0.0+`): Published to npm as `@latest`. - **Semver:** All releases follow [Semantic Versioning](https://semver.org/). Public symbols reachable from the documented subpaths above are covered by the contract; symbols marked `@experimental` in JSDoc may change in any minor release. - **Cross-language alignment:** Major versions are coordinated across the Honua SDK family (JavaScript, Python, .NET) so a single semver line tells you what the contract is on every platform. > **Advanced packaging.** Downstream packagers can also produce a three-package > split (`@honua/sdk` / `@honua/sdk-esri-compat` / `@honua/honua-migrate`) via > `npm run build:split-packages`. This is an opt-in build target, not the default > consumer install. See [`docs/split-packages.md`](./docs/split-packages.md) if you > are integrating with a downstream registry that needs the smaller surfaces. Maintainers can verify the artifact consumers actually install with `npm run verify:packed-sdk` after the library and browser builds. The gate packs the root package, installs it offline into an isolated ESM project, imports every stable and experimental subpath, typechecks every shipped declaration target, and runs the packaged `honua --help` command. Temporary package and consumer directories are removed after both successful and failed runs. --- # File: docs/generated/sample-catalog.md # SDK sample catalog This inventory is generated from [`samples/catalog.v1.json`](../../samples/catalog.v1.json). Do not edit it by hand. Catalog contract: `honua.sdk.sample-catalog.v1` · SDK: `@honua/sdk-js` (effective version derived from `package.json`) · 27 executable examples | Sample | Tier | Support | Data | Disposition | Demonstration | | --- | --- | --- | --- | --- | --- | | [`ai-spatial-app-builder`](../../examples/ai-spatial-app-builder/README.md) | advanced | experimental | fixture | rework | Builds a reviewable spatial application plan with the SDK agent-tool surface. | | [`app-bootstrap-basic`](../../examples/app-bootstrap-basic/README.md) | reference | deprecated | fixture | retire | Bootstraps a minimal application through the legacy app-platform compatibility surface. | | [`arcgis-source-app`](../../examples/arcgis-source-app/README.md) | reference | internal | fixture | keep | Provides the ArcGIS JavaScript source application used by the migration end-to-end harness. | | [`edit-workflow-demo`](../../examples/edit-workflow-demo/README.md) | flagship | supported | fixture | rework | Demonstrates optimistic edits, attachments, conflicts, and safe recovery. | | [`geocoding-quickstart`](../../examples/geocoding-quickstart/README.md) | recipe | supported | hybrid | keep | Runs forward, reverse, and suggestion workflows with map feedback. | | [`geoprocessing-job-runner`](../../examples/geoprocessing-job-runner/README.md) | advanced | supported | hybrid | merge | Submits, polls, cancels, and inspects asynchronous geoprocessing jobs. | | [`imagery-cog-quickstart`](../../examples/imagery-cog-quickstart/README.md) | flagship | supported | hybrid | merge | Compares WMS imagery, COG-backed ImageServer tiles, and export previews. | | [`kepler-analytics`](../../examples/kepler-analytics/README.md) | advanced | experimental | hybrid | rework | Replays operations data through kepler.gl with linked filters and KPI evidence. | | [`maplibre-quickstart`](../../examples/maplibre-quickstart/README.md) | flagship | supported | hybrid | keep | Connects, discovers, explains, queries, and mounts one source with linked views and inspectable evidence. | | [`mcp-gis-assistant`](../../examples/mcp-gis-assistant/README.md) | advanced | experimental | fixture | rework | Demonstrates assistant tool discovery and safe SDK-backed spatial operations. | | [`migration-workbench`](../../docs/migration-honua-maplibre.md) | flagship | supported | fixture | rework | Scans and transforms ArcGIS application source with auditable compatibility results. | | [`node-backend-quickstart`](../../examples/node-backend-quickstart/README.md) | recipe | supported | hybrid | keep | Uses the protocol-neutral client from a Node service without browser dependencies. | | [`oauth-signin`](../../examples/oauth-signin/README.md) | recipe | supported | fixture | keep | Demonstrates browser authentication and session lifecycle without embedding credentials. | | [`overture-geoparquet`](../../examples/overture-geoparquet/README.md) | flagship | experimental | hybrid | keep | Plans and executes bounded Overture GeoParquet queries with truthful worker, range, memory, and pruning evidence. | | [`planning-permitting-workbench`](../../examples/planning-permitting-workbench/README.md) | flagship | supported | fixture | rework | Combines parcels, hazards, sketching, editing, and export in a task-oriented application. | | [`pmtiles-static`](../../examples/pmtiles-static/README.md) | recipe | supported | fixture | keep | Loads a static PMTiles archive without a Honua server. | | [`react-quickstart`](../../examples/react-quickstart/README.md) | recipe | supported | hybrid | keep | Uses the React provider, hooks, and map component over the same quickstart contract. | | [`realtime-incident-dashboard`](../../examples/realtime-incident-dashboard/README.md) | flagship | supported | hybrid | keep | Runs live-first incident command with observable reconciliation and a guarded, resettable edit lab. | | [`runtime-parity-showcase`](../../examples/runtime-parity-showcase/README.md) | reference | experimental | fixture | replace | Compares supported rendering paths and makes fidelity differences explicit. | | [`service-explorer`](../../examples/service-explorer/README.md) | flagship | supported | hybrid | rework | Browses heterogeneous spatial services with capability and cache diagnostics. | | [`spatial-analytics-workbench`](../../examples/spatial-analytics-workbench/README.md) | flagship | experimental | hybrid | rework | Explains and accepts one plan linking AOI, map, table, chart, provenance, and reusable output. | | [`stac-imagery-browser`](../../examples/stac-imagery-browser/README.md) | advanced | supported | fixture | merge | Discovers STAC collections and previews supported imagery assets. | | [`standalone-quickstart`](../../examples/standalone-quickstart/README.md) | flagship | supported | hybrid | merge | Connects a public Esri service directly to MapLibre without a Honua server. | | [`storytelling-25d-map`](../../examples/storytelling-25d-map/README.md) | advanced | supported | hybrid | merge | Combines terrain, extrusion, OGC overlays, and route playback in a guided story. | | [`terrain-rgb-elevation`](../../examples/terrain-rgb-elevation/README.md) | advanced | supported | hybrid | merge | Reads Terrain-RGB tiles for point elevation and route profiles. | | [`unified-ops-workspace`](../../examples/unified-ops-workspace/README.md) | advanced | deprecated | fixture | retire | Composes incident command, analysis, and shared workspace state. | | [`web-components-basic`](../../examples/web-components-basic/README.md) | reference | deprecated | fixture | retire | Demonstrates the SDK custom-element controls against a map. | The catalog also carries fixture/live commands, endpoint configuration names, provenance, attribution, freshness, validation, and the complete 21-route honua.io migration mapping. The presentation-safe projection is [`samples/dist/honua-site-samples.v1.json`](../../samples/dist/honua-site-samples.v1.json). --- # File: docs/quickstart.md # Five-minute quickstart: endpoint to linked MapLibre map The canonical server-connected browser workflow is the tested app in [`examples/maplibre-quickstart`](../examples/maplibre-quickstart/README.md). It makes the SDK's five-stage journey visible instead of hiding network and fallback decisions: ```text connect → discover → explain → query → mount ``` If you do not need a Honua server, start with the [`standalone quickstart`](./standalone-quickstart.md). It connects directly to a public GeoServices endpoint. This page covers the protocol-neutral Honua lane with compatibility, discovery, planning, evidence, and linked views. ## Run the deterministic lane ```bash npm ci npm run demo:quickstart:mock ``` Open the printed `quickstartMockUrl`. No account, credential, or network-hosted basemap is required. The app: 1. checks SDK/server compatibility; 2. discovers layer metadata and constructs the protocol capability contract; 3. explains a deterministic query plan before fetching rows; 4. executes that accepted plan through `Dataset → Source → Query → Result`; 5. mounts the result in MapLibre and links map, table, filter, detail, and popup state. The page also exposes provenance, capture/observation time, auth mode, SDK/server/data versions, metadata cache state, plan fingerprint, pushdown, fidelity, and degradation. Fixture replay is labeled explicitly and never presented as live data. ## Use an anonymous live endpoint Copy [`.env.example`](../examples/maplibre-quickstart/.env.example), point it at a public CORS-enabled Honua layer, then run the same app: ```bash cp examples/maplibre-quickstart/.env.example examples/maplibre-quickstart/.env npm run demo:quickstart ``` The required live values are: - `VITE_HONUA_QUICKSTART_BASE_URL` - `VITE_HONUA_QUICKSTART_SERVICE_ID` - `VITE_HONUA_QUICKSTART_LAYER_ID` - `VITE_HONUA_QUICKSTART_DATA_VERSION` The filter, bounded record count, basemap style, and optional snapshot timestamp are documented in the [sample README](../examples/maplibre-quickstart/README.md#secret-free-live-run). The browser quickstart rejects API keys and bearer tokens because Vite embeds environment values in public JavaScript. Use an anonymous endpoint or a server-side proxy/session. Protected server-only staging validation remains separate. ## The SDK shape The sample uses stable subpath imports and the explicitly experimental planner: ```ts doc-test=compile import { PROTOCOL_DEFAULT_CAPABILITIES, createDataset } from "@honua/sdk-js/contract"; import { HonuaClient } from "@honua/sdk-js/honua"; import { executeQueryPlan, explainQuery } from "@honua/sdk-js/query-planner"; const client = new HonuaClient({ baseUrl: "https://your-public-honua.example" }); const metadata = await client.getLayerMetadata("public-service", 0); const descriptor = { id: "public-features", protocol: "geoservices-feature-service" as const, locator: { url: "https://your-public-honua.example", serviceId: "public-service", layerId: 0 }, capabilities: PROTOCOL_DEFAULT_CAPABILITIES["geoservices-feature-service"], schema: { fields: metadata.fields }, }; const dataset = createDataset({ id: "public-map", client, sources: [descriptor] }); const source = dataset.source("public-features"); if (!source) throw new Error("Source resolution failed"); const query = { where: "1=1", outFields: ["*"], returnGeometry: true, pagination: { limit: 25 } }; const plan = explainQuery({ descriptor, query, sourceVersion: "public-service-2026-07" }); const execution = await executeQueryPlan(plan, source, { sourceVersion: "public-service-2026-07" }); console.log(execution.result.features); ``` Planning is synchronous and side-effect free. Execution validates plan integrity and source context before invoking the accepted step. Capability gaps and unsafe fallback bounds throw structured errors; they do not become silent empty maps. ## Requests and validation A healthy startup performs three Honua requests before the basemap's own assets: 1. `GET /api/v1/admin/capabilities` 2. `GET /rest/services/{serviceId}/FeatureServer/{layerId}?f=json` 3. `GET /rest/services/{serviceId}/FeatureServer/{layerId}/query?...` The required CI lane is fixture-only: ```bash npm run demo:quickstart:typecheck npx vitest run test/quickstart-config.test.ts test/quickstart-data.test.ts test/quickstart-linked-exploration.test.ts npm run demo:quickstart:build npm run test:playwright:quickstart ``` See [`quickstart-troubleshooting.md`](./quickstart-troubleshooting.md) for compatibility, discovery, configuration, geometry, plan, CORS, and staging diagnostics. --- # File: docs/generated/learning-paths.md # Learn the Honua SDK by task Choose the outcome you need, then follow the linked guide and runnable implementation. The examples are the canonical executable source; this guide deliberately contains no copied implementation snippets. API reference is SDK-owned at [https://honua-io.github.io/honua-sdk-js/api/](https://honua-io.github.io/honua-sdk-js/api/); the task narrative and deployed sample catalog are site-owned at [https://honua.io/samples](https://honua.io/samples). These paths describe the SDK version in [`package.json`](../../package.json). Sample support, tier, data, provenance, freshness, and degradation metadata comes from the [versioned sample catalog](../../samples/contract/v1/README.md); use the [installation and compatibility guide](../../INSTALL.md) when reading docs for another release. ## Execution labels - **Fixture** (`fixture`): Deterministic committed data; no live-service claim. - **Public live** (`public-live`): Reads a public standards endpoint without a Honua account. - **Demo live** (`demo-live`): Can read the deployed demo.honua.io service when configured and reports availability and freshness. - **Authenticated** (`authenticated`): Requires caller-supplied credentials; documentation never embeds a secret. - **Degraded** (`degraded`): A reduced capability remains visible with a structured reason and recovery path. - **Experimental** (`experimental`): Uses a pre-1.0 surface that may change in a minor release. ## Learning paths ### 1. Start with a public map Open a public GeoServices endpoint and render useful data without an account. Labels: `fixture` · `public-live` - Guide: [docs/standalone-quickstart.md](../standalone-quickstart.md) - Runnable example: [standalone-quickstart](../../examples/standalone-quickstart) - Executable entry: [examples/standalone-quickstart/src/main.ts](../../examples/standalone-quickstart/src/main.ts) - Example notes: [examples/standalone-quickstart/README.md](../../examples/standalone-quickstart/README.md) - Compile check: `npm run demo:standalone:typecheck` - Sample contract: `flagship` · `supported` - Data and auth: `hybrid` · `anonymous` - Provenance: Committed public-response fixture or public Esri endpoint. - Freshness: Fixture retrieval metadata or live response time. - Catalog degradation: Protocol capability gaps are surfaced without requiring a Honua facade. - Live sample: [sample-expr-builder.html](https://honua.io/sample-expr-builder.html) - Supported API imports: `@honua/sdk-js/esri-compat` (`FeatureLayerCompat`); `@honua/sdk-js/honua` (`HonuaClient`); `@honua/sdk-js/map` (`loadHonuaFeatureServiceGeoJson`) - honua.io journey: `connect-existing-gis` ### 2. Connect and inspect sources Discover services, layers, schemas, and capability gaps before choosing a workflow. Labels: `fixture` · `demo-live` · `authenticated` · `degraded` - Guide: [docs/shared-client-contract.md](../shared-client-contract.md) - Runnable example: [service-explorer](../../examples/service-explorer) - Executable entry: [examples/service-explorer/src/data.ts](../../examples/service-explorer/src/data.ts) - Example notes: [examples/service-explorer/README.md](../../examples/service-explorer/README.md) - Compile check: `npm run demo:service-explorer:typecheck` - Sample contract: `flagship` · `supported` - Data and auth: `hybrid` · `api-key` - Provenance: Committed multi-protocol catalog or configured Honua catalog. - Freshness: Metadata cache and live observation timestamps. - Catalog degradation: Unavailable admin metadata falls back to service discovery with a visible diagnostic. - Live sample: [sample-service-explorer.html](https://honua.io/sample-service-explorer.html) - Supported API imports: `@honua/sdk-js/contract` (`createDataset`); `@honua/sdk-js/honua` (`HonuaClient`) - honua.io journey: `connect-existing-gis` - Degradation: The current shell still uses the 0.1.x app-workspace compatibility shim; #399 owns its supported-import migration. ### 3. Query from Node or browser code Issue bounded queries, inspect typed results, and keep capability failures explicit. Labels: `fixture` · `demo-live` · `authenticated` · `degraded` - Guide: [docs/composition.md](../composition.md) - Runnable example: [node-backend-quickstart](../../examples/node-backend-quickstart) - Executable entry: [examples/node-backend-quickstart/src/server.ts](../../examples/node-backend-quickstart/src/server.ts) - Example notes: [examples/node-backend-quickstart/README.md](../../examples/node-backend-quickstart/README.md) - Compile check: `npm run demo:node-backend:typecheck` - Sample contract: `recipe` · `supported` - Data and auth: `hybrid` · `api-key` - Provenance: Committed mock responses or configured Honua endpoint. - Freshness: Fixture replay or live query observation time. - Catalog degradation: The recipe fails explicitly when required server capabilities are absent. - Supported API imports: `@honua/sdk-js` (`QueryBuilder`); `@honua/sdk-js/honua` (`HonuaClient`) - honua.io journey: `query-map-style` - Degradation: The live endpoint may omit requested query capabilities; the sample keeps the typed capability error visible. ### 4. Connect, explain, and map query results Connect, discover, explain, query, and mount one source with linked MapLibre views and visible runtime evidence. Labels: `fixture` · `demo-live` · `experimental` - Guide: [docs/quickstart.md](../quickstart.md) - Runnable example: [maplibre-quickstart](../../examples/maplibre-quickstart) - Executable entry: [examples/maplibre-quickstart/src/main.ts](../../examples/maplibre-quickstart/src/main.ts) - Example notes: [examples/maplibre-quickstart/README.md](../../examples/maplibre-quickstart/README.md) - Compile check: `npm run demo:quickstart:typecheck` - Sample contract: `flagship` · `supported` - Data and auth: `hybrid` · `anonymous` - Provenance: Versioned Honolulu fixture replay or an anonymous configured Honua source, identified in runtime evidence. - Freshness: Fixture capture time and data version, or live response observation time. - Catalog degradation: Capability misses, plan warnings, and bounded fallback are shown rather than silently returning an empty map. - Live sample: [demo.html](https://honua.io/demo.html) - Supported API imports: `@honua/sdk-js/contract` (`createDataset`); `@honua/sdk-js/exploration` (`createExplorationContext`); `@honua/sdk-js/honua` (`HonuaClient`); `@honua/sdk-js/query-planner` (`executeQueryPlan`, `explainQuery`) - honua.io journey: `query-map-style` ### 5. Analyze linked spatial views Keep map, table, chart, and spatial aggregation state aligned with visible fallback evidence. Labels: `fixture` · `experimental` · `degraded` - Guide: [docs/warehouse-analytics-sources.md](../warehouse-analytics-sources.md) - Runnable example: [spatial-analytics-workbench](../../examples/spatial-analytics-workbench) - Executable entry: [examples/spatial-analytics-workbench/src/main.ts](../../examples/spatial-analytics-workbench/src/main.ts) - Example notes: [examples/spatial-analytics-workbench/README.md](../../examples/spatial-analytics-workbench/README.md) - Compile check: `npm run demo:spatial-analytics:typecheck` - Sample contract: `flagship` · `experimental` - Data and auth: `hybrid` · `anonymous` - Provenance: Committed analysis fixtures or a configured public GeoServices source. - Freshness: Fixture replay uses a fixed observation; configured live execution records its observation time. - Catalog degradation: Remote fixtures are labeled replayed, bounded-local execution is capped, unsafe materialization is rejected, and OGC/DuckDB planner execution is a structured #389 follow-on. - Live sample: [demo-analyst-workbench.html](https://honua.io/demo-analyst-workbench.html) · [sample-spatial-analytics.html](https://honua.io/sample-spatial-analytics.html) - Supported API imports: `@honua/sdk-js/contract` (`resolveSpatialAggregationWidgetSummary`); `@honua/sdk-js/exploration` (`createExplorationContext`) - honua.io journey: `linked-large-data-analysis` - Degradation: The current shell still uses the 0.1.x app-workspace compatibility shim; #399 owns its supported-import migration. ### 6. Edit with recovery and capability checks Apply optimistic edits, attachments, and conflict recovery without hiding unsupported mutations. Labels: `fixture` · `degraded` - Guide: [docs/shared-client-contract.md](../shared-client-contract.md) - Runnable example: [edit-workflow-demo](../../examples/edit-workflow-demo) - Executable entry: [examples/edit-workflow-demo/src/main.ts](../../examples/edit-workflow-demo/src/main.ts) - Example notes: [examples/edit-workflow-demo/README.md](../../examples/edit-workflow-demo/README.md) - Compile check: `npm run demo:edit-workflow:typecheck` - Sample contract: `flagship` · `supported` - Data and auth: `fixture` · `none` - Provenance: Committed deterministic edit fixture. - Freshness: Deterministic fixture replay time. - Catalog degradation: Editing is read-only when the service does not advertise mutation capability. - Live sample: [demo-editing.html](https://honua.io/demo-editing.html) - Supported API imports: `@honua/sdk-js/contract` (`createEditSession`); `@honua/sdk-js/honua` (`HonuaCapabilityNotSupportedError`) - honua.io journey: `realtime-operations` - Degradation: The current shell still uses the 0.1.x app-workspace compatibility shim, and mutations become read-only when capability or auth is absent. ### 7. Operate through realtime and offline transitions Reconcile snapshots and deltas while showing reconnect, staleness, and deterministic fixture fallback. Labels: `fixture` · `demo-live` · `degraded` - Guide: [docs/realtime-subscriptions.md](../realtime-subscriptions.md) - Runnable example: [realtime-incident-dashboard](../../examples/realtime-incident-dashboard) - Executable entry: [examples/realtime-incident-dashboard/src/realtime-transport.ts](../../examples/realtime-incident-dashboard/src/realtime-transport.ts) - Example notes: [examples/realtime-incident-dashboard/README.md](../../examples/realtime-incident-dashboard/README.md) - Compile check: `npm run demo:incident:typecheck` - Sample contract: `flagship` · `supported` - Data and auth: `hybrid` · `anonymous` - Provenance: Deployed demo stream when advertised; otherwise visibly labeled deterministic replay. Edits are limited to an isolated resettable fixture profile. - Freshness: Snapshot, observation, and event times plus cursor, sequence, lag, reconnect, deduplication, and reconciliation outcomes. - Catalog degradation: Unavailable live capability becomes a visibly labeled read-only replay; stale/offline state disables mutation, and live writes fail closed without an isolated resettable profile. - Live sample: [demo-public-safety.html](https://honua.io/demo-public-safety.html) - Supported API imports: `@honua/sdk-js/realtime` (`createRealtimeServerSentEventsTransport`) - honua.io journey: `realtime-operations` - Degradation: The production offline persistence path is not complete; reconnect and stale-state behavior remains explicit while fixture replay stays deterministic. ### 8. Add terrain and 3D context Progress from stable 2D map primitives to an explicitly experimental 2.5D/3D experience. Labels: `fixture` · `authenticated` · `experimental` · `degraded` - Guide: [docs/scene-workspace.md](../scene-workspace.md) - Runnable example: [storytelling-25d-map](../../examples/storytelling-25d-map) - Executable entry: [examples/storytelling-25d-map/src/map.ts](../../examples/storytelling-25d-map/src/map.ts) - Example notes: [examples/storytelling-25d-map/README.md](../../examples/storytelling-25d-map/README.md) - Compile check: `npm run demo:25d:typecheck` - Sample contract: `advanced` · `supported` - Data and auth: `hybrid` · `api-key` - Provenance: Committed Oahu story fixtures or configured Honua services. - Freshness: Fixture capture or live layer observation time. - Catalog degradation: Terrain and live overlays fail independently with structured status. - Live sample: [demo-maui-3d.html](https://honua.io/demo-maui-3d.html) - Supported API imports: `@honua/sdk-js/map` (`HonuaMap`); `@honua/sdk-js/runtime` (`HonuaMapRuntime`) - honua.io journey: `imagery-terrain-3d` - Degradation: The runnable 2.5D shell still reaches the scene-workspace compatibility surface; stable map/runtime imports are the taught foundation until #399 migrates it. ### 9. Migrate an ArcGIS application Scan and transform one file at a time with explicit compatibility evidence and manual gaps. Labels: `fixture` · `public-live` · `degraded` - Guide: [docs/migration-honua-maplibre.md](../migration-honua-maplibre.md) - Runnable example: [migration-workbench](../../examples/migration-workbench) - Executable entry: [examples/migration-workbench/src/main.ts](../../examples/migration-workbench/src/main.ts) - Example notes: [docs/migration-honua-maplibre.md](../migration-honua-maplibre.md) - Compile check: `npm run demo:migration-workbench:typecheck` - Sample contract: `flagship` · `supported` - Data and auth: `fixture` · `none` - Provenance: Committed ArcGIS source fixtures and migration reports. - Freshness: Versioned with the migration corpus. - Catalog degradation: Unsupported ArcGIS modules are reported as manual work, never silently removed. - Live sample: [sample-codemod.html](https://honua.io/sample-codemod.html) - Supported API imports: `@honua/sdk-js/migration` (`runEsriCompatCodemod`, `scanArcGisUsage`) - honua.io journey: `migrate-arcgis` - Degradation: Unsupported ArcGIS modules remain manual migration work and are reported rather than removed silently. ### 10. Automate safely with agent tools Expose bounded map actions with capability explanations while keeping writes behind host approval. Labels: `fixture` · `experimental` · `degraded` - Guide: [docs/ai-map-kit.md](../ai-map-kit.md) - Runnable example: [mcp-gis-assistant](../../examples/mcp-gis-assistant) - Executable entry: [examples/mcp-gis-assistant/src/assistant.ts](../../examples/mcp-gis-assistant/src/assistant.ts) - Example notes: [examples/mcp-gis-assistant/README.md](../../examples/mcp-gis-assistant/README.md) - Compile check: `npm run demo:mcp-gis-assistant:typecheck` - Sample contract: `advanced` · `experimental` - Data and auth: `fixture` · `none` - Provenance: Committed MCP response fixture. - Freshness: Deterministic fixture replay time. - Catalog degradation: Write tools are unavailable without host policy and explicit approval. - Supported API imports: `@honua/sdk-js/agent-tools` (`HONUA_AGENT_TOOL_DEFINITIONS`, `createHonuaAgentToolExecutor`) - honua.io journey: `safe-agent-automation` - Degradation: The current assistant shell still uses the app-workspace compatibility surface, and write execution stays disabled without explicit host approval. ## Publication boundary - Guides link to runnable example source and never maintain a copied implementation snippet. - Repository documentation uses relative links; canonical API and site narrative links use the declared owners. - Site consumers join the [learning navigation manifest](../learning-paths.v1.json) to the [static sample projection](../../samples/dist/honua-site-samples.v1.json) by `sampleId`; catalog-owned metadata is not copied into another source file. - Sample metadata/artifact/evidence projection is coordinated by [SDK issue #401](https://github.com/honua-io/honua-sdk-js/issues/401) and [honua-site issue #120](https://github.com/honua-io/honua-site/issues/120). --- # File: docs/standalone-quickstart.md # Standalone Quickstart: The SDK Against Any Public Endpoint `@honua/sdk-js` is a typed geospatial service client first and a Honua client second. Its protocol clients speak raw **Esri GeoServices**, so they work against *any* ArcGIS Server / ArcGIS Online endpoint with **no Honua server, no API key, and no account**. This is the maintained, MapLibre-targeting successor to `esri-leaflet`. Start here. A [Honua Server](https://github.com/honua-io/honua-server) is the *upgrade path* (authored MapPackages, realtime, collaboration, MCP) — not the entry fee. The [capability matrix](./standalone-capability-matrix.md) draws the honest line between what is backend-agnostic and what needs a Honua Server. ## The first code block runs with zero Honua infrastructure Point the SDK at a public FeatureServer and get typed query results: ```ts doc-test=compile import { HonuaClient } from "@honua/sdk-js/honua"; // A public Esri Living Atlas FeatureServer — no key, no account, no Honua server. const url = "https://services.arcgis.com/P3ePLMYs2RVChkJx/arcgis/rest/services/2020_Census_State_Apportionment/FeatureServer/0"; const client = new HonuaClient({ baseUrl: "https://services.arcgis.com/P3ePLMYs2RVChkJx/arcgis" }); const { features } = await client.queryFeatures({ serviceId: "2020_Census_State_Apportionment", layerId: 0, where: "1=1", outFields: ["NAME", "Total_Pop_2020", "Seats_2020"], returnGeometry: false, resultRecordCount: 5, }); console.log(`Loaded ${features?.length ?? 0} states`); ``` `HonuaClient` builds the standard GeoServices request path (`/rest/services/{serviceId}/FeatureServer/{layerId}/query`) and joins it to whatever origin you pass as `baseUrl`, so `services.arcgis.com`, `sampleserver6.arcgisonline.com`, or your own ArcGIS Server all work unchanged. ### Render it on MapLibre in one call `@honua/sdk-js/map` turns a public FeatureServer URL straight into a MapLibre `geojson` source: ```ts doc-test=skip reason="partial excerpt requires application host context" import { HonuaClient } from "@honua/sdk-js/honua"; import { loadHonuaFeatureServiceGeoJson } from "@honua/sdk-js/map"; import maplibregl from "maplibre-gl"; const client = new HonuaClient({ baseUrl: "https://services.arcgis.com/P3ePLMYs2RVChkJx/arcgis" }); const source = await loadHonuaFeatureServiceGeoJson(client, url, { outFields: ["NAME"] }); const map = new maplibregl.Map({ container: "map", style: "https://demotiles.maplibre.org/style.json" }); map.on("load", () => { map.addSource("states", source); map.addLayer({ id: "states-fill", source: "states", type: "fill", paint: { "fill-color": "#0f766e" } }); }); ``` ### The esri-leaflet migration path works standalone too Pointing `FeatureLayerCompat` (and the rest of `@honua/sdk-js/esri-compat`) at a `services.arcgis.com`-style URL Just Works — it parses the URL, derives the origin, and constructs its own client. Existing ArcGIS-shaped code migrates file-by-file without standing up any Honua infrastructure: ```ts doc-test=skip reason="partial excerpt requires application host context" import { FeatureLayerCompat } from "@honua/sdk-js/esri-compat"; // Same familiar shape as esri-leaflet / arcgis-rest — no server required. const layer = new FeatureLayerCompat({ url }); const { features } = await layer.queryFeatures({ where: "Seats_2020 > 10", outFields: ["NAME"] }); ``` ## Run the committed example The [`examples/standalone-quickstart/`](../examples/standalone-quickstart/README.md) app does exactly the above — public GeoServices query → MapLibre render → the `FeatureLayerCompat` drop-in proof — and ships two lanes: ```bash npm install # Live lane: hits the pinned public Esri endpoint. npm run demo:standalone # Deterministic lane: replays recorded fixtures on same-origin paths (what CI runs). npm run demo:standalone:mock # prints standaloneMockUrl=http://127.0.0.1:PORT ``` CI only ever runs the fixture lane, so it never depends on a third party. Refresh the recordings from the live endpoints on demand with `npm run demo:standalone:refresh-fixtures`. A scheduled, non-blocking workflow (`.github/workflows/standalone-live-smoke.yml`) probes the live endpoints weekly and never gates a PR. ## Documented public endpoints These are the pinned, stable public services the docs and example use. All are anonymous, read-only, and permitted for demonstration use. | Endpoint | Protocol | Provider / license note | | --- | --- | --- | | `https://services.arcgis.com/P3ePLMYs2RVChkJx/arcgis/rest/services/2020_Census_State_Apportionment/FeatureServer/0` | GeoServices FeatureServer | Esri Living Atlas; US Census apportionment (public-domain data), publicly shared by Esri. | | `https://sampleserver6.arcgisonline.com/arcgis/rest/services/SampleWorldCities/MapServer/0` | GeoServices MapServer | Esri's public sample server, hosted expressly for samples/demos. | | `https://demo.pygeoapi.io/master/collections` | OGC API Features | pygeoapi's official public demo (MIT project); Natural Earth (public-domain) data. | > **OGC endpoints and the typed surface.** The GeoServices client is the fully > backend-agnostic lane today: it targets raw ArcGIS request paths. The SDK's > *typed* OGC API / WFS / STAC surfaces currently address the Honua server's OGC > facade paths — see the [capability matrix](./standalone-capability-matrix.md) > for the honest, per-capability breakdown and roadmap. ## Add a Honua Server (the upgrade path) When you want more than reads against open services, a [Honua Server](https://github.com/honua-io/honua-server) unlocks: - **Authored `MapPackage`s** — `loadMapPackage()` + `HonuaMapRuntime` render a server-authored map with styles, layer order, and metadata baked in. - **Realtime** — subscription-backed feature updates (`@honua/sdk-js/realtime`). - **Collaboration** — shared/saved maps and multi-user sessions. - **MCP + AI** — the `@honua/mcp-server` and agent tools surface. Spin one up locally and point the same code at it: ```bash # From a honua-server checkout: docker compose up # then set baseUrl to your local server, e.g. http://127.0.0.1:8080 ``` See the [maplibre-quickstart](../examples/maplibre-quickstart/README.md) for the server-connected lane, [`docs/maplibre-runtime.md`](./maplibre-runtime.md) for the `MapPackage` runtime, and [`docs/realtime-subscriptions.md`](./realtime-subscriptions.md) for realtime. --- # File: docs/quickstart-troubleshooting.md # Quickstart Troubleshooting Use this guide when the committed quickstart app at [`examples/maplibre-quickstart/`](../examples/maplibre-quickstart/README.md) does not load as expected. ## Unsupported Compatibility Baseline Symptoms: - the app stops before querying data - the overlay shows an unsupported compatibility message - `window.__HONUA_QUICKSTART_RUNTIME__.serverVersion` or `releaseChannel` is missing or below the SDK baseline - the app reports `Server does not expose GET /api/v1/admin/capabilities.` Checks: - confirm `GET /api/v1/admin/capabilities` is reachable from the browser - confirm the endpoint responds with a JSON object containing `success` and `data.compatibility` - confirm the server reports version `>= 1.0.0` - confirm `data.compatibility.controlPlaneApi.major` is integer `1` with base path `/api/v1/admin` - confirm `data.compatibility.controlPlaneApi.deprecated` is `false` - confirm `data.compatibility.metadataSchemas[]` entries include `version` and `deprecated` - confirm `data.compatibility.features` includes the expected boolean flags - confirm the release channel is `preview` or newer The quickstart app intentionally fails before the feature query when this contract is not satisfied. ## Protected Endpoint Or Invalid Auth Symptoms: - `401` or `403` responses on compatibility or feature-query requests - browser console shows a request error before the map becomes ready Checks: - use a CORS-enabled endpoint that permits anonymous reads for the browser flagship - do not add browser API keys or bearer tokens; the app rejects them because Vite would publish their values - put protected browser traffic behind an application backend or session flow - for server-only staging CI, set `HONUA_STAGING_API_KEY` or `HONUA_STAGING_BEARER_TOKEN` when required The page always reports its auth mode. It never renders credential values. ## CORS Or Browser Network Failures Symptoms: - the browser reports `TypeError: Failed to fetch` - requests work from server-side tools but fail from the browser app Checks: - confirm the Honua server allows the browser origin used by Vite or preview - confirm preflight responses allow the headers your auth mode sends - use the same-origin fixture lane first: ```bash npm run demo:quickstart:mock ``` If the fixture lane works but the live lane fails, the problem is likely environment or browser policy rather than the example code. ## Invalid Quickstart Config Symptoms: - the app fails before the compatibility check runs - the overlay or inline status reports `A quickstart layer id must be an integer.` - the overlay or inline status reports `A quickstart result record count must be greater than zero.` - the overlay reports that the browser quickstart is intentionally secret-free - local staging runs fail with `HONUA_STAGING_BASE_URL is required.` or the matching `HONUA_STAGING_*` required-variable message - the staging workflow fails during its validation step with `Missing HONUA_STAGING_BASE_URL`, `Missing HONUA_STAGING_SERVICE_ID`, or `Missing HONUA_STAGING_LAYER_ID` - `npm run test:quickstart:staging` fails before issuing any network requests when staging env values are malformed Checks: - set `VITE_HONUA_QUICKSTART_LAYER_ID` to an integer - set `VITE_HONUA_QUICKSTART_RESULT_RECORD_COUNT` to a positive integer - remove `VITE_HONUA_QUICKSTART_API_KEY` and `VITE_HONUA_QUICKSTART_BEARER_TOKEN`; use an anonymous endpoint or proxy - set `HONUA_STAGING_BASE_URL`, `HONUA_STAGING_SERVICE_ID`, and `HONUA_STAGING_LAYER_ID` for local or CI staging runs - for staging CI, keep `HONUA_STAGING_LAYER_ID` integer-valued and `HONUA_STAGING_RESULT_RECORD_COUNT` positive when overridden These validation failures happen before the app or staging integration issues `GET /api/v1/admin/capabilities`. ## Empty Feature Queries Symptoms: - the app reports `The feature query returned no features...` - staging integration fails with `featureCount` equal to `0` Checks: - confirm `VITE_HONUA_QUICKSTART_SERVICE_ID` and `VITE_HONUA_QUICKSTART_LAYER_ID` - confirm the configured `where` clause matches seeded data - reduce filter complexity and retry with `1=1` - keep `resultRecordCount` bounded but non-zero The quickstart app expects at least one feature because it fits the map and drives an inspection popup from the query result. ## Unsupported Or Missing Geometry Symptoms: - the app reports `none included renderable geometry` - the feature query succeeds but the map never renders data Checks: - confirm the layer returns geometry-bearing features - confirm the quickstart query keeps `returnGeometry: true` - confirm the requested layer returns geometry that converts into the app's point, line, or polygon render buckets - keep `outSr: 4326` so MapLibre receives browser-safe coordinates The app filters out non-renderable records and only creates render layers for returned points, lines, or polygons. That means `featureCount` can be greater than `renderableFeatureCount` when the server returns mixed-quality data. ## Basemap Style Load Failures Symptoms: - the overlay reports `Failed to load the basemap style "..."` - Honua requests succeed but the map never initializes Checks: - confirm `VITE_HONUA_QUICKSTART_BASEMAP_STYLE` is reachable from the browser - confirm the style JSON and its dependent assets are public or otherwise accessible - use the fixture lane to remove basemap-network variability from the initial debug loop The fixture lane serves `/__honua-quickstart__/basemap-style.json` locally to keep smoke coverage deterministic. ## Staging CI Config Drift The live staging suite is triggered by `npm run test:quickstart:staging` and `.github/workflows/quickstart-staging.yml`. The deterministic browser smoke lane runs from `.github/workflows/ci.yml` on `trunk` plus `release/**` pushes and pull requests. Required environment variables: - `HONUA_STAGING_BASE_URL` - `HONUA_STAGING_SERVICE_ID` - `HONUA_STAGING_LAYER_ID` Optional environment variables: - `HONUA_STAGING_WHERE` - `HONUA_STAGING_RESULT_RECORD_COUNT` - `HONUA_STAGING_API_KEY` - `HONUA_STAGING_BEARER_TOKEN` - `HONUA_QUICKSTART_STAGING_SUMMARY_FILE` If staging CI starts failing after an environment change: - confirm the configured service and layer still exist - confirm the environment still exposes non-empty geometry-bearing data - confirm the auth policy still matches the configured secret or public access path - remember the staging suite reuses the shared quickstart data loader only; it does not open the browser app or fetch the basemap style - review the workflow step summary for `serverVersion`, `releaseChannel`, `featureCount`, `renderableFeatureCount`, `geometryTypes`, and `queryDurationMs` ## Inspecting Runtime State For browser debugging and Playwright smoke assertions, inspect: - `window.__HONUA_QUICKSTART_EVENTS__` - `window.__HONUA_QUICKSTART_RUNTIME__` - `window.__HONUA_QUICKSTART_DISPOSE__()` Useful fields: - `baseUrl`, `serviceId`, `layerId` - `serverVersion`, `releaseChannel` - `sdkVersion`, `dataVersion`, `planId`, `planFingerprint`, `planPushdown` - `featureCount`, `renderableFeatureCount`, `geometryTypes` - `queryDurationMs` - `layerIds`, `mapReady`, `selectedFeatureId`, `popupOpen`, `lastError` - `journeyComplete`, `disposed` Every telemetry event is also dispatched as `CustomEvent("honua:quickstart")`. ## External Follow-on This repo still depends on one bounded external child ticket for long-term staging stability: - `honua-server`: expose and document a stable staging quickstart dataset for JS SDK CI, including the canonical service, layer, auth policy, and non-empty geometry contract. --- # File: docs/shared-client-contract.md # Shared Client Contract Status: implemented in `src/contract/` (ticket `honua-sdk-js-23`). The shared contract is the protocol-neutral vocabulary every Honua data adapter speaks. It exists so cross-protocol code — exploration views, visual builders, and the server `SourceBinding`/`MapPackage` exporters — can be written once against `Dataset` / `Source` / `Query` / `Result` / `MapBinding` rather than re-litigating the surface in each ticket. ## Goals (and non-goals) - **Goal:** one canonical name for "the dataset", "the source", "the capability", "the query", "the result", "the map binding", and "the exploration state" across `HonuaFeatureLayer`, `HonuaMapService`, `HonuaOgcFeatures`, first-party OGC render/search adapters, first-party WMS / WMTS adapters, the first-party WFS 2.0 adapter, and the first-party OData adapter. - **Goal:** wrap (do not replace) the existing runtime classes in `src/core/surfaces.ts`. Existing callers continue to work; adapter tickets opt in to the canonical surface. - **Goal:** stable serialization shape that survives a round-trip with the server `SourceBinding` / `MapPackage` documents (see [`source-binding-alignment.md`](./source-binding-alignment.md)). - **Non-goal:** a runtime rewrite. The contract is a typed surface plus thin adapter functions — one per built-in protocol (`geoServicesFeatureSource`, `geoServicesMapServiceSource`, `geoServicesImageSource`, `geoServicesGeometryServiceSource`, `geoServicesGPServiceSource`, `ogcFeaturesSource`, `ogcTilesSource`, `ogcMapsSource`, `stacSearchSource`, `wmsSource`, `wmtsSource`, `wfsSource`, `odataSource`). - **Non-goal:** a query DSL. `Query.where` is still a SQL-92 / CQL2 string; adapters translate to their wire format. ## Module layout ``` src/contract/ ├── index.ts # barrel — re-exports types and source factories ├── spatial-aggregation.ts # indexed aggregation request/response metadata ├── tiles.ts # dynamic query tile descriptors, cache keys, identity ├── types.ts # protocol, capability, source, dataset, query, result └── source.ts # createDataset + built-in adapters ``` Public entrypoint: `@honua/sdk-js/contract` (also re-exported from the top-level `@honua/sdk-js` and `@honua/sdk-js/honua` barrels). ## Cross-SDK binding policy This JS contract is also the draft semantic anchor for the Python and .NET SDKs. The SDKs should align behavior and vocabulary while keeping language-native names and casing. For example, the same operation may surface as `queryAll()` in TypeScript, `query_all()` in Python, and `QueryAllAsync()` in .NET. The binding policy and cross-SDK fixture pack are documented in [`sdk-surface-alignment.md`](./sdk-surface-alignment.md). The JSON fixture pack lives under `test/fixtures/sdk-contract/`; it is intentionally language-neutral so downstream SDKs can consume the same protocol, capability, result, unsupported-capability, and degraded-result scenarios. ## Canonical nouns | Type | What it is | | --- | --- | | `Protocol` | One of eighteen identifiers — shared canonical gRPC FeatureService transport (`grpc`), five GeoServices service types (`geoservices-feature-service`, `geoservices-map-service`, `geoservices-image-service`, `geoservices-geometry-service`, `geoservices-gp-service`), OGC API + STAC adapters (`ogc-features`, `ogc-tiles`, `ogc-maps`, `ogc-records`, `stac`), `wfs`, `wms`, `wmts`, `odata`, plus three MapLibre-native (`maplibre-vector`, `maplibre-raster`, `maplibre-geojson`). | | `Capability` | A coarse-grained protocol capability (`query`, `queryAggregate`, `spatialAggregate`, `queryExtent`, `queryObjectIds`, `queryRelated`, `applyEdits`, `attachments`, `render`, `tiles`, `sql`, `stream`, `pbf`, `connect`, `image`, `geometry`, `geoprocess`, `processes`). The canonical `Source` surface standardizes the query / edit / related / attachment / object-id subset today; `spatialAggregate`, `image` / `geometry` / `geoprocess` / `processes` are negotiated for indexed analytics, `Source.protocol()` escape hatches, and for the `IJobRun`-based OGC API Processes runner because their request shapes are too protocol-specific to belong on the unified query envelope. | | `Capabilities` | `ReadonlySet`. Set membership = first-party protocol support, whether the caller consumes it through a canonical `Source` method or the typed protocol escape hatch. Under `strict` (default) a missing capability throws `HonuaCapabilityNotSupportedError`. Under `degraded` only call sites with a defined fallback proceed (today: OGC `queryAggregate` and `queryExtent`); every other missing capability still throws. | | `SourceLocator` | Protocol-specific endpoint info (`url`, `serviceId`, `layerId`, `collectionId`, `tileMatrixSetId`, `styleId`, `typeName`, `entitySet`, `taskName`). Field-compatible with the server `SourceBinding.locator`; `tileMatrixSetId` / `styleId` carry OGC API Tiles / Maps route hints for downstream `SourceBinding` work tracked in [`source-binding-alignment.md`](./source-binding-alignment.md). | | `SourceDescriptor` | `{ id, protocol, locator, capabilities, schema?, attribution? }`. The serializable identity of one source. | | `Source` | Runtime handle. Methods: `query`, `queryAll`, `queryAggregate`, `queryExtent`, `stream`, `queryObjectIds`, `applyEdits`, `queryRelated`, `attachments` (namespace), `protocol` (typed escape hatch; `adapter` is the legacy alias). | | `Dataset` | Logical grouping of sources sharing identity. Methods: `source(id)`, `sourceIds()`, `isCompatible()`, `supportsFeature()`. | | `Query` | `{ where?, spatialFilter?, outFields?, orderBy?, pagination?, aggregation?, returnGeometry?, outSr?, signal? }`. | | `Result` | `{ features, exceededTransferLimit, totalCount?, aggregateRows?, extent?, fields?, degraded? }`. | | `SpatialAggregationRequest` / `SpatialAggregationResult` | Indexed spatial aggregation contract for large result sets. Requests carry `where`, `spatialFilter`, `viewport`, zoom/index-resolution hints, opaque index selection, summary specs (`category`, `histogram`, `range`, `count`, `sum`, `avg`, `min`, `max`), and optional `groupBy`. Results carry opaque indexed cells, grouped/totals summaries, backend index metadata, widget metadata, and progressive loading state. | | `EditEnvelope` | `{ adds?, updates?, deletes?, rollbackOnFailure?, signal? }`. Each add / update is a `CanonicalFeature` (attributes + optional geometry + optional id). | | `EditResult` | `{ added, updated, deleted, degraded? }` — one `EditOutcome` per requested operation. | | `EditWorkflowSession` | Form/edit session over one `Source`: projects field metadata and domains, exposes relationship / attachment capability states, stages feature and attachment edits, runs optimistic hooks, and returns a normalized `EditWorkflowSubmitResult`. | | `RelatedQuery` / `RelatedResult` | Canonical related-records request and response. Adapters that lack relationships (OGC, OData, ImageServer) throw rather than return empty groups. | | `AttachmentApi` | Namespace returned by `Source.attachments`. Methods: `query`, `list`, `add`, `update`, `delete`. Adapters that do not advertise `attachments` throw `HonuaCapabilityNotSupportedError` from each method so the namespace property is always present and capability negotiation stays uniform. | | `MapBinding` | `{ sourceId, layerIds, style?, minzoom?, maxzoom? }`. Maps onto `MapPackage.sourceBindings` + `MapPackage.mapSpec` server-side. The `@honua/sdk-js/runtime` module consumes a full `MapPackage` on the client — see [`maplibre-runtime.md`](./maplibre-runtime.md). | | `QueryTileSourceDescriptor` | Typed dynamic vector/query tile descriptor. It binds a canonical `Source` or source id to tile endpoint metadata, query/projection cache identity, fallback policy, and feature identity hooks. Runtime MapLibre helpers and the canonical `/query-tiles` server contract live in [`dynamic-query-tiles.md`](./dynamic-query-tiles.md). | ## Dynamic query tile server contract Dynamic query tiles have a server-side contract in addition to the client descriptor. The canonical route prefix is `/query-tiles`, with TileJSON, vector tile, and feature-detail routes under `/query-tiles/sources/{sourceId}`. The contract defines request parameters for filters, projection fields, output spatial reference, tile matrix set, extent, simplification tolerance, max features, cache partitioning, and cache busting. `@honua/sdk-js/contract` exports route builders, parser helpers, response types, and the `QUERY_TILE_SERVER_CONTRACT_VERSION` constant. The reusable fixture lives at `test/fixtures/sdk-contract/query-tile-server.v1.json` and is intended for Honua server implementation repos to consume in their own conformance tests. ## Capability negotiation Two policies, declared at `createDataset({ capabilityPolicy })`: - `strict` (default): `Source` operations whose required capability is missing throw `HonuaCapabilityNotSupportedError`. Callers can branch on `error.capability` and `error.protocol` to swap protocols, fall back, or surface the limitation to the user. - `degraded`: `Source` operations attempt a client-side fallback when the server cannot serve the capability natively. The result envelope carries `degraded: DegradedReason[]` documenting what was approximated and why. The capability matrix lives in [`protocol-capability-matrix.md`](./protocol-capability-matrix.md). Callers must pass the capability set they want enforced via `SourceDescriptor.capabilities` — per-source overrides (e.g. downgrading `queryAggregate` on a Feature Service whose metadata reports `supportsStatistics: false`) are the caller's responsibility for the GeoServices, OGC, STAC, WFS, and WMS adapters today. **OData is the first adapter to implement automatic metadata-driven downgrades**: the entity-set adapter lazily fetches `$metadata` on the first capability-gated method, parses `Capabilities.*` annotations, and intersects the declared capability set with what the server advertises. See [`protocol-capability-matrix.md`](./protocol-capability-matrix.md) under *OData* for the implementation details. Other adapters (GeoServices `supportsStatistics`, OGC `conformsTo`) follow the same pattern as follow-up work. The registry is intentionally broader than the current `Source` method list so downstream adapter tickets can negotiate `render` / `tiles` / `sql` / `queryObjectIds` / `spatialAggregate` / etc. without inventing a second capability vocabulary. `spatialAggregate` has no default protocol support today; sources should advertise it only when source-specific metadata confirms an indexed aggregation backend. Apps must treat returned cell ids and `index.model.id` as opaque, so H3, Quadbin, or a provider-specific grid can drive the same widgets. For multi-source compositions, use `intersectCapabilities` from `@honua/sdk-js/contract` to compute the **weakest** capability set across the participating sources before fanning a call out — the rule plus the partition-then-intersect pattern for per-operation reasoning lives in [`composition.md`](./composition.md). Adapters that emit `Result.degraded[]` populate `DegradedReason.sourceId` from `descriptor.id` so a fan-out can attribute each degradation back to the exact source that triggered it. ## Source factory ```ts doc-test=skip reason="partial excerpt requires application host context" import { createDataset, type SourceDescriptor } from "@honua/sdk-js/contract"; const dataset = createDataset({ id: "parcels", client, capabilityPolicy: "strict", sources: [ { id: "parcels-fs", protocol: "geoservices-feature-service", locator: { url: "...", serviceId: "Parcels", layerId: 0 }, capabilities: PROTOCOL_DEFAULT_CAPABILITIES["geoservices-feature-service"], }, ] satisfies SourceDescriptor[], }); const parcels = dataset.source("parcels-fs")!; const result = await parcels.query({ where: "STATE = 'CA'", pagination: { limit: 100 } }); ``` The built-in resolver handles `geoservices-feature-service`, `geoservices-map-service`, `geoservices-image-service`, `geoservices-geometry-service`, `geoservices-gp-service`, `ogc-features`, `ogc-tiles`, `ogc-maps`, `stac`, `wfs`, `wms`, `wmts`, and `odata`. MapLibre-native sources register through `CreateDatasetOptions.resolveSource`. OGC API Processes is a job runner rather than a queryable source — reach it through `HonuaClient.ogcProcesses().execute(...)` (returns the canonical `IJobRun`) instead of `Dataset.source()`. The five GeoServices factories cover the surface published in `honua-server/docs/gis/geoservices-rest-parity.md`: - `geoServicesFeatureSource` — FeatureServer (query, edits, related, attachments, object ids, replica/calculate/validateSQL/append/bins/estimate via `protocol()`). - `geoServicesMapServiceSource` — MapServer (read-only query family, related records; export/identify/find/legend/tile via `protocol()`). - `geoServicesImageSource` — ImageServer (raster catalog query, exportImage / identify / tile / legend via `protocol()`). - `geoServicesGeometryServiceSource` — Geometry Service (utility-only; query family throws, operations live behind `protocol()`). - `geoServicesGPServiceSource` — GP Service (utility-only; submitJob / jobStatus / cancelJob / jobResult via `protocol()`). The OGC API and STAC factories cover `docs/ogc-api.md`: - `ogcFeaturesSource` — OGC API Features (query, edits, object ids, stream; `queryAggregate` / `queryExtent` degrade client-side). - `ogcTilesSource` — OGC API Tiles (render-only; query family throws, `HonuaOgcTileset` / `HonuaOgcTiles` reachable via `protocol()`). - `ogcMapsSource` — OGC API Maps (render-only; same shape as Tiles). - `ogcRecordsSource` — OGC API Records metadata catalog search (`/collections/{catalogId}/items`, queryObjectIds, stream; Records-specific `q` / `type` / `externalIds` and raw response access via `protocol()`). - `stacSearchSource` — STAC API search (`/search` query, queryObjectIds, stream; cross-collection scope via `locator.collectionId`). The WMS / WMTS factories cover the OGC web-map services per `docs/protocol-capability-matrix.md`: - `wmsSource` — WMS 1.3.0 (render + tiles via `GetMap`; `query` via point-only `GetFeatureInfo`; raw multi-pixel `featureInfo()` and the per-layer service handles reachable via `Source.protocol("wms" | "wms-layer")`). - `wmtsSource` — WMTS 1.0.0 (render + tiles via RESTful tiles; query family throws; service / layer / tileset handles reachable via `Source.protocol("wmts" | "wmts-layer" | "wmts-tileset")`). `docs/wfs.md` documents the WFS 2.0 factory in the same shape: - `wfsSource` — WFS 2.0 (query, queryAll, queryExtent, queryObjectIds, applyEdits, stream; FES 2.0 emission for `Query.where` / `Query.spatialFilter`; raw GML / `` payloads via `protocol("wfs")`). The OData factory wraps an OData v4 entity set behind the canonical surface: - `odataSource` — query / queryObjectIds / stream / applyEdits first-party (POST/PATCH/DELETE; PUT is unsupported per the parity matrix and addressed by `PATCH` with the full canonical body). Dialect-specific `$batch` / `$apply` / `$search` / `$deltatoken` reach `HonuaOdataEntitySet` through `Source.protocol("odata")`. OData is the **first adapter** to lazily fetch service metadata (`$metadata`) and intersect the declared `Capabilities` set with what the server advertises through `Capabilities.*` annotations — see [`protocol-capability-matrix.md`](./protocol-capability-matrix.md) for the precedent and [`decisions/odata-library-selection.md`](./decisions/odata-library-selection.md) for the runtime-library posture. ## Edit Workflow Sessions `createEditSession({ source, kind, feature, metadata, optimistic })` is the SDK-level workflow contract for form and editor surfaces. It is intentionally layered over `Source.applyEdits`, `Source.queryRelated`, and `Source.attachments` rather than replacing the protocol adapters. The session resolves fields from `source.descriptor.schema.fields` plus caller-supplied domains, validates required / read-only / length / coded-value / range constraints before submit, and exposes `capabilities()` so unsupported `applyEdits`, `attachments`, and `queryRelated` states are explicit and testable. Relationship reads use `session.queryRelated(...)`; attachment reads and staged add / update / delete mutations use the same source attachment namespace. `submit()` sends the feature edit envelope first, then applies staged attachment mutations against the committed feature id when the backend returns one. Optimistic hooks run in this order: `optimistic.apply` before the remote edit, `optimistic.commit` on full success, and `optimistic.rollback` for failed or partial feature / attachment results. `EditWorkflowSubmitResult.failures` normalizes per-row errors from GeoServices, OGC Features, WFS, and OData into `validation`, `conflict`, `capability`, `transport`, `server`, or `partial-failure`. When a version / ETag / updated-at field is present (or supplied in `metadata.conflict`), conflict failures carry that information so hosts can present reload / merge / overwrite workflows without parsing protocol-specific error text. `createEditSketchWorkflow(...)` wraps the same session contract with UI-independent sketch state. It exposes explicit support for point, line, polygon, rectangle, circle, and buffer tools; dirty state; undo / redo / discard; attachment staging; validation; submit; and an optional annotation persistence hook that runs only after a successful edit commit. Unsupported sketch, attachment, relationship, conflict, or annotation persistence capabilities remain visible in the returned snapshot instead of being hidden by component state. `Source.queryAll()` and `Source.stream()` drain every page the server returns — the built-in adapters override the core helpers' 100-page default so a large `queryAll()` is not silently truncated. Callers who want a hard cap should paginate with `Query.pagination` (`offset` skips ahead; `limit` clips, and its meaning depends on the method): - `query()` — `limit` is the single-page record count. - `queryAll()` — `limit` is the total-row cap on the materialized result. The adapter sizes `pageSize` and `maxPages` from `limit` so the paging loop fetches at most `limit + 1` rows; the extra row lets the result stamp `exceededTransferLimit: true` when more records exist. - `stream()` — `limit` is the per-batch page size (not a global cap). Each yielded `Result` carries up to `limit` features; callers that want a global cap must stop iterating explicitly. ## Compatibility gating `Dataset.isCompatible()` calls `HonuaClient.checkCompatibility()` once and caches the result. `Dataset.supportsFeature(feature)` proxies to `HonuaClient.supportsFeature` for fine-grained checks. Both reuse the existing compatibility-gate workflow — no new wire calls were introduced. ## Protocol escape hatch `Source.protocol("geoservices-feature-service")` returns the underlying `HonuaFeatureLayer` instance (or `undefined` for the wrong kind). The accessor name is `protocol` because it surfaces protocol-specific operations — raw `where`, raw `outFields`, GeoServices `calculate` / `validateSQL` / replica / `queryBins` / `getEstimates` — that the canonical `Source` surface intentionally does not expose. The `adapter()` method is preserved as a legacy alias for callers written against the original ticket-23 surface; it returns the same instance. The `AdapterTypeMap` interface uses TypeScript declaration merging so adapter tickets can plug in their own kind → instance type mapping without touching this file. `grpc` is a canonical protocol id for the shared FeatureService transport and fixture pack; it is consumed through the client gRPC-Web transport rather than a `Source.protocol("grpc")` adapter handle in this package. ```ts doc-test=skip reason="partial excerpt requires application host context" declare module "@honua/sdk-js/contract" { interface AdapterTypeMap { "my-protocol": MyProtocolLayer; } } ``` The shipped map covers `geoservices-feature-service` → `HonuaFeatureLayer`, `geoservices-map-service` → `HonuaMapService`, `geoservices-map-layer` → `HonuaMapLayer`, `geoservices-image-service` → `HonuaImageService`, `geoservices-geometry-service` → `HonuaGeometryService`, `geoservices-gp-service` → `HonuaGeoprocessingService`, `ogc-features` → `HonuaOgcFeatureCollection`, `ogc-tiles` → `HonuaOgcTileset | HonuaOgcTiles`, `ogc-maps` → `HonuaOgcMaps | HonuaOgcCollectionMap`, `ogc-processes` → `HonuaOgcProcesses`, `stac` → `HonuaStacSearch`, `wms` → `HonuaWms`, `wms-layer` → `HonuaWmsLayer`, `wmts` → `HonuaWmts`, `wmts-layer` → `HonuaWmtsLayer`, `wmts-tileset` → `HonuaWmtsTileset`, `wfs` → `HonuaWfsFeatureType`, and `odata` → `HonuaOdataEntitySet`. The WFS root handle (capabilities cache, stored-query discovery) is reachable through `Source.protocol("wfs").root`. ## What downstream tickets must consume 1. New protocol adapters must implement `Source` and register either as a built-in `case` in `buildBuiltInSource` (the precedent followed by `wmsSource` / `wmtsSource` / `wfsSource` / `odataSource`) or through `CreateDatasetOptions.resolveSource`. They must declare their default capability set in `PROTOCOL_DEFAULT_CAPABILITIES` (this file owns that table — adapter PRs extend it). 2. Visual builder, exploration, and server-export tickets must consume `Dataset` / `Source` / `Query` / `Result` / `MapBinding` rather than the per-class request shapes (`QueryFeaturesRequest`, etc.). Per-class shapes are still available via `Source.adapter()` for legacy paths. 3. New error types must flow through `HonuaError` and `isHonuaError`. This ticket added `HonuaCapabilityNotSupportedError` and `HonuaExplorationContextError`. The first-party WMS / WMTS adapter ticket extended the union with `HonuaWmsCapabilitiesParseError` and `HonuaWmtsCapabilitiesParseError` so callers can classify XML parser failures through the same guard. ## Async operations: `IJobRun` Long-running server-side operations surface through the canonical `IJobRun` interface in `@honua/sdk-js/contract`. OGC API Processes, GeoServices REST GPServer, and the open `honua-io/geospatial-grpc` `ProcessService` all normalize onto the same lifecycle vocabulary: ```ts doc-test=skip reason="partial excerpt requires application host context" import type { IJobRun } from "@honua/sdk-js/contract"; const job: IJobRun = await client.ogcProcesses().execute({ processId: "buffer", inputs: { feature: someGeoJson }, mode: "async", }); const unwatch = job.watch((snap) => { console.log(snap.status, snap.progress); }); const { outputs } = await job.results(); unwatch(); ``` For app code that should not know which protocol is behind a workflow, use `HonuaProcessRunner`: ```ts doc-test=skip reason="partial excerpt requires application host context" const ogc = client.ogcProcessRunner(); const gp = client.geoprocessingRunner("Analysis", "OverlayFacilities"); const grpc = client.geospatialGrpcProcessRunner(processServiceClient); await ogc.execute({ processId: "buffer", inputs }); await gp.execute({ inputs: gpParameters, resultNames: ["outputLayer"] }); await grpc.execute({ plan: executionPlan, context: executionContext }); ``` `createOgcProcessesAdapter`, `createGeoServicesGpAdapter`, and `createGeospatialGrpcProcessAdapter` expose the same adapter contract for callers that construct protocol handles outside `HonuaClient`. The geospatial-grpc adapter is structural: it expects a generated `ProcessService` client with `validatePlan`, `dryRunPlan`, `submitJob`, `getJob`, `getJobResult`, and `cancelJob`, but this package does not vendor the `honua-io/geospatial-grpc` generated process proto directly. `IJobRun` exposes `id`, `type`, `status`, `progress`, `poll()`, `watch()`, `results()`, and `cancel()`. The OGC API Processes 1.0 status vocabulary (`accepted`, `running`, `successful`, `failed`, `dismissed`) is canonical. GeoServices `esriJobSubmitted` / `esriJobExecuting` / `esriJobSucceeded` / `esriJobFailed` / `esriJobCancelled` and geospatial-grpc `JobState` values (`JOB_STATE_RUNNING`, `JOB_STATE_COMPLETED`, `JOB_STATE_FAILED`, `JOB_STATE_CANCELLED`, etc.) translate onto it. Failed OGC runs reject `results()` with `HonuaJobFailedError`, whose `message` is populated from the server's `statusInfo.exception.message` when present and falls back to `statusInfo.message` otherwise (to match honua-server's `StatusInfo` DTO, which exposes only `message`). `cancel()` is idempotent against the two documented benign paths: "job gone" (404) returns the cached status, and the terminal race (409 "Cannot dismiss completed job" from honua-server) triggers a follow-up GET and returns the authoritative terminal status — but only if the poll confirms a terminal state. honua-server also emits 409 for "Dismiss could not be confirmed" (backend dismissal unconfirmed) and "Cancellation not supported" (backend lacks cancel support); both rethrow as `HonuaHttpError` so callers can branch or retry instead of seeing a fabricated success. Submitted processes are typed as `IJobRun`; `HonuaOgcProcessJobRun` is the implementation behind that interface and should not be the caller-facing contract. ## OGC API Tiles / Maps / Records / Processes / STAC The first-party OGC adapters live alongside `HonuaOgcFeatures`: | Conformance area | Entry point | Source protocol | Contract capabilities | | --- | --- | --- | --- | | OGC API Features | `client.ogcFeatures()` / `HonuaOgcFeatures` | `ogc-features` | `query`, `queryObjectIds`, `applyEdits`, `stream` | | OGC API Tiles | `client.ogcTiles()` / `HonuaOgcTiles`, `HonuaOgcTileset` | `ogc-tiles` | `render`, `tiles` (tileset-bound when locator includes `tileMatrixSetId`; root discovery handle otherwise) | | OGC API Maps | `client.ogcMaps()` / `HonuaOgcMaps`, `HonuaOgcCollectionMap` | `ogc-maps` | `render` | | OGC API Records | `client.ogcRecords()` / `HonuaOgcRecords`, `HonuaOgcRecordCollection` | `ogc-records` | `query`, `queryObjectIds`, `stream` | | OGC API Processes | `client.ogcProcesses()` / `HonuaOgcProcesses` | (no source — job runner) | `processes` from conformance negotiation, not `PROTOCOL_DEFAULT_CAPABILITIES` | | STAC API | `client.stac()` / `HonuaStacSearch` | `stac` | `query`, `queryObjectIds`, `stream` | OGC API Tiles and OGC API Maps are render-only — their `Source.query*` methods throw, and renderers reach the underlying class through `Source.adapter("ogc-tiles")` / `Source.adapter("ogc-maps")`. OGC API Records and STAC both flow through the canonical `Source.query()` path, but Records is metadata catalog search and STAC is asset/item search. OGC API Processes does not register as a `Source` because its inputs are not queryable; it produces `IJobRun` from `execute(...)`. ## WMS / WMTS web-map services The first-party OGC web-map adapters share the contract surface: | Service | Entry point | Source protocol | Contract capabilities | | --- | --- | --- | --- | | WMS 1.3.0 | `client.wms(serviceId)` / `HonuaWms`, `HonuaWmsLayer` | `wms` | `render`, `tiles`, `query` (point-only `GetFeatureInfo`) | | WMTS 1.0.0 | `client.wmts(serviceId)` / `HonuaWmts`, `HonuaWmtsLayer`, `HonuaWmtsTileset` | `wmts` | `render`, `tiles` | `Source.protocol("wms")` returns the service handle and `Source.protocol("wms-layer")` a layer-bound handle (when `locator.typeName` is set). WMTS exposes three handles — `Source.protocol("wmts")` (service), `"wmts-layer"` (layer-bound), and `"wmts-tileset"` (layer × style × tile-matrix-set bound). MapLibre integration ships through the runtime helpers `buildWmsRasterSourceSpec(descriptor)` / `buildWmtsRasterSourceSpec(descriptor)` from `@honua/sdk-js/runtime` — they emit a `raster` source spec without forcing callers to hand-assemble a `GetMap` URL or RESTful tile template. The style-spec resolver `createSources(client, style)` (from `@honua/sdk-js`) and `HonuaMap.getSource(name)` both produce the same `HonuaWms` / `HonuaWmsLayer` / `HonuaWmts` / `HonuaWmtsTileset` handles when a style declares a `honua-wms` / `honua-wmts` source type — the shared `src/style/wms-wmts-resolvers.ts` module owns the URL parsing and layer / tileset selection rules so the two surfaces never diverge. The WMS `LAYERS=` / `locator.typeName` / `spec.layers` value is parsed through the canonical `parseWmsLayerNames` helper (in `src/core/wms.ts`); the layer-bound `HonuaWmsLayer` handle is only returned for a single non-empty token, while multi-layer composites (`"a,b"`) stay on the service-level `HonuaWms` handle. See [`docs/protocol-capability-matrix.md`](./protocol-capability-matrix.md) for axis-order, dimension, legend, and TileMatrixSet notes. OGC conformance class identifiers are intentionally kept *internal*. `negotiateOgcCapabilities(protocol, conformsTo)` from `@honua/sdk-js/honua` translates a server-advertised `conformsTo[]` list into a canonical `Capabilities` set; downstream callers that want to gate on a specific extension (CQL2, transactions, etc.) use `hasOgcConformanceClass(...)` with a substring match. No OGC conformance class name appears as a primary SDK type, per the ticket constraint. ## Test coverage Conformance fixtures under `test/contract/` exercise the canonical surface against mock adapters for each protocol. Adding a new protocol adapter means adding a fixture there; the parametrized scenarios run unchanged. - `test/contract/conformance.test.ts` — cross-protocol parametric scenarios. Each new adapter registers a harness; the suite runs the same query / queryExtent / queryAggregate / stream cases against every harness. - `test/contract/odata-conformance.test.ts` — adapter-specific translation rules and escape-hatch surface (`metadata`, `batch`, `apply`, `search`, `delta`, `raw`). - `test/contract/ogc-conformance.test.ts` — the conformance-class → capability negotiation translation table. --- # File: docs/query-planner.md # Deterministic query planner `@honua/sdk-js/query-planner` is the first production slice of the execution planner described by the [north-star application-kernel decision](./decisions/north-star-sdk-application-kernel.md). It turns the current protocol-neutral `Query` plus an already-discovered `SourceDescriptor` into a versioned, serializable IR and an immutable explain plan. Explaining is synchronous and side-effect free: it does not fetch metadata or rows, mutate a renderer, or execute the query. The subpath is experimental while the remaining compiler and columnar slices land. It is intentionally not exported from the root barrel. ## Remote pushdown The first compiler targets an existing GeoServices FeatureServer query path, and the OGC API Features compiler targets a collection `/items` request. The compiled request is included in the plan so diagnostics, CLIs, agents, and renderers can inspect the same decision before execution. ```ts doc-test=skip reason="partial excerpt requires application host context" import { PROTOCOL_DEFAULT_CAPABILITIES } from "@honua/sdk-js/contract"; import { executeQueryPlan, explainQuery } from "@honua/sdk-js/query-planner"; const descriptor = { id: "incidents", protocol: "geoservices-feature-service", locator: { url: "https://demo.honua.io", serviceId: "incidents", layerId: 0 }, capabilities: PROTOCOL_DEFAULT_CAPABILITIES["geoservices-feature-service"], } as const; const plan = explainQuery({ descriptor, sourceVersion: "2026-07-10", authorizationScope: ["data:read"], query: { where: "status = 'open'", aggregation: { groupBy: ["severity"], metrics: [{ fn: "count", field: "OBJECTID", alias: "incidents" }], }, }, }); console.log(plan.fingerprint, plan.steps[0]?.compiled); // `source` is the matching Source from Dataset.source(...). Version and scope // are repeated so execution can reject a stale or differently-authorized plan. const execution = await executeQueryPlan(plan, source, { sourceVersion: "2026-07-10", authorizationScope: ["data:read"], }); console.log(execution.result.aggregateRows); ``` For an OGC API Features source, the same `explainQuery()` call selects `ogc-api-features-query-v1` from the descriptor protocol. Source-native `Query.where` is identified as CQL2 text; projection, sorting, pagination, CRS, and envelope-intersects filters compile to `properties`, `sortby`, `limit`/`offset`, `crs`, and `bbox` respectively: ```ts doc-test=compile import { PROTOCOL_DEFAULT_CAPABILITIES } from "@honua/sdk-js/contract"; import { explainQuery } from "@honua/sdk-js/query-planner"; const ogcPlan = explainQuery({ descriptor: { id: "parcels", protocol: "ogc-features", locator: { url: "https://example.test/ogc", collectionId: "parcels" }, capabilities: PROTOCOL_DEFAULT_CAPABILITIES["ogc-features"], }, query: { where: "status = 'active'", outFields: ["parcel_id", "owner"], orderBy: [{ field: "updated_at", direction: "desc" }], spatialFilter: { geometryType: "esriGeometryEnvelope", geometry: { xmin: -158.4, ymin: 20.5, xmax: -157.6, ymax: 21.8 }, }, pagination: { limit: 100 }, }, }); const firstStep = ogcPlan.steps[0]; if (!firstStep || firstStep.engine !== "remote") throw new Error("Expected a remote query step"); console.log(firstStep.compiled); // { compiler: "ogc-api-features-query-v1", collectionId: "parcels", // filter: "status = 'active'", filterLang: "cql2-text", ... } ``` The OGC compiler rejects non-envelope spatial filters, relationships other than envelope-intersects/intersects, non-EPSG:4326 or malformed bounds, portable geometry-suppression claims, and claimed remote aggregation. It never weakens those requests to a broader bbox query. Aggregation remains available through the explicit bounded-local policy below: the CQL2 filter and required-field projection stay remote while the exact aggregate runs only after the materialization ceilings pass. `Query.signal` never enters the IR or fingerprint. Supply cancellation only to `executeQueryPlan`. Source URLs in plan identity are stripped of credentials, query strings, and fragments; pass stable authorization scope identifiers, not tokens. ## Bounded degraded execution Fallback is disabled by default. When a source can query features but cannot push down aggregation, local execution requires both `capabilityPolicy: "degraded"` and an explicit `bounded-local` budget: ```ts doc-test=skip reason="partial excerpt requires application host context" const plan = explainQuery({ descriptor, capabilityPolicy: "degraded", fallback: { mode: "bounded-local", maxRows: 5_000, maxBytes: 8_000_000 }, estimates: { rows: 3_200, bytes: 4_100_000 }, query: { where: "status = 'open'", aggregation: { metrics: [{ fn: "count", field: "OBJECTID" }] }, }, }); ``` The plan pushes filters and required-field projection to the server. Its logical input limit is `maxRows`; the compiled wire request exposes the adapter-owned `maxRows + 1` overflow sentinel. Aggregation runs locally only after the row and byte ceilings pass. Planning rejects a known over-budget estimate. Execution rejects an overflow sentinel or transfer-limit response; it never silently reports a partial aggregate. `maxRows` is also capped by the SDK at `MAX_LOCAL_MATERIALIZATION_ROWS`. ## Determinism and plan validity - `QUERY_IR_VERSION` and `QUERY_PLAN_VERSION` are both `1.0` for this slice. - Objects serialize with sorted keys; array order remains semantically significant. SHA-256 fingerprints are identical in browsers, workers, and Node for the same descriptor, query, policy, versions, scope, and estimates. - Capabilities, authorization scopes, source/schema versions, CRS/query fields, and fallback budgets participate in the fingerprint. - `executeQueryPlan` verifies the fingerprint and the current source context. A changed plan, locator, version, capability set, or scope is rejected; the executor does not re-plan. - Feature/query/result caching remains bypassed. Opt-in materialization is a separate workflow. ## Deliberate first-slice boundaries This foundation does not close the full planner workstream. Follow-on slices must add typed semantic predicates and temporal windows, CQL2 JSON and richer OGC filter negotiation, WFS FES, OData, gRPC, and DuckDB compilers, spatial aggregation, joins/composition, columnar/worker execution, cache/freshness decisions, cost models, realtime snapshot/delta plans, receipts, golden cross-protocol fixtures, and shared CLI/renderer/MCP consumption. Histogram and time-series aggregation are rejected by this compiler rather than silently ignored. --- # File: docs/columnar-data-plane.md # Columnar batch transfer contract `@honua/sdk-js/query-planner` includes the first bounded data-plane slice for large query results. It defines a dependency-free Honua batch envelope and an ownership transfer primitive. Arrow/GeoArrow adapters may populate its buffers and metadata, but the envelope does not define a standalone Arrow layout and is not independently interoperable without the originating adapter's layout contract. It does not decode Arrow, execute queries, or create workers. The entrypoint is experimental while the broader planner, streaming, renderer, and realtime work in issue #394 is completed. ## Create a batch without copying payload bytes ```ts doc-test=skip reason="partial excerpt requires application host context" import { createColumnarBatch, leaseColumnarBatch } from "@honua/sdk-js/query-planner"; const coordinates = new Float64Array([21.31, -157.86, 21.44, -157.77]); const batch = createColumnarBatch({ id: "places:0", sequence: 0, rowCount: 2, schema: { id: "places-schema-v1", fields: [ { name: "geometry", type: { name: "geoarrow.point", parameters: { dimensions: 2 } }, nullable: false, metadata: { "ARROW:extension:name": "geoarrow.point" }, }, ], metadata: { crs: "EPSG:4326" }, }, buffers: [ { id: "geometry.values", field: "geometry", role: "geometry", data: coordinates.buffer, byteOffset: coordinates.byteOffset, byteLength: coordinates.byteLength, }, ], }); const lease = leaseColumnarBatch(batch); const receipt = await lease.transfer((message, transfer) => { worker.postMessage(message, { transfer: [...transfer] }); }); console.log(receipt.metrics); // { rows: 2, logicalBytes: 32, backingBytes: 32, // transferBytes: 32, copiedBytes: 0, ... } ``` Payload bytes are never copied. Schema and metadata descriptors are normalized and frozen, while batch creation retains each caller-provided `ArrayBuffer`. Multiple views over one buffer produce one transfer-list entry. Zero-byte backing buffers are valid; detached buffers are rejected using an attachment check that distinguishes the two cases. ## Memory ceilings Creation and transfer default to at most 1,000,000 rows and 64 MiB of unique backing allocations per batch. Descriptor normalization also defaults to at most 4,096 total schema fields, 8,192 metadata/type-parameter entries, 16,384 buffer views, and 1 MiB of UTF-8 descriptor identifiers, keys, and string values. The corresponding `maxRows`, `maxBackingBytes`, `maxSchemaNodes`, `maxMetadataEntries`, `maxBufferViews`, and `maxStringBytes` limits may be lowered or explicitly raised; there is no unbounded mode. Array widths are checked from one captured length before element access, and metadata keys are accumulated only to the configured bound. Normalization therefore fails before copying an oversized schema or descriptor list. Empty views and many views sharing one small backing allocation still count against `maxBufferViews`; they cannot bypass the CPU/heap ceiling by keeping `backingBytes` low. Limits supplied when a lease is created remain its transfer defaults, so a deliberately raised ceiling is not accidentally replaced by the global default. A transfer may tighten either ceiling; a pre-handoff limit failure leaves the lease owned and invokes no target. `backingBytes` sums `ArrayBuffer.byteLength` for every unique backing allocation. It does not claim operating-system resident or physical memory usage. This intentionally rejects a tiny view backed by an unexpectedly large buffer. `logicalBytes` is the sum of described view lengths and can differ when views overlap or share memory. `copiedBytes` is always zero for this API. ## Ownership, cancellation, and acknowledgement A `ColumnarBatchLease` starts in `owned` and can be transferred once. Live leases reserve their unique backing buffers, so the same batch—or another batch sharing one buffer—cannot be leased concurrently. Disposal releases the reservation. The SDK checks an `AbortSignal`, then performs a structured-clone ownership transfer itself before invoking the consumer. The original buffers are detached, the lease becomes `transferred`, and the consumer receives the SDK-owned clone plus its exact transfer list for an optional subsequent worker/port handoff. The optional promise returned by the consumer is an acknowledgement and backpressure boundary. Cancellation only applies before ownership handoff. If the consumer throws or acknowledgement fails, the error is `transport-failed`, but the lease stays `transferred`: the original buffers are already detached and retrying them would be unsafe. A limit or structured-clone failure before handoff leaves the lease owned. `dispose()` is idempotent for an owned or transferred lease and releases the lease's references. It cannot revoke other references held by the caller. ## Typed errors `HonuaColumnarTransferError.code` is one of: - `invalid-batch` - `row-limit-exceeded` - `memory-limit-exceeded` - `schema-limit-exceeded` - `metadata-limit-exceeded` - `buffer-view-limit-exceeded` - `string-limit-exceeded` - `already-leased` - `aborted` - `already-transferred` - `disposed` - `transport-failed` ## Deliberate remaining scope This slice does not claim the full #394 workstream. Arrow IPC decoding, filter/projection/reprojection/aggregation workers, multi-batch streaming, planner integration, renderer consumption, batch cache identity, realtime patches, CSP worker factories, and bounded conversion back to feature objects remain separate work. --- # File: docs/realtime-resume.md # Resumable realtime delivery The `@honua/sdk-js/realtime` subpath includes an opt-in, transport-neutral delivery gate for snapshot-plus-delta streams. It sits between a transport and the existing realtime reducer/store. The gate does not open or reconnect a network transport; it decides whether an event is safe to apply and whether a durable cursor can resume the exact accepted subscription. ```ts doc-test=skip reason="partial excerpt requires application host context" import { createRealtimeFeatureStore, createResumableRealtimeSubscription, } from "@honua/sdk-js/realtime"; const featureStore = createRealtimeFeatureStore(); const delivery = await createResumableRealtimeSubscription({ 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, apply: (event, signal) => { if (signal.aborted) return; featureStore.apply(event); }, }); transportObserver.next = (event) => { if (event.type === "snapshot" || event.type === "delta" || event.type === "upsert" || event.type === "delete") { void delivery.enqueue(event); } }; ``` ## Safety model A `honua.realtime-checkpoint@1` binds all resume positions to: - source identity and source version; - accepted query/plan fingerprint; - schema version; - an opaque authorization-scope fingerprint; - the last contiguous sequence plus available cursor, watermark, timestamp, and delta-token positions; - a bounded recent event-id window. Changing any bound identity produces `resnapshot-required`; the SDK never silently reuses the cursor. A new subscription without a compatible checkpoint also requires a replacement snapshot before deltas can apply. Adapters project a server-expired cursor, an unsupported resume mode, or a transport-detected gap through `delivery.requireResnapshot(...)`. That method invalidates queued work and accepts only a replacement snapshot next; it does not silently restart from the newest delta. After a baseline exists, only the next contiguous safe-integer sequence can advance it. Older sequences are reported as duplicates. Missing sequences, conflicting top-level/nested checkpoint fields, or reuse of a recent event id at a new sequence stop delivery and require a replacement snapshot. A replacement snapshot received during ordinary live delivery must advance the existing sequence; a stale or equal snapshot cannot regress the baseline. A replacement snapshot may establish a lower sequence only after an explicit `requireResnapshot(...)` transition, which marks a deliberate new recovery epoch. Accepted replacement snapshots reset the bounded event-id window. `enqueue` treats transport input as untrusted at runtime. Only snapshot, upsert, delete, and delta discriminators reach the consumer. The SDK captures event identity and resume metadata synchronously, projects only cursor, watermark, timestamp, sequence, and delta-token fields into the versioned checkpoint envelope, and drops unrelated fields before application or persistence. Caller mutation after enqueue therefore cannot change durable deduplication identity, and credentials or adapter metadata cannot hitchhike inside a saved checkpoint. The persisted recent-event-id history defaults to 256 entries and has an absolute 4,096-entry safety ceiling. Oversized loaded histories are rejected before their elements are scanned; accepted histories are copied directly from their configured bounded tail. This first gate requires a trustworthy monotonic sequence on every snapshot or delta. Cursor-only and delta-token-only protocols are not silently assigned a client sequence: their future adapters must obtain an ordering guarantee from the server or report resume as unsupported and resnapshot. ## Backpressure and cancellation `maxPendingEvents` bounds the applying event plus queued data events (default 64). Overflow aborts the active delivery, resolves queued work as `resnapshot-required`, and refuses more deltas. One replacement snapshot may wait behind an abort-ignoring consumer because it is the only recovery path; ordinary data remains bounded. Consumers should honor the supplied `AbortSignal` and make application idempotent, because JavaScript cannot undo a side effect already performed by a callback that ignores cancellation. Closing or aborting the gate prevents any later result from advancing its checkpoint. Consumer errors leave the prior checkpoint unchanged. Checkpoint save errors are explicit: the in-memory accepted position remains visible with `checkpointPersisted: false`, phase becomes `error`, and no further events are accepted by that gate. Without a `checkpointStore`, accepted checkpoints remain available in memory but `checkpointPersisted` stays false. Callers may persist them as part of their own atomic application transaction; the SDK does not claim durability it did not observe. Checkpoint persistence occurs after successful consumer application. This is an at-least-once boundary, not a transaction spanning an arbitrary application store and checkpoint database. Applications that require atomic exactly-once effects must persist their materialized state and checkpoint transactionally, or use event ids/versions to make replay idempotent. ## Scope and remaining work This is the first production slice of issue [#393](https://github.com/honua-io/honua-sdk-js/issues/393), not completion of the full workstream. It does not claim: - automatic SSE, WebSocket, OData-delta, or protocol-feed reconnection; - cursor-only protocol adaptation where no trustworthy ordering sequence is available; - server support for cursor retention or expiry negotiation; - renderer, cache, columnar-batch, or offline-store patch integration; - lag/retry transport telemetry or a shared transport conformance suite. Adapters should declare unsupported resume behavior before subscription when the protocol can determine it. Expired server cursors must be projected as an explicit replacement-snapshot transition rather than fixture fallback or an unverified continuation. --- # File: docs/plugin-manifest-certification.md # Plugin manifest and certification contract The experimental `@honua/sdk-js/plugin` entrypoint is the first bounded slice of the plugin SDK tracked by [issue #392](https://github.com/honua-io/honua-sdk-js/issues/392). It lets a third-party package describe its compatibility and authority boundary as inert JSON, then produces a deterministic report for a specific host. This slice does **not** import plugin code, install packages, maintain a global registry, or pass credentials to an extension. A certified manifest means only that its declaration is well formed and compatible with the supplied host snapshot. Behavioral fixtures, cancellation, retries, cleanup, performance, runtime registration, and support-program review remain future certification phases. ## Authoring a manifest ```ts doc-test=compile import { HONUA_PLUGIN_API_VERSION, HONUA_PLUGIN_MANIFEST_VERSION, certifyHonuaPluginManifest, type HonuaPluginManifest, } from "@honua/sdk-js/plugin"; const manifest = { manifestVersion: HONUA_PLUGIN_MANIFEST_VERSION, id: "com.example.cloud-tiles", version: "1.0.0", kind: "protocol", package: { name: "@example/honua-cloud-tiles", entrypoint: "./plugin.js" }, compatibility: { pluginApi: HONUA_PLUGIN_API_VERSION, minimumSdk: "0.1.0-beta.0", maximumSdkExclusive: "0.2.0", environments: ["browser", "worker"], }, capabilities: ["tiles"], requestedGrants: { networkOrigins: ["https://tiles.example.com"] }, data: { cache: "memory", freshness: "ttl", authentication: "none", provenance: "preserved", mutation: "none", realtime: "none", }, lifecycle: { initialization: "explicit", disposal: "required" }, support: "community", } as const satisfies HonuaPluginManifest<"protocol">; const host = { pluginApi: HONUA_PLUGIN_API_VERSION, sdkVersion: "0.1.0-beta.0", environment: "browser", grants: { networkOrigins: ["https://tiles.example.com"] }, }; // The trust boundary accepts JSON text, never executable object values. const report = certifyHonuaPluginManifest(JSON.stringify(manifest), JSON.stringify(host)); ``` `JSON.stringify` is appropriate for trusted application-owned literals like the example above. Do not import an untrusted plugin module and stringify its exports: serialization itself can run getters or Proxy traps before the SDK is called. Read third-party manifest bytes as text (for example, from the package file or an HTTP response) and pass that text directly to the validator. The report contains no time, machine path, or random identifier, so the same manifest and host snapshot serialize identically. Its `manifest` and `host` blocks contain the complete canonical snapshots plus SHA-256 fingerprints; changing an entrypoint, capability, data semantic, requested authority, peer, grant, environment, API version, or SDK version changes the corresponding fingerprint. CI can reject when `status` is `"rejected"` and archive the deeply frozen JSON as certification evidence without permitting manifest/host swaps. The top-level `sha256` covers every other report field, including both complete snapshots, their hashes, status, checks, and diagnostics. It is an integrity receipt for externally archived evidence, not a signature or proof of issuer. ## Compatibility policy - `manifestVersion` and `pluginApi` are exact versions. Unknown versions fail closed; consumers must not guess how to reinterpret them. - SDK and peer bounds use exact SemVer values rather than arbitrary range expressions. `minimumSdk` is inclusive and `maximumSdkExclusive` is optional. - Prerelease ordering follows SemVer, so `0.1.0-beta.2` satisfies a `0.1.0-beta.0` minimum but does not satisfy `0.1.0`. - SemVer parsing is linear-time over ASCII input and comparison uses SemVer's deterministic ASCII identifier order rather than the process locale. - Environment and peer checks use only the explicit host snapshot. A missing required peer rejects certification; a missing optional peer is a warning. - This experimental API is not yet the GA compatibility promise requested by #392. Promotion requires the remaining runtime and independent-kit phases. ## Security boundary - Network grants are exact HTTPS origins. Wildcards, paths, embedded credentials, and non-TLS origins are rejected. - Credential grants contain scope identifiers only—never tokens, passwords, headers, environment-variable names, or credential values. - Persistent data requires scoped storage. Declared mutation requires an explicit mutation grant. Authenticated access requires credential scopes. - Capability semantics also fail closed. `protocol:edit` and `source-format:write` require both `data.mutation: "explicit"` and a mutation grant; `cache:write` and `cache:invalidate` require persistent-cache semantics and scoped storage. The full machine-readable mapping is exported as `HONUA_PLUGIN_CAPABILITY_REQUIRED_GRANTS`. - Certification fails if the application grant set is weaker than the plugin request. A report is not itself an enforcement mechanism; the future host runtime must inject only the certified grants and must never expose ambient credentials or mutation authority. - The package entrypoint is repeatedly percent-decoded to a stable form before validation, normalized in the certified snapshot, and rejected on encoded or literal traversal, absolute escape, backslashes, query/fragment suffixes, or malformed/excessive encoding. This API never resolves or executes it. - Manifest and host inputs must be JSON text. Raw objects—including accessors, proxies, custom prototypes, symbols, functions, and other executable or noncloneable values—are rejected from their primitive `typeof` result before any reflection or user code can run. A side-effect-free lexical pass enforces text, depth, and node-count bounds before `JSON.parse` materializes values. Validation and fingerprinting then use only the parser-created, detached, deeply frozen snapshot; caller objects are never reflected on or returned. ## Extension inventory The closed `HONUA_PLUGIN_KINDS` and `HONUA_PLUGIN_CAPABILITIES` registries cover protocol, source/format, renderer, auth, geocoder/routing, analysis, style, cache, and realtime declarations. Capability names are kind-specific so a manifest cannot inflate its advertised surface with unknown strings. The manifest also records support status and cache, freshness, auth, provenance, mutation, realtime, peer, environment, and disposal semantics for inventory tools. ## Remaining work in #392 - typed lifecycle hooks and explicit per-application registration/DI; - behavioral conformance fixtures for semantics, cancellation, retries, cleanup, bundle metadata, and performance; - an independently installable runner/CLI and signed or golden reports; - an external-style plugin proving runtime registration without core imports; - deprecation, naming, support, security-review, and certification governance. --- # File: docs/deckgl-adapter.md # deck.gl binary adapter (experimental) `@honua/sdk-js/deckgl` is a renderer-neutral boundary between Honua plan/source identity and deck.gl binary layer data. It is an experimental first slice of [#388](https://github.com/honua-io/honua-sdk-js/issues/388), not the complete GPU analytics workstream. The adapter currently projects scatterplot data from caller-owned typed arrays. It does not convert to GeoJSON or create one object per feature. Array views are forwarded to deck.gl unchanged and `projection.metrics.copiedBytes` is always zero. Default ceilings reject more than 1,000,000 rows, 32 attributes, 64 forwarded properties, or 256 MiB of unique backing allocations before a deck.gl layer is constructed. The adapter reads foreign request, data, identity, attribute, and property descriptors once into a bounded frozen snapshot. Typed-array byte length, offset, component width, and backing allocation metrics come from JavaScript intrinsics rather than overridable getters. Attribute offsets and strides must be component-aligned, and `normalized`, when present, must be boolean. ## Optional peer deck.gl is not part of the root SDK dependency graph: ```sh npm install @deck.gl/layers ``` Load the peer lazily or inject the constructor from an existing deck.gl install: ```ts doc-test=compile import { createDeckGlAdapter, loadDeckGlPeers } from "@honua/sdk-js/deckgl"; const adapter = createDeckGlAdapter({ peers: await loadDeckGlPeers() }); ``` Injecting peers is useful for import maps, custom builds, and tests. The lazy loader never chooses a CDN and reports `HonuaDeckGlAdapterError` with code `missing-peer` when the package or required export is unavailable. ## Binary projection and picking identity ```ts doc-test=skip reason="partial excerpt requires application host context" const positions = new Float32Array([157.85, 21.3, 157.86, 21.31]); const featureIds = new Uint32Array([301, 302]); const projection = adapter.project({ layer: "scatterplot", layerId: "incidents", data: { length: 2, attributes: { getPosition: { value: positions, size: 2 }, }, }, identity: { sourceId: "incidents-live", planId: "plan:sha256:…", sourceVersion: "42", featureIds, }, props: { radiusUnits: "meters" }, }); projection.selectionForPick(1); // { sourceId, planId, sourceVersion, featureId: 302, rowIndex: 1 } ``` `featureIds` is copied once into a private bounded scalar array at projection construction. Picks never reread caller-owned identity or row-count objects, so later caller mutation cannot change selection identity. This identity copy is separate from the binary payload; geometry and attribute buffers remain zero-copy. A caller can map the pick result into exploration/selection state without relying on unstable deck.gl object identity. Mounting uses a small host contract so standalone Deck instances and MapLibre overlay owners can retain control of their own layer collections: ```ts doc-test=skip reason="partial excerpt requires application host context" const mounted = projection.mount(host); // host implements addLayer/removeLayer mounted.dispose(); adapter.dispose(); // also disposes every still-owned mount ``` Successful removal is idempotent. If a host removal throws, the handle stays owned and reports `dispose-failed`; calling `mounted.dispose()` or `adapter.dispose()` again retries it. Mount registration happens before the foreign `addLayer` callback, so synchronous adapter disposal rolls the newly added layer back before `mount()` returns. ## Diagnostics and boundaries `DECK_GL_CAPABILITIES` is the capability truth for this contract version. Scatterplot is `gpu-binary`; feature path/polygon, vector tile, H3, Quadbin, heatmap, cluster, contour, and trips are explicitly `not-implemented`. The projection diagnostic reports the chosen strategy, input-array precision, fidelity, and absence of an implicit fallback. Unsupported or malformed paths throw a typed error rather than materializing feature objects or silently downgrading. Remaining #388 work includes normative Arrow/GeoArrow mappings after the columnar data-plane contract lands, indexed and aggregate layer families, realtime buffer patch/rebuild rules, direct MapLibre overlay and standalone Deck hosts, WebGPU boundaries, and the million-feature browser benchmark against [#387](https://github.com/honua-io/honua-sdk-js/issues/387). --- # File: docs/cesium-entity-adapter.md # Experimental Cesium entity adapter `@honua/sdk-js/scene-workspace` exposes an experimental accepted-plan workflow for projecting a canonical `Source` query into a Cesium `EntityCollection`: ```ts doc-test=skip reason="partial excerpt requires application host context" import { explainQuery } from "@honua/sdk-js/query-planner"; import { mountSourceToCesium } from "@honua/sdk-js/scene-workspace"; const plan = explainQuery({ descriptor: source.descriptor, query: { pagination: { limit: 5_000 }, returnGeometry: true, outSr: 4326, }, sourceVersion, schemaVersion, authorizationScope, }); const mounted = await mountSourceToCesium(viewer.entities, source, plan, { sourceVersion, schemaVersion, authorizationScope, time: { startField: "observed_at", endField: "expires_at" }, verticalDatum: "ellipsoidal-wgs84", }); await mounted.refresh(); mounted.dispose(); ``` The core projection (`projectSourceToCesium`) does not import Cesium. Mounting accepts either a minimal injected Cesium module or async loader; if neither is provided, the optional `cesium` peer is imported lazily. Importing the entrypoint in Node/SSR therefore does not initialize a browser or WebGL runtime. ## Supported slice - One accepted, remote `query` step whose executable query exactly matches the canonical IR, with explicit geometry and a positive row limit no greater than `maxEntities` (10,000 by default). - Explicit WGS84 output (`outSr: 4326`). Coordinates are never silently reinterpreted or reprojected. - Point, single-part line, and single-ring polygon geometries in GeoJSON or common Esri shapes. Finite Z values require an explicit `verticalDatum: "ellipsoidal-wgs84"`; otherwise the feature is omitted with a stable fidelity diagnostic. - Stable entity identity from `featureIdField` or the source primary key. - Attributes are copied into deeply frozen JSON-like scalar, dense-array, and plain-object snapshots. Dates, class instances, sparse arrays, accessors, and other non-JSON values omit the feature with an unsupported-fidelity diagnostic rather than retaining mutable caller-owned objects. - Optional offset-bearing ISO instants with at most millisecond precision, or integer Unix epoch-millisecond start/end attributes, mapped to Cesium availability intervals. Ambiguous local-time strings and precision-losing timestamps are omitted with a fidelity diagnostic. - Serialized snapshot refresh, cancellation checks before renderer mutation, reentrant-disposal guards, rollback attempts, deterministic cleanup, and retryable failed disposal. - Stable diagnostics for transfer-limited results, source degradation, missing identity, unsupported geometry, invalid time intervals, and snapshot rebuilds. Unsupported features are omitted with `fidelity: "unsupported"`; they are not rendered as plausible substitutes. Invalid plans, CRS, limits, and adapter options fail before mounting. ## Deliberate non-goals for this slice This is not completion of issue #395 and is not yet a beta Cesium adapter. Terrain and imagery providers, glTF/models, point clouds and 3D Tiles continue through the existing scene primitive adapter. Multi-part geometry and polygon holes, vertical datum transforms, styling, clustering, streaming/tiled execution, live deltas, camera/selection/filter synchronization, attribution UI, asset authorization/caching, browser examples, and WebGL leak/performance certification remain future work. Refresh currently declares an `entity-snapshot` rebuild boundary. Stable IDs are preserved across rebuilds, but this first slice does not claim fine-grained property or sampled-position delta application. --- # File: docs/offline-regions.md # Downloadable offline regions (experimental) `@honua/sdk-js/offline` is the first bounded slice of issue [#396](https://github.com/honua-io/honua-sdk-js/issues/396). It defines a versioned manifest and a storage-neutral download coordinator. It does not make the broader local-first feature complete. ```ts doc-test=skip reason="partial excerpt requires application host context" import { createOfflineRegionManifest, downloadOfflineRegion, } from "@honua/sdk-js/offline"; const manifest = await createOfflineRegionManifest({ name: "Field area", sourceId: "incidents", endpoint: "https://example.test/FeatureServer/0", authorizationScopeFingerprint: currentAclFingerprint, bounds: { minX: -158.3, minY: 21.4, maxX: -157.6, maxY: 21.8, crs: "EPSG:4326" }, minZoom: 8, maxZoom: 14, sourceVersion: "source-v3", schemaVersion: "schema-v7", planVersion: "plan-v2", observation: { state: "live", observedAt: "2026-07-10T10:00:00Z" }, resources: plannedResources, }); const receipt = await downloadOfflineRegion(manifest, { store: applicationStore, load: applicationResourceLoader, logicalQuotaBytes: 512 * 1024 * 1024, signal: abortController.signal, onProgress: renderProgress, }); ``` ## Contract guarantees - Manifest identity is deterministic over normalized content. Resource ids, attribution ids, and object keys use locale-independent code-unit ordering. - URL credentials and recognized signed/auth query parameters are removed. Only a domain-separated SHA-256 digest of the caller's authorization scope fingerprint is persisted. - Every resource has an exact logical byte length and required SHA-256 digest. Loader output is copied into coordinator-owned memory before hashing, progress, or writing, so later loader mutation cannot alter committed bytes. Each resource also carries source, schema, plan, and attribution identities inherited from the manifest unless the planner supplies more specific versions. Snapshot observation, validity, expiration, and HTTP validators remain explicit instead of making cached bytes appear live. - Untrusted manifests are synchronously normalized into an owned snapshot before any hash or adapter await. Plain shapes, dense arrays, resource/attribution/ metadata counts, logical bytes, and incremental UTF-8 string bytes are bounded before copying, sorting, or canonical JSON construction. - Quota is explicitly **logical payload-byte quota**: it sums declared resource payload lengths and does not claim physical disk occupancy, unique backing bytes, compression, deduplication, or store overhead. Admission is deterministic: expired regions, then least-recently-used regions, then code-unit id order. Pinned regions are never automatically evicted. - The injected transaction stages evictions and writes, then publishes them atomically with the manifest and receipt. Commit compares the inventory revision used for planning and independently enforces logical quota in the same atomic mutation; a concurrent winner makes the loser return typed `inventory-changed` and roll back. Storage implementations must copy bytes before `write()` resolves and satisfy this CAS/atomicity contract. - Progress is explicit across planning, download, write, commit, and completion. A successful receipt reports `integrity: "verified"` and `quotaAccounting: "logical-payload-bytes"`. The manifest contains logical resource ids, not request URLs. The injected loader may resolve short-lived signed URLs or authorization at download time; those values never cross the persistent-store boundary. ## Non-goals and remaining work This slice intentionally provides no IndexedDB or service-worker adapter, no network reachability policy, no resumable partial transaction, no encryption policy, no storage schema migration, no cached query/read integration, and no local edit queue or replica conflict replay. Those remain required before issue #396 can satisfy its GA acceptance criteria. This entrypoint is `@experimental` and subpath-only so the root and browser bundles do not absorb it. --- # File: docs/agent-safety.md # Safe agent plan boundary `@honua/sdk-js/agent-safety` is an experimental, deterministic trust boundary between untrusted plan proposals and host-owned effect execution. It validates JSON-compatible structured data, produces an immutable dry run and effect budget, binds a reviewer signature to the exact plan and policy, revalidates current source context, and signs/verifies execution evidence. The module never invokes a model, tool, source, renderer, subscription, or job. An accepted approval is evidence for a host executor; it is not an executor. `@honua/sdk-js/agent-tools` remains the runtime action adapter, and `@honua/sdk-js/query-planner` remains the source of query-plan fingerprints. ## Plan and policy Every step declares its tool, effect, fields, row and byte ceilings, exact query plan fingerprint, canonical parameters digest, canonical operation-input digest, and source binding. The operation digest is recomputed from the visible tool, effect, source ID, query-plan identity, fields, and parameters digest, so those claims cannot disagree. The source binding includes schema/source versions, stable authorization-scope identifiers, data mode, observation time, attribution, and credential-free citations. The plan also identifies its actor and can identify the proposing provider/model. Do not put prompts, credentials, tokens, or tool arguments in this envelope. ```ts doc-test=skip reason="partial excerpt requires application host context" import { dryRunAgentPlan, issueAgentApproval, verifyAgentStepAuthorization, } from "@honua/sdk-js/agent-safety"; const policy = { allowedTools: ["query"], // Omission is deliberately read-only. Other effects require explicit opt-in. allowedEffects: ["read"], sources: { incidents: { fields: ["OBJECTID", "status"], authorizationScope: ["incidents:read"], schemaVersions: ["schema-7"], sourceVersions: ["snapshot-9"], dataModes: ["live"], maxProvenanceAgeMs: 60_000, citationOrigins: ["https://data.example.test"], citationResourcePrefixes: ["/incidents"], }, }, maxSteps: 1, maxRows: 500, maxBytes: 2_000_000, maxFieldsPerStep: 16, maxAuthorizationScopesPerSource: 8, maxCitationsPerSource: 4, maxOperationParameterBytes: 16_384, maxOperationParameterNodes: 256, maxOperationParameterDepth: 8, }; const dryRun = dryRunAgentPlan(untrustedPlan, policy, { now: "2026-07-10T20:00:00.000Z", }); // The signer is supplied by a trusted host. Keep keys out of browser bundles. const approval = await issueAgentApproval( dryRun, policy, { id: "approval-42", approver: "reviewer@example.test", issuedAt: "2026-07-10T20:00:00.000Z", expiresAt: "2026-07-10T20:05:00.000Z", maxRows: 100, // single-step shorthand; may narrow, never widen }, hostSigner, { now: "2026-07-10T20:00:00.000Z", maxClockSkewMs: 5_000 }, ); // Immediately before an effect, compare exact current bindings and operation // parameters, then atomically consume the single-use approval step. const authorization = await verifyAgentStepAuthorization( dryRun, policy, approval, hostVerifier, { sources: { incidents: currentIncidentSourceBinding } }, "query-incidents", exactQueryPlanInput, hostAtomicApprovalUseStore, ); // Execute only authorization.operation (the frozen validated snapshot), never // the original mutable exactQueryPlanInput object. ``` `dryRunAgentPlan` rejects unknown properties, accessors, non-plain objects, invalid or duplicate values, non-canonical timestamps, credentials in citation URLs, conflicting bindings, unknown tools/sources, operation-input drift, disallowed effects or fields, scope/version/data-mode drift, stale provenance, and row/byte overflow. Its default effect allowlist is only `read`. Foreign policy data is descriptor-snapshotted and frozen exactly once per public operation. That same snapshot supplies the signed policy digest, dry-run revalidation, freshness check, and parameter limits. Reflection errors are reported as `HonuaAgentSafetyError`; a raw Proxy trap error is not exposed. Policy ceilings are themselves capped by `AGENT_SAFETY_HARD_LIMITS`. Collection lengths are rejected before indexed descriptors are read, and operation JSON is bounded by node count, depth, object/array count, and cumulative UTF-8 bytes. The same cumulative pre-signature budget applies to dry runs, approval requests, approval step arrays, current contexts/source maps, execution evidence, consumption records, and receipts—including both property-key and value bytes. ## Signatures, cancellation, and receipts Signer/verifier interfaces carry an algorithm and key ID and receive canonical JSON. Hosts can back them with WebCrypto, KMS, HSM, or a remote same-origin signing service. Approval and receipt verifiers require the configured algorithm/key ID to match the envelope. Signatures cover the canonical payload; SHA-256 envelope digests provide deterministic identity and diagnostics. All operations accept `AbortSignal`. Cancellation is checked before validation, across awaited signer/verifier boundaries, and before a successful value is returned. Aborted work cannot return a usable approval or receipt. Approval envelopes are explicitly single-use per step. The required `AgentApprovalUseConsumer` must atomically consume the approval-digest/step key and return an unforgeable host record containing a nonce, consumption time, opaque authentication token, and exact approval/step/input binding. The SDK immediately asks the same store to verify that record. This host-owned replay store is the concurrency boundary that prevents duplicate mutation, publish, share, subscription, and job effects. Signed approvals contain a row/byte allocation for every step. Multi-step approvals must use `stepLimits` when narrowing; an ambiguous aggregate-only narrowing is rejected. Step authorization returns a copy with those narrowed limits, not the wider proposed limits. After a separately authorized host executor finishes, it can call `issueAgentExecutionReceipt` with bounded outcome evidence. The helper first re-verifies approval and current context, refuses evidence beyond approved row/byte ceilings, and signs the plan, policy, source bindings, approval, step ID, operation-input digest, atomic-use digest, outcome, result digest, and completion time. `verifyAgentExecutionReceipt` repeats those checks deterministically and rejects any modified field. Receipt issuance and verification require a host consumption verifier; public digests alone cannot create a receipt. The authenticated consumption record and token are included under the receipt signature. Receipt verification evaluates the historical approval at the signed completion time, so an authentic receipt remains verifiable after approval expiry. Supply the bound historical source context, not newly discovered current metadata. The API precondition is JSON-compatible structured data produced by JSON parsing or an equivalent structured clone. Indexed and object accessors are rejected without invocation. JavaScript `Proxy` traps are executable by language-level reflection itself and cannot be made side-effect free by a portable library; do not pass Proxies across this boundary. When reflection nevertheless fails, the failure is typed and no partially normalized authority escapes. Citation URLs must be HTTPS and contain no user info, query, or fragment. Paths are repeatedly percent-decoded within a fixed bound, Unicode-normalized, checked for traversal, and canonicalized before identity hashing. Every citation must match the source policy's exact origin and decoded resource-prefix allowlist. The SDK does not claim to recognize secrets embedded in arbitrary opaque path text; hosts prevent those paths by choosing narrow resource IDs or prefixes. ## Deliberate boundaries This is one production vertical slice of #397, not completion of that XL workstream. It does not translate natural language, run tools, provide a model adapter, manage signing keys, parse query predicates to infer field use, perform compensating actions, implement an audit sink, or establish SDK/CLI/MCP execution parity. Hosts must declare every field a step may read or write; query compiler integration can automate that declaration in a later slice. --- # File: docs/decisions/north-star-sdk-application-kernel.md # North-star SDK application-kernel contract Status: **accepted for review** on 2026-07-10 for [`honua-sdk-js#386`](https://github.com/honua-io/honua-sdk-js/issues/386). Merging this decision accepts the product boundary and TypeScript direction; it does not claim that the proposed facade is implemented. ## Decision summary Honua will be a **universal geospatial application kernel**, not a proprietary renderer and not an application shell: > Connect to a spatial source, understand it automatically, explain and execute > the best available plan, render it through an interchangeable engine, and > automate the same plan under explicit policy. The small front-door vocabulary is: 1. `createHonua()` creates one resource owner and policy boundary. 2. `honua.connect(locator)` discovers an endpoint or static asset and returns a connection. 3. `connection.inspect()` returns schema, effective capabilities, diagnostics, freshness, and provenance. 4. `connection.explain(query)` returns a serializable execution plan without reading result data. 5. `connection.query(queryOrPlan)` executes either a query or a previously inspected plan. 6. `connection.mount(target, options)` projects the same source/plan and linked state through an optional renderer adapter. 7. Every owned handle has idempotent `dispose()` and `Symbol.asyncDispose`. `Dataset -> Source -> Query -> Result` remains the protocol-neutral semantic contract. The kernel is an orchestration facade over it, not a replacement. Focused subpaths remain the advanced API and escape hatch. ## Product boundary ### The kernel owns - URL and static-asset classification, endpoint-layout discovery, metadata caching, and effective capability negotiation. - Resource ownership, cancellation, diagnostics, provenance, and operation IDs. - A protocol-neutral query intermediate representation, plan selection, and execution receipts. - Renderer adapter lifecycle and renderer-fidelity reporting. - Policy hooks used by people, applications, and agents to execute the same plans. - Stable extension seams for protocol, loader, renderer, auth, cache, realtime, and analysis plugins. ### The kernel does not own - Raster/vector/3D rendering engines. MapLibre, deck.gl, and Cesium remain optional peers maintained by their respective projects. - Application shells, dashboards, widget libraries, Studio, Operator, or hosted-product administration. Those remain in `@honua/app-platform` or product repositories, consistent with the accepted [1.0 scope split](./scope-split-and-1.0.md). - An opaque AI execution path. Agent prompts must compile to the same typed, inspectable plan used by human-authored code. - Silent protocol emulation. Fallback and fidelity loss are plan steps and diagnostics; unsupported operations remain typed failures. - Ownership of sample-gallery prose or a second copy of example code in the website repository. ## Public TypeScript shape This section fixes names, ownership, and behavior. Exact helper overloads can be refined in implementation issues only when they preserve these invariants. The compile-only contract is committed under [`test/design/north-star-api/`](../../test/design/north-star-api/). ### Kernel and connection ```ts doc-test=skip reason="partial excerpt requires application host context" interface HonuaKernel extends AsyncDisposable { readonly diagnostics: DiagnosticChannel; connect>( locator: string | URL | ConnectLocator, options?: ConnectOptions, ): Promise>; dispose(): Promise; } interface HonuaConnection extends AsyncDisposable { readonly id: string; readonly dataset: Dataset; readonly sourceDescriptors: readonly SourceDescriptor[]; readonly diagnostics: DiagnosticChannel; inspect(options?: { refresh?: boolean; signal?: AbortSignal }): Promise; source(id?: SourceId): Source; explain( query: Readonly>, options?: ExplainOptions, ): Promise>; query( queryOrPlan: Readonly> | ExecutionPlan, options?: QueryOptions, ): Promise>; mount(target: string | Element | object, options: MountOptions): Promise; dispose(): Promise; } ``` `createHonua({ plugins })` accepts versioned lightweight plugin descriptors. Static/analytics engines such as GeoParquet/DuckDB are activated by importing a focused plugin factory and injecting its peer module; `connect()` never reaches out to a CDN or loads a heavy engine by surprise. The complete plugin lifecycle and certification metadata are owned by #392. `connect()` completes baseline discovery before resolving. A layer/collection URL has one default source. A service/catalog root may discover many; calling `source()`, `query()`, `explain()`, or `mount()` without a source then throws a typed ambiguity error listing valid source IDs and recovery guidance. It never chooses the first collection silently. `connection.source(id)` returns the existing `Source`. Advanced callers keep `Source.protocol(kind)` for typed raw protocol access. The legacy `Source.adapter()` alias is not promoted into the facade. ### Inspection ```ts doc-test=skip reason="partial excerpt requires application host context" interface ConnectionInspection { readonly id: string; readonly endpoint: string; readonly defaultSourceId?: SourceId; readonly sources: readonly SourceInspection[]; readonly observation: Observation; readonly diagnostics: readonly KernelDiagnostic[]; } interface SourceInspection extends SourceDescriptor { readonly discovery: "metadata" | "declared" | "inferred" | "unavailable"; readonly title?: string; readonly geometryType?: string; readonly crs?: string; readonly observation: Observation; } ``` The snapshot is immutable. `inspect()` uses the metadata snapshot established by `connect()`; `{ refresh: true }` conditionally revalidates it. Discovery failure is explicit (`discovery: "unavailable"` plus a diagnostic), not fabricated protocol defaults presented as observed server truth. ### Explain and query An execution plan includes: - a stable ID and deterministic fingerprint; - connection/source identity and the canonical query; - ordered remote, worker, renderer, or client steps; - pushdown degree and reason for each step; - expected requests, rows, and bytes where estimable; - cache decision and freshness policy; - required authorization scopes; - exact/equivalent/approximate/unsupported fidelity; - provenance, expiry, and structured diagnostics. `explain()` may refresh metadata under the caller's freshness policy but must not fetch result rows or mutate application/renderer state. `query(plan)` executes the reviewed plan only when its fingerprint, source version, policy, and validity window still match. Otherwise it throws `plan-stale` with a replacement plan; it does not silently re-plan. Feature output is additive to today's result contract: ```ts doc-test=skip reason="partial excerpt requires application host context" type FeatureQueryResult = Result & { readonly format: "features"; readonly execution: ExecutionReceipt; }; ``` Columnar output is a distinct result so a million-row path does not materialize JavaScript feature objects: ```ts doc-test=skip reason="partial excerpt requires application host context" interface ColumnarQueryResult { readonly format: "columnar"; readonly schema: readonly { name: string; type: string }[]; readonly batches: AsyncIterable>; readonly execution: ExecutionReceipt; } ``` Cancellation is end-to-end. Aborting a query stops page fetches, worker work, and renderer ingestion. A partial stream carries a terminal diagnostic and is never represented as a complete `Result`. ### Mount and renderer escape hatches ```ts doc-test=skip reason="partial excerpt requires application host context" interface RendererAdapter { readonly kind: "maplibre" | "deckgl" | "cesium"; readonly environments: readonly ("browser" | "node" | "worker")[]; readonly peer: unknown; // caller-injected module; never a core import } interface MountedMap extends AsyncDisposable { readonly id: string; readonly renderer: "maplibre" | "deckgl" | "cesium"; readonly ready: Promise; readonly diagnostics: DiagnosticChannel; raw(kind: "maplibre" | "deckgl" | "cesium"): T | undefined; dispose(): Promise; } ``` `mount()` resolves after the adapter accepts the source/layer plan. `ready` resolves after the first usable frame and rejects with render diagnostics. Automatic styles are deterministic schema/geometry projections and include a fidelity report; `"auto"` is never a remote generative call. When passed a selector or element, the SDK owns the renderer it creates. When passed an existing renderer host, it borrows the host by default and removes only Honua-owned sources, layers, listeners, workers, and requests. `raw(kind)` is the renderer-specific escape hatch; renderer methods do not leak into the kernel contract. MapLibre adapter construction lives under `@honua/sdk-js/runtime`; deck.gl and Cesium adapters may use focused subpaths decided by their implementation issues. Importing `@honua/sdk-js` must not load any renderer, DuckDB, Arrow, Cesium, or model-provider code. ### Diagnostics, freshness, and provenance Diagnostics use one channel at kernel, connection, execution, and mounted-map scope. `snapshot()` supports post-failure inspection; `subscribe()` supports live developer tooling. Every diagnostic has an ID, stage, severity, operation ID, optional source ID, remediation, and preserved cause. ```ts doc-test=skip reason="partial excerpt requires application host context" interface Observation { readonly state: "live" | "cached" | "replayed" | "pending-local"; readonly observedAt: string; readonly validAt?: string; readonly expiresAt?: string; readonly cursor?: string; readonly provenance: readonly ProvenanceRecord[]; } ``` - `live`: read from the addressed service during this operation. - `cached`: served from a cache and accompanied by observation/expiry time. - `replayed`: deterministic fixture, offline log, or resumed cursor replay. - `pending-local`: an optimistic local edit not yet accepted upstream. Signed URLs and credentials never appear in provenance or diagnostics. The canonical source URL drops query credentials and records a stable asset ID or origin/path. Receipts distinguish the metadata observation from the result-data observation so fresh metadata cannot make replayed rows appear live. ### Agent proposal contract Agent planning stays in `@honua/sdk-js/agent-tools` and requires a caller-owned model/provider. No provider SDK enters the root bundle. ```ts doc-test=skip reason="partial excerpt requires application host context" const proposal = await agent.propose(instruction, { connections: [incidents], policy: { allow: ["data:read", "map:write"], deny: ["data:write", "publish"] }, }); const preview = await proposal.dryRun(); const grant = preview.allowed ? await host.requestApproval(proposal.approval) : undefined; if (grant) await proposal.execute({ approval: grant }); ``` `dryRun()` performs validation and estimates but no data or renderer mutation. Every agent-generated execution requires a host-issued, opaque approval grant bound to the proposal ID and plan fingerprint. A changed plan invalidates the grant. Read-only is the default policy; data edits and publish are denied unless independently granted. Every attempt, denial, execution, and compensating action emits an audit diagnostic and execution receipt. ## Lifecycle and ownership ```mermaid stateDiagram-v2 [*] --> Kernel: createHonua Kernel --> Discovering: connect(locator) Discovering --> Connected: metadata + capability snapshot Discovering --> Kernel: typed connect failure Connected --> Connected: inspect / explain Connected --> Executing: query(query or reviewed plan) Executing --> Connected: result + receipt Connected --> Mounted: mount(adapter) Mounted --> Connected: mountedMap.dispose() Connected --> Disposed: connection.dispose() Kernel --> Disposed: kernel.dispose() cascades Disposed --> [*] ``` Disposal rules: - Every `dispose()` is idempotent and awaits in-flight cancellation/worker shutdown. - `MountedMap.dispose()` disposes its owned renderer session, not the connection or borrowed host. - `HonuaConnection.dispose()` aborts its executions and disposes mounted maps, metadata watches, loader workers, and source-local caches it owns. - `HonuaKernel.dispose()` disposes all connections and kernel-owned auth/cache resources. Caller-supplied providers and renderer hosts remain caller-owned. - Any operation after disposal throws the existing typed `disposed` pattern. ## Dependency direction ```mermaid flowchart TD Apps[Applications / @honua/react / app-platform] --> Kernel[Small kernel facade] Agents[@honua/sdk-js/agent-tools] --> Planner[Query IR + execution planner] Kernel --> Planner Kernel --> Contract[Dataset / Source / Query / Result] Planner --> Contract Contract --> Protocols[First-party protocol adapters] Kernel --> RenderContract[Renderer adapter contract] MapLibre[MapLibre adapter] --> RenderContract Deck[deck.gl adapter] --> RenderContract Cesium[Cesium adapter] --> RenderContract MapLibre -. optional peer .-> MLPeer[maplibre-gl] Deck -. optional peer .-> DeckPeer[deck.gl / Arrow] Cesium -. optional peer .-> CesiumPeer[cesium] Plugins[Certified third-party plugins] --> Contract Plugins --> RenderContract ``` The arrows are dependencies. Core never points to React, app-platform, renderer peers, DuckDB, or model providers. A renderer adapter may depend on the kernel contract; the kernel refers only to the adapter interface. ## Golden workflows The authoritative compile fixtures are: 1. [`public-url-to-map.ts`](../../test/design/north-star-api/public-url-to-map.ts) 2. [`large-data-linked-analysis.ts`](../../test/design/north-star-api/large-data-linked-analysis.ts) 3. [`safe-agent-proposal.ts`](../../test/design/north-star-api/safe-agent-proposal.ts) They are checked by `npm run verify:north-star-api`. They import a design-only contract under `test/design`; no production API was added for this ADR. ### 1. Public URL to useful map This is the normative basic workflow: nine application lines including imports, inspection, first-frame readiness, and cleanup; no key or Honua account. ```ts doc-test=skip reason="normative future API; compile fixture lives under test/design" import maplibregl from "maplibre-gl"; import { createHonua } from "@honua/sdk-js"; import { maplibreRenderer } from "@honua/sdk-js/runtime"; const honua = createHonua(); const data = await honua.connect(PUBLIC_FEATURE_LAYER_URL); const info = await data.inspect(); const map = await data.mount("#map", { renderer: maplibreRenderer(maplibregl), style: "auto" }); await map.ready; await honua.dispose(); ``` ### 2. Large-data linked analysis The workflow connects an AWS-hosted GeoParquet/PMTiles asset, requests columnar or tiled display execution, reuses the stable `ExplorationContext` for map, table, chart, filter, and selection state, and pushes an aggregate summary to DuckDB or the remote service. The plan must prove that the display path does not materialize unbounded feature objects. `deck.gl` and DuckDB are injected optional peers. A viewport/filter change creates a new fingerprinted plan; it does not mutate the reviewed plan in place. ### 3. Safe agent-proposed plan The workflow connects the deployed `demo.honua.io` feature surface, compiles a prompt into the same execution-plan type, dry-runs it, requests approval, and executes only with a plan-bound grant. The fixture proves at compile time that `proposal.execute()` cannot be called without an `ApprovalGrant`. ### DX review | Workflow | Application lines / introduced concepts | Required configuration | Cleanup contract | | --- | --- | --- | --- | | Public URL -> map | 9 lines including imports; kernel, connection, renderer adapter, mounted handle | Public URL only; no account/key | One awaited `honua.dispose()` cascades connection and owned map | | Large-data linked analysis | 1 connection, 2 reviewed plans, 1 existing exploration context, 2 optional peer factories | AWS asset locator supplied by public config or runner; never an embedded signed URL | Kernel disposes worker/connection/map; caller disposes its exploration context | | Safe agent proposal | 1 connection, proposal, dry run, approval grant, receipt | Caller-owned model provider and host approval callback | Kernel disposal plus provider lifecycle retained by caller | Only the first workflow is constrained to ten application lines. Advanced workflows deliberately expose plans, peers, and ownership rather than hiding cost or authority behind defaults. ## Deterministic and live validation lanes Each golden workflow has two evidence lanes; neither substitutes for the other. | Lane | Trigger | Inputs | Required evidence | | --- | --- | --- | --- | | Deterministic | Every pull request, offline | Committed metadata/result/columnar fixtures and a deterministic agent provider | Typecheck, semantic assertions, recorded provenance sidecar, replayed freshness state, disposal/leak assertions | | Public live | Scheduled and manual | Public AWS assets, public endpoints, and deployed `https://demo.honua.io` | Discovery/query/render or headless-plan success, live provenance/freshness, latency/bytes, explicit endpoint/version report | | Authenticated live | Scheduled/manual when configured | Private AWS objects or protected demo capabilities | GitHub OIDC or repository secrets supplied only to the runner; explicit executed/credential-unavailable status; no silent green skip | Credential rules: - Example source, HTML, browser bundles, fixture metadata, logs, diagnostics, snapshots, and PR artifacts contain no secret, bearer token, access key, signed-URL query, or credential-bearing source URL. - Prefer public read-only AWS sample assets. Authenticated AWS CI uses short-lived OIDC credentials and resolves private locators inside the runner. - Browser examples never receive CI secrets through `VITE_*` variables. - Fixture refresh is a reviewed script that strips credentials, records source identity/version/retrieval time/license, and produces a deterministic digest. - A live-lane result reports `executed`, `failed`, or `credential-unavailable`; missing credentials do not masquerade as execution. The SDK repository owns executable samples and tests. Each flagship example will expose a small, versioned catalog manifest containing slug, title, capabilities, protocols, renderer, fixture command, live command, screenshot, and provenance metadata. The `honua-site` samples directory/gallery consumes a generated projection of that manifest and links to or embeds the deployed SDK artifact. It must not fork `src/`, mock servers, fixtures, or workflow logic. Changes flow SDK example -> validated catalog artifact -> site projection. The site may own narrative/marketing copy and ordering, but not a second executable implementation. The current sample inventory is evidence, not a preservation constraint. The program optimizes for compelling task journeys, time to first success, and clear proof of differentiated capability. During curation, each current sample receives an explicit **keep, rework, merge, replace, or retire** disposition; legacy URLs get redirects where appropriate, but weak examples are not retained solely to avoid change. SDK-side execution is tracked by #398 (modernization epic), #399 (flagship migrations), #400 (generated learning/docs), and #401 (versioned manifest/artifact/evidence contract). The site-side catalog projection is [honua-site#120](https://github.com/honua-io/honua-site/issues/120) and the task-journey experience redesign is [honua-site#121](https://github.com/honua-io/honua-site/issues/121). This decision PR intentionally does not edit `honua-site`. ## Environment and distribution compatibility | Environment | Supported kernel operations | Constraints | | --- | --- | --- | | Node 20+ | `connect`, `inspect`, `explain`, `query`, streaming, agent planning | No DOM `mount`; a headless adapter must explicitly advertise Node support. Browser auth stores are unavailable. | | Browser ESM/bundler | All operations | Renderer/analytics peers are explicit imports; workers obey CSP and are disposable. | | Web Worker | `connect`, `inspect`, `explain`, `query`, columnar processing | No element/selector mount. `OffscreenCanvas` requires an adapter that advertises worker support. Auth/cache implementations are injected. | | React 18/19 | Same kernel through `HonuaProvider` and hooks | StrictMode cannot duplicate connections/subscriptions; component unmount disposes only resources it owns. | | Build-less ESM | Root/browser kernel plus URL-imported focused adapters | Import maps resolve optional peers; no implicit bare import or dynamic CDN dependency. | The root stays ESM-only, NodeNext-compatible, strict, and side-effect free. Renderer and analytics adapters declare peers and carry their own bundle budgets. `createHonua()` and built-in lightweight discovery must preserve the one-lightweight-runtime-dependency posture established by #357. ## Mapping to the current SDK | North-star concept | Existing symbol(s) | Disposition | | --- | --- | --- | | `createHonua()` | `new HonuaClient()`, `createDataset()` | Add a thin owner/facade after discovery/planner contracts land; retain both low-level APIs. | | `connect()` | `createDataset`, `SourceLocator.layout`, URL parsers, OGC/STAC landing-page discovery, GeoParquet/PMTiles resolvers | Compose and generalize. Do not add another protocol-specific constructor. Tracked by #391. | | `HonuaConnection` | `Dataset` plus owned metadata/worker/runtime handles | New orchestration handle; its `dataset` exposes the existing contract. | | `inspect()` | `SourceDescriptor`, `SourceSchema`, `Capabilities`, `Source.protocol(...).describe()`, metadata cache docs | Normalize into immutable observed metadata; distinguish discovered vs declared defaults. | | `query()` | `Source.query`, `queryAll`, `stream`, `queryAggregate` | Reuse adapter execution. The facade adds plan/receipt/format; direct `Source` calls remain valid. | | `explain()` / `ExecutionPlan` | Per-adapter compilers, `DegradedReason`, query-tile diagnostics | New shared planner contract, not a wrapper around log strings. Tracked by #389. | | Feature results | `Result` | Preserve fields; add mandatory execution receipt only on the facade result. | | Columnar results | GeoParquet/DuckDB runtime and row mapping | Replace row-object default for large paths with batches; tracked by #394. | | `mount()` | `loadMapPackage`, `HonuaMapRuntime`, `HonuaMap`, source bridge | Adapt behind renderer contract; do not add renderer methods to `Dataset`/`Source`. | | MapLibre adapter | `HonuaMapRuntime`, `MaplibreMap`, `loadHonuaFeatureServiceGeoJson` | Productionize automatic source/style projection under #390. | | deck.gl adapter | No stable production adapter | New optional adapter under #388. | | Cesium adapter | App-platform scene adapters and compatibility examples | New stable renderer adapter without moving scene-workspace back into core; #395. | | Linked state | `ExplorationContext`, view controllers, interaction bindings | Reuse unchanged; `mount`/planner accept snapshots and bindings. | | Diagnostics | typed error hierarchy, `Result.degraded`, runtime diagnostics/events, request/runtime telemetry | Unify through a scoped channel while preserving typed errors. | | Freshness/provenance | `SourceFreshnessContract`, cache diagnostics, realtime cursors | Keep declared freshness and add observed state/receipts; #393 and #396 implement storage/stream semantics. | | Agent proposal | `createHonuaAiMapKit`, tool executor, audit events, MCP evals | Reuse tools/audit; add provider-neutral proposal -> plan -> grant flow under #397. | | Plugin | `resolveSource`, declaration-merging adapter map, auth/GeoParquet/PMTiles injection seams | Converge on one versioned plugin lifecycle and certification contract under #392. | | Disposal | `HonuaMapRuntime.dispose`, `ExplorationContext.dispose`, `GeoparquetRuntime.dispose`, React cleanup | Compose under kernel ownership; retain leaf disposal methods. | | React | `HonuaProvider`, `useDataset`, `useQuery`, `HonuaMap` | Evolve bindings to accept kernel/connection; no second cache or planner. | | Raw escape hatch | `Source.protocol()`, runtime `.map`, protocol clients | Keep `Source.protocol`; replace direct runtime leakage in the facade with typed `MountedMap.raw(kind)`. | ## Name collisions and migration rules - `HonuaClient` remains the raw transport/service client. It is not renamed to `HonuaKernel`; `createHonua()` owns one or more clients as discovery requires. - `HonuaMap` remains a programmatic source/layer model. The mounted resource is named `MountedMap`, avoiding a third `HonuaMap` class. - `loadMapPackage()` remains the explicit saved-package path. `mount()` may compile a transient package internally but must not hide hosted-package fetch or validation semantics. - `inspect()` is normalized kernel metadata. Protocol-specific `describe()`, `getCapabilities()`, and layer-metadata calls remain raw escape hatches. - `ExecutionPlan` is distinct from app-platform Operator/Studio plans. The latter may reference an execution-plan fingerprint but do not define SDK execution semantics. - React's `` is a component, while `MountedMap` is its owned/borrowed imperative handle. The component delegates to `mount()`. - No existing stable symbol is deprecated by this ADR. Low-level contracts are essential advanced APIs. Deprecation may begin only after the facade has equivalent conformance and migration documentation. - Expired app-platform shims remain governed by #385; this design does not use them or move app-shell concepts back into the stable package. ## Follow-on contract The implementation issues must reference and preserve this decision: - #391: `connect`, discovery, inspection, and effective capabilities. - #389: query IR, plan fingerprint, explain semantics, and receipts. - #390, #388, #395: MapLibre, deck.gl, and Cesium adapters. - #394: columnar result and worker semantics. - #393 and #396: observed freshness, cursors, persistent caches, and pending local edits. - #397: agent proposal, policy, approval grant, audit, and provenance. - #392: plugin lifecycle and certification. - #385 and #387: stable surface/bundle reconciliation and measurable DX gates. - #398, #399, #400, and #401: compelling flagship journeys, generated learning material, and the SDK-owned sample artifact/evidence projection contract. - [honua-site#120](https://github.com/honua-io/honua-site/issues/120) and [honua-site#121](https://github.com/honua-io/honua-site/issues/121): consume the SDK projection and redesign the samples/docs experience without copying executable implementations. An implementation may extend a focused option type, but changing ownership, silent-fallback rules, plan review semantics, observed-state vocabulary, or dependency direction requires a superseding ADR. ## Consequences Positive: - The common path is materially smaller without hiding the existing powerful contract. - Protocols, renderers, human-authored queries, and agent actions converge on one explainable execution model. - Optional peers and app-platform separation remain enforceable. - Live AWS/demo evidence and deterministic CI prove different failure classes while sharing provenance semantics. Costs: - `connect()` cannot ship honestly until metadata discovery is consistent. - Plans and receipts become versioned public contracts requiring conformance fixtures across every adapter. - Renderer adapters must expose fidelity differences instead of promising impossible parity. - Resource ownership and live-lane evidence add work to every new plugin. ## Acceptance evidence for #386 - Product boundary and API/lifecycle/dependency decisions: this ADR. - Three compile-tested workflows: `test/design/north-star-api/*.ts`. - Current-symbol collision and migration inventory: mapping sections above. - Node/browser/worker/React/build-less and optional-peer rules: compatibility sections above. - No production API: the proposed declarations live only under `test/design`. - Follow-on traceability: issue list above; each implementation ticket receives a link to this accepted-for-review contract. --- # File: docs/protocol-capability-matrix.md # Protocol × Capability Matrix Status: implemented in `src/contract/types.ts` (`PROTOCOL_DEFAULT_CAPABILITIES`). Update both this document and the table in code together. The matrix below is the **default** capability set per protocol. Callers that need a narrower surface for a specific source (for example a Feature Service whose metadata reports `supportsStatistics: false`) must intersect the default set themselves and pass the result on `SourceDescriptor.capabilities`. The gRPC default is shared by the canonical FeatureService transport, and the GeoServices, OGC, STAC, WFS, and WMS adapter constructors do not read service metadata today, so per-source downgrades for those protocols stay caller-side. **OData is the exception**: the `odataSource` adapter lazily fetches `$metadata` on the first capability-gated method, parses `Capabilities.*` annotations (both inline inside `` and sibling `` blocks), and intersects the descriptor's declared `Capabilities` set with the server's advertised flags — see the *OData* notes below for details. Other adapters will follow the same pattern as follow-up work. This matrix spans the full shared capability vocabulary, not just the protocol-neutral `Source` methods implemented in this ticket. Capabilities without a canonical `Source` method today are negotiated for `Source.adapter()` escape hatches and follow-on adapter tickets. `✓` = first-party support, no client-side fallback needed. `◐` = supported only under `degraded` capability policy (client-side fallback). `—` = not supported. | Capability | gRPC | GS Feature | GS Map | GS Image | GS Geometry | GS GP | OGC Features | OGC Tiles | OGC Maps | OGC Records | STAC | WFS | WMS | WMTS | OData | GeoParq | | --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | | `query` | ✓ | ✓ | ✓ | ✓ | — | — | ✓ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | | `queryAggregate` | ✓ | ✓ | ✓ | — | — | — | ◐ | — | — | — | — | — | — | — | — | ✓ | | `spatialAggregate` | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | | `queryExtent` | ✓ | ✓ | ✓ | ✓ | — | — | ◐ | — | — | — | — | ✓ | — | — | — | — | | `queryObjectIds` | ✓ | ✓ | ✓ | ✓ | — | — | ✓ | — | — | ✓ | ✓ | ✓ | — | — | ✓ | — | | `queryRelated` | — | ✓ | ✓ | — | — | — | — | — | — | — | — | — | — | — | — | — | | `applyEdits` | ✓ | ✓ | — | — | — | — | ✓ | — | — | — | — | ✓ | — | — | ✓ | — | | `attachments` | — | ✓ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | | `render` | — | — | ✓ | ✓ | — | — | — | ✓ | ✓ | — | — | — | ✓ | ✓ | — | — | | `tiles` | — | ◐ | ✓ | ✓ | — | — | — | ✓ | — | — | — | — | ✓ | ✓ | — | — | | `sql` | — | ✓ | ✓ | — | — | — | — | — | — | — | — | — | — | — | — | — | | `stream` | ✓ | ✓ | ✓ | — | — | — | ✓ | — | — | ✓ | ✓ | ✓ | — | — | ✓ | ✓ | | `pbf` | — | ✓ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | | `connect` | — | ✓ | — | ✓ | ✓ | ✓ | — | — | — | — | — | — | — | — | — | — | | `image` | — | — | — | ✓ | — | — | — | — | — | — | — | — | — | — | — | — | | `geometry` | — | — | — | — | ✓ | — | — | — | — | — | — | — | — | — | — | — | | `geoprocess` | — | — | — | — | — | ✓ | — | — | — | — | — | — | — | — | — | — | | `processes` | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | MapLibre-native sources (`maplibre-vector`, `maplibre-raster`, `maplibre-geojson`) are render-only and contribute `render` and (where applicable) `tiles`. They are excluded from the table above because they do not flow through the `Source.query` path. `pmtiles` is a first-party protocol (`PROTOCOL_DEFAULT_CAPABILITIES.pmtiles` = `{ tiles }`) but, like the MapLibre-native sources, is tiles-only and does not flow through `Source.query`, so it is documented in the *PMTiles* note below rather than as a table column. **GeoParquet** (`geoparquet`, column `GeoParq`) is the DuckDB-WASM-backed `Source` in `@honua/sdk-js/geoparquet`. The same `Query` (`where` / `spatialFilter` / `outFields` / `orderBy` / `pagination` / `aggregation`) compiles to DuckDB SQL over `read_parquet(...)`, so `query`, `queryAggregate` (GROUP BY), and `stream` are first-party. Envelope `spatialFilter`s push down to `ST_Intersects` over the native geometry (or a GeoParquet 1.1 bbox covering column); non-envelope filters reduce to their bbox and are reported via `Result.degraded`. `queryExtent`, `queryObjectIds`, `queryRelated`, `applyEdits`, and `attachments` are honest misses — the static-file source is read-only and exposes no server-side ids/extent endpoints. Metadata (`describe()`: schema, geometry column, CRS, row estimate) and a raw `sql()` escape hatch live on `Source.protocol("geoparquet")`. Because DuckDB-WASM is a multi-megabyte optional peer, the adapter lives behind its own entrypoint (wired into `createDataset` via `geoparquetResolver`) and is never in the `/contract` or `/honua` graph. `spatialAggregate` is an indexed analytics capability rather than the field-statistics `queryAggregate` path. This SDK slice defines the contract in `src/contract/spatial-aggregation.ts` without assigning first-party default support to any protocol. A source may advertise `spatialAggregate` only after backend metadata confirms an indexed aggregation implementation for that source; the request/response shapes keep the index model opaque so H3, Quadbin, or provider-specific grids can all satisfy the same app contract. ## Notes by protocol ### gRPC FeatureService Canonical transport shared across the SDKs and generated from the `geospatial-grpc` FeatureService definitions. The default capability set is `query`, `queryAggregate`, `queryExtent`, `queryObjectIds`, `applyEdits`, and `stream`. In JS, gRPC-Web is selected on `HonuaClient` with `transport: "grpc-web"`; it is not exposed as a `Source.protocol("grpc")` adapter handle. ### GeoServices Feature Service First-class. Aggregations set `outStatistics`, `groupByFieldsForStatistics`, and `returnGeometry=false` as top-level fields on the translated `QueryFeaturesRequest` so both the REST serializer and the gRPC-Web adapter pick them up (they are not stashed in `extraParams`, which the gRPC path would silently drop). Pagination uses `resultOffset` / `resultRecordCount`. Streaming wraps `HonuaFeatureLayer.queryFeaturesStream`; the adapter derives `pageSize` from `Query.pagination.limit` so `source.stream({ pagination: { limit } })` yields pages of at most `limit` rows instead of the core helper's default 2000. `pbf` is supported when the server returns `f=pbf`; the contract accepts both encodings transparently. ### GeoServices Map Service / Map Layer Same query semantics as Feature Service for the layers it exposes (including the root-level aggregation encoding and `pagination.limit` → `pageSize` bridge for `stream()`). `render` and `tiles` come from the service-level export endpoints. `applyEdits` and `attachments` are not supported because the Map Service endpoint is read-only — `Source.applyEdits()` and `Source.attachments.*` throw `HonuaCapabilityNotSupportedError` so a mixed-source app does not silently drop the edits. `Source.queryObjectIds()` and `Source.queryRelated()` round-trip through the same canonical envelopes as the FeatureServer adapter. ### GeoServices Image Service The Image Service adapter wraps Honua Server's ImageServer endpoints (see `feature-server-matrix.md`'s sibling `image-server-matrix.md`). `Source.query()` returns the raster catalog as canonical features (one row per raster, footprint geometry on each). `Source.queryAll()` drains pages from the catalog endpoint internally (`resultOffset` / `resultRecordCount`) and uses a `limit + 1` lookahead row to stamp `exceededTransferLimit: true` when the cap is hit, mirroring the FeatureServer / OGC `queryAll` semantics. `Source.queryExtent()` and `Source.queryObjectIds()` reuse the same catalog endpoint with the standard GeoServices shaping flags (`returnExtentOnly`, `returnIdsOnly`). Tile URLs come from `Source.protocol("geoservices-image-service").tileUrl(level, row, col)`. `exportImage`, `identify`, `legend` live on the same `HonuaImageService` typed escape hatch — these protocol-specific operations are not on the canonical `Source` because their request shapes (mosaic rule, rendering rule, pixel size, raster function chains) are ImageServer-specific. The wrapper accepts both `GET` (params on the query string) and `POST` (form-encoded body) per request; `POST` is the correct mode when payload size or proxy URL limits would truncate a `GET` URL. The catalog endpoint does not honor `Query.spatialFilter`, `Query.orderBy`, or `Query.outFields`, so the adapter rejects those fields explicitly rather than silently widening the result; use `Query.where` to constrain the catalog or move to a FeatureServer source for richer query semantics. `applyEdits`, `attachments`, `queryRelated`, `queryAggregate`, and `stream` are intentionally absent from the default capability set; the canonical methods throw `HonuaCapabilityNotSupportedError` rather than silently no-op. ### GeoServices Geometry Service Geometry Service is a stateless utility — it does not host features — so the canonical query family throws on every method. The default capabilities advertise only `geometry` and `connect`. Operations (`buffer`, `simplify`, `project`, `intersect`, `union`, `clip`, `difference`) live behind `Source.protocol("geoservices-geometry-service")` on a `HonuaGeometryService` instance whose request shapes match the routes in `honua-server/docs/gis/geometry-service-matrix.md`. The wrapper targets the `EndpointRegistry` prefix `/rest/services/Utilities/Geometry/GeometryServer/` (the canonical Esri Utilities path); `POST` requests submit form-encoded bodies (the default), `GET` keeps params in the query string. Operations the server does not implement (`autoComplete`, `convexHull`, `cut`, `densify`, etc.) intentionally have no wrapper. ### GeoServices GP Service GP Services run async tasks rather than hosting features. The default capabilities advertise only `geoprocess` and `connect`; the canonical query family throws. Task lifecycle — `submitJob`, `jobStatus`, `cancelJob`, `jobResult` — lives behind `Source.protocol("geoservices-gp-service")` on a `HonuaGeoprocessingService` instance. The service id and task name come from `SourceLocator.serviceId` / `SourceLocator.taskName` so a single descriptor uniquely identifies a task without leaking task parameters into the canonical descriptor shape. `createDataset` rejects descriptors that advertise `geoprocess` without a `locator.taskName` because Honua Server publishes the lifecycle routes only under `/rest/services//GPServer//...`; descriptors that advertise only `connect` (service-root metadata probe) may omit the task name. GeoServices GPServer also participates in the unified process runner: `client.geoprocessingRunner(serviceId, taskName)` returns a `HonuaProcessRunner` that submits GP parameters and exposes the same `IJobRun` lifecycle as OGC API Processes and geospatial-grpc ProcessService clients. ### Geospatial gRPC Process Service The open `honua-io/geospatial-grpc` protocol defines `geospatial.v1.ProcessService` with `ValidatePlan`, `DryRunPlan`, `ExecutePlan`, `ExecutePlanStream`, `SubmitJob`, `GetJob`, `GetJobResult`, and `CancelJob`. The JS SDK keeps the adapter structural because generated process proto lives outside this package today: `client.geospatialGrpcProcessRunner(processServiceClient)` accepts a generated Connect client and normalizes `JobState` values onto `IJobRun`. `JOB_STATE_COMPLETED` maps to `successful`, `JOB_STATE_FAILED` to `failed`, `JOB_STATE_CANCELLED` to `dismissed`, and draft/clarification/validated/approval states map to `accepted`. ### OGC API Tiles Render-only adapter. `tiles` and `render` are the first-party capabilities; the canonical `Source.query*` family throws `HonuaCapabilityNotSupportedError` because the conformance class is tile-fetch, not feature-query. The canonical tile path is `/collections/{id}/tiles/{tms}/{z}/{y}/{x}`; tile-matrix-set discovery uses `/tileMatrixSets` and `/tileMatrixSets/{id}`. Styled-tile access (OGC `/styles/{styleId}/tiles/...`) is part of the standard but is not exposed by honua-server today, so the SDK does not synthesize that route. `Source.adapter("ogc-tiles")` returns a `HonuaOgcTileset` bound to `(collectionId, tileMatrixSetId)` when both are set in the locator; when only `collectionId` is set, the adapter falls back to the root `HonuaOgcTiles` handle so callers can discover which tile-matrix-sets the server advertises before rebinding. ### OGC API Maps Render-only adapter. `render` is the first-party capability; same escape-hatch model as Tiles. `Source.adapter("ogc-maps")` returns either the dataset-level `HonuaOgcMaps` (when `locator.collectionId` is unset) or a `HonuaOgcCollectionMap` bound to the descriptor's collection + optional style. The wire path is `/maps[/collections/{id}][/styles/{styleId}]/map`; bbox / crs / format flow through query parameters. The `format` field is normalized to the server's short-name token (`png`, `jpeg`, `jpg`, `tiff`, `tif`) before it is written to `f=`; media-type aliases (`image/png`, etc.) are accepted for ergonomics and translated. The public request envelope has no `filter` field because honua-server's Maps request model has none. ### OGC API Processes No `Source` adapter — Processes is a job runner, not a queryable source. `HonuaClient.ogcProcesses().execute(...)` returns the canonical `IJobRun` (the same interface every other long-running operation in the SDK speaks). The implementation polls `/jobs/{jobId}` until terminal, fetches `/jobs/{jobId}/results` on `successful`, and maps failure terminals onto `JobSnapshot.error`: it prefers `statusInfo.exception` (OGC Processes Part 1 vocabulary) and falls back to `statusInfo.message` so honua-server's single-`message` failure text still surfaces through `HonuaJobFailedError.message`. `cancel()` issues `DELETE /jobs/{jobId}` and is idempotent only on the documented benign paths: 404 (job gone) returns the cached status; 409 with problem-details title `"Cannot dismiss completed job"` triggers a follow-up GET and returns the authoritative terminal status — but only if the poll confirms a terminal state, otherwise the original 409 is rethrown. The non-benign 409 titles `"Dismiss could not be confirmed"` (backend dismissal unconfirmed) and `"Cancellation not supported"` (backend lacks dismissal capability) are rethrown verbatim. The canonical `processes` capability is part of `CAPABILITIES` and is returned by `negotiateOgcCapabilities("ogc-processes", conformance)`; it is intentionally absent from `PROTOCOL_DEFAULT_CAPABILITIES` because there is no `ogc-processes` `Source` protocol. ### OGC API Records Metadata-catalog adapter. `query`, `queryObjectIds`, and `stream` are first-party capabilities over `/ogc/records/collections/{catalogId}/items`. The direct `client.ogcRecords()` surface exposes Records-specific query parameters (`q`, `type`, `externalIds`, `datetime`, `bbox`, `ids`, `profile`) and raw response access for HTML/JSON/profile negotiation. The canonical `Source.query` path maps `Query.where` to CQL2 `filter` and `spatialFilter` envelopes to Records `bbox`; aggregation, edits, attachments, related records, and extent-only queries are not advertised. Records describes metadata about resources and remains separate from STAC asset search and Honua admin/control-plane metadata APIs. ### STAC API STAC piggy-backs on OGC API Features for items but adds a cross-collection `/search` endpoint. The canonical `Source.query` uses `/search` (GET by default; opt into POST with `usePost: true`). Both the GET and POST paths serialize `intersects` (as JSON on GET, raw on POST) and `fields` (as a CSV with `-` prefixes on GET, structured on POST) so caller-supplied geometry constraints and selections are not silently dropped. `spatialFilter` translates to STAC `bbox` only — `intersects` geometry support requires CQL2 and is left to a downstream extension. Paging follows the server's `rel=next` link: honua-server emits `?offset=N` on the href and the adapter parses that numeric offset; non-Honua STAC servers that emit an opaque `?next=…` token remain supported as a fallback. `Query.pagination.offset` propagates through to the STAC `offset` parameter on the initial request. `queryAggregate` and `queryExtent` are not advertised. STAC's collection-scoping is handled via `locator.collectionId`; the adapter forwards it as the `collections=[id]` parameter on the wire. ### OGC API Features `query`, `queryObjectIds`, `applyEdits`, `stream` are first-party. OGC has no batch edit endpoint, so `applyEdits` fans out to per-item `createItem` / `replaceItem` / `deleteItem` calls and forwards `EditEnvelope.signal` to every request — aborting the signal cancels every operation that has not yet been issued, while operations already in flight resolve into per-item failures on the returned `EditResult`. `queryAll()` requests `limit + 1` rows from `itemsAll()` when the caller caps the result with `Query.pagination.limit` so the adapter can stamp `exceededTransferLimit: true` when more records exist (mirroring the GeoServices lookahead-row pattern). `queryAggregate` is degraded — `Source.query({ aggregation })` aggregates client-side over the returned page, while `Source.queryAggregate()` drains every page first and then aggregates. Both stamp a `queryAggregate` `DegradedReason` on the `Result` so downstream views can flag the number as non-authoritative. `queryExtent` is also degraded: an unfiltered `queryExtent()` with no `outSr` returns the collection metadata's `extent.spatial.bbox[0]` shortcut, while a filtered request (`where` or `spatialFilter`) — or any request that sets `outSr` — drains `itemsAll()` and computes the bbox client-side over matching features. The `outSr` carve-out exists because the metadata bbox is frozen in the collection's native CRS (typically CRS84) and the OGC `/collections/{id}` endpoint does not accept a target CRS, so reusing the shortcut when the caller asked for a different CRS would silently return the wrong coordinates. `queryExtent` returns `{ extent, count? }` and does not carry a `degraded` array. Only `spatialFilter.geometryType = "esriGeometryEnvelope"` is translated (to the OGC `bbox` query param); other geometry types would require CQL2, which the adapter does not yet emit, so they throw rather than silently drop the constraint. Likewise, only `spatialRel` values of `esriSpatialRelIntersects` or `esriSpatialRelEnvelopeIntersects` are accepted — the OGC `bbox` parameter is defined as an envelope-intersects predicate (OGC 17-069r4 §7.15.3), so `contains`/`within`/`crosses`/etc. throw rather than silently widen to bbox semantics. `queryRelated`, `attachments`, and `pbf` are out-of-scope for the OGC standard. ### WFS First-party WFS 2.0 adapter (`wfsSource`); see [`wfs.md`](./wfs.md) for the full reference. `query` / `queryAll` / `stream` all route through `GetFeature` after a one-time `GetCapabilities` negotiation; `Query.where` compiles to FES 2.0 (comparison, `IN`, `BETWEEN`, `LIKE`, `IS NULL`, boolean combinators, parenthesization), and `Query.spatialFilter` becomes either a KVP `bbox=` for envelope-only requests or a `` for everything else (envelope, point, polygon, polyline). Filters longer than ~7 KB switch to POST GetFeature with the `` body. Anything richer than the supported subset (subqueries, function calls, vendor extensions, curves / surfaces) throws `HonuaCapabilityNotSupportedError("query")` rather than ship a silent partial filter — callers reach the wire through `Source.protocol("wfs")`. WFS `propertyName=` drops every property the caller does not list, including the geometry column, so `Query.outFields` and `Query.returnGeometry` are resolved together: an `outFields` list with `returnGeometry !== false` appends the geometry property (`the_geom` by default) before the projection lands on the wire so geometry survives; `returnGeometry === false` paired with an `outFields` list emits exactly the requested fields (no geometry); a `returnGeometry === false` request without an `outFields` list throws `HonuaCapabilityNotSupportedError("query")` because WFS cannot suppress geometry without enumerating non-geometry properties. `queryExtent` prefers the per-feature-type `WGS84BoundingBox` from `GetCapabilities` for unfiltered requests so no extra HTTP traffic is issued; filtered or `outSr`-bearing requests drain every matching page (2000 features per page) and compute the bbox client-side, ignoring caller pagination, `Query.outFields`, and `Query.returnGeometry` so geometry is preserved on every drained page and the returned extent covers the full matching set. `queryObjectIds` has no interoperable server-side ids-only mode, so the adapter drains the matching set in 2000-feature pages and projects each GeoJSON `id`. The drain strips `Query.outFields` and `Query.returnGeometry` (the GeoJSON `id` is read from each feature's top-level field, so neither knob affects the result) so the request cannot push the geometry property onto the wire and a caller-supplied `returnGeometry: false` cannot trip the field-projection guard. `Query.pagination.limit` caps the global id count (callers can stop the drain without learning the server's page size) and `Query.pagination.offset` chooses where the drain starts. `pagination.limit === 0` is treated as an explicit zero cap across `query`, `stream`, and `queryObjectIds` (each short-circuits before the wire call); `queryAll` still issues a single 1-row lookahead so `exceededTransferLimit` can flip when more records exist — matching the `withPagingBounds` / `applyQueryAllLimit` semantics shared with GeoServices and OGC Features. Content negotiation prefers `application/geo+json` / `application/json` when the server's `OperationsMetadata` advertises it; if only GML is offered the canonical `query()` throws and callers reach the GML payload through `Source.protocol("wfs")`. GML decoding is intentionally out of scope. `applyEdits` builds a single `` POST body (`` / `` / ``) and surfaces the per-handle `InsertResults` IDs onto `EditOutcome.id`. Each `` is stamped with a stable `handle="add-N"` (1-based, matching `envelope.adds` order) and the returned `` buckets are indexed by that handle, so reordered or omitted (`releaseAction="SOME"` partial failure) buckets never misassign IDs to the wrong `envelope.adds[i]`; inserts whose handle is missing from the response surface as `{ success: false }`. The handle attribute is informational in WFS 2.0, so when no `` carries one the adapter falls back to the legacy positional pairing rather than dropping every id. `rollbackOnFailure` drives the transaction `releaseAction` (`ALL` vs `SOME`). Updates whose `id` is `undefined` / `null` are filtered out before the transaction body is built and surface as per-item failures (`{ success: false, error: { code: 400, description: "update.id is required" } }`) so an unaddressed `` can never reach the server; if every operation in the envelope is absent or malformed the wire round-trip is skipped. Stored-query discovery (`ListStoredQueries`) and execution (`GetFeature?storedquery_id=...`) are reachable through `Source.protocol("wfs")!.root.storedQuery(id).execute({ parameters })`. Stored queries that advertise only GML (e.g. Honua Server's `urn:ogc:def:query:OGC-WFS::GetFeatureById`) cannot be projected onto the canonical `Source.query()` envelope; the canonical surface throws `HonuaCapabilityNotSupportedError("query")` and points the caller at the protocol escape hatch. Locking (`LockFeature` / `GetFeatureWithLock`) is not exposed in the canonical surface; callers that need it reach the wire through `Source.protocol("wfs")`. The capabilities XML walker refuses any document declaring `` or `` to defend against XXE-class attacks. WFS `Result.totalCount` populates from the `numberMatched` GeoJSON field; `exceededTransferLimit` is set when `numberMatched > features.length`. ### WMS First-party WMS 1.3.0 adapter. `render` and `tiles` come from `GetMap`; `query` is supported through `GetFeatureInfo` with a point spatial filter (`Query.spatialFilter.geometryType === "esriGeometryPoint"`). The adapter constructs a 1×1 render envelope around the requested point, asks for `INFO_FORMAT=application/json`, and decodes the JSON response into the canonical `Result` envelope. The wire CRS is derived from the spatial filter geometry's `spatialReference` (`latestWkid` first, then `wkid`, then `wkt`) and falls back to `CRS:84` (the WMS 1.3.0 longitude/latitude code that preserves the canonical `(x, y)` axis order). `Query.outSr` is intentionally not consulted on this path because it is the **output** spatial reference, not the input CRS for GetFeatureInfo. Non-point queries throw `HonuaCapabilityNotSupportedError("query", "wms", id)` because WMS has no spatial-rel semantics for envelopes / polygons; raw multi-pixel GetFeatureInfo lives behind `Source.protocol("wms").featureInfo()`. Other canonical `Query` fields that GetFeatureInfo cannot honor are rejected up front rather than silently dropped: `query({ aggregation })` throws `HonuaCapabilityNotSupportedError("queryAggregate", ...)`, and `Query.where` / `Query.outFields` / `Query.orderBy` / `Query.pagination.offset` / `Query.returnGeometry === false` / `Query.outSr` throw typed `Error` messages so a mixed-source caller cannot get an unfiltered, reprojected, or differently-shaped result. `Query.outSr` fails fast because honua-server's WMS GetFeatureInfo projects the response in the request CRS itself and exposes no separate output-SR knob — callers that need a specific projection must stamp the spatial filter geometry's `spatialReference` with the desired CRS (the wire CRS is derived from there) or reproject the result client-side. `Query.pagination.limit` is honored — it maps to `FEATURE_COUNT` on the wire. `Source.protocol("wms-layer")` is registered only when `locator.typeName` parses to a single non-empty layer token because `HonuaWmsLayer` is a single-layer handle (its `describe()` resolves exactly one `` from the parsed Capabilities). Multi-layer composites (`typeName: "a,b"`) keep `Source.protocol("wms-layer")` unset and route through the service-level `Source.protocol("wms")` handle, which can target the composite verbatim via `featureInfo()` / `map()`. Styled-map selection enumerates per-layer styles from `HonuaWms.capabilities()` and is bound on the layer handle (`layer.map`, `layer.featureInfo`) via the `style` parameter or descriptor `locator.styleId`. Dimension handling (`TIME` / `ELEVATION`) flows through the typed `WmsMapRequest` envelope; defaults come from `Capabilities`, request overrides go on the wire. honua-server does not implement `GetLegendGraphic` today, so the adapter raises `HonuaCapabilityNotSupportedError` from `legend()` when the parsed Capabilities advertise no `` request element. The gating always runs: when the caller does not pre-supply `options.capabilities`, the handle lazy-loads them once via `getWmsCapabilities` and caches the in-flight promise on the instance so repeat `legend()` calls reuse the same fetch (transient failures clear the cache so the next call retries). The `HonuaWms` parser extracts each ``'s own ``, ``, `<CRS>`, `<BoundingBox>`, `<Style>`, and `<Dimension>` from the layer's direct children only — descendant `<Layer>` subtrees are stripped before metadata reads so child fields never leak upward into the parent (or, through ancestor-merge, sideways into sibling layers). CRS axis order is honored per WMS 1.3 §6.7.3.2 — `EPSG:4326` is swapped to (lat, lon) on the wire while `CRS:84` and `EPSG:3857` keep canonical (x, y) tuples. MapLibre integration ships through `buildWmsRasterSourceSpec(descriptor)`, which emits a `raster` source spec with a pre-baked KVP `tiles` template using MapLibre's runtime `{bbox-epsg3857}` / `{width}` / `{height}` placeholders. ### WMTS First-party WMTS 1.0.0 adapter. Render-only — `Source.query()` throws because WMTS GetFeatureInfo is keyed on tile pixels (which doesn't fit the canonical `Query.spatialFilter`). Capabilities expose advertised TileMatrixSets through the typed surface; honua-server only advertises `WebMercatorQuad` today and the parser-driven design tolerates additional sets without a client refactor. Protocol escape hatches: `Source.protocol("wmts")` returns the service handle, `Source.protocol("wmts-layer")` a layer-bound handle, and `Source.protocol("wmts-tileset")` a (layer × style × TMS) tileset handle. `fetchWmtsTile` defaults to the RESTful route (`{layer}/{style}/{tms}/{z}/{y}/{x}.{ext}`) because it is a single string substitution per tile and skips `URLSearchParams`; `mode: "kvp"` is opt-in for KVP-only proxies. The `Format` MIME → RESTful `.ext` mapping is canonical and shared between the wire client and the MapLibre helper via `wmtsExtensionForFormat` (`src/core/wms-types.ts`): `image/png` → `png`, `image/jpeg` / `image/jpg` → `jpeg`, `image/webp` → `webp`; unknown formats fall back to `png`. The same caller-supplied `format` therefore lands on the same path extension whether the URL is composed by `fetchWmtsTile` or `buildWmtsRasterSourceSpec`. `request.extraParams` is honored on both routing modes — under `mode: "kvp"` keys are merged into the query string verbatim; under the default RESTful route the path-encoded WMTS keys (`LAYER`, `STYLE`, `TILEMATRIXSET`, `TILEMATRIX`, `TILEROW`, `TILECOL`, `FORMAT`, `INFOFORMAT`, `I`, `J`, plus `SERVICE` / `VERSION` / `REQUEST`) take precedence and any conflicting `extraParams` keys (case-insensitive) are dropped so the same URL never carries the value twice. `buildWmtsRasterSourceSpec(descriptor)` emits a MapLibre `raster` source spec using the same RESTful path with `{z}/{y}/{x}` placeholders. ### OData First-party adapter: `query`, `queryObjectIds`, `stream`, `applyEdits`. Tabular query with `$filter`, `$select`, `$orderby`, `$top`, `$skip`, `$expand` maps cleanly onto `Query.where`, `outFields`, `orderBy`, `pagination`. `Query.where` accepts SQL-92 / OData `$filter` text; the adapter rewrites a small intersection (`IS NULL` → `eq null`, `<>` → `ne`, `=` → `eq`, plus the SQL comparison operators `>=` / `<=` / `>` / `<` → `ge` / `le` / `gt` / `lt`) and rejects operators the parity matrix documents as unsupported (`has`, `in`, `any`, `all`, `cast`, `isof`) rather than letting them reach the wire. The rewrite tokenizes single-quoted string literals (with `''` escape sequences preserved) so values like `NAME = 'A=B'` or `NAME = 'has'` or `NAME = 'A>B'` round-trip unchanged — rewrites and unsupported-operator detection only run against non-literal spans. `Query.outFields` lowers onto OData's `$select` for plain field names and `$expand` for navigation paths. Plain entries (`["STATE", "ACRES"]`) become `$select=STATE,ACRES`. Dotted entries identify a navigation property and a sub-field — `["Owner.name"]` becomes `$expand=Owner($select=name)`, multiple sub-fields under the same navigation share one expand entry (`$expand=Owner($select=name,email)`), and multi-level dotted paths nest as `$expand=Owner($expand=address($select=street))`. The same `outFields` list can mix plain and dotted entries; each goes to the matching query option so a server that distinguishes `$select` from `$expand` honors both. The descriptor locator resolves to the entity-set request path one of two ways. SDK callers that pass `locator.entitySet` (e.g. `"Parcels"` or the navigation path `"Layers(1)/Features"`) win unchanged. Bindings produced by Honua Server only carry `url`, `serviceId`, and `layerId`, so when `entitySet` is absent the adapter derives the canonical layer-scoped path `Layers(<layerId>)/Features` from `locator.layerId`. A descriptor that has neither field is rejected with `createDataset: source "<id>" (odata) requires locator.entitySet or locator.layerId`. The resolved token is the wire path used for entity requests; CSDL metadata lookups (capabilities, key fields, schema) key on the **unqualified entity-set name** instead — for navigation paths that is the trailing segment, exposed as `HonuaOdataEntitySet.entitySetName` so the lookup still hits the `<EntitySet Name="…">` entry the server emits. `Query.spatialFilter` translates to a `geo.intersects` / `geo.distance` predicate against the geometry column. Only `esriSpatialRelIntersects` / `esriSpatialRelEnvelopeIntersects` (intersects) and `esriSpatialRelDistance` (distance, requires `spatialFilter.distance`) are accepted; other relations throw rather than silently widen. The WKT literal carries the **input** geometry's SRID (resolved from `spatialFilter.geometry.spatialReference.{wkid|latestWkid}` first, then the metadata-declared SRID on the *selected* geometry column — matched by name when the descriptor or metadata pinned a column, never borrowed across columns when an entity type declares more than one spatial field); `Query.outSr` is a request for the *output* CRS and is not stamped onto the input WKT (the OData server has no request-side output-CRS knob — output geometry comes back in the column's declared SRID). The geometry column resolves from `SourceDescriptor.schema.fields` first (prefers a field whose canonical `type === "esriFieldTypeGeometry"`), otherwise from the lazy `$metadata` probe (the first property typed `Edm.Geography` / `Edm.Geometry` per CSDL parsing). For spatial filters the adapter throws when neither declares one rather than guess at the column name; the same resolved name is reused by `returnGeometry === false` $select trimming and the row-to-feature geometry split, so a schema-declared spatial column named anything other than `Geometry` / `Geography` / `Shape` is honored end-to-end. `Query.returnGeometry === false` keeps the geometry column off the wire: when `outFields` is set the resolved geometry column is filtered out of `$select`; when `outFields` is unset the adapter derives `$select` from `$metadata` to include only non-spatial columns. The canonical name guesses (`Geometry` / `Geography` / `Shape`) are kept as a fallback for the `$select` filter only when neither the descriptor schema nor `$metadata` declares a spatial column. As a defensive backstop, the canonical `Result` drops geometry from each feature even if the server still emitted it. Server-driven pagination (`$skiptoken` via `@odata.nextLink`) is consumed transparently inside `queryAll()` and `stream()`. `queryAll` honors `Query.pagination.limit` with the GeoServices/OGC `limit + 1` lookahead pattern (sent on the wire as `$top = limit + 1`) so `exceededTransferLimit: true` is stamped accurately when the cap is hit. As a belt-and-braces signal, `@odata.count > limited.length` also sets the flag for servers that respect `$top` exactly but report a higher matched-row total. The lookahead is added by the canonical `Source.queryAll` only — the lower-level `HonuaOdataEntitySet.queryAll` treats `params.top` as an exact hard cap (including `top === 0`, which collects zero rows) so callers that already added a lookahead row do not double-count. `queryObjectIds` projects the metadata key field through `$select`, drains the matching set, and slices the resulting id list to `Query.pagination.limit` (no lookahead row is needed because the result is ids, not features, and there is no `exceededTransferLimit` flag to stamp on `FeatureId[]`); a `pagination.limit === 0` cap collapses to an empty array. `applyEdits` routes adds → `POST /<entitySet>`, updates → `PATCH /<entitySet>(<key>)` with the full canonical body, deletes → `DELETE /<entitySet>(<key>)`. PUT is not issued — Honua Server's OData v4 parity matrix lists PUT as unsupported (PATCH-only); the documented `PATCH with full body` path matches `PUT` replacement semantics on the canonical surface. Composite-key entities address rows by passing the pre-formatted key expression on `deletes: FeatureId[]` (e.g. `"LayerId=1,ObjectId=3"`) or by populating each key field on `updates[].attributes`. Layer-scoped paths (`Layers(<n>)/Features`) carry the parent key (`LayerId`) in the URL itself, so callers can — and should — pass a bare ObjectId on `feature.id` or `deletes: FeatureId[]`; the adapter strips `LayerId` from the entity-set key parens to produce `/odata/Layers(<n>)/Features(<objectId>)` directly. When `EditEnvelope.rollbackOnFailure === true` AND `$metadata` advertises `Capabilities.BatchSupported`, the adapter collapses the envelope into a single `$batch` request whose every operation carries the same `atomicityGroup` token. Honua Server's `ODataBatchHandler` groups requests by `request.AtomicityGroup` and rolls the entire group back when any operation fails. The per-operation outcomes are threaded back into the canonical `EditOutcome` buckets in the original order. When `$batch` is not advertised, the adapter degrades to per-call edits and stamps a `degraded[]` entry citing `applyEdits` so downstream views can flag the result as non-atomic. `queryAggregate`, `queryExtent`, `queryRelated`, and `attachments` are intentionally absent from the canonical surface. Aggregation lives behind `Source.protocol("odata").apply(...)` (`$apply` driver), extent computation is a caller-side concern when the entity exposes `Edm.Geography`, related navigation goes through `$expand` on the escape-hatch `query()` (or `protocol("odata").raw(...)`), and attachments are not in the parity matrix. OData is the **first adapter** to implement automatic metadata-driven capability intersection. The lazy `$metadata` fetch on first method call parses `Capabilities.*` annotations and intersects against the descriptor's declared `Capabilities`; declared capabilities the service explicitly advertises as `false` raise `HonuaCapabilityNotSupportedError` with both protocol and reason in the message. The CSDL parser accepts both annotation shapes — inline inside `<EntitySet>` *and* sibling `<Annotations Target="<schema>.<container>/<entitySet>">` blocks (the form Honua Server emits next to the `EntityContainer`) — and parses the OData v4 capability vocabulary including `Insertable` / `Updatable` / `Deletable` / `Searchable` / `Filterable` / `Selectable` / `Expandable` / `Countable` plus the generic `Supported` property records use for `ChangeTracking`. Other adapters (GeoServices `supportsStatistics`, OGC `conformsTo`) follow the same pattern but currently rely on caller-supplied caps; the contract doc tracks this as the precedent for that future work. Dialect-specific `$batch`, `$apply`, `$search`, and `$deltatoken` operations live behind `Source.protocol("odata")` on a `HonuaOdataEntitySet` instance: - `metadata({ refresh? })` — cached, SDK-shaped projection of `$metadata` (`entitySets`, `keys`, `fields`, `capabilities`). - `batch(operations, { atomicity })` — JSON `$batch` envelope. Pass `atomicity: "all"` to stamp the same `atomicityGroup` token on every request item so the server runs them as one change-set with rollback on any failure (per OData v4 §11.7.7.3 and Honua Server's `ODataBatchHandler`, which groups by `request.AtomicityGroup`). Default behavior (`"none"`) leaves the field unset so each request runs independently. - `apply(transformations)` — `$apply` driver (`aggregate` / `groupby` / `filter` / `compute`) returning `{ rows, totalCount? }`. - `search(text, params)` — `$search` driver returning a `HonuaOdataPage<T>`. - `delta({ since? })` — async-iterable `$deltatoken` change feed; the final page carries `deltaLink` for the caller to persist. - `raw(method, path, init?)` — last-resort passthrough that still flows through `HonuaClient.pipelineFetch` (auth headers, retry, timeout, interceptors, telemetry, normalized `HonuaHttpError`, `init.signal` propagation for caller-driven cancellation). All HTTP traffic — including `$metadata` discovery and `raw()` passthrough — is routed through `HonuaClient.pipelineFetch` / `pipelineRequestJson` rather than the GeoServices-shaped `HonuaClient.request()` helper, so the OData wire request never carries the `f=json` parameter (Honua Server's OData validators reject it as `InvalidQueryOption`). Auth headers, interceptors, retry, timeout, and normalized error mapping all apply to OData the same way they do to every other adapter. Library posture is recorded in [`decisions/odata-library-selection.md`](./decisions/odata-library-selection.md). ### PMTiles Tiles-only. A PMTiles archive is a single immutable file (raster or vector tile pyramid) on any static host or object storage. The default capability set is `{ tiles }`, so the entire canonical query family (`query` / `queryAll` / `queryExtent` / `queryObjectIds` / `applyEdits` / `stream`) throws `HonuaCapabilityNotSupportedError` — an archive has no feature-query surface. Archive metadata (bounds, min/max zoom, and vector layer names) is inspected through the typed escape hatch: `Source.protocol("pmtiles").describe()` (or the standalone `describePmtilesArchive(url)` helper), which returns a normalized `PmtilesArchiveDescription`. The `pmtiles` package is an optional peer dependency imported lazily — a build that never inspects or renders a PMTiles archive pays no cost. The MapLibre runtime auto-registers the `pmtiles://` protocol on map attach (`loadMapPackage`), so a `pmtiles` MapPackage source binding renders with no manual `addProtocol` wiring; see [`pmtiles.md`](./pmtiles.md). ## Multi-source negotiation When more than one source participates in a composition, the composition supports a capability **only when every participating source supports it**. Use `intersectCapabilities` from `@honua/sdk-js/contract` to compute that weakest set: ```ts doc-test=compile import { PROTOCOL_DEFAULT_CAPABILITIES, intersectCapabilities, } from "@honua/sdk-js/contract"; const weakest = intersectCapabilities([ { capabilities: PROTOCOL_DEFAULT_CAPABILITIES["geoservices-feature-service"] }, { capabilities: PROTOCOL_DEFAULT_CAPABILITIES.wms }, ]); weakest.has("query"); // true weakest.has("applyEdits"); // false — WMS lacks edits ``` The helper is structurally typed on `{ capabilities }` so it accepts both `SourceDescriptor[]` (declared capabilities) and live `Source[]` (post-metadata-negotiation capabilities — the OData adapter, for example, intersects the descriptor's declared set with `$metadata` flags on first capability-gated method). Promising a capability the weakest source lacks is the worst possible mixed-source failure mode (silent wrong result). The full guide and the per-operation partitioning pattern live in [`composition.md`](./composition.md). ## Maintaining the matrix When you add or remove a capability for any protocol: 1. Update `PROTOCOL_DEFAULT_CAPABILITIES` in `src/contract/types.ts`. 2. Update this table. 3. Update the conformance scenario in `test/contract/` so the canonical tests fail the right way for the new shape. 4. Per-source downgrades are the caller's responsibility today: pass an intersected `Capabilities` set on `SourceDescriptor.capabilities`. When automatic metadata-driven downgrades land in the adapter constructors, document the override in the adapter's source file and add a unit test covering the intersected set. 5. Adapters that emit `Result.degraded[]` should populate `DegradedReason.sourceId` with `descriptor.id` so mixed-source fan-outs can attribute the degradation back to the source that emitted it. --- # File: docs/standalone-capability-matrix.md # Backend-Agnostic vs Honua-Server-Enhanced Capability Matrix This is the honest line between what `@honua/sdk-js` does against **any** standards-speaking server (no Honua infrastructure) and what needs a [Honua Server](https://github.com/honua-io/honua-server). It complements the per-protocol [Protocol × Capability Matrix](./protocol-capability-matrix.md), which is the code-of-record for what each protocol's client supports; this page answers the orthogonal question: *does the capability require a Honua server?* See the [standalone quickstart](./standalone-quickstart.md) for the runnable backend-agnostic path. ## Legend - `standalone` — works against any public/self-hosted standards server (an ArcGIS Server / ArcGIS Online endpoint, etc.) with no Honua server, key, or account. Proven live against public endpoints; replayed from fixtures in CI. - `backend-agnostic` — the typed surface addresses a raw third-party endpoint's own path layout (discovered from the server, not a fixed prefix), no Honua infrastructure required. The Honua facade layout stays a detected fast path. Proven from recorded fixtures per raw layout in CI; live-proven against the named public servers by the scheduled `standalone-live-smoke` lane. - `honua-enhanced` — requires a Honua Server (or a server that implements Honua's facade paths). These are the upgrade-path features. - `facade-bound` — the *typed* SDK surface exists and is protocol-correct, but it currently addresses Honua's server facade paths (e.g. `/ogc/features/...`) rather than a raw third-party endpoint's own path layout. Backend-agnostic support for raw endpoints is roadmap; use GeoServices or the backend-agnostic lanes for the standalone path today. ## Matrix | Capability | Standalone? | Backend needed | Sample app / API | Notes | | --- | :-: | --- | --- | --- | | GeoServices FeatureServer query (`queryFeatures`, `queryFeaturesAll`, streaming, count, extent) | `standalone` | Any ArcGIS Server / Online | `examples/standalone-quickstart`, `HonuaClient.queryFeatures`, `Source` (`geoservices-feature-service`) | Raw `/rest/services/.../FeatureServer/{id}/query` path; verified live against `services.arcgis.com`. | | GeoServices MapServer / ImageServer reads (query, export, identify, legend, find) | `standalone` | Any ArcGIS Server / Online | `HonuaClient.mapService` / `imageService` | Raw GeoServices paths; verified live against `sampleserver6.arcgisonline.com`. | | Esri → GeoJSON → MapLibre source | `standalone` | Any ArcGIS Server / Online | `loadHonuaFeatureServiceGeoJson` (`@honua/sdk-js/map`) | One call from a public FeatureServer URL to a MapLibre `geojson` source. | | Esri compat drop-in (`FeatureLayerCompat`, `MapImageLayerCompat`, query/edits API) | `standalone` | Any ArcGIS Server / Online | `@honua/sdk-js/esri-compat`, `examples/standalone-quickstart` | The `esri-leaflet` migration path; parses `services.arcgis.com`-style URLs and builds its own client. | | `honua-migrate` codemod / ArcGIS scanner | `standalone` | None (build-time) | `@honua/sdk-js/migration`, `npm run scan:arcgis` | Static analysis + rewrites; no server involved at all. | | Geometry ops (buffer/area/measure/simplify/reproject) | `standalone` | None (client-side) | `@honua/sdk-js/geometry` | Pure client-side turf/proj4 ops. | | OGC API Features query (`Source` `ogc-features`) | `backend-agnostic` | Any OGC API Features server | `Source` (`ogc-features`, `locator.layout: "ogc-api" \| "auto"`), `HonuaClient.resolveOgcFeaturesLayout` | Discovers the collections/items layout from the landing page `rel="data"`/`rel="conformance"` links (OGC API - Common); item paths follow the `{collections}/{id}/items` template. Fixture-proven against **pygeoapi** (`demo.pygeoapi.io/master`) and **ldproxy** (`demo.ldproxy.net`); same typed `Query` yields an identical `Result` on a raw collection and the Honua facade. Facade (`/ogc/features/...`) stays the zero-round-trip default. | | WFS 2.0 query / GetFeature (`Source` `wfs`) | `backend-agnostic` | Any WFS 2.0 server | `Source` (`wfs`), `HonuaWfs` | Drives a raw `GetCapabilities` endpoint and issues GetFeature against the **DCP operation URL** the server advertises (`ows:DCP/ows:HTTP/*/@xlink:href`), not an assumed path — e.g. a GeoServer mounted at `/geoserver/ows` that advertises `/geoserver/wfs`. Fixture-proven against **GeoServer** (`ahocevar.com/geoserver`). | | STAC search (`Source` `stac`) | `backend-agnostic` | Any STAC API or static catalog | `Source` (`stac`, `locator.layout: "stac-api" \| "stac-static"`), `HonuaStacSearch`, `HonuaStacStaticCatalog` | `stac-api` runs `/search` under a raw API root; `stac-static` walks a static `catalog.json` tree via `rel="child"`/`rel="item"` links with client-side filtering. Fixture-proven against **Earth Search** (`earth-search.aws.element84.com/v1`) and a static catalog tree. | | OData v4 query (`Source` `odata`) | `backend-agnostic` | Any OData v4 service | `Source` (`odata`), `HonuaOdataEntitySet` | Capability-negotiated via `$metadata`; the service `basePath` is taken from `locator.url` so any service root works. Fixture-proven against the OASIS **TripPin** reference service (`services.odata.org/TripPinRESTierService`). | | OGC API Tiles / Maps / Processes / Records (typed surface) | `facade-bound` | Honua Server (facade paths) today | `Source` (`ogc-tiles`, `ogc-maps`, `ogc-records`), `HonuaClient` OGC methods | Protocol-correct clients that still address Honua's `/ogc/...` facade paths; raw third-party layout discovery for these families is roadmap. Use the backend-agnostic Features/WFS/STAC/OData lanes or GeoServices for the standalone path today. | | Server compatibility gate (`checkCompatibility`) | `honua-enhanced` | Honua Server | `HonuaClient.checkCompatibility` | Reads `/api/v1/admin/capabilities`; skip it for standalone reads. | | Authored `MapPackage` runtime (`loadMapPackage`, `HonuaMapRuntime`) | `honua-enhanced` | Honua Server | `@honua/sdk-js/runtime`, `examples/maplibre-quickstart` | Server-authored styles/layer order/metadata. | | Realtime subscriptions | `honua-enhanced` | Honua Server | `@honua/sdk-js/realtime`, `examples/realtime-incident-dashboard` | Subscription-backed live updates. | | Collaboration / saved maps | `honua-enhanced` | Honua Server | `@honua/sdk-js/collaboration` | Shared/saved maps, multi-user sessions. | | MCP tools / AI surfaces | `honua-enhanced` | Honua Server + `@honua/mcp-server` | `mcp/`, `@honua/sdk-js/agent-tools` | Assistant discovery/query over MCP. | ## Rule of thumb If your data lives behind an ArcGIS Server / ArcGIS Online endpoint, an OGC API Features server (pygeoapi, ldproxy, GeoServer OGC API), a WFS 2.0 server, a STAC API or static catalog, or an OData v4 service, the SDK is a drop-in typed client **today, standalone** — point a `Source` at the raw endpoint (set `locator.layout` for OGC API Features / STAC where the server is not a Honua facade). Reach for a Honua Server when you need authored map packages, realtime, collaboration, the MCP/AI surfaces, or the OGC API Tiles / Maps / Processes / Records families (still facade-bound today). --- # File: docs/sdk-surface-alignment.md # Cross-SDK Surface Alignment Status: pending review for https://github.com/honua-io/honua-sdk-js/issues/44. Once the PR merges this becomes the canonical reference for downstream Python and .NET SDK alignment work; flip the status line to `reviewed` at that point. The Honua SDKs should share one mental model even when each language keeps its own naming conventions. JavaScript may use `queryAll()`, Python may use `query_all()`, and .NET may use `QueryAllAsync()`. Those names are allowed to differ. The semantics behind them must not. ## Canonical Semantics Use the `@honua/sdk-js/contract` vocabulary as the current source of truth: | Concept | Meaning | | --- | --- | | `Dataset` | Logical grouping of one or more sources plus compatibility checks. | | `SourceDescriptor` | Serializable source identity: protocol, locator, capabilities, schema, and attribution. | | `Source` | Runtime handle for the common query, edit, related-record, attachment, and escape-hatch surface. | | `Protocol` | Stable source protocol identifier. | | `Capability` | Stable operation capability identifier. | | `Query` | Protocol-neutral request intent. | | `Result` | Protocol-neutral result envelope. | | `EditEnvelope` / `EditResult` | Protocol-neutral write envelope and result. | | `MapBinding` | Source → renderer-layer binding (used by `MapPackage` round-trips). | | `protocol(...)` | Explicit native protocol escape hatch. | These are the canonical nouns pinned in [`test/fixtures/sdk-contract/semantic-contract.v1.json`](../test/fixtures/sdk-contract/semantic-contract.v1.json) and validated by the drift gate ([`test/contract/sdk-contract-fixtures.test.ts`](../test/contract/sdk-contract-fixtures.test.ts)). Renaming a noun without updating both sites trips CI. ## Language Bindings Language bindings must be idiomatic. The binding table below is guidance for public API names, not a byte-for-byte requirement: | Concept | TypeScript | Python | .NET | | --- | --- | --- | --- | | query all records | `queryAll()` | `query_all()` | `QueryAllAsync()` | | stream pages | `stream()` | `stream()` / `iter_*()` | `StreamAsync()` / `QueryPagesAsync()` | | query object ids | `queryObjectIds()` | `query_object_ids()` | `QueryObjectIdsAsync()` | | apply edits | `applyEdits()` | `apply_edits()` | `ApplyEditsAsync()` | | return geometry | `returnGeometry` | `return_geometry` | `ReturnGeometry` | | output fields | `outFields` | `out_fields` | `OutFields` | | pagination | `pagination` | `pagination` / `limit` / `offset` | `Pagination` / `Limit` / `Offset` | | native protocol escape hatch | `source.protocol(...)` | `source.protocol(...)` | `source.Protocol(...)` | Existing SDK names can remain as aliases or facades. For example, Python's `FeatureQuery` and .NET's `FeatureQueryRequest` can continue to exist while new source-oriented facades converge on the same behavior. ## Stable Protocol IDs The canonical protocol identifiers are the values exported from `PROTOCOLS` in `src/contract/types.ts`. Serialized descriptors and docs should prefer the canonical identifiers; SDKs may accept short or legacy aliases for already-shipped public names by normalizing on read through the `protocolAliases` map in the fixture pack: - `grpc` - `geoservices-feature-service` - `geoservices-map-service` - `geoservices-image-service` - `geoservices-geometry-service` - `geoservices-gp-service` - `ogc-features` - `ogc-tiles` - `ogc-maps` - `stac` - `wfs` - `wms` - `wmts` - `odata` - `maplibre-vector` - `maplibre-raster` - `maplibre-geojson` ## Stable Capability IDs The canonical capability identifiers are the values exported from `CAPABILITIES` in `src/contract/types.ts`. SDKs should gate behavior on these semantic identifiers rather than inventing per-repo names for the same operation. Default capability sets per protocol live in `PROTOCOL_DEFAULT_CAPABILITIES` and are mirrored in [`docs/protocol-capability-matrix.md`](./protocol-capability-matrix.md). Capability misses must be explicit. The required behavior is a typed SDK error that names the missing capability and protocol (`HonuaCapabilityNotSupportedError` in JS; equivalent typed errors in Python and .NET). Returning an empty result for an unsupported operation is not acceptable because it is indistinguishable from a valid query with no matches. ## Query Semantics All SDKs should preserve these query semantics even when the local request type uses language-specific names: - `where`: logical attribute filter. Adapters translate it to SQL-92, CQL2, OData `$filter`, FES, or provider-native syntax as appropriate. - `spatialFilter` / `bbox`: spatial constraint. SDKs may expose a compact bbox helper, but the behavior must be documented. - `outFields`: fields or properties to return. - `orderBy`: provider-supported sort order. - `pagination.offset`: records to skip before returning data. - `pagination.limit`: record cap. `query()`, `queryAll()`, and `stream()` must document whether the limit is per-page or total. - `returnGeometry`: whether returned features include geometry. - `outSr`: requested output spatial reference when the protocol supports it. - `signal` / cancellation token: caller-driven cancellation. ## Result Semantics All SDKs should expose the same result facts: - features are an array, empty when no records matched. - each feature has an identifier when available, attributes/properties, and optional geometry. - result envelopes preserve paging state (`exceededTransferLimit`, `hasMoreResults`, or an idiomatic alias). - total count is present when the protocol reports it. - field schema is present when the protocol reports it. - extent is present for extent-only or extent-enriched queries. - degraded results carry structured reasons with capability, protocol, and optional source id. - raw/provider-native payload access remains available for advanced workflows. ## Backwards Compatibility And Deprecation Policy The RFC aligns semantics — it does not delete shipped APIs. - **Existing public names are preserved** as aliases or facades over the canonical surface. JS keeps `Source.adapter()` as a documented alias of `Source.protocol()`; Python keeps `FeatureQuery` and friends; .NET keeps `FeatureQueryRequest` and friends. Each binding may add the canonical name alongside the legacy name without renaming the legacy name. - **Deprecation requires a documented removal timeline.** A binding that introduces a canonical alternative may mark the legacy name deprecated and remove it at the next major version, never sooner. The deprecation note must point at the canonical replacement and (where applicable) at this RFC. - **Protocol aliases normalize on read.** Short and legacy protocol names that already shipped in any SDK appear in the `protocolAliases` map of [`semantic-contract.v1.json`](../test/fixtures/sdk-contract/semantic-contract.v1.json). SDKs accept those aliases as input, but every SDK-side serialized descriptor — `SourceDescriptor` JSON, fixture pack, telemetry, log line — emits the canonical kebab-case id. The server `SourceBinding` wire format is independent: it stays on its documented snake_case enum (`geoservices_feature_service`, `ogc_features`, …) per [`source-binding-alignment.md`](./source-binding-alignment.md), and the runtime normalizes server wire values to canonical SDK ids inside `src/runtime/source-bridge.ts` before they reach `createDataset`. A server-side wire-format rename would be a coordinated cross-repo contract change, not a downstream SDK edit. Adding a new alias is backwards- compatible; renaming a canonical id is not. - **Capability behavior may not silently change.** Adding a capability to a protocol is backwards-compatible. Removing one (or downgrading from first-party to client-side fallback) is a breaking change and must ship in a major version with a `degraded[]` migration path documented in the per-protocol notes of `docs/protocol-capability-matrix.md`. - **`canonicalNouns` is part of the contract.** Renaming `Dataset`, `SourceDescriptor`, `Source`, `Protocol`, `Capability`, `Query`, `Result`, `EditEnvelope`, `EditResult`, or `MapBinding` is a breaking change to every downstream SDK; the drift gate enforces this. ## Decisions Locked By This RFC These are the alignment points the RFC nails down. Any future change to one of these requires a follow-up RFC plus coordinated multi-SDK updates. - Canonical protocol identifiers and the canonical → alias normalization direction. - Canonical capability identifiers and per-protocol default capability sets. - Query field names and semantics for `where`, spatial filter / bbox, `outFields`, `orderBy`, `pagination`, `returnGeometry`, `outSr`, and cancellation. - Result envelope shape: features array (never undefined), pagination state, `totalCount`, field schema, extent, degraded reasons, and raw payload access. - Unsupported-capability behavior: typed error, never a silent no-op or empty result. - Native protocol escape hatch: every protocol-specific operation that does not belong on the shared surface lives behind the binding-idiomatic `protocol(...)` accessor (`Source.protocol(...)` in JS / Python, `Source.Protocol(...)` in .NET). The shared surface stays free of protocol-typed structures. ## Fixture Pack The cross-SDK fixture pack lives under [`test/fixtures/sdk-contract/semantic-contract.v1.json`](../test/fixtures/sdk-contract/semantic-contract.v1.json). It is intentionally JSON-only so Python and .NET can consume the same payloads without depending on the JS test helpers. Coverage today: - `protocols`, `capabilities`, and `defaultCapabilities` are pinned against the live JS exports — drift trips CI. - `protocolAliases` records the short / legacy names already shipped (or about to ship) in Python and .NET so each SDK normalizes on read. - `canonicalNouns` enumerates the ten contract nouns the drift gate enforces. - `languageBindings` documents idiomatic per-language method and field names. - `resultScenarios` cover FeatureServer, OGC API Features, STAC, OData, and WFS; included variants exercise nominal queries, empty pages, exceeded transfer limits, and the `queryAggregate` client-side degraded fallback for OGC API Features. - `unsupportedCapabilityScenarios` cover render-only protocols (WMTS, OGC API Tiles, OGC API Maps), utility-only GeoServices protocols (Geometry Service, GP Service), and protocol-specific capability gaps (OGC API Features `queryRelated`). The JS drift gate in `test/contract/sdk-contract-fixtures.test.ts` validates that the fixture pack stays aligned with the exported JS contract registries and with the documented result/error/degraded semantics. Downstream SDK work should consume this fixture pack directly or mirror it with a clear checksum/update process. ## Downstream Implementation Work This RFC is JS-side and docs-only. The Python and .NET SDK alignment lands as separate downstream tickets: - **Python**: `honua-sdk-python` will pin against `semantic-contract.v1.json`, expose the canonical surface as `Source.query_all` / `Source.protocol(...)`, and keep `FeatureQuery` plus the existing free-function helpers as backwards-compatible facades. Track via `honua-io/honua-sdk-python` issue (TODO: add link once issue is filed). - **.NET**: `honua-sdk-dotnet` will mirror the same fixture pack, expose `Source.QueryAllAsync` / `Source.Protocol(...)` / `Source.QueryObjectIdsAsync`, and keep `FeatureQueryRequest` as a facade. Track via `honua-io/honua-sdk-dotnet` issue (TODO: add link once issue is filed). This ticket explicitly does not edit either repository. Filing the downstream issues is part of merging this RFC; updating the placeholders above with the real issue links is the only follow-up edit allowed inside `honua-sdk-js` once the downstream tickets are open. ## References - Canonical type definitions: [`src/contract/types.ts`](../src/contract/types.ts) - Cross-protocol matrix: [`docs/protocol-capability-matrix.md`](./protocol-capability-matrix.md) - Shared client contract design: [`docs/shared-client-contract.md`](./shared-client-contract.md) - Source-binding round-trip: [`docs/source-binding-alignment.md`](./source-binding-alignment.md) - Drift gate test: [`test/contract/sdk-contract-fixtures.test.ts`](../test/contract/sdk-contract-fixtures.test.ts) - Tracking issue: https://github.com/honua-io/honua-sdk-js/issues/44 - Cross-repo coordination: `honua-io/honua-server#813` --- # File: docs/errors.md # Error reference The Honua JS SDK exposes a small, named error hierarchy from `@honua/sdk-js`. Every error thrown by the SDK is an instance of one of the classes below, plus the runtime-type guard `isHonuaError(error)` for ergonomic narrowing in `catch` blocks. Use them to gate retry / refresh / surface-to-user decisions instead of parsing message strings. ## At-a-glance table | Class | Source | When it fires | Recover by | |-------|--------|---------------|------------| | `HonuaHttpError` | Any REST call | The server returned a non-2xx status with a parsed error envelope (4xx or 5xx). | Branch on `.statusCode`: `401`/`403` → refresh credentials or surface to user; `404` → treat as missing; `409` → conflict, refetch and retry; `429` → respect `Retry-After`; `5xx` → use the SDK's `retry` option or back off and retry idempotent calls. | | `HonuaTimeoutError` | Any REST call | The `timeoutMs` configured on the client elapsed before the response arrived. | Increase `timeoutMs` per-call (the per-request `AbortSignal` is independent), or surface a "server slow" indicator. The request is idempotent-safe to retry. | | `HonuaNetworkError` | Any REST call | The transport itself failed (`fetch` rejected) — DNS, TLS, offline, or upstream connection reset. | Inspect `.cause` if present; back off and retry. For browsers, this is also the most common error to render as "Check your connection." | | `HonuaAbortError` | Any REST call | The caller's `AbortSignal` was aborted (or the SDK aborted on timeout — see `HonuaTimeoutError` for that case). | Do **not** retry. The caller asked to stop. Treat as a successful cancellation. | | `HonuaGrpcError` | `transport: "grpc-web"` only | A gRPC-Web call returned a non-OK `Code`. | Branch on `.code` (Connect/`google.rpc.Code`): `UNAUTHENTICATED` → refresh credentials, `PERMISSION_DENIED` → surface; `UNAVAILABLE` → retry with backoff; `DEADLINE_EXCEEDED` → increase deadline or retry; `INVALID_ARGUMENT` → fix the call site. | | `HonuaAuthError` | `@honua/sdk-js/auth` providers (`oauth2`, `clientCredentials`) | A credential could not be produced. | Branch on `.code`: `interaction_required` → start interactive sign-in (`auth.signIn()`); `refresh_failed` → transient token-endpoint failure, retry later; `invalid_grant` → refresh token/authorization code expired or revoked (the stored credential is cleared) → interactive sign-in. The underlying transport/parse failure, when present, is on `.cause`. | | `HonuaCapabilityNotSupportedError` | `Source.query` / `Source.applyEdits` / etc. | Under the default `capabilityPolicy: "strict"`, the active source does not support the requested operation (e.g. `query()` on a `wmts` source). | Either downgrade the request (drop the unsupported clause), fall back to `Source.protocol(...)` for raw protocol access, or set `capabilityPolicy: "degraded"` on `createDataset` to coerce best-effort behavior with a `degraded` reason in the `Result`. | | `HonuaDiscoveryError` | `connect`, discovery truth, cache identity | Endpoint metadata, protocol hints, source selection, or cached discovery observations are invalid or ambiguous. | Branch on `.code`: provide an explicit supported protocol for `ambiguous-protocol`; select a listed source ID for `ambiguous-source`; use a reviewed adapter for `unsupported-protocol`; evict/rebuild entries for `invalid-discovery-cache`. Do not retry unchanged invalid input. | | `HonuaExplorationContextError` | `@honua/sdk-js/exploration` | An exploration intent referenced a missing slice / view, or the snapshot is incompatible with the active context schema. | Surface to user (UI bug) or migrate the saved snapshot. Do not retry. | | `HonuaWfsExceptionError` | `wfs` adapter | The WFS server returned a `<ows:ExceptionReport>`. The original `exceptionCode`, `locator`, and `exceptionText` are preserved on the instance. | Branch on `.exceptionCode` (`InvalidParameterValue`, `OperationNotSupported`, `MissingParameterValue`, etc.). Most are caller bugs; surface to user. | | `HonuaJobFailedError` | OGC Processes / geoprocessing job polling | An async job (`IJobRun.results()`) reached a non-success terminal state (`failed` / `dismissed`). The terminal `.status`, `.errorCode`, and `.details` are preserved on the instance. | Branch on `.status` / `.errorCode`. Usually a server-side or input error; surface to user. Do not blindly retry. | ## Narrowing in `catch` Prefer the `isHonuaError` guard so unrelated exceptions (e.g. caller TypeErrors in callbacks) propagate normally: ```ts doc-test=skip reason="partial excerpt requires application host context" import { HonuaHttpError, HonuaTimeoutError, HonuaCapabilityNotSupportedError, isHonuaError } from "@honua/sdk-js"; try { await dataset.source("parcels")!.queryAll({ where: "1=1" }); } catch (error) { if (!isHonuaError(error)) throw error; if (error instanceof HonuaCapabilityNotSupportedError) { // expected for capability misses — fall back to a narrower query return fallbackQuery(); } if (error instanceof HonuaHttpError && error.statusCode === 401) { await refreshCredentials(); return retry(); } if (error instanceof HonuaTimeoutError) { notifyUser("Server slow — try again in a moment."); return; } throw error; } ``` ## Retry policy The SDK's built-in retry (`HonuaClientOptions.retry`) automatically handles a subset of these errors when configured: | Error | Retried by built-in `retry`? | |-------|-------------------------------| | `HonuaHttpError` with status in `retryStatuses` (default `[408, 429, 500, 502, 503, 504]`) | Yes | | `HonuaNetworkError` | Yes | | `HonuaTimeoutError` | Yes | | `HonuaGrpcError` with retryable code | **No** — the built-in `retry` policy is not applied to the gRPC-web transport; wrap these calls yourself | | `HonuaAuthError` | **No** — resolved by the auth provider's own silent-refresh / single-flight logic; a 401/403 additionally triggers one force-refresh + replay. Branch on `.code` to sign in or surface. | | `HonuaAbortError` | **No** — caller asked to stop | | `HonuaCapabilityNotSupportedError` | **No** — would never succeed | | `HonuaWfsExceptionError` | **No** — caller bug | | `HonuaExplorationContextError` | **No** — state bug | ## Capability policy `createDataset({ capabilityPolicy: "strict" })` is the default and is recommended for production. It surfaces capability misses as `HonuaCapabilityNotSupportedError` *before* the network call, so unsupported features can never silently degrade to an empty result. `capabilityPolicy: "degraded"` is intended for exploratory tools that prefer best-effort results with an explicit `degraded` reason annotated on the `Result`. --- # File: docs/auth.md # Authentication `@honua/sdk-js` ships a small, pluggable authentication layer. A `HonuaClient` accepts an **auth provider** that resolves credentials on demand; the client attaches them to every request across **all transports** (REST fetch paths and gRPC-web calls), refreshes them transparently before they expire, and force- refreshes once on a replay-safe `401`/`403`. ```ts doc-test=compile import { HonuaClient } from "@honua/sdk-js/honua"; import { oauth2 } from "@honua/sdk-js/auth"; const auth = oauth2({ authorizationEndpoint: "https://idp.example/authorize", tokenEndpoint: "https://idp.example/token", clientId: "my-app", redirectUri: `${location.origin}/callback`, scopes: ["openid", "profile"], }); const client = new HonuaClient({ baseUrl: "https://api.example", auth }); ``` The auth providers live in the dedicated `@honua/sdk-js/auth` subpath so a `HonuaClient`-only consumer never pays the PKCE / token-exchange code size. Only [WebCrypto](https://developer.mozilla.org/docs/Web/API/Web_Crypto_API) is used for PKCE, so there are **no new runtime dependencies** (Node ≥20 exposes `globalThis.crypto`). ## Built-in providers | Provider | Import | Use | Interactive? | |----------|--------|-----|--------------| | `apiKeyAuth(key)` | `@honua/sdk-js/auth` | Static API key → `X-API-Key`. | No | | `bearerTokenAuth(token)` | `@honua/sdk-js/auth` | Static bearer → `Authorization: Bearer …`. | No | | `oauth2(config)` | `@honua/sdk-js/auth` | OAuth2 authorization-code + PKCE (browser). | Yes | | `clientCredentials(config)` | `@honua/sdk-js/auth` | OAuth2 client-credentials (Node/server). | No | All four satisfy the core `HonuaAuthProvider` interface (`getCredentials(context) → credentials`), so they drop straight into `new HonuaClient({ auth })`. You can also pass a bare function (`auth: (ctx) => ({ bearerToken })`) for fully custom logic. ## OAuth2 authorization-code + PKCE `oauth2(config)` returns an `OAuth2Provider` that is **both** the client's auth provider **and** the controller your app drives for the interactive flow. ### Flow (redirect mode — the default) 1. **Start sign-in.** `await auth.signIn()` generates a PKCE verifier (`code_verifier`), its S256 challenge, and an unguessable `state`, stashes the verifier + state in `sessionStorage`, and redirects the browser to the authorization endpoint. (The returned promise never resolves — the page unloads.) 2. **Handle the callback.** When the browser returns to `redirectUri`, call `await auth.handleRedirectCallback()`. It validates `state` (CSRF guard), exchanges the `code` + verifier at the token endpoint, and persists the tokens in the credential store. 3. **Use the client.** Every request now carries `Authorization: Bearer …`. When the access token nears expiry it is silently refreshed via the `refresh_token` grant. ```ts doc-test=skip reason="partial excerpt requires application host context" // On the page that renders your app / callback: if (auth.isRedirectCallback()) { await auth.handleRedirectCallback(); // completes the sign-in } else if (!(await auth.isSignedIn())) { await auth.signIn(); // starts the sign-in (redirects away) } ``` ### Popup mode Set `mode: "popup"`. `await auth.signIn()` opens a popup, waits for the redirect back to `redirectUri`, exchanges the code, and resolves with the stored credential — no full-page navigation. The redirect page still runs your app; it does not need to call `handleRedirectCallback()` because the opener reads the result. ### Silent refresh & the no-refresh-token fallback When a request needs a token and the cached one is expiring (within the clock-skew window, default 60 s), the provider refreshes it using the `refresh_token` grant. If the authorization server did **not** issue a refresh token, the provider throws `HonuaAuthError` with code `interaction_required` — your app should catch it and call `signIn()` again (or use a hidden-iframe / silent-auth strategy of your own). ### Single-flight refresh If _N_ concurrent requests all observe an expiring token, exactly **one** token-endpoint round-trip is made; the other _N − 1_ await the same refresh. This holds both inside the provider and at the client level. ### Lifecycle events ```ts doc-test=skip reason="partial excerpt requires application host context" const handle = auth.on((event) => { switch (event.type) { case "signed-in": /* event.credential */ break; case "token-refreshed": /* event.credential */ break; case "refresh-failed": /* event.error (HonuaAuthError) */ break; case "signed-out": break; } }); handle.remove(); // unsubscribe ``` `await auth.signOut()` clears the stored credential (and calls the optional `revocationEndpoint`), emitting `signed-out`. ## Client-credentials (Node / server) For confidential server clients, use `clientCredentials`. There is no interactive step; tokens are fetched on demand and re-requested when they expire (this grant issues no refresh token). Refresh is single-flight. ```ts doc-test=compile import { clientCredentials } from "@honua/sdk-js/auth"; const auth = clientCredentials({ tokenEndpoint: "https://idp.example/token", clientId: process.env.CLIENT_ID!, clientSecret: process.env.CLIENT_SECRET!, scopes: ["honua.read"], }); ``` > **Never use `clientCredentials` in a browser** — the client secret would be > exposed to every visitor. It is for Node/server processes only. ## Credential stores Credentials are held in a pluggable `CredentialStore`, scoped per origin/service (the key defaults to `${clientId}@${token-endpoint origin}`) so a token for one service is never returned for another. | Store | Import | Persistence | Risk | |-------|--------|-------------|------| | `InMemoryCredentialStore` (default) | `@honua/sdk-js/auth` | JS heap; cleared on reload | Lowest — no other script/tab can read it. | | `sessionStorageCredentialStore()` | `@honua/sdk-js/auth` | Per-tab; cleared on tab close | Readable by any script on the origin (XSS). | | `localStorageCredentialStore()` | `@honua/sdk-js/auth` | Across tabs & restarts | Highest — persists the exposure window. | ```ts doc-test=compile import { oauth2, sessionStorageCredentialStore } from "@honua/sdk-js/auth"; const auth = oauth2({ authorizationEndpoint: "https://identity.example.com/oauth2/authorize", tokenEndpoint: "https://identity.example.com/oauth2/token", clientId: "public-browser-client", redirectUri: "https://app.example.com/oauth/callback", store: sessionStorageCredentialStore(), // opt-in; understand the risk below }); ``` ### Storage & security - **Prefer the in-memory default.** Tokens never touch disk or Web Storage, so a script-injection (XSS) bug cannot exfiltrate a token from another context. - **Web Storage is opt-in for a reason.** `sessionStorage`/`localStorage` are readable by _any_ JavaScript running on the origin. A single XSS vulnerability turns stored tokens into stolen tokens. `localStorage` is worse than `sessionStorage` because it persists across restarts and tabs. - **Credentials are never sent cross-origin.** The client refuses to follow a cross-origin redirect (it never replays your `X-API-Key`/`Authorization` to a redirect target off the configured base origin — see [`errors.md`](./errors.md) and the credential-leak tests). Absolute request URLs to a different origin are rejected outright. - **PKCE only, always S256.** The implicit flow is not supported; the code challenge method is always `S256`. ## Wiring into transports The client resolves credentials through one shared pipeline, so providers apply uniformly: - **REST / fetch** — auth headers are merged onto every request; a replay-safe `401`/`403` triggers one force-refresh and a single retry. - **gRPC-web** (`transport: "grpc-web"`) — a Connect interceptor re-resolves credentials on every call (and every retry attempt). - **Realtime (SSE)** — native `EventSource` cannot set headers and reconnects internally, so realtime streams that need a token should carry it as a query parameter (or use a custom `eventSourceFactory`) and **re-read it on each (re)connect** so a refreshed token is picked up after a drop. Use `await client.getAuthToken()` / `await client.getAuthHeaders()` to obtain the current (refreshed) credential at connect time. ## Esri IdentityManager compatibility Migrated ArcGIS apps that call `IdentityManager.registerOAuthInfos(...)` can be backed by the real engine. Bind an `oauth2(...)` provider to a server and `getCredential(url)` drives the actual PKCE / refresh flow: ```ts doc-test=compile import { oauth2 } from "@honua/sdk-js/auth"; import { identityManager, OAuthInfoCompat } from "@honua/sdk-js/esri-compat"; const info = new OAuthInfoCompat({ appId: "app", portalUrl: "https://portal.example" }); identityManager.registerOAuthInfos([info]); identityManager.registerOAuth2Engine( info.portalUrl, oauth2(info.toOAuth2Config({ redirectUri: `${location.origin}/callback` })), ); // Drives the real flow (silent refresh, or interactive signIn on // interaction_required), registers the token, and returns it: const credential = await identityManager.getCredential("https://portal.example/sharing/rest"); ``` `OAuthInfoCompat.toOAuth2Config()` derives the ArcGIS Portal endpoints (`<portalUrl>/sharing/rest/oauth2/authorize` and `/token`) from `portalUrl` + `appId`; pass explicit endpoints/overrides where needed. ## Errors Auth failures are `HonuaAuthError` (in the typed error hierarchy) with a `.code`: | `.code` | Meaning | Recover by | |---------|---------|------------| | `interaction_required` | No token and no way to get one silently. | `auth.signIn()`. | | `refresh_failed` | Transient token-endpoint failure. | Retry later; the stored credential is kept. | | `invalid_grant` | Refresh token / code expired or revoked. | The credential is cleared → `auth.signIn()`. | See [`errors.md`](./errors.md) for the full hierarchy and the runnable example under [`examples/oauth-signin/`](../examples/oauth-signin/). --- # File: docs/react.md # React bindings (`@honua/react`) `@honua/react` is the idiomatic React layer over the framework-neutral `@honua/sdk-js` core: a context provider, data hooks, and declarative map components. The core SDK stays React-free — React is imported only from this entry point (`@honua/sdk-js/react`, published standalone as `@honua/react`). - **SSR-safe.** Nothing touches `window` / `document` at import time; `HonuaMap` only imports `maplibre-gl` inside a mount effect. - **StrictMode-safe.** Works under React 18 and 19 StrictMode double-invocation — mount/unmount/remount leaks nothing and never double-subscribes. - **Optional peers.** `react` and `react-dom` are optional peer dependencies; `maplibre-gl` is needed only when you render a `HonuaMap`. ## Install ```bash npm install @honua/sdk-js @honua/react react react-dom maplibre-gl ``` Inside this monorepo the same surface is available at the subpath `@honua/sdk-js/react`. ## Provider setup Wrap your app once with a `HonuaClient`. The provider owns a session-scoped query cache shared by every hook below it. ```tsx doc-test=skip reason="partial excerpt requires application host context" import { HonuaClient } from "@honua/sdk-js/honua"; import { HonuaProvider } from "@honua/react"; const client = new HonuaClient({ baseUrl: "https://honua.example.com" }); export function Root() { return ( <HonuaProvider client={client}> <App /> </HonuaProvider> ); } ``` ## Hooks | Hook | Purpose | |------|---------| | `useHonuaClient()` | The active `HonuaClient`. | | `useDataset(options)` | A memoized `Dataset` (`createDataset`) bound to the provider client. | | `useQuery(source, query?, options?)` | Run a contract `Query` against a `Source` with loading/error/data state. | | `useCapabilities(options?)` | Fetch the server's capability / compatibility descriptor. | | `useMapRuntime()` | The `HonuaMapRuntime` owned by the enclosing `HonuaMap`. | | `useRealtime(factory, deps?)` | Open a realtime subscription and tear it down on unmount. | ```tsx doc-test=compile import { PROTOCOL_DEFAULT_CAPABILITIES } from "@honua/sdk-js/contract"; import { useDataset, useQuery } from "@honua/react"; function Incidents() { const dataset = useDataset({ id: "ops", sources: [ { id: "incidents", protocol: "geoservices-feature-service", locator: { url: "https://honua.example.com", serviceId: "incidents", layerId: 0 }, capabilities: PROTOCOL_DEFAULT_CAPABILITIES["geoservices-feature-service"], }, ], }); const source = dataset.source("incidents"); const { data, isLoading, error, refetch } = useQuery(source, { where: "STATUS = 'OPEN'", returnGeometry: true, }); if (isLoading) return <p>Loading…</p>; if (error) return <p>Query failed: {String(error)}</p>; return <p>{data?.features.length ?? 0} open incidents. <button onClick={refetch}>Refresh</button></p>; } ``` ### Memoization contract (NFR-001) `useQuery` caches per `(source, query-hash)` inside the provider and reads through `useSyncExternalStore`, so: - the returned `data` keeps a **stable reference** until the underlying result changes — safe as a `useEffect` / `useMemo` dependency; - two components issuing the same `(source, query)` share **one** in-flight request; - the request is aborted (via `AbortController`) when the last consumer unmounts or the query key changes, and superseded responses are ignored (no races). To keep the query key stable, memoize the `query` object (or pass a literal) and the dataset `sources`. `refetch()` forces a fresh fetch; `useHonuaQueryCache()` exposes `invalidate(key)` / `clear()` for manual cache control. ## Map components `HonuaMap` owns a `HonuaMapRuntime` over a MapLibre map. `HonuaLayer` and `HonuaPopup` declaratively add a runtime source/layer and a click popup; they add on mount and remove on unmount. ```tsx doc-test=skip reason="partial excerpt requires application host context" import { HonuaMap, HonuaLayer, HonuaPopup } from "@honua/react"; import { HONUA_MAP_PACKAGE_FORMAT_V1 } from "@honua/sdk-js/runtime"; const mapPackage = { mapPackageId: "sites", format: HONUA_MAP_PACKAGE_FORMAT_V1, sourceBindings: [], mapSpec: { version: 8, sources: {}, layers: [{ id: "bg", type: "background", paint: { "background-color": "#0b1021" } }] }, initialView: { center: [-157.84, 21.31], zoom: 10 }, }; <HonuaMap package={mapPackage} style={{ height: 480 }}> <HonuaLayer source={{ id: "sites", spec: { type: "geojson", data: sitesGeoJson } }} layer={{ id: "sites-circles", type: "circle", source: "sites", paint: { "circle-radius": 8 } }} /> <HonuaPopup layer="sites-circles" binding={{ sourceId: "sites", title: "Site", fieldName: "name" }} /> </HonuaMap>; ``` By default `HonuaMap` creates and owns the MapLibre map (dynamically importing `maplibre-gl`). Pass `mapLibre={maplibregl}` to supply the module explicitly, or `mapOptions` to tune the constructor. ### Composing with an externally-owned map (`@vis.gl/react-maplibre`) When another library owns the map (e.g. `@vis.gl/react-maplibre`'s `<Map>`), pass the existing instance to `HonuaMap` via `map`. `HonuaMap` then attaches its runtime to that map and **does not** remove it on unmount — the owner keeps control of the map lifecycle; Honua only disposes its runtime. ```tsx doc-test=skip reason="partial excerpt requires application host context" import { Map, useMap } from "@vis.gl/react-maplibre"; import { HonuaMap, HonuaLayer } from "@honua/react"; function HonuaOverlay({ mapPackage }) { const { current } = useMap(); const map = current?.getMap(); if (!map) return null; return ( <HonuaMap package={mapPackage} map={map}> <HonuaLayer layer={{ id: "incidents", type: "circle", source: "incidents" }} /> </HonuaMap> ); } <Map initialViewState={{ longitude: -157.84, latitude: 21.31, zoom: 10 }} mapStyle={style}> <HonuaOverlay mapPackage={mapPackage} /> </Map>; ``` ## Web components vs. React The SDK also ships framework-neutral web components (`@honua/sdk-js/web-components`). Choose based on your app: - **Use `@honua/react`** in a React app: you get JSX, typed props, hooks that participate in React state/effects, Suspense-friendly data flow, and ref-free composition with the rest of your component tree. - **Use web components** in a framework-agnostic page, a non-React framework, or when embedding a Honua map into server-rendered HTML with no build step. They work anywhere custom elements do, at the cost of React ergonomics. Both talk to the same `HonuaClient` and runtime, so you can mix them. ## Runnable example `examples/react-quickstart/` is a Vite app wiring `HonuaProvider`, `useDataset`, `useQuery`, `useCapabilities`, `HonuaMap`, `HonuaLayer`, and `HonuaPopup` together under `StrictMode`: ```bash npm run demo:react-quickstart # dev server npm run demo:react-quickstart:mock # build + serve against fixtures npm run demo:react-quickstart:build # production build ``` --- # File: docs/geometry.md # `@honua/geometry` — client-side geometry operations `@honua/geometry` (the `@honua/sdk-js/geometry` subpath, split-packaged as `@honua/geometry`) adds curated, tree-shakeable client-side geometry operations and reprojection to the Honua SDK, wrapping the individual [`@turf/*`](https://turfjs.org) packages and [`proj4`](https://github.com/proj4js/proj4js). It is typed against the SDK's own GeoJSON contract and never reimplements the underlying algorithms. The core SDK has **no dependency edge** into this module, so core-only consumers never pull turf/proj4 into their bundle. See [`decisions/geometry-library-selection.md`](./decisions/geometry-library-selection.md) for the dependency-placement ADR. ## Install / import ```bash # split package (turf/proj4 are regular deps — zero config) npm install @honua/geometry # or via the unified SDK subpath (install the turf/proj4 optional peers you use) npm install @honua/sdk-js @turf/buffer @turf/area proj4 ``` ```ts doc-test=compile import { area, buffer, project, toWgs84 } from "@honua/geometry"; // unified: import { area, buffer } from "@honua/sdk-js/geometry"; ``` ## Operations | Op | turf backing | Notes | | --- | --- | --- | | `buffer(geom, radius, unit?)` | `@turf/buffer` | Geodesic buffer; `unit` default `"meters"`. | | `area(geom)` | `@turf/area` | Geodesic area in **m²**. | | `length(geom, unit?)` | `@turf/length` | Geodesic length; `unit` default `"meters"`. | | `centroid(geom)` | `@turf/centroid` | Returns a `Point`. | | `bbox(geom)` | `@turf/bbox` | `[minX, minY, maxX, maxY]`. | | `simplify(geom, tolerance, highQuality?)` | `@turf/simplify` | Ramer–Douglas–Peucker vertex reduction. | | `booleanIntersects(a, b)` | `@turf/boolean-intersects` | Predicate. | | `booleanContains(a, b)` | `@turf/boolean-contains` | Predicate. | | `booleanWithin(a, b)` | `@turf/boolean-within` | Predicate. | | `union(...polys)` | `@turf/union` | Polygon union; `null` if empty. | | `intersect(a, b)` | `@turf/intersect` | Polygon intersection; `null` if disjoint. | | `difference(a, b)` | `@turf/difference` | Polygon `a − b`; `null` if fully removed. | | `nearestPoint(target, points)` | `@turf/nearest-point` | Nearest candidate `Feature<Point>`. | | `convex(geom)` | `@turf/convex` | Convex hull; `null` if undefined. | ### Units Linear units (`buffer`, `length`): `"meters"` (default), `"kilometers"`, `"miles"`, `"feet"`, `"yards"`, `"nauticalmiles"`. `area` always returns square meters — divide by the relevant factor for other units. ## Reprojection | Function | Purpose | | --- | --- | | `project(geom, fromCrs, toCrs)` | Reproject between any registered CRS. | | `toWgs84(geom, fromCrs)` | Fast path → EPSG:4326. | | `toWebMercator(geom, fromCrs)` | Fast path → EPSG:3857. | | `defineProjection(code, proj4def)` | Register an additional CRS on demand. | | `normalizeCrs(code)` | Resolve numeric/`"EPSG:xxxx"`/alias codes to a proj4 id. | Only **EPSG:4326** and **EPSG:3857** are bundled (both are built into proj4, so the module registers nothing at import time and is side-effect-safe for tree-shaking). Web Mercator aliases (`3785`, `900913`, `102100`, `102113`) are normalized to `EPSG:3857`. `project` never mutates its input; a same-CRS call is a deep clone. Extra ordinates (`z`/`m`) pass through untouched. ```ts doc-test=skip reason="partial excerpt requires application host context" defineProjection(32610, "+proj=utm +zone=10 +datum=WGS84 +units=m +no_defs +type=crs"); const utm = project(feature.geometry, 4326, 32610); ``` ## CRS caveats - turf operates on **WGS84 longitude/latitude degrees**. Feed geographic coordinates to `area`/`length`/`buffer`, or reproject first. - Running geodesic ops on projected coordinates (e.g. raw Web Mercator meters) yields wrong results — reproject to 4326 first (`toWgs84`). - Coordinate-space ops (`union`/`intersect`/`difference`/`simplify`/`convex` and the boolean predicates) are CRS-agnostic as long as **both** operands share the same CRS. ## `geometryEngine` compat shim For ArcGIS migrants, `@honua/sdk-js/esri-compat` exposes a `geometryEngine`-shaped shim (`geometryEngineCompat` / `geometryEngineAsyncCompat`) backed by `@honua/geometry`. Inputs may be plain Esri-JSON geometries or Honua compat instances (`PointCompat`, `PolygonCompat`, …) that expose `toJSON()`. Results are Esri geometries stamped with the source `spatialReference`. The migration codemod rewrites `import geometryEngine from "@arcgis/core/geometry/geometryEngine"` (and `geometryEngineAsync`) to these shims. ### Coverage matrix | geometryEngine op | Shim | turf backing | Semantic difference vs. Esri | | --- | --- | --- | --- | | `buffer` | ✅ `buffer` | `@turf/buffer` | Esri `buffer` is **planar**; the shim is **geodesic** (closer to `geodesicBuffer`). | | `union` | ✅ `union` | `@turf/union` | Coordinate-space; parity within polygon-clipping tolerance. | | `intersect` | ✅ `intersect` | `@turf/intersect` | Coordinate-space. `null` when disjoint. | | `difference` | ✅ `difference` | `@turf/difference` | Coordinate-space. | | `geodesicArea` | ✅ `geodesicArea` | `@turf/area` | Reprojects to WGS84; spherical model. | | `planarArea` | ✅ `planarArea` | shoelace | Exact in a projected CRS (e.g. Web Mercator); meaningless in degrees. | | `geodesicLength` | ✅ `geodesicLength` | `@turf/length` | Reprojects to WGS84; spherical model. | | `planarLength` | ✅ `planarLength` | Euclidean | Exact in a projected CRS. | | `simplify` | ✅ `simplify` | Esri↔GeoJSON round-trip | **Topological normalization** (ring rewinding), not vertex reduction. Use `@honua/geometry`'s `simplify(geom, tolerance)` for RDP thinning. | | `convexHull` | ✅ `convexHull` | `@turf/convex` | Coordinate-space. | | `contains` | ✅ `contains` | `@turf/boolean-contains` | Predicate. | | `intersects` | ✅ `intersects` | `@turf/boolean-intersects` | Predicate. | | `geodesicDensify`, `densify`, `offset`, `cut`, `generalize`, `nearestCoordinate`, `nearestVertex`, `rotate`, `flip*`, `clip`, `overlaps`, `touches`, `crosses`, `within`, `disjoint`, `equals`, `isSimple`, `relate`, `symmetricDifference`, `geodesicBuffer` | ❌ not shimmed | — | Migration codemod keeps a **manual-intervention TODO** for these call sites. | ### Parity tolerances Numerical parity is tested in `test/geometry-engine-compat.test.ts` against analytically-derived expected outputs (fixtures use Web Mercator geometries so planar ops have exact values): - **planar area / length:** exact to `< 1e-6` relative (pure coordinate arithmetic). - **geodesic area / length:** within ~0.5 % of the great-circle analytic value (turf's spherical model vs. a spherical hand-calc). - **buffer:** asserted on monotonic area growth and output shape, not exact vertices (geodesic turf buffer ≠ Esri planar buffer). ## Example: measure + buffer + reproject ```ts doc-test=skip reason="partial excerpt requires application host context" import { area, buffer, toWgs84 } from "@honua/geometry"; // A parcel returned in Web Mercator (EPSG:3857). const parcel3857 = feature.geometry; // Reproject to WGS84 so geodesic measures are correct. const parcel = toWgs84(parcel3857, 3857); // Measure it (m²) and grow a 25 m setback ring. const parcelArea = area(parcel); const setback = buffer(parcel, 25, "meters"); console.log(`parcel is ${parcelArea.toFixed(0)} m²`); ``` --- # File: docs/bundle-sizes.md <!-- GENERATED FILE — do not edit by hand. --> <!-- Regenerate with: npm run report:bundle-sizes --> # Bundle sizes Per-entrypoint bundle sizes for `@honua/sdk-js`, measured the way a real consumer builds them: esbuild `--bundle --minify`, target `es2020`, runtime peers (`maplibre-gl`, `cesium`, `@bufbuild/*`, `@connectrpc/*`) kept external. Ceilings are enforced in CI via `npm run verify:bundle-budgets` (budgets live in [`bundle-budgets.json`](../bundle-budgets.json), set to actual + ~10% headroom). _Generated 2026-07-11 at commit `e154234`._ | Entrypoint | Min | Min budget | Gzip | Gzip budget | | --- | ---: | ---: | ---: | ---: | | `.` (root) | 430.9 KiB | 451.6 KiB | 115.1 KiB | 119.3 KiB | | `/honua` | 433.4 KiB | 454.4 KiB | 115.5 KiB | 119.9 KiB | | `/contract` | 232.0 KiB | 235.3 KiB | 60.1 KiB | 66.1 KiB | | `/runtime` | 425.3 KiB | 458.7 KiB | 110.9 KiB | 119.5 KiB | | `/realtime` | 26.6 KiB | 29.3 KiB | 7.8 KiB | 8.6 KiB | | `/offline` | 23.6 KiB | 26.0 KiB | 7.7 KiB | 8.5 KiB | | `/esri-compat` | 943.4 KiB | 1026.2 KiB | 233.7 KiB | 253.6 KiB | | `/expr` | 7.7 KiB | 8.4 KiB | 2.4 KiB | 2.7 KiB | | `/webmap` | 19.5 KiB | 21.5 KiB | 5.9 KiB | 6.5 KiB | | `/geocoding` | 4.9 KiB | 5.3 KiB | 1.9 KiB | 2.1 KiB | | `/auth` | 12.0 KiB | 12.8 KiB | 3.8 KiB | 4.0 KiB | | `/style` | 37.5 KiB | 39.9 KiB | 8.4 KiB | 9.1 KiB | | `/map` | 79.6 KiB | 87.4 KiB | 21.8 KiB | 24.0 KiB | | `/geoparquet` (duckdb-wasm external — lazy peer) | 16.5 KiB | 17.8 KiB | 6.0 KiB | 6.5 KiB | | `/deckgl` (deck.gl external — lazy peer) | 11.5 KiB | 12.5 KiB | 4.1 KiB | 4.4 KiB | | `/react` (react/react-dom external) | 377.3 KiB | 405.7 KiB | 97.6 KiB | 104.5 KiB | | `/geometry` (turf/proj4 bundled — real consumer cost) | 516.4 KiB | 568.0 KiB | 142.7 KiB | 157.0 KiB | | browser IIFE (`./browser` unpkg/jsdelivr) | 431.8 KiB | 452.7 KiB | 115.4 KiB | 119.7 KiB | | browser ESM (`./browser`) | 430.3 KiB | 451.0 KiB | 115.1 KiB | 119.3 KiB | | tree-shake guard (`{ HonuaClient }` only) | 189.8 KiB | 204.3 KiB | 48.4 KiB | 52.1 KiB | | tree-shake guard (`{ FeatureLayerCompat }` from `/esri-compat`) | 204.6 KiB | 220.4 KiB | 51.9 KiB | 55.8 KiB | | tree-shake guard (`{ buffer }` from `/geometry`, turf bundled) | 287.5 KiB | 316.3 KiB | 65.6 KiB | 72.2 KiB | | tree-shake guard (`{ mountSourceToMapLibre }` from `/map`) | 17.2 KiB | 18.8 KiB | 6.4 KiB | 7.0 KiB | --- # File: docs/guide.md # `@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](../README.md) for the 60-second tour and the [`docs/`](.) directory for topic-specific docs. ## Contents ### Getting set up - [Demo apps in depth](#demo-apps-in-depth) - [Install](#install) - [Local development](#local-development) - [Verify](#verify) - [Server compatibility baseline](#server-compatibility-baseline) - [Entrypoints](#entrypoints) - [Split package artifacts](#split-package-artifacts) ### Core surfaces - [Shared client contract and exploration](#shared-client-contract-and-exploration) - [MapLibre GL JS runtime for `MapPackage`](#maplibre-gl-js-runtime-for-mappackage) - [Generated app preview runtime](#generated-app-preview-runtime) - [Request/auth bridge](#requestauth-bridge) - [Streaming pagination](#streaming-pagination) - [Event lifecycle (`.on`)](#event-lifecycle-on) ### Protocol cookbooks - [OGC API Features (Honua-first)](#ogc-api-features-honua-first) - [OGC API Tiles, Maps, Processes, STAC](#ogc-api-tiles-maps-processes-stac) - [WFS 2.0](#wfs-20) - [OData v4](#odata-v4) - [Mixed Esri + OGC in one app](#mixed-esri--ogc-in-one-app) - [MapServer query helpers](#mapserver-query-helpers) - [TimeSlider integration](#timeslider-integration) ### Migration tooling - [Migration CLI](#migration-cli) - [Migration admin scanner](#migration-admin-scanner) - [Esri sample corpus (#206)](#esri-sample-corpus-206) - [FeatureTable demo lane (#327)](#featuretable-demo-lane-327) ## Demo apps in depth > The committed quickstart app uses the same `queryFeatures()` request shape shown in > the [README quickstart](../README.md#60-second-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. - [`examples/maplibre-quickstart/`](./examples/maplibre-quickstart/README.md): committed MapLibre quickstart app with a deterministic fixture-backed mock lane, one compatibility check, one read-only feature query, popup inspection, browser telemetry, and a matching staging integration suite that reuses the same compatibility-plus-query data-loading path. - [`examples/react-quickstart/`](./examples/react-quickstart/README.md): `@honua/react` quickstart — `HonuaProvider` + `useDataset`/`useQuery`/`useCapabilities` hooks and a `HonuaMap`/`HonuaLayer`/`HonuaPopup` composition over the same deterministic fixture lane, booted under React StrictMode with Playwright smoke coverage. - [`examples/storytelling-25d-map/`](./examples/storytelling-25d-map/README.md): pitched `2.5D` MapLibre demo with Honua compatibility gating, same-origin fixture mocking, OGC collection overlays, polygon extrusions, and route replay. - [`examples/kepler-analytics/`](./examples/kepler-analytics/README.md): fixture-first kepler.gl analytics demo for an `operations replay` workflow with committed GeoJSON plus metadata, KPI cards, walkthrough copy, and focused browser smoke coverage. - [`examples/imagery-cog-quickstart/`](./examples/imagery-cog-quickstart/README.md): MapLibre imagery proof for WMS `GetMap`, COG-backed ImageServer tiles, `exportImage` previews, legends, and metadata cache state through one Honua client configuration. - [`examples/spatial-analytics-workbench/`](./examples/spatial-analytics-workbench/README.md): Honua Cloud analytics workbench for AOI jobs, materialized outputs, linked map/table/chart state, and fixture-backed indexed aggregation cells plus category/histogram/range widgets. - [`examples/edit-workflow-demo/`](./examples/edit-workflow-demo/README.md): Honua Cloud editing workflow for metadata-backed forms, optimistic create/update/delete, rollback diagnostics, conflicts, relationships, and attachment lifecycle checks over shared map/table/form context. - [`examples/geocoding-quickstart/`](./examples/geocoding-quickstart/README.md): MapLibre geocoding sample for forward geocoding, reverse lookup from a clicked point, typeahead suggestions, and GeocodeServer audit mapping through `HonuaGeocodingClient`. - [`examples/terrain-rgb-elevation/`](./examples/terrain-rgb-elevation/README.md): MapLibre Terrain-RGB elevation proof with fixture-safe DEM tiles, clicked point elevation lookup, drawn line profile queries, and explicit SDK gap notes for untyped terrain value/profile endpoints. - [`examples/unified-ops-workspace/`](./examples/unified-ops-workspace/README.md): fixture-backed operational workspace shell that composes incident command and analysis review modules over one shared app workspace, linked-view context, realtime state, review drafts, and saved snapshot diagnostics. - [`docs/examples/cesium-route-playback/README.md`](./docs/examples/cesium-route-playback/README.md): exploratory Cesium route-playback spike that consumes one bounded Honua `FeatureServer/query` response, keeps the preprocessing steps explicit, and stays outside the SDK's `SceneViewCompat` and WebMap 3D support contract. 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`](./docs/honua-cloud-demo-services.md), with the machine-readable manifest at [`examples/cloud-demo-services.json`](./examples/cloud-demo-services.json) and env template at [`examples/cloud-demo.env.example`](./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: ```bash # 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: ```bash 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: ```ts doc-test=compile 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: - server version `>= 1.0.0` - control-plane API major integer `1` with base path `/api/v1/admin` - control-plane API deprecation flag `false` - server release channel `preview` or newer --- Initial JavaScript SDK scaffold for the JS-first migration phase (`#324`). This package currently provides: - core HTTP client (`HonuaClient`) for FeatureServer, MapServer export/query/related-record operations, and catalog operations, plus fluent layer wrappers (`client.featureLayer(...)`, `client.mapLayer(...)`, `service.featureLayer(...)`, `service.featureLayers()`, `service.mapLayer(...)`, `service.mapLayers()`, `service.mapService().layer(...)`), - first-class OGC API clients/wrappers for Features (`client.ogcFeatures()`), Tiles (`client.ogcTiles()`), Maps (`client.ogcMaps()`), Processes (`client.ogcProcesses()`), and STAC (`client.stac()`), including landing/conformance discovery, feature items, tile and map bytes, async `IJobRun` process execution, and STAC search, - first-party OGC web-map clients/wrappers for WMS 1.3.0 (`client.wms(serviceId)` → `HonuaWms` / `HonuaWmsLayer`) and WMTS 1.0.0 (`client.wmts(serviceId)` → `HonuaWmts` / `HonuaWmtsLayer` / `HonuaWmtsTileset`) covering Capabilities, `GetMap` (with `TIME` / `ELEVATION` dimension handling and CRS axis-order swap per WMS 1.3 §6.7.3.2), `GetFeatureInfo`, `GetLegendGraphic` (when the server advertises it; otherwise `HonuaCapabilityNotSupportedError`), RESTful + KVP tile fetch, plus MapLibre source helpers `buildWmsRasterSourceSpec(descriptor)` / `buildWmtsRasterSourceSpec(descriptor)` from `@honua/sdk-js/runtime`, - dynamic query tile descriptors and MapLibre helpers (`defineQueryTileSource`, `buildMapLibreQueryTileSourceSpec`, `QueryTileRequestController`) for query-backed vector tiles with cache key normalization, viewport request aborts, feature identity mapping, selected-feature detail lookup, and unsupported-protocol/fallback diagnostics, - Esri-style compatibility wrappers (`FeatureLayerCompat`, `MapImageLayerCompat`, `TileLayerCompat`, `RouteLayerCompat`, `MapCompat`, `MapViewCompat`, `SceneViewCompat`, `WebMapCompat`) for migration-critical patterns, including basic `when()` lifecycle support, `FeatureLayer.refresh()/createQuery()/queryFeaturesAll()/queryObjectIds()/queryFeatureCount()/queryExtent()/queryRelatedFeatures()/addAttachment()/updateAttachment()/listFields()/getField()`, `MapImageLayer.when()/refresh()/createQuery()/exportImage()/getLegend()/find()/identify()/queryFeatures()/queryFeaturesAll()/queryFeatureCount()/queryObjectIds()/queryExtent()/queryRelatedFeatures()/findSublayerById()/sublayer(...).query*()` where writable `layer.sublayers` and sublayer lookups return query-capable `MapImageSublayerCompat` wrappers (and auto-hydrate from metadata when not explicitly configured) with nested `sublayer.sublayers/allSublayers`, `sublayer.visible`, and `sublayer.definitionExpression` bridging to query defaults, `FeatureTableCompat` row/query helpers with runtime `layer` switching (`table.layer = nextLayer` / `table.setLayer(nextLayer)`), `Map` layer collection helpers, `GraphicsLayerCompat`/`GroupLayerCompat`, and `MapView` watch/event handles with popup/layer-view bridges plus `toMap`/`toScreen`/`hitTest`/`takeScreenshot()` and `view.ui.add/remove/move/getComponents` compatibility, - identify controller (`IdentifyCompat`) for cross-layer MapServer identify workflows with optional popup auto-open, - compat widgets/components (`LayerListCompat`, `LegendCompat`, `PopupCompat`, `SearchCompat`, `BasemapGalleryCompat`, `BookmarksCompat`, `ExpandCompat`, `SketchCompat`, `EditorCompat`, `TrackCompat`, `MeasurementCompat`, `TimeSliderCompat`, `DirectionsCompat`) backed by a shared `CompatEventBus` so widgets/components can subscribe to layer/view changes, including popup feature-selection navigation and search source/result state helpers (default search sources support both ArcGIS `queryFeatures` layers and OGC collection-style `items()` layers), - common map controls (`HomeCompat`, `BasemapToggleCompat`, `LocateCompat`, `ScaleBarCompat`, `CompassCompat`, `FullscreenCompat`, `ZoomCompat`, `AttributionCompat`) wired to the same event bus for shared view state updates, - request/auth migration bridge helpers (`createEsriRequestInterceptors`, `createArcGisTokenInterceptor`) plus core `HonuaClient` interceptor hooks (`before`/`after`/`error`), - URL parsing helpers for ArcGIS FeatureLayer endpoint detection, - ArcGIS usage scanner (`scanArcGisUsage`) for migration inventory and risk flags, - safe codemod runner (`runEsriCompatCodemod`) for migration-safe constructors across layers, views, widgets, and controls, - migration report builder with explicit manual TODOs and rewrite metric, - JS parity matrix artifacts (`getJsParityMatrix` / `summarizeJsParityMatrix` and `getJsRuntimeParityMatrix` / `summarizeJsRuntimeParity`) for constructor-level and runtime-capability `native/compat/assisted/unsupported` tracking across Honua compat and esri-leaflet targets, - service reconciliation helper (`runLayerReconciliation`) for feature-count, geometry-validity, and attribute-key checks, - unit tests for request mapping and URL parsing, - WebMap JSON conversion utilities (`parseWebMap`, renderer/symbol/popup converters) with a golden fixture suite and compatibility contract documented in [`docs/webmap-json-compatibility.md`](./docs/webmap-json-compatibility.md). ## Entrypoints Prefer subpath entrypoints to keep Honua-first and migration layers separate: - Honua-first core: `@honua/sdk-js/honua` - Esri compat bridge: `@honua/sdk-js/esri-compat` - Migration tooling: `@honua/sdk-js/migration` - Canonical shared client contract: `@honua/sdk-js/contract` - Exploration state + linked-view presets: `@honua/sdk-js/exploration` - MapLibre GL JS runtime for `MapPackage`: `@honua/sdk-js/runtime` - React bindings (provider, hooks, map components): `@honua/sdk-js/react` — see [`docs/react.md`](./react.md) - Client-side geometry ops + reprojection (curated turf/proj4): `@honua/sdk-js/geometry` — see [`docs/geometry.md`](./geometry.md) - Generated operations-dashboard manifest projection and preview runtime: `@honua/sdk-js/generated-app` - Studio package-family projections, validation/preview envelopes, capability manifest, and publish/share/embed contracts (MCP/QGIS-safe): `@honua/sdk-js/studio` — see [`docs/studio-package-contracts.md`](./studio-package-contracts.md) 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. - `@honua/sdk-js/contract` — canonical `Dataset`, `Source`, `SourceDescriptor`, `Capabilities`, `Query`, `Result`, `IJobRun`, and `MapBinding` types, plus `createDataset(...)` and built-in adapters for the five GeoServices service types (`geoservices-feature-service`, `geoservices-map-service`, `geoservices-image-service`, `geoservices-geometry-service`, `geoservices-gp-service`), the four OGC API + STAC adapters (`ogc-features`, `ogc-tiles`, `ogc-maps`, `stac`), the OGC web-map adapters (`wms`, `wmts`), `wfs` (WFS 2.0), and `odata` (OData v4). Image / Geometry / GP services expose their protocol-specific surface (raster `exportImage` / `identify`, geometry `project` / `buffer` / `simplify` / `intersect` / `union` / `clip` / `difference`, GP `submitJob` / `jobStatus` / `cancelJob` / `jobResult`) through the typed `Source.protocol()` escape hatch — the canonical query family throws `HonuaCapabilityNotSupportedError` for utility-only services so mixed-source apps surface the limitation explicitly. The ImageServer adapter rejects `Query.spatialFilter` / `orderBy` / `outFields` rather than silently widening the catalog result. WMS surfaces `query` through point-only `GetFeatureInfo`; WMTS is render-only (`Source.query()` throws). The WFS adapter compiles `Query.where` / `Query.spatialFilter` to FES 2.0, prefers GeoJSON over GML via `OperationsMetadata` negotiation, builds `<wfs:Transaction>` bodies for `applyEdits`, and reaches raw GML / `LockFeature` / `GetPropertyValue` / stored queries through `Source.protocol("wfs")`. The OData adapter exposes `query` / `queryObjectIds` / `stream` / `applyEdits` first-party (PATCH-with-full-body in place of PUT, per the parity matrix) and surfaces `$batch` / `$apply` / `$search` / `$deltatoken` through `Source.protocol("odata")` on a `HonuaOdataEntitySet`; it is also the first adapter to lazily fetch service `$metadata` and intersect the declared capability set with the server's `Capabilities.*` annotations. Capability negotiation is `strict` by default; `degraded` opts into client-side fallbacks that surface `Result.degraded[]`. Full contract reference: [`docs/shared-client-contract.md`](./docs/shared-client-contract.md). - `@honua/sdk-js/exploration` — `createExplorationContext(...)` returning an observable, microtask-coalesced reducer over filters, spatial filter, extent, selection, sort, pagination, visible fields, grouping, and aggregation. View bindings (`map`, `grid`, `chart`, `form`, `custom`) propagate through five linked-view presets (`globalLinked`, `mapDriven`, `gridDriven`, `chartDriven`, `decoupled`). Full state model and worked example: [`docs/exploration-context.md`](./docs/exploration-context.md). - Capability coverage per protocol: [`docs/protocol-capability-matrix.md`](./docs/protocol-capability-matrix.md). - Cross-SDK semantic alignment for JS/Python/.NET language bindings: [`docs/sdk-surface-alignment.md`](./docs/sdk-surface-alignment.md). - Round-trip mapping to the server `SourceBinding` / `MapPackage` document: [`docs/source-binding-alignment.md`](./docs/source-binding-alignment.md). - Live SDK ↔ Honua Server protocol integration lane: [`docs/integration-tests.md`](./docs/integration-tests.md). ```ts doc-test=compile 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({ where: "STATUS = 'ACTIVE'", pagination: { limit: 100 } }); const { extent } = await parcels.queryExtent({ where: "STATUS = 'ACTIVE'" }); 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)`. ## 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. ```ts doc-test=skip reason="partial excerpt requires application host context" import 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(); ``` - Full runtime reference: [`docs/maplibre-runtime.md`](./docs/maplibre-runtime.md). - Protocol routing (server `SourceBinding` → MapLibre / contract adapter): [`docs/source-binding-alignment.md`](./docs/source-binding-alignment.md#runtime-consumer-honuasdk-jsruntime). - `MapPackage` format tag: `honua_map_package.v1`. The loader throws `HonuaMapPackageError { stage: "load" }` on any other value, and `updatePackage` rejects format mismatches without mutating the map. 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`](./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`. ```ts doc-test=skip reason="partial excerpt requires application host context" 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`](./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 ```bash 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. ```bash npm install ``` Storytelling demo loops: ```bash 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: ```bash 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: ```bash 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 COG demo loop: ```bash npm run demo:imagery-cog:mock # or, with examples/imagery-cog-quickstart/.env configured: npm run demo:imagery-cog ``` Node backend quickstart loop: ```bash npm run demo:node-backend:mock # in another terminal: npm run demo:node-backend ``` ## Verify ```bash 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 npx vitest run test/quickstart-config.test.ts test/quickstart-data.test.ts 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 npm run test:quickstart:staging # requires HONUA_STAGING_* env 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/sdk` -> `dist/packages/honua-sdk` - `@honua/sdk-esri-compat` -> `dist/packages/honua-sdk-esri-compat` - `@honua/honua-migrate` -> `dist/packages/honua-migrate` ```bash npm run build:split-packages npm run verify:split-packages ``` Create local tarballs for all split packages: ```bash npm run pack:split-packages ``` CI publish workflow: - manual dry-run or publish via `Publish JS SDK Packages` workflow - tag-triggered publish uses Release Please tags in form `js-sdk-<version>`; the workflow also accepts `js-sdk-v<version>` and enforces tag/version match - Release Please dispatches the JS SDK and MCP publish workflows after creating releases; package publishes skip versions that already exist on npm. ## Request/Auth Bridge ```ts doc-test=compile 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. ```ts doc-test=skip reason="partial excerpt requires application host context" 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) ```ts doc-test=compile 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. ```ts doc-test=skip reason="partial excerpt requires application host context" 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 surface — import it (or call `client.stac()`) instead of hand-writing `fetch` against STAC endpoints. It is re-exported from both the root barrel (`@honua/sdk-js`) and the `@honua/sdk-js/honua` subpath, so it bundles with esbuild/Vite/Rollup like the rest of the client: ```ts doc-test=compile // Browser app (esbuild / Vite / Rollup). No Node-only imports. import { HonuaClient, HonuaStacSearch } from "@honua/sdk-js"; 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"] })) { // … } ``` See [`docs/ogc-api.md`](./docs/ogc-api.md) for the full developer reference, [`docs/shared-client-contract.md`](./docs/shared-client-contract.md) for the canonical `Source` / `IJobRun` model, and [`docs/protocol-capability-matrix.md`](./docs/protocol-capability-matrix.md) for capability coverage. ## WFS 2.0 ```ts doc-test=compile 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")!; 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(); ``` `Query.where` 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`](./docs/wfs.md). ## OData v4 ```ts doc-test=compile 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")!; 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))", ); ``` `Query.where` accepts SQL-92 / OData `$filter` text; the adapter rewrites the documented intersection (`IS NULL` → `eq 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`](./docs/protocol-capability-matrix.md) for the rule and [`docs/decisions/odata-library-selection.md`](./docs/decisions/odata-library-selection.md) for the runtime-library posture. ## Mixed Esri + OGC in one app ```ts doc-test=compile 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([ parcelsLayer.queryFeatures({ where: "status = 'active'", outFields: ["OBJECTID"] }), parcelsOgc.items({ limit: 50 }), ]); ``` ## MapServer query helpers ```ts doc-test=compile 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 ```ts doc-test=compile 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) ```ts doc-test=compile 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 ```ts doc-test=compile 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); // Queries now include the time parameter automatically const features = await layer.queryFeatures({ where: "1=1" }); // Disconnect when done connection.remove(); ``` ## Migration CLI ```bash # Scan only node dist/src/migration/cli.js scan ./src --report scan-report.json # Safe codemod (dry run) node dist/src/migration/cli.js codemod ./src --report migration-report.json # Safe codemod (write changes) node dist/src/migration/cli.js codemod ./src --write --report migration-report.json # Safe codemod (explicit honua target alias) node dist/src/migration/cli.js codemod ./src --target honua --write --report migration-report.json # Safe codemod (write changes targeting esri-leaflet for supported subset) node dist/src/migration/cli.js codemod ./src --target esri-leaflet --write --report migration-report.json # Safe codemod (write + inline TODO annotations for manual sites) node dist/src/migration/cli.js codemod ./src --write --annotate-todos --report migration-report.json # Emit parity matrix JSON (for docs/CI dashboards) node dist/src/migration/cli.js matrix --report parity-matrix.json # Emit runtime parity matrix JSON (for JS API capability tracking) node dist/src/migration/cli.js runtime-matrix --report runtime-parity-matrix.json # Generate readiness metrics for bundled complex real-sample fixtures node dist/src/migration/cli.js 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 node dist/src/migration/cli.js 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 node dist/src/migration/cli.js 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 node dist/src/migration/cli.js 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) node dist/src/migration/cli.js 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 node dist/src/migration/cli.js 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 node dist/src/migration/cli.js content scan --portal https://org.maps.arcgis.com --report ./content/scan.json # Content export (WebMaps + hosted layers) node dist/src/migration/cli.js content export --portal https://org.maps.arcgis.com --output-dir ./export --report ./content/export.json # Content import into Honua admin endpoint node dist/src/migration/cli.js content import --source ./export --target https://honua.example.com --admin-api-key $HONUA_ADMIN_API_KEY --report ./content/import.json # Content reconcile using export + import reports node dist/src/migration/cli.js content reconcile --source ./export --report ./content/reconcile.json # Convert a WebMap JSON export into Honua style config and rewrite portal URLs node dist/src/migration/cli.js 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: ```ts doc-test=compile 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`](./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: - fixture: `esri-demo-feature-table-relates-app` - sample: `FeatureTable with related records` - source: `https://developers.arcgis.com/javascript/latest/sample-code/widgets-featuretable-relates/` Fallback target: - fixture: `esri-demo-feature-table-popup-interaction-app` - sample: `Feature table with popup interaction` - source: `https://developers.arcgis.com/javascript/latest/sample-code/widgets-featuretable-popup-interaction/` Pinned attribution metadata is tracked in `test/fixtures/DEMO_TARGETS.md` and fixture-local `ATTRIBUTION.md` files. Runbook (codemod-only CI reproducible path): ```bash # 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: - elapsed time: `reports/demo-featuretable-codemod-report.json -> elapsedMs` - manual rewrite ratio: `migration.manualRewriteMetric.ratio` - manual intervention count: `migration.manualInterventionMetric.numerator` - manual rewrite count: `migration.manualRewriteMetric.numerator` Quick metric extract: ```bash 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: - default target (`--target honua` alias of `honua-compat`) rewrites safe constructors: - `new FeatureLayer({ url: ... })` -> `new FeatureLayerCompat({ url: ... })` (supports `id`, `title`, `outFields`, `definitionExpression`, `renderer`, `popupTemplate`, `labelingInfo`, `labelsVisible`, `opacity`, `visible`, `minScale`, `maxScale`, `legendEnabled`, and `listMode`; `url` may be absolute or relative, including path-prefixed deployments) - `new Polyline(...)` -> `new PolylineCompat(...)` - `new Polygon(...)` -> `new PolygonCompat(...)` - `new Extent(...)` -> `new ExtentCompat(...)` - `new SpatialReference(...)` -> `new SpatialReferenceCompat(...)` - `new Color(...)` -> `new ColorCompat(...)` - `new PictureMarkerSymbol(...)` -> `new PictureMarkerSymbolCompat(...)` - `new TextSymbol(...)` -> `new TextSymbolCompat(...)` - `new LabelClass(...)` -> `new LabelClassCompat(...)` - `new SimpleFillSymbol(...)` -> `new SimpleFillSymbolCompat(...)` - `new ClassBreaksRenderer(...)` -> `new ClassBreaksRendererCompat(...)` - `new SimpleRenderer(...)` -> `new SimpleRendererCompat(...)` - `new UniqueValueRenderer(...)` -> `new UniqueValueRendererCompat(...)` - `new GraphicsLayer(...)` -> `new GraphicsLayerCompat(...)` - `new GroupLayer(...)` -> `new GroupLayerCompat(...)` - `new MapImageLayer({ url: ... })` -> `new MapImageLayerCompat({ url: ... })` (supports `id`, `title`, `sublayers`, `opacity`, `visible`, `minScale`, `maxScale`, `listMode`, and `legendEnabled`; runtime helpers include `exportImage/getLegend/find/identify/queryFeatures/queryFeaturesAll/queryFeatureCount/queryObjectIds/queryExtent/queryRelatedFeatures` plus `sublayer(...).query*()`, `sublayer(...).queryFeaturesAll()`, `sublayer(...).queryRelatedFeatures()`, and `sublayer.visible/definitionExpression`; `url` may be absolute or relative, including path-prefixed deployments) - `new TileLayer({ url: ... })` -> `new TileLayerCompat({ url: ... })` (supports `id`, `title`, `opacity`, `visible`, `minScale`, `maxScale`, and `listMode`; `url` may be absolute or relative, including path-prefixed deployments) - `new RouteLayer(...)` -> `new RouteLayerCompat(...)` - `new Map(...)` -> `new MapCompat(...)` (supports `basemap`, `layers`, `ground`, `tables`, `portalItem`, and `spatialReference`) - `new MapView(...)` -> `new MapViewCompat(...)` (supports `map`, `container`, `center`, `zoom`, `scale`, `rotation`, `extent`, `constraints`, `padding`, `highlightOptions`, `spatialReference`, and `popup`) - `new SceneView(...)` -> `new SceneViewCompat(...)` (supports core `MapView` options plus `viewingMode`, `qualityProfile`, and `camera`) - `new WebMap(...)` -> `new WebMapCompat(...)` (supports `portalItem`, `basemap`, `layers`, `ground`, `tables`, and `spatialReference`) - `new FeatureTable(...)` -> `new FeatureTableCompat(...)` (supports common table options including `highlightIds` and `multiSortEnabled`) - `new FeatureForm(...)` -> `new FeatureFormCompat(...)` (supports `feature`, `fieldConfig`, `groupDisplay`, `headingLevel`, and `visibleElements`) - `new FeatureTemplates(...)` -> `new FeatureTemplatesCompat(...)` (supports `layerInfos`, `container`, `filterFunction`, and `groupBy`) - `new LayerList(...)` -> `new LayerListCompat(...)` (supports `view`, `map`, `container`, `includeHidden`, `autoRefresh`, and `listItemCreatedFunction`) - `new Legend(...)` -> `new LegendCompat(...)` (supports `view`, `map`, `layers`, `container`, `includeHidden`, and `autoRefresh`) - `new Popup(...)` -> `new PopupCompat(...)` - `new Home(...)` -> `new HomeCompat(...)` (supports `view`, `container`, and `viewpoint`) - `new BasemapToggle(...)` -> `new BasemapToggleCompat(...)` - `new Locate(...)` -> `new LocateCompat(...)` (supports `view`, `container`, `zoom`, and `locateProvider`) - `new ScaleBar(...)` -> `new ScaleBarCompat(...)` - `new Search(...)` -> `new SearchCompat(...)` (supports `sources`, `includeDefaultSources`, `autoNavigate`, and `autoRefreshSources`) - `new BasemapGallery(...)` -> `new BasemapGalleryCompat(...)` - `new Bookmarks(...)` -> `new BookmarksCompat(...)` - `new Expand(...)` -> `new ExpandCompat(...)` - `new Compass(...)` -> `new CompassCompat(...)` - `new Fullscreen(...)` -> `new FullscreenCompat(...)` - `new Zoom(...)` -> `new ZoomCompat(...)` - `new Attribution(...)` -> `new AttributionCompat(...)` - `new Sketch(...)` -> `new SketchCompat(...)` - `new Editor(...)` -> `new EditorCompat(...)` - `new Track(...)` -> `new TrackCompat(...)` (supports `tracking`, `goToLocationEnabled`, `useHeadingEnabled`, `rotationEnabled`, `scale`, and `trackProvider`) - `new Measurement(...)` -> `new MeasurementCompat(...)` - `new TimeSlider(...)` -> `new TimeSliderCompat(...)` - `new Directions(...)` -> `new DirectionsCompat(...)` - alternate target (`--target esri-leaflet`) rewrites deterministic subset plus broad compat fallbacks: - `new FeatureLayer({ ... })` -> `HonuaEsriLeaflet.featureLayer({ ... })` - `new MapImageLayer({ ... })` -> `HonuaEsriLeaflet.dynamicMapLayer({ ... })` - `new TileLayer({ ... })` -> `HonuaEsriLeaflet.tiledMapLayer({ ... })` - `new Map({ ... })` -> `new MapCompat({ ... })` - `new MapView({ ... })` -> `new MapViewCompat({ ... })` - `new SceneView({ ... })` -> `new SceneViewCompat({ ... })` - `new LayerList({ ... })` -> `new LayerListCompat({ ... })` - `new Legend({ ... })` -> `new LegendCompat({ ... })` - `new Popup({ ... })` -> `new PopupCompat({ ... })` - `new Search({ ... })` -> `new SearchCompat({ ... })` - `new Home/BasemapToggle/Locate/ScaleBar/BasemapGallery/Expand/Compass/Bookmarks/Fullscreen/Zoom/Attribution(...)` -> corresponding `*Compat` constructor - dynamic imports for those modules -> `Promise.resolve({ default: HonuaEsriLeaflet.* })` - dynamic imports for compat fallback modules -> `import("@honua/sdk-esri-compat").then((m) => ({ default: m.*Compat }))` - advanced 3D-only modules (for example `Slice` and external-renderer style APIs) are emitted as manual TODO/report entries - `--target esri-leaflet` is for ArcGIS JS (`@arcgis/core`) inputs; existing Esri Leaflet apps generally do not need codemod migration - it rewrites supported dynamic imports to compat bridge expressions when safe (for example SceneView dynamic import), - it skips complex constructors and records manual TODO entries in the report, - it keeps CommonJS `require(...)` constructor sites as manual TODOs (for example `.cjs` or `.js` files exporting via `module.exports`/`exports.*`), - optionally it can inject inline `// TODO(honua-migrate)...` comments for manual sites (`--annotate-todos`), - it computes `manualRewrite = numerator / denominator` for codemod-scoped call sites, - it computes `manualIntervention = numerator / denominator` across codemod-scoped call sites plus unhandled ArcGIS usage hits, - migration report JSON includes `codemodTarget` so CI artifacts clearly indicate target mode (`honua-compat` or `esri-leaflet`), - it supports CI gating flags: - `--fail-on-manual` - `--fail-on-unhandled` - `--fail-on-blocked` - `--max-manual-ratio <0..1>` - `--max-manual-intervention-ratio <0..1>` - CLI summary includes: - per-type migration counts as `byKind=feature-layer:auto/manual/total,graphic:auto/manual/total,point-geometry:auto/manual/total,polyline-geometry:auto/manual/total,polygon-geometry:auto/manual/total,extent-geometry:auto/manual/total,spatial-reference:auto/manual/total,color:auto/manual/total,simple-line-symbol:auto/manual/total,simple-marker-symbol:auto/manual/total,picture-marker-symbol:auto/manual/total,text-symbol:auto/manual/total,label-class:auto/manual/total,simple-fill-symbol:auto/manual/total,class-breaks-renderer:auto/manual/total,simple-renderer:auto/manual/total,unique-value-renderer:auto/manual/total,...,route-layer:auto/manual/total,layer-list:auto/manual/total,legend-widget:auto/manual/total,popup-widget:auto/manual/total,home-widget:auto/manual/total,basemap-toggle-widget:auto/manual/total,locate-widget:auto/manual/total,scale-bar-widget:auto/manual/total,search-widget:auto/manual/total,basemap-gallery-widget:auto/manual/total,bookmarks-widget:auto/manual/total,expand-widget:auto/manual/total,compass-widget:auto/manual/total,fullscreen-widget:auto/manual/total,zoom-widget:auto/manual/total,attribution-widget:auto/manual/total,sketch-widget:auto/manual/total,editor-widget:auto/manual/total,track-widget:auto/manual/total,measurement-widget:auto/manual/total,time-slider-widget:auto/manual/total,directions-widget:auto/manual/total,query:auto/manual/total,feature-set:auto/manual/total,oauth-info:auto/manual/total,identity-manager:auto/manual/total,esri-request:auto/manual/total,esri-config:auto/manual/total,reactive-utils:auto/manual/total`, - grouped manual reasons, - unhandled ArcGIS module inventory (with `static-import` / `dynamic-import` / `require` usage style), - scanner flags include module-shape and risk hints (for example `commonjs-detected`, `scene-3d-detected`, `dynamic-import-detected`), - readiness classification (`ready`, `assisted`, `blocked`) with explicit gate results. --- # File: docs/ogc-api.md # OGC API Client Status: implemented in `src/core/ogc-tiles.ts`, `src/core/ogc-maps.ts`, `src/core/ogc-processes.ts`, `src/core/ogc-records.ts`, `src/core/stac.ts`, and (for OGC API Features) `src/core/surfaces.ts`. This document is the developer reference for the first-party OGC API adapter. It complements the design notes in [`shared-client-contract.md`](./shared-client-contract.md) and the capability matrix in [`protocol-capability-matrix.md`](./protocol-capability-matrix.md). Canonical OGC API Features queries can be explained before execution through `@honua/sdk-js/query-planner`. The deterministic `ogc-api-features-query-v1` output exposes the collection, CQL2 text filter, properties, sort, bbox, CRS, and paging request without fetching metadata or rows. See [Deterministic query planner](./query-planner.md#remote-pushdown). ## Conformance areas covered | Area | Surface | Source protocol | Notes | | --- | --- | --- | --- | | OGC API Features | `client.ogcFeatures()` | `ogc-features` | landing → collections → items, CQL2 filtering, transactions where the server advertises them | | OGC API Tiles | `client.ogcTiles()` | `ogc-tiles` | tileset discovery, vector + raster tiles on the canonical `/collections/{id}/tiles/{tms}/…` route (styled tiles deferred — see below) | | OGC API Maps | `client.ogcMaps()` | `ogc-maps` | dataset / collection map renders, styled access | | OGC API Processes | `client.ogcProcesses()` | (none — job runner) | discovery, async execution, `IJobRun<T>` surface | | OGC API Records | `client.ogcRecords()` | `ogc-records` | metadata/catalog discovery, record search, record detail, raw response access | | STAC API | `client.stac()` | `stac` | landing, collections, items, cross-collection search | OGC conformance class identifiers (e.g. `http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/filter`) are **not** primary SDK types. `negotiateOgcCapabilities(protocol, conformance)` translates a `/conformance` response into a canonical `Capabilities` set; `hasOgcConformanceClass(conformance, substring)` is the explicit substring gate for callers that want to branch on a specific extension. ## Getting started ```ts doc-test=compile import { HonuaClient } from "@honua/sdk-js/honua"; const baseUrl = "https://your-honua-server.example"; const client = new HonuaClient({ baseUrl }); ``` ### OGC API Features (already shipped) ```ts doc-test=skip reason="partial excerpt requires application host context" const ogc = client.ogcFeatures(); const items = await ogc.collection("parcels").items({ limit: 50, filter: "STATUS = 'ACTIVE'" }); ``` ### OGC API Tiles ```ts doc-test=skip reason="partial excerpt requires application host context" const tiles = client.ogcTiles(); // 1) Discover tilesets the server advertises for a collection const tilesets = await tiles.tilesets({ collectionId: "parcels" }); // 2) Bind to one tileset and fetch a tile const tileset = tiles.tileset("parcels", "WebMercatorQuad"); const tile = await tileset.tile({ tileMatrix: 5, tileRow: 9, tileCol: 12 }); console.log(tile.contentType); // "application/vnd.mapbox-vector-tile" console.log(tile.bytes.byteLength); // Raster tiles use the same route and rely on the `Accept` header to // negotiate the output media type. const png = await tileset.tile({ tileMatrix: 4, tileRow: 2, tileCol: 3, accept: "image/png" }); ``` Styled-tile access (the OGC `/styles/{styleId}/tiles/…` route) is part of the OGC API Tiles standard but is not currently exposed by the Honua server. The SDK intentionally does not synthesize that path today; it will be added when the server endpoint ships. ### OGC API Maps ```ts doc-test=skip reason="partial excerpt requires application host context" const maps = client.ogcMaps(); // Dataset-level render across multiple collections const map = await maps.map({ width: 1024, height: 1024, bbox: [-122, 37, -120, 38], collections: ["parcels", "roads"], format: "png", // short-name token; `image/png` also accepted and normalized }); // Collection-level styled render const styled = await maps.collection("parcels", "topographic").map({ width: 512, height: 512, }); ``` The `format` option is serialized to the server's `f` query parameter as a short-name token (`png`, `jpeg`, `jpg`, `tiff`, `tif`); the SDK normalizes `image/…` media-type aliases for ergonomics and sends a media-type `Accept` header regardless. The request envelope intentionally has no `filter` field — honua-server's Maps request model has no matching property, so a CQL2 filter would be silently ignored. Callers who need a custom query parameter can still pass one through `extraParams`. ### OGC API Processes ```ts doc-test=skip reason="partial excerpt requires application host context" import type { IJobRun } from "@honua/sdk-js/honua"; const processes = client.ogcProcesses(); const job: IJobRun<{ result: GeoJSON.Feature }> = await processes.execute({ processId: "buffer", inputs: { feature: somePoint, distance: 500 }, mode: "async", }); const unwatch = job.watch((snap) => { console.log("status=", snap.status, "progress=", snap.progress?.percent); }); try { const { outputs } = await job.results(); console.log("result feature:", outputs.result); } catch (error) { // HonuaJobFailedError carries status, errorCode, details console.error(error); } finally { unwatch(); } ``` `IJobRun` is the canonical async-operation surface. It is reused for every long-running operation in the SDK; the OGC Processes runner is just one implementation. `cancel()` is idempotent against the documented 404 / terminal-race paths but does not silently swallow other failures: - `404` ("job already gone"): return the cached status. - `409 "Cannot dismiss completed job"` (honua-server's `JobEndpoints` terminal-state pre-check / post-apply / durable conflict — the job raced the caller into a terminal state): re-poll the job and return the authoritative terminal status if the poll confirms it. If the poll surfaces a non-terminal status, the original 409 is rethrown so the caller is not lied to. - `409 "Dismiss could not be confirmed"` (backend dismissal request unconfirmed) and `409 "Cancellation not supported"` (backend lacks dismissal capability): rethrow the `HonuaHttpError` so the caller can branch or retry. Discrimination is by RFC 7807 problem-details `title` (and `detail` substring fallback). Terminal-failure snapshots populate `JobError` from `statusInfo.exception` when the server provides it, and fall back to `statusInfo.message` otherwise (honua-server's `StatusInfo` DTO only emits `message`). The resulting `HonuaJobFailedError.message` carries the server's reason text. ### STAC API ```ts doc-test=skip reason="partial excerpt requires application host context" const stac = client.stac(); // GET /search const page = await stac.search({ bbox: [-122, 37, -120, 38], collections: ["sentinel-2"], filter: "cloud_cover < 10", filterLang: "cql2-text", limit: 50, offset: 0, }); // GET /search with intersects + fields selection const filtered = await stac.search({ intersects: somePolygon, fields: { include: ["id", "properties.datetime"], exclude: ["assets.thumbnail"] }, }); // POST /search (large filter bodies, big intersects geometries, etc.) const post = await stac.search({ usePost: true, intersects: somePolygon, collections: ["sentinel-2"], }); // Drain across pages — advances via the rel=next link's `?offset=N` const everything = await stac.searchAll({ collections: ["sentinel-2"] }); ``` `StacSearchRequest.offset` is honua-server's canonical paging token: its `/stac/search` GET handler binds `[FromQuery] int? offset` and emits a `rel=next` link whose `href` carries `?offset=N`. `searchAll` / `searchStream` follow that link verbatim. `next` is kept as optional support for non-Honua STAC servers that advertise an opaque `?next=…` token instead. `intersects` and `fields` are serialized onto the GET query (intersects as JSON; fields as `id,properties.datetime,-assets.thumbnail`) in addition to the POST body. ### OGC API Records ```ts doc-test=skip reason="partial excerpt requires application host context" const records = client.ogcRecords(); // Discover available metadata catalogs. const catalogs = await records.collections(); // Search one catalog. Records query parameters are catalog metadata // filters, not STAC item-search filters. const page = await records.collection("catalog").search({ q: ["roads", "parcels"], type: ["service", "collection"], externalIds: ["FeatureServer:transportation"], bbox: [-158, 21, -157, 22], datetime: "2025-01-01/2025-12-31", limit: 25, offset: 0, }); const detail = await records.collection("catalog").record({ recordId: "service-transportation" }); // Raw response access keeps the shared auth/interceptor/retry pipeline but // leaves body decoding to the caller. const html = await records.collection("catalog").rawRecord({ recordId: "service-transportation", responseFormat: "html", accept: "text/html", }); ``` Records and STAC are intentionally separate surfaces. STAC describes spatiotemporal assets and collection/item search for imagery/data assets. Records describes metadata about resources: services, layers, collections, maps, scenes, styles, STAC collections, source descriptors, and other catalog entries. Honua admin metadata and migration inventory remain Honua-specific control-plane/read-model APIs; Records is the standards-facing public catalog view over stable metadata. `OgcRecordsSearchRequest` supports the Records core search parameters `bbox`, `datetime`, `limit`, `q`, `ids`, `type`, and `externalIds`, plus optional `filter` / `filter-lang` / `filter-crs` when the server advertises the Records filtering conformance class. `searchAll` and `searchStream` follow `rel=next` links that carry `?offset=N`. ## Canonical Source registration Source-shaped OGC adapters register through the shared client contract from `@honua/sdk-js/contract`. Cross-protocol code consumes them through the same `Dataset` / `Source` vocabulary as GeoServices / OData: ```ts doc-test=skip reason="partial excerpt requires application host context" import { createDataset, PROTOCOL_DEFAULT_CAPABILITIES } from "@honua/sdk-js/contract"; const dataset = createDataset({ id: "parcels", client, sources: [ { id: "parcels-ogc", protocol: "ogc-features", locator: { url: baseUrl, collectionId: "parcels" }, capabilities: PROTOCOL_DEFAULT_CAPABILITIES["ogc-features"], }, { id: "parcels-tiles", protocol: "ogc-tiles", locator: { url: baseUrl, collectionId: "parcels", tileMatrixSetId: "WebMercatorQuad" }, capabilities: PROTOCOL_DEFAULT_CAPABILITIES["ogc-tiles"], }, { id: "parcels-map", protocol: "ogc-maps", locator: { url: baseUrl, collectionId: "parcels", styleId: "topographic" }, capabilities: PROTOCOL_DEFAULT_CAPABILITIES["ogc-maps"], }, { id: "parcels-stac", protocol: "stac", locator: { url: baseUrl, collectionId: "parcels" }, capabilities: PROTOCOL_DEFAULT_CAPABILITIES.stac, }, { id: "catalog-records", protocol: "ogc-records", locator: { url: baseUrl, collectionId: "catalog" }, capabilities: PROTOCOL_DEFAULT_CAPABILITIES["ogc-records"], }, ], }); const features = await dataset.source("parcels-ogc")!.query({ where: "STATUS = 'ACTIVE'" }); const stacItems = await dataset.source("parcels-stac")!.query({ where: "cloud_cover < 10" }); const catalogRecords = await dataset.source("catalog-records")!.query({ where: "type = 'service'" }); const tileset = dataset.source("parcels-tiles")!.adapter("ogc-tiles"); // HonuaOgcTileset const map = dataset.source("parcels-map")!.adapter("ogc-maps"); // HonuaOgcCollectionMap const recordsCatalog = dataset.source("catalog-records")!.adapter("ogc-records"); // HonuaOgcRecordCollection ``` OGC API Tiles and OGC API Maps are render-only; their `Source.query*` methods throw `HonuaCapabilityNotSupportedError`. Renderers always reach the underlying class through `Source.adapter("ogc-tiles")` / `Source.adapter("ogc-maps")`. ## Capability negotiation and graceful degradation ```ts doc-test=compile import { HonuaClient } from "@honua/sdk-js/honua"; import { negotiateOgcCapabilities, hasOgcConformanceClass } from "@honua/sdk-js/honua"; const client = new HonuaClient({ baseUrl: "https://example.test" }); const conformance = await client.getOgcFeaturesConformance(); const caps = negotiateOgcCapabilities("ogc-features", conformance); if (caps.has("queryAggregate")) { // server advertises ogcapi-features-3/conf/filter; a strict Source can allow // the adapter's documented client-side aggregation fallback. } if (hasOgcConformanceClass(conformance, "features-4/1.0/conf/create-replace-delete")) { // server advertises Part 4 transactions } ``` Under `capabilityPolicy: "degraded"`, OGC API Features falls back to client-side aggregation and metadata-bbox extent (see [`protocol-capability-matrix.md`](./protocol-capability-matrix.md) for the per-protocol coverage). All other adapters honour the strict behaviour: a missing capability throws `HonuaCapabilityNotSupportedError`. ## Conformance fixtures The repo ships parametrized conformance suites under `test/contract/`: - `ogc-tiles.test.ts` — tileset discovery + tile fetch + Source adapter - `ogc-maps.test.ts` — dataset / collection / styled map renders - `ogc-processes.test.ts` — `IJobRun` lifecycle, cancel, error paths - `ogc-records.test.ts` — Records query params, paging, raw access, Source adapter - `stac.test.ts` — GET / POST search + Source adapter - `ogc-conformance.test.ts` — conformance-class negotiation These run against mock fetch responders and are the regression baseline for "passes against Honua Server's current OGC parity matrix" (Features CITE-certified, Tiles CITE-certified, Maps, Processes async). --- # File: docs/wfs.md # WFS 2.0 adapter `@honua/sdk-js` ships a first-party WFS 2.0 client that conforms to the shared JS client contract from [`docs/shared-client-contract.md`](./shared-client-contract.md). A WFS source registered through `createDataset({ sources })` produces the same canonical `Source<T>` / `Query<T>` / `Result<T>` / `EditEnvelope<T>` / `EditResult` shapes as the GeoServices and OGC Features adapters, so mixed-source operator apps do not have to learn WFS / XML specifics. ```ts doc-test=compile import { createDataset, PROTOCOL_DEFAULT_CAPABILITIES } from "@honua/sdk-js/contract"; import { HonuaClient } from "@honua/sdk-js"; 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" }, capabilities: PROTOCOL_DEFAULT_CAPABILITIES.wfs, }, ], }); const source = dataset.source("parcels-wfs")!; const result = await source.query({ where: "STATE = 'CA' AND ACRES > 10" }); ``` ## Capabilities The default capability set (`PROTOCOL_DEFAULT_CAPABILITIES.wfs`) is `query`, `queryExtent`, `queryObjectIds`, `applyEdits`, `stream`. The capability matrix in [`protocol-capability-matrix.md`](./protocol-capability-matrix.md) covers each row's degraded fallbacks (none here — WFS is either supported or it isn't). `queryAggregate`, `queryRelated`, `attachments` throw `HonuaCapabilityNotSupportedError` because WFS 2.0 does not expose server-side aggregation, related-records, or feature attachments. ## Locator ```ts doc-test=compile interface WfsLocator { url: string; // Fully qualified WFS endpoint (e.g. https://server/wfs) typeName: string; // Namespace-qualified feature-type name (e.g. parcels:lot) featureNamespace?: string; // URI bound to the typeName prefix (required for prefixed applyEdits) } ``` The endpoint URL must share an origin with the `HonuaClient`'s `baseUrl`; cross-origin WFS sources require constructing a separate `HonuaClient`. `featureNamespace` is the namespace URI the server advertises for the `typeName` prefix (typically declared as `xmlns:<prefix>="…"` on the `<wfs:WFS_Capabilities>` root). The canonical adapter binds it on the `<wfs:Transaction>` root so per-handle feature elements (`<parcels:lot>…</parcels:lot>`) and prefixed `typeName="…"` attribute references on `<wfs:Update>` / `<wfs:Delete>` resolve. When the locator omits `featureNamespace` and the type name carries a prefix, the adapter falls back to a synthetic URN (`urn:honua:wfs:feature-namespace:<prefix>`) so the document is well-formed XML; strict servers will reject the synthetic URI with an `<ows:ExceptionReport>` whose locator names the prefix, telling callers which descriptor field to set. Unprefixed `typeName` values do not need `featureNamespace`. ## Capability negotiation `HonuaWfs.capabilities()` issues a single `GetCapabilities` request the first time `query`, `queryAll`, `queryObjectIds`, `stream`, or `queryExtent` runs (the no-network `queryExtent` shortcut also reads the cached snapshot to find the per-feature-type `ows:WGS84BoundingBox`). The parsed snapshot is cached per `HonuaWfs` instance — subsequent calls reuse it. Use `wfs.refresh()` to drop the cache. `applyEdits` does not pre-fetch capabilities because the canonical transaction body never needs the output-format negotiation; servers that do not advertise `Transaction` surface that as a server-side `OperationProcessingFailed` `<ows:ExceptionReport>` on the first transaction request, projected onto `HonuaWfsExceptionError` (carrying `exceptionCode` and `locator`). The descriptor's `capabilities` set is the SDK's promise of what the adapter can fulfil; the constructor does not currently widen or narrow it from `GetCapabilities`. Callers that need a downgraded set per source (for example, dropping `applyEdits` for a server that publishes WFS read-only) intersect the default themselves and pass the result on `SourceDescriptor.capabilities`. ## Content-type negotiation The adapter treats the server as advertising JSON when `OperationsMetadata.GetFeature` lists any of `application/geo+json`, `application/json`, `application/vnd.geo+json`, `json`, or `geojson` and prefers the GeoJSON encoding over GML. If only GML is advertised, the canonical `Source.query()` throws `HonuaCapabilityNotSupportedError("query")` rather than ship raw XML through `Result.features`. Callers can still reach the GML payload through `Source.protocol("wfs").getFeature(...)` (see "Protocol escape hatch" below). GML decoding is intentionally out of scope. A future ticket may add an opt-in GML decoder; for now the canonical surface is GeoJSON-only. ## Filter encoding (FES 2.0) `Query.where` compiles to FES 2.0 OGC Filter Encoding. The supported subset is: - comparison operators: `=`, `<>`, `!=`, `<`, `<=`, `>`, `>=` - `IN (a, b, …)` / `NOT IN (…)` - `BETWEEN x AND y` / `NOT BETWEEN` - `LIKE 'pattern%'` / `NOT LIKE` - `IS NULL` / `IS NOT NULL` - boolean combinators `AND`, `OR`, `NOT` - parenthesization - string literals (single-quoted, `''` escapes), numeric literals, property identifiers (dotted path supported) Anything richer — function calls, subqueries, vendor extensions — yields `HonuaCapabilityNotSupportedError("query")` rather than emitting a silent partial filter. Callers that need the full FES vocabulary reach the wire through `Source.protocol("wfs").getFeature({ filter: rawFesXml })`. `Query.spatialFilter` compiles to FES `<fes:BBOX>` (envelope geometry with `spatialRel` undefined / `esriSpatialRelIntersects` / `esriSpatialRelEnvelopeIntersects`) or the corresponding spatial predicate (`<fes:Intersects>`, `<fes:Within>`, `<fes:Contains>`, `<fes:Crosses>`, `<fes:Overlaps>`, `<fes:Touches>`). For envelope geometry with a non-intersects relation (`Contains`, `Within`, `Crosses`, `Overlaps`, `Touches`), the adapter lowers the envelope to a GML 3.2 polygon and emits the requested predicate so the server honors the relation rather than silently widening to bbox semantics. Geometry serialization is GML 3.2 simple (point / line / polygon); curves and surfaces throw and require the escape hatch. The geometry property name defaults to `the_geom`. Servers using a different name (`geometry`, `shape`, …) can supply a per-source filter through the protocol escape hatch. ## Field projection (`outFields` and `returnGeometry`) WFS `propertyName=` drops every property the caller does not list, including the geometry column. The canonical `Query` contract treats `outFields` and `returnGeometry` as independent controls (geometry is included unless `returnGeometry === false`), so the adapter resolves the two like this: | `outFields` | `returnGeometry` | wire `propertyName=` | | --- | --- | --- | | _unset / empty_ | _unset_ / `true` | _omitted_ — server returns all fields + geometry | | _set_ | _unset_ / `true` | `outFields,the_geom` (geometry property appended unless already listed) | | _set_ | `false` | `outFields` only — geometry is intentionally dropped | | _unset / empty_ | `false` | _refused_ — `HonuaCapabilityNotSupportedError("query")` | The `returnGeometry === false` + no-`outFields` case throws because WFS cannot suppress geometry without enumerating every non-geometry property; silently widening to "geometry included" would break the canonical contract. Callers that need geometry-less rows must list the non-geometry fields they want, or reach the wire through `Source.protocol("wfs")`. `queryExtent` and `queryObjectIds` ignore both `outFields` and `returnGeometry` on the drain path so caller intent on those fields cannot break the drain. `queryExtent` always issues geometry-bearing pages (it computes a bbox from each feature's geometry); a `returnGeometry: false` paired with `queryExtent` does **not** throw, the field is stripped before the drain. `queryObjectIds` reads each feature's GeoJSON `id` directly, so neither knob affects the result — both are stripped before the drain so the caller's `outFields` cannot push the geometry property onto the wire and `returnGeometry: false` cannot trip the propertyName-suppression guard. ## GET vs. POST routing Filters whose encoded length exceeds `~7000` characters are routed through POST GetFeature with a `<wfs:GetFeature>` body containing a `<wfs:Query typeNames="…" srsName="…">`, optional `<wfs:PropertyName>` projections, the same `<fes:Filter>` tree, and an optional `<fes:SortBy>` block. `Query.outFields`, `Query.orderBy`, and `Query.outSr` survive the GET → POST switch — the only transport difference is the body encoding. The 7000-character threshold is a single constant we revise after telemetry lands. `Query.outSr` accepts either a string CRS URI / EPSG token (passed through verbatim) or a numeric WKID. Numeric WKIDs are translated to the OGC URN form `urn:ogc:def:crs:EPSG::<wkid>` so the wire shape matches what `OperationsMetadata` and `Filter_Capabilities` advertise. ## Pagination `Query.pagination.offset` maps to `startIndex`; `Query.pagination.limit` maps to `count`. `queryAll` requests `limit + 1` rows so the adapter can stamp `Result.exceededTransferLimit: true` when more records exist. `stream` paginates internally with a 2000-row default page size or the caller's `pagination.limit` when supplied. `pagination.limit === 0` is treated as an explicit "zero records" cap across `query`, `queryAll`, `stream`, and `queryObjectIds` — the default page-size fallback only applies when `limit` is unset or negative. `query` / `stream` / `queryObjectIds` short-circuit before the wire call (returning an empty result, no yielded pages, or `[]` respectively); `queryAll` still issues a single 1-row lookahead so `exceededTransferLimit` can flip when more records exist. `Result.totalCount` populates from the GeoJSON `numberMatched` field; `exceededTransferLimit` flips when `numberMatched > features.length`. ## queryObjectIds WFS 2.0 has no interoperable server-side ids-only mode, so `queryObjectIds` drains the matching set across pages and projects the GeoJSON `id` from each feature. The default page size is 2000; the drain stops as soon as the server returns a short page. `Query.pagination.offset` chooses where the drain starts (forwarded as `startIndex` to `GetFeature`) and `Query.pagination.limit`, when set, caps the global id count — not the per-page count — so callers can stop the drain without learning the server's default page size. The adapter shrinks each page to `min(2000, remaining)` so the final page never overshoots the cap. A `pagination.limit` of `0` short-circuits the drain and returns `[]` without a wire call. `Query.outFields` and `Query.returnGeometry` are stripped before each drained page is requested. The drain reads each feature's top-level GeoJSON `id` (a sibling of `properties` and `geometry`), so neither knob affects the result; stripping them keeps the wire request from emitting an unnecessary `propertyName=` and prevents `returnGeometry: false` from tripping the canonical "no `outFields`" guard documented in [Field projection](#field-projection-outfields-and-returngeometry). ## queryExtent Unfiltered `queryExtent()` (no `where`, no `spatialFilter`, no `outSr`) reads the per-feature-type `ows:WGS84BoundingBox` from `GetCapabilities` and returns the cached envelope without any extra HTTP traffic. Filtered or `outSr`-bearing requests drain every page of the matching set (2000 features per page) and compute the bbox client-side, so the returned extent always covers the full filtered set rather than just the first server page. Caller pagination (`Query.pagination.offset` / `.limit`), `Query.outFields`, and `Query.returnGeometry` are intentionally ignored on this path — `queryExtent` answers "what bbox holds the matching records" rather than "what bbox holds the first page", and the drain must always see geometry on the wire to compute a bbox. A caller-supplied `outFields` projection would emit `propertyName=...` and drop geometry from every drained page; a caller-supplied `returnGeometry: false` would trip the field-projection guard. The drain therefore strips all three fields before issuing each `GetFeature` page so geometry is preserved end-to-end and the call cannot be refused on a knob that does not apply to extent computation. `queryExtent` returns `{ extent, count? }` and does not carry a `degraded[]` array; the OGC Features adapter is the only one that flags this fallback today. ## Edits (`applyEdits`) `applyEdits` builds a single `<wfs:Transaction>` POST body with `<wfs:Insert>`, `<wfs:Update>`, and `<wfs:Delete>` blocks. Geometry payloads come from `CanonicalFeature.geometry` (GeoJSON → GML 3.2). The transaction's `releaseAction` follows `EditEnvelope.rollbackOnFailure`: | `rollbackOnFailure` | `releaseAction` | | --- | --- | | `true` | `ALL` | | `false` / omitted | `SOME` | Per-handle `<wfs:InsertResults>` `<fes:ResourceId rid="…"/>` IDs populate `EditOutcome.id`. The adapter stamps each `<wfs:Insert>` with a stable `handle="add-N"` (1-based, matching `envelope.adds` order) and indexes the returned `<wfs:Feature handle="…">` buckets by handle when mapping ResourceIds back onto `EditResult.added`, so a server that reorders the buckets — or omits them under `releaseAction="SOME"` when an insert fails — does not misassign IDs to the wrong `envelope.adds[i]`. Inserts whose handle is missing from the response surface as `{ success: false }` rather than silently inheriting the neighbouring success. The handle attribute is informational in WFS 2.0, so when no `<wfs:Feature>` carries one the adapter falls back to the legacy positional pairing instead of dropping every id. `OperationProcessingFailed` and other `<ows:ExceptionReport>` responses surface as `HonuaWfsExceptionError` with `.exceptionCode` / `.locator` preserved. `CanonicalFeature.id` is required on every update because each `<wfs:Update>` is filtered by `<fes:ResourceId>`; without an id the block would mass-update every feature in the type. Updates whose `id` is `undefined` / `null` are filtered out before the transaction body is built and surface as deterministic per-item failures (`{ success: false, error: { code: 400, description: "update.id is required" } }`) on `EditResult.updated`. Valid updates in the same envelope still travel as a single transaction. When every operation in the envelope is absent or malformed the adapter skips the wire round-trip entirely so the server never sees an unaddressed transaction. ## Stored queries `ListStoredQueries` and `DescribeStoredQueries` are reachable through the protocol escape hatch: ```ts doc-test=skip reason="partial excerpt requires application host context" const wfs = source.protocol("wfs")!; // HonuaWfsFeatureType const ids = await wfs.root.storedQueries(); const sq = wfs.root.storedQuery("byKey"); const response = await sq.execute({ parameters: { id: 1 } }); ``` A stored query whose output is JSON returns canonical features through `response.kind === "json"`. Stored queries that advertise only GML (today: Honua Server's `urn:ogc:def:query:OGC-WFS::GetFeatureById`) cannot be projected onto the canonical envelope — `Source.query()` does not carry stored-query intent because that would re-introduce WFS-specific shapes at the top level. The escape hatch above still returns the raw GML payload. ## Protocol escape hatch `Source.protocol("wfs")` returns a bound `HonuaWfsFeatureType` whose methods carry the raw WFS-shaped payloads: ```ts doc-test=skip reason="partial excerpt requires application host context" const wfs = source.protocol("wfs")!; // Raw XML capabilities payload (cached after the first call). const snapshot = await wfs.capabilities(); // Custom FES filter (escape unsupported expressions). await wfs.getFeature({ filter: customFesXml }); // GetPropertyValue (returns raw XML). await wfs.getPropertyValue({ valueReference: "ACRES" }); // Custom Transaction body. await wfs.transaction({ body: rawTransactionXml }); ``` `HonuaWfs` (`wfs.root`) owns the capabilities cache and exposes `capabilities()`, `refresh()`, `rawCapabilities()`, `storedQueries()`, and `storedQuery(id)` so callers can drive WFS without going through the canonical `Source` surface when they need full FES expressivity. ## Locking WFS `LockFeature` / `GetFeatureWithLock` are not exposed in the canonical contract. Callers that need locks reach the wire through the protocol escape hatch — there is no top-level `Source.lock()` concept. ## Defenses - The capabilities XML walker refuses any document declaring `<!DOCTYPE>` or `<!ENTITY>`. This stops XXE-class attacks before any property is read. The same walker is reused for `<ows:ExceptionReport>` and `<wfs:TransactionResponse>` parsing, so every WFS XML payload entering the canonical surface is hardened. - The WFS adapter never reaches `fetch` directly; all wire calls go through `HonuaClient.requestText`, so the existing interceptor / retry / timeout / abort signal pipeline applies. - WFS responses with content-type `application/xml` containing `<ows:ExceptionReport>` are turned into typed `HonuaWfsExceptionError` instances (`exceptionCode`, `locator`, and the message as the parser saw it) so the canonical surface surfaces structured WFS errors instead of opaque HTTP failures. `HonuaWfsExceptionError` is also raised when an `<ows:ExceptionReport>` arrives wrapped inside a `HonuaHttpError` body (the body is sniffed for `ExceptionReport` and re-thrown as the typed error before the failure leaves the adapter). ## Server compatibility The adapter targets WFS 2.0 servers. WFS 1.x is intentionally not supported — Honua Server publishes WFS 2.0 only. Non-Honua servers that advertise WFS 2.0 with at least GeoJSON output should work out of the box; servers that only emit GML force the canonical surface to throw and require the escape hatch. --- # File: docs/geoparquet.md # GeoParquet / DuckDB-WASM source `@honua/sdk-js/geoparquet` adds a `Source` that runs the **same protocol-neutral `Query`** you use against a FeatureServer or an OGC API Features collection — but against GeoParquet files, in the browser, via [DuckDB-WASM](https://duckdb.org/docs/api/wasm/overview) and its `spatial` extension. The query compiles to SQL over `read_parquet(...)` and returns the standard `Result` (GeoJSON features + schema), so results render through the same query-tiles runtime path as any other source. This is the "query Overture GeoParquet in the browser via DuckDB-WASM" reference architecture, slotted directly into the `Dataset → Source → Query → Result` contract. ## Install `@duckdb/duckdb-wasm` is an **optional peer dependency** — it is not bundled and not pulled into the `/contract` or `/honua` entrypoints. Install it (and its `apache-arrow` peer) only when you use this source: ```bash npm i @duckdb/duckdb-wasm apache-arrow ``` The engine is reached through a dynamic `import()`, so there is no static dependency edge from the core SDK. If the peer is missing, constructing a query throws a clear "install `@duckdb/duckdb-wasm`" error rather than failing opaquely. ## Quickstart Wire the resolver into `createDataset`. One `GeoparquetRuntime` — one shared DuckDB Web Worker — backs every geoparquet source in the dataset. ```ts doc-test=compile import { createDataset, PROTOCOL_DEFAULT_CAPABILITIES } from "@honua/sdk-js/contract"; import { geoparquetResolver } from "@honua/sdk-js/geoparquet"; import { envelope } from "@honua/sdk-js"; import { HonuaClient } from "@honua/sdk-js/honua"; const client = new HonuaClient({ baseUrl: "https://your-honua-server.example" }); const geoparquet = geoparquetResolver(); const dataset = createDataset({ id: "overture", client, capabilityPolicy: "degraded", resolveSource: geoparquet, sources: [ { id: "places", protocol: "geoparquet", // A single file, or a hive-partitioned glob (e.g. an Overture theme): locator: { url: "https://example.com/overture/theme=places/**/*.parquet" }, capabilities: PROTOCOL_DEFAULT_CAPABILITIES.geoparquet, }, ], }); const places = dataset.source("places")!; // The SAME Query object shape that runs against a FeatureServer source: const result = await places.query({ where: "categories.primary = 'restaurant'", spatialFilter: envelope(-158.5, 21.2, -157.6, 21.7), outFields: ["id", "names", "categories"], pagination: { limit: 500 }, returnGeometry: true, }); for (const feature of result.features) { console.log(feature.attributes.id /* GERS id preserved */, feature.geometry); } // Tear down the shared worker when the client is disposed: await geoparquet.dispose(); ``` You can also construct a source directly with `geoparquetSource(descriptor, { runtime })` if you are not using `createDataset`. ## How the Query compiles to SQL | `Query` field | DuckDB SQL | | --- | --- | | `outFields` | quoted identifier projection; geometry projected as `ST_AsGeoJSON(...)` | | `where` | passed through verbatim, wrapped in `( … )` (caller-authored SQL, like GeoServices `where`) | | `spatialFilter` (envelope) | `ST_Intersects(<geom>, ST_MakeEnvelope(xmin, ymin, xmax, ymax))`, or a GeoParquet 1.1 `bbox` covering-column comparison (row-group prune) | | `spatialFilter` (point/polyline/polygon) | reduced to its bounding box; reported in `Result.degraded` as an approximation | | `orderBy` | `ORDER BY "field" ASC|DESC` | | `pagination` | `LIMIT` / `OFFSET` | | `aggregation` | `GROUP BY` with `count/sum/avg/min/max/stddev_samp/var_samp` metrics | | `returnGeometry: false` | geometry omitted from the projection | ### SQL-injection safety Every value the compiler interpolates is escaped: - **Identifiers** (`outFields`, `orderBy`, `groupBy`, geometry column): double-quoted with embedded quotes doubled; control characters rejected. - **String literals** (parquet URLs): single-quoted with embedded quotes doubled; NUL rejected. - **Numeric literals** (bbox, limit, offset): validated finite / non-negative. The one deliberate exception is `Query.where`, which — exactly like the GeoServices and OGC adapters — is caller-authored filter SQL passed through verbatim. The trust boundary on `where` is the caller's, identical to a GeoServices `where` clause. The compiler is covered by snapshot tests in `test/geoparquet-sql.test.ts`. ## Both metadata styles The source handles both geometry storage conventions, detected from the parquet footer and cached per source-URL set: 1. **GeoParquet 1.0 / 1.1 metadata files** — the `geo` key-value JSON is parsed for the primary geometry column, CRS, and (1.1) the `bbox` covering column. 2. **Parquet-native geometry** — a `GEOMETRY` / `GEOGRAPHY` column type (Parquet 2.11, March 2025), a raw WKB `BLOB`, or a GeoJSON string column, inferred from the DuckDB column type and conventional geometry column names. The physical encoding (`GEOMETRY` used directly, `BLOB` wrapped in `ST_GeomFromWKB`, string wrapped in `ST_GeomFromGeoJSON`) is keyed off the type DuckDB actually returns, so both styles produce an identical `Result` shape. ## `describe()` Reach the metadata through the typed escape hatch: ```ts doc-test=skip reason="partial excerpt requires application host context" const handle = places.protocol("geoparquet")!; const description = await handle.describe(); // { schema: HonuaFieldInfo[], geometryColumns: ["geometry"], // geometryEncoding: "wkb" | "native" | "geojson", crs: "OGC:CRS84", // rowEstimate: 12345 } ← rowEstimate from the parquet footer, no table scan const rows = await handle.sql("SELECT count(*) FROM read_parquet('...')"); // raw escape hatch ``` ## Aggregation ```ts doc-test=skip reason="partial excerpt requires application host context" const summary = await places.queryAggregate({ aggregation: { groupBy: ["categories.primary"], metrics: [{ fn: "count", field: "*", alias: "n" }], }, }); // summary.aggregateRows: [{ "categories.primary": "restaurant", n: 812 }, …] ``` ## Lifecycle & memory ceiling - A `GeoparquetRuntime` owns **exactly one** DuckDB instance and one Web Worker, created lazily on the first query and shared across every source it backs (NFR-001: single shared worker per client). - Call `resolver.dispose()` / `runtime.dispose()` when the owning client is torn down to terminate the worker. Disposal is idempotent. - DuckDB-WASM runs inside a single WASM linear memory with a **~4 GiB ceiling** (32-bit addressing). In practice keep the working set — scanned columns × matched rows, plus the spatial index — well under ~2 GiB. Prefer narrow `outFields`, push a `spatialFilter` down, and bound results with `pagination.limit` on large Overture extracts. - Parquet footers / row-group metadata are cached per source-URL set **inside the runtime**; there is no on-disk persistence. - `Source.stream()` uses DuckDB-WASM Arrow record batches when the driver supports them; `Source.query()` intentionally materializes its bounded result. `Query.signal` is forwarded to the browser driver's `cancelSent()` path. - Browser deployments can set `loadSpatial: false` when a GeoParquet `bbox` covering is sufficient, and can pin `extensionRepository` plus `preloadExtensions` to keep Parquet execution self-hosted. - Browser deployments reading large remote objects can set `filesystem: { reliableHeadRequests: true, allowFullHttpReads: false }` to require range-capable HTTP I/O and fail closed instead of allowing DuckDB-WASM's full-file fallback. ## Capability honesty `geoparquet` advertises `{ query, queryAggregate, stream }`. Everything else is an honest miss that throws `HonuaCapabilityNotSupportedError`: `queryExtent`, `queryObjectIds`, `queryRelated`, `applyEdits`, and `attachments`. The source is read-only (static files) and exposes no server-side ids/extent endpoint. There is no realtime path. ## Overture recipe Overture Maps ships monthly GeoParquet releases. The [`examples/overture-geoparquet`](../examples/overture-geoparquet/) demo runs entirely against a **fixture-sized extract committed to the repo** (no Honua server, CI-deterministic) and preserves GERS ids in results. For live Overture data, resolve a pinned file from Overture's STAC catalog before constructing the source. Do not hand a global glob to a browser without an AOI, projection, result limit, memory budget, cancellation, and file-level STAC selection: ```ts doc-test=skip reason="partial excerpt requires application host context" // Overture release layout (see https://docs.overturemaps.org/): const PINNED_ITEM = "https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-06-17.0/theme=places/type=place/part-00000-6c973aba-862d-590f-a178-70bcd31cde1c-c000.zstd.parquet"; createDataset({ id: "overture-live", client, capabilityPolicy: "degraded", resolveSource: geoparquetResolver(), sources: [ { id: "places", protocol: "geoparquet", locator: { url: PINNED_ITEM }, capabilities: PROTOCOL_DEFAULT_CAPABILITIES.geoparquet, }, ], }); ``` Always send a `spatialFilter`, narrow `outFields`, `pagination.limit`, and `signal` against live Overture. A bbox predicate creates a pruning opportunity; it does not by itself prove bytes avoided or row groups skipped. The current browser driver does not expose its internal HTTP bytes/ranges, rows scanned, or row-group pruning metrics. The flagship sample reports those as unverified and uses an explicit execution deadline rather than falling back to full materialization. ## Regenerating the test fixtures The tiny committed fixtures under `test/fixtures/geoparquet/` are produced by `npm run geoparquet:fixtures` (which drives DuckDB-WASM's Node bindings). Only run it when the fixture schema changes. --- # File: docs/webmap-json-compatibility.md # WebMap JSON Compatibility Contract This document defines the compatibility contract for `parseWebMap()` in `@honua/sdk-js/webmap`. Version target: ArcGIS WebMap JSON `2.x` (current AGOL output). ## Output Contract `parseWebMap(input)` returns: - `style`: Honua/MapLibre style specification (`version: 8`) - `warnings`: non-fatal compatibility warnings (`code`, `path`, `message`, optional `context`) - `bookmarks`: passthrough WebMap bookmarks array - `popups`: popup metadata keyed by operational layer id Parser behavior: - Unsupported or unknown properties are warnings, not throw conditions. - Conversion aims to preserve visible map behavior for supported 2D properties. - Unsupported 3D and complex Arcade flows are explicitly called out for manual intervention. - The exploratory Cesium example under `docs/examples/cesium-route-playback/` consumes `HonuaClient.queryFeatures()` directly and does not expand `parseWebMap()` 3D support. ## Support Matrix | WebMap JSON area | Support | Notes | | --- | --- | --- | | `operationalLayers` | Partial | `ArcGISFeatureLayer` and `ArcGISMapServiceLayer` supported | | `baseMap` | Partial | Tiled basemap layers supported; vector basemap partial | | `spatialReference` | Partial | Web Mercator (3857/102100/102113) and 4326 handling | | `initialViewpoint` | Partial | `targetGeometry` point/extent + optional `scale` | | `initialExtent` | Full | Converted to `center`/`zoom` | | `bookmarks` | Full | Passed through in parser result | | Simple renderer | Full | Converted to layer paint/layout | | Unique value renderer | Full | Converted to `match` expressions | | Class breaks renderer | Full | Converted to `step` expressions | | Heatmap renderer | Unsupported | Warning emitted | | Dot density renderer | Unsupported | Warning emitted | | Popup `title`/`description` | Full | Preserved in popup config | | Popup `fieldInfos` | Full | Preserved in popup config | | Popup `mediaInfos` | Full (basic) | Preserved in popup config | | Popup `expressionInfos` | Unsupported | Warning emitted; manual intervention | | `labelingInfo` | Partial | Basic `$feature.FIELD` and simple concat only | | 3D properties | Unsupported | Warning emitted; manual intervention | ## Warning Codes Primary warning codes emitted by the parser: - `unknown-property` - `unsupported-webmap-version` - `unsupported-renderer` - `unsupported-symbol` - `unsupported-layer-type` - `unsupported-feature-collection` - `unsupported-spatial-reference` - `unsupported-viewpoint-geometry` - `unsupported-arcade-expression` - `unsupported-3d-property` - `complex-arcade` - `complex-label-expression` - `vector-tile-partial` - `sprite-required` ## Golden Fixtures Golden fixtures live under: - `test/fixtures/webmap-json/*/input.json` - `test/fixtures/webmap-json/*/expected.json` Fixture suite test: - `test/webmap-parse.test.ts` The suite is executed by `npm test`, which is required in CI (`.github/workflows/ci.yml`) on every PR and push to `trunk`. ## Manual Intervention Cases Manual intervention is expected for: - 3D scene properties and layer types - complex Arcade expressions in labels/popups - renderer types outside simple/uniqueValue/classBreaks - inline `featureCollection` content --- # File: docs/maplibre-runtime.md # MapLibre GL JS Runtime (`@honua/sdk-js/runtime`) Status: implemented in `src/runtime/` (ticket `honua-sdk-js-21`). Public entrypoint: `@honua/sdk-js/runtime` (subpath export only; the root barrel does not re-export the runtime so hosts that do not need a map can avoid pulling in the MapLibre-aware code). The runtime binds a server-produced `MapPackage` (from `honua-io/honua-server#731`) to a caller-provided `maplibre-gl.Map`. It composes the style, projects `sourceBindings[]` through the shared `@honua/sdk-js/contract` adapters, applies `StyleRef` overrides and `ThemeSpec` tokens, wires popups / legend / initial view, and exposes a stable operational API for `#22` (mixed-protocol composition) and `#29` (operator components) to build on. The runtime **does not** instantiate `maplibre-gl.Map`, issue edit writes, or duplicate query logic — `maplibre-gl` stays a peer dependency, edits flow through the existing adapters, and queries go through the contract's `Source` handles. ## Accepted plan to native GeoJSON `@honua/sdk-js/map` includes the first production slice of the automatic source-to-map workflow. It executes an already-accepted query plan against a canonical `Source`, projects the result to a native MapLibre GeoJSON source, generates geometry-aware default layers, and owns refresh/disposal without importing `maplibre-gl`: ```ts doc-test=skip reason="partial excerpt requires application host context" import { mountSourceToMapLibre } from "@honua/sdk-js/map"; import { explainQuery } from "@honua/sdk-js/query-planner"; const plan = explainQuery({ descriptor: parcels.descriptor, query: { pagination: { limit: 5_000 }, outSr: 4326 } }); const mounted = await mountSourceToMapLibre(map, parcels, plan); console.table(mounted.diagnostics); await mounted.refresh(); mounted.dispose(); ``` The mount is peer-injected and therefore safe to import in browser, SSR, Node, and worker builds. `executeQueryPlan` verifies the accepted fingerprint and source context before querying. The adapter then: - selects the exact `geojson-query` strategy and records plan fingerprint, source/schema versions, authorization scope, attribution, and strategy on the projection; - promotes the descriptor primary key to MapLibre feature identity; - emits point, line, and polygon layers with stable ids and default paint, including filtered layers for mixed-geometry results; - reports empty, mixed, and unsupported-geometry states through stable machine-readable diagnostics rather than hiding fidelity loss; - rejects descriptor/runtime capability drift, aggregate or attribute-only plans, existing source/layer ids, invalid clustering, stale plan context, and renderer mutation failures with typed errors; - treats invalid, missing, and explicitly mismatched geometry as visible degraded diagnostics rather than an exact-ready map; - rolls back partial source/layer mutation (including renderers that mutate and then throw), serializes refreshes through `setData`, cancels in-flight work, and disposes its layers/source idempotently. This is intentionally not the whole #390 strategy matrix. Automatic vector or raster tiles, PMTiles, WMS/WMTS, dynamic query tiles, owned Map construction, labels/popups/selection, edits, and realtime subscriptions remain on their existing lower-level APIs until later slices can bind them to the same accepted plan and lifecycle contract without ambiguous fallback. ## Module layout ``` src/runtime/ ├── index.ts # barrel — public surface ├── map-package.ts # HonuaMapPackage type (mirrors honua-server#731) ├── map-package-fetch.ts # hosted fetch + load-from-id helpers ├── map-package-validation.ts # typed validation diagnostics ├── map-package-watch.ts # disposable polling watcher ├── load-package.ts # loadMapPackage(pkg, map, opts) → HonuaMapRuntime ├── runtime.ts # HonuaMapRuntime class + event/telemetry types ├── source-bridge.ts # SourceBinding[] → SourceDescriptor[] + native sources ├── query-tiles.ts # dynamic query tile MapLibre helpers + request lifecycle ├── style-compose.ts # applyStyleRefs + applyTheme ├── diff.ts # MapPackageDiff primitives for updatePackage ├── popups.ts # bindPopup + default unstyled DOM renderer ├── legend.ts # buildLegend + swatch backfill └── errors.ts # HonuaMapPackageError (stages) ``` ## Public surface ```ts doc-test=skip reason="partial excerpt requires application host context" import maplibregl from "maplibre-gl"; import { HonuaClient } from "@honua/sdk-js"; import { loadMapPackage } from "@honua/sdk-js/runtime"; const map = new maplibregl.Map({ container: "map" }); const runtime = await loadMapPackage(pkg, map, { client: new HonuaClient({ baseUrl: "https://honua.example.com" }), popupFactory: () => new maplibregl.Popup(), }); runtime.setLayerVisibility("parcels-fill", false); const legend = runtime.getLegend(); await runtime.updatePackage(nextPkg); runtime.dispose(); ``` | Export | Shape | Notes | | --- | --- | --- | | `loadMapPackage(pkg, map, opts)` | `Promise<HonuaMapRuntime>` | Inline-package load entry point. Throws `HonuaMapPackageError` for binding failures under `sourceErrorPolicy: "fail-fast"`; under the default `"tolerant"` policy a single per-source binding failure does not abort the load — see *Tolerant binding* below. Query-time adapter failures surface on the per-`Source` promises from `runtime.dataset` and through the shared `HonuaClient` interceptor chain; consumers can broadcast them through `runtime.reportSourceError(sourceId, error)` to convert query-time rejections into the canonical `source-error` event. | | `fetchMapPackage(idOrLocator, opts)` | `Promise<FetchMapPackageResult>` | Fetches a hosted package through `HonuaClient.pipelineFetch`, validates it, resolves style refs when a resolver is supplied, and uses ETag / Last-Modified validators from the server when available. | | `loadMapPackageFromId(idOrLocator, map, opts)` | `Promise<LoadMapPackageFromIdResult>` | Builder-style helper: fetches a hosted package, then delegates to `loadMapPackage`. The result includes the runtime plus fetch diagnostics/cache state. | | `watchMapPackage(idOrLocator, opts)` | `MapPackageWatchHandle` | Opt-in polling watcher with `dispose()` and `refresh()`. It reuses `fetchMapPackage`, reports structural updates that require full style reload, and can apply updates to an existing `HonuaMapRuntime`. | | `validateMapPackage(pkg, opts)` | `ValidateMapPackageResult` | Returns typed diagnostics for format/version, missing sources, unsupported protocols, stale/expired packages, and style-ref target mismatches. | | `HonuaMapRuntime` | class | `map`, `honuaMap`, `dataset`, `mapPackage`, `composedStyle`, `getLegend`, `setLayerVisibility`, `bindPopup`, `setViewState`, `updatePackage`, `on`, `reportSourceError`, `dispose`. | | `runtime.hitTest(input, opts)` | `Promise<HonuaHitTestResult>` | Renderer-neutral hit-test wrapper over MapLibre `queryRenderedFeatures`. Returns normalized pointer location, source-qualified feature identities, raw rendered features, geometry/properties when supplied by the renderer, optional detail payloads, and typed degraded states for unsupported or partial renderer data. | | `runtime.onPointer(handler, opts)` | `{ remove() }` | Subscribes to `"click"`, `"dblclick"`, or `"mousemove"` and invokes `hitTest` for each pointer event. | | `HonuaMapPackage` | type | v1 package shape. `format` is gated against `HONUA_MAP_PACKAGE_FORMAT_V1` (`"honua_map_package.v1"`). | | `HONUA_MAP_PACKAGE_FORMAT_V1` | const | Canonical format tag. | | `HonuaMapPackageError` / `HonuaMapPackageErrorStage` | class / union | Stage union: `"load" \| "update" \| "style-compose" \| "source-bind" \| "view" \| "popup" \| "dispose"`. | | `HonuaRuntimeEvent` / `HonuaRuntimeEventListener` | types | See Events below. | | `HonuaRuntimeTelemetry` | type | `before` / `after` / `error` collector, matching the `HonuaRequestInterceptor` shape. | | `MaplibreMap` | interface | Duck-typed subset of `maplibre-gl.Map`; keeps the SDK bundle-neutral. | | `SetViewStateInput` | type | `{ bbox?, center?, zoom?, pitch?, bearing?, padding?, animate? }`. | | `applyStyleRefs`, `applyTheme`, `composeStyle` | functions | Pure helpers — safe to call outside a runtime for testing / SSR composition. | | `projectSourceBindings`, `toHonuaSourceSpec` | functions | Exposed for `#22` and adapter tickets that need the bridge without loading a package. | | `buildWmsRasterSourceSpec`, `buildWmtsRasterSourceSpec` | functions | Pre-bake a MapLibre `raster` source spec from a WMS / WMTS `SourceDescriptor`. Used by callers that compose a map outside `loadMapPackage`. See the source-binding projection table for the URL templates emitted on each protocol. | | `buildMapLibreQueryTileSourceSpec`, `buildQueryTileJson`, `buildQueryTileUrlTemplate`, `buildQueryTileUrl` | functions | Build TileJSON and MapLibre `vector` source specs from `QueryTileSourceDescriptor`. See [`dynamic-query-tiles.md`](./dynamic-query-tiles.md). | | `QueryTileRequestController`, `queryTilesForViewport`, `diagnoseQueryTileSourceSupport` | class / functions | Opt-in viewport tile lifecycle helper with abortable requests, bounded cache, diagnostics, and unsupported-protocol/fallback reporting. | | `hitTestMap`, `normalizePointerEvent`, `normalizeHitTestFeatures`, `createQueryTileDetailLoader` | functions | Framework-neutral interaction helpers exported from `@honua/sdk-js/interactions`; useful for apps that do not load a full `HonuaMapRuntime`. | | `diffPackages`, `MapPackageDiff` | function / type | Stable-id diff used by `updatePackage`. | | `buildLegend`, `LegendEntry` | function / type | Shared with operator components. | | `bindPopup`, `defaultPopupRenderer`, `PopupFactory`, `PopupRenderer` | function / types | The default DOM renderer is intentionally unstyled — rich popups belong in `#29`. | ## Loader options ```ts doc-test=skip reason="partial excerpt requires application host context" interface LoadMapPackageOptions { client: HonuaClient; resolveStyleRef?: (styleId: string, presetId?: string) => Promise<HonuaStyleRefBody>; resolveTheme?: (themeId: string) => Promise<HonuaMapPackageThemeSpec>; resolveSource?: SourceResolver; // forwarded to createDataset for tiles / MapLibre-native sources skipCompatibilityCheck?: boolean; // tests / conformance fixtures telemetry?: HonuaRuntimeTelemetry; popupFactory?: PopupFactory; // required only if runtime.bindPopup is called popupRenderer?: PopupRenderer; // defaults to defaultPopupRenderer applyInitialView?: boolean; // default true onEvent?: HonuaRuntimeEventListener; // subscribed BEFORE initial emissions sourceErrorPolicy?: "tolerant" | "fail-fast"; // default "tolerant" — see Tolerant binding styleSpecValidationMode?: "strict" | "warning-only" | "renderer-deferred"; // default "strict" } ``` `resolveStyleRef` and `resolveTheme` are only invoked when the package omits the inline body. Draft-1 of `honua-server#731` attaches both inline; out-of-band retrieval plugs in through these hooks without reopening the loader. `onEvent` is registered on the runtime before the first `source-ready` / `package-loaded` emissions, so callers that need to observe the initial lifecycle without racing against `loadMapPackage`'s return must use this hook instead of calling `runtime.on(...)` after `await`. Subsequent events (`package-updated`, `disposed`, ...) also flow through the same listener. Runtime source/layer mutation helpers lazily use `@maplibre/maplibre-gl-style-spec` without constructing a MapLibre map. `"strict"` reports style-spec errors as fatal `HonuaRuntimeDiagnosticError` entries before renderer mutation, `"warning-only"` preserves the diagnostics as warnings, and `"renderer-deferred"` skips SDK style-spec checks so the host renderer reports invalid values. ## Source and Layer Mutation `HonuaMapRuntime` owns the renderer-specific implementation used by `HonuaController`'s app-level CRUD surface. The API follows Mapbox GL source and layer concepts: ```ts doc-test=skip reason="partial excerpt requires application host context" runtime.addSource("dispatch", { type: "geojson", data: { type: "FeatureCollection", features: [] }, }); runtime.addLayer( { id: "dispatch-points", type: "circle", source: "dispatch", paint: { "circle-color": "#00a884", "circle-radius": 6 }, }, { beforeId: "parcels-fill" }, ); runtime.updateLayer("dispatch-points", { paint: { "circle-color": "#006f5f" }, metadata: { editedBy: "operator" }, }); runtime.moveLayer("dispatch-points", { position: "top" }); runtime.refreshSource("dispatch"); runtime.removeLayer("dispatch-points"); runtime.removeSource("dispatch"); ``` Supported source inputs include common native MapLibre source entries and Honua custom source entries for GeoServices, OGC Features, WMS, and WMTS. Query-tile sources can be created with `buildMapLibreQueryTileSourceSpec()`, while hosted MapPackage source bindings should continue to flow through `loadMapPackage()` or `updatePackage()` when server state is the source of truth. The mutation helpers validate source specs, layer specs, style expressions, filters, layer ordering, and duplicate/missing IDs before mutating the map. Renderer failures are wrapped as `HonuaRuntimeDiagnosticError` diagnostics. For incremental paint/layout/filter patches the runtime uses MapLibre's property setters when available; structural updates fall back to `setStyle(..., { diff: true })`. ## Hosted MapPackage Fetch Builder-style usage starts from a package id instead of an inline `MapPackage`: ```ts doc-test=skip reason="partial excerpt requires application host context" import { HonuaClient } from "@honua/sdk-js"; import { loadMapPackageFromId, watchMapPackage } from "@honua/sdk-js/runtime"; const client = new HonuaClient({ baseUrl: "https://honua.example.com" }); const { runtime, diagnostics } = await loadMapPackageFromId("map_123", map, { client, popupFactory: () => new maplibregl.Popup(), resolveStyleRef: (styleId, presetId) => fetchStyleBody(styleId, presetId), }); for (const diagnostic of diagnostics) { console.warn(diagnostic.code, diagnostic.message); } const watcher = watchMapPackage("map_123", { client, runtime, intervalMs: 30_000, onEvent: (event) => { if (event.type === "reload-required") { console.info(event.reason); } }, }); // Later, when the host tears down the map: watcher.dispose(); ``` `fetchMapPackage` accepts either an id (`"map_123"`), a direct path or same-origin URL, a `honua://map-packages/{id}` locator, or an object with `id`, `packageId`, `path`, `url`, or `href`. Id locators default to `/api/v1/map-packages/{id}`; pass `resolvePath` when a deployment uses a different hosted package route. For low-latency hosted package updates, `watchMapPackage` can use a configured or package-advertised realtime channel and fall back to polling when the channel is unavailable. See [`map-package-realtime-watch.md`](./map-package-realtime-watch.md) for the message schema, lifecycle events, reconnect behavior, auth notes, and fallback contract. Fetch results include: - `mapPackage`: the validated package, with missing style-ref bodies inlined when `resolveStyleRef` succeeds. - `diagnostics`: typed warnings/errors for unsupported formats, missing source bindings, unsupported protocols, stale packages (`maxAgeMs`), expired packages (`status: "Expired"` or `expiresAt`), missing layer sources, and style-ref resolution failures. - `cache`: SDK fetch cache state. The helper stores validators in a per-client in-memory cache by default. On later fetches it sends `If-None-Match` / `If-Modified-Since` when the server supplied ETag or Last-Modified. Pass `cache: false` to bypass the SDK cache, or pass a `MapPackageFetchCache` to share cache state across clients/tests. Validation errors throw `HonuaMapPackageError { stage: "validate" }` with `{ diagnostics }` in `detail`. Pass `allowInvalid: true` when a host wants to display diagnostics without rejecting the fetch. ### Tolerant binding (`sourceErrorPolicy`) `loadMapPackage` defaults to `sourceErrorPolicy: "tolerant"`: a single per-source binding failure (resolver throws, `toHonuaSourceSpec` throws, eager `dataset.source(id)` materialization throws) does not abort the load. The failed source is dropped from the composed style along with any layer whose `source` references it; the runtime emits one `source-error` event per failed source after the `source-ready` events and before `package-loaded`; `HonuaRuntimeTelemetry.error` receives a `source-bind` span. Remaining sources continue to render. Pass `sourceErrorPolicy: "fail-fast"` to restore the historical single-source behaviour, where any binding failure rejects the load with `HonuaMapPackageError({ stage: "source-bind" })`. Configuration-level binding errors (unknown protocol, missing `locator.url`, duplicate `sourceId`, deferred `workspace_artifact`) raised by `projectSourceBindings` always fail-fast under either policy — those are operator errors that must be fixed. Mixed-source consumers that fan a query out across the dataset can broadcast a query-time rejection back through the runtime by calling `runtime.reportSourceError(sourceId, error)` — the helper emits the same `source-error` event and pipes the failure through the `source-bind` telemetry span, so observers see one consistent per-source error channel for both bind-time and query-time failures. The full guide lives in [`composition.md`](./composition.md). ## Hit Testing and Pointer Events `hitTest` accepts a MapLibre-style event, `{ point, lngLat }`, or a raw DOM pointer event. It returns screen point, optional longitude/latitude, and a feature stack normalized to Honua source identity: ```ts doc-test=skip reason="partial excerpt requires application host context" const hit = await runtime.hitTest(event, { layers: ["incidents-symbol", "incidents-fill"], sourceIds: ["incidents"], featureIdProperty: "incident_id", tolerance: 4, maxResults: 5, }); const first = hit.features[0]; if (first?.selectionTarget) { view.select([first.selectionTarget], { replace: true }); } ``` Every `HonuaHitFeature` includes `layerId`, `sourceId`, `sourceLayer`, `featureId`, `selectionTarget`, `properties`, `geometry`, `rawFeature`, and `degraded[]` when the renderer omits a field. The top-level result also has `degraded[]`; for example, non-MapLibre renderers without `queryRenderedFeatures` return an empty feature list plus `{ reason: "renderer-unsupported" }`. Optional detail loading is bounded by the caller's `AbortSignal`: ```ts doc-test=skip reason="partial excerpt requires application host context" const controller = new AbortController(); const hit = await runtime.hitTest(event, { layers: ["incidents-symbol"], queryTileSources: { incidents: incidentsQueryTileDescriptor }, loadDetails: true, signal: controller.signal, }); ``` When `queryTileSources` includes a descriptor for the hit source, the runtime composes with the existing query-tile detail path and `runtime.dataset.source(sourceId)`. Without a descriptor it falls back to a single-feature source query when an id field is available. Failed, aborted, or unavailable detail reads are reported in `feature.degraded` instead of rejecting the whole hit-test result. Apps that do not use `HonuaMapRuntime` can use the neutral helpers directly: ```ts doc-test=skip reason="partial excerpt requires application host context" import { hitTestMap } from "@honua/sdk-js/interactions"; map.on("click", async (event) => { const hit = await hitTestMap(map, event, { layers: interactiveLayerIds, sourceIds: ["incidents"], featureIdProperty: "incident_id", maxResults: 1, }); openInspector(hit.features[0]); }); ``` ## `MapPackage` version gate - `pkg.format` must equal `HONUA_MAP_PACKAGE_FORMAT_V1` (`"honua_map_package.v1"`). The loader throws `HonuaMapPackageError { stage: "load" }` for any other value. - `updatePackage(next)` throws `HonuaMapPackageError { stage: "update" }` when `next.format` does not match the already-loaded format so version mismatches surface at the call site rather than silently corrupting the map. - Unknown fields on `HonuaMapPackage` (and on each `SourceBinding` locator) are preserved on round-trip through `updatePackage`, so minor additive changes on the server do not force a runtime bump. ## Source binding projection `projectSourceBindings` routes each `SourceBinding` to one of three destinations, using the alignment table in [`source-binding-alignment.md`](./source-binding-alignment.md): | Server wire protocol (snake_case) | Route | SDK protocol / source type | | --- | --- | --- | | `geoservices_feature_service` | contract adapter | `geoservices-feature-service`, custom source type `honua-feature-service`. | | `geoservices_map_service` | contract adapter | `geoservices-map-service`, custom source type `honua-map-service`. | | `ogc_features` | contract adapter | `ogc-features`, custom source type `honua-ogc-features`. Collection id is copied from `locator.collectionId`. | | `wfs` | contract adapter | `wfs` (built-in), custom source type `honua-wfs`. `locator.typeName` (and optional `locator.featureNamespace`) are forwarded; first-party WFS 2.0 adapter ships in `@honua/sdk-js/contract`. | | `wms` | contract adapter | `wms`, custom source type `honua-wms`. `locator.typeName` projects as `layers`, `locator.styleId` as `styles`. Use `buildWmsRasterSourceSpec(descriptor)` to produce a MapLibre-ready `{ type: "raster", tiles, tileSize }` spec with a pre-baked KVP `GetMap` template that uses MapLibre's `{bbox-epsg3857}` / `{width}` / `{height}` placeholders. | | `wmts` | contract adapter | `wmts`, custom source type `honua-wmts`. `locator.typeName` / `locator.styleId` / `locator.tileMatrixSetId` project as `layer` / `style` / `tileMatrixSet`. Use `buildWmtsRasterSourceSpec(descriptor)` to produce a MapLibre-ready `{ type: "raster", tiles, tileSize, scheme: "xyz" }` spec using the RESTful `{layer}/{style}/{tms}/{z}/{y}/{x}.{ext}` route. | | `odata` | contract adapter | `odata`. The entity-set request path comes from `locator.entitySet` when set; otherwise it is derived from `locator.layerId` as `Layers(<layerId>)/Features` to match Honua Server's layer-scoped OData routes. Optional path prefix from `locator.url`. | | `vector_tile` / `ogc_tiles` | MapLibre-native | Projected to a `{ type: "vector", tiles: [url], attribution? }` source entry — no contract adapter. | | `raster_tile` / `ogc_maps` | MapLibre-native | Projected to a `{ type: "raster", tiles: [url], attribution? }` source entry. | | `workspace_artifact` | deferred | Throws `HonuaMapPackageError { stage: "source-bind" }` — no artifact resolver is wired yet. | `geoservices-image-service`, `geoservices-geometry-service`, and `geoservices-gp-service` are contract-layer adapters (constructed directly via `geoServicesImageSource`, `geoServicesGeometryServiceSource`, `geoServicesGPServiceSource`) and are not currently translated by `source-bridge.ts`. ImageServer / Geometry / GP bindings on a `MapPackage` are rejected at `stage: "source-bind"` because those services are typically utility / non-map surfaces; routing them through the runtime is a follow-on for downstream tickets that need ImageServer rasters or geoprocessing tasks composed alongside a map. Additional contract: - Duplicate `sourceId` across bindings is rejected at `stage: "source-bind"`. - A protocol-backed binding without a `locator.url` is rejected at `stage: "source-bind"`. - `binding.filter` is captured per source id and passed through to the Honua custom source spec (`definitionExpression` on `honua-feature-service`, `filter` on `honua-ogc-features` and the generic fallback). Edits never flow from the runtime. - Locator fields are normalized during projection so the shared contract adapters can bind even when the server ships a URL-only `SourceBinding.locator`: - **GeoServices Feature / Map Service**: `serviceId` and numeric `layerId` are parsed from the canonical `/rest/services/<name>/FeatureServer/<id>` or `/rest/services/<name>/MapServer/<id>` URL shape when the binding omits them. Explicit locator fields always win over parsed ones. - **OGC API Features**: `collectionId` is parsed from the `/collections/<id>` URL segment when omitted. - **WMS / WMTS**: `serviceId` is parsed from `/rest/services/<name>/MapServer/WMS` (and `/WMTS`) and from `/ogc/services/<name>/wms` (and `/wmts`) when the binding omits it. `locator.typeName` and `locator.styleId` are not URL-derived today; the server ships them when a binding pins a specific layer or style. - **OData**: the server `SourceLocator` carries only `url`, `serviceId`, and `layerId`, so `entitySet` is typically absent on bindings produced by the server. The `odataSource` adapter derives the canonical layer-scoped request path `Layers(<layerId>)/Features` from `locator.layerId` when `locator.entitySet` is absent (see the *OData* notes in [`protocol-capability-matrix.md`](./protocol-capability-matrix.md) for the exact resolution rule). - **`locator.layerId`**: the server's canonical `SourceLocator.LayerId` is `string?`, so `HonuaMapPackageLocator.layerId` is typed `number | string`. Numeric strings (e.g. `"0"`) are coerced to numbers during projection; non-numeric strings are left unset so the adapter surfaces the typed validation error. - Capabilities for protocol-backed descriptors are always sourced from `PROTOCOL_DEFAULT_CAPABILITIES[protocol]` (see [`protocol-capability-matrix.md`](./protocol-capability-matrix.md)). The server `SourceBinding` shape does not carry a `capabilities` field today, and the runtime does not expose a hook to downgrade per-source capabilities — callers that need a narrower set must call `createDataset` directly and pass explicit `SourceDescriptor` entries, then bind the map through the lower-level SDK primitives. ## Style composition `composeStyle` runs two passes over `pkg.mapSpec`: 1. **`applyStyleRefs`** merges each `styleRefs[*].body` onto its corresponding layer by id. The body is a `Record<string, HonuaStyleRefLayerOverride>` where keys are `mapSpec.layers[].id` and values carry any of `paint`, `layout`, `minzoom`, `maxzoom`, `filter`, `metadata`. Unknown layer ids are silently skipped (they may belong to a downstream adapter plugin). If `ref.body` is absent and no `resolveStyleRef` was supplied, the loader throws `HonuaMapPackageError { stage: "style-compose" }`. 2. **`applyTheme`** substitutes `{theme:key}` placeholders in string `paint` / `layout` values against `pkg.theme.tokens` (or the resolved `pkg.themeId` body). Only a full-string match on `/^\{theme:([^}]+)\}$/` is replaced — substring interpolation is not supported in v1. Unresolved tokens are left untouched so authors can flag missing tokens at review time. Theme application recurses into nested arrays / objects inside `paint` / `layout` so expression literals can reference theme tokens. ## Operational API | Method | Behavior | | --- | --- | | `addSource(sourceId, source)` | Adds a Honua custom source or MapLibre-native source to `runtime.honuaMap`, `runtime.composedStyle`, and the host map via `map.addSource()` when available. Falls back to `setStyle(..., { diff: true })` for renderer adapters that only expose full style application. | | `updateSource(sourceId, source)` | Replaces a source spec and reapplies the composed style while preserving layer ids. Use for locator/data/source-option changes; paint/layout/filter-only updates should use layer helpers to avoid source reloads. | | `removeSource(sourceId)` | Removes the source and any dependent layers from the runtime style, `HonuaMap`, and host map. Returns the removed layer ids. | | `addLayer(layer, order?)` | Adds a typed layer spec, validates paint/layout/filter expressions first, and supports MapLibre `beforeId` as a string or `{ beforeId }`, `{ afterId }`, `{ position: "top" | "bottom" }`. | | `updateLayer(layerId, patch)` | Patches layer paint/layout/filter in place with `setPaintProperty` / `setLayoutProperty` / `setFilter` when the layer shape is stable. Structural layer changes or explicit reordering use a diffed `setStyle` path. | | `removeLayer(layerId)` / `moveLayer(layerId, order?)` | Removes or reorders runtime-owned layers while keeping `runtime.composedStyle` and `runtime.honuaMap` aligned with the renderer. | | `setLayerPaint(layerId, paint)` / `setLayerLayout(layerId, layout)` / `setLayerFilter(layerId, filter)` | Convenience wrappers around `updateLayer` for common Mapbox-style style changes. | | `setLayerVisibility(layerId, visible)` | `map.setLayoutProperty(layerId, "visibility", …)`. | | `validateStyleExpression(value)` / `validateFilterExpression(filter, layerId?)` | Runs Honua expression checks plus MapLibre style-spec validation when enabled, returning typed diagnostics with path, layer id, source id, protocol, severity, and MapLibre validation context. Mutating helpers throw `HonuaRuntimeDiagnosticError` before touching the renderer when diagnostics contain errors. | | `getLegend()` | Runs `buildLegend` against the current package and composed style; backfills missing swatches from the first `fill-color` / `circle-color` / `line-color` paint property when it is a string literal. | | `bindPopup(layerId, binding?)` | Requires `opts.popupFactory`. When `binding` is omitted the runtime looks up `pkg.popupBindings[]` by the layer's source id. The default renderer emits an unstyled `<dl>` of the first feature's properties (or a `{field}` template when `binding.template` is set). Returns a `{ remove() }` handle; re-binding on the same layer tears down the prior handle. | | `bindHover(layerId, options?)` / `bindClick(layerId, handler, options?)` / `bindSelect(layerId, options?)` | Infers source and source-layer from the runtime layer, binds MapLibre layer events, and manages hover/click/select feature-state without requiring app code to pass MapLibre types. `bindClick` emits a source-qualified selection target when a feature id is available. | | `bindSelectionToExploration(layerId, view, options?)` / `syncSelectionFromExploration(layerId, view, options?)` | Bridges runtime layer interactions to `ExplorationContext` selection and reflects source-qualified shared selection back into MapLibre feature-state. | | `layerSelectionTarget(layerId, id)` / `setFeatureStateForTarget(target, state)` / `getFeatureStateForTarget(target)` / `removeFeatureStateForTarget(target, key?)` | Converts layer + feature id pairs into source-qualified targets and applies feature-state using either runtime targets or `ExplorationContext` source-qualified targets. | | `setViewState(view)` | If `view.bbox` is supplied and `map.fitBounds` exists, fits the bounds (`animate: false` by default). Otherwise falls back to `map.jumpTo` for `center` / `zoom` / `pitch` / `bearing`. A final fallback applies `pkg.initialView.bbox` when no input is given. | | `updatePackage(next)` | See the Update lifecycle section. | | `on(listener)` | Subscribes to `HonuaRuntimeEvent`. Returns a `{ remove() }` handle. | | `dispose()` | Clears popup bindings, removes every layer and source owned by the composed style via `honuaMap.clear()` + `map.removeLayer` / `map.removeSource`, emits `disposed`, and rejects subsequent mutating calls. Idempotent. | `runtime.map`, `runtime.honuaMap`, `runtime.dataset`, `runtime.mapPackage`, and `runtime.composedStyle` are readable at any time. The runtime helpers delegate to the lower-level `src/interactions/feature-state` and `src/interactions/exploration-bindings` modules, so apps can still use those lower-level primitives directly when they need custom behavior. ## Update lifecycle `updatePackage(next)` does three things in order: 1. **Format gate.** `next.format` must match the currently loaded format, or the call throws `HonuaMapPackageError { stage: "update" }` before any map mutation. 2. **Diff.** `diffPackages(previous, next)` produces a `MapPackageDiff` keyed by stable ids: - Added / removed / changed source bindings (locator, filter, attribution, or protocol differences). - Added / removed / changed layer ids (paint, layout, filter, source, source-layer, min/max zoom, metadata). - A `structuralReason` string and `incremental: false` when any of the following hold: `mapSpec.version` changed, the layer set changed, the layer order changed, any source binding was added / removed / changed, OR the composed layer changed outside the runtime's paint / layout / filter patch surface. Source-binding changes force the structural path because the runtime must rebuild the underlying `Dataset` and `HonuaMap` so `runtime.dataset.source(id)` observes the new locator / filter. 3. **Apply.** If `diff.incremental` is false, the runtime rebuilds the composed style and calls `map.setStyle(composed)` first; only once that returns does it clear the old `HonuaMap` and swap in the freshly projected `dataset` / `honuaMap` references. This ordering guarantees that if the host map's `setStyle` throws, the runtime's previous state — `dataset`, `honuaMap`, `mapPackage`, `composedStyle`, and all popup bindings — is left intact so the caller can retry without a half-applied update. After a successful swap, any popup binding whose layer id is no longer present, whose layer source changed, or whose package-resolved binding changed is torn down so stale click listeners do not linger. Otherwise it removes dropped layers, patches changed layers in place via `setPaintProperty` / `setLayoutProperty` / `setFilter`, and emits a single `package-updated` event with the diff attached. Theme-only tweaks and single-layer paint/filter edits never trigger a full `setStyle`; root layer changes such as `minzoom`, `maxzoom`, `metadata`, `source`, `source-layer`, or `type` do. Incremental layer patching iterates the **union** of previous and next paint / layout keys. Keys present in the previous layer but dropped in the next are cleared by calling `setPaintProperty(layerId, key, undefined)` / `setLayoutProperty(layerId, key, undefined)` so MapLibre resets them to the property default rather than retaining the stale value. Identical values are short-circuited with a strict-equality check so unchanged properties do not trip a MapLibre setter call. After any structural reload, `runtime.dataset` and `runtime.honuaMap` return the new references (both are exposed through getters, not fixed `readonly` fields, so live callers see the refreshed state immediately). ## Events Subscribe through `LoadMapPackageOptions.onEvent` to observe the initial emissions, or `runtime.on(listener)` for subsequent events. `onEvent` is the only way to capture `source-ready` / `package-loaded` on a successful load because those events are dispatched synchronously before `loadMapPackage` returns the runtime handle. `runtime.on(...)` handles every subsequent event. | Event | Emitted when | | --- | --- | | `{ type: "package-loaded", packageId }` | After the first `setStyle` + initial view apply succeed. Fired last, once per successful load. | | `{ type: "source-ready", sourceId }` | Once per source id produced by the contract `Dataset`, fired synchronously just before `package-loaded`. | | `{ type: "source-error", sourceId, error }` | Per-source binding or query-time failure. Under `sourceErrorPolicy: "tolerant"` the loader emits one `source-error` per source whose bind-time projection / spec-build / eager materialization threw; the runtime fans this through the listener chain right after `source-ready` and before `package-loaded`, with telemetry observing a `source-bind` error span. Consumers that fan a query out across the dataset broadcast query-time rejections through `runtime.reportSourceError(sourceId, error)`, which emits the same event so listeners do not have to subscribe to a parallel error channel. | | `{ type: "package-updated", packageId, diff }` | After `updatePackage` completes (both incremental and full-`setStyle` paths). | | `{ type: "layer-rendered", layerId }` | Declared for MapLibre render callbacks bridged by the host. The runtime itself does not fire it today. | | `{ type: "disposed", packageId }` | Once inside `dispose()`, just before listeners are cleared. | Listeners fire synchronously in subscription order. Adapter-level request errors continue to flow through the `HonuaClient` interceptor chain for trace correlation — the runtime does not add a parallel pipeline. ## Errors `HonuaMapPackageError` wraps every runtime-binding failure and carries `{ packageId, stage, detail, cause }`. Stages: - `load` — format validation, `mapSpec` missing, unknown loader error. - `update` — `updatePackage` format mismatch or unhandled error. - `style-compose` — missing style-ref body with no resolver, theme resolver threw, general composition failure. - `source-bind` — unknown protocol, missing locator, duplicate source id, deferred `workspace_artifact`. - `view` — `initialView` application failed. - `popup` — `bindPopup` called without a `popupFactory`, or no binding found for the layer. - `dispose` — mutating call after `dispose()`. Per-source protocol failures keep their existing classes (`HonuaCapabilityNotSupportedError`, `HonuaHttpError`, adapter-specific errors) and are not wrapped by the runtime. They surface on the per-`Source` promises exposed by `runtime.dataset` and through the shared `HonuaClient` interceptor chain. The runtime additionally emits `source-error` events for per-source binding failures absorbed under the tolerant policy and for any query-time rejection a consumer broadcasts through `runtime.reportSourceError(sourceId, error)`. See *Tolerant binding* above and [`composition.md`](./composition.md) for the full mixed-source contract. ## Telemetry `HonuaRuntimeTelemetry` is a `{ before?, after?, error? }` collector matching the `HonuaRequestInterceptor` contract. The runtime emits spans for `kind: "load" | "update" | "dispose" | "source-bind" | "popup"` with `startedAt` / `finishedAt` / `durationMs`. The `source-bind` error span fires once per source whose binding fails under the tolerant `sourceErrorPolicy`, and once per call to `runtime.reportSourceError(sourceId, error)`; its `detail` carries `{ sourceId }` so observability stacks see the failure alongside other runtime spans. Adapter traffic is still instrumented through the shared `HonuaClient` interceptor chain, so distributed-trace correlation is preserved end-to-end. ## Peer dependency posture - `maplibre-gl` is a peer/dev dependency — the runtime never imports it at the type or value level. `MaplibreMap` and `PopupHandle` are duck-typed, matching the pattern used by `src/interactions/feature-state`. - Hosts pass their own `maplibre-gl.Map` instance. Custom subclasses and third-party wrappers (deck.gl overlay, OpenLayers bridge) are supported as long as they satisfy the `MaplibreMap` method shape. - `popupFactory` keeps the popup dependency on the host side; omit it when the app does not call `runtime.bindPopup`. ## Test coverage `test/runtime/runtime.test.ts` exercises the full `load → updatePackage → dispose` lifecycle against a recording mock map (31 tests). Behavior covered includes: - Format gate rejects non-v1 packages and `workspace_artifact` bindings surface `HonuaMapPackageError { stage: "source-bind" }`. - Source projection routes each protocol to the correct destination, captures filters, translates `snake_case` server protocol names to `kebab-case` SDK protocols, and rejects duplicate source ids. - `composeStyle` applies `StyleRef` overrides; `applyTheme` substitutes `{theme:key}` placeholders and leaves unknown tokens in place. - `diffPackages` flags structural changes (layer reorder, mapSpec version bump, source bindings added / removed / changed), and the runtime promotes composed root-layer changes to the same path; incremental patches update paint / layout / filter without re-running `setStyle`. - Event stream emits `package-loaded`, `source-ready`, `package-updated`, `disposed` in the documented order. - `dispose` removes layers and sources in reverse and ignores subsequent calls; further mutating calls throw `stage: "dispose"`. Regression coverage added alongside this release (+11 tests) locks in the fix-pass behaviors: - **Source-binding structural fallback.** A locator change or a new binding forces a full `setStyle` *and* swaps `runtime.dataset` / `runtime.honuaMap` to fresh references so `runtime.dataset.source(id)` observes the new locator / filter. - **Paint / layout key removal.** Removing a paint or layout key (e.g. dropping `fill-opacity` or `fill-sort-key` from the next layer) calls `setPaintProperty` / `setLayoutProperty` with `undefined` so MapLibre resets the property instead of retaining the stale value. - **URL-only locator backfill.** `projectSourceBindings` parses `serviceId` / numeric `layerId` from a canonical `/rest/services/<name>/FeatureServer/<id>` (and `MapServer` variant) URL when the binding omits them, parses `collectionId` from `/collections/<id>` for OGC API Features bindings, and coerces numeric-string `layerId` values to numbers so the C# server mirror (which serialises `LayerId` as a string) still binds through the built-in adapters. An end-to-end test loads a URL-only GeoServices binding and verifies `runtime.dataset.source(id).adapter(...)` is reachable. - **`onEvent` captures initial lifecycle.** `LoadMapPackageOptions.onEvent` receives `source-ready` and `package-loaded` without racing against `loadMapPackage`'s `await`-return. - **Structural-update error containment.** When `map.setStyle` throws during a structural `updatePackage`, the previous `honuaMap` / `dataset` / `mapPackage` references are preserved so the runtime is not left half-applied. - **Popup reap on layer removal.** A structural update that drops a previously bound layer tears down the layer's popup click listener before emitting `package-updated`. - **Non-patchable composed layer changes.** A package update that changes a root layer field such as `minzoom` routes through `setStyle` rather than claiming an incremental paint/layout/filter patch applied it. - **Popup reap on binding changes.** Updating `popupBindings[]` for an active package-resolved popup tears down the existing click listener so the closed-over binding cannot go stale. Conformance-style assertions rely only on the duck-typed `MaplibreMap` interface so no `maplibre-gl` dependency creeps into the SDK's runtime bundle. ## Deferred follow-ups - `workspace_artifact` resolver wiring — blocked on server surface. - Partial-load recovery (skip unresolved sources, continue) — the loader is strict in v1; an `opts.allowPartial` escape hatch is tracked alongside `#22` mixed-source composition. - Refinement / preview components — opaque pass-through today; `#29` operator components are the documented home. - Finer-grained diff primitives (layer reorder without teardown) — extension point already in `diff.ts`; no consumer yet. --- # File: docs/pmtiles.md # PMTiles [PMTiles](https://github.com/protomaps/PMTiles) is a single-file archive format for an entire pyramid of map tiles (raster or vector). One `.pmtiles` file on static hosting — S3, R2, GCS, or any HTTP server that honours `Range` requests — is a complete basemap or overlay backend. No tile server, no Honua server. MapLibre GL JS renders PMTiles through a registered protocol handler (`maplibregl.addProtocol("pmtiles", …)`). The Honua SDK wires that up for you: the runtime auto-registers the `pmtiles://` protocol on map attach, and the `pmtiles` package is an **optional peer dependency** imported lazily, so a build that never touches PMTiles pays no bundle cost. - `pmtiles` is an optional `peerDependency` (see `package.json`). Install it alongside `maplibre-gl` when you use PMTiles: `npm i pmtiles`. - Reference an archive as a MapLibre source `url` with the `pmtiles://` scheme: `pmtiles://https://example.com/basemap.pmtiles`. ## Rendering a PMTiles source (runtime) `loadMapPackage` auto-registers the `pmtiles://` protocol before it applies the composed style, so a `pmtiles` source binding renders with no manual `addProtocol` call. The registration is lazy (the `pmtiles` and `maplibre-gl` packages are imported only when a PMTiles-backed map loads) and idempotent across every map you attach. ```ts doc-test=compile import { HonuaClient } from "@honua/sdk-js/honua"; import { HONUA_MAP_PACKAGE_FORMAT_V1, loadMapPackage } from "@honua/sdk-js/runtime"; import maplibregl from "maplibre-gl"; const map = new maplibregl.Map({ container: "map", style: { version: 8, sources: {}, layers: [] } }); await new Promise((resolve) => map.on("load", resolve)); await loadMapPackage( { format: HONUA_MAP_PACKAGE_FORMAT_V1, mapPackageId: "pmtiles-basemap", sourceBindings: [ { sourceId: "basemap", protocol: "pmtiles", // `sourceType` picks the MapLibre source kind: "vector" (default) or "raster". locator: { url: "pmtiles://https://example.com/basemap.pmtiles", sourceType: "vector" }, }, ], mapSpec: { version: 8, sources: {}, layers: [ { id: "water", type: "fill", source: "basemap", "source-layer": "water", paint: { "fill-color": "#9cf" } }, ], }, }, map, // No protocol-backed sources → no Honua server needed. `HonuaClient` is // required by the contract but never called here. { client: new HonuaClient({ baseUrl: location.origin }), skipCompatibilityCheck: true }, ); ``` A `pmtiles` source binding projects onto a MapLibre-native source (`{ type: "vector" | "raster", url: "pmtiles://…" }`) via `projectSourceBindings`. The `locator.sourceType` hint selects the MapLibre source kind and defaults to `vector` (the common PMTiles basemap case). ### Registering the protocol yourself If you add a PMTiles source to a live map imperatively (`runtime.addSource`, which is synchronous) or drive MapLibre directly, register the protocol first: ```ts doc-test=skip reason="partial excerpt requires application host context" import { ensurePmtilesProtocol } from "@honua/sdk-js/runtime"; await ensurePmtilesProtocol(); // lazy, idempotent — safe to call repeatedly map.addSource("basemap", { type: "vector", url: "pmtiles://https://example.com/basemap.pmtiles" }); ``` ## Inspecting archive metadata (contract) PMTiles participates in the protocol-neutral `Dataset` / `Source` model as a **tiles-only** protocol. Its default capability set is `{ tiles }`, so the canonical query family throws `HonuaCapabilityNotSupportedError` — an archive has no feature-query surface. Archive metadata is inspected through the typed escape hatch or the standalone helper: ```ts doc-test=skip reason="partial excerpt requires application host context" import { createDataset, describePmtilesArchive } from "@honua/sdk-js/contract"; // Standalone: inspect any archive URL. const info = await describePmtilesArchive("https://example.com/basemap.pmtiles"); console.log(info.tileKind); // "mvt" | "png" | "jpeg" | "webp" | "avif" | "unknown" console.log(info.bounds); // [west, south, east, north] in degrees console.log(info.minZoom, info.maxZoom); console.log(info.vectorLayers.map((layer) => layer.id)); // source-layer names // Through a Dataset: `describe()` on the typed adapter handle. const dataset = createDataset({ id: "basemaps", client, skipCompatibilityCheck: true, sources: [ { id: "basemap", protocol: "pmtiles", locator: { url: "pmtiles://https://example.com/basemap.pmtiles" }, capabilities: new Set(["tiles"]), }, ], }); const archive = dataset.source("basemap")!.protocol("pmtiles"); const meta = await archive!.describe(); ``` `PmtilesArchiveDescription` carries `url`, `tileKind`, `bounds`, `minZoom`, `maxZoom`, `center`, `vectorLayers`, an optional `attribution`, and the raw `metadata` JSON. ## Build-less CDN recipe PMTiles works from a plain HTML page with no bundler — load `maplibre-gl` and `pmtiles` from a CDN and register the protocol: ```html <script src="https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.js"></script> <script src="https://unpkg.com/pmtiles@4/dist/pmtiles.js"></script> <link href="https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.css" rel="stylesheet" /> <div id="map" style="position:absolute;inset:0"></div> <script> const protocol = new pmtiles.Protocol(); maplibregl.addProtocol("pmtiles", protocol.tile); new maplibregl.Map({ container: "map", style: { version: 8, sources: { basemap: { type: "raster", url: "pmtiles://https://example.com/basemap.pmtiles" } }, layers: [{ id: "basemap", type: "raster", source: "basemap" }], }, }); </script> ``` ## Notes - **Immutable + HTTP caching.** PMTiles archives are immutable; the reader fetches byte ranges and relies on HTTP caching. There is no realtime path. - **Range requests required.** Static hosts must honour the `Range` header (S3 / R2 / GCS do). The `examples/pmtiles-static` mock server does too. - **Runnable example.** See [`examples/pmtiles-static`](../examples/pmtiles-static) and `npm run demo:pmtiles-static`. --- # File: docs/browser-bundle.md # Prebuilt browser bundle (CDN / build-less) `@honua/sdk-js` ships ESM + TypeScript types as its canonical output, intended for consumption through a bundler (Vite, webpack, esbuild, Rollup) or Node. For static sites, quick prototypes, and CSP-strict pages that cannot run their own bundler, the package also publishes a **prebuilt, self-contained browser bundle** under `dist/browser/`. This is an *additive* artifact — it does not change the ESM/`exports`/types surface. Production apps should keep importing from `@honua/sdk-js` (and its subpaths) through their bundler; the browser bundle exists only for build-less consumers. ## Artifacts | File | Format | Use | | --- | --- | --- | | `dist/browser/honua-sdk.min.js` | minified IIFE (`globalName: HonuaSDK`) | `<script>` tags, unpkg, jsDelivr | | `dist/browser/honua-sdk.esm.js` | minified ESM | `<script type="module">`, esm.sh, native CDN imports | Both ship with `.map` sourcemaps. The bundle targets `es2020`. The `package.json` `browser`, `unpkg`, and `jsdelivr` fields point at the IIFE build, and the `./browser` subpath export points at the ESM build, so ESM CDNs that resolve subpaths (e.g. esm.sh) can serve it directly. ## Usage — global `<script>` (IIFE) ```html <script src="https://cdn.jsdelivr.net/npm/@honua/sdk-js/dist/browser/honua-sdk.min.js"></script> <script> // The whole public API of `@honua/sdk-js` is on window.HonuaSDK. const client = new HonuaSDK.HonuaClient({ baseUrl: "https://your-honua-server.example", }); const dataset = HonuaSDK.createDataset({ id: "parcels", client, sources: [{ id: "parcels-fs", protocol: "geoservices", url: "https://.../FeatureServer/0" }], }); </script> ``` unpkg works the same way: ```html <script src="https://unpkg.com/@honua/sdk-js/dist/browser/honua-sdk.min.js"></script> ``` ## Usage — native ES module (ESM) ```html <script type="module"> import { HonuaClient, createDataset } from "https://esm.sh/@honua/sdk-js/browser"; const client = new HonuaClient({ baseUrl: "https://your-honua-server.example" }); </script> ``` ## What's bundled vs. external The bundle is focused on the **core SDK public API** (everything re-exported from `src/index.ts`). The sole real runtime dependency, `@maplibre/maplibre-gl-style-spec`, is bundled in. The heavy runtime *peers* are kept **external** so the artifact stays small and does not inline multi-megabyte map/protobuf runtimes: - `maplibre-gl` - `cesium` - `@bufbuild/protobuf` - `@connectrpc/connect` - `@connectrpc/connect-web` If you use a part of the SDK that needs one of these (e.g. the MapLibre runtime or gRPC transport), load that peer yourself — for the IIFE build, ensure it is available on the page before the code path that needs it runs. ## Building the bundle ```bash npm run build # canonical tsc ESM + types output (dist/src) npm run build:browser # esbuild → dist/browser/*.js (+ .map) ``` `scripts/build-browser-bundle.mjs` bundles straight from `src/index.ts` using the locally installed `esbuild`, so it does not strictly require `npm run build` to have run first — but `npm run build:browser` is the documented entry point. It is intentionally **not** wired into the CI `build` step so an esbuild option mismatch can never break the main release build; run it explicitly (or at publish time) to produce the artifact. The script smoke-checks that the IIFE build defines the `HonuaSDK` global and prints the output sizes. > Note: `dist/` is gitignored. The browser bundle is produced at build/publish > time, not committed to source control. The `files` array in `package.json` > includes `dist/browser` so the artifact is included in the published npm > tarball. --- # File: docs/studio-package-contracts.md # Studio Package Contracts (`@honua/sdk-js/studio`) Status: experimental, implemented for ticket `honua-sdk-js#230`. `@honua/sdk-js/studio` is the single, browser-safe import path for the Studio package families, their unified validation/preview envelopes, the capability manifest, and the publish/share/embed contracts. It exists so Console browser interop, MCP, QGIS, generated apps, and embeds **consume one set of server-owned contract projections instead of forking their own package schemas.** ```ts doc-test=compile import { HONUA_QUERY_PACKAGE_FORMAT_V1, type HonuaStudioPackage, type StudioPackageValidationResponse, tagStudioPackage, toStudioValidationResponse, } from "@honua/sdk-js/studio"; ``` ## MCP / QGIS safety The barrel exports **only types and pure functions** and never imports from `operator`, `esri-compat`, `web-components`, `interactions`, or `realtime` — none of the MapLibre/DOM/Console-coupled modules. MCP servers and QGIS plugins can import `@honua/sdk-js/studio` without pulling renderer code or triggering a build error from Console/Esri internals. This boundary is enforced by a source scan in `test/studio/studio-contracts.test.ts`. The established `map` and `dashboard`/`app` shapes are re-exported here from their leaf modules (`runtime/map-package`, `generated-app/manifest`), so a consumer reaches every family from one path without taking on the MapLibre runtime. ## Package families and format constants Each family is gated by a `format` constant — the canonical version string, exactly as on `MapPackage`. A loader refuses any other value. | Family | Format constant | Format string | Projection type | Stability | | --- | --- | --- | --- | --- | | query | `HONUA_QUERY_PACKAGE_FORMAT_V1` | `honua_query_package.v1` | `HonuaQueryPackage` | experimental (stub) | | analysis | `HONUA_ANALYSIS_PACKAGE_FORMAT_V1` | `honua_analysis_package.v1` | `HonuaAnalysisPackage` | experimental (stub) | | map | `HONUA_MAP_PACKAGE_FORMAT_V1` | `honua_map_package.v1` | `HonuaMapPackage` | stable family, re-exported | | dashboard | `HONUA_GENERATED_APP_MANIFEST_FORMAT_V1` | `honua_generated_app_manifest.v1` | `HonuaGeneratedAppManifest` | re-exported | | report | `HONUA_REPORT_PACKAGE_FORMAT_V1` | `honua_report_package.v1` | `HonuaReportPackage` | experimental (stub) | | form | `HONUA_FORM_PACKAGE_FORMAT_V1` | `honua_form_package.v1` | `HonuaFormPackage` | experimental (stub) | | app | — (uses `HonuaGeneratedAppPackage.version`) | — | `HonuaGeneratedAppPackage` | re-exported | | workflow | `HONUA_WORKFLOW_PACKAGE_FORMAT_V1` | `honua_workflow_package.v1` | `HonuaWorkflowPackage` | experimental (stub) | | gp | `HONUA_GP_PACKAGE_FORMAT_V1` | `honua_gp_package.v1` | `HonuaGPPackage` | experimental (stub) | | etl | `HONUA_ETL_PACKAGE_FORMAT_V1` | `honua_etl_package.v1` | `HonuaETLPackage` | experimental (stub) | The **stub** families (query, analysis, report, form, workflow, gp, etl) have no finalized server contract yet. Their interfaces are minimal and open-ended (every field optional beyond `packageId`/`format`, plus `[extra: string]: unknown`) so additive server changes never break a client. They expand once the matching server contract lands. The `map` and generated-app (`dashboard`/`app`) families are the established, landed shapes. Lifecycle status is shared across families via `HonuaStudioPackageStatus` (`"Draft" | "Composing" | "Ready" | "Failed" | "Expired"`), the same union as `HonuaMapPackageStatus`. ### The `HonuaStudioPackage` discriminated union `HonuaStudioPackage` is a union tagged by a client-side `packageFamily` field. **The server wire shape does not carry `packageFamily`** — projection helpers add it. Build a tagged value with `tagStudioPackage(family, pkg)` rather than casting a raw wire object, then narrow on `packageFamily`: ```ts doc-test=skip reason="partial excerpt requires application host context" const tagged = tagStudioPackage("query", rawQueryPackage); if (tagged.packageFamily === "query") { // tagged is narrowed to { packageFamily: "query" } & HonuaQueryPackage console.log(tagged.querySpec?.where); } ``` `STUDIO_PACKAGE_FAMILIES` enumerates every family discriminant and `isStudioPackageFamily(value)` guards an untrusted string. ## Unified validation response Every family returns the same `{ valid, diagnostics, pkg? }` shape through `StudioPackageValidationResponse<T>`, so SDK, Console, MCP, and QGIS clients consume one contract: ```ts doc-test=skip reason="partial excerpt requires application host context" interface StudioPackageValidationResponse<T> { readonly valid: boolean; readonly diagnostics: readonly StudioPackageDiagnostic[]; readonly pkg?: T; } ``` The existing map-family `ValidateMapPackageResult` is **not renamed** — it is structurally bridged: ```ts doc-test=skip reason="partial excerpt requires application host context" import { validateMapPackage } from "@honua/sdk-js/runtime"; import { fromMapPackageValidation } from "@honua/sdk-js/studio"; const response = fromMapPackageValidation(validateMapPackage(rawMapPackage)); // response: StudioPackageValidationResponse<HonuaMapPackage> ``` For other families, the generic adapter takes the field name holding the package: ```ts doc-test=skip reason="partial excerpt requires application host context" toStudioValidationResponse<HonuaQueryPackage>(rawQueryResult, "queryPackage"); ``` `HonuaMapPackageDiagnostic` is already a structural subtype of `StudioPackageDiagnostic` (it only adds `packageId?`), so map diagnostics flow through unchanged. For convenience, `fromMapPackageValidation` and `toStudioValidationResponse` are also re-exported from `@honua/sdk-js/runtime` (tagged `@experimental`) so existing map-package consumers can bridge from the path they already use. `StudioPackagePreviewResponse<T>` is the parallel envelope for previewing a package (composed without publishing), carrying optional `artifacts` and diagnostics. ## Capability manifest `StudioCapabilityManifest` lets a client gate UI/tool exposure on what the connected server actually supports: ```ts doc-test=skip reason="partial excerpt requires application host context" import { hasCapability, getCapability } from "@honua/sdk-js/studio"; if (hasCapability(manifest, "package.query")) { // advertise the query builder } ``` `hasCapability` is `true` only for an advertised **and enabled** capability; `getCapability` returns the entry regardless of enabled state. > The server capability-manifest endpoint is not yet defined (see Open > questions). These are client projection types ahead of that contract. ## Publish, share, and embed `StudioPublishRequest<T>`, `StudioPublishResponse`, and `StudioEmbedConfig` carry the versioned publication contract for generated artifacts. `HonuaShareRequest` / `HonuaShareResponse` are **re-exported from `control-plane/types`** (single source of truth — not re-declared) so Console and MCP reach the share contract from the one `@honua/sdk-js/studio` path without taking on the full control-plane client. `StudioEmbedConfig.token` / `embedUrl` describe an **already-issued** embed descriptor; the type does not imply a token-minting call inside the SDK. ## Generated apps Generated apps consume published `map`, `dashboard`, `report`, and `app` package projections from `@honua/sdk-js/studio` without importing Console or MapLibre code — the `dashboard` family is `HonuaGeneratedAppManifest` and the `app` family is `HonuaGeneratedAppPackage`, both re-exported here. For the preview runtime that hydrates these manifests, see [`docs/generated-app-runtime.md`](./generated-app-runtime.md). ## Versioning Every new export is tagged `@experimental` in JSDoc and is subpath-only (not re-exported from the root barrels), so it is outside the SDK semver contract until the matching server contracts land. The format constants follow the `HONUA_X_PACKAGE_FORMAT_V1 = "honua_x_package.v1"` pattern. ## Cross-surface parity (MCP / QGIS) The parity layer that makes packages portable across Console, MCP, and QGIS — a shared `provenance` envelope, a family-agnostic `validateStudioPackage` helper, a documented Vega-Lite chart subset, and cross-surface fixtures — is documented in [`studio-package-parity.md`](./studio-package-parity.md) (`honua-sdk-js#226`). ## Open questions (pending server contracts) These are tracked for follow-up once the server shapes are finalized: - Whether `dashboard` is exactly `HonuaGeneratedAppManifest` or a distinct server `DashboardPackage` wrapper. - The fetch path for `StudioCapabilityManifest` (dedicated endpoint vs. embedded in another response). - Full ETL/workflow/GP package shapes (currently stub-only). - Whether embed-token issuance is in scope for the SDK client. - Whether the MCP server (`/mcp`) should import package/validation types from `@honua/sdk-js/studio` in a follow-on. --- # File: docs/migration-honua-maplibre.md # `honua-maplibre` migration target The `honua-maplibre` codemod target rewrites a curated subset of `@arcgis/core` constructors directly into `@honua/sdk-js/map` helpers plus MapLibre GL source/layer definitions, instead of routing them through the Esri-shaped compat shims. It is the codemod option that produces MapLibre-native output for apps that want to leave the ArcGIS JS API behind end-to-end. This page documents the slice that shipped in PR #208 (commit `af1ebee`). It does not close issue #205 — open acceptance items are called out under [Manual gaps](#manual-gaps). ## How it differs from the other targets The migration codemod (`src/migration/codemod.ts`) exposes three targets selected with `--target` on the CLI: | Target | Output shape | What it rewrites natively | | --- | --- | --- | | `honua-compat` (alias `honua`) | `@honua/sdk-esri-compat` shims that mimic the ArcGIS JS API surface | Every kind in `REWRITE_SPECS` (`SUPPORTED_ARCGIS_MODULE_KIND_BY_PATH`) — full 2D constructor surface (layers, views, widgets, controls, tasks, support). | | `honua-maplibre` | `@honua/sdk-js/map` helpers + raw MapLibre style/layer objects | The five kinds in `HONUA_MAPLIBRE_NATIVE_KINDS`: `feature-layer`, `map-image-layer`, `tile-layer`, `map`, `map-view`. Everything else still falls through to a manual TODO. | | `esri-leaflet` | Mixed esri-leaflet primitives + compat fallback | `feature-layer`, `map-image-layer`, `tile-layer` natively; the remaining 2D surface via `ESRI_LEAFLET_COMPAT_FALLBACK_KINDS`. | `honua-compat` is the broadest deterministic option — it covers the full `REWRITE_SPECS` matrix and is the default. `honua-maplibre` trades surface coverage for a MapLibre-native output: it only emits native helpers for the layer/map/view core, and everything else (widgets, controls, renderers, geometry constructors, tasks) becomes a manual TODO because there is no `@honua/sdk-js/map` helper for it yet. `esri-leaflet` sits in between — three native layer kinds with a compat fallback for the rest. ## Auto-migrated, assisted, and unsupported kinds The exact set of kinds the `honua-maplibre` target rewrites natively is defined in `src/migration/codemod.ts` as `HONUA_MAPLIBRE_NATIVE_KINDS` and consumed by `TARGET_SUPPORTED_KINDS["honua-maplibre"]`: - `feature-layer` (`@arcgis/core/layers/FeatureLayer`) → `createHonuaFeatureServiceLayer({ url, ... })` returning a `HonuaFeatureServiceSourceSpecification` plus a default MapLibre render layer (`circle`/`line`/`fill` chosen via `layerType`). - `map-image-layer` (`@arcgis/core/layers/MapImageLayer`) → `createHonuaMapServiceLayer({ url, ... })` returning a `HonuaMapServiceSourceSpecification` plus a default raster render layer. - `tile-layer` (`@arcgis/core/layers/TileLayer`) → `createHonuaTileServiceLayer({ url, ... })` returning a MapLibre raster source pointing at `<url>/tile/{z}/{y}/{x}` plus a raster render layer. - `map` (`@arcgis/core/Map`) → `createHonuaMapLibreStyle({ basemap, layers, ... })` returning a `HonuaStyleSpecification` (MapLibre style version 8) that aggregates the migrated layers' sources and layers. - `map-view` (`@arcgis/core/views/MapView`) → `createHonuaMapLibreMapOptions({ container, style, center, zoom, bearing, pitch })`, the options bag passed to `new maplibregl.Map(...)`. The helper signatures live in `src/map/maplibre-target.ts`. ### Rendering feature-service layers (custom source → `geojson`) `createHonuaTileServiceLayer` and `createHonuaMapServiceLayer` emit MapLibre-native `raster` sources that render as soon as you add them to a map. `createHonuaFeatureServiceLayer` is different: its `source` uses the custom `honua-feature-service` type, which MapLibre's renderer does not understand. Adding that source to a `maplibre-gl` `Map` directly is a silent no-op — no error, no features. To render feature-service layers, run them through the MapLibre runtime adapter exported from `@honua/sdk-js/map`. It fetches features via the SDK query path and produces a standard `geojson` source MapLibre renders natively: ```ts doc-test=skip reason="partial excerpt requires application host context" import maplibregl from "maplibre-gl"; import { HonuaClient } from "@honua/sdk-js"; import { createHonuaFeatureServiceLayer, loadHonuaFeatureServiceGeoJson, } from "@honua/sdk-js/map"; const client = new HonuaClient({ baseUrl: "https://gis.example.com" }); const layer = createHonuaFeatureServiceLayer({ url, outFields: ["*"] }); // Replace the custom honua-feature-service source with a fetched geojson source. const source = await loadHonuaFeatureServiceGeoJson(client, layer.source.url); map.addSource(layer.sourceId, source); map.addLayer(layer.layer); ``` For a whole `HonuaMap`, `registerHonuaFeatureServiceSources(map, honuaMap, client)` walks every `honua-feature-service` source and live-patches it on the MapLibre map (adding a new `geojson` source or calling `setData` on an existing one). Both helpers live in `src/map/feature-service-adapter.ts`. Every other constructor kind in `REWRITE_SPECS` — geometries, symbols, renderers, `WebMap`, `SceneView`, `GraphicsLayer`, `GroupLayer`, `VectorTileLayer`, `GeoJSONLayer`, `WMSLayer`, `WFSLayer`, `ImageryLayer`, `FeatureFilter`, all widgets (`LayerList`, `Legend`, `Popup`, `Search`, `Sketch`, `Editor`, `TimeSlider`, …), all controls (`Home`, `Zoom`, `Compass`, `ScaleBar`, `Fullscreen`, `BasemapToggle`, …), `Query`, `OAuthInfo`, `IdentityManager`, `esriRequest`, `esriConfig`, `reactiveUtils`, `RouteTask`, etc. — is **not** in `HONUA_MAPLIBRE_NATIVE_KINDS`. The codemod emits a manual TODO with a reason at each call site and lists the unhandled ArcGIS module under `unhandledArcGisModules` in the report. The runtime parity matrix (`src/migration/runtime-matrix.ts::inferHonuaMapLibreRuntimeStatus`) categorizes these surfaces as `assisted` for `honua-maplibre`, except for the `feature-layer`, `map-image-layer`, and the `map-view.navigation-go-to` capability which are tagged `native`. Use the canonical fixture [`test/fixtures/esri-maplibre-simple-app/`](../test/fixtures/esri-maplibre-simple-app/src/main.ts) as a worked example — it imports `Map`, `MapView`, `FeatureLayer`, `MapImageLayer`, and `TileLayer` from `@arcgis/core` and exercises exactly the kinds that `honua-maplibre` can rewrite natively. ## CLI flag and invocations The codemod is selected with `--target honua-maplibre` on the `codemod`, `fixtures`, and `demo` subcommands of `node dist/src/migration/cli.js`. The accepted target tokens are `honua` (alias of `honua-compat`), `honua-compat`, `honua-maplibre`, and `esri-leaflet` — anything else is rejected by the parser. ```bash # Dry-run the codemod against the bundled MapLibre fixture node dist/src/migration/cli.js codemod test/fixtures/esri-maplibre-simple-app \ --target honua-maplibre \ --report reports/maplibre-fixture-report.json # Write the rewrite in place with inline TODOs for manual sites node dist/src/migration/cli.js codemod test/fixtures/esri-maplibre-simple-app \ --target honua-maplibre \ --write --annotate-todos \ --report reports/maplibre-fixture-report.json # Run the codemod against the hand-written parcel viewer example node dist/src/migration/cli.js codemod examples/arcgis-source-app \ --target honua-maplibre \ --report reports/arcgis-source-app-maplibre-report.json # Per-fixture readiness metrics for a single MapLibre fixture, no gating node dist/src/migration/cli.js fixtures test/fixtures \ --target honua-maplibre \ --fixtures esri-maplibre-simple-app \ --report reports/maplibre-fixture-metrics.json ``` The shared codemod flags (`--write`, `--annotate-todos`, `--report <path>`, `--compat-import-path <pkg>`, `--fail-on-manual`, `--fail-on-unhandled`, `--fail-on-blocked`, `--max-manual-ratio`, `--max-manual-intervention-ratio`) all apply unchanged. With `--target honua-maplibre` the codemod only rewrites the five native kinds; manual TODOs and unhandled-module entries are expected to appear for any constructor outside that set, and gating flags like `--fail-on-manual` will fail closed against apps with widgets, renderers, or non-2D-core surfaces. ## Migration report fields The report writer is `buildJsMigrationReport` (`src/migration/report.ts`); its `JsMigrationReport` shape is what gets written to the path passed to `--report`. The fields you can key off when consuming a `honua-maplibre` report are: - `codemodTarget`: `"honua-maplibre"` for runs launched with `--target honua-maplibre`. The union is `"honua-compat" | "esri-leaflet" | "honua-maplibre"`. - `rootDir`: absolute path of the codemod root that was scanned. - `scanSummary` / `scanReport`: the `scanArcGisUsage`/`summarizeArcGisScan` output (import counts, flags like `scene-3d-detected`, `advanced-widget-or-networking-detected`). - `codemodResult.metrics.totalCodemodScopedCallSites`, `codemodResult.metrics.autoMigratedCallSites`, `codemodResult.metrics.manualCallSites`, `codemodResult.metrics.byKind[<kind>]` (per-kind `auto/manual/total` counts). - `manualRewriteMetric`: `{ numerator, denominator, ratio, scope }` where `numerator/denominator` = manual call sites / total codemod-scoped call sites. `ratio` is the value compared against `--max-manual-ratio`. - `manualInterventionMetric`: `{ numerator, denominator, ratio, scope, manualCodemodCallSites, unhandledUsageHits }` — extends the rewrite metric by adding `unhandledArcGisModules` hits into both numerator and denominator. Compared against `--max-manual-intervention-ratio`. - `readiness`: `"ready" | "assisted" | "blocked"` — derived from `gates`. Blocking flags (`scene-3d-detected`, `advanced-widget-or-networking-detected`) force `blocked`. - `gates`: array of `{ gate: "no-manual-todos" | "no-unhandled-modules" | "no-blocking-flags", passed, detail }`. These are the MapLibre readiness gates: with `honua-maplibre`, expect `no-manual-todos` and `no-unhandled-modules` to fail on any app that uses widgets, renderers, geometry constructors, or other kinds outside `HONUA_MAPLIBRE_NATIVE_KINDS`. - `manualTodos`: per-call-site `{ file, line, column, kind, reason, difficulty? }`. - `manualTodoReasons`: rolled-up reasons sorted by count, each with a `kinds` list — useful for grouping cleanup work by category. - `manualTodosByKind`: dense map of every `CodemodConstructorKind` to its manual-call-site count for this run. - `unhandledArcGisModules`: `{ modulePath, usageStyle, count }` entries for ArcGIS modules outside the codemod's per-target supported set. `usageStyle` is `"static-import"`, `"dynamic-import"`, or `"require"`. The parity matrix tooling (`matrix` / `runtime-matrix` subcommands) also emits a `honuaMapLibre` summary block alongside `honuaCompat` and `esriLeaflet` with `native`/`assisted`/`unsupported` counts — see `summarizeJsParityMatrix` and `summarizeJsRuntimeParity`. ## Manual gaps Issue #205 is a slice ladder, not a single landing. `honua-maplibre` ships the native-rewrite path for the layer/map/view core; the remaining acceptance items live in [`docs/migration-punch-list.md`](./migration-punch-list.md) and stay open until they ship. The notable ones, framed against the punch list: - **WebMap → MapLibre style conversion.** The `web-map` kind is not in `HONUA_MAPLIBRE_NATIVE_KINDS`, so `new WebMap(...)` constructors emit a manual TODO under this target. Server-side / CLI WebMap conversion lives behind the `content-webmap` subcommand and is tracked separately. - **Widget visual parity.** Per the punch list "Widget UI behavior" entry: the compat shims accept ArcGIS option shapes but render through the Honua widget host with non-byte-identical visuals. `honua-maplibre` does not paper over this — widget constructors fall to manual TODO instead of being silently rewritten. - **3D / SceneView and scene layers.** `scene-view`, `SceneLayer`/`BuildingSceneLayer`/`IntegratedMeshLayer`/ `PointCloudLayer`/`MeshLayer`/`ElevationLayer`/`VoxelLayer` are flagged with the `scene-3d-detected` blocking flag and force `readiness: "blocked"`. MapLibre is 2D-only by design; 3D parity is not in this target's scope. - **`Locator` / `Geoprocessor` / `NetworkAnalyst` (beyond `RouteTask`).** Not in `HONUA_MAPLIBRE_NATIVE_KINDS`; manual TODO with the unhandled-module list pointing at the missing surfaces. - **Renderers, symbols, popup templates, `reactiveUtils.watch`, event-name remap, `Query` deep-transform.** These ship for `honua-compat` (some shipped as Tasks D/E/F in the punch list) but they do not exist as native MapLibre helpers — under `honua-maplibre` they remain manual TODO surfaces. - **Playwright smoke / runtime evidence lane for MapLibre output.** Open acceptance item on #205. For the broader migration story — including the `honua-compat` parity surface, the codemod gates, and the test corpus — start at [`docs/migration-punch-list.md`](./migration-punch-list.md) and the fixture-only Esri sample corpus in [`docs/esri-sample-corpus.md`](./esri-sample-corpus.md). --- # File: docs/migration-punch-list.md # ArcGIS JS SDK → Honua JS SDK migration punch list This is an honest accounting of where the Honua JS SDK's two public-facing claims stand: 1. **"Parity with the ArcGIS JS SDK"** — the symbol surface app authors can `import` and call against. 2. **"Automated app conversion"** — the `src/migration` codemod that rewrites ArcGIS imports/constructors to Honua compat shims. It is intentionally written so a reader can tell what is shipped, what is *partial*, and what is *not started*. None of the entries below are aspirational — they are concrete work items. --- ## What is true today ### Parity surface (`src/esri-compat/`) The compat module ships shims for the constructors enumerated in `src/migration/codemod.ts::SUPPORTED_ARCGIS_MODULE_KIND_BY_PATH`. Every kind in that map has a row in `parity-matrix.ts`, and every row is asserted by `test/migration-parity-matrix.test.ts`. The shims back onto real Honua surfaces in `src/core/` (HonuaClient, HonuaFeatureService, HonuaImageService, HonuaMapService, …) rather than being throwaway no-ops. Specifically: - 2D `FeatureLayer`, `GraphicsLayer`, `GroupLayer`, `MapImageLayer`, `TileLayer`, `RouteLayer`, `Basemap`, `VectorTileLayer`, `GeoJSONLayer`, `WMSLayer`, `WFSLayer`, `ImageryLayer` - `Graphic`, `Point`, `Polyline`, `Polygon`, `Extent`, `SpatialReference`, `Color`, `FeatureSet` - Symbols: `SimpleLineSymbol`, `SimpleMarkerSymbol`, `PictureMarkerSymbol`, `TextSymbol`, `LabelClass`, `SimpleFillSymbol` - Renderers: `SimpleRenderer`, `UniqueValueRenderer`, `ClassBreaksRenderer` - Views: `Map`, `MapView`, `SceneView` (2D-only behavior), `WebMap` - Widgets (deterministic 2D-only set): `Home`, `BasemapToggle`, `BasemapGallery`, `BasemapLayerList`, `LayerList`, `Legend`, `Popup`, `Search`, `Track`, `Swipe`, `Feature`, `FeatureForm`, `FeatureTemplates`, `FeatureTable`, `DistanceMeasurement2D`, `AreaMeasurement2D`, `Compass`, `Fullscreen`, `Zoom`, `Attribution`, `ScaleBar`, `Locate`, `Bookmarks`, `Print`, `Sketch`, `Editor`, `Expand`, `TimeSlider`, `Directions`, `CoordinateConversion`, `Measurement` - Tasks & support: `RouteTask`, `Query`, `OAuthInfo`, `IdentityManager`, `esriRequest`, `esriConfig`, `reactiveUtils`, `FeatureFilter` The `MapViewLayerViewCompat` returned from `view.whenLayerView()` now carries `filter` / `effect` / `visible` state and forwards filter predicates through `queryFeatures` / `queryFeatureCount` / `queryObjectIds`, merging `where`, `objectIds`, `geometry`, `spatialRelationship`, and `timeExtent` with any per-call options (layer-view filter ∧ caller-provided options). ### Automated app conversion (`src/migration/codemod.ts`) The codemod is a real AST rewriter built on the TypeScript Compiler API (it does **not** use regex). It: - Detects `import { X } from "@arcgis/core/..."` and rewrites bindings to the matching `XCompat` symbol from `esri-compat`. - Detects `new X(opts)` for every kind above and rewrites to the compat constructor when the option literal is "safe" — i.e., a top-level object literal whose properties are all in the per-kind allowlist (`FEATURE_LAYER_ALLOWED_PROPS`, `MAP_VIEW_ALLOWED_PROPS`, …). The new layer kinds added in this pass — `feature-filter`, `vector-tile-layer`, `geojson-layer`, `wms-layer`, `wfs-layer`, `imagery-layer` — go through the same `isSafeAllowedPropertiesCall` switch and gain TODO annotations on unsafe call sites. - Emits `manualTodos` and `manualTodoReasons` on a `byKind` matrix for every untouched call site, so app authors can drive cleanup against a checklist. - Emits a parity report (`src/migration/report.ts`) with three gates (`no-manual-todos`, `no-unhandled-modules`, `no-blocking-flags`) and a readiness verdict. - Knows the `esri-leaflet` migration target as a separate column, with `ESRI_LEAFLET_COMPAT_FALLBACK_KINDS` gating which kinds are treated as deterministic vs. assisted. - Knows the `honua-maplibre` migration target as a third column, gated by `HONUA_MAPLIBRE_NATIVE_KINDS` (`feature-layer`, `map-image-layer`, `tile-layer`, `map`, `map-view`). The native rewrite emits `@honua/sdk-js/map` helpers plus MapLibre style/layer objects instead of compat shims; everything outside that set falls through to a manual TODO. See [`docs/migration-honua-maplibre.md`](./migration-honua-maplibre.md) for the per-target rewrite surface, CLI invocations, report fields, and the manual-gap list against issue #205. Everything in this section is covered by tests under `test/` (`migration-codemod.test.ts` is 75 cases; `migration-report.test.ts`, `migration-gating.test.ts`, and `migration-parity-matrix.test.ts` hold the surface stable). --- ## What the claim does **not** yet cover ### Parity gaps 1. **3D / SceneView.** `SceneView` exists as a compat class but it shares the 2D `MapView` behavior under the hood. Anything that requires a WebGL/CesiumJS path (`environment`, `viewingMode: "global"`, `goTo` with altitude, `Ground`, `ElevationLayer`, scene layers, `Camera`, `qualityProfile`) is not implemented. The codemod sets the `scene-3d-detected` blocking flag when it sees such imports, so the gate fails closed. 2. **Scene layers.** `SceneLayer`, `BuildingSceneLayer`, `IntegratedMeshLayer`, `PointCloudLayer`, `MeshLayer`, `ElevationLayer`, `VoxelLayer` have no shims. These all need server-side glTF/I3S support (`honua-server` ticket). 3. **`Locator` / `Geoprocessor` / `NetworkAnalyst` (beyond RouteTask).** Today only `RouteTask` is shimmed against `HonuaRouteService`. The remaining task surfaces (geocoding, service-area, OD-cost-matrix, closest-facility, non-network geoprocessing) need their own surfaces under `src/core/` and matching compat classes. 4. **`Query` argument depth.** *Shipped (Task E).* The codemod now deep-transforms `new Query({...})` argument literals into the Honua `QueryFeaturesRequest` shape: renames `start` → `resultOffset`, `num` → `resultRecordCount`, `outSpatialReference` → `outSr`, `spatialRelationship` → `spatialRel`; splits `geometry: { type, ...rest }` into a sibling `geometryType` field with the matching `esriGeometry*` enum value; and lowercases `outStatistics[i].statisticType` against the STATISTIC_TYPE_MAP keyset (count/sum/min/max/avg/stddev/var). Divergent / dynamic shapes emit a manual TODO with a precise reason — specifically: `timeExtent` (no Honua field; use `extraParams.time`), `quantizationParameters` (server-side optimization with no Honua equivalent), `relationParam` (no Honua support), `geometry` without a string-literal `type` discriminator, and `outStatistics` items with a non-literal `statisticType` or an unknown enum value. The shim `QueryCompat` accepts either ArcGIS or Honua property spellings (`spatialRelationship` / `spatialRel`, `outSpatialReference` / `outSr`, `num` / `resultRecordCount`, `start` / `resultOffset`) so codemod output and hand-written callers both validate. 5. **Popup actions / popup templates with `actions`, `outFields: ["*"]` expansions, and `fieldInfos` formatters.** These rewrite cleanly only for the simple case; arrow-function field-info `format` callbacks fall through to manual TODO. 6. **Widget UI behavior.** The widget shims accept the same constructor options ArcGIS does, but they render through the Honua widget host (`HonuaWidgetHost`). Visual parity (icons, ARIA, CSS class names that downstream apps style against) is **not** byte-identical. Apps that rely on `calcite-action--`-prefixed selectors will need style work. 7. **`reactiveUtils.watch` semantics (partial).** Compat `watch` is property-name-based and synchronous. ArcGIS `watch` accepts an accessor function and returns a `WatchHandle` with `pause()` / `resume()`. Single-property accessors — `reactiveUtils.watch(() => view.scale, h)` where `view` is a tracked compat instance — are now rewritten by the codemod to `reactiveUtils.watch(view, "scale", h)`. Multi-property accessors (`() => [view.scale, view.zoom]`) and complex bodies still fall through to a manual TODO. `pause()` / `resume()` on the handle is still unsupported. ### Codemod gaps These are the load-bearing pieces required to turn "rewrites known constructors" into "automated app conversion": 1. **Event-name remap (Task D, partial).** The codemod now rewrites well-known divergent event names on `.on()` and `.watch()` calls in files that have at least one ArcGIS import being touched. The dictionary is in `ARCGIS_TO_COMPAT_EVENT_REMAP` in `src/migration/codemod.ts` and currently covers `layerview-create*`, `layerview-destroy`, `visibility-change`, `extent-change`, `rotation-change`, `scale-change`, `zoom-change`, `center-change`, `spatial-reference-change`, `padding-change`, `constraints-change`, `highlight-options-change`, `basemap-change`, `ground-change`, `portal-item-change`, `active-basemap-change`, `camera-change`, `quality-profile-change`, `viewing-mode-change`, and `refresh`. Receiver-aware scoping is now in place: the rewrite only fires when the receiver is an Identifier whose binding came from `new XCompat(...)` where `X` was a tracked ArcGIS import (via `resolveAssignedIdentifierForNewExpression`). Receivers that don't trace back to a tracked compat instance (e.g., `someEmitter.on("layerview-create", h)`) are left alone even when the file has ArcGIS imports. What is still missing: pointer/keyboard event handlers (`click`, `pointer-down`, `key-down`, …) which compat doesn't emit yet. 2. **Dynamic / CJS imports (Task F, partial).** Dynamic ESM imports (`import("@arcgis/core/...")`) are rewritten through the `isArcGisDynamicImportCall` pass and have test coverage for SceneView, esriConfig, esriRequest, and IdentityManager. CommonJS `require("@arcgis/core/...")` is now auto-rewritten for the `honua-compat` target when constructor options are safe: the codemod emits `const { XCompat } = require("@honua/sdk-esri-compat");` and rewrites `new X(opts)` to `new XCompat(opts)` in `.cjs` files and `.js` files containing CommonJS markers. The original `const X = require("@arcgis/core/...")` declaration is now pruned when no other references to `X` remain in the file (constructor calls are the only references in the typical case; the codemod scans for non-constructor uses and preserves the require if it sees any). The `esri-leaflet` target still emits a manual TODO for CJS require constructors. 3. **Module re-exports beyond the registered set.** The codemod already follows `localArcGisReExports` for one level. Deeper barrel-file chains (`./esri.ts` → `./layers/index.ts` → …) aren't followed. 4. **`view.map.add(layer)` argument-shape transformations.** When a `new FeatureLayer({ ... })` is rewritten, its option literal is passed through verbatim. We do **not** translate ArcGIS-shaped properties whose Honua-side names differ (e.g., `renderer.field` casing, `popupTemplate.content` block shapes). The shim accepts the ArcGIS shape at runtime, but the report should flag known-divergent property values so app authors can confirm intent. 5. **WebMap JSON → MapLibre style derivation (`honua-maplibre` target).** *Partially shipped.* `src/map/webmap-maplibre.ts` exposes `webmapJsonToMapLibreStyle(webmap, options)`, a pure converter that delegates the heavy lifting to the existing `src/webmap/` pipeline — `parseWebMap` orchestrates `convertBasemap`, `convertOperationalLayer`, `convertRenderer`, `convertPopupInfo`, `convertLabelingInfo`, and `convertExtent`/`convertInitialViewpoint` — and returns a `HonuaStyleSpecification` ready for `map.setStyle(...)` plus a structured `manualGaps: WebMapMapLibreManualGap[]` array. `manualGaps` is the single source of truth for unsupported constructs: every entry carries `{ kind, reason, path?, layerId?, expression?, context? }` and the `kind` taxonomy covers `arcade-expression` (popup `expressionInfos` and complex Arcade label expressions), `unsupported-renderer` (heatmap, dotDensity, and any other renderer the converter doesn't handle), `scene-3d` (top-level `ground` / `camera` / `viewingMode`, `ArcGISSceneServiceLayer`, `elevationInfo`, `heightInfo`, `sceneProperties`), `dashboard-reference`, `experience-builder-reference`, `custom-widget-reference` (`applicationProperties.viewing.widgetsOnScreen` and the top-level `widgets` array used by host shells), plus pass-throughs for `unsupported-symbol`, `unsupported-layer-type`, `unsupported-feature-collection`, and `unsupported-webmap-version`. The migration codemod now wires this converter into its `honua-maplibre` constructor-rewriting pass: any `new WebMap({ ... })` call whose argument is a static WebMap JSON object literal (top-level `operationalLayers` and/or `baseMap`, fully literal values, no `portalItem`) is rewritten to `webmapJsonToMapLibreStyle({ ... })` from `@honua/sdk-js/map`, and the helper's `manualGaps` array is propagated into the migration report as per-gap `web-map` manual TODOs (Arcade, unsupported renderer, scene/3D, dashboard shell, …). **What still requires manual review:** dynamic / portal-loaded WebMaps (`new WebMap({ portalItem: { id: someId } })`, `WebMap.load()`, identifier references, computed properties, spreads) still emit a `web-map` manual TODO with no rewrite — they need a runtime fetch the codemod cannot synthesize. The gap surface is also intentionally honest about its ceiling, since Arcade execution, scene/3D rendering, Dashboard widget hosts, Experience Builder pages, and custom-widget plugins all require runtime support the JS SDK cannot synthesize from JSON alone. Each emitted gap is a `// TODO` anchor for an app author, not a promise of automated migration. 6. **End-to-end demo conversion.** Shipped at `examples/arcgis-source-app/` + `test/migration-e2e.test.ts`. The sample is a hand-written parcel viewer exercising `FeatureLayer` (with `outFields` + `popupTemplate`), `Map`, `MapView`, an untouched `view.on("click", …)` handler, and an event-name-remapped `layer.on("layerview-create", …)` handler. The e2e test copies it into a tempdir, runs the codemod with `target: "honua-compat"`, asserts every expected rewrite landed and the readiness gate is `"ready"`, and then runs `tsc --noEmit` against the migrated source with `paths` resolving `@honua/sdk-esri-compat` to the workspace `src/esri-compat/` entry. The CI job runs this harness immediately after the unit test step. Remaining work: extend the sample to additional layer kinds (Imagery / VectorTile / WMS / WFS / GeoJSON) so the harness exercises the full surface, not just the parcel viewer slice. --- ## What's required to make the claim defensibly true In rough effort order: | Bucket | Effort | Notes | | --- | --- | --- | | Codemod event-name remap (Task D) | 1–2 days | Pure JS-side work; needs an event-name dictionary plus AST pass. | | Codemod dynamic-import + CJS (Task F) | 1–2 days | Mirror the static-ESM path; care needed for awaited dynamic imports' destructuring. | | ~~Query argument deep-transform (Task E)~~ | ~~3–5 days~~ | Shipped: codemod renames `start`/`num`/`outSpatialReference`/`spatialRelationship`, splits `geometry` into `geometry`+`geometryType`, normalizes `outStatistics[i].statisticType` against `STATISTIC_TYPE_MAP`, and emits precise manual TODOs for `timeExtent`, `quantizationParameters`, `relationParam`, dynamic statisticType, and geometry without a literal `type`. `QueryCompat` accepts both ArcGIS and Honua property spellings. | | Locator + Geoprocessor compat (Task C) | 1–2 weeks | Requires `HonuaGeocodeService`, `HonuaGeoprocessService` surfaces (server work). | | Scene-layer compat (Task A-rest) | 4–8 weeks | Requires I3S/glTF pipeline on `honua-server`; not pure-JS work. | | ~~E2E demo conversion harness (Task I)~~ | ~~1 week~~ | Shipped: `examples/arcgis-source-app/` + `test/migration-e2e.test.ts`, wired into the JS SDK CI workflow after the unit-test step. | With the first three rows (Tasks D/F/E) now shipped, "automated app conversion" reaches deterministic constructor rewrite + event-name remap + dynamic/CJS imports + Query argument deep-transform, plus a manual-TODO report for everything else. The remaining rows (Locator/Geoprocessor and Scene-layer compat) still gate the unqualified "parity with ArcGIS JS SDK" claim — that should remain qualified as "deterministic 2D parity; scene/3D and advanced tasks remain assisted migration" until they land. --- # File: docs/esri-sample-corpus.md # Esri Sample Migration Corpus This corpus is the first bounded slice for paired Esri sample app and referenced service migration evidence. It is intentionally fixture-first. ## PR CI Guardrails - PR CI must not contact live Esri services. - PR CI must not vendor Esri sample source code. - PR CI must not commit Esri-hosted service data, basemaps, screenshots, or Portal exports. - Sample source URLs are metadata references only. - Samples that require API keys, OAuth, private content, premium services, or unclear content terms stay skipped unless credentials, permission, and terms review are explicitly configured. The committed manifest lives at `test/fixtures/esri-sample-corpus/manifest.json`. Its fixtures are tiny Honua-owned snippets that mimic common ArcGIS Maps SDK for JavaScript patterns: service URL references, Portal item references, API-key routing references, and OAuth/private Portal references. ## Manifest Shape The manifest uses `schemaVersion: "honua.esri-sample-corpus.v1"` and records: - global guardrails for no vendored Esri code, no committed Esri service data, fixture-only PR CI, and opt-in live evidence lanes - one entry per curated sample with `id`, `title`, source URL metadata, fixture location, status, license metadata, terms notes, skip reasons, and expected references - status values: `ci-fixture`, `live-evidence`, and `skipped` - skip reasons: `requires-api-key`, `requires-oauth`, `premium-service`, `private-content`, `unclear-content-terms`, and `unsupported-sample-family` The migration entrypoint exports helpers to load and inspect the corpus: ```ts doc-test=compile import { analyzeEsriSampleFixture, loadEsriSampleCorpusManifest, summarizeEsriSampleCorpus, } from "@honua/sdk-js/migration"; const manifest = loadEsriSampleCorpusManifest("test/fixtures/esri-sample-corpus/manifest.json"); const summary = summarizeEsriSampleCorpus(manifest); const analysis = analyzeEsriSampleFixture(manifest.samples[0], { manifestDir: "test/fixtures/esri-sample-corpus", }); void summary; void analysis; ``` ## Per-sample evidence For each curated sample, the migration entrypoint can run the existing codemod against the local fixture and emit a structured evidence record: ```ts doc-test=compile import { emitEsriSampleCorpusEvidence } from "@honua/sdk-js/migration"; const evidence = emitEsriSampleCorpusEvidence({ manifestPath: "test/fixtures/esri-sample-corpus/manifest.json", // Defaults to "honua-maplibre". Other codemod targets are also accepted. }); for (const sample of evidence.samples) { // sample.status === "migrated" | "skipped" | "error" // sample.classification.auto / manual / unsupported // sample.manualTodos.reasons (deduped by reason, includes affected kinds) // sample.referencedServices / sample.portalItems / sample.guardrailFlags // sample.urlRewriteSummary.byKind / sample.unsupportedApis } // Aggregate across the corpus — per-status counts always sum to the manifest // length, and unsupported APIs are deduplicated. const { statusCounts, totals, uniqueUnsupportedApis } = evidence.aggregate; void statusCounts; void totals; void uniqueUnsupportedApis; ``` Samples whose manifest entry is `status: "skipped"` (private-portal, routing API-key, premium-service, etc.) appear in the evidence record with `status: "skipped"` and the manifest skip reason — they always count toward the aggregate, but never as `migrated`. No live Esri services are contacted; the helper operates entirely on the committed fixture corpus. The same evidence can be persisted to disk via the migration CLI subcommand `corpus-evidence`. It writes one JSON artifact per sample to `<out>/samples/<sampleId>.json` and a single aggregate document to `<out>/corpus-evidence.json`. `--corpus` defaults to `test/fixtures/esri-sample-corpus`, and `--target` defaults to `honua-maplibre`: ```bash node dist/src/migration/cli.js corpus-evidence \ --corpus test/fixtures/esri-sample-corpus \ --out ./corpus-evidence ``` ## Live Evidence Lanes Live Esri sample/service evidence is reserved for scheduled or manual runs. A live lane should: - require an explicit environment flag and any needed credentials - rate-limit requests and avoid load/performance testing against Esri services - record source sample URL, retrieved date, service URLs, Portal item ids, service import result, app migration result, unsupported/manual-review items, and browser smoke result - skip samples requiring credentials, premium services, private content, or unclear content terms unless the lane has explicit permission - emit artifacts for review rather than committing Esri source or service data Follow-on issue #206 work can layer service import, app codemod, URL rewriting, and Playwright smoke evidence on top of this manifest and extraction helper. ## Related: `honua-maplibre` migration target The codemod's MapLibre-native migration target is documented in [`docs/migration-honua-maplibre.md`](./migration-honua-maplibre.md). That page covers the `--target honua-maplibre` flag, the rewritten kinds, the migration report fields, and the manual gaps that remain open on issue #205. The bundled MapLibre fixture `test/fixtures/esri-maplibre-simple-app/` is the canonical example for that target and is referenced from the doc. --- # File: mcp/README.md # `@honua/mcp-server` The **platform-free** Model Context Protocol (MCP) server for geospatial feature services. Point it at **any** public Esri FeatureServer or OGC API endpoint and it exposes discovery, query, and analysis workflows to any MCP client — with **zero** platform lock-in and no metering. Mapbox, CARTO, and Esri each ship an MCP server bound to their own platform; this one is bound to none. **Release status: beta.** Tool contracts are certified against live and fixture targets on every release; remaining pre-1.0 work is hardening, not surface change. Two modes: - **Standalone (platform-free) — the front door.** The `honua-mcp` bin runs the direct-SDK surface against any public FeatureServer/OGC endpoint. No Honua server, no admin API, no `/mcp` catalog required. - **Honua-enhanced — the upgrade path.** The `honua-mcp-proxy` bin bridges a Honua deployment's full `/mcp` operator catalog (planning, async jobs, publishing) to stdio. Honua does **not** expose an AI/MCP feature-mutation tool — AI operational data editing is not supported (honua-server ADR-0028). ## Requirements - Node.js `>=20` - Any reachable FeatureServer/OGC endpoint (public or Honua) ## Platform-free mode (any ArcGIS / OGC endpoint) Point `HONUA_BASE_URL` at any origin/folder that serves the standard GeoServices REST paths (`/rest/services`, `/FeatureServer/{id}/query`). Example — the public US Census 2020 apportionment FeatureServer on `services.arcgis.com`: ```bash npm install npm run build HONUA_BASE_URL="https://services.arcgis.com/P3ePLMYs2RVChkJx/arcgis" \ HONUA_TRANSPORT="rest" node dist/src/index.js ``` Tools that require a Honua-only surface (server-side styling via OGC API – Styles, a `/rest/services` catalog) **degrade gracefully** on a plain endpoint: they return a structured result ```json { "available": false, "surface": "OGC API - Styles", "reason": "…", "guidance": "…" } ``` instead of crashing, hanging, or returning misleading empty data. This is the same skip-with-reason honesty the certification suite uses. ## Environment Variables - `HONUA_BASE_URL` (required): absolute base URL — a public ArcGIS folder (`https://services.arcgis.com/<org>/arcgis`) or a Honua deployment (`https://honua.example.com`). - `HONUA_TRANSPORT` (optional): `grpc-web` (default, Honua deployments) or `rest`. Use `rest` for plain public ArcGIS/OGC endpoints. - `HONUA_API_KEY` (optional): API key when your deployment requires it. Public endpoints need none. - `HONUA_TIMEOUT_MS` (optional): request timeout in milliseconds (default `30000`) - `HONUA_RETRY_MAX_RETRIES` (optional): retry attempts for transient failures (default `2`) When `HONUA_API_KEY` is configured, use `https://` for non-localhost servers. ## MCP Tools All read-only: - `honua_list_services` — discover services (degrades if the target has no catalog) - `honua_describe_layer` - `honua_query_features` - `honua_count_features` - `honua_get_extent` - `honua_statistics` - `honua_explain_capability_gap` - `honua_get_style`, `honua_apply_style_preset` — structured-unavailable on a plain FeatureServer ## MCP Resources - `honua://services` - `honua://services/{encodedServiceId}/layers/{layerId}` - `honua://styles`, `honua://styles/{styleId}` — structured-unavailable on a plain FeatureServer ## Certification The package ships a **deterministic MCP certification harness** that proves the advertised MCP surface is well-formed and conformant to the open [`geospatial-mcp`](https://github.com/honua-io/geospatial-mcp) standard. It is fully offline — no model/API calls, no network in the default path — and is the evidence document for the WS-H "Provability" workstream. For each tool the server advertises (over an in-memory MCP transport) it: 1. Enumerates `tools/list`, `resources/list`, and `prompts/list`. 2. Validates each `inputSchema` (and any `structuredContent` output schema) is well-formed JSON Schema, accepting both the draft-07 dialect emitted by zod-to-json-schema and the draft 2020-12 dialect of the standard. 3. Where a vendored standard schema matches the advertised tool (by the standard's `referenceToolName`), checks **conformance** — every standard `required` property must be accepted with a compatible type. Standard tools that are not advertised, and advertised tools outside the standard, are recorded as **known gaps**, not failures. 4. **Round-trips** every read-only tool (`tools/call` with a fixture input), validating the response. Write/destructive tools are never called. It emits a stable machine-readable JSON report plus a human-readable Markdown summary, and exits non-zero on any conformance/round-trip failure. ```bash # Run the certifier against the offline fixture backend and write artifacts: npm run certify # CI entry points (also runnable locally): npm run test:certification # gate: runs harness tests + certifier, exits non-zero on failure npm run test:certification:artifact # evidence: writes artifacts, always exits 0 ``` ### Platform-free certification lane A dedicated **standalone** target (`HONUA_MCP_CERT_TARGET=standalone`, or `--target standalone`) certifies the platform-free surface against an in-process fixture of a **plain public FeatureServer** — the recorded US Census 2020 apportionment layer from `services.arcgis.com`, with **no** Honua surfaces. It proves the tools certify **green with honest skips** against a non-Honua endpoint: the data tools round-trip against real recorded data, the Honua-only style tools degrade to structured "not available" results, and the auth/mutation/job contracts skip-with-reason. Fixture-backed and deterministic — no network. ```bash npm run certify:standalone # platform-free cert (plain FeatureServer fixture) npm run test:certification:standalone # CI gate variant ``` The fixture is recorded by `scripts/record-census-fixtures.mjs` into `src/certification/census-data.ts`; the evaluator that replays it is verified for parity against the live recordings in `test/certification/census-fixture-client.test.ts`. Artifacts are written to the package root as `mcp-certification-results.json` and `mcp-certification-results.md` (gitignored; uploaded by CI). To certify against a **live** honua-server, set `HONUA_BASE_URL` (and `HONUA_TRANSPORT`, `HONUA_MCP_SERVICE_ID`, `HONUA_MCP_LAYER_ID`); the harness then drives a real `HonuaClient` instead of the fixture. The standard schemas are vendored under `certification/geospatial-mcp-schemas/` (see that directory's `PROVENANCE.md` for the pinned source revision). ## Transport-symmetric stdio proxy The honua server exposes one MCP catalog over streamable-HTTP/SSE at `/mcp`. Claude-Desktop-style clients speak **stdio**. Rather than reimplement that catalog (which is how the HTTP and stdio surfaces historically drifted apart), this package ships a **stdio proxy** (`honua-mcp-proxy`) that bridges a local stdio MCP client to the remote HTTP-SSE MCP server. It connects upstream as an MCP client and re-exposes the *same* catalog downstream over stdio — identical tools, identical input/output schemas, identical resources and prompts, and live `tools/list_changed` notifications. There is one source-of-truth catalog (the server's `/mcp`); the SDK proxies it, so the two transports are symmetric by construction. (Cross-repo: honua-io/honua-server#1950.) ```bash # Bridge a stdio MCP client to a remote honua /mcp surface: HONUA_MCP_REMOTE_URL="https://demo.honua.io/mcp" honua-mcp-proxy ``` Environment variables: - `HONUA_MCP_REMOTE_URL` (required; alias `HONUA_MCP_URL`): the remote honua `/mcp` endpoint to proxy. - `HONUA_MCP_AUTH_TOKEN` (optional): sent as `Authorization: Bearer <token>`. - `HONUA_API_KEY` (optional): sent as `x-api-key`. A parity test (`test/proxy.test.ts`) asserts the tool/resource/template catalog the downstream client sees is byte-identical to the upstream surface, that `tools/call` and resource reads round-trip identically, and that `list_changed` notifications are forwarded. ## Cross-model workflow eval (provability) The package ships a **cross-model workflow eval** that proves the "any client → any workflow" claim: a held-out corpus of GIS workflows (`src/eval/corpus.ts`) is driven through the MCP surface by different client LLMs and graded for end-to-end success / clarification / edit rates per model. (Cross-repo: honua-io/honua-server#1956.) - **Deterministic control (offline, CI):** a scripted "ideal client" runs every workflow's real `tools/call` round-trips against the offline fixture surface — no model/API calls — and is graded identically to the live models. This is the reproducible CI gate. - **Live cross-model (Claude + GPT):** when `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` are set, the Claude driver (latest Opus, `claude-opus-4-8`) and the GPT driver (GA flagship, `gpt-5.5`, override with `OPENAI_MODEL`) run the corpus through a real agentic tool-use loop over the identical catalog. The `@anthropic-ai/sdk` / `openai` packages are imported dynamically (not dependencies); keys come from the environment and are never hardcoded. Set `HONUA_MCP_REMOTE_URL` to drive a live remote `/mcp` instead of the fixture. ```bash npm run eval # run the eval (live models join if their keys are set) npm run eval:offline # force the deterministic control + fixture surface npm run test:eval # gate: harness tests + offline eval, exits non-zero on failure npm run test:eval:artifact # evidence: writes artifacts, always exits 0 ``` Artifacts (`mcp-eval-results.json` / `mcp-eval-results.md`, gitignored, uploaded by CI) record the per-model scorecard. The CI gate asserts the deterministic control passes every scenario; live cross-model runs are recorded but informational. ### Platform-free semantic corpus (`src/eval/standalone-corpus.ts`) The **standalone** corpus is 50+ scenarios run against the plain public FeatureServer fixture (the census layer) with **semantic** grading — not just tool trajectory. Each scenario asserts the *meaning* of the answer: correct feature counts (52 rows, 4 states with ≥20 seats), correct geographic facts (California is the most populous; Wyoming the least; the House totals 435 seats), correct tool selection for ambiguous asks (count vs. query vs. statistics), refusal / clarification behavior on ambiguous or unsupported requests, and anti-hallucination guards. Because the fixture replays real recorded data, a wrong number or a hallucinated place name fails. The grading taxonomy is documented in [`evals/README.md`](evals/README.md). ```bash npm run eval:standalone # deterministic control over the census fixture (offline) ``` ### Live lane (paid, manual only) The **operator corpus** (`src/eval/operator-corpus.ts`, 8 harder scenarios) is meant to run against a live honua-server **operator** `/mcp` surface with real models. This is a **billable** lane: it makes real Anthropic / OpenAI (or AWS Bedrock) calls. It never runs on push/PR — locally via `npm run eval:live`, or in CI via the manual **`MCP Live Cross-Model Eval`** workflow (`.github/workflows/mcp-eval-live.yml`, `workflow_dispatch`-only). The live model SDKs are not package dependencies; install them first for a live run: ```bash npm install --no-save @anthropic-ai/sdk openai # only needed for live runs ``` `eval:live` runs the operator corpus (`--corpus operator`) against the remote `/mcp` and always writes artifacts (`--artifact-only`). Authentication uses the same headers as the stdio proxy — **no dev-auth bypass**; the resolved auth mode (`bearer` / `api-key` / `anonymous`) is recorded in the artifact so the run proves it was authenticated. Set `HONUA_EVAL_REQUIRE_AUTH=1` to refuse an anonymous run outright. Shared env for every live run: - `HONUA_MCP_REMOTE_URL` (required): the operator `/mcp` endpoint, e.g. `https://demo.honua.io/mcp`. - `HONUA_MCP_AUTH_TOKEN`: sent as `Authorization: Bearer <token>` (preferred; ⇒ auth mode `bearer`). - `HONUA_API_KEY`: sent as `x-api-key` (⇒ auth mode `api-key`) when the deployment uses key auth instead. - `HONUA_EVAL_REQUIRE_AUTH=1` (recommended): fail fast if neither credential is present. ```bash # Anthropic Claude (default claude-opus-4-8; override with HONUA_EVAL_ANTHROPIC_MODEL): HONUA_MCP_REMOTE_URL="https://demo.honua.io/mcp" \ HONUA_MCP_AUTH_TOKEN="$HONUA_TOKEN" \ HONUA_EVAL_REQUIRE_AUTH=1 \ ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \ npm run eval:live -- --driver anthropic # OpenAI GPT (default gpt-5.5; override with OPENAI_MODEL, e.g. gpt-5.6-sol once you have access): HONUA_MCP_REMOTE_URL="https://demo.honua.io/mcp" \ HONUA_MCP_AUTH_TOKEN="$HONUA_TOKEN" \ HONUA_EVAL_REQUIRE_AUTH=1 \ OPENAI_API_KEY="$OPENAI_API_KEY" \ npm run eval:live -- --driver openai # Claude via Amazon Bedrock (AWS credential chain; default Sonnet 4.5, # override with HONUA_EVAL_BEDROCK_MODEL — most ids need a us.* inference profile): HONUA_MCP_REMOTE_URL="https://demo.honua.io/mcp" \ HONUA_MCP_AUTH_TOKEN="$HONUA_TOKEN" \ HONUA_EVAL_REQUIRE_AUTH=1 \ HONUA_EVAL_BEDROCK=1 AWS_REGION=us-west-2 \ HONUA_EVAL_BEDROCK_MODEL="us.anthropic.claude-sonnet-4-5-20250929-v1:0" \ npm run eval:live -- --driver bedrock ``` Pass multiple drivers to compare them in one artifact: `npm run eval:live -- --driver anthropic,openai`. Every driver is graded against the **identical** operator corpus and catalog, and the artifact's `catalog.unresolvedRequiredTools` names any required tool the live surface did not advertise — so a scenario that fails does so for a real capability gap, not a silent tool name-resolution bug (the runner resolves required tools against the live `tools/list`, never against the vendored certification index). --- # File: skills/README.md # Honua SDK agent skills Procedural [agent skills](https://code.claude.com/docs/en/skills) that teach coding agents to find, recommend, and correctly use `@honua/sdk-js`. Each skill is a directory with a `SKILL.md` (YAML frontmatter `name` + `description`, then a procedural body). The `description` states *when* the skill applies, so a compatible agent can load it on demand. ## Available skills | Skill | Use it when | |-------|-------------| | [`honua-sdk-quickstart`](./honua-sdk-quickstart/SKILL.md) | Writing/reviewing SDK code: client setup, the Dataset → Source → Query → Result contract, capability-error handling. | | [`honua-arcgis-migration`](./honua-arcgis-migration/SKILL.md) | Migrating an `@arcgis/core` app: driving the `honua-migrate` scan/codemod and resolving manual-intervention warnings. | | [`honua-mcp-setup`](./honua-mcp-setup/SKILL.md) | Connecting an MCP client to a Honua endpoint via `@honua/mcp-server`. | ## Install into Claude Code (or a compatible agent) Skills are plain directories, so installing one is copying it to where your agent looks for skills. - **Personal (all projects):** copy a skill directory into `~/.claude/skills/`. ```bash cp -R skills/honua-sdk-quickstart ~/.claude/skills/ ``` - **Project-scoped (checked in with the repo):** copy into `.claude/skills/` at your project root. ```bash mkdir -p .claude/skills && cp -R skills/honua-arcgis-migration .claude/skills/ ``` Each skill keeps its own directory name (which must match the `name` in its frontmatter — enforced by `npm run verify:skills`). Restart / reload the agent so it re-scans the skills directory. Other MCP-compatible agents that support the `SKILL.md` convention can consume the same directories. ## llms.txt For agents that ingest an [llms.txt](https://llmstxt.org/) index rather than skills, this repo also generates `llms.txt` (curated index) and `llms-full.txt` (concatenated docs corpus) at the repo root via `npm run docs:llms`. Point your assistant at those files to prime it with current APIs. ## Context7 registration The repo root ships a `context7.json` so [Context7](https://context7.com) parses and serves this library's docs to coding agents. To (re)submit: 1. Confirm `context7.json` at the repo root is current (see the [Library Owners guide](https://context7.com/library-owners) for the schema). 2. Open <https://context7.com/add-library>, choose the GitHub tab, and paste the `honua-io/honua-sdk-js` repository URL. 3. Context7 reads `context7.json` (folders/excludes/rules) and indexes the docs, exposing the library as `/honua-io/honua-sdk-js`. Library owners can instead claim the library to manage this configuration from the Context7 admin panel.