@honua/app-platform 0.0.20-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +11 -0
- package/app/index.d.ts +164 -0
- package/app/index.d.ts.map +1 -0
- package/app/index.js +424 -0
- package/app-controller/controller.d.ts +425 -0
- package/app-controller/controller.d.ts.map +1 -0
- package/app-controller/controller.js +1173 -0
- package/app-controller/index.d.ts +14 -0
- package/app-controller/index.d.ts.map +1 -0
- package/app-controller/index.js +12 -0
- package/app-workspace/index.d.ts +421 -0
- package/app-workspace/index.d.ts.map +1 -0
- package/app-workspace/index.js +722 -0
- package/collaboration/client.d.ts +40 -0
- package/collaboration/client.d.ts.map +1 -0
- package/collaboration/client.js +299 -0
- package/collaboration/errors.d.ts +15 -0
- package/collaboration/errors.d.ts.map +1 -0
- package/collaboration/errors.js +17 -0
- package/collaboration/fixture.d.ts +41 -0
- package/collaboration/fixture.d.ts.map +1 -0
- package/collaboration/fixture.js +362 -0
- package/collaboration/index.d.ts +18 -0
- package/collaboration/index.d.ts.map +1 -0
- package/collaboration/index.js +14 -0
- package/collaboration/types.d.ts +240 -0
- package/collaboration/types.d.ts.map +1 -0
- package/collaboration/types.js +1 -0
- package/contract/analytics-sources.d.ts +149 -0
- package/contract/analytics-sources.d.ts.map +1 -0
- package/contract/analytics-sources.js +235 -0
- package/contract/edit-session.d.ts +203 -0
- package/contract/edit-session.d.ts.map +1 -0
- package/contract/edit-session.js +760 -0
- package/contract/edit-sketch.d.ts +91 -0
- package/contract/edit-sketch.d.ts.map +1 -0
- package/contract/edit-sketch.js +281 -0
- package/contract/index.d.ts +63 -0
- package/contract/index.d.ts.map +1 -0
- package/contract/index.js +52 -0
- package/contract/jobs.d.ts +149 -0
- package/contract/jobs.d.ts.map +1 -0
- package/contract/jobs.js +40 -0
- package/contract/pmtiles.d.ts +162 -0
- package/contract/pmtiles.d.ts.map +1 -0
- package/contract/pmtiles.js +155 -0
- package/contract/source.d.ts +447 -0
- package/contract/source.d.ts.map +1 -0
- package/contract/source.js +3909 -0
- package/contract/spatial-aggregation.d.ts +336 -0
- package/contract/spatial-aggregation.d.ts.map +1 -0
- package/contract/spatial-aggregation.js +298 -0
- package/contract/temporal.d.ts +455 -0
- package/contract/temporal.d.ts.map +1 -0
- package/contract/temporal.js +146 -0
- package/contract/tiles.d.ts +370 -0
- package/contract/tiles.d.ts.map +1 -0
- package/contract/tiles.js +519 -0
- package/contract/types.d.ts +694 -0
- package/contract/types.d.ts.map +1 -0
- package/contract/types.js +183 -0
- package/contract/widget-source.d.ts +205 -0
- package/contract/widget-source.d.ts.map +1 -0
- package/contract/widget-source.js +1473 -0
- package/control-plane/client.d.ts +95 -0
- package/control-plane/client.d.ts.map +1 -0
- package/control-plane/client.js +374 -0
- package/control-plane/index.d.ts +18 -0
- package/control-plane/index.d.ts.map +1 -0
- package/control-plane/index.js +14 -0
- package/control-plane/types.d.ts +233 -0
- package/control-plane/types.d.ts.map +1 -0
- package/control-plane/types.js +7 -0
- package/controls/basemap-style-binding.d.ts +54 -0
- package/controls/basemap-style-binding.d.ts.map +1 -0
- package/controls/basemap-style-binding.js +199 -0
- package/controls/basemap-switcher.d.ts +95 -0
- package/controls/basemap-switcher.d.ts.map +1 -0
- package/controls/basemap-switcher.js +379 -0
- package/controls/element-utils.d.ts +48 -0
- package/controls/element-utils.d.ts.map +1 -0
- package/controls/element-utils.js +78 -0
- package/controls/index.d.ts +33 -0
- package/controls/index.d.ts.map +1 -0
- package/controls/index.js +30 -0
- package/controls/layer-list.d.ts +98 -0
- package/controls/layer-list.d.ts.map +1 -0
- package/controls/layer-list.js +375 -0
- package/controls/legend-derive.d.ts +53 -0
- package/controls/legend-derive.d.ts.map +1 -0
- package/controls/legend-derive.js +196 -0
- package/controls/legend.d.ts +104 -0
- package/controls/legend.d.ts.map +1 -0
- package/controls/legend.js +381 -0
- package/controls/swipe-control.d.ts +80 -0
- package/controls/swipe-control.d.ts.map +1 -0
- package/controls/swipe-control.js +368 -0
- package/controls/types.d.ts +227 -0
- package/controls/types.d.ts.map +1 -0
- package/controls/types.js +11 -0
- package/core/auth/client-credentials-provider.d.ts +51 -0
- package/core/auth/client-credentials-provider.d.ts.map +1 -0
- package/core/auth/client-credentials-provider.js +115 -0
- package/core/auth/credential-store.d.ts +52 -0
- package/core/auth/credential-store.d.ts.map +1 -0
- package/core/auth/credential-store.js +82 -0
- package/core/auth/index.d.ts +23 -0
- package/core/auth/index.d.ts.map +1 -0
- package/core/auth/index.js +20 -0
- package/core/auth/oauth2-provider.d.ts +114 -0
- package/core/auth/oauth2-provider.d.ts.map +1 -0
- package/core/auth/oauth2-provider.js +408 -0
- package/core/auth/pkce.d.ts +25 -0
- package/core/auth/pkce.d.ts.map +1 -0
- package/core/auth/pkce.js +46 -0
- package/core/auth/static-providers.d.ts +20 -0
- package/core/auth/static-providers.d.ts.map +1 -0
- package/core/auth/static-providers.js +26 -0
- package/core/auth/token-endpoint.d.ts +40 -0
- package/core/auth/token-endpoint.d.ts.map +1 -0
- package/core/auth/token-endpoint.js +133 -0
- package/core/auth/types.d.ts +144 -0
- package/core/auth/types.d.ts.map +1 -0
- package/core/auth/types.js +8 -0
- package/core/batch.d.ts +37 -0
- package/core/batch.d.ts.map +1 -0
- package/core/batch.js +79 -0
- package/core/cache-state.d.ts +79 -0
- package/core/cache-state.d.ts.map +1 -0
- package/core/cache-state.js +87 -0
- package/core/client.d.ts +607 -0
- package/core/client.d.ts.map +1 -0
- package/core/client.js +1716 -0
- package/core/compatibility.d.ts +18 -0
- package/core/compatibility.d.ts.map +1 -0
- package/core/compatibility.js +245 -0
- package/core/errors.d.ts +139 -0
- package/core/errors.d.ts.map +1 -0
- package/core/errors.js +176 -0
- package/core/esri-geojson.d.ts +66 -0
- package/core/esri-geojson.d.ts.map +1 -0
- package/core/esri-geojson.js +363 -0
- package/core/geoparquet-sql.d.ts +162 -0
- package/core/geoparquet-sql.d.ts.map +1 -0
- package/core/geoparquet-sql.js +333 -0
- package/core/geoservices.d.ts +34 -0
- package/core/geoservices.d.ts.map +1 -0
- package/core/geoservices.js +434 -0
- package/core/grpc-adapter.d.ts +23 -0
- package/core/grpc-adapter.d.ts.map +1 -0
- package/core/grpc-adapter.js +688 -0
- package/core/odata.d.ts +347 -0
- package/core/odata.d.ts.map +1 -0
- package/core/odata.js +913 -0
- package/core/ogc-conformance.d.ts +35 -0
- package/core/ogc-conformance.d.ts.map +1 -0
- package/core/ogc-conformance.js +134 -0
- package/core/ogc-endpoint-layout.d.ts +70 -0
- package/core/ogc-endpoint-layout.d.ts.map +1 -0
- package/core/ogc-endpoint-layout.js +173 -0
- package/core/ogc-features.d.ts +23 -0
- package/core/ogc-features.d.ts.map +1 -0
- package/core/ogc-features.js +129 -0
- package/core/ogc-maps.d.ts +49 -0
- package/core/ogc-maps.d.ts.map +1 -0
- package/core/ogc-maps.js +126 -0
- package/core/ogc-processes.d.ts +118 -0
- package/core/ogc-processes.d.ts.map +1 -0
- package/core/ogc-processes.js +423 -0
- package/core/ogc-records.d.ts +75 -0
- package/core/ogc-records.d.ts.map +1 -0
- package/core/ogc-records.js +251 -0
- package/core/ogc-tiles.d.ts +125 -0
- package/core/ogc-tiles.d.ts.map +1 -0
- package/core/ogc-tiles.js +183 -0
- package/core/path-utils.d.ts +69 -0
- package/core/path-utils.d.ts.map +1 -0
- package/core/path-utils.js +123 -0
- package/core/pbf-decoder.d.ts +39 -0
- package/core/pbf-decoder.d.ts.map +1 -0
- package/core/pbf-decoder.js +702 -0
- package/core/process-runner.d.ts +104 -0
- package/core/process-runner.d.ts.map +1 -0
- package/core/process-runner.js +315 -0
- package/core/protocol-transport.d.ts +76 -0
- package/core/protocol-transport.d.ts.map +1 -0
- package/core/protocol-transport.js +15 -0
- package/core/query-builder.d.ts +261 -0
- package/core/query-builder.d.ts.map +1 -0
- package/core/query-builder.js +538 -0
- package/core/request-pipeline.d.ts +88 -0
- package/core/request-pipeline.d.ts.map +1 -0
- package/core/request-pipeline.js +273 -0
- package/core/spatial-filter.d.ts +93 -0
- package/core/spatial-filter.d.ts.map +1 -0
- package/core/spatial-filter.js +153 -0
- package/core/stac-static.d.ts +51 -0
- package/core/stac-static.d.ts.map +1 -0
- package/core/stac-static.js +195 -0
- package/core/stac.d.ts +67 -0
- package/core/stac.d.ts.map +1 -0
- package/core/stac.js +259 -0
- package/core/surfaces.d.ts +540 -0
- package/core/surfaces.d.ts.map +1 -0
- package/core/surfaces.js +1964 -0
- package/core/terrain-elevation.d.ts +114 -0
- package/core/terrain-elevation.d.ts.map +1 -0
- package/core/terrain-elevation.js +150 -0
- package/core/types.d.ts +1404 -0
- package/core/types.d.ts.map +1 -0
- package/core/types.js +13 -0
- package/core/wfs-capabilities.d.ts +153 -0
- package/core/wfs-capabilities.d.ts.map +1 -0
- package/core/wfs-capabilities.js +490 -0
- package/core/wfs-filter.d.ts +118 -0
- package/core/wfs-filter.d.ts.map +1 -0
- package/core/wfs-filter.js +634 -0
- package/core/wfs.d.ts +232 -0
- package/core/wfs.d.ts.map +1 -0
- package/core/wfs.js +426 -0
- package/core/wire-shared.d.ts +32 -0
- package/core/wire-shared.d.ts.map +1 -0
- package/core/wire-shared.js +76 -0
- package/core/wms-axis.d.ts +36 -0
- package/core/wms-axis.d.ts.map +1 -0
- package/core/wms-axis.js +73 -0
- package/core/wms-capabilities.d.ts +110 -0
- package/core/wms-capabilities.d.ts.map +1 -0
- package/core/wms-capabilities.js +559 -0
- package/core/wms-types.d.ts +128 -0
- package/core/wms-types.d.ts.map +1 -0
- package/core/wms-types.js +32 -0
- package/core/wms.d.ts +144 -0
- package/core/wms.d.ts.map +1 -0
- package/core/wms.js +338 -0
- package/core/wmts-capabilities.d.ts +82 -0
- package/core/wmts-capabilities.d.ts.map +1 -0
- package/core/wmts-capabilities.js +402 -0
- package/core/wmts.d.ts +103 -0
- package/core/wmts.d.ts.map +1 -0
- package/core/wmts.js +332 -0
- package/core/xml-text.d.ts +20 -0
- package/core/xml-text.d.ts.map +1 -0
- package/core/xml-text.js +85 -0
- package/esri-compat/basemap-gallery.d.ts +38 -0
- package/esri-compat/basemap-gallery.d.ts.map +1 -0
- package/esri-compat/basemap-gallery.js +159 -0
- package/esri-compat/basemap-layer-list.d.ts +35 -0
- package/esri-compat/basemap-layer-list.d.ts.map +1 -0
- package/esri-compat/basemap-layer-list.js +139 -0
- package/esri-compat/basemap.d.ts +32 -0
- package/esri-compat/basemap.d.ts.map +1 -0
- package/esri-compat/basemap.js +84 -0
- package/esri-compat/bookmarks.d.ts +36 -0
- package/esri-compat/bookmarks.d.ts.map +1 -0
- package/esri-compat/bookmarks.js +108 -0
- package/esri-compat/class-breaks-renderer.d.ts +50 -0
- package/esri-compat/class-breaks-renderer.d.ts.map +1 -0
- package/esri-compat/class-breaks-renderer.js +164 -0
- package/esri-compat/color.d.ts +22 -0
- package/esri-compat/color.d.ts.map +1 -0
- package/esri-compat/color.js +106 -0
- package/esri-compat/controls.d.ts +174 -0
- package/esri-compat/controls.d.ts.map +1 -0
- package/esri-compat/controls.js +479 -0
- package/esri-compat/coordinate-conversion.d.ts +41 -0
- package/esri-compat/coordinate-conversion.d.ts.map +1 -0
- package/esri-compat/coordinate-conversion.js +125 -0
- package/esri-compat/directions.d.ts +45 -0
- package/esri-compat/directions.d.ts.map +1 -0
- package/esri-compat/directions.js +123 -0
- package/esri-compat/editor.d.ts +47 -0
- package/esri-compat/editor.d.ts.map +1 -0
- package/esri-compat/editor.js +170 -0
- package/esri-compat/esri-config.d.ts +16 -0
- package/esri-compat/esri-config.d.ts.map +1 -0
- package/esri-compat/esri-config.js +23 -0
- package/esri-compat/esri-request.d.ts +17 -0
- package/esri-compat/esri-request.d.ts.map +1 -0
- package/esri-compat/esri-request.js +127 -0
- package/esri-compat/event-bus.d.ts +106 -0
- package/esri-compat/event-bus.d.ts.map +1 -0
- package/esri-compat/event-bus.js +90 -0
- package/esri-compat/expand.d.ts +36 -0
- package/esri-compat/expand.d.ts.map +1 -0
- package/esri-compat/expand.js +97 -0
- package/esri-compat/extent.d.ts +43 -0
- package/esri-compat/extent.d.ts.map +1 -0
- package/esri-compat/extent.js +140 -0
- package/esri-compat/feature-filter.d.ts +55 -0
- package/esri-compat/feature-filter.d.ts.map +1 -0
- package/esri-compat/feature-filter.js +109 -0
- package/esri-compat/feature-form.d.ts +64 -0
- package/esri-compat/feature-form.d.ts.map +1 -0
- package/esri-compat/feature-form.js +137 -0
- package/esri-compat/feature-layer.d.ts +173 -0
- package/esri-compat/feature-layer.d.ts.map +1 -0
- package/esri-compat/feature-layer.js +661 -0
- package/esri-compat/feature-set.d.ts +32 -0
- package/esri-compat/feature-set.d.ts.map +1 -0
- package/esri-compat/feature-set.js +80 -0
- package/esri-compat/feature-table.d.ts +141 -0
- package/esri-compat/feature-table.d.ts.map +1 -0
- package/esri-compat/feature-table.js +500 -0
- package/esri-compat/feature-templates.d.ts +41 -0
- package/esri-compat/feature-templates.d.ts.map +1 -0
- package/esri-compat/feature-templates.js +97 -0
- package/esri-compat/feature.d.ts +33 -0
- package/esri-compat/feature.d.ts.map +1 -0
- package/esri-compat/feature.js +83 -0
- package/esri-compat/geojson-layer.d.ts +86 -0
- package/esri-compat/geojson-layer.d.ts.map +1 -0
- package/esri-compat/geojson-layer.js +191 -0
- package/esri-compat/geometry-engine.d.ts +107 -0
- package/esri-compat/geometry-engine.d.ts.map +1 -0
- package/esri-compat/geometry-engine.js +266 -0
- package/esri-compat/graphic.d.ts +46 -0
- package/esri-compat/graphic.d.ts.map +1 -0
- package/esri-compat/graphic.js +103 -0
- package/esri-compat/graphics-layer.d.ts +49 -0
- package/esri-compat/graphics-layer.d.ts.map +1 -0
- package/esri-compat/graphics-layer.js +185 -0
- package/esri-compat/group-layer.d.ts +48 -0
- package/esri-compat/group-layer.d.ts.map +1 -0
- package/esri-compat/group-layer.js +212 -0
- package/esri-compat/identify.d.ts +71 -0
- package/esri-compat/identify.d.ts.map +1 -0
- package/esri-compat/identify.js +299 -0
- package/esri-compat/identity-manager.d.ts +74 -0
- package/esri-compat/identity-manager.d.ts.map +1 -0
- package/esri-compat/identity-manager.js +245 -0
- package/esri-compat/imagery-layer.d.ts +78 -0
- package/esri-compat/imagery-layer.d.ts.map +1 -0
- package/esri-compat/imagery-layer.js +167 -0
- package/esri-compat/label-class.d.ts +31 -0
- package/esri-compat/label-class.d.ts.map +1 -0
- package/esri-compat/label-class.js +102 -0
- package/esri-compat/layer-list.d.ts +77 -0
- package/esri-compat/layer-list.d.ts.map +1 -0
- package/esri-compat/layer-list.js +342 -0
- package/esri-compat/legend.d.ts +53 -0
- package/esri-compat/legend.d.ts.map +1 -0
- package/esri-compat/legend.js +254 -0
- package/esri-compat/map-image-layer.d.ts +162 -0
- package/esri-compat/map-image-layer.d.ts.map +1 -0
- package/esri-compat/map-image-layer.js +709 -0
- package/esri-compat/map-view.d.ts +323 -0
- package/esri-compat/map-view.d.ts.map +1 -0
- package/esri-compat/map-view.js +1175 -0
- package/esri-compat/map.d.ts +47 -0
- package/esri-compat/map.d.ts.map +1 -0
- package/esri-compat/map.js +209 -0
- package/esri-compat/measurement-2d.d.ts +61 -0
- package/esri-compat/measurement-2d.d.ts.map +1 -0
- package/esri-compat/measurement-2d.js +184 -0
- package/esri-compat/measurement.d.ts +45 -0
- package/esri-compat/measurement.d.ts.map +1 -0
- package/esri-compat/measurement.js +193 -0
- package/esri-compat/oauth-info.d.ts +50 -0
- package/esri-compat/oauth-info.d.ts.map +1 -0
- package/esri-compat/oauth-info.js +142 -0
- package/esri-compat/picture-marker-symbol.d.ts +35 -0
- package/esri-compat/picture-marker-symbol.d.ts.map +1 -0
- package/esri-compat/picture-marker-symbol.js +126 -0
- package/esri-compat/point.d.ts +31 -0
- package/esri-compat/point.d.ts.map +1 -0
- package/esri-compat/point.js +102 -0
- package/esri-compat/polygon.d.ts +31 -0
- package/esri-compat/polygon.d.ts.map +1 -0
- package/esri-compat/polygon.js +107 -0
- package/esri-compat/polyline.d.ts +31 -0
- package/esri-compat/polyline.d.ts.map +1 -0
- package/esri-compat/polyline.js +107 -0
- package/esri-compat/popup-template.d.ts +55 -0
- package/esri-compat/popup-template.d.ts.map +1 -0
- package/esri-compat/popup-template.js +128 -0
- package/esri-compat/popup.d.ts +54 -0
- package/esri-compat/popup.d.ts.map +1 -0
- package/esri-compat/popup.js +312 -0
- package/esri-compat/print.d.ts +47 -0
- package/esri-compat/print.d.ts.map +1 -0
- package/esri-compat/print.js +105 -0
- package/esri-compat/query.d.ts +58 -0
- package/esri-compat/query.d.ts.map +1 -0
- package/esri-compat/query.js +128 -0
- package/esri-compat/reactive-utils.d.ts +24 -0
- package/esri-compat/reactive-utils.d.ts.map +1 -0
- package/esri-compat/reactive-utils.js +193 -0
- package/esri-compat/request.d.ts +41 -0
- package/esri-compat/request.d.ts.map +1 -0
- package/esri-compat/request.js +253 -0
- package/esri-compat/route-layer.d.ts +59 -0
- package/esri-compat/route-layer.d.ts.map +1 -0
- package/esri-compat/route-layer.js +223 -0
- package/esri-compat/route-task.d.ts +88 -0
- package/esri-compat/route-task.d.ts.map +1 -0
- package/esri-compat/route-task.js +225 -0
- package/esri-compat/scene-view.d.ts +41 -0
- package/esri-compat/scene-view.d.ts.map +1 -0
- package/esri-compat/scene-view.js +68 -0
- package/esri-compat/search.d.ts +112 -0
- package/esri-compat/search.d.ts.map +1 -0
- package/esri-compat/search.js +820 -0
- package/esri-compat/simple-fill-symbol.d.ts +27 -0
- package/esri-compat/simple-fill-symbol.d.ts.map +1 -0
- package/esri-compat/simple-fill-symbol.js +85 -0
- package/esri-compat/simple-line-symbol.d.ts +27 -0
- package/esri-compat/simple-line-symbol.d.ts.map +1 -0
- package/esri-compat/simple-line-symbol.js +85 -0
- package/esri-compat/simple-marker-symbol.d.ts +29 -0
- package/esri-compat/simple-marker-symbol.d.ts.map +1 -0
- package/esri-compat/simple-marker-symbol.js +92 -0
- package/esri-compat/simple-renderer.d.ts +29 -0
- package/esri-compat/simple-renderer.d.ts.map +1 -0
- package/esri-compat/simple-renderer.js +92 -0
- package/esri-compat/sketch.d.ts +64 -0
- package/esri-compat/sketch.d.ts.map +1 -0
- package/esri-compat/sketch.js +227 -0
- package/esri-compat/spatial-reference.d.ts +31 -0
- package/esri-compat/spatial-reference.d.ts.map +1 -0
- package/esri-compat/spatial-reference.js +102 -0
- package/esri-compat/swipe.d.ts +34 -0
- package/esri-compat/swipe.d.ts.map +1 -0
- package/esri-compat/swipe.js +93 -0
- package/esri-compat/table-list.d.ts +36 -0
- package/esri-compat/table-list.d.ts.map +1 -0
- package/esri-compat/table-list.js +126 -0
- package/esri-compat/text-symbol.d.ts +37 -0
- package/esri-compat/text-symbol.d.ts.map +1 -0
- package/esri-compat/text-symbol.js +123 -0
- package/esri-compat/tile-layer.d.ts +52 -0
- package/esri-compat/tile-layer.d.ts.map +1 -0
- package/esri-compat/tile-layer.js +168 -0
- package/esri-compat/time-slider.d.ts +70 -0
- package/esri-compat/time-slider.d.ts.map +1 -0
- package/esri-compat/time-slider.js +208 -0
- package/esri-compat/track.d.ts +51 -0
- package/esri-compat/track.d.ts.map +1 -0
- package/esri-compat/track.js +174 -0
- package/esri-compat/unique-value-renderer.d.ts +41 -0
- package/esri-compat/unique-value-renderer.d.ts.map +1 -0
- package/esri-compat/unique-value-renderer.js +127 -0
- package/esri-compat/url.d.ts +13 -0
- package/esri-compat/url.d.ts.map +1 -0
- package/esri-compat/url.js +68 -0
- package/esri-compat/vector-tile-layer.d.ts +47 -0
- package/esri-compat/vector-tile-layer.d.ts.map +1 -0
- package/esri-compat/vector-tile-layer.js +130 -0
- package/esri-compat/web-map.d.ts +14 -0
- package/esri-compat/web-map.d.ts.map +1 -0
- package/esri-compat/web-map.js +24 -0
- package/esri-compat/wfs-layer.d.ts +64 -0
- package/esri-compat/wfs-layer.d.ts.map +1 -0
- package/esri-compat/wfs-layer.js +145 -0
- package/esri-compat/wms-layer.d.ts +76 -0
- package/esri-compat/wms-layer.d.ts.map +1 -0
- package/esri-compat/wms-layer.js +157 -0
- package/exploration/context.d.ts +25 -0
- package/exploration/context.d.ts.map +1 -0
- package/exploration/context.js +396 -0
- package/exploration/index.d.ts +27 -0
- package/exploration/index.d.ts.map +1 -0
- package/exploration/index.js +24 -0
- package/exploration/presets.d.ts +27 -0
- package/exploration/presets.d.ts.map +1 -0
- package/exploration/presets.js +114 -0
- package/exploration/reducer.d.ts +23 -0
- package/exploration/reducer.d.ts.map +1 -0
- package/exploration/reducer.js +332 -0
- package/exploration/selection.d.ts +25 -0
- package/exploration/selection.d.ts.map +1 -0
- package/exploration/selection.js +34 -0
- package/exploration/selectors.d.ts +56 -0
- package/exploration/selectors.d.ts.map +1 -0
- package/exploration/selectors.js +110 -0
- package/exploration/types.d.ts +294 -0
- package/exploration/types.d.ts.map +1 -0
- package/exploration/types.js +33 -0
- package/expr/expression.d.ts +457 -0
- package/expr/expression.d.ts.map +1 -0
- package/expr/expression.js +663 -0
- package/expr/index.d.ts +32 -0
- package/expr/index.d.ts.map +1 -0
- package/expr/index.js +30 -0
- package/filter-registry/index.d.ts +145 -0
- package/filter-registry/index.d.ts.map +1 -0
- package/filter-registry/index.js +531 -0
- package/gen/honua/v1/feature_service_pb.d.ts +918 -0
- package/gen/honua/v1/feature_service_pb.d.ts.map +1 -0
- package/gen/honua/v1/feature_service_pb.js +375 -0
- package/generated-app/errors.d.ts +41 -0
- package/generated-app/errors.d.ts.map +1 -0
- package/generated-app/errors.js +41 -0
- package/generated-app/index.d.ts +17 -0
- package/generated-app/index.d.ts.map +1 -0
- package/generated-app/index.js +12 -0
- package/generated-app/manifest.d.ts +183 -0
- package/generated-app/manifest.d.ts.map +1 -0
- package/generated-app/manifest.js +14 -0
- package/generated-app/projection.d.ts +44 -0
- package/generated-app/projection.d.ts.map +1 -0
- package/generated-app/projection.js +234 -0
- package/generated-app/runtime.d.ts +215 -0
- package/generated-app/runtime.d.ts.map +1 -0
- package/generated-app/runtime.js +1196 -0
- package/geocoding/index.d.ts +106 -0
- package/geocoding/index.d.ts.map +1 -0
- package/geocoding/index.js +278 -0
- package/interactions/detail-model.d.ts +54 -0
- package/interactions/detail-model.d.ts.map +1 -0
- package/interactions/detail-model.js +105 -0
- package/interactions/exploration-bindings.d.ts +120 -0
- package/interactions/exploration-bindings.d.ts.map +1 -0
- package/interactions/exploration-bindings.js +218 -0
- package/interactions/feature-state.d.ts +144 -0
- package/interactions/feature-state.d.ts.map +1 -0
- package/interactions/feature-state.js +169 -0
- package/interactions/hit-test.d.ts +112 -0
- package/interactions/hit-test.d.ts.map +1 -0
- package/interactions/hit-test.js +291 -0
- package/interactions/index.d.ts +31 -0
- package/interactions/index.d.ts.map +1 -0
- package/interactions/index.js +24 -0
- package/interactions/share-state.d.ts +36 -0
- package/interactions/share-state.d.ts.map +1 -0
- package/interactions/share-state.js +123 -0
- package/map/feature-service-adapter.d.ts +138 -0
- package/map/feature-service-adapter.d.ts.map +1 -0
- package/map/feature-service-adapter.js +279 -0
- package/map/honua-map.d.ts +203 -0
- package/map/honua-map.d.ts.map +1 -0
- package/map/honua-map.js +347 -0
- package/map/index.d.ts +27 -0
- package/map/index.d.ts.map +1 -0
- package/map/index.js +22 -0
- package/map/maplibre-target.d.ts +103 -0
- package/map/maplibre-target.d.ts.map +1 -0
- package/map/maplibre-target.js +164 -0
- package/map/webmap-maplibre.d.ts +60 -0
- package/map/webmap-maplibre.d.ts.map +1 -0
- package/map/webmap-maplibre.js +212 -0
- package/operate/client.d.ts +94 -0
- package/operate/client.d.ts.map +1 -0
- package/operate/client.js +361 -0
- package/operate/index.d.ts +22 -0
- package/operate/index.d.ts.map +1 -0
- package/operate/index.js +19 -0
- package/operate/types.d.ts +397 -0
- package/operate/types.d.ts.map +1 -0
- package/operate/types.js +17 -0
- package/operator/client.d.ts +60 -0
- package/operator/client.d.ts.map +1 -0
- package/operator/client.js +20 -0
- package/operator/controllers/approval.d.ts +52 -0
- package/operator/controllers/approval.d.ts.map +1 -0
- package/operator/controllers/approval.js +123 -0
- package/operator/controllers/base.d.ts +27 -0
- package/operator/controllers/base.d.ts.map +1 -0
- package/operator/controllers/base.js +53 -0
- package/operator/controllers/builder-workspace.d.ts +55 -0
- package/operator/controllers/builder-workspace.d.ts.map +1 -0
- package/operator/controllers/builder-workspace.js +108 -0
- package/operator/controllers/chat.d.ts +61 -0
- package/operator/controllers/chat.d.ts.map +1 -0
- package/operator/controllers/chat.js +154 -0
- package/operator/controllers/clarification.d.ts +52 -0
- package/operator/controllers/clarification.d.ts.map +1 -0
- package/operator/controllers/clarification.js +121 -0
- package/operator/controllers/execution.d.ts +60 -0
- package/operator/controllers/execution.d.ts.map +1 -0
- package/operator/controllers/execution.js +286 -0
- package/operator/controllers/index.d.ts +23 -0
- package/operator/controllers/index.d.ts.map +1 -0
- package/operator/controllers/index.js +15 -0
- package/operator/controllers/map-workspace.d.ts +85 -0
- package/operator/controllers/map-workspace.d.ts.map +1 -0
- package/operator/controllers/map-workspace.js +296 -0
- package/operator/controllers/plan-review.d.ts +66 -0
- package/operator/controllers/plan-review.d.ts.map +1 -0
- package/operator/controllers/plan-review.js +124 -0
- package/operator/errors.d.ts +62 -0
- package/operator/errors.d.ts.map +1 -0
- package/operator/errors.js +75 -0
- package/operator/i18n/index.d.ts +10 -0
- package/operator/i18n/index.d.ts.map +1 -0
- package/operator/i18n/index.js +8 -0
- package/operator/i18n/messages.d.ts +19 -0
- package/operator/i18n/messages.d.ts.map +1 -0
- package/operator/i18n/messages.js +54 -0
- package/operator/index.d.ts +19 -0
- package/operator/index.d.ts.map +1 -0
- package/operator/index.js +15 -0
- package/operator/telemetry.d.ts +26 -0
- package/operator/telemetry.d.ts.map +1 -0
- package/operator/telemetry.js +9 -0
- package/operator/theming/index.d.ts +12 -0
- package/operator/theming/index.d.ts.map +1 -0
- package/operator/theming/index.js +9 -0
- package/operator/theming/provider.d.ts +27 -0
- package/operator/theming/provider.d.ts.map +1 -0
- package/operator/theming/provider.js +26 -0
- package/operator/theming/tokens.d.ts +28 -0
- package/operator/theming/tokens.d.ts.map +1 -0
- package/operator/theming/tokens.js +65 -0
- package/operator/workspace/events.d.ts +70 -0
- package/operator/workspace/events.d.ts.map +1 -0
- package/operator/workspace/events.js +9 -0
- package/operator/workspace/index.d.ts +12 -0
- package/operator/workspace/index.d.ts.map +1 -0
- package/operator/workspace/index.js +8 -0
- package/operator/workspace/types.d.ts +178 -0
- package/operator/workspace/types.d.ts.map +1 -0
- package/operator/workspace/types.js +47 -0
- package/operator/workspace/workspace.d.ts +58 -0
- package/operator/workspace/workspace.d.ts.map +1 -0
- package/operator/workspace/workspace.js +339 -0
- package/package.json +105 -0
- package/realtime/exploration.d.ts +16 -0
- package/realtime/exploration.d.ts.map +1 -0
- package/realtime/exploration.js +32 -0
- package/realtime/honua-server.d.ts +123 -0
- package/realtime/honua-server.d.ts.map +1 -0
- package/realtime/honua-server.js +188 -0
- package/realtime/index.d.ts +20 -0
- package/realtime/index.d.ts.map +1 -0
- package/realtime/index.js +14 -0
- package/realtime/projections.d.ts +30 -0
- package/realtime/projections.d.ts.map +1 -0
- package/realtime/projections.js +51 -0
- package/realtime/reducer.d.ts +14 -0
- package/realtime/reducer.d.ts.map +1 -0
- package/realtime/reducer.js +351 -0
- package/realtime/sse.d.ts +23 -0
- package/realtime/sse.d.ts.map +1 -0
- package/realtime/sse.js +143 -0
- package/realtime/store.d.ts +18 -0
- package/realtime/store.d.ts.map +1 -0
- package/realtime/store.js +63 -0
- package/realtime/types.d.ts +190 -0
- package/realtime/types.d.ts.map +1 -0
- package/realtime/types.js +10 -0
- package/replica-sync/client.d.ts +32 -0
- package/replica-sync/client.d.ts.map +1 -0
- package/replica-sync/client.js +52 -0
- package/replica-sync/errors.d.ts +22 -0
- package/replica-sync/errors.d.ts.map +1 -0
- package/replica-sync/errors.js +29 -0
- package/replica-sync/fixture.d.ts +40 -0
- package/replica-sync/fixture.d.ts.map +1 -0
- package/replica-sync/fixture.js +364 -0
- package/replica-sync/index.d.ts +22 -0
- package/replica-sync/index.d.ts.map +1 -0
- package/replica-sync/index.js +17 -0
- package/replica-sync/types.d.ts +270 -0
- package/replica-sync/types.d.ts.map +1 -0
- package/replica-sync/types.js +13 -0
- package/runtime/diff.d.ts +31 -0
- package/runtime/diff.d.ts.map +1 -0
- package/runtime/diff.js +143 -0
- package/runtime/errors.d.ts +40 -0
- package/runtime/errors.d.ts.map +1 -0
- package/runtime/errors.js +35 -0
- package/runtime/index.d.ts +59 -0
- package/runtime/index.d.ts.map +1 -0
- package/runtime/index.js +40 -0
- package/runtime/legend.d.ts +23 -0
- package/runtime/legend.d.ts.map +1 -0
- package/runtime/legend.js +38 -0
- package/runtime/load-package.d.ts +125 -0
- package/runtime/load-package.d.ts.map +1 -0
- package/runtime/load-package.js +272 -0
- package/runtime/map-package-fetch.d.ts +107 -0
- package/runtime/map-package-fetch.d.ts.map +1 -0
- package/runtime/map-package-fetch.js +336 -0
- package/runtime/map-package-realtime.d.ts +169 -0
- package/runtime/map-package-realtime.d.ts.map +1 -0
- package/runtime/map-package-realtime.js +350 -0
- package/runtime/map-package-validation.d.ts +30 -0
- package/runtime/map-package-validation.d.ts.map +1 -0
- package/runtime/map-package-validation.js +257 -0
- package/runtime/map-package-watch.d.ts +78 -0
- package/runtime/map-package-watch.d.ts.map +1 -0
- package/runtime/map-package-watch.js +695 -0
- package/runtime/map-package.d.ts +167 -0
- package/runtime/map-package.d.ts.map +1 -0
- package/runtime/map-package.js +15 -0
- package/runtime/ogc-styles.d.ts +110 -0
- package/runtime/ogc-styles.d.ts.map +1 -0
- package/runtime/ogc-styles.js +195 -0
- package/runtime/pmtiles-protocol.d.ts +77 -0
- package/runtime/pmtiles-protocol.d.ts.map +1 -0
- package/runtime/pmtiles-protocol.js +120 -0
- package/runtime/popups.d.ts +62 -0
- package/runtime/popups.d.ts.map +1 -0
- package/runtime/popups.js +95 -0
- package/runtime/query-tiles.d.ts +217 -0
- package/runtime/query-tiles.d.ts.map +1 -0
- package/runtime/query-tiles.js +801 -0
- package/runtime/runtime.d.ts +243 -0
- package/runtime/runtime.d.ts.map +1 -0
- package/runtime/runtime.js +1147 -0
- package/runtime/source-bridge.d.ts +99 -0
- package/runtime/source-bridge.d.ts.map +1 -0
- package/runtime/source-bridge.js +428 -0
- package/runtime/style-compose.d.ts +46 -0
- package/runtime/style-compose.d.ts.map +1 -0
- package/runtime/style-compose.js +144 -0
- package/runtime/style-interactions.d.ts +141 -0
- package/runtime/style-interactions.d.ts.map +1 -0
- package/runtime/style-interactions.js +574 -0
- package/runtime/style-spec-validation.d.ts +23 -0
- package/runtime/style-spec-validation.d.ts.map +1 -0
- package/runtime/style-spec-validation.js +357 -0
- package/scene-workspace/analysis-widgets.d.ts +334 -0
- package/scene-workspace/analysis-widgets.d.ts.map +1 -0
- package/scene-workspace/analysis-widgets.js +424 -0
- package/scene-workspace/cesium-adapter.d.ts +438 -0
- package/scene-workspace/cesium-adapter.d.ts.map +1 -0
- package/scene-workspace/cesium-adapter.js +493 -0
- package/scene-workspace/index.d.ts +21 -0
- package/scene-workspace/index.d.ts.map +1 -0
- package/scene-workspace/index.js +14 -0
- package/scene-workspace/primitives.d.ts +162 -0
- package/scene-workspace/primitives.d.ts.map +1 -0
- package/scene-workspace/primitives.js +255 -0
- package/scene-workspace/scene-discovery.d.ts +196 -0
- package/scene-workspace/scene-discovery.d.ts.map +1 -0
- package/scene-workspace/scene-discovery.js +228 -0
- package/scene-workspace/scene-view.d.ts +209 -0
- package/scene-workspace/scene-view.d.ts.map +1 -0
- package/scene-workspace/scene-view.js +267 -0
- package/scene-workspace/types.d.ts +229 -0
- package/scene-workspace/types.d.ts.map +1 -0
- package/scene-workspace/types.js +23 -0
- package/scene-workspace/workspace.d.ts +13 -0
- package/scene-workspace/workspace.d.ts.map +1 -0
- package/scene-workspace/workspace.js +391 -0
- package/share/denial.d.ts +78 -0
- package/share/denial.d.ts.map +1 -0
- package/share/denial.js +61 -0
- package/share/embed-token.d.ts +71 -0
- package/share/embed-token.d.ts.map +1 -0
- package/share/embed-token.js +99 -0
- package/share/index.d.ts +35 -0
- package/share/index.d.ts.map +1 -0
- package/share/index.js +30 -0
- package/share/open-data.d.ts +114 -0
- package/share/open-data.d.ts.map +1 -0
- package/share/open-data.js +81 -0
- package/share/types.d.ts +163 -0
- package/share/types.d.ts.map +1 -0
- package/share/types.js +17 -0
- package/studio/capability-manifest.d.ts +285 -0
- package/studio/capability-manifest.d.ts.map +1 -0
- package/studio/capability-manifest.js +59 -0
- package/studio/chart-spec.d.ts +85 -0
- package/studio/chart-spec.d.ts.map +1 -0
- package/studio/chart-spec.js +106 -0
- package/studio/index.d.ts +34 -0
- package/studio/index.d.ts.map +1 -0
- package/studio/index.js +27 -0
- package/studio/provenance.d.ts +131 -0
- package/studio/provenance.d.ts.map +1 -0
- package/studio/provenance.js +39 -0
- package/studio/publish.d.ts +64 -0
- package/studio/publish.d.ts.map +1 -0
- package/studio/publish.js +13 -0
- package/studio/types.d.ts +278 -0
- package/studio/types.d.ts.map +1 -0
- package/studio/types.js +71 -0
- package/studio/validate.d.ts +45 -0
- package/studio/validate.d.ts.map +1 -0
- package/studio/validate.js +255 -0
- package/studio/validation.d.ts +112 -0
- package/studio/validation.d.ts.map +1 -0
- package/studio/validation.js +42 -0
- package/style/factory.d.ts +30 -0
- package/style/factory.d.ts.map +1 -0
- package/style/factory.js +61 -0
- package/style/index.d.ts +25 -0
- package/style/index.d.ts.map +1 -0
- package/style/index.js +24 -0
- package/style/specification.d.ts +203 -0
- package/style/specification.d.ts.map +1 -0
- package/style/specification.js +178 -0
- package/style/wms-wmts-resolvers.d.ts +38 -0
- package/style/wms-wmts-resolvers.d.ts.map +1 -0
- package/style/wms-wmts-resolvers.js +92 -0
- package/web-components/controller.d.ts +39 -0
- package/web-components/controller.d.ts.map +1 -0
- package/web-components/controller.js +640 -0
- package/web-components/elements.d.ts +210 -0
- package/web-components/elements.d.ts.map +1 -0
- package/web-components/elements.js +1651 -0
- package/web-components/index.d.ts +24 -0
- package/web-components/index.d.ts.map +1 -0
- package/web-components/index.js +22 -0
- package/web-components/maplibre-renderer.d.ts +22 -0
- package/web-components/maplibre-renderer.d.ts.map +1 -0
- package/web-components/maplibre-renderer.js +474 -0
- package/web-components/types.d.ts +397 -0
- package/web-components/types.d.ts.map +1 -0
- package/web-components/types.js +1 -0
- package/webmap/contract.d.ts +28 -0
- package/webmap/contract.d.ts.map +1 -0
- package/webmap/contract.js +130 -0
- package/webmap/convert-basemap.d.ts +17 -0
- package/webmap/convert-basemap.d.ts.map +1 -0
- package/webmap/convert-basemap.js +78 -0
- package/webmap/convert-extent.d.ts +17 -0
- package/webmap/convert-extent.d.ts.map +1 -0
- package/webmap/convert-extent.js +114 -0
- package/webmap/convert-label.d.ts +13 -0
- package/webmap/convert-label.d.ts.map +1 -0
- package/webmap/convert-label.js +125 -0
- package/webmap/convert-layer.d.ts +22 -0
- package/webmap/convert-layer.d.ts.map +1 -0
- package/webmap/convert-layer.js +181 -0
- package/webmap/convert-popup.d.ts +34 -0
- package/webmap/convert-popup.d.ts.map +1 -0
- package/webmap/convert-popup.js +68 -0
- package/webmap/convert-renderer.d.ts +17 -0
- package/webmap/convert-renderer.d.ts.map +1 -0
- package/webmap/convert-renderer.js +161 -0
- package/webmap/convert-symbol.d.ts +17 -0
- package/webmap/convert-symbol.d.ts.map +1 -0
- package/webmap/convert-symbol.js +122 -0
- package/webmap/index.d.ts +44 -0
- package/webmap/index.d.ts.map +1 -0
- package/webmap/index.js +33 -0
- package/webmap/parse.d.ts +25 -0
- package/webmap/parse.d.ts.map +1 -0
- package/webmap/parse.js +83 -0
- package/webmap/types.d.ts +264 -0
- package/webmap/types.d.ts.map +1 -0
- package/webmap/types.js +13 -0
- package/webmap/warnings.d.ts +30 -0
- package/webmap/warnings.d.ts.map +1 -0
- package/webmap/warnings.js +37 -0
|
@@ -0,0 +1,3909 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in `Source` adapters that wrap the runtime classes in
|
|
3
|
+
* `src/core/surfaces.ts` (`HonuaFeatureLayer`, `HonuaMapLayer`,
|
|
4
|
+
* `HonuaOgcFeatureCollection`) and the OGC / STAC / WFS / WMS / OData
|
|
5
|
+
* adapters in `src/core/`. Consumers register adapters for any other
|
|
6
|
+
* protocol (MapLibre-native sources, etc.) via
|
|
7
|
+
* `CreateDatasetOptions.resolveSource`.
|
|
8
|
+
*
|
|
9
|
+
* These adapters do not reimplement query logic — they translate the
|
|
10
|
+
* canonical `Query` / `Result` envelope to and from the existing per-class
|
|
11
|
+
* request and response shapes.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
import { HonuaCapabilityNotSupportedError, HonuaHttpError } from "../core/errors.js";
|
|
16
|
+
import { HonuaOdataEntitySet, buildOdataSpatialFilter, odataFieldSchema, rewriteWhereToOdataFilter, } from "../core/odata.js";
|
|
17
|
+
import { HonuaOgcCollectionMap, HonuaOgcMaps } from "../core/ogc-maps.js";
|
|
18
|
+
import { HonuaOgcRecordCollection } from "../core/ogc-records.js";
|
|
19
|
+
import { HonuaOgcTiles, HonuaOgcTileset } from "../core/ogc-tiles.js";
|
|
20
|
+
import { trimTrailingSlashes } from "../core/path-utils.js";
|
|
21
|
+
import { HonuaStacStaticCatalog } from "../core/stac-static.js";
|
|
22
|
+
import { HonuaStacSearch } from "../core/stac.js";
|
|
23
|
+
import { HonuaFeatureLayer, HonuaGeometryService, HonuaGeoprocessingService, HonuaImageService, HonuaMapLayer, HonuaMapService, HonuaOgcFeatureCollection, HonuaOgcFeatures, } from "../core/surfaces.js";
|
|
24
|
+
import { UNSUPPORTED_FES, compileSpatialFilter, compileWhere, geoJsonGeometryToGml, serializeFes, } from "../core/wfs-filter.js";
|
|
25
|
+
import { HonuaWfs, HonuaWfsFeatureType } from "../core/wfs.js";
|
|
26
|
+
import { HonuaWms, HonuaWmsLayer, parseWmsLayerNames } from "../core/wms.js";
|
|
27
|
+
import { HonuaWmts, HonuaWmtsLayer, HonuaWmtsTileset } from "../core/wmts.js";
|
|
28
|
+
import { HonuaPmtilesArchive, stripPmtilesScheme } from "./pmtiles.js";
|
|
29
|
+
import { CAPABILITIES, PROTOCOL_DEFAULT_CAPABILITIES, } from "./types.js";
|
|
30
|
+
/**
|
|
31
|
+
* Construct a protocol-neutral `Dataset` over a `HonuaClient`.
|
|
32
|
+
*
|
|
33
|
+
* A `Dataset` groups one or more `Source`s. Each `Source` accepts a protocol-neutral
|
|
34
|
+
* `Query` and returns a protocol-neutral `Result`. The dataset is the cross-protocol
|
|
35
|
+
* unit of work: a single `Dataset` can mix GeoServices FeatureServer, OGC API Features,
|
|
36
|
+
* WFS, STAC, and OData sources under one capability policy.
|
|
37
|
+
*
|
|
38
|
+
* `Source` handles are constructed lazily, server compatibility is checked once per
|
|
39
|
+
* dataset (and cached on the underlying client), and capability gaps surface as
|
|
40
|
+
* {@link HonuaCapabilityNotSupportedError} under the default `"strict"` policy rather
|
|
41
|
+
* than silently returning empty results.
|
|
42
|
+
*
|
|
43
|
+
* Operations the canonical surface does not cover stay reachable through the typed
|
|
44
|
+
* `source.protocol(...)` escape hatch (e.g. ImageServer `exportImage`, GeometryServer
|
|
45
|
+
* `project`, GPServer `submitJob`, WFS `LockFeature`).
|
|
46
|
+
*
|
|
47
|
+
* @param options - The dataset id, the `HonuaClient` to talk to, the list of
|
|
48
|
+
* `SourceDescriptor`s, and (optionally) `capabilityPolicy` and a custom
|
|
49
|
+
* `resolveSource` factory.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```ts
|
|
53
|
+
* import { createDataset, PROTOCOL_DEFAULT_CAPABILITIES } from "@honua/sdk-js/contract";
|
|
54
|
+
* import { HonuaClient } from "@honua/sdk-js/honua";
|
|
55
|
+
*
|
|
56
|
+
* const client = new HonuaClient({ baseUrl: "https://your-honua-server.example" });
|
|
57
|
+
*
|
|
58
|
+
* const dataset = createDataset({
|
|
59
|
+
* id: "parcels",
|
|
60
|
+
* client,
|
|
61
|
+
* sources: [
|
|
62
|
+
* {
|
|
63
|
+
* id: "parcels-fs",
|
|
64
|
+
* protocol: "geoservices-feature-service",
|
|
65
|
+
* locator: { url: "https://your-honua-server.example", serviceId: "parcels", layerId: 0 },
|
|
66
|
+
* capabilities: PROTOCOL_DEFAULT_CAPABILITIES["geoservices-feature-service"],
|
|
67
|
+
* },
|
|
68
|
+
* ],
|
|
69
|
+
* });
|
|
70
|
+
*
|
|
71
|
+
* const parcels = dataset.source("parcels-fs")!;
|
|
72
|
+
* const result = await parcels.queryAll({
|
|
73
|
+
* where: "STATUS = 'ACTIVE'",
|
|
74
|
+
* outFields: ["OBJECTID", "NAME"],
|
|
75
|
+
* returnGeometry: true,
|
|
76
|
+
* pagination: { limit: 500 },
|
|
77
|
+
* });
|
|
78
|
+
*
|
|
79
|
+
* console.log(`Loaded ${result.features.length} features`);
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
export function createDataset(options) {
|
|
83
|
+
const { id, client, sources, resolveSource } = options;
|
|
84
|
+
const policy = options.capabilityPolicy ?? "strict";
|
|
85
|
+
const descriptors = new Map();
|
|
86
|
+
const handles = new Map();
|
|
87
|
+
let compatibilityPromise;
|
|
88
|
+
for (const descriptor of sources) {
|
|
89
|
+
if (descriptors.has(descriptor.id)) {
|
|
90
|
+
throw new Error(`createDataset: duplicate source id "${descriptor.id}"`);
|
|
91
|
+
}
|
|
92
|
+
descriptors.set(descriptor.id, descriptor);
|
|
93
|
+
}
|
|
94
|
+
function resolve(descriptor) {
|
|
95
|
+
const cached = handles.get(descriptor.id);
|
|
96
|
+
if (cached)
|
|
97
|
+
return cached;
|
|
98
|
+
const built = buildBuiltInSource(descriptor, client, policy) ??
|
|
99
|
+
resolveSource?.(descriptor, { client, capabilityPolicy: policy });
|
|
100
|
+
if (!built) {
|
|
101
|
+
throw new HonuaCapabilityNotSupportedError("query", descriptor.protocol, descriptor.id);
|
|
102
|
+
}
|
|
103
|
+
handles.set(descriptor.id, built);
|
|
104
|
+
return built;
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
id,
|
|
108
|
+
client,
|
|
109
|
+
sourceDescriptors: [...descriptors.values()],
|
|
110
|
+
source(sourceId) {
|
|
111
|
+
const descriptor = descriptors.get(sourceId);
|
|
112
|
+
if (!descriptor)
|
|
113
|
+
return undefined;
|
|
114
|
+
return resolve(descriptor);
|
|
115
|
+
},
|
|
116
|
+
sourceIds() {
|
|
117
|
+
return [...descriptors.keys()];
|
|
118
|
+
},
|
|
119
|
+
isCompatible() {
|
|
120
|
+
if (options.skipCompatibilityCheck)
|
|
121
|
+
return Promise.resolve(true);
|
|
122
|
+
if (!compatibilityPromise) {
|
|
123
|
+
compatibilityPromise = client
|
|
124
|
+
.checkCompatibility()
|
|
125
|
+
.then((status) => status.supported)
|
|
126
|
+
.catch(() => false);
|
|
127
|
+
}
|
|
128
|
+
return compatibilityPromise;
|
|
129
|
+
},
|
|
130
|
+
supportsFeature(feature) {
|
|
131
|
+
return client.supportsFeature(feature).catch(() => false);
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
// ── Built-in resolver ─────────────────────────────────────────
|
|
136
|
+
function buildBuiltInSource(descriptor, client, policy) {
|
|
137
|
+
switch (descriptor.protocol) {
|
|
138
|
+
case "geoservices-feature-service":
|
|
139
|
+
return geoServicesFeatureSource(descriptor, client, policy);
|
|
140
|
+
case "geoservices-map-service":
|
|
141
|
+
return geoServicesMapServiceSource(descriptor, client, policy);
|
|
142
|
+
case "geoservices-image-service":
|
|
143
|
+
return geoServicesImageSource(descriptor, client, policy);
|
|
144
|
+
case "geoservices-geometry-service":
|
|
145
|
+
return geoServicesGeometryServiceSource(descriptor, client, policy);
|
|
146
|
+
case "geoservices-gp-service":
|
|
147
|
+
return geoServicesGPServiceSource(descriptor, client, policy);
|
|
148
|
+
case "ogc-features":
|
|
149
|
+
return ogcFeaturesSource(descriptor, client, policy);
|
|
150
|
+
case "ogc-tiles":
|
|
151
|
+
return ogcTilesSource(descriptor, client, policy);
|
|
152
|
+
case "ogc-maps":
|
|
153
|
+
return ogcMapsSource(descriptor, client, policy);
|
|
154
|
+
case "ogc-records":
|
|
155
|
+
return ogcRecordsSource(descriptor, client, policy);
|
|
156
|
+
case "wms":
|
|
157
|
+
return wmsSource(descriptor, client, policy);
|
|
158
|
+
case "wmts":
|
|
159
|
+
return wmtsSource(descriptor, client, policy);
|
|
160
|
+
case "stac":
|
|
161
|
+
return stacSearchSource(descriptor, client, policy);
|
|
162
|
+
case "wfs":
|
|
163
|
+
return wfsSource(descriptor, client, policy);
|
|
164
|
+
case "odata":
|
|
165
|
+
return odataSource(descriptor, client, policy);
|
|
166
|
+
case "pmtiles":
|
|
167
|
+
return pmtilesSource(descriptor, client, policy);
|
|
168
|
+
default:
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// ── GeoServices Feature Service ───────────────────────────────
|
|
173
|
+
/**
|
|
174
|
+
* Adapter factory for a GeoServices FeatureServer layer.
|
|
175
|
+
*
|
|
176
|
+
* Used internally by {@link createDataset} when it sees a descriptor with
|
|
177
|
+
* `protocol: "geoservices-feature-service"`. Calling it directly is rarely
|
|
178
|
+
* necessary; pass the descriptor to `createDataset` instead.
|
|
179
|
+
*
|
|
180
|
+
* @example
|
|
181
|
+
* ```ts
|
|
182
|
+
* const dataset = createDataset({
|
|
183
|
+
* id: "parcels",
|
|
184
|
+
* client,
|
|
185
|
+
* sources: [{
|
|
186
|
+
* id: "parcels-fs",
|
|
187
|
+
* protocol: "geoservices-feature-service",
|
|
188
|
+
* locator: { url: "https://your-honua-server.example", serviceId: "parcels", layerId: 0 },
|
|
189
|
+
* capabilities: PROTOCOL_DEFAULT_CAPABILITIES["geoservices-feature-service"],
|
|
190
|
+
* }],
|
|
191
|
+
* });
|
|
192
|
+
* const result = await dataset.source("parcels-fs")!.queryAll({
|
|
193
|
+
* where: "STATUS = 'ACTIVE'",
|
|
194
|
+
* outFields: ["OBJECTID", "NAME"],
|
|
195
|
+
* });
|
|
196
|
+
* ```
|
|
197
|
+
*/
|
|
198
|
+
export function geoServicesFeatureSource(descriptor, client, policy) {
|
|
199
|
+
const { serviceId, layerId } = requireFeatureServiceLocator(descriptor);
|
|
200
|
+
const layer = new HonuaFeatureLayer({ client, serviceId, layerId });
|
|
201
|
+
const caps = descriptor.capabilities ?? PROTOCOL_DEFAULT_CAPABILITIES["geoservices-feature-service"];
|
|
202
|
+
const adapterRegistry = {
|
|
203
|
+
"geoservices-feature-service": layer,
|
|
204
|
+
};
|
|
205
|
+
return makeSource(descriptor, caps, policy, adapterRegistry, {
|
|
206
|
+
async query(request) {
|
|
207
|
+
ensureCapability(descriptor, caps, "query");
|
|
208
|
+
const requestParams = toFeatureLayerRequest(request);
|
|
209
|
+
if (request?.aggregation) {
|
|
210
|
+
ensureCapability(descriptor, caps, "queryAggregate");
|
|
211
|
+
const { aggregateAlias, ...aggFields } = buildAggregationFields(request.aggregation);
|
|
212
|
+
return aggregateResultFromFeatureLayer(await layer.queryFeatures({ ...requestParams, ...aggFields }), aggregateAlias);
|
|
213
|
+
}
|
|
214
|
+
const response = await layer.queryFeatures(requestParams);
|
|
215
|
+
return featureLayerResultFromTyped(response);
|
|
216
|
+
},
|
|
217
|
+
async queryAll(request) {
|
|
218
|
+
ensureCapability(descriptor, caps, "query");
|
|
219
|
+
const params = withPagingBounds(toFeatureLayerRequest(request), request?.pagination?.limit);
|
|
220
|
+
const features = await layer.queryFeaturesAll(params);
|
|
221
|
+
const { features: limited, exceededTransferLimit } = applyQueryAllLimit(features, request?.pagination?.limit);
|
|
222
|
+
return {
|
|
223
|
+
features: limited,
|
|
224
|
+
exceededTransferLimit,
|
|
225
|
+
totalCount: limited.length,
|
|
226
|
+
};
|
|
227
|
+
},
|
|
228
|
+
async queryAggregate(request) {
|
|
229
|
+
ensureCapability(descriptor, caps, "queryAggregate");
|
|
230
|
+
const requestParams = toFeatureLayerRequest(request);
|
|
231
|
+
const { aggregateAlias, ...aggFields } = buildAggregationFields(request.aggregation);
|
|
232
|
+
const response = await layer.queryFeatures({ ...requestParams, ...aggFields });
|
|
233
|
+
return aggregateResultFromFeatureLayer(response, aggregateAlias);
|
|
234
|
+
},
|
|
235
|
+
async queryExtent(request) {
|
|
236
|
+
ensureCapability(descriptor, caps, "queryExtent");
|
|
237
|
+
const response = await layer.queryFeatures(toExtentOnlyRequest(toFeatureLayerRequest(request)));
|
|
238
|
+
return extractExtentEnvelope(response);
|
|
239
|
+
},
|
|
240
|
+
async *stream(request) {
|
|
241
|
+
ensureCapability(descriptor, caps, "stream");
|
|
242
|
+
const stream = layer.queryFeaturesStream(withStreamPageSize(toFeatureLayerRequest(request), request?.pagination?.limit));
|
|
243
|
+
for await (const page of stream) {
|
|
244
|
+
yield {
|
|
245
|
+
features: page,
|
|
246
|
+
exceededTransferLimit: false,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
async queryObjectIds(request) {
|
|
251
|
+
ensureCapability(descriptor, caps, "queryObjectIds");
|
|
252
|
+
return layer.queryObjectIds(toFeatureLayerRequest(request));
|
|
253
|
+
},
|
|
254
|
+
async applyEdits(envelope) {
|
|
255
|
+
ensureCapability(descriptor, caps, "applyEdits");
|
|
256
|
+
const response = await layer.applyEdits(toApplyEditsRequest(envelope));
|
|
257
|
+
return canonicalEditResult(response);
|
|
258
|
+
},
|
|
259
|
+
async queryRelated(request) {
|
|
260
|
+
ensureCapability(descriptor, caps, "queryRelated");
|
|
261
|
+
const response = await layer.queryRelatedRecords(toRelatedRecordsRequest(request));
|
|
262
|
+
return canonicalRelatedResult(response);
|
|
263
|
+
},
|
|
264
|
+
attachments: featureLayerAttachmentApi(descriptor, caps, layer),
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
// ── GeoServices Map Service / Map Layer ───────────────────────
|
|
268
|
+
/**
|
|
269
|
+
* Adapter factory for a GeoServices MapServer layer.
|
|
270
|
+
*
|
|
271
|
+
* Reach `Source.protocol("geoservices-map-service")` for raw `exportImage` /
|
|
272
|
+
* `identify` / `find` access; the canonical `query()` runs against a single
|
|
273
|
+
* sublayer via the MapServer query endpoint.
|
|
274
|
+
*
|
|
275
|
+
* @example
|
|
276
|
+
* ```ts
|
|
277
|
+
* const dataset = createDataset({
|
|
278
|
+
* id: "basemap",
|
|
279
|
+
* client,
|
|
280
|
+
* sources: [{
|
|
281
|
+
* id: "states-mapserver",
|
|
282
|
+
* protocol: "geoservices-map-service",
|
|
283
|
+
* locator: { url: "https://your-honua-server.example", serviceId: "states", layerId: 0 },
|
|
284
|
+
* capabilities: PROTOCOL_DEFAULT_CAPABILITIES["geoservices-map-service"],
|
|
285
|
+
* }],
|
|
286
|
+
* });
|
|
287
|
+
* ```
|
|
288
|
+
*/
|
|
289
|
+
export function geoServicesMapServiceSource(descriptor, client, policy) {
|
|
290
|
+
const { serviceId, layerId } = requireMapServiceLocator(descriptor);
|
|
291
|
+
const service = new HonuaMapService({ client, serviceId });
|
|
292
|
+
const layer = new HonuaMapLayer({ client, serviceId, layerId });
|
|
293
|
+
const caps = descriptor.capabilities ?? PROTOCOL_DEFAULT_CAPABILITIES["geoservices-map-service"];
|
|
294
|
+
const adapterRegistry = {
|
|
295
|
+
"geoservices-map-service": service,
|
|
296
|
+
"geoservices-map-layer": layer,
|
|
297
|
+
};
|
|
298
|
+
return makeSource(descriptor, caps, policy, adapterRegistry, {
|
|
299
|
+
async query(request) {
|
|
300
|
+
ensureCapability(descriptor, caps, "query");
|
|
301
|
+
const params = toFeatureLayerRequest(request);
|
|
302
|
+
if (request?.aggregation) {
|
|
303
|
+
ensureCapability(descriptor, caps, "queryAggregate");
|
|
304
|
+
const { aggregateAlias, ...aggFields } = buildAggregationFields(request.aggregation);
|
|
305
|
+
return aggregateResultFromUntyped(await layer.queryFeatures({ ...params, ...aggFields }), aggregateAlias);
|
|
306
|
+
}
|
|
307
|
+
const response = await layer.queryFeatures(params);
|
|
308
|
+
return featureLayerResultFromUntyped(response);
|
|
309
|
+
},
|
|
310
|
+
async queryAll(request) {
|
|
311
|
+
ensureCapability(descriptor, caps, "query");
|
|
312
|
+
const params = withPagingBounds(toFeatureLayerRequest(request), request?.pagination?.limit);
|
|
313
|
+
const features = await layer.queryFeaturesAll(params);
|
|
314
|
+
const typed = features.map((toTypedFeature));
|
|
315
|
+
const { features: limited, exceededTransferLimit } = applyQueryAllLimit(typed, request?.pagination?.limit);
|
|
316
|
+
return {
|
|
317
|
+
features: limited,
|
|
318
|
+
exceededTransferLimit,
|
|
319
|
+
totalCount: limited.length,
|
|
320
|
+
};
|
|
321
|
+
},
|
|
322
|
+
async queryAggregate(request) {
|
|
323
|
+
ensureCapability(descriptor, caps, "queryAggregate");
|
|
324
|
+
const params = toFeatureLayerRequest(request);
|
|
325
|
+
const { aggregateAlias, ...aggFields } = buildAggregationFields(request.aggregation);
|
|
326
|
+
const response = await layer.queryFeatures({ ...params, ...aggFields });
|
|
327
|
+
return aggregateResultFromUntyped(response, aggregateAlias);
|
|
328
|
+
},
|
|
329
|
+
async queryExtent(request) {
|
|
330
|
+
ensureCapability(descriptor, caps, "queryExtent");
|
|
331
|
+
const response = await layer.queryFeatures(toExtentOnlyRequest(toFeatureLayerRequest(request)));
|
|
332
|
+
return extractExtentEnvelope(response);
|
|
333
|
+
},
|
|
334
|
+
async *stream(request) {
|
|
335
|
+
ensureCapability(descriptor, caps, "stream");
|
|
336
|
+
const stream = layer.queryFeaturesStream(withStreamPageSize(toFeatureLayerRequest(request), request?.pagination?.limit));
|
|
337
|
+
for await (const page of stream) {
|
|
338
|
+
yield {
|
|
339
|
+
features: page.map((toTypedFeature)),
|
|
340
|
+
exceededTransferLimit: false,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
async queryObjectIds(request) {
|
|
345
|
+
ensureCapability(descriptor, caps, "queryObjectIds");
|
|
346
|
+
return layer.queryObjectIds(toFeatureLayerRequest(request));
|
|
347
|
+
},
|
|
348
|
+
async applyEdits() {
|
|
349
|
+
// MapServer is read-only; applyEdits exists only on the FeatureServer
|
|
350
|
+
// surface. Refuse rather than silently no-op so callers can branch.
|
|
351
|
+
throw new HonuaCapabilityNotSupportedError("applyEdits", descriptor.protocol, descriptor.id);
|
|
352
|
+
},
|
|
353
|
+
async queryRelated(request) {
|
|
354
|
+
ensureCapability(descriptor, caps, "queryRelated");
|
|
355
|
+
const response = await layer.queryRelatedRecords(toRelatedRecordsRequest(request));
|
|
356
|
+
return canonicalRelatedResult(response);
|
|
357
|
+
},
|
|
358
|
+
attachments: unsupportedAttachmentApi(descriptor),
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
// ── OGC API Features ──────────────────────────────────────────
|
|
362
|
+
/**
|
|
363
|
+
* Adapter factory for an OGC API Features collection.
|
|
364
|
+
*
|
|
365
|
+
* @example
|
|
366
|
+
* ```ts
|
|
367
|
+
* const dataset = createDataset({
|
|
368
|
+
* id: "addresses",
|
|
369
|
+
* client,
|
|
370
|
+
* sources: [{
|
|
371
|
+
* id: "addresses-ogc",
|
|
372
|
+
* protocol: "ogc-features",
|
|
373
|
+
* locator: { url: "https://your-honua-server.example", collectionId: "addresses" },
|
|
374
|
+
* capabilities: PROTOCOL_DEFAULT_CAPABILITIES["ogc-features"],
|
|
375
|
+
* }],
|
|
376
|
+
* });
|
|
377
|
+
* const result = await dataset.source("addresses-ogc")!.queryAll({
|
|
378
|
+
* where: "city = 'Honolulu'",
|
|
379
|
+
* pagination: { limit: 200 },
|
|
380
|
+
* });
|
|
381
|
+
* ```
|
|
382
|
+
*/
|
|
383
|
+
export function ogcFeaturesSource(descriptor, client, policy) {
|
|
384
|
+
const { collectionId } = requireOgcLocator(descriptor);
|
|
385
|
+
const layoutMode = ogcFeaturesLayoutMode(descriptor.locator.layout);
|
|
386
|
+
const collection = new HonuaOgcFeatureCollection({
|
|
387
|
+
client,
|
|
388
|
+
collectionId,
|
|
389
|
+
...(layoutMode ? { layout: layoutMode } : {}),
|
|
390
|
+
});
|
|
391
|
+
const root = new HonuaOgcFeatures({ client, ...(layoutMode ? { layout: layoutMode } : {}) });
|
|
392
|
+
const caps = descriptor.capabilities ?? PROTOCOL_DEFAULT_CAPABILITIES["ogc-features"];
|
|
393
|
+
const adapterRegistry = {
|
|
394
|
+
"ogc-features": collection,
|
|
395
|
+
};
|
|
396
|
+
void root;
|
|
397
|
+
return makeSource(descriptor, caps, policy, adapterRegistry, {
|
|
398
|
+
async query(request) {
|
|
399
|
+
ensureCapability(descriptor, caps, "query");
|
|
400
|
+
if (request?.aggregation) {
|
|
401
|
+
// OGC has no server-side aggregation; the adapter always aggregates
|
|
402
|
+
// client-side over the returned page. `queryAggregate` is therefore a
|
|
403
|
+
// degradable capability here.
|
|
404
|
+
ensureCapabilityOrFallback(descriptor, caps, "queryAggregate", policy);
|
|
405
|
+
}
|
|
406
|
+
const response = await collection.items(toOgcRequest(request));
|
|
407
|
+
const features = response.features.map((toTypedFeatureFromOgc));
|
|
408
|
+
const totalCount = response.numberMatched;
|
|
409
|
+
const degraded = [];
|
|
410
|
+
const exceededTransferLimit = totalCount !== undefined && features.length < totalCount;
|
|
411
|
+
if (request?.aggregation) {
|
|
412
|
+
const aggregateRows = clientSideAggregate(features, request.aggregation);
|
|
413
|
+
degraded.push({
|
|
414
|
+
capability: "queryAggregate",
|
|
415
|
+
reason: "OGC API Features does not expose server-side aggregation; aggregating client-side over the returned page.",
|
|
416
|
+
protocol: "ogc-features",
|
|
417
|
+
sourceId: descriptor.id,
|
|
418
|
+
});
|
|
419
|
+
return {
|
|
420
|
+
features,
|
|
421
|
+
exceededTransferLimit,
|
|
422
|
+
totalCount,
|
|
423
|
+
aggregateRows,
|
|
424
|
+
degraded,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
features,
|
|
429
|
+
exceededTransferLimit,
|
|
430
|
+
totalCount,
|
|
431
|
+
};
|
|
432
|
+
},
|
|
433
|
+
async queryAll(request) {
|
|
434
|
+
ensureCapability(descriptor, caps, "query");
|
|
435
|
+
const limit = request?.pagination?.limit;
|
|
436
|
+
// When the caller caps the result with `pagination.limit`, request
|
|
437
|
+
// `limit + 1` rows from the OGC helper (the lookahead row) so the
|
|
438
|
+
// adapter can stamp `exceededTransferLimit: true` when more records
|
|
439
|
+
// exist. Mirrors the GeoServices `withPagingBounds` + `applyQueryAllLimit`
|
|
440
|
+
// pattern so `queryAll({ pagination: { limit } })` has the same
|
|
441
|
+
// contract across protocols.
|
|
442
|
+
const ogcRequest = toOgcRequest(request);
|
|
443
|
+
if (typeof limit === "number" && Number.isFinite(limit) && limit >= 0) {
|
|
444
|
+
ogcRequest.limit = limit + 1;
|
|
445
|
+
}
|
|
446
|
+
const all = await collection.itemsAll(withUnboundedMaxPages(ogcRequest));
|
|
447
|
+
const typed = all.map((toTypedFeatureFromOgc));
|
|
448
|
+
const { features, exceededTransferLimit } = applyQueryAllLimit(typed, limit);
|
|
449
|
+
return {
|
|
450
|
+
features,
|
|
451
|
+
exceededTransferLimit,
|
|
452
|
+
totalCount: features.length,
|
|
453
|
+
};
|
|
454
|
+
},
|
|
455
|
+
async queryAggregate(request) {
|
|
456
|
+
// Always client-side (OGC has no server-side aggregation). Under `strict`
|
|
457
|
+
// the descriptor must advertise `queryAggregate`; under `degraded` the
|
|
458
|
+
// fallback runs unconditionally.
|
|
459
|
+
ensureCapabilityOrFallback(descriptor, caps, "queryAggregate", policy);
|
|
460
|
+
const all = await collection.itemsAll(withUnboundedMaxPages(toOgcMaterializedRequest(request)));
|
|
461
|
+
const features = all.map((toTypedFeatureFromOgc));
|
|
462
|
+
return {
|
|
463
|
+
features,
|
|
464
|
+
exceededTransferLimit: false,
|
|
465
|
+
totalCount: features.length,
|
|
466
|
+
aggregateRows: clientSideAggregate(features, request.aggregation),
|
|
467
|
+
degraded: [
|
|
468
|
+
{
|
|
469
|
+
capability: "queryAggregate",
|
|
470
|
+
reason: "OGC API Features aggregation evaluated client-side over the materialized result set.",
|
|
471
|
+
protocol: "ogc-features",
|
|
472
|
+
sourceId: descriptor.id,
|
|
473
|
+
},
|
|
474
|
+
],
|
|
475
|
+
};
|
|
476
|
+
},
|
|
477
|
+
async queryExtent(request) {
|
|
478
|
+
ensureCapabilityOrFallback(descriptor, caps, "queryExtent", policy);
|
|
479
|
+
// Collection-wide extent (no filters): the metadata bbox is accurate
|
|
480
|
+
// and avoids draining items. A filtered request must be computed
|
|
481
|
+
// client-side — the collection bbox would over-report the matching
|
|
482
|
+
// subset's extent. `outSr` also forces the items path so the extent
|
|
483
|
+
// is expressed in the caller's requested CRS; the metadata bbox is
|
|
484
|
+
// frozen in the collection's native CRS (typically CRS84) and would
|
|
485
|
+
// silently mislead `queryExtent({ outSr: 3857 })` callers.
|
|
486
|
+
if (!hasExtentFilter(request) && request?.outSr === undefined) {
|
|
487
|
+
const meta = await collection.metadata();
|
|
488
|
+
const bbox = meta.extent?.spatial?.bbox?.[0];
|
|
489
|
+
if (!bbox || bbox.length < 4)
|
|
490
|
+
return { extent: null };
|
|
491
|
+
const [xmin, ymin, xmax, ymax] = bbox;
|
|
492
|
+
return { extent: { xmin, ymin, xmax, ymax } };
|
|
493
|
+
}
|
|
494
|
+
const all = await collection.itemsAll(withUnboundedMaxPages(toOgcMaterializedRequest(request)));
|
|
495
|
+
return computeExtentFromOgcFeatures(all);
|
|
496
|
+
},
|
|
497
|
+
async *stream(request) {
|
|
498
|
+
ensureCapability(descriptor, caps, "stream");
|
|
499
|
+
const stream = collection.itemsStream(withUnboundedMaxPages(toOgcRequest(request)));
|
|
500
|
+
for await (const page of stream) {
|
|
501
|
+
yield {
|
|
502
|
+
features: page.map((toTypedFeatureFromOgc)),
|
|
503
|
+
exceededTransferLimit: false,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
},
|
|
507
|
+
async queryObjectIds(request) {
|
|
508
|
+
ensureCapability(descriptor, caps, "queryObjectIds");
|
|
509
|
+
// OGC `/items` does not expose a server-side ids-only mode; drain the
|
|
510
|
+
// matching set and project the GeoJSON `id`. Callers that need a
|
|
511
|
+
// bounded scan should pass `pagination.limit`.
|
|
512
|
+
const all = await collection.itemsAll(withUnboundedMaxPages(toOgcRequest(request)));
|
|
513
|
+
const ids = [];
|
|
514
|
+
for (const feature of all) {
|
|
515
|
+
if (feature.id !== undefined && feature.id !== null) {
|
|
516
|
+
ids.push(feature.id);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return ids;
|
|
520
|
+
},
|
|
521
|
+
async applyEdits(envelope) {
|
|
522
|
+
ensureCapability(descriptor, caps, "applyEdits");
|
|
523
|
+
const added = [];
|
|
524
|
+
const updated = [];
|
|
525
|
+
const deleted = [];
|
|
526
|
+
// OGC has no batch edit endpoint; edits fan out to per-item
|
|
527
|
+
// createItem / replaceItem / deleteItem. The canonical envelope's
|
|
528
|
+
// signal aborts every remaining request as soon as the caller
|
|
529
|
+
// cancels — each item call passes the same signal through.
|
|
530
|
+
const { signal } = envelope;
|
|
531
|
+
for (const add of envelope.adds ?? []) {
|
|
532
|
+
try {
|
|
533
|
+
const created = await collection.createItem({
|
|
534
|
+
feature: featureToGeoJsonFeature(add),
|
|
535
|
+
...(signal ? { signal } : {}),
|
|
536
|
+
});
|
|
537
|
+
added.push({ id: created.id, success: true });
|
|
538
|
+
}
|
|
539
|
+
catch (err) {
|
|
540
|
+
added.push({ success: false, error: editErrorFromCatch(err) });
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
for (const update of envelope.updates ?? []) {
|
|
544
|
+
if (update.id === undefined || update.id === null) {
|
|
545
|
+
updated.push({ success: false, error: { code: 400, description: "update.id is required" } });
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
try {
|
|
549
|
+
await collection.replaceItem({
|
|
550
|
+
featureId: update.id,
|
|
551
|
+
feature: featureToGeoJsonFeature(update),
|
|
552
|
+
...(signal ? { signal } : {}),
|
|
553
|
+
});
|
|
554
|
+
updated.push({ id: update.id, success: true });
|
|
555
|
+
}
|
|
556
|
+
catch (err) {
|
|
557
|
+
updated.push({ id: update.id, success: false, error: editErrorFromCatch(err) });
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
for (const id of envelope.deletes ?? []) {
|
|
561
|
+
try {
|
|
562
|
+
await collection.deleteItem({ featureId: id, ...(signal ? { signal } : {}) });
|
|
563
|
+
deleted.push({ id, success: true });
|
|
564
|
+
}
|
|
565
|
+
catch (err) {
|
|
566
|
+
deleted.push({ id, success: false, error: editErrorFromCatch(err) });
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return { added, updated, deleted };
|
|
570
|
+
},
|
|
571
|
+
async queryRelated() {
|
|
572
|
+
// OGC API Features has no related-records concept; refuse rather than
|
|
573
|
+
// silently return empty groups.
|
|
574
|
+
throw new HonuaCapabilityNotSupportedError("queryRelated", descriptor.protocol, descriptor.id);
|
|
575
|
+
},
|
|
576
|
+
attachments: unsupportedAttachmentApi(descriptor),
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
// ── GeoServices Image Service ────────────────────────────────
|
|
580
|
+
/**
|
|
581
|
+
* Build a `Source` over an Esri-style ImageServer endpoint. The query
|
|
582
|
+
* surface drives the raster catalog (each row is a raster with footprint
|
|
583
|
+
* geometry); image export, identify, and tile operations live behind
|
|
584
|
+
* `Source.protocol("geoservices-image-service")`.
|
|
585
|
+
*
|
|
586
|
+
* @example
|
|
587
|
+
* ```ts
|
|
588
|
+
* const dataset = createDataset({
|
|
589
|
+
* id: "imagery",
|
|
590
|
+
* client,
|
|
591
|
+
* sources: [{
|
|
592
|
+
* id: "naip",
|
|
593
|
+
* protocol: "geoservices-image-service",
|
|
594
|
+
* locator: { url: "https://your-honua-server.example", serviceId: "naip" },
|
|
595
|
+
* capabilities: PROTOCOL_DEFAULT_CAPABILITIES["geoservices-image-service"],
|
|
596
|
+
* }],
|
|
597
|
+
* });
|
|
598
|
+
* const img = dataset.source("naip")!.protocol("geoservices-image-service");
|
|
599
|
+
* const png = await img?.exportImage({ bbox: [-158.5, 21.2, -157.6, 21.7], size: [512, 512] });
|
|
600
|
+
* ```
|
|
601
|
+
*/
|
|
602
|
+
export function geoServicesImageSource(descriptor, client, policy) {
|
|
603
|
+
const { serviceId } = requireImageServiceLocator(descriptor);
|
|
604
|
+
const service = new HonuaImageService({ client, serviceId });
|
|
605
|
+
const caps = descriptor.capabilities ?? PROTOCOL_DEFAULT_CAPABILITIES["geoservices-image-service"];
|
|
606
|
+
const adapterRegistry = {
|
|
607
|
+
"geoservices-image-service": service,
|
|
608
|
+
};
|
|
609
|
+
return makeSource(descriptor, caps, policy, adapterRegistry, {
|
|
610
|
+
async query(request) {
|
|
611
|
+
ensureCapability(descriptor, caps, "query");
|
|
612
|
+
requireImageServerCompatibleQuery(request);
|
|
613
|
+
// ImageServer query returns the raster catalog. The GeoServices
|
|
614
|
+
// request shape is identical to FeatureServer query so we reuse
|
|
615
|
+
// `toFeatureLayerRequest` and dispatch through the service.
|
|
616
|
+
const response = await service.queryRasterCatalog(toFeatureLayerRequest(request));
|
|
617
|
+
return featureLayerResultFromUntyped(response);
|
|
618
|
+
},
|
|
619
|
+
async queryAll(request) {
|
|
620
|
+
ensureCapability(descriptor, caps, "query");
|
|
621
|
+
requireImageServerCompatibleQuery(request);
|
|
622
|
+
const baseParams = toFeatureLayerRequest(request);
|
|
623
|
+
const limit = request?.pagination?.limit;
|
|
624
|
+
// ImageServer's catalog endpoint supports `resultOffset`/`resultRecordCount`
|
|
625
|
+
// but the wrapper does not expose a `queryAllRasterCatalog` helper, so
|
|
626
|
+
// drain pages here. Mirror `withPagingBounds` + `applyQueryAllLimit` so
|
|
627
|
+
// an empty/short page or a `limit + 1` lookahead row stops the loop and
|
|
628
|
+
// stamps `exceededTransferLimit` correctly.
|
|
629
|
+
const startingOffset = typeof baseParams.resultOffset === "number" ? baseParams.resultOffset : 0;
|
|
630
|
+
const pageSize = typeof limit === "number" && limit > 0
|
|
631
|
+
? Math.max(1, Math.min(limit, 2000))
|
|
632
|
+
: typeof baseParams.resultRecordCount === "number" && baseParams.resultRecordCount > 0
|
|
633
|
+
? baseParams.resultRecordCount
|
|
634
|
+
: 2000;
|
|
635
|
+
const collected = [];
|
|
636
|
+
let lastFields;
|
|
637
|
+
const target = typeof limit === "number" && limit >= 0 ? limit + 1 : Number.POSITIVE_INFINITY;
|
|
638
|
+
for (let page = 0;; page += 1) {
|
|
639
|
+
const response = await service.queryRasterCatalog({
|
|
640
|
+
...baseParams,
|
|
641
|
+
resultOffset: startingOffset + page * pageSize,
|
|
642
|
+
resultRecordCount: pageSize,
|
|
643
|
+
});
|
|
644
|
+
if (response.fields)
|
|
645
|
+
lastFields = response.fields;
|
|
646
|
+
const pageFeatures = response.features ?? [];
|
|
647
|
+
for (const feature of pageFeatures) {
|
|
648
|
+
collected.push(feature);
|
|
649
|
+
if (collected.length >= target)
|
|
650
|
+
break;
|
|
651
|
+
}
|
|
652
|
+
if (collected.length >= target)
|
|
653
|
+
break;
|
|
654
|
+
if (pageFeatures.length < pageSize)
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
const typed = collected.map((toTypedFeature));
|
|
658
|
+
const { features: bounded, exceededTransferLimit } = applyQueryAllLimit(typed, limit);
|
|
659
|
+
return {
|
|
660
|
+
features: bounded,
|
|
661
|
+
exceededTransferLimit,
|
|
662
|
+
totalCount: bounded.length,
|
|
663
|
+
...(lastFields ? { fields: lastFields } : {}),
|
|
664
|
+
};
|
|
665
|
+
},
|
|
666
|
+
async queryAggregate() {
|
|
667
|
+
throw new HonuaCapabilityNotSupportedError("queryAggregate", descriptor.protocol, descriptor.id);
|
|
668
|
+
},
|
|
669
|
+
async queryExtent(request) {
|
|
670
|
+
ensureCapability(descriptor, caps, "queryExtent");
|
|
671
|
+
requireImageServerCompatibleQuery(request);
|
|
672
|
+
const response = await service.queryRasterCatalog(toExtentOnlyRequest(toFeatureLayerRequest(request)));
|
|
673
|
+
return extractExtentEnvelope(response);
|
|
674
|
+
},
|
|
675
|
+
// biome-ignore lint/correctness/useYield: ImageServer has no streaming raster-catalog mode; this generator refuses iteration rather than silently emit a single page
|
|
676
|
+
async *stream() {
|
|
677
|
+
throw new HonuaCapabilityNotSupportedError("stream", descriptor.protocol, descriptor.id);
|
|
678
|
+
},
|
|
679
|
+
async queryObjectIds(request) {
|
|
680
|
+
ensureCapability(descriptor, caps, "queryObjectIds");
|
|
681
|
+
requireImageServerCompatibleQuery(request);
|
|
682
|
+
return service.queryRasterCatalogObjectIds(toFeatureLayerRequest(request));
|
|
683
|
+
},
|
|
684
|
+
async applyEdits() {
|
|
685
|
+
throw new HonuaCapabilityNotSupportedError("applyEdits", descriptor.protocol, descriptor.id);
|
|
686
|
+
},
|
|
687
|
+
async queryRelated() {
|
|
688
|
+
throw new HonuaCapabilityNotSupportedError("queryRelated", descriptor.protocol, descriptor.id);
|
|
689
|
+
},
|
|
690
|
+
attachments: unsupportedAttachmentApi(descriptor),
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
// ── GeoServices Geometry Service ─────────────────────────────
|
|
694
|
+
/**
|
|
695
|
+
* Build a `Source` over an Esri-style Geometry Service endpoint. Geometry
|
|
696
|
+
* Service is a stateless utility — it does not host features — so the
|
|
697
|
+
* query family throws `HonuaCapabilityNotSupportedError` and operations
|
|
698
|
+
* (buffer, project, simplify, etc.) live behind
|
|
699
|
+
* `Source.protocol("geoservices-geometry-service")`.
|
|
700
|
+
*
|
|
701
|
+
* @example
|
|
702
|
+
* ```ts
|
|
703
|
+
* const geom = dataset.source("geom")!.protocol("geoservices-geometry-service");
|
|
704
|
+
* const projected = await geom?.project({
|
|
705
|
+
* inSr: 4326,
|
|
706
|
+
* outSr: 3857,
|
|
707
|
+
* geometries: [{ x: -158, y: 21 }],
|
|
708
|
+
* });
|
|
709
|
+
* ```
|
|
710
|
+
*/
|
|
711
|
+
export function geoServicesGeometryServiceSource(descriptor, client, policy) {
|
|
712
|
+
const service = new HonuaGeometryService({ client });
|
|
713
|
+
const caps = descriptor.capabilities ?? PROTOCOL_DEFAULT_CAPABILITIES["geoservices-geometry-service"];
|
|
714
|
+
const adapterRegistry = {
|
|
715
|
+
"geoservices-geometry-service": service,
|
|
716
|
+
};
|
|
717
|
+
return makeSource(descriptor, caps, policy, adapterRegistry, unsupportedFeatureSurface(descriptor));
|
|
718
|
+
}
|
|
719
|
+
// ── GeoServices GP Service ───────────────────────────────────
|
|
720
|
+
/**
|
|
721
|
+
* Build a `Source` over an Esri-style GP (geoprocessing) service. GP
|
|
722
|
+
* services run async tasks rather than hosting features; the canonical
|
|
723
|
+
* feature surface throws and task submission / status / result lookup
|
|
724
|
+
* live behind `Source.protocol("geoservices-gp-service")`.
|
|
725
|
+
*
|
|
726
|
+
* @example
|
|
727
|
+
* ```ts
|
|
728
|
+
* const gp = dataset.source("buffer-task")!.protocol("geoservices-gp-service");
|
|
729
|
+
* const job = await gp!.submitJob({ inputs: { distance: 500 } });
|
|
730
|
+
* const result = await gp!.awaitJobResult(job.jobId);
|
|
731
|
+
* ```
|
|
732
|
+
*/
|
|
733
|
+
export function geoServicesGPServiceSource(descriptor, client, policy) {
|
|
734
|
+
const caps = descriptor.capabilities ?? PROTOCOL_DEFAULT_CAPABILITIES["geoservices-gp-service"];
|
|
735
|
+
const { serviceId, taskName } = requireGPServiceLocator(descriptor, caps);
|
|
736
|
+
const service = new HonuaGeoprocessingService({
|
|
737
|
+
client,
|
|
738
|
+
serviceId,
|
|
739
|
+
taskName,
|
|
740
|
+
});
|
|
741
|
+
const adapterRegistry = {
|
|
742
|
+
"geoservices-gp-service": service,
|
|
743
|
+
};
|
|
744
|
+
return makeSource(descriptor, caps, policy, adapterRegistry, unsupportedFeatureSurface(descriptor));
|
|
745
|
+
}
|
|
746
|
+
// ── OGC API Tiles ─────────────────────────────────────────────
|
|
747
|
+
/**
|
|
748
|
+
* Render-only Source adapter for OGC API Tiles. The query family throws
|
|
749
|
+
* `HonuaCapabilityNotSupportedError` (the conformance class is
|
|
750
|
+
* tile-fetch, not feature-query); rendering integrations consume the
|
|
751
|
+
* tileset through `Source.protocol("ogc-tiles")` to reach the underlying
|
|
752
|
+
* runtime class.
|
|
753
|
+
*
|
|
754
|
+
* @example
|
|
755
|
+
* ```ts
|
|
756
|
+
* const tiles = dataset.source("ogc-tileset")!.protocol("ogc-tiles");
|
|
757
|
+
* const bytes = await tiles!.tile({ tileMatrix: "WebMercatorQuad", tileRow: 5, tileCol: 8 });
|
|
758
|
+
* ```
|
|
759
|
+
*/
|
|
760
|
+
export function ogcTilesSource(descriptor, client, policy) {
|
|
761
|
+
const { collectionId, tileMatrixSetId } = requireOgcTilesLocator(descriptor);
|
|
762
|
+
const caps = descriptor.capabilities ?? PROTOCOL_DEFAULT_CAPABILITIES["ogc-tiles"];
|
|
763
|
+
// Descriptors without a tileMatrixSetId cannot construct a usable
|
|
764
|
+
// HonuaOgcTileset (every tile route requires `tileMatrixSetId`). Expose
|
|
765
|
+
// the root HonuaOgcTiles adapter instead so callers can discover the
|
|
766
|
+
// tilesets the server advertises for the collection before binding one.
|
|
767
|
+
const adapter = tileMatrixSetId !== undefined && tileMatrixSetId !== ""
|
|
768
|
+
? new HonuaOgcTileset({ client, collectionId, tileMatrixSetId })
|
|
769
|
+
: new HonuaOgcTiles({ client });
|
|
770
|
+
const adapterRegistry = {
|
|
771
|
+
"ogc-tiles": adapter,
|
|
772
|
+
};
|
|
773
|
+
return makeSource(descriptor, caps, policy, adapterRegistry, unsupportedFeatureSurface(descriptor));
|
|
774
|
+
}
|
|
775
|
+
// ── PMTiles archive ───────────────────────────────────────────
|
|
776
|
+
/**
|
|
777
|
+
* Tiles-only Source adapter for a PMTiles archive (`protocol: "pmtiles"`).
|
|
778
|
+
*
|
|
779
|
+
* PMTiles archives are immutable single-file tile stores, so the canonical
|
|
780
|
+
* query family throws `HonuaCapabilityNotSupportedError` (there is no
|
|
781
|
+
* feature-query surface on an archive). Archive metadata — bounds, min/max
|
|
782
|
+
* zoom, and vector layer names — is inspected through the typed escape hatch:
|
|
783
|
+
* `Source.protocol("pmtiles").describe()`. Rendering integrations register the
|
|
784
|
+
* `pmtiles://` protocol on the map (see `@honua/sdk-js/runtime`) and reference
|
|
785
|
+
* the archive as a MapLibre source `url`.
|
|
786
|
+
*
|
|
787
|
+
* `locator.url` may carry a leading `pmtiles://` scheme (the MapLibre form) or
|
|
788
|
+
* be a bare archive URL; both resolve to the same archive.
|
|
789
|
+
*
|
|
790
|
+
* @example
|
|
791
|
+
* ```ts
|
|
792
|
+
* const archive = dataset.source("basemap")!.protocol("pmtiles");
|
|
793
|
+
* const info = await archive!.describe();
|
|
794
|
+
* console.log(info.bounds, info.vectorLayers.map((layer) => layer.id));
|
|
795
|
+
* ```
|
|
796
|
+
*/
|
|
797
|
+
export function pmtilesSource(descriptor, _client, policy) {
|
|
798
|
+
const url = stripPmtilesScheme(requirePmtilesLocator(descriptor));
|
|
799
|
+
const caps = descriptor.capabilities ?? PROTOCOL_DEFAULT_CAPABILITIES.pmtiles;
|
|
800
|
+
const archive = new HonuaPmtilesArchive(url);
|
|
801
|
+
const adapterRegistry = {
|
|
802
|
+
pmtiles: archive,
|
|
803
|
+
};
|
|
804
|
+
return makeSource(descriptor, caps, policy, adapterRegistry, unsupportedFeatureSurface(descriptor));
|
|
805
|
+
}
|
|
806
|
+
// ── OGC API Maps ──────────────────────────────────────────────
|
|
807
|
+
/**
|
|
808
|
+
* Render-only Source adapter for OGC API Maps. Same shape as the Tiles
|
|
809
|
+
* adapter — `Source.protocol("ogc-maps")` exposes the runtime class for
|
|
810
|
+
* server-rendered map images; the canonical query family throws.
|
|
811
|
+
*
|
|
812
|
+
* @example
|
|
813
|
+
* ```ts
|
|
814
|
+
* const maps = dataset.source("ogc-map")!.protocol("ogc-maps");
|
|
815
|
+
* const png = await maps!.map({ bbox: [-158, 21, -157, 22], width: 512, height: 512 });
|
|
816
|
+
* ```
|
|
817
|
+
*/
|
|
818
|
+
export function ogcMapsSource(descriptor, client, policy) {
|
|
819
|
+
const root = new HonuaOgcMaps({ client });
|
|
820
|
+
const adapterRegistry = {};
|
|
821
|
+
if (descriptor.locator.collectionId !== undefined) {
|
|
822
|
+
adapterRegistry["ogc-maps"] = new HonuaOgcCollectionMap({
|
|
823
|
+
client,
|
|
824
|
+
collectionId: descriptor.locator.collectionId,
|
|
825
|
+
styleId: descriptor.locator.styleId,
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
else {
|
|
829
|
+
adapterRegistry["ogc-maps"] = root;
|
|
830
|
+
}
|
|
831
|
+
const caps = descriptor.capabilities ?? PROTOCOL_DEFAULT_CAPABILITIES["ogc-maps"];
|
|
832
|
+
return makeSource(descriptor, caps, policy, adapterRegistry, unsupportedFeatureSurface(descriptor));
|
|
833
|
+
}
|
|
834
|
+
// ── OGC API Records ───────────────────────────────────────────
|
|
835
|
+
/**
|
|
836
|
+
* Metadata-catalog Source adapter for OGC API Records. The canonical query
|
|
837
|
+
* family searches one catalog (`locator.collectionId`, the Records
|
|
838
|
+
* collection/catalog id) and returns record documents as typed features.
|
|
839
|
+
* Records-specific search affordances (`q`, `type`, `externalIds`,
|
|
840
|
+
* `profile`, raw HTML/JSON access) live on `Source.protocol("ogc-records")`.
|
|
841
|
+
*
|
|
842
|
+
* @example
|
|
843
|
+
* ```ts
|
|
844
|
+
* const records = dataset.source("catalog")!;
|
|
845
|
+
* const result = await records.query({ where: "type = 'dataset' AND q = 'parcels'" });
|
|
846
|
+
* ```
|
|
847
|
+
*/
|
|
848
|
+
export function ogcRecordsSource(descriptor, client, policy) {
|
|
849
|
+
const { collectionId } = requireOgcRecordsLocator(descriptor);
|
|
850
|
+
const collection = new HonuaOgcRecordCollection({ client, collectionId });
|
|
851
|
+
const caps = descriptor.capabilities ?? PROTOCOL_DEFAULT_CAPABILITIES["ogc-records"];
|
|
852
|
+
const adapterRegistry = {
|
|
853
|
+
"ogc-records": collection,
|
|
854
|
+
};
|
|
855
|
+
return makeSource(descriptor, caps, policy, adapterRegistry, {
|
|
856
|
+
async query(request) {
|
|
857
|
+
ensureCapability(descriptor, caps, "query");
|
|
858
|
+
if (request?.aggregation) {
|
|
859
|
+
throw new HonuaCapabilityNotSupportedError("queryAggregate", descriptor.protocol, descriptor.id);
|
|
860
|
+
}
|
|
861
|
+
const response = await collection.search(toOgcRecordsRequest(request));
|
|
862
|
+
const features = (response.features ?? []).map((toTypedFeatureFromOgcRecord));
|
|
863
|
+
const totalCount = response.numberMatched;
|
|
864
|
+
return {
|
|
865
|
+
features,
|
|
866
|
+
exceededTransferLimit: totalCount !== undefined && features.length < totalCount,
|
|
867
|
+
...(totalCount !== undefined ? { totalCount } : {}),
|
|
868
|
+
};
|
|
869
|
+
},
|
|
870
|
+
async queryAll(request) {
|
|
871
|
+
ensureCapability(descriptor, caps, "query");
|
|
872
|
+
const limit = request?.pagination?.limit;
|
|
873
|
+
const records = await collection.searchAll({
|
|
874
|
+
...toOgcRecordsRequest(request),
|
|
875
|
+
...withPagingBounds({}, limit),
|
|
876
|
+
});
|
|
877
|
+
const typed = records.map((toTypedFeatureFromOgcRecord));
|
|
878
|
+
const { features, exceededTransferLimit } = applyQueryAllLimit(typed, limit);
|
|
879
|
+
return {
|
|
880
|
+
features,
|
|
881
|
+
exceededTransferLimit,
|
|
882
|
+
totalCount: features.length,
|
|
883
|
+
};
|
|
884
|
+
},
|
|
885
|
+
async queryAggregate() {
|
|
886
|
+
throw new HonuaCapabilityNotSupportedError("queryAggregate", descriptor.protocol, descriptor.id);
|
|
887
|
+
},
|
|
888
|
+
async queryExtent() {
|
|
889
|
+
throw new HonuaCapabilityNotSupportedError("queryExtent", descriptor.protocol, descriptor.id);
|
|
890
|
+
},
|
|
891
|
+
async *stream(request) {
|
|
892
|
+
ensureCapability(descriptor, caps, "stream");
|
|
893
|
+
const limit = request?.pagination?.limit;
|
|
894
|
+
const stream = collection.searchStream({
|
|
895
|
+
...toOgcRecordsRequest(request),
|
|
896
|
+
pageSize: limit !== undefined ? Math.max(1, limit) : undefined,
|
|
897
|
+
maxPages: Number.MAX_SAFE_INTEGER,
|
|
898
|
+
});
|
|
899
|
+
for await (const page of stream) {
|
|
900
|
+
yield {
|
|
901
|
+
features: page.map((toTypedFeatureFromOgcRecord)),
|
|
902
|
+
exceededTransferLimit: false,
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
},
|
|
906
|
+
async queryObjectIds(request) {
|
|
907
|
+
ensureCapability(descriptor, caps, "queryObjectIds");
|
|
908
|
+
const limit = request?.pagination?.limit;
|
|
909
|
+
const records = await collection.searchAll({
|
|
910
|
+
...toOgcRecordsRequest(request),
|
|
911
|
+
...withPagingBounds({}, limit),
|
|
912
|
+
});
|
|
913
|
+
const ids = [];
|
|
914
|
+
for (const record of records) {
|
|
915
|
+
if (record.id !== undefined && record.id !== null) {
|
|
916
|
+
ids.push(record.id);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
if (typeof limit === "number" && limit >= 0 && ids.length > limit) {
|
|
920
|
+
return ids.slice(0, limit);
|
|
921
|
+
}
|
|
922
|
+
return ids;
|
|
923
|
+
},
|
|
924
|
+
async applyEdits() {
|
|
925
|
+
throw new HonuaCapabilityNotSupportedError("applyEdits", descriptor.protocol, descriptor.id);
|
|
926
|
+
},
|
|
927
|
+
async queryRelated() {
|
|
928
|
+
throw new HonuaCapabilityNotSupportedError("queryRelated", descriptor.protocol, descriptor.id);
|
|
929
|
+
},
|
|
930
|
+
attachments: unsupportedAttachmentApi(descriptor),
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
// ── WMS 1.3 ───────────────────────────────────────────────────
|
|
934
|
+
/**
|
|
935
|
+
* `Source` adapter for first-party WMS 1.3.0 services. The render path
|
|
936
|
+
* routes through `HonuaWms` / `HonuaWmsLayer` (both of which sit
|
|
937
|
+
* behind the canonical `Source.protocol("wms" | "wms-layer")` escape
|
|
938
|
+
* hatch); `Source.query()` translates a point spatial filter into a
|
|
939
|
+
* `GetFeatureInfo` call. Non-point queries throw
|
|
940
|
+
* `HonuaCapabilityNotSupportedError` so the canonical query envelope
|
|
941
|
+
* stays honest — multi-pixel feature info lives on
|
|
942
|
+
* `Source.protocol("wms").featureInfo()`.
|
|
943
|
+
*/
|
|
944
|
+
/**
|
|
945
|
+
* Adapter factory for a WMS 1.3.0 endpoint.
|
|
946
|
+
*
|
|
947
|
+
* The canonical `Source.query()` is implemented through point-only
|
|
948
|
+
* `GetFeatureInfo`. Reach `Source.protocol("wms")` for raw `GetMap` /
|
|
949
|
+
* `GetLegendGraphic` access (with TIME / ELEVATION dimension handling and
|
|
950
|
+
* CRS axis-order swap per WMS 1.3 §6.7.3.2).
|
|
951
|
+
*
|
|
952
|
+
* @example
|
|
953
|
+
* ```ts
|
|
954
|
+
* const wms = dataset.source("usgs-imagery")!.protocol("wms");
|
|
955
|
+
* const png = await wms!.getMap({
|
|
956
|
+
* layers: ["topo"], bbox: [-158, 21, -157, 22], crs: "EPSG:4326",
|
|
957
|
+
* width: 512, height: 512,
|
|
958
|
+
* });
|
|
959
|
+
* ```
|
|
960
|
+
*/
|
|
961
|
+
export function wmsSource(descriptor, client, policy) {
|
|
962
|
+
const { serviceId } = requireWmsLocator(descriptor);
|
|
963
|
+
const layerName = descriptor.locator.typeName;
|
|
964
|
+
const styleId = descriptor.locator.styleId;
|
|
965
|
+
const root = new HonuaWms({ client, serviceId });
|
|
966
|
+
const adapterRegistry = { wms: root };
|
|
967
|
+
// `HonuaWmsLayer` is a single-layer handle (its `describe()` resolves
|
|
968
|
+
// exactly one `<Layer>` from the parsed Capabilities). Multi-layer
|
|
969
|
+
// composites (`LAYERS=a,b`) must stay on the service-level `wms`
|
|
970
|
+
// handle and use `featureInfo()` directly; registering them as a
|
|
971
|
+
// `wms-layer` would silently mis-route `describe()` / `stylesIn()` /
|
|
972
|
+
// `legend()` to a single layer name that does not exist on the wire.
|
|
973
|
+
const parsedLayers = parseWmsLayerNames(layerName);
|
|
974
|
+
if (parsedLayers.length === 1) {
|
|
975
|
+
const layerOpts = {
|
|
976
|
+
client,
|
|
977
|
+
serviceId,
|
|
978
|
+
layerName: parsedLayers[0],
|
|
979
|
+
};
|
|
980
|
+
if (typeof styleId === "string")
|
|
981
|
+
layerOpts.defaultStyleId = styleId;
|
|
982
|
+
adapterRegistry["wms-layer"] = new HonuaWmsLayer(layerOpts);
|
|
983
|
+
}
|
|
984
|
+
const caps = descriptor.capabilities ?? PROTOCOL_DEFAULT_CAPABILITIES.wms;
|
|
985
|
+
return makeSource(descriptor, caps, policy, adapterRegistry, {
|
|
986
|
+
async query(request) {
|
|
987
|
+
ensureCapability(descriptor, caps, "query");
|
|
988
|
+
requireWmsCompatibleQuery(descriptor, request);
|
|
989
|
+
const layers = wmsRequireLayers(descriptor, layerName);
|
|
990
|
+
const { x: px, y: py, crs } = wmsExtractPointFromQuery(request, descriptor);
|
|
991
|
+
const bboxRadius = 0.0001; // tiny envelope around the point keeps the request a 1×1 image.
|
|
992
|
+
const widthHeight = 1;
|
|
993
|
+
const featureCount = request?.pagination?.limit;
|
|
994
|
+
const response = await client.getWmsFeatureInfo({
|
|
995
|
+
serviceId,
|
|
996
|
+
layers,
|
|
997
|
+
...(styleId !== undefined ? { styles: [styleId] } : {}),
|
|
998
|
+
queryLayers: layers,
|
|
999
|
+
crs,
|
|
1000
|
+
bbox: [px - bboxRadius, py - bboxRadius, px + bboxRadius, py + bboxRadius],
|
|
1001
|
+
width: widthHeight,
|
|
1002
|
+
height: widthHeight,
|
|
1003
|
+
i: 0,
|
|
1004
|
+
j: 0,
|
|
1005
|
+
infoFormat: "application/json",
|
|
1006
|
+
...(featureCount !== undefined ? { featureCount } : {}),
|
|
1007
|
+
...(request?.signal ? { signal: request.signal } : {}),
|
|
1008
|
+
});
|
|
1009
|
+
return {
|
|
1010
|
+
features: response.features ?? [],
|
|
1011
|
+
exceededTransferLimit: false,
|
|
1012
|
+
};
|
|
1013
|
+
},
|
|
1014
|
+
async queryAll(request) {
|
|
1015
|
+
ensureCapability(descriptor, caps, "query");
|
|
1016
|
+
// GetFeatureInfo returns a single response set; queryAll degenerates
|
|
1017
|
+
// to query() because there is no paging on the wire.
|
|
1018
|
+
const result = await this.query(request);
|
|
1019
|
+
return result;
|
|
1020
|
+
},
|
|
1021
|
+
async queryAggregate() {
|
|
1022
|
+
throw new HonuaCapabilityNotSupportedError("queryAggregate", descriptor.protocol, descriptor.id);
|
|
1023
|
+
},
|
|
1024
|
+
async queryExtent() {
|
|
1025
|
+
throw new HonuaCapabilityNotSupportedError("queryExtent", descriptor.protocol, descriptor.id);
|
|
1026
|
+
},
|
|
1027
|
+
// biome-ignore lint/correctness/useYield: WMS exposes only single-shot GetFeatureInfo, which is delivered through query()
|
|
1028
|
+
async *stream() {
|
|
1029
|
+
throw new HonuaCapabilityNotSupportedError("stream", descriptor.protocol, descriptor.id);
|
|
1030
|
+
},
|
|
1031
|
+
async queryObjectIds() {
|
|
1032
|
+
throw new HonuaCapabilityNotSupportedError("queryObjectIds", descriptor.protocol, descriptor.id);
|
|
1033
|
+
},
|
|
1034
|
+
async applyEdits() {
|
|
1035
|
+
throw new HonuaCapabilityNotSupportedError("applyEdits", descriptor.protocol, descriptor.id);
|
|
1036
|
+
},
|
|
1037
|
+
async queryRelated() {
|
|
1038
|
+
throw new HonuaCapabilityNotSupportedError("queryRelated", descriptor.protocol, descriptor.id);
|
|
1039
|
+
},
|
|
1040
|
+
attachments: unsupportedAttachmentApi(descriptor),
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
// ── WMTS 1.0 ──────────────────────────────────────────────────
|
|
1044
|
+
/**
|
|
1045
|
+
* Render-only `Source` adapter for first-party WMTS 1.0.0 services.
|
|
1046
|
+
* `Source.query()` throws because WMTS GetFeatureInfo is keyed on tile
|
|
1047
|
+
* pixels (not a canonical spatial filter); raw access lives on
|
|
1048
|
+
* `Source.protocol("wmts" | "wmts-layer" | "wmts-tileset")`.
|
|
1049
|
+
*/
|
|
1050
|
+
/**
|
|
1051
|
+
* Adapter factory for a WMTS 1.0.0 endpoint. Render-only; `Source.query()`
|
|
1052
|
+
* throws `HonuaCapabilityNotSupportedError`. Reach `Source.protocol("wmts")`
|
|
1053
|
+
* for RESTful + KVP tile fetch and capabilities.
|
|
1054
|
+
*
|
|
1055
|
+
* @example
|
|
1056
|
+
* ```ts
|
|
1057
|
+
* const wmts = dataset.source("basemap-tiles")!.protocol("wmts");
|
|
1058
|
+
* const tile = await wmts!.tile({ tileMatrixSet: "WebMercatorQuad", tileMatrix: "8", tileRow: 5, tileCol: 8 });
|
|
1059
|
+
* ```
|
|
1060
|
+
*/
|
|
1061
|
+
export function wmtsSource(descriptor, client, policy) {
|
|
1062
|
+
const { serviceId } = requireWmtsLocator(descriptor);
|
|
1063
|
+
const layerName = descriptor.locator.typeName;
|
|
1064
|
+
const tileMatrixSetId = descriptor.locator.tileMatrixSetId ?? "WebMercatorQuad";
|
|
1065
|
+
const styleId = descriptor.locator.styleId ?? "default";
|
|
1066
|
+
const root = new HonuaWmts({ client, serviceId });
|
|
1067
|
+
const adapterRegistry = { wmts: root };
|
|
1068
|
+
if (typeof layerName === "string" && layerName.length > 0) {
|
|
1069
|
+
adapterRegistry["wmts-layer"] = new HonuaWmtsLayer({
|
|
1070
|
+
client,
|
|
1071
|
+
serviceId,
|
|
1072
|
+
layerName,
|
|
1073
|
+
defaultStyleId: styleId,
|
|
1074
|
+
defaultTileMatrixSetId: tileMatrixSetId,
|
|
1075
|
+
});
|
|
1076
|
+
adapterRegistry["wmts-tileset"] = new HonuaWmtsTileset({
|
|
1077
|
+
client,
|
|
1078
|
+
serviceId,
|
|
1079
|
+
layerName,
|
|
1080
|
+
styleId,
|
|
1081
|
+
tileMatrixSetId,
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
const caps = descriptor.capabilities ?? PROTOCOL_DEFAULT_CAPABILITIES.wmts;
|
|
1085
|
+
return makeSource(descriptor, caps, policy, adapterRegistry, unsupportedFeatureSurface(descriptor));
|
|
1086
|
+
}
|
|
1087
|
+
// ── STAC API ──────────────────────────────────────────────────
|
|
1088
|
+
/**
|
|
1089
|
+
* Adapter factory for a STAC API search endpoint.
|
|
1090
|
+
*
|
|
1091
|
+
* The canonical `Source.query()` runs a `POST /search` against the STAC root,
|
|
1092
|
+
* with `Query.spatialFilter` (e.g. an `envelope(...)` bounding box) and
|
|
1093
|
+
* `Query.where` translated into STAC's `bbox` / `datetime` / `filter`
|
|
1094
|
+
* parameters.
|
|
1095
|
+
*
|
|
1096
|
+
* @example
|
|
1097
|
+
* ```ts
|
|
1098
|
+
* import { envelope } from "@honua/sdk-js";
|
|
1099
|
+
*
|
|
1100
|
+
* const dataset = createDataset({
|
|
1101
|
+
* id: "imagery",
|
|
1102
|
+
* client,
|
|
1103
|
+
* sources: [{
|
|
1104
|
+
* id: "stac-search",
|
|
1105
|
+
* protocol: "stac",
|
|
1106
|
+
* locator: { url: "https://your-honua-server.example/stac" },
|
|
1107
|
+
* capabilities: PROTOCOL_DEFAULT_CAPABILITIES.stac,
|
|
1108
|
+
* }],
|
|
1109
|
+
* });
|
|
1110
|
+
* const result = await dataset.source("stac-search")!.query({
|
|
1111
|
+
* where: "collections IN ('landsat-c2-l2')",
|
|
1112
|
+
* spatialFilter: envelope(-158.5, 21.2, -157.6, 21.7),
|
|
1113
|
+
* });
|
|
1114
|
+
* ```
|
|
1115
|
+
*/
|
|
1116
|
+
export function stacSearchSource(descriptor, client, policy) {
|
|
1117
|
+
// Endpoint-layout selection. `stac-api` treats `locator.url` as a raw STAC
|
|
1118
|
+
// API root (search / collections mounted directly under it) rather than the
|
|
1119
|
+
// Honua `/stac` facade. `stac-static` reads a static catalog.json tree with
|
|
1120
|
+
// no search endpoint. Omitted / `honua-facade` keeps the `/stac` facade.
|
|
1121
|
+
const layout = descriptor.locator.layout;
|
|
1122
|
+
const stacBasePath = layout === "stac-api" ? "" : undefined;
|
|
1123
|
+
const stac = new HonuaStacSearch({ client, ...(stacBasePath !== undefined ? { basePath: stacBasePath } : {}) });
|
|
1124
|
+
const staticCatalog = layout === "stac-static" ? new HonuaStacStaticCatalog(client, client.serverBaseUrl) : undefined;
|
|
1125
|
+
const caps = descriptor.capabilities ?? PROTOCOL_DEFAULT_CAPABILITIES.stac;
|
|
1126
|
+
const collectionScope = descriptor.locator.collectionId;
|
|
1127
|
+
const adapterRegistry = {
|
|
1128
|
+
stac,
|
|
1129
|
+
};
|
|
1130
|
+
return makeSource(descriptor, caps, policy, adapterRegistry, {
|
|
1131
|
+
async query(request) {
|
|
1132
|
+
ensureCapability(descriptor, caps, "query");
|
|
1133
|
+
if (request?.aggregation) {
|
|
1134
|
+
throw new HonuaCapabilityNotSupportedError("queryAggregate", descriptor.protocol, descriptor.id);
|
|
1135
|
+
}
|
|
1136
|
+
const stacRequest = toStacRequest(request, collectionScope);
|
|
1137
|
+
const response = staticCatalog
|
|
1138
|
+
? await staticCatalog.search(toStacStaticParams(stacRequest))
|
|
1139
|
+
: await stac.search(stacRequest);
|
|
1140
|
+
const features = (response.features ?? []).map((toTypedFeatureFromStac));
|
|
1141
|
+
const totalCount = response.numberMatched ?? response.context?.matched;
|
|
1142
|
+
const exceededTransferLimit = totalCount !== undefined && features.length < totalCount;
|
|
1143
|
+
return {
|
|
1144
|
+
features,
|
|
1145
|
+
exceededTransferLimit,
|
|
1146
|
+
totalCount,
|
|
1147
|
+
};
|
|
1148
|
+
},
|
|
1149
|
+
async queryAll(request) {
|
|
1150
|
+
ensureCapability(descriptor, caps, "query");
|
|
1151
|
+
const limit = request?.pagination?.limit;
|
|
1152
|
+
if (staticCatalog) {
|
|
1153
|
+
const response = await staticCatalog.search(toStacStaticParams(toStacRequest(request, collectionScope)));
|
|
1154
|
+
const typed = (response.features ?? []).map((toTypedFeatureFromStac));
|
|
1155
|
+
const { features, exceededTransferLimit } = applyQueryAllLimit(typed, limit);
|
|
1156
|
+
return { features, exceededTransferLimit, totalCount: features.length };
|
|
1157
|
+
}
|
|
1158
|
+
const items = await stac.searchAll({
|
|
1159
|
+
...toStacRequest(request, collectionScope),
|
|
1160
|
+
...withPagingBounds({}, limit),
|
|
1161
|
+
});
|
|
1162
|
+
const typed = items.map((toTypedFeatureFromStac));
|
|
1163
|
+
const { features, exceededTransferLimit } = applyQueryAllLimit(typed, limit);
|
|
1164
|
+
return {
|
|
1165
|
+
features,
|
|
1166
|
+
exceededTransferLimit,
|
|
1167
|
+
totalCount: features.length,
|
|
1168
|
+
};
|
|
1169
|
+
},
|
|
1170
|
+
async queryAggregate() {
|
|
1171
|
+
throw new HonuaCapabilityNotSupportedError("queryAggregate", descriptor.protocol, descriptor.id);
|
|
1172
|
+
},
|
|
1173
|
+
async queryExtent() {
|
|
1174
|
+
throw new HonuaCapabilityNotSupportedError("queryExtent", descriptor.protocol, descriptor.id);
|
|
1175
|
+
},
|
|
1176
|
+
async *stream(request) {
|
|
1177
|
+
ensureCapability(descriptor, caps, "stream");
|
|
1178
|
+
const limit = request?.pagination?.limit;
|
|
1179
|
+
if (staticCatalog) {
|
|
1180
|
+
// A static catalog has no server-side paging; yield the whole
|
|
1181
|
+
// filtered set as one page.
|
|
1182
|
+
const response = await staticCatalog.search(toStacStaticParams(toStacRequest(request, collectionScope)));
|
|
1183
|
+
yield {
|
|
1184
|
+
features: (response.features ?? []).map((toTypedFeatureFromStac)),
|
|
1185
|
+
exceededTransferLimit: false,
|
|
1186
|
+
};
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
const stream = stac.searchStream({
|
|
1190
|
+
...toStacRequest(request, collectionScope),
|
|
1191
|
+
pageSize: limit !== undefined ? Math.max(1, limit) : undefined,
|
|
1192
|
+
maxPages: Number.MAX_SAFE_INTEGER,
|
|
1193
|
+
});
|
|
1194
|
+
for await (const page of stream) {
|
|
1195
|
+
yield {
|
|
1196
|
+
features: page.map((toTypedFeatureFromStac)),
|
|
1197
|
+
exceededTransferLimit: false,
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
},
|
|
1201
|
+
async queryObjectIds(request) {
|
|
1202
|
+
ensureCapability(descriptor, caps, "queryObjectIds");
|
|
1203
|
+
// STAC `/search` does not expose a server-side ids-only mode; drain
|
|
1204
|
+
// the matching items and project the GeoJSON `id`. `pagination.limit`
|
|
1205
|
+
// routes through `withPagingBounds` so the underlying searchAll
|
|
1206
|
+
// fetches at most `limit + 1` rows, never a full-catalog scan.
|
|
1207
|
+
const limit = request?.pagination?.limit;
|
|
1208
|
+
const items = staticCatalog
|
|
1209
|
+
? ((await staticCatalog.search(toStacStaticParams(toStacRequest(request, collectionScope)))).features ?? [])
|
|
1210
|
+
: await stac.searchAll({
|
|
1211
|
+
...toStacRequest(request, collectionScope),
|
|
1212
|
+
...withPagingBounds({}, limit),
|
|
1213
|
+
});
|
|
1214
|
+
const ids = [];
|
|
1215
|
+
for (const item of items) {
|
|
1216
|
+
if (item.id !== undefined && item.id !== null) {
|
|
1217
|
+
ids.push(item.id);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
if (typeof limit === "number" && limit >= 0 && ids.length > limit) {
|
|
1221
|
+
return ids.slice(0, limit);
|
|
1222
|
+
}
|
|
1223
|
+
return ids;
|
|
1224
|
+
},
|
|
1225
|
+
async applyEdits() {
|
|
1226
|
+
throw new HonuaCapabilityNotSupportedError("applyEdits", descriptor.protocol, descriptor.id);
|
|
1227
|
+
},
|
|
1228
|
+
async queryRelated() {
|
|
1229
|
+
throw new HonuaCapabilityNotSupportedError("queryRelated", descriptor.protocol, descriptor.id);
|
|
1230
|
+
},
|
|
1231
|
+
attachments: unsupportedAttachmentApi(descriptor),
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
// ── WFS 2.0 ───────────────────────────────────────────────────
|
|
1235
|
+
/**
|
|
1236
|
+
* Threshold above which the WFS adapter switches to POST GetFeature with a
|
|
1237
|
+
* `<fes:Filter>` body. URL budget under 7000 chars stays GET-friendly; longer
|
|
1238
|
+
* filters are routed through POST so we do not stress middleboxes that
|
|
1239
|
+
* trim long query strings. The threshold is intentionally a single
|
|
1240
|
+
* constant we can revise after telemetry lands.
|
|
1241
|
+
*/
|
|
1242
|
+
const WFS_GET_FILTER_BUDGET = 7000;
|
|
1243
|
+
const DEFAULT_WFS_GEOMETRY_PROPERTY = "the_geom";
|
|
1244
|
+
/**
|
|
1245
|
+
* Adapter factory for a WFS 2.0 endpoint.
|
|
1246
|
+
*
|
|
1247
|
+
* `Query.where` and `Query.spatialFilter` compile to FES 2.0; GeoJSON is
|
|
1248
|
+
* preferred over GML via `OperationsMetadata` negotiation. `applyEdits()`
|
|
1249
|
+
* builds `<wfs:Transaction>` bodies. Reach `Source.protocol("wfs")` for raw
|
|
1250
|
+
* GML / `LockFeature` / stored-query access.
|
|
1251
|
+
*
|
|
1252
|
+
* @example
|
|
1253
|
+
* ```ts
|
|
1254
|
+
* const dataset = createDataset({
|
|
1255
|
+
* id: "wfs-parcels",
|
|
1256
|
+
* client,
|
|
1257
|
+
* sources: [{
|
|
1258
|
+
* id: "parcels",
|
|
1259
|
+
* protocol: "wfs",
|
|
1260
|
+
* locator: { url: "https://your-honua-server.example/wfs", typeName: "ns:Parcels" },
|
|
1261
|
+
* capabilities: PROTOCOL_DEFAULT_CAPABILITIES.wfs,
|
|
1262
|
+
* }],
|
|
1263
|
+
* });
|
|
1264
|
+
* const result = await dataset.source("parcels")!.queryAll({
|
|
1265
|
+
* where: "STATUS = 'ACTIVE'",
|
|
1266
|
+
* pagination: { limit: 500 },
|
|
1267
|
+
* });
|
|
1268
|
+
* ```
|
|
1269
|
+
*/
|
|
1270
|
+
export function wfsSource(descriptor, client, policy) {
|
|
1271
|
+
const { url, typeName, featureNamespace, srsName: wfsSrsName } = requireWfsLocator(descriptor);
|
|
1272
|
+
const root = new HonuaWfs({ client, endpointUrl: url });
|
|
1273
|
+
const featureType = new HonuaWfsFeatureType({ root, typeName });
|
|
1274
|
+
const caps = descriptor.capabilities ?? PROTOCOL_DEFAULT_CAPABILITIES.wfs;
|
|
1275
|
+
void policy;
|
|
1276
|
+
const adapterRegistry = {
|
|
1277
|
+
wfs: featureType,
|
|
1278
|
+
};
|
|
1279
|
+
/**
|
|
1280
|
+
* Negotiated output format cache. Populated lazily on the first
|
|
1281
|
+
* capability-gated call so a no-op `createDataset({ sources: [...wfs] })`
|
|
1282
|
+
* does not issue network traffic.
|
|
1283
|
+
*/
|
|
1284
|
+
let cachedFormat;
|
|
1285
|
+
async function negotiateJsonOrThrow() {
|
|
1286
|
+
if (cachedFormat)
|
|
1287
|
+
return cachedFormat;
|
|
1288
|
+
const snapshot = await root.capabilities();
|
|
1289
|
+
const choice = root.negotiateOutputFormat(snapshot);
|
|
1290
|
+
if (!choice) {
|
|
1291
|
+
throw new HonuaCapabilityNotSupportedError("query", descriptor.protocol, descriptor.id);
|
|
1292
|
+
}
|
|
1293
|
+
if (choice.kind !== "json") {
|
|
1294
|
+
throw new HonuaCapabilityNotSupportedError("query", descriptor.protocol, `${descriptor.id} (server advertises only ${choice.format}; reach raw GML through Source.protocol("wfs"))`);
|
|
1295
|
+
}
|
|
1296
|
+
cachedFormat = choice;
|
|
1297
|
+
return choice;
|
|
1298
|
+
}
|
|
1299
|
+
return makeSource(descriptor, caps, policy, adapterRegistry, {
|
|
1300
|
+
async query(request) {
|
|
1301
|
+
ensureCapability(descriptor, caps, "query");
|
|
1302
|
+
if (request?.aggregation) {
|
|
1303
|
+
// WFS 2.0 has no server-side aggregation; refuse rather than ship a
|
|
1304
|
+
// silent partial result.
|
|
1305
|
+
throw new HonuaCapabilityNotSupportedError("queryAggregate", descriptor.protocol, descriptor.id);
|
|
1306
|
+
}
|
|
1307
|
+
// `pagination.limit === 0` is an explicit zero cap, not "unbounded";
|
|
1308
|
+
// short-circuit before the wire call so we do not silently widen to the
|
|
1309
|
+
// server's default page size.
|
|
1310
|
+
if (request?.pagination?.limit === 0) {
|
|
1311
|
+
return { features: [], exceededTransferLimit: false };
|
|
1312
|
+
}
|
|
1313
|
+
const choice = await negotiateJsonOrThrow();
|
|
1314
|
+
const json = await runGetFeatureJson(featureType, typeName, choice, request, request?.pagination?.limit);
|
|
1315
|
+
return resultFromGeoJson(json);
|
|
1316
|
+
},
|
|
1317
|
+
async queryAll(request) {
|
|
1318
|
+
ensureCapability(descriptor, caps, "query");
|
|
1319
|
+
const choice = await negotiateJsonOrThrow();
|
|
1320
|
+
const limit = request?.pagination?.limit;
|
|
1321
|
+
const target = typeof limit === "number" && limit >= 0 ? limit + 1 : Number.POSITIVE_INFINITY;
|
|
1322
|
+
const collected = [];
|
|
1323
|
+
let totalCount;
|
|
1324
|
+
let offset = request?.pagination?.offset ?? 0;
|
|
1325
|
+
// Mirror `withPagingBounds`: a finite limit (including 0) sets the
|
|
1326
|
+
// lookahead page size to `limit + 1` so we can stamp
|
|
1327
|
+
// `exceededTransferLimit` without overfetching a full default page.
|
|
1328
|
+
const pageSize = typeof limit === "number" && limit >= 0 ? Math.max(1, Math.min(limit + 1, 2000)) : 2000;
|
|
1329
|
+
while (collected.length < target) {
|
|
1330
|
+
const pageRequest = {
|
|
1331
|
+
...(request ?? {}),
|
|
1332
|
+
pagination: { offset, limit: pageSize },
|
|
1333
|
+
};
|
|
1334
|
+
const json = await runGetFeatureJson(featureType, typeName, choice, pageRequest, pageSize);
|
|
1335
|
+
const result = resultFromGeoJson(json);
|
|
1336
|
+
if (result.totalCount !== undefined)
|
|
1337
|
+
totalCount = result.totalCount;
|
|
1338
|
+
collected.push(...result.features);
|
|
1339
|
+
if (result.features.length < pageSize)
|
|
1340
|
+
break;
|
|
1341
|
+
offset += result.features.length;
|
|
1342
|
+
}
|
|
1343
|
+
const { features, exceededTransferLimit } = applyQueryAllLimit(collected, limit);
|
|
1344
|
+
return {
|
|
1345
|
+
features,
|
|
1346
|
+
exceededTransferLimit,
|
|
1347
|
+
totalCount: totalCount ?? features.length,
|
|
1348
|
+
};
|
|
1349
|
+
},
|
|
1350
|
+
async queryAggregate() {
|
|
1351
|
+
throw new HonuaCapabilityNotSupportedError("queryAggregate", descriptor.protocol, descriptor.id);
|
|
1352
|
+
},
|
|
1353
|
+
async queryExtent(request) {
|
|
1354
|
+
ensureCapability(descriptor, caps, "queryExtent");
|
|
1355
|
+
// Unfiltered queryExtent: prefer the per-feature-type WGS84BoundingBox
|
|
1356
|
+
// from GetCapabilities so we avoid an extra HTTP request entirely.
|
|
1357
|
+
if (!hasExtentFilter(request) && request?.outSr === undefined) {
|
|
1358
|
+
const snapshot = await root.capabilities();
|
|
1359
|
+
const ft = snapshot.featureTypes.find((entry) => entry.name === typeName);
|
|
1360
|
+
if (ft?.wgs84BoundingBox) {
|
|
1361
|
+
return { extent: { ...ft.wgs84BoundingBox } };
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
// Filtered request: drain every matching page so the returned extent
|
|
1365
|
+
// covers all features the filter resolves to, not just the first
|
|
1366
|
+
// server-default page. Caller pagination is intentionally ignored —
|
|
1367
|
+
// queryExtent is a "what bbox holds the matching set" question.
|
|
1368
|
+
const choice = await negotiateJsonOrThrow();
|
|
1369
|
+
const drainPageSize = 2000;
|
|
1370
|
+
const extentRequest = toWfsExtentDrainRequest(request);
|
|
1371
|
+
let xmin = Number.POSITIVE_INFINITY;
|
|
1372
|
+
let ymin = Number.POSITIVE_INFINITY;
|
|
1373
|
+
let xmax = Number.NEGATIVE_INFINITY;
|
|
1374
|
+
let ymax = Number.NEGATIVE_INFINITY;
|
|
1375
|
+
let saw = false;
|
|
1376
|
+
let count = 0;
|
|
1377
|
+
let offset = 0;
|
|
1378
|
+
while (true) {
|
|
1379
|
+
const pageRequest = {
|
|
1380
|
+
...extentRequest,
|
|
1381
|
+
pagination: { offset, limit: drainPageSize },
|
|
1382
|
+
};
|
|
1383
|
+
const json = await runGetFeatureJson(featureType, typeName, choice, pageRequest, drainPageSize);
|
|
1384
|
+
const page = computeExtentFromFeatureCollection(json);
|
|
1385
|
+
count += page.count;
|
|
1386
|
+
if (page.extent) {
|
|
1387
|
+
if (page.extent.xmin < xmin)
|
|
1388
|
+
xmin = page.extent.xmin;
|
|
1389
|
+
if (page.extent.ymin < ymin)
|
|
1390
|
+
ymin = page.extent.ymin;
|
|
1391
|
+
if (page.extent.xmax > xmax)
|
|
1392
|
+
xmax = page.extent.xmax;
|
|
1393
|
+
if (page.extent.ymax > ymax)
|
|
1394
|
+
ymax = page.extent.ymax;
|
|
1395
|
+
saw = true;
|
|
1396
|
+
}
|
|
1397
|
+
if (page.count < drainPageSize)
|
|
1398
|
+
break;
|
|
1399
|
+
offset += page.count;
|
|
1400
|
+
}
|
|
1401
|
+
return saw ? { extent: { xmin, ymin, xmax, ymax }, count } : { extent: null, count };
|
|
1402
|
+
},
|
|
1403
|
+
async *stream(request) {
|
|
1404
|
+
ensureCapability(descriptor, caps, "stream");
|
|
1405
|
+
// WFS 2.0 has no native streaming verb; iterate pages over GetFeature
|
|
1406
|
+
// and yield each as a Result. Reuses the same negotiation as `query`.
|
|
1407
|
+
const limit = request?.pagination?.limit;
|
|
1408
|
+
// `pagination.limit === 0` is an explicit zero cap; yield nothing
|
|
1409
|
+
// rather than silently fall back to the default 2000-row page size.
|
|
1410
|
+
if (limit === 0)
|
|
1411
|
+
return;
|
|
1412
|
+
const choice = await negotiateJsonOrThrow();
|
|
1413
|
+
const pageSize = typeof limit === "number" && limit > 0 ? Math.max(1, limit) : 2000;
|
|
1414
|
+
let offset = request?.pagination?.offset ?? 0;
|
|
1415
|
+
while (true) {
|
|
1416
|
+
const pageRequest = {
|
|
1417
|
+
...(request ?? {}),
|
|
1418
|
+
pagination: { offset, limit: pageSize },
|
|
1419
|
+
};
|
|
1420
|
+
const json = await runGetFeatureJson(featureType, typeName, choice, pageRequest, pageSize);
|
|
1421
|
+
const result = resultFromGeoJson(json);
|
|
1422
|
+
if (result.features.length === 0)
|
|
1423
|
+
break;
|
|
1424
|
+
yield {
|
|
1425
|
+
features: result.features,
|
|
1426
|
+
exceededTransferLimit: false,
|
|
1427
|
+
};
|
|
1428
|
+
if (result.features.length < pageSize)
|
|
1429
|
+
break;
|
|
1430
|
+
offset += result.features.length;
|
|
1431
|
+
}
|
|
1432
|
+
},
|
|
1433
|
+
async queryObjectIds(request) {
|
|
1434
|
+
ensureCapability(descriptor, caps, "queryObjectIds");
|
|
1435
|
+
// WFS does not expose a server-side ids-only mode that interoperates
|
|
1436
|
+
// across implementations; drain the matching set across pages and
|
|
1437
|
+
// project the GeoJSON `id`. `Query.pagination.limit` bounds the scan
|
|
1438
|
+
// as a global cap (not a per-page count) so callers can stop the drain
|
|
1439
|
+
// without learning the server's default page size. A finite limit
|
|
1440
|
+
// (including 0) is honored as a real cap; only `undefined` / negative
|
|
1441
|
+
// means "drain everything".
|
|
1442
|
+
const requestedLimit = request?.pagination?.limit;
|
|
1443
|
+
if (requestedLimit === 0)
|
|
1444
|
+
return [];
|
|
1445
|
+
const choice = await negotiateJsonOrThrow();
|
|
1446
|
+
const limitCap = typeof requestedLimit === "number" && requestedLimit > 0 ? requestedLimit : undefined;
|
|
1447
|
+
const drainPageSize = 2000;
|
|
1448
|
+
const ids = [];
|
|
1449
|
+
let offset = request?.pagination?.offset ?? 0;
|
|
1450
|
+
const idsRequest = toWfsObjectIdsDrainRequest(request);
|
|
1451
|
+
while (true) {
|
|
1452
|
+
const remainingCap = limitCap !== undefined ? Math.max(0, limitCap - ids.length) : undefined;
|
|
1453
|
+
if (remainingCap === 0)
|
|
1454
|
+
break;
|
|
1455
|
+
const pageSize = remainingCap !== undefined ? Math.min(drainPageSize, remainingCap) : drainPageSize;
|
|
1456
|
+
const pageRequest = {
|
|
1457
|
+
...idsRequest,
|
|
1458
|
+
pagination: { offset, limit: pageSize },
|
|
1459
|
+
};
|
|
1460
|
+
const json = await runGetFeatureJson(featureType, typeName, choice, pageRequest, pageSize);
|
|
1461
|
+
const collection = json;
|
|
1462
|
+
const features = collection.features ?? [];
|
|
1463
|
+
for (const feature of features) {
|
|
1464
|
+
if (feature.id !== undefined && feature.id !== null) {
|
|
1465
|
+
ids.push(feature.id);
|
|
1466
|
+
if (limitCap !== undefined && ids.length >= limitCap)
|
|
1467
|
+
break;
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
if (limitCap !== undefined && ids.length >= limitCap)
|
|
1471
|
+
break;
|
|
1472
|
+
if (features.length < pageSize)
|
|
1473
|
+
break;
|
|
1474
|
+
offset += features.length;
|
|
1475
|
+
}
|
|
1476
|
+
return ids;
|
|
1477
|
+
},
|
|
1478
|
+
async applyEdits(envelope) {
|
|
1479
|
+
ensureCapability(descriptor, caps, "applyEdits");
|
|
1480
|
+
// CanonicalFeature.id is required for updates because each <wfs:Update>
|
|
1481
|
+
// is filtered by `<fes:ResourceId>`; without an id the block would
|
|
1482
|
+
// mass-update every feature in the type. Mirror the OGC adapter's
|
|
1483
|
+
// per-item guard: push a failure outcome for each malformed update and
|
|
1484
|
+
// build the transaction with only the valid ones.
|
|
1485
|
+
const malformedUpdateIndices = new Set();
|
|
1486
|
+
const validUpdates = [];
|
|
1487
|
+
for (const [idx, update] of (envelope.updates ?? []).entries()) {
|
|
1488
|
+
if (update.id === undefined || update.id === null) {
|
|
1489
|
+
malformedUpdateIndices.add(idx);
|
|
1490
|
+
}
|
|
1491
|
+
else {
|
|
1492
|
+
validUpdates.push(update);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
const adds = envelope.adds ?? [];
|
|
1496
|
+
const deletes = envelope.deletes ?? [];
|
|
1497
|
+
let summary;
|
|
1498
|
+
if (adds.length === 0 && validUpdates.length === 0 && deletes.length === 0) {
|
|
1499
|
+
// Nothing to send — every operation was either absent or malformed.
|
|
1500
|
+
// Skip the wire round-trip entirely so the server never sees an
|
|
1501
|
+
// unaddressed transaction.
|
|
1502
|
+
summary = { totalInserted: 0, totalUpdated: 0, totalDeleted: 0, insertResults: [] };
|
|
1503
|
+
}
|
|
1504
|
+
else {
|
|
1505
|
+
const filtered = { ...envelope, updates: validUpdates };
|
|
1506
|
+
const body = buildTransactionBody(typeName, filtered, featureNamespace, wfsSrsName);
|
|
1507
|
+
const transactionOptions = { body };
|
|
1508
|
+
if (envelope.signal)
|
|
1509
|
+
transactionOptions.signal = envelope.signal;
|
|
1510
|
+
summary = await featureType.transaction(transactionOptions);
|
|
1511
|
+
}
|
|
1512
|
+
return canonicalEditResultFromTransaction(envelope, summary, malformedUpdateIndices);
|
|
1513
|
+
},
|
|
1514
|
+
async queryRelated() {
|
|
1515
|
+
throw new HonuaCapabilityNotSupportedError("queryRelated", descriptor.protocol, descriptor.id);
|
|
1516
|
+
},
|
|
1517
|
+
attachments: unsupportedAttachmentApi(descriptor),
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
function requireWfsLocator(descriptor) {
|
|
1521
|
+
const { url, typeName, featureNamespace, srsName } = descriptor.locator;
|
|
1522
|
+
if (typeof url !== "string" || url.length === 0) {
|
|
1523
|
+
throw new Error(`createDataset: source "${descriptor.id}" (wfs) requires locator.url`);
|
|
1524
|
+
}
|
|
1525
|
+
if (typeof typeName !== "string" || typeName.length === 0) {
|
|
1526
|
+
throw new Error(`createDataset: source "${descriptor.id}" (wfs) requires locator.typeName`);
|
|
1527
|
+
}
|
|
1528
|
+
if (featureNamespace !== undefined && (typeof featureNamespace !== "string" || featureNamespace.length === 0)) {
|
|
1529
|
+
throw new Error(`createDataset: source "${descriptor.id}" (wfs) locator.featureNamespace must be a non-empty string`);
|
|
1530
|
+
}
|
|
1531
|
+
return {
|
|
1532
|
+
url,
|
|
1533
|
+
typeName,
|
|
1534
|
+
featureNamespace: typeof featureNamespace === "string" ? featureNamespace : undefined,
|
|
1535
|
+
srsName: wfsSrsNameFromOutSr(srsName),
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
function toWfsExtentDrainRequest(request) {
|
|
1539
|
+
// The drain computes a bbox over geometries, so a caller-supplied
|
|
1540
|
+
// `returnGeometry: false` would suppress geometry on every page and
|
|
1541
|
+
// leave the extent empty. Strip it alongside `outFields` and
|
|
1542
|
+
// `pagination` so the drain always sees the full geometry-bearing
|
|
1543
|
+
// response regardless of the caller's projection / paging / geometry
|
|
1544
|
+
// intent.
|
|
1545
|
+
const { outFields: _outFields, pagination: _pagination, returnGeometry: _returnGeometry, ...extentRequest } = request ?? {};
|
|
1546
|
+
void _outFields;
|
|
1547
|
+
void _pagination;
|
|
1548
|
+
void _returnGeometry;
|
|
1549
|
+
return extentRequest;
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* `queryObjectIds` reads the GeoJSON `id` from each drained feature, so
|
|
1553
|
+
* neither `outFields` nor `returnGeometry` affects the result. Strip
|
|
1554
|
+
* both before issuing each `GetFeature` page so a caller-supplied
|
|
1555
|
+
* `returnGeometry: false` cannot throw the runGetFeatureJson guard
|
|
1556
|
+
* (which refuses to suppress geometry without an explicit `outFields`)
|
|
1557
|
+
* and so the drain's `propertyName=` does not balloon with the
|
|
1558
|
+
* geometry property the caller never asked for.
|
|
1559
|
+
*/
|
|
1560
|
+
function toWfsObjectIdsDrainRequest(request) {
|
|
1561
|
+
const { outFields: _outFields, returnGeometry: _returnGeometry, pagination: _pagination, ...idsRequest } = request ?? {};
|
|
1562
|
+
void _outFields;
|
|
1563
|
+
void _returnGeometry;
|
|
1564
|
+
void _pagination;
|
|
1565
|
+
return idsRequest;
|
|
1566
|
+
}
|
|
1567
|
+
/**
|
|
1568
|
+
* Compile `Query.where` and `Query.spatialFilter` into a (possibly empty)
|
|
1569
|
+
* FES filter and route the request through GET or POST GetFeature based on
|
|
1570
|
+
* the URL budget. Returns the decoded GeoJSON FeatureCollection.
|
|
1571
|
+
*/
|
|
1572
|
+
async function runGetFeatureJson(featureType, typeName, choice, request, pageSize) {
|
|
1573
|
+
const filterNodes = [];
|
|
1574
|
+
let needsPostBody = false;
|
|
1575
|
+
let bbox;
|
|
1576
|
+
if (request?.where !== undefined && request.where !== "") {
|
|
1577
|
+
const compiled = compileWhere(request.where);
|
|
1578
|
+
if (compiled === UNSUPPORTED_FES) {
|
|
1579
|
+
throw new HonuaCapabilityNotSupportedError("query", "wfs", `where clause is not expressible in FES; route the request through Source.protocol("wfs") to emit a custom filter`);
|
|
1580
|
+
}
|
|
1581
|
+
if (compiled.kind !== "and" || compiled.operands.length > 0) {
|
|
1582
|
+
filterNodes.push(compiled);
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
if (request?.spatialFilter) {
|
|
1586
|
+
const spatialRel = request.spatialFilter.spatialRel;
|
|
1587
|
+
// The `bbox=` KVP and `<fes:BBOX>` both encode envelope-intersects
|
|
1588
|
+
// semantics (OGC 09-026r2). Only take the bbox shortcut when the caller
|
|
1589
|
+
// wants intersects (default), envelope-intersects, or did not specify.
|
|
1590
|
+
// Anything else (Contains, Within, Crosses, Overlaps, Touches) needs an
|
|
1591
|
+
// FES predicate that preserves the requested relation, which means
|
|
1592
|
+
// routing the envelope through compileSpatialFilter so the geometry is
|
|
1593
|
+
// serialized as a polygon under the correct spatial op.
|
|
1594
|
+
const isEnvelopeIntersects = spatialRel === undefined ||
|
|
1595
|
+
spatialRel === "esriSpatialRelIntersects" ||
|
|
1596
|
+
spatialRel === "esriSpatialRelEnvelopeIntersects";
|
|
1597
|
+
if (request.spatialFilter.geometryType === "esriGeometryEnvelope" &&
|
|
1598
|
+
isEnvelopeIntersects &&
|
|
1599
|
+
filterNodes.length === 0 &&
|
|
1600
|
+
request.where === undefined) {
|
|
1601
|
+
// BBox-only requests are short and travel safely as a `bbox=` KVP, no
|
|
1602
|
+
// FES emission required. Saves a round-trip through XML serialization
|
|
1603
|
+
// and keeps the URL human-debuggable.
|
|
1604
|
+
const env = request.spatialFilter.geometry;
|
|
1605
|
+
if (typeof env.xmin === "number" &&
|
|
1606
|
+
typeof env.ymin === "number" &&
|
|
1607
|
+
typeof env.xmax === "number" &&
|
|
1608
|
+
typeof env.ymax === "number") {
|
|
1609
|
+
bbox = `${env.xmin},${env.ymin},${env.xmax},${env.ymax}`;
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
else {
|
|
1613
|
+
const filterSrsName = wfsSrsNameFromOutSr(request.outSr);
|
|
1614
|
+
const compiled = compileSpatialFilter(request.spatialFilter, {
|
|
1615
|
+
geometryProperty: DEFAULT_WFS_GEOMETRY_PROPERTY,
|
|
1616
|
+
...(filterSrsName !== undefined ? { srsName: filterSrsName } : {}),
|
|
1617
|
+
});
|
|
1618
|
+
if (compiled === UNSUPPORTED_FES) {
|
|
1619
|
+
throw new HonuaCapabilityNotSupportedError("query", "wfs", `spatial filter (geometryType=${request.spatialFilter.geometryType}, spatialRel=${request.spatialFilter.spatialRel ?? "default"}) is not expressible in FES; reach the wire through Source.protocol("wfs")`);
|
|
1620
|
+
}
|
|
1621
|
+
filterNodes.push(compiled);
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
const filterXml = filterNodes.length > 0 ? serializeFes(filterNodes, { typeName }) : undefined;
|
|
1625
|
+
if (filterXml !== undefined) {
|
|
1626
|
+
const encoded = encodeURIComponent(filterXml);
|
|
1627
|
+
if (encoded.length > WFS_GET_FILTER_BUDGET)
|
|
1628
|
+
needsPostBody = true;
|
|
1629
|
+
}
|
|
1630
|
+
const params = {};
|
|
1631
|
+
if (bbox !== undefined)
|
|
1632
|
+
params.bbox = bbox;
|
|
1633
|
+
if (filterXml !== undefined && !needsPostBody)
|
|
1634
|
+
params.filter = filterXml;
|
|
1635
|
+
// WFS `propertyName=` drops every property the caller does not list,
|
|
1636
|
+
// including the geometry column. Honor the canonical contract: when
|
|
1637
|
+
// `outFields` is set and `returnGeometry !== false`, append the
|
|
1638
|
+
// geometry property so geometry is preserved; when
|
|
1639
|
+
// `returnGeometry === false`, omit the geometry property; when
|
|
1640
|
+
// `returnGeometry === false` is asked without an `outFields` list,
|
|
1641
|
+
// refuse the request because WFS cannot suppress geometry without
|
|
1642
|
+
// enumerating every non-geometry property — silently widening to
|
|
1643
|
+
// "geometry included" would break the canonical contract.
|
|
1644
|
+
const callerOutFields = request?.outFields && request.outFields.length > 0 ? request.outFields : undefined;
|
|
1645
|
+
const wantsGeometry = request?.returnGeometry !== false;
|
|
1646
|
+
let propertyNames;
|
|
1647
|
+
if (callerOutFields !== undefined) {
|
|
1648
|
+
if (wantsGeometry) {
|
|
1649
|
+
const merged = [...callerOutFields];
|
|
1650
|
+
if (!merged.includes(DEFAULT_WFS_GEOMETRY_PROPERTY))
|
|
1651
|
+
merged.push(DEFAULT_WFS_GEOMETRY_PROPERTY);
|
|
1652
|
+
propertyNames = merged;
|
|
1653
|
+
}
|
|
1654
|
+
else {
|
|
1655
|
+
propertyNames = [...callerOutFields];
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
else if (request?.returnGeometry === false) {
|
|
1659
|
+
throw new HonuaCapabilityNotSupportedError("query", "wfs", `returnGeometry=false requires an explicit outFields list (WFS propertyName cannot suppress geometry without enumerating non-geometry properties); set Query.outFields or reach the wire through Source.protocol("wfs")`);
|
|
1660
|
+
}
|
|
1661
|
+
if (propertyNames)
|
|
1662
|
+
params.propertyName = [...propertyNames];
|
|
1663
|
+
const sortBy = request?.orderBy && request.orderBy.length > 0
|
|
1664
|
+
? request.orderBy.map((s) => `${s.field}${s.direction === "desc" ? " D" : " A"}`).join(",")
|
|
1665
|
+
: undefined;
|
|
1666
|
+
if (sortBy !== undefined)
|
|
1667
|
+
params.sortBy = sortBy;
|
|
1668
|
+
if (typeof pageSize === "number" && pageSize > 0) {
|
|
1669
|
+
params.count = pageSize;
|
|
1670
|
+
}
|
|
1671
|
+
else if (typeof request?.pagination?.limit === "number" && request.pagination.limit > 0) {
|
|
1672
|
+
params.count = request.pagination.limit;
|
|
1673
|
+
}
|
|
1674
|
+
if (typeof request?.pagination?.offset === "number" && request.pagination.offset > 0) {
|
|
1675
|
+
params.startIndex = request.pagination.offset;
|
|
1676
|
+
}
|
|
1677
|
+
const srsName = wfsSrsNameFromOutSr(request?.outSr);
|
|
1678
|
+
if (srsName !== undefined)
|
|
1679
|
+
params.srsName = srsName;
|
|
1680
|
+
params.outputFormat = choice.format;
|
|
1681
|
+
if (request?.signal)
|
|
1682
|
+
params.signal = request.signal;
|
|
1683
|
+
if (needsPostBody && filterXml !== undefined) {
|
|
1684
|
+
params.method = "POST";
|
|
1685
|
+
const postOptions = {
|
|
1686
|
+
typeName,
|
|
1687
|
+
filter: filterXml,
|
|
1688
|
+
count: params.count,
|
|
1689
|
+
startIndex: params.startIndex,
|
|
1690
|
+
outputFormat: choice.format,
|
|
1691
|
+
};
|
|
1692
|
+
if (propertyNames)
|
|
1693
|
+
postOptions.propertyNames = propertyNames;
|
|
1694
|
+
if (sortBy !== undefined)
|
|
1695
|
+
postOptions.sortBy = sortBy;
|
|
1696
|
+
if (srsName !== undefined)
|
|
1697
|
+
postOptions.srsName = srsName;
|
|
1698
|
+
params.body = buildPostGetFeatureBody(postOptions);
|
|
1699
|
+
}
|
|
1700
|
+
const response = await featureType.getFeature(params);
|
|
1701
|
+
if (response.kind !== "json") {
|
|
1702
|
+
throw new HonuaCapabilityNotSupportedError("query", "wfs", `WFS GetFeature returned ${response.contentType}; canonical surface only carries JSON. Reach raw output through Source.protocol("wfs")`);
|
|
1703
|
+
}
|
|
1704
|
+
return response.data;
|
|
1705
|
+
}
|
|
1706
|
+
function buildPostGetFeatureBody(options) {
|
|
1707
|
+
const countAttr = typeof options.count === "number" ? ` count="${options.count}"` : "";
|
|
1708
|
+
const startAttr = typeof options.startIndex === "number" ? ` startIndex="${options.startIndex}"` : "";
|
|
1709
|
+
const outputAttr = ` outputFormat="${escapeXmlAttr(options.outputFormat)}"`;
|
|
1710
|
+
const queryAttrs = options.srsName !== undefined ? ` srsName="${escapeXmlAttr(options.srsName)}"` : "";
|
|
1711
|
+
const propertyXml = options.propertyNames && options.propertyNames.length > 0
|
|
1712
|
+
? options.propertyNames.map((name) => `<wfs:PropertyName>${escapeXmlText(name)}</wfs:PropertyName>`).join("")
|
|
1713
|
+
: "";
|
|
1714
|
+
const sortByXml = options.sortBy !== undefined ? buildSortByXml(options.sortBy) : "";
|
|
1715
|
+
return `<wfs:GetFeature xmlns:wfs="http://www.opengis.net/wfs/2.0" xmlns:fes="http://www.opengis.net/fes/2.0" service="WFS" version="2.0.0"${outputAttr}${countAttr}${startAttr}><wfs:Query typeNames="${escapeXmlAttr(options.typeName)}"${queryAttrs}>${propertyXml}${options.filter}${sortByXml}</wfs:Query></wfs:GetFeature>`;
|
|
1716
|
+
}
|
|
1717
|
+
/**
|
|
1718
|
+
* Build a `<fes:SortBy>` block from the same comma-separated `sortBy` string
|
|
1719
|
+
* the GET path emits (`FIELD A,OTHER D`). Empty entries are skipped.
|
|
1720
|
+
*/
|
|
1721
|
+
function buildSortByXml(sortBy) {
|
|
1722
|
+
const entries = sortBy
|
|
1723
|
+
.split(",")
|
|
1724
|
+
.map((entry) => entry.trim())
|
|
1725
|
+
.filter((entry) => entry.length > 0);
|
|
1726
|
+
if (entries.length === 0)
|
|
1727
|
+
return "";
|
|
1728
|
+
const properties = entries.map((entry) => {
|
|
1729
|
+
const parts = entry.split(/\s+/);
|
|
1730
|
+
const field = parts[0];
|
|
1731
|
+
const direction = parts[1]?.toUpperCase() === "D" ? "DESC" : "ASC";
|
|
1732
|
+
return `<fes:SortProperty><fes:ValueReference>${escapeXmlText(field)}</fes:ValueReference><fes:SortOrder>${direction}</fes:SortOrder></fes:SortProperty>`;
|
|
1733
|
+
});
|
|
1734
|
+
return `<fes:SortBy>${properties.join("")}</fes:SortBy>`;
|
|
1735
|
+
}
|
|
1736
|
+
/**
|
|
1737
|
+
* Translate a canonical `Query.outSr` (string CRS URI / EPSG token, or numeric
|
|
1738
|
+
* WKID) into the WFS `srsName` form. Numeric WKIDs become the OGC URN form
|
|
1739
|
+
* `urn:ogc:def:crs:EPSG::<wkid>` so cross-server interop matches the format
|
|
1740
|
+
* advertised in `OperationsMetadata` / `Filter_Capabilities`.
|
|
1741
|
+
*/
|
|
1742
|
+
function wfsSrsNameFromOutSr(outSr) {
|
|
1743
|
+
if (typeof outSr === "string")
|
|
1744
|
+
return outSr.length > 0 ? outSr : undefined;
|
|
1745
|
+
if (typeof outSr === "number" && Number.isFinite(outSr))
|
|
1746
|
+
return `urn:ogc:def:crs:EPSG::${outSr}`;
|
|
1747
|
+
return undefined;
|
|
1748
|
+
}
|
|
1749
|
+
function escapeXmlAttr(value) {
|
|
1750
|
+
return value.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">");
|
|
1751
|
+
}
|
|
1752
|
+
function resultFromGeoJson(data) {
|
|
1753
|
+
if (typeof data !== "object" || data === null) {
|
|
1754
|
+
return { features: [], exceededTransferLimit: false };
|
|
1755
|
+
}
|
|
1756
|
+
const collection = data;
|
|
1757
|
+
const features = (collection.features ?? []).map((f) => ({
|
|
1758
|
+
attributes: (f.properties ?? {}),
|
|
1759
|
+
geometry: f.geometry,
|
|
1760
|
+
}));
|
|
1761
|
+
const totalCount = typeof collection.numberMatched === "number" && Number.isFinite(collection.numberMatched)
|
|
1762
|
+
? collection.numberMatched
|
|
1763
|
+
: undefined;
|
|
1764
|
+
const exceededTransferLimit = totalCount !== undefined && features.length < totalCount;
|
|
1765
|
+
const out = {
|
|
1766
|
+
features,
|
|
1767
|
+
exceededTransferLimit,
|
|
1768
|
+
};
|
|
1769
|
+
if (totalCount !== undefined)
|
|
1770
|
+
out.totalCount = totalCount;
|
|
1771
|
+
return out;
|
|
1772
|
+
}
|
|
1773
|
+
function computeExtentFromFeatureCollection(data) {
|
|
1774
|
+
if (typeof data !== "object" || data === null)
|
|
1775
|
+
return { extent: null, count: 0 };
|
|
1776
|
+
const collection = data;
|
|
1777
|
+
let xmin = Number.POSITIVE_INFINITY;
|
|
1778
|
+
let ymin = Number.POSITIVE_INFINITY;
|
|
1779
|
+
let xmax = Number.NEGATIVE_INFINITY;
|
|
1780
|
+
let ymax = Number.NEGATIVE_INFINITY;
|
|
1781
|
+
let saw = false;
|
|
1782
|
+
let count = 0;
|
|
1783
|
+
for (const feature of collection.features ?? []) {
|
|
1784
|
+
count += 1;
|
|
1785
|
+
visitGeometryCoords(feature.geometry, (x, y) => {
|
|
1786
|
+
if (x < xmin)
|
|
1787
|
+
xmin = x;
|
|
1788
|
+
if (y < ymin)
|
|
1789
|
+
ymin = y;
|
|
1790
|
+
if (x > xmax)
|
|
1791
|
+
xmax = x;
|
|
1792
|
+
if (y > ymax)
|
|
1793
|
+
ymax = y;
|
|
1794
|
+
saw = true;
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
if (!saw)
|
|
1798
|
+
return { extent: null, count };
|
|
1799
|
+
return { extent: { xmin, ymin, xmax, ymax }, count };
|
|
1800
|
+
}
|
|
1801
|
+
function buildTransactionBody(typeName, envelope, featureNamespace, srsName) {
|
|
1802
|
+
const releaseAction = envelope.rollbackOnFailure ? "ALL" : "SOME";
|
|
1803
|
+
const { prefix } = splitTypeName(typeName);
|
|
1804
|
+
// Bind the feature-namespace prefix on the Transaction root so prefixed
|
|
1805
|
+
// feature elements inside <wfs:Insert> and prefixed `typeName=` attribute
|
|
1806
|
+
// references on <wfs:Update>/<wfs:Delete> resolve. When the descriptor's
|
|
1807
|
+
// locator does not advertise `featureNamespace`, fall back to a synthetic
|
|
1808
|
+
// URN so the document is at least well-formed XML; strict servers will
|
|
1809
|
+
// reject the unknown URI with a diagnostic ExceptionReport that the caller
|
|
1810
|
+
// can resolve by setting `locator.featureNamespace`.
|
|
1811
|
+
const featureNs = prefix ? (featureNamespace ?? syntheticFeatureNamespace(prefix)) : undefined;
|
|
1812
|
+
const featureNsAttr = prefix && featureNs ? ` xmlns:${prefix}="${escapeXmlAttr(featureNs)}"` : "";
|
|
1813
|
+
const blocks = [];
|
|
1814
|
+
// Insert handles are read back in `canonicalEditResultFromTransaction`
|
|
1815
|
+
// to map server-echoed `<wfs:Feature handle="…">` entries inside
|
|
1816
|
+
// `<wfs:InsertResults>` onto the originating `envelope.adds[i]`. The
|
|
1817
|
+
// shared scheme lives in `wfsInsertHandle` so the build side and the
|
|
1818
|
+
// result-mapping side cannot drift.
|
|
1819
|
+
const adds = envelope.adds ?? [];
|
|
1820
|
+
for (let i = 0; i < adds.length; i += 1) {
|
|
1821
|
+
blocks.push(buildInsertBlock(typeName, adds[i], wfsInsertHandle(i), srsName));
|
|
1822
|
+
}
|
|
1823
|
+
let handleCounter = adds.length;
|
|
1824
|
+
for (const update of envelope.updates ?? []) {
|
|
1825
|
+
handleCounter += 1;
|
|
1826
|
+
blocks.push(buildUpdateBlock(typeName, update, `upd-${handleCounter}`, srsName));
|
|
1827
|
+
}
|
|
1828
|
+
for (const id of envelope.deletes ?? []) {
|
|
1829
|
+
handleCounter += 1;
|
|
1830
|
+
blocks.push(buildDeleteBlock(typeName, id, `del-${handleCounter}`));
|
|
1831
|
+
}
|
|
1832
|
+
return `<wfs:Transaction xmlns:wfs="http://www.opengis.net/wfs/2.0" xmlns:fes="http://www.opengis.net/fes/2.0" xmlns:gml="http://www.opengis.net/gml/3.2"${featureNsAttr} service="WFS" version="2.0.0" releaseAction="${releaseAction}">${blocks.join("")}</wfs:Transaction>`;
|
|
1833
|
+
}
|
|
1834
|
+
/**
|
|
1835
|
+
* Split a namespace-qualified WFS type name like `parcels:lot` into its
|
|
1836
|
+
* prefix and local components. Returns `{ prefix: undefined, local: typeName }`
|
|
1837
|
+
* for unprefixed names.
|
|
1838
|
+
*/
|
|
1839
|
+
function splitTypeName(typeName) {
|
|
1840
|
+
const colon = typeName.indexOf(":");
|
|
1841
|
+
if (colon < 0)
|
|
1842
|
+
return { prefix: undefined, local: typeName };
|
|
1843
|
+
return { prefix: typeName.slice(0, colon), local: typeName.slice(colon + 1) };
|
|
1844
|
+
}
|
|
1845
|
+
/**
|
|
1846
|
+
* Deterministic stub URI used when the locator does not declare a
|
|
1847
|
+
* `featureNamespace`. Servers that perform strict schema validation will
|
|
1848
|
+
* reject this URI with an `<ows:ExceptionReport>` rather than silently
|
|
1849
|
+
* accept the body — the message names the prefix so callers know which
|
|
1850
|
+
* `featureNamespace` to set on the descriptor.
|
|
1851
|
+
*/
|
|
1852
|
+
function syntheticFeatureNamespace(prefix) {
|
|
1853
|
+
return `urn:honua:wfs:feature-namespace:${prefix}`;
|
|
1854
|
+
}
|
|
1855
|
+
function buildInsertBlock(typeName, feature, handle, srsName) {
|
|
1856
|
+
const attributes = feature.attributes;
|
|
1857
|
+
const propertyXml = Object.entries(attributes)
|
|
1858
|
+
.filter(([, value]) => value !== undefined)
|
|
1859
|
+
.map(([key, value]) => `<${escapeXmlElement(key)}>${escapeXmlText(formatLiteral(value))}</${escapeXmlElement(key)}>`)
|
|
1860
|
+
.join("");
|
|
1861
|
+
const geometryXml = feature.geometry ? (geoJsonGeometryToGml(feature.geometry, srsName) ?? "") : "";
|
|
1862
|
+
const geometryProperty = escapeXmlElement(DEFAULT_WFS_GEOMETRY_PROPERTY);
|
|
1863
|
+
const featureXml = `<${escapeXmlElement(typeName)}>${propertyXml}${geometryXml ? `<${geometryProperty}>${geometryXml}</${geometryProperty}>` : ""}</${escapeXmlElement(typeName)}>`;
|
|
1864
|
+
return `<wfs:Insert handle="${handle}">${featureXml}</wfs:Insert>`;
|
|
1865
|
+
}
|
|
1866
|
+
function buildUpdateBlock(typeName, feature, handle, srsName) {
|
|
1867
|
+
const attributes = feature.attributes;
|
|
1868
|
+
const properties = Object.entries(attributes)
|
|
1869
|
+
.filter(([, value]) => value !== undefined)
|
|
1870
|
+
.map(([key, value]) => `<wfs:Property><wfs:ValueReference>${escapeXmlText(key)}</wfs:ValueReference><wfs:Value>${escapeXmlText(formatLiteral(value))}</wfs:Value></wfs:Property>`)
|
|
1871
|
+
.join("");
|
|
1872
|
+
const geometryProperty = feature.geometry
|
|
1873
|
+
? `<wfs:Property><wfs:ValueReference>${escapeXmlText(DEFAULT_WFS_GEOMETRY_PROPERTY)}</wfs:ValueReference><wfs:Value>${geoJsonGeometryToGml(feature.geometry, srsName) ?? ""}</wfs:Value></wfs:Property>`
|
|
1874
|
+
: "";
|
|
1875
|
+
const filter = feature.id !== undefined
|
|
1876
|
+
? `<fes:Filter><fes:ResourceId rid="${escapeXmlAttr(String(feature.id))}"/></fes:Filter>`
|
|
1877
|
+
: "";
|
|
1878
|
+
return `<wfs:Update handle="${handle}" typeName="${escapeXmlAttr(typeName)}">${properties}${geometryProperty}${filter}</wfs:Update>`;
|
|
1879
|
+
}
|
|
1880
|
+
function buildDeleteBlock(typeName, id, handle) {
|
|
1881
|
+
return `<wfs:Delete handle="${handle}" typeName="${escapeXmlAttr(typeName)}"><fes:Filter><fes:ResourceId rid="${escapeXmlAttr(String(id))}"/></fes:Filter></wfs:Delete>`;
|
|
1882
|
+
}
|
|
1883
|
+
function formatLiteral(value) {
|
|
1884
|
+
if (value === null)
|
|
1885
|
+
return "";
|
|
1886
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
1887
|
+
return String(value);
|
|
1888
|
+
return String(value);
|
|
1889
|
+
}
|
|
1890
|
+
function escapeXmlElement(name) {
|
|
1891
|
+
// Element names in WFS are namespace-qualified, but we treat the typeName
|
|
1892
|
+
// as a single token so callers may include `:` or `_`. Disallow anything
|
|
1893
|
+
// that could break the document.
|
|
1894
|
+
return name.replace(/[^A-Za-z0-9_:.-]/g, "_");
|
|
1895
|
+
}
|
|
1896
|
+
function escapeXmlText(value) {
|
|
1897
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
1898
|
+
}
|
|
1899
|
+
/**
|
|
1900
|
+
* Stable handle scheme for `<wfs:Insert>` blocks. The server is expected
|
|
1901
|
+
* (but not required by WFS 2.0) to echo this on the matching
|
|
1902
|
+
* `<wfs:Feature handle="…">` element inside `<wfs:InsertResults>`, which
|
|
1903
|
+
* lets `canonicalEditResultFromTransaction` map returned ResourceIds back
|
|
1904
|
+
* onto `envelope.adds[i]` even when the server reorders the buckets or
|
|
1905
|
+
* omits failed inserts under `releaseAction="SOME"`.
|
|
1906
|
+
*/
|
|
1907
|
+
function wfsInsertHandle(idx) {
|
|
1908
|
+
return `add-${idx + 1}`;
|
|
1909
|
+
}
|
|
1910
|
+
function canonicalEditResultFromTransaction(envelope, summary, malformedUpdateIndices = new Set()) {
|
|
1911
|
+
// Index `<wfs:InsertResults>` by the handle the server echoed so that
|
|
1912
|
+
// server-side reordering or `releaseAction="SOME"` partial failures do
|
|
1913
|
+
// not misassign ResourceIds to the wrong `envelope.adds[i]`. The
|
|
1914
|
+
// handle attribute is informational in the WFS 2.0 spec, so when the
|
|
1915
|
+
// server omits it on every `<wfs:Feature>` we fall back to the legacy
|
|
1916
|
+
// positional pairing rather than dropping every insert id silently.
|
|
1917
|
+
let anyHandleEchoed = false;
|
|
1918
|
+
const insertByHandle = new Map();
|
|
1919
|
+
for (const bucket of summary.insertResults) {
|
|
1920
|
+
if (bucket.handle !== undefined) {
|
|
1921
|
+
anyHandleEchoed = true;
|
|
1922
|
+
insertByHandle.set(bucket.handle, bucket.ids);
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
const added = (envelope.adds ?? []).map((_, idx) => {
|
|
1926
|
+
const matchedIds = anyHandleEchoed ? insertByHandle.get(wfsInsertHandle(idx)) : summary.insertResults[idx]?.ids;
|
|
1927
|
+
const insertedId = matchedIds?.[0];
|
|
1928
|
+
// When handles are echoed, presence in `InsertResults` is the
|
|
1929
|
+
// authoritative per-insert success signal (the server only emits a
|
|
1930
|
+
// bucket for the inserts that actually committed). Without handles
|
|
1931
|
+
// we fall back to the legacy "first N succeeded" heuristic anchored
|
|
1932
|
+
// on `totalInserted`.
|
|
1933
|
+
const ok = anyHandleEchoed ? matchedIds !== undefined : idx < summary.totalInserted;
|
|
1934
|
+
const out = { success: ok };
|
|
1935
|
+
if (insertedId !== undefined)
|
|
1936
|
+
out.id = insertedId;
|
|
1937
|
+
return out;
|
|
1938
|
+
});
|
|
1939
|
+
// Updates that were skipped because they had no id never went on the wire;
|
|
1940
|
+
// their outcome is a deterministic 400. The remaining indices align with
|
|
1941
|
+
// the transaction summary in the order the body sent them.
|
|
1942
|
+
const updated = [];
|
|
1943
|
+
let validUpdateIdx = 0;
|
|
1944
|
+
for (const [idx, update] of (envelope.updates ?? []).entries()) {
|
|
1945
|
+
if (malformedUpdateIndices.has(idx)) {
|
|
1946
|
+
updated.push({ success: false, error: { code: 400, description: "update.id is required" } });
|
|
1947
|
+
continue;
|
|
1948
|
+
}
|
|
1949
|
+
const out = { success: validUpdateIdx < summary.totalUpdated };
|
|
1950
|
+
if (update.id !== undefined)
|
|
1951
|
+
out.id = update.id;
|
|
1952
|
+
updated.push(out);
|
|
1953
|
+
validUpdateIdx += 1;
|
|
1954
|
+
}
|
|
1955
|
+
const deleted = (envelope.deletes ?? []).map((id, idx) => ({
|
|
1956
|
+
id,
|
|
1957
|
+
success: idx < summary.totalDeleted,
|
|
1958
|
+
}));
|
|
1959
|
+
return { added, updated, deleted };
|
|
1960
|
+
}
|
|
1961
|
+
// ── OData entity set ──────────────────────────────────────────
|
|
1962
|
+
/**
|
|
1963
|
+
* Build a `Source` over an OData v4 entity set. The `Query` translation
|
|
1964
|
+
* lowers `where` / `outFields` / `orderBy` / pagination / spatial filter
|
|
1965
|
+
* onto OData's `$`-prefixed query options. Dialect-specific operations
|
|
1966
|
+
* (`$batch`, `$apply`, `$search`, `$deltatoken`) live behind
|
|
1967
|
+
* `Source.protocol("odata")` on the returned `HonuaOdataEntitySet`.
|
|
1968
|
+
*
|
|
1969
|
+
* `$metadata` is fetched lazily on the first call that needs it and
|
|
1970
|
+
* cached on the entity-set instance. The fetched capability annotations
|
|
1971
|
+
* are intersected with the descriptor's declared `Capabilities` set the
|
|
1972
|
+
* first time a capability-gated method is invoked — this is the
|
|
1973
|
+
* precedent for the metadata-driven downgrade pattern referenced in
|
|
1974
|
+
* `docs/shared-client-contract.md`.
|
|
1975
|
+
*/
|
|
1976
|
+
/**
|
|
1977
|
+
* Adapter factory for an OData v4 entity set.
|
|
1978
|
+
*
|
|
1979
|
+
* `Query.where` compiles to OData `$filter`, `Query.outFields` becomes
|
|
1980
|
+
* `$select`, `Query.orderBy` becomes `$orderby`, and `Query.pagination`
|
|
1981
|
+
* becomes `$top`/`$skip`. Geospatial filters translate to OData's
|
|
1982
|
+
* `geo.intersects` / `geo.distance` family.
|
|
1983
|
+
*
|
|
1984
|
+
* @example
|
|
1985
|
+
* ```ts
|
|
1986
|
+
* const dataset = createDataset({
|
|
1987
|
+
* id: "incidents",
|
|
1988
|
+
* client,
|
|
1989
|
+
* sources: [{
|
|
1990
|
+
* id: "incidents",
|
|
1991
|
+
* protocol: "odata",
|
|
1992
|
+
* locator: { url: "https://your-honua-server.example/odata", entitySet: "Incidents" },
|
|
1993
|
+
* capabilities: PROTOCOL_DEFAULT_CAPABILITIES.odata,
|
|
1994
|
+
* }],
|
|
1995
|
+
* });
|
|
1996
|
+
* const result = await dataset.source("incidents")!.queryAll({
|
|
1997
|
+
* where: "Severity ge 3",
|
|
1998
|
+
* orderBy: [{ field: "ReportedAt", direction: "desc" }],
|
|
1999
|
+
* pagination: { limit: 100 },
|
|
2000
|
+
* });
|
|
2001
|
+
* ```
|
|
2002
|
+
*/
|
|
2003
|
+
export function odataSource(descriptor, client, policy) {
|
|
2004
|
+
const { entitySet, basePath } = requireOdataLocator(descriptor);
|
|
2005
|
+
const entity = new HonuaOdataEntitySet({ client, entitySet, basePath });
|
|
2006
|
+
const declaredCaps = descriptor.capabilities ?? PROTOCOL_DEFAULT_CAPABILITIES.odata;
|
|
2007
|
+
const negotiation = new OdataCapabilityNegotiator(entity, declaredCaps);
|
|
2008
|
+
const adapterRegistry = {
|
|
2009
|
+
odata: entity,
|
|
2010
|
+
};
|
|
2011
|
+
return makeSource(descriptor, declaredCaps, policy, adapterRegistry, {
|
|
2012
|
+
async query(request) {
|
|
2013
|
+
ensureCapability(descriptor, declaredCaps, "query");
|
|
2014
|
+
await negotiation.ensureAdvertised(descriptor, "query");
|
|
2015
|
+
const geomColumn = await resolveOdataGeometryColumn(entity, descriptor);
|
|
2016
|
+
const params = await buildOdataParams(entity, descriptor, request, { count: true, geomColumn });
|
|
2017
|
+
const page = await entity.query(params);
|
|
2018
|
+
return odataResultFromPage(descriptor, page, await negotiation.fieldsFor(descriptor.id), request?.returnGeometry, geomColumn);
|
|
2019
|
+
},
|
|
2020
|
+
async queryAll(request) {
|
|
2021
|
+
ensureCapability(descriptor, declaredCaps, "query");
|
|
2022
|
+
await negotiation.ensureAdvertised(descriptor, "query");
|
|
2023
|
+
const geomColumn = await resolveOdataGeometryColumn(entity, descriptor);
|
|
2024
|
+
const params = await buildOdataParams(entity, descriptor, request, { count: true, geomColumn });
|
|
2025
|
+
const limit = request?.pagination?.limit;
|
|
2026
|
+
// Lookahead row: ask the server for `limit + 1` so we can prove
|
|
2027
|
+
// truncation by collecting more than `limit` rows even when the
|
|
2028
|
+
// response carries no `@odata.nextLink`. Mirrors the GeoServices
|
|
2029
|
+
// and OGC `queryAll` truncation pattern.
|
|
2030
|
+
if (typeof limit === "number" && limit >= 0) {
|
|
2031
|
+
params.top = limit + 1;
|
|
2032
|
+
}
|
|
2033
|
+
const drained = await entity.queryAll(params);
|
|
2034
|
+
const allFeatures = drained.rows.map((row) => odataRowToFeature(row, descriptor, request?.returnGeometry, geomColumn));
|
|
2035
|
+
const { features: limited, exceededTransferLimit: collectedExceeded } = applyQueryAllLimit(allFeatures, limit);
|
|
2036
|
+
// Belt-and-braces: if the server respected `$top` exactly but
|
|
2037
|
+
// reports `@odata.count > limited.length`, that also proves
|
|
2038
|
+
// truncation. Either signal sets the flag.
|
|
2039
|
+
const countExceeded = typeof drained.totalCount === "number" && typeof limit === "number" && drained.totalCount > limited.length;
|
|
2040
|
+
const exceededTransferLimit = collectedExceeded || countExceeded;
|
|
2041
|
+
const fields = await negotiation.fieldsFor(descriptor.id);
|
|
2042
|
+
return {
|
|
2043
|
+
features: limited,
|
|
2044
|
+
exceededTransferLimit,
|
|
2045
|
+
...(typeof drained.totalCount === "number"
|
|
2046
|
+
? { totalCount: drained.totalCount }
|
|
2047
|
+
: { totalCount: limited.length }),
|
|
2048
|
+
...(fields.length > 0 ? { fields } : {}),
|
|
2049
|
+
};
|
|
2050
|
+
},
|
|
2051
|
+
async queryAggregate() {
|
|
2052
|
+
// OData aggregation is dialect-specific (`$apply` pipeline). The
|
|
2053
|
+
// canonical surface refuses; callers reach the typed escape hatch
|
|
2054
|
+
// (`Source.protocol("odata").apply(...)`) for the dialect surface.
|
|
2055
|
+
throw new HonuaCapabilityNotSupportedError("queryAggregate", descriptor.protocol, descriptor.id);
|
|
2056
|
+
},
|
|
2057
|
+
async queryExtent() {
|
|
2058
|
+
// OData does not expose an extent endpoint; computing one client-side
|
|
2059
|
+
// would require draining every matching row, which the canonical
|
|
2060
|
+
// surface refuses to do silently. Mirrors the OGC `attachments` /
|
|
2061
|
+
// `queryRelated` posture: refuse rather than ship a degraded path.
|
|
2062
|
+
throw new HonuaCapabilityNotSupportedError("queryExtent", descriptor.protocol, descriptor.id);
|
|
2063
|
+
},
|
|
2064
|
+
async *stream(request) {
|
|
2065
|
+
ensureCapability(descriptor, declaredCaps, "stream");
|
|
2066
|
+
await negotiation.ensureAdvertised(descriptor, "query");
|
|
2067
|
+
const geomColumn = await resolveOdataGeometryColumn(entity, descriptor);
|
|
2068
|
+
const params = await buildOdataParams(entity, descriptor, request, { count: false, geomColumn });
|
|
2069
|
+
const fields = await negotiation.fieldsFor(descriptor.id);
|
|
2070
|
+
const stream = entity.queryStream(params);
|
|
2071
|
+
for await (const page of stream) {
|
|
2072
|
+
yield odataResultFromPage(descriptor, page, fields, request?.returnGeometry, geomColumn);
|
|
2073
|
+
}
|
|
2074
|
+
},
|
|
2075
|
+
async queryObjectIds(request) {
|
|
2076
|
+
ensureCapability(descriptor, declaredCaps, "queryObjectIds");
|
|
2077
|
+
await negotiation.ensureAdvertised(descriptor, "query");
|
|
2078
|
+
const keyField = await negotiation.keyField(descriptor.id);
|
|
2079
|
+
const geomColumn = await resolveOdataGeometryColumn(entity, descriptor);
|
|
2080
|
+
const params = await buildOdataParams(entity, descriptor, request, { count: false, geomColumn });
|
|
2081
|
+
params.select = [keyField];
|
|
2082
|
+
// `queryObjectIds` projects only the key field, so `out.expand`
|
|
2083
|
+
// built from dotted `outFields` is irrelevant; clear it so a
|
|
2084
|
+
// caller-supplied projection cannot inflate the wire request.
|
|
2085
|
+
delete params.expand;
|
|
2086
|
+
// `entity.queryAll` respects `top` exactly. The canonical
|
|
2087
|
+
// `queryObjectIds` honors `Query.pagination.limit` as a hard cap
|
|
2088
|
+
// and slices the projection to it (mirrors the STAC adapter at
|
|
2089
|
+
// line 825). No lookahead row is needed because the result is
|
|
2090
|
+
// ids, not features — there is no `exceededTransferLimit` flag
|
|
2091
|
+
// to stamp.
|
|
2092
|
+
const limit = request?.pagination?.limit;
|
|
2093
|
+
const drained = await entity.queryAll(params);
|
|
2094
|
+
const ids = [];
|
|
2095
|
+
for (const row of drained.rows) {
|
|
2096
|
+
const value = row[keyField];
|
|
2097
|
+
if (value === undefined || value === null)
|
|
2098
|
+
continue;
|
|
2099
|
+
ids.push(typeof value === "number" ? value : String(value));
|
|
2100
|
+
}
|
|
2101
|
+
if (typeof limit === "number" && limit >= 0 && ids.length > limit) {
|
|
2102
|
+
return ids.slice(0, limit);
|
|
2103
|
+
}
|
|
2104
|
+
return ids;
|
|
2105
|
+
},
|
|
2106
|
+
async applyEdits(envelope) {
|
|
2107
|
+
ensureCapability(descriptor, declaredCaps, "applyEdits");
|
|
2108
|
+
await negotiation.ensureAdvertised(descriptor, "applyEdits");
|
|
2109
|
+
const keyFields = await negotiation.keyFields(descriptor.id);
|
|
2110
|
+
// Atomic path: when the caller asks for rollback-on-failure AND the
|
|
2111
|
+
// service advertises `$batch`, collapse the envelope into a single
|
|
2112
|
+
// OData batch with a shared `atomicityGroup` so a later failure
|
|
2113
|
+
// tears down earlier edits server-side. When `$batch` is not
|
|
2114
|
+
// advertised the rollback contract cannot be honored — degrade to
|
|
2115
|
+
// the per-call path and stamp `degraded[]` so downstream views can
|
|
2116
|
+
// flag the result.
|
|
2117
|
+
if (envelope.rollbackOnFailure === true) {
|
|
2118
|
+
const batchAdvertised = await negotiation.batchAdvertised();
|
|
2119
|
+
if (batchAdvertised) {
|
|
2120
|
+
return atomicOdataApplyEdits(entity, envelope, keyFields, descriptor);
|
|
2121
|
+
}
|
|
2122
|
+
const result = await perCallOdataApplyEdits(entity, envelope, keyFields);
|
|
2123
|
+
return {
|
|
2124
|
+
...result,
|
|
2125
|
+
degraded: [
|
|
2126
|
+
...(result.degraded ?? []),
|
|
2127
|
+
{
|
|
2128
|
+
capability: "applyEdits",
|
|
2129
|
+
protocol: descriptor.protocol,
|
|
2130
|
+
reason: "rollbackOnFailure was requested but the OData service does not advertise $batch; edits ran per-call without atomicity.",
|
|
2131
|
+
sourceId: descriptor.id,
|
|
2132
|
+
},
|
|
2133
|
+
],
|
|
2134
|
+
};
|
|
2135
|
+
}
|
|
2136
|
+
return perCallOdataApplyEdits(entity, envelope, keyFields);
|
|
2137
|
+
},
|
|
2138
|
+
async queryRelated() {
|
|
2139
|
+
// OData has no canonical related-records surface; navigation
|
|
2140
|
+
// properties live behind the typed escape hatch (`$expand` via
|
|
2141
|
+
// `protocol("odata").raw(...)` or `apply()` for cross-set joins).
|
|
2142
|
+
throw new HonuaCapabilityNotSupportedError("queryRelated", descriptor.protocol, descriptor.id);
|
|
2143
|
+
},
|
|
2144
|
+
attachments: unsupportedAttachmentApi(descriptor),
|
|
2145
|
+
});
|
|
2146
|
+
}
|
|
2147
|
+
/**
|
|
2148
|
+
* Per-call OData edits — issues independent POST/PATCH/DELETE requests
|
|
2149
|
+
* and collects per-row outcomes. This is the non-atomic fallback used
|
|
2150
|
+
* when `rollbackOnFailure` is unset or the server does not advertise
|
|
2151
|
+
* `$batch`.
|
|
2152
|
+
*/
|
|
2153
|
+
async function perCallOdataApplyEdits(entity, envelope, keyFields) {
|
|
2154
|
+
const added = [];
|
|
2155
|
+
const updated = [];
|
|
2156
|
+
const deleted = [];
|
|
2157
|
+
const { signal } = envelope;
|
|
2158
|
+
for (const add of envelope.adds ?? []) {
|
|
2159
|
+
try {
|
|
2160
|
+
const created = await entity.add(featureToOdataBody(add), {
|
|
2161
|
+
...(signal ? { signal } : {}),
|
|
2162
|
+
});
|
|
2163
|
+
const id = readKey(created, keyFields, add.id);
|
|
2164
|
+
added.push(id !== undefined ? { id, success: true } : { success: true });
|
|
2165
|
+
}
|
|
2166
|
+
catch (err) {
|
|
2167
|
+
added.push({ success: false, error: editErrorFromCatch(err) });
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
// Layer-scoped paths like `Layers(<n>)/Features` carry the parent key
|
|
2171
|
+
// (`LayerId`) in the URL itself, so the entity-set key parens only
|
|
2172
|
+
// need the non-parent components. `readKey` / `readKeyFromBody` keep
|
|
2173
|
+
// using the full `keyFields` because the response body still carries
|
|
2174
|
+
// every key.
|
|
2175
|
+
const urlKeys = urlKeyFields(entity.entitySet, keyFields);
|
|
2176
|
+
for (const update of envelope.updates ?? []) {
|
|
2177
|
+
const key = canonicalKeyToOdata(update, urlKeys);
|
|
2178
|
+
if (key === undefined) {
|
|
2179
|
+
updated.push({ success: false, error: { code: 400, description: "update.id is required" } });
|
|
2180
|
+
continue;
|
|
2181
|
+
}
|
|
2182
|
+
try {
|
|
2183
|
+
await entity.update(key, featureToOdataBody(update), { ...(signal ? { signal } : {}) });
|
|
2184
|
+
updated.push({ id: update.id ?? readKeyFromBody(update.attributes, keyFields), success: true });
|
|
2185
|
+
}
|
|
2186
|
+
catch (err) {
|
|
2187
|
+
updated.push({ id: update.id, success: false, error: editErrorFromCatch(err) });
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
for (const id of envelope.deletes ?? []) {
|
|
2191
|
+
const key = canonicalKeyToOdata({ id, attributes: {} }, urlKeys);
|
|
2192
|
+
if (key === undefined) {
|
|
2193
|
+
deleted.push({ success: false, error: { code: 400, description: "delete id is required" } });
|
|
2194
|
+
continue;
|
|
2195
|
+
}
|
|
2196
|
+
try {
|
|
2197
|
+
await entity.delete(key, { ...(signal ? { signal } : {}) });
|
|
2198
|
+
deleted.push({ id, success: true });
|
|
2199
|
+
}
|
|
2200
|
+
catch (err) {
|
|
2201
|
+
deleted.push({ id, success: false, error: editErrorFromCatch(err) });
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
return { added, updated, deleted };
|
|
2205
|
+
}
|
|
2206
|
+
/**
|
|
2207
|
+
* Atomic OData edits — collapses the envelope into a single
|
|
2208
|
+
* `$batch` request whose change-set wraps every operation in the same
|
|
2209
|
+
* `atomicityGroup`. Honua Server's `ODataBatchHandler` rolls back the
|
|
2210
|
+
* entire group when any operation fails. The batch responses are
|
|
2211
|
+
* threaded back into per-row `EditOutcome` entries in the original
|
|
2212
|
+
* order so callers see the same shape regardless of transport.
|
|
2213
|
+
*/
|
|
2214
|
+
async function atomicOdataApplyEdits(entity, envelope, keyFields, descriptor) {
|
|
2215
|
+
const operations = [];
|
|
2216
|
+
const plan = [];
|
|
2217
|
+
const bucketIndices = { add: [], update: [], delete: [] };
|
|
2218
|
+
// Layer-scoped paths like `Layers(<n>)/Features` carry the parent key
|
|
2219
|
+
// (`LayerId`) in the URL itself, so the entity-set key parens only
|
|
2220
|
+
// need the non-parent components.
|
|
2221
|
+
const urlKeys = urlKeyFields(entity.entitySet, keyFields);
|
|
2222
|
+
// Sequence ids deterministically so the response shape is predictable
|
|
2223
|
+
// and the test surface stays small.
|
|
2224
|
+
let seq = 1;
|
|
2225
|
+
const adds = envelope.adds ?? [];
|
|
2226
|
+
for (const add of adds) {
|
|
2227
|
+
const id = String(seq++);
|
|
2228
|
+
operations.push({
|
|
2229
|
+
id,
|
|
2230
|
+
method: "POST",
|
|
2231
|
+
url: stripLeadingSlashLocal(entity.entitySet),
|
|
2232
|
+
body: featureToOdataBody(add),
|
|
2233
|
+
});
|
|
2234
|
+
bucketIndices.add.push(plan.length);
|
|
2235
|
+
plan.push({ kind: "add", id: add.id });
|
|
2236
|
+
}
|
|
2237
|
+
const updates = envelope.updates ?? [];
|
|
2238
|
+
for (const update of updates) {
|
|
2239
|
+
const id = String(seq++);
|
|
2240
|
+
const key = canonicalKeyToOdata(update, urlKeys);
|
|
2241
|
+
if (key === undefined) {
|
|
2242
|
+
bucketIndices.update.push(plan.length);
|
|
2243
|
+
plan.push({ kind: "update", id: update.id });
|
|
2244
|
+
continue;
|
|
2245
|
+
}
|
|
2246
|
+
operations.push({
|
|
2247
|
+
id,
|
|
2248
|
+
method: "PATCH",
|
|
2249
|
+
url: `${entity.entitySet}(${key})`,
|
|
2250
|
+
body: featureToOdataBody(update),
|
|
2251
|
+
});
|
|
2252
|
+
bucketIndices.update.push(plan.length);
|
|
2253
|
+
plan.push({ kind: "update", id: update.id, key });
|
|
2254
|
+
}
|
|
2255
|
+
const deletes = envelope.deletes ?? [];
|
|
2256
|
+
for (const rawId of deletes) {
|
|
2257
|
+
const id = String(seq++);
|
|
2258
|
+
const key = canonicalKeyToOdata({ id: rawId, attributes: {} }, urlKeys);
|
|
2259
|
+
if (key === undefined) {
|
|
2260
|
+
bucketIndices.delete.push(plan.length);
|
|
2261
|
+
plan.push({ kind: "delete", id: rawId });
|
|
2262
|
+
continue;
|
|
2263
|
+
}
|
|
2264
|
+
operations.push({ id, method: "DELETE", url: `${entity.entitySet}(${key})` });
|
|
2265
|
+
bucketIndices.delete.push(plan.length);
|
|
2266
|
+
plan.push({ kind: "delete", id: rawId, key });
|
|
2267
|
+
}
|
|
2268
|
+
// Items with `key === undefined` were never added to operations; mark
|
|
2269
|
+
// their outcome here without consulting the response.
|
|
2270
|
+
const added = new Array(adds.length);
|
|
2271
|
+
const updated = new Array(updates.length);
|
|
2272
|
+
const deleted = new Array(deletes.length);
|
|
2273
|
+
for (let i = 0; i < updates.length; i += 1) {
|
|
2274
|
+
const planIdx = bucketIndices.update[i];
|
|
2275
|
+
if (plan[planIdx].key === undefined) {
|
|
2276
|
+
updated[i] = { success: false, error: { code: 400, description: "update.id is required" } };
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
for (let i = 0; i < deletes.length; i += 1) {
|
|
2280
|
+
const planIdx = bucketIndices.delete[i];
|
|
2281
|
+
if (plan[planIdx].key === undefined) {
|
|
2282
|
+
deleted[i] = { success: false, error: { code: 400, description: "delete id is required" } };
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
if (operations.length === 0) {
|
|
2286
|
+
// Every requested edit was malformed — return the validation outcomes
|
|
2287
|
+
// rather than send an empty batch.
|
|
2288
|
+
return { added, updated, deleted };
|
|
2289
|
+
}
|
|
2290
|
+
let batchOutcomes;
|
|
2291
|
+
try {
|
|
2292
|
+
const batchResult = await entity.batch(operations, {
|
|
2293
|
+
atomicity: "all",
|
|
2294
|
+
...(envelope.signal ? { signal: envelope.signal } : {}),
|
|
2295
|
+
});
|
|
2296
|
+
batchOutcomes = batchResult.responses;
|
|
2297
|
+
}
|
|
2298
|
+
catch (err) {
|
|
2299
|
+
// Whole-batch failure (network/auth/etc.). Apply the same error to
|
|
2300
|
+
// every requested row so the caller sees a uniform rollback signal.
|
|
2301
|
+
const error = editErrorFromCatch(err);
|
|
2302
|
+
for (let i = 0; i < adds.length; i += 1)
|
|
2303
|
+
added[i] = { success: false, error };
|
|
2304
|
+
for (let i = 0; i < updates.length; i += 1) {
|
|
2305
|
+
if (!updated[i])
|
|
2306
|
+
updated[i] = { id: updates[i].id, success: false, error };
|
|
2307
|
+
}
|
|
2308
|
+
for (let i = 0; i < deletes.length; i += 1) {
|
|
2309
|
+
if (!deleted[i])
|
|
2310
|
+
deleted[i] = { id: deletes[i], success: false, error };
|
|
2311
|
+
}
|
|
2312
|
+
return { added, updated, deleted };
|
|
2313
|
+
}
|
|
2314
|
+
// Index outcomes by id for O(1) lookup.
|
|
2315
|
+
const byId = new Map();
|
|
2316
|
+
for (const r of batchOutcomes)
|
|
2317
|
+
byId.set(String(r.id), r);
|
|
2318
|
+
// Walk the operations in submission order so outcome indices line up
|
|
2319
|
+
// with adds/updates/deletes positions.
|
|
2320
|
+
let opCursor = 0;
|
|
2321
|
+
for (let i = 0; i < adds.length; i += 1) {
|
|
2322
|
+
const id = operations[opCursor].id ?? String(opCursor + 1);
|
|
2323
|
+
opCursor += 1;
|
|
2324
|
+
const outcome = byId.get(String(id));
|
|
2325
|
+
added[i] = batchOutcomeToEdit(outcome, adds[i].id, keyFields);
|
|
2326
|
+
}
|
|
2327
|
+
for (let i = 0; i < updates.length; i += 1) {
|
|
2328
|
+
if (updated[i])
|
|
2329
|
+
continue; // skipped (no key)
|
|
2330
|
+
const id = operations[opCursor].id ?? String(opCursor + 1);
|
|
2331
|
+
opCursor += 1;
|
|
2332
|
+
const outcome = byId.get(String(id));
|
|
2333
|
+
updated[i] = batchOutcomeToEdit(outcome, updates[i].id, keyFields);
|
|
2334
|
+
}
|
|
2335
|
+
for (let i = 0; i < deletes.length; i += 1) {
|
|
2336
|
+
if (deleted[i])
|
|
2337
|
+
continue;
|
|
2338
|
+
const id = operations[opCursor].id ?? String(opCursor + 1);
|
|
2339
|
+
opCursor += 1;
|
|
2340
|
+
const outcome = byId.get(String(id));
|
|
2341
|
+
deleted[i] = batchOutcomeToEdit(outcome, deletes[i], keyFields);
|
|
2342
|
+
}
|
|
2343
|
+
void descriptor;
|
|
2344
|
+
return { added, updated, deleted };
|
|
2345
|
+
}
|
|
2346
|
+
function batchOutcomeToEdit(outcome, fallbackId, keyFields) {
|
|
2347
|
+
if (!outcome) {
|
|
2348
|
+
return {
|
|
2349
|
+
...(fallbackId !== undefined ? { id: fallbackId } : {}),
|
|
2350
|
+
success: false,
|
|
2351
|
+
error: { code: 0, description: "missing batch response" },
|
|
2352
|
+
};
|
|
2353
|
+
}
|
|
2354
|
+
if (outcome.status >= 200 && outcome.status < 300) {
|
|
2355
|
+
const body = outcome.body && typeof outcome.body === "object" ? outcome.body : undefined;
|
|
2356
|
+
const id = body ? readKey(body, keyFields, fallbackId) : fallbackId;
|
|
2357
|
+
return id !== undefined ? { id, success: true } : { success: true };
|
|
2358
|
+
}
|
|
2359
|
+
const description = describeBatchError(outcome.body, outcome.status);
|
|
2360
|
+
return {
|
|
2361
|
+
...(fallbackId !== undefined ? { id: fallbackId } : {}),
|
|
2362
|
+
success: false,
|
|
2363
|
+
error: { code: outcome.status, description },
|
|
2364
|
+
};
|
|
2365
|
+
}
|
|
2366
|
+
function describeBatchError(body, status) {
|
|
2367
|
+
if (body && typeof body === "object") {
|
|
2368
|
+
const err = body.error;
|
|
2369
|
+
if (err && typeof err.message === "string")
|
|
2370
|
+
return err.message;
|
|
2371
|
+
}
|
|
2372
|
+
return `HTTP ${status}`;
|
|
2373
|
+
}
|
|
2374
|
+
function stripLeadingSlashLocal(url) {
|
|
2375
|
+
return url.startsWith("/") ? url.slice(1) : url;
|
|
2376
|
+
}
|
|
2377
|
+
function requireOdataLocator(descriptor) {
|
|
2378
|
+
const { entitySet, url, layerId } = descriptor.locator;
|
|
2379
|
+
// Resolve the entity-set token. Honua Server's `SourceLocator` only
|
|
2380
|
+
// carries `url`, `serviceId`, and `layerId`, so server-produced OData
|
|
2381
|
+
// bindings arrive without `entitySet`. The canonical server route is
|
|
2382
|
+
// layer-scoped — `/odata/Layers(<layerId>)/Features` — so derive that
|
|
2383
|
+
// path when only `layerId` is provided. SDK callers that already pass
|
|
2384
|
+
// `entitySet` (the historical path) continue to win.
|
|
2385
|
+
let resolvedEntitySet;
|
|
2386
|
+
if (typeof entitySet === "string" && entitySet !== "") {
|
|
2387
|
+
resolvedEntitySet = entitySet;
|
|
2388
|
+
}
|
|
2389
|
+
else if (typeof layerId === "number" && Number.isFinite(layerId)) {
|
|
2390
|
+
resolvedEntitySet = `Layers(${layerId})/Features`;
|
|
2391
|
+
}
|
|
2392
|
+
if (resolvedEntitySet === undefined) {
|
|
2393
|
+
throw new Error(`createDataset: source "${descriptor.id}" (odata) requires locator.entitySet or locator.layerId`);
|
|
2394
|
+
}
|
|
2395
|
+
// `locator.url` is informational on the canonical surface; the request
|
|
2396
|
+
// path is built from `basePath/<entitySet>`. When `locator.url` carries
|
|
2397
|
+
// a path, treat its trailing path component as the basePath so a
|
|
2398
|
+
// descriptor pointing at `https://srv/odata/v4` still resolves correctly.
|
|
2399
|
+
const basePath = url ? extractOdataBasePath(url) : "/odata";
|
|
2400
|
+
return { entitySet: resolvedEntitySet, basePath };
|
|
2401
|
+
}
|
|
2402
|
+
function extractOdataBasePath(url) {
|
|
2403
|
+
try {
|
|
2404
|
+
const parsed = new URL(url);
|
|
2405
|
+
const pathname = trimTrailingSlashes(parsed.pathname);
|
|
2406
|
+
return pathname === "" ? "/odata" : pathname;
|
|
2407
|
+
}
|
|
2408
|
+
catch {
|
|
2409
|
+
// Relative URL: take it as the basePath verbatim.
|
|
2410
|
+
return url.startsWith("/") ? trimTrailingSlashes(url) || "/odata" : `/${trimTrailingSlashes(url)}`;
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
/**
|
|
2414
|
+
* Resolve the OData geometry column for a descriptor. Prefers
|
|
2415
|
+
* `descriptor.schema.fields` (where a field of type
|
|
2416
|
+
* `esriFieldTypeGeometry` names the column), then falls back to the
|
|
2417
|
+
* lazy `$metadata` probe (the first property typed `Edm.Geography` /
|
|
2418
|
+
* `Edm.Geometry` per CSDL parsing). Returns `undefined` when neither
|
|
2419
|
+
* declares one — callers fall back to the canonical name guesses
|
|
2420
|
+
* (`Geometry` / `Geography` / `Shape`) where the column name is
|
|
2421
|
+
* needed for client-side filtering only (never for emitting a
|
|
2422
|
+
* spatial filter, which throws when the column cannot be resolved).
|
|
2423
|
+
*/
|
|
2424
|
+
async function resolveOdataGeometryColumn(entity, descriptor) {
|
|
2425
|
+
const schemaCol = descriptor.schema?.fields?.find((f) => f.type === "esriFieldTypeGeometry")?.name;
|
|
2426
|
+
if (schemaCol)
|
|
2427
|
+
return schemaCol;
|
|
2428
|
+
try {
|
|
2429
|
+
const meta = await entity.metadata();
|
|
2430
|
+
const typeName = meta.entitySets[entity.entitySetName];
|
|
2431
|
+
const fields = (typeName ? meta.fields[typeName] : undefined) ?? [];
|
|
2432
|
+
return fields.find((f) => f.isSpatial)?.name;
|
|
2433
|
+
}
|
|
2434
|
+
catch {
|
|
2435
|
+
return undefined;
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
/**
|
|
2439
|
+
* Split a list of canonical `outFields` into a root `$select` list and
|
|
2440
|
+
* an OData `$expand` argument. Plain field names go into `$select`.
|
|
2441
|
+
* Dotted field paths (e.g. `Owner.name`) are translated to OData
|
|
2442
|
+
* navigation expands such as `Owner($select=name)`. Multi-level paths
|
|
2443
|
+
* (e.g. `Owner.address.street`) nest as
|
|
2444
|
+
* `Owner($expand=address($select=street))`. Multiple fields under the
|
|
2445
|
+
* same navigation share one expand entry
|
|
2446
|
+
* (`Owner($select=name,email)`) so the URL stays compact.
|
|
2447
|
+
*/
|
|
2448
|
+
function splitOdataOutFields(outFields) {
|
|
2449
|
+
const root = { select: [], children: new Map() };
|
|
2450
|
+
for (const field of outFields) {
|
|
2451
|
+
const segments = field.split(".");
|
|
2452
|
+
if (segments.length === 1) {
|
|
2453
|
+
root.select.push(segments[0]);
|
|
2454
|
+
continue;
|
|
2455
|
+
}
|
|
2456
|
+
let cursor = root;
|
|
2457
|
+
for (let i = 0; i < segments.length - 1; i += 1) {
|
|
2458
|
+
const seg = segments[i];
|
|
2459
|
+
let child = cursor.children.get(seg);
|
|
2460
|
+
if (!child) {
|
|
2461
|
+
child = { select: [], children: new Map() };
|
|
2462
|
+
cursor.children.set(seg, child);
|
|
2463
|
+
}
|
|
2464
|
+
cursor = child;
|
|
2465
|
+
}
|
|
2466
|
+
cursor.select.push(segments[segments.length - 1]);
|
|
2467
|
+
}
|
|
2468
|
+
function serialize(node) {
|
|
2469
|
+
const parts = [];
|
|
2470
|
+
if (node.select.length > 0)
|
|
2471
|
+
parts.push(`$select=${node.select.join(",")}`);
|
|
2472
|
+
if (node.children.size > 0) {
|
|
2473
|
+
const segs = [];
|
|
2474
|
+
for (const [name, child] of node.children) {
|
|
2475
|
+
const inner = serialize(child);
|
|
2476
|
+
segs.push(inner === "" ? name : `${name}(${inner})`);
|
|
2477
|
+
}
|
|
2478
|
+
parts.push(`$expand=${segs.join(",")}`);
|
|
2479
|
+
}
|
|
2480
|
+
return parts.join(";");
|
|
2481
|
+
}
|
|
2482
|
+
const expand = [];
|
|
2483
|
+
for (const [name, child] of root.children) {
|
|
2484
|
+
const inner = serialize(child);
|
|
2485
|
+
expand.push(inner === "" ? name : `${name}(${inner})`);
|
|
2486
|
+
}
|
|
2487
|
+
return { select: root.select, expand };
|
|
2488
|
+
}
|
|
2489
|
+
/**
|
|
2490
|
+
* Decide whether a candidate root `outFields` entry refers to the
|
|
2491
|
+
* geometry column. When the resolver supplied a column name (from
|
|
2492
|
+
* descriptor schema or metadata), match against it directly. The
|
|
2493
|
+
* canonical name guesses (`geometry`, `geography`, `shape`) are
|
|
2494
|
+
* preserved as a fallback so a server that emits a hard-coded geometry
|
|
2495
|
+
* field still drops out of `$select` even when neither schema nor
|
|
2496
|
+
* metadata declares one.
|
|
2497
|
+
*/
|
|
2498
|
+
function isOdataGeometryFieldName(field, resolved) {
|
|
2499
|
+
if (resolved && field === resolved)
|
|
2500
|
+
return true;
|
|
2501
|
+
if (resolved)
|
|
2502
|
+
return false;
|
|
2503
|
+
const lower = field.toLowerCase();
|
|
2504
|
+
return lower === "geometry" || lower === "geography" || lower === "shape";
|
|
2505
|
+
}
|
|
2506
|
+
/**
|
|
2507
|
+
* Build the OData query params for a canonical `Query`. Materializes
|
|
2508
|
+
* `$metadata` through the negotiator only when a translation rule needs
|
|
2509
|
+
* a geometry column or the key field — otherwise the request stays
|
|
2510
|
+
* metadata-free.
|
|
2511
|
+
*/
|
|
2512
|
+
async function buildOdataParams(entity, descriptor, request, options) {
|
|
2513
|
+
const out = {};
|
|
2514
|
+
if (options.count)
|
|
2515
|
+
out.count = true;
|
|
2516
|
+
if (!request)
|
|
2517
|
+
return out;
|
|
2518
|
+
const filterParts = [];
|
|
2519
|
+
if (request.where !== undefined && request.where !== "") {
|
|
2520
|
+
const rewritten = rewriteWhereToOdataFilter(request.where);
|
|
2521
|
+
if (rewritten !== "")
|
|
2522
|
+
filterParts.push(rewritten);
|
|
2523
|
+
}
|
|
2524
|
+
if (request.spatialFilter) {
|
|
2525
|
+
// `$metadata` is advisory here — it only refines the geometry column
|
|
2526
|
+
// and SRID context. When it is missing or failing, fall back to the
|
|
2527
|
+
// descriptor-derived `geomColumn` and column defaults instead of
|
|
2528
|
+
// rejecting the query (same degradation as `resolveOdataGeometryColumn`).
|
|
2529
|
+
const meta = await entity.metadata().catch(() => undefined);
|
|
2530
|
+
const typeName = meta ? meta.entitySets[entity.entitySetName] : undefined;
|
|
2531
|
+
const spatialFields = (typeName && meta ? meta.fields[typeName] : undefined) ?? [];
|
|
2532
|
+
// The WKT SRID stamps the **input** literal's coordinate system, not
|
|
2533
|
+
// the desired output SR (`Query.outSr` controls the response geometry
|
|
2534
|
+
// SR via column-side projection on the server). Derive the input SRID
|
|
2535
|
+
// from `spatialFilter.geometry.spatialReference` and fall back to the
|
|
2536
|
+
// metadata-declared column SRID; if neither is available, omit the
|
|
2537
|
+
// SRID prefix and let the column default apply. The geometry column
|
|
2538
|
+
// itself is preferred from `options.geomColumn` (descriptor schema
|
|
2539
|
+
// first, then metadata isSpatial) so a schema-declared column named
|
|
2540
|
+
// anything other than `Geometry`/`Geography`/`Shape` is honored.
|
|
2541
|
+
const ctx = {
|
|
2542
|
+
...(options.geomColumn ? { geometryColumn: options.geomColumn } : {}),
|
|
2543
|
+
...(spatialFields.length > 0 ? { geometryFields: spatialFields } : {}),
|
|
2544
|
+
};
|
|
2545
|
+
filterParts.push(buildOdataSpatialFilter(request.spatialFilter, ctx));
|
|
2546
|
+
}
|
|
2547
|
+
if (filterParts.length > 0)
|
|
2548
|
+
out.filter = filterParts.join(" and ");
|
|
2549
|
+
if (request.outFields && request.outFields.length > 0) {
|
|
2550
|
+
const split = splitOdataOutFields(request.outFields);
|
|
2551
|
+
let rootSelect = split.select;
|
|
2552
|
+
if (request.returnGeometry === false) {
|
|
2553
|
+
rootSelect = rootSelect.filter((f) => !isOdataGeometryFieldName(f, options.geomColumn));
|
|
2554
|
+
}
|
|
2555
|
+
if (rootSelect.length > 0)
|
|
2556
|
+
out.select = rootSelect;
|
|
2557
|
+
if (split.expand.length > 0)
|
|
2558
|
+
out.expand = split.expand;
|
|
2559
|
+
}
|
|
2560
|
+
else if (request.returnGeometry === false) {
|
|
2561
|
+
// No outFields supplied — derive `$select` from metadata so the
|
|
2562
|
+
// geometry column never reaches the wire. Falls through silently
|
|
2563
|
+
// when metadata is unavailable; the result-side dropper still
|
|
2564
|
+
// strips the geometry from the canonical Result.
|
|
2565
|
+
const meta = await entity.metadata().catch(() => undefined);
|
|
2566
|
+
if (meta) {
|
|
2567
|
+
const typeName = meta.entitySets[entity.entitySetName];
|
|
2568
|
+
const allFields = (typeName ? meta.fields[typeName] : undefined) ?? [];
|
|
2569
|
+
const nonSpatial = allFields
|
|
2570
|
+
.filter((f) => !f.isSpatial && (options.geomColumn === undefined || f.name !== options.geomColumn))
|
|
2571
|
+
.map((f) => f.name);
|
|
2572
|
+
if (nonSpatial.length > 0)
|
|
2573
|
+
out.select = nonSpatial;
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
if (request.orderBy && request.orderBy.length > 0) {
|
|
2577
|
+
out.orderBy = request.orderBy.map((s) => (s.direction === "desc" ? `${s.field} desc` : s.field));
|
|
2578
|
+
}
|
|
2579
|
+
if (request.pagination?.limit !== undefined)
|
|
2580
|
+
out.top = request.pagination.limit;
|
|
2581
|
+
if (request.pagination?.offset !== undefined && request.pagination.offset > 0) {
|
|
2582
|
+
out.skip = request.pagination.offset;
|
|
2583
|
+
}
|
|
2584
|
+
if (request.signal)
|
|
2585
|
+
out.signal = request.signal;
|
|
2586
|
+
void descriptor;
|
|
2587
|
+
return out;
|
|
2588
|
+
}
|
|
2589
|
+
function odataResultFromPage(descriptor, page, fields, returnGeometry, geomColumn) {
|
|
2590
|
+
const features = page.rows.map((row) => odataRowToFeature(row, descriptor, returnGeometry, geomColumn));
|
|
2591
|
+
const exceededTransferLimit = typeof page.totalCount === "number" ? features.length < page.totalCount : Boolean(page.nextLink);
|
|
2592
|
+
return {
|
|
2593
|
+
features,
|
|
2594
|
+
exceededTransferLimit,
|
|
2595
|
+
...(typeof page.totalCount === "number" ? { totalCount: page.totalCount } : {}),
|
|
2596
|
+
...(fields.length > 0 ? { fields } : {}),
|
|
2597
|
+
};
|
|
2598
|
+
}
|
|
2599
|
+
/**
|
|
2600
|
+
* Convert one OData JSON row into a canonical `HonuaTypedFeature`. The
|
|
2601
|
+
* geometry column (when the row carries one as GeoJSON via Honua Server's
|
|
2602
|
+
* spatial encoding) is split out from the attributes envelope. When
|
|
2603
|
+
* `returnGeometry === false`, the geometry column is dropped from both
|
|
2604
|
+
* the attributes envelope and the `geometry` field — defensive against
|
|
2605
|
+
* servers that ignore the `$select` exclusion (or callers that bypassed
|
|
2606
|
+
* metadata-derived selects). The geometry column is sourced from the
|
|
2607
|
+
* caller-resolved `geomColumn` (descriptor schema → metadata
|
|
2608
|
+
* `isSpatial`) and falls back to the canonical name guesses on the
|
|
2609
|
+
* row keys when neither declares one.
|
|
2610
|
+
*/
|
|
2611
|
+
function odataRowToFeature(row, descriptor, returnGeometry, geomColumn) {
|
|
2612
|
+
const geometryKey = geomColumn ??
|
|
2613
|
+
descriptor.schema?.fields?.find((f) => f.type === "esriFieldTypeGeometry")?.name ??
|
|
2614
|
+
findGeometryKey(row);
|
|
2615
|
+
if (geometryKey && row[geometryKey] !== undefined) {
|
|
2616
|
+
const { [geometryKey]: geometry, ...rest } = row;
|
|
2617
|
+
return {
|
|
2618
|
+
attributes: rest,
|
|
2619
|
+
geometry: returnGeometry === false ? null : (geometry ?? null),
|
|
2620
|
+
};
|
|
2621
|
+
}
|
|
2622
|
+
return { attributes: row, geometry: null };
|
|
2623
|
+
}
|
|
2624
|
+
function findGeometryKey(row) {
|
|
2625
|
+
for (const key of Object.keys(row)) {
|
|
2626
|
+
const lower = key.toLowerCase();
|
|
2627
|
+
if (lower === "geometry" || lower === "geography" || lower === "shape")
|
|
2628
|
+
return key;
|
|
2629
|
+
}
|
|
2630
|
+
return undefined;
|
|
2631
|
+
}
|
|
2632
|
+
function featureToOdataBody(feature) {
|
|
2633
|
+
const body = { ...feature.attributes };
|
|
2634
|
+
if (feature.geometry !== undefined && feature.geometry !== null) {
|
|
2635
|
+
if (body.Geometry === undefined && body.geometry === undefined) {
|
|
2636
|
+
body.Geometry = feature.geometry;
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
return body;
|
|
2640
|
+
}
|
|
2641
|
+
function readKey(body, keyFields, fallback) {
|
|
2642
|
+
if (keyFields.length === 1) {
|
|
2643
|
+
const value = body[keyFields[0]];
|
|
2644
|
+
if (typeof value === "number" || typeof value === "string")
|
|
2645
|
+
return value;
|
|
2646
|
+
}
|
|
2647
|
+
if (keyFields.length > 1) {
|
|
2648
|
+
// Composite key: surface as `Field=value,Field=value`. Numeric fallback
|
|
2649
|
+
// is preferred when only one of the key parts is OBJECTID-like to keep
|
|
2650
|
+
// the EditOutcome.id readable.
|
|
2651
|
+
const objectId = body.ObjectId ?? body.objectId ?? body.OBJECTID;
|
|
2652
|
+
if (typeof objectId === "number" || typeof objectId === "string")
|
|
2653
|
+
return objectId;
|
|
2654
|
+
}
|
|
2655
|
+
return fallback;
|
|
2656
|
+
}
|
|
2657
|
+
function readKeyFromBody(attributes, keyFields) {
|
|
2658
|
+
if (typeof attributes !== "object" || attributes === null)
|
|
2659
|
+
return undefined;
|
|
2660
|
+
return readKey(attributes, keyFields, undefined);
|
|
2661
|
+
}
|
|
2662
|
+
/**
|
|
2663
|
+
* For OData navigation paths like `Layers(<n>)/Features`, the parent key
|
|
2664
|
+
* (`LayerId`) is already encoded in the URL path — the entity-set key
|
|
2665
|
+
* parens should only carry the non-parent components of the composite
|
|
2666
|
+
* key. Returns the metadata key fields with the parent-key field removed
|
|
2667
|
+
* when the path is layer-scoped, so a caller addressing a row by its
|
|
2668
|
+
* bare ObjectId still produces a valid `Features(<objectId>)` URL.
|
|
2669
|
+
*
|
|
2670
|
+
* Direct paths (e.g. `Parcels`) return the input key fields unchanged.
|
|
2671
|
+
*/
|
|
2672
|
+
function urlKeyFields(entitySetPath, keyFields) {
|
|
2673
|
+
if (!/^Layers\(\d+\)\/Features$/i.test(entitySetPath))
|
|
2674
|
+
return keyFields;
|
|
2675
|
+
const filtered = keyFields.filter((f) => f.toLowerCase() !== "layerid");
|
|
2676
|
+
// Defense in depth: if filtering eliminates every key field (the
|
|
2677
|
+
// metadata declared LayerId-only) fall back to the original list so
|
|
2678
|
+
// the formatter at least has something to work with.
|
|
2679
|
+
return filtered.length === 0 ? keyFields : filtered;
|
|
2680
|
+
}
|
|
2681
|
+
function canonicalKeyToOdata(feature, keyFields) {
|
|
2682
|
+
if (keyFields.length === 0) {
|
|
2683
|
+
if (feature.id === undefined || feature.id === null)
|
|
2684
|
+
return undefined;
|
|
2685
|
+
return formatOdataKeyValue(feature.id);
|
|
2686
|
+
}
|
|
2687
|
+
if (keyFields.length === 1) {
|
|
2688
|
+
const fieldName = keyFields[0];
|
|
2689
|
+
const value = feature.attributes?.[fieldName] ?? feature.id;
|
|
2690
|
+
if (value === undefined || value === null)
|
|
2691
|
+
return undefined;
|
|
2692
|
+
return formatOdataKeyValue(value);
|
|
2693
|
+
}
|
|
2694
|
+
// Composite key — prefer attribute-derived components. When `feature.id`
|
|
2695
|
+
// is a pre-formatted key expression (`LayerId=1,ObjectId=3` or the bare
|
|
2696
|
+
// `1,3` tuple), use it verbatim so callers that address composite-key
|
|
2697
|
+
// rows on the canonical `deletes: FeatureId[]` envelope still work.
|
|
2698
|
+
if (typeof feature.id === "string" && (feature.id.includes("=") || feature.id.includes(","))) {
|
|
2699
|
+
return feature.id;
|
|
2700
|
+
}
|
|
2701
|
+
const parts = [];
|
|
2702
|
+
for (const fieldName of keyFields) {
|
|
2703
|
+
const value = feature.attributes?.[fieldName];
|
|
2704
|
+
if (value === undefined || value === null) {
|
|
2705
|
+
// Allow a single-value `feature.id` to fill the ObjectId component
|
|
2706
|
+
// when the key is the conventional Honua (LayerId, ObjectId) pair
|
|
2707
|
+
// and the caller addressed the row by its bare ObjectId.
|
|
2708
|
+
if (fieldName === "ObjectId" && (typeof feature.id === "number" || typeof feature.id === "string")) {
|
|
2709
|
+
parts.push(`${fieldName}=${formatOdataKeyValue(feature.id)}`);
|
|
2710
|
+
continue;
|
|
2711
|
+
}
|
|
2712
|
+
return undefined;
|
|
2713
|
+
}
|
|
2714
|
+
parts.push(`${fieldName}=${formatOdataKeyValue(value)}`);
|
|
2715
|
+
}
|
|
2716
|
+
return parts.join(",");
|
|
2717
|
+
}
|
|
2718
|
+
function formatOdataKeyValue(value) {
|
|
2719
|
+
if (typeof value === "number")
|
|
2720
|
+
return String(value);
|
|
2721
|
+
// Strings are quoted; numeric strings stay as numbers because OData's
|
|
2722
|
+
// key syntax accepts both for Edm.Int* keys.
|
|
2723
|
+
const numeric = Number(value);
|
|
2724
|
+
if (Number.isFinite(numeric) && String(numeric) === value)
|
|
2725
|
+
return String(numeric);
|
|
2726
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
2727
|
+
}
|
|
2728
|
+
/**
|
|
2729
|
+
* Lazy, per-source negotiator that fetches `$metadata` exactly once and
|
|
2730
|
+
* intersects the declared capability set with what the service actually
|
|
2731
|
+
* advertises through `Capabilities.*` annotations. The intersection is
|
|
2732
|
+
* surfaced as `HonuaCapabilityNotSupportedError` on the first call that
|
|
2733
|
+
* needs the missing capability.
|
|
2734
|
+
*/
|
|
2735
|
+
class OdataCapabilityNegotiator {
|
|
2736
|
+
entity;
|
|
2737
|
+
declared;
|
|
2738
|
+
metaPromise;
|
|
2739
|
+
fieldsCache;
|
|
2740
|
+
constructor(entity, declared) {
|
|
2741
|
+
this.entity = entity;
|
|
2742
|
+
this.declared = [...declared];
|
|
2743
|
+
}
|
|
2744
|
+
async ensureAdvertised(descriptor, capability) {
|
|
2745
|
+
// Only check capabilities the descriptor declares — caller-passed
|
|
2746
|
+
// narrower sets short-circuit through `ensureCapability` before this
|
|
2747
|
+
// is reached.
|
|
2748
|
+
if (!this.declared.includes(capability))
|
|
2749
|
+
return;
|
|
2750
|
+
// A missing or failing `$metadata` endpoint is allowed at runtime —
|
|
2751
|
+
// degrade to the descriptor's declared capability set instead of
|
|
2752
|
+
// rejecting the canonical call, mirroring `fieldsFor` / `keyFields` /
|
|
2753
|
+
// `batchAdvertised`. Errors from the data request itself still
|
|
2754
|
+
// surface from `query`.
|
|
2755
|
+
const meta = await this.materialize().catch(() => undefined);
|
|
2756
|
+
if (!meta)
|
|
2757
|
+
return;
|
|
2758
|
+
const advertised = meta.capabilities[this.entity.entitySetName] ?? {};
|
|
2759
|
+
const flag = advertisedFlag(advertised, capability);
|
|
2760
|
+
if (flag === false) {
|
|
2761
|
+
throw new HonuaCapabilityNotSupportedError(capability, descriptor.protocol, descriptor.id);
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
async fieldsFor(_sourceId) {
|
|
2765
|
+
void _sourceId;
|
|
2766
|
+
if (this.fieldsCache)
|
|
2767
|
+
return this.fieldsCache;
|
|
2768
|
+
try {
|
|
2769
|
+
const meta = await this.materialize();
|
|
2770
|
+
const fields = odataFieldSchema(meta, this.entity.entitySetName);
|
|
2771
|
+
this.fieldsCache = fields;
|
|
2772
|
+
return fields;
|
|
2773
|
+
}
|
|
2774
|
+
catch {
|
|
2775
|
+
// A missing `$metadata` endpoint is allowed at runtime — the
|
|
2776
|
+
// canonical surface still works, just without the schema in the
|
|
2777
|
+
// result envelope. Errors are surfaced from `query` itself so the
|
|
2778
|
+
// adapter does not double-throw.
|
|
2779
|
+
this.fieldsCache = [];
|
|
2780
|
+
return this.fieldsCache;
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
async keyFields(_sourceId) {
|
|
2784
|
+
void _sourceId;
|
|
2785
|
+
const meta = await this.materialize().catch(() => undefined);
|
|
2786
|
+
if (!meta)
|
|
2787
|
+
return [];
|
|
2788
|
+
const typeName = meta.entitySets[this.entity.entitySetName];
|
|
2789
|
+
if (!typeName)
|
|
2790
|
+
return [];
|
|
2791
|
+
return meta.keys[typeName] ?? [];
|
|
2792
|
+
}
|
|
2793
|
+
async keyField(sourceId) {
|
|
2794
|
+
const keys = await this.keyFields(sourceId);
|
|
2795
|
+
if (keys.length === 1)
|
|
2796
|
+
return keys[0];
|
|
2797
|
+
// Composite key: prefer the conventional Honua `ObjectId` field for
|
|
2798
|
+
// `queryObjectIds()` so the canonical surface returns a flat
|
|
2799
|
+
// `FeatureId[]` instead of composite-key strings.
|
|
2800
|
+
if (keys.includes("ObjectId"))
|
|
2801
|
+
return "ObjectId";
|
|
2802
|
+
if (keys.length > 0)
|
|
2803
|
+
return keys[0];
|
|
2804
|
+
return "ObjectId";
|
|
2805
|
+
}
|
|
2806
|
+
/**
|
|
2807
|
+
* Returns true when the entity-set's `Capabilities.BatchSupported`
|
|
2808
|
+
* annotation is `true`, false when explicitly `false`, and true by
|
|
2809
|
+
* default when the metadata is unavailable (the OData spec defaults
|
|
2810
|
+
* `$batch` to supported when the annotation is absent).
|
|
2811
|
+
*/
|
|
2812
|
+
async batchAdvertised() {
|
|
2813
|
+
const meta = await this.materialize().catch(() => undefined);
|
|
2814
|
+
if (!meta)
|
|
2815
|
+
return true;
|
|
2816
|
+
const flag = meta.capabilities[this.entity.entitySetName]?.batch;
|
|
2817
|
+
return flag !== false;
|
|
2818
|
+
}
|
|
2819
|
+
materialize() {
|
|
2820
|
+
if (!this.metaPromise) {
|
|
2821
|
+
this.metaPromise = this.entity.metadata();
|
|
2822
|
+
}
|
|
2823
|
+
return this.metaPromise;
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
function advertisedFlag(advertised, capability) {
|
|
2827
|
+
switch (capability) {
|
|
2828
|
+
case "query":
|
|
2829
|
+
return advertised.query;
|
|
2830
|
+
case "stream":
|
|
2831
|
+
return advertised.query;
|
|
2832
|
+
case "queryObjectIds":
|
|
2833
|
+
return advertised.query;
|
|
2834
|
+
case "applyEdits":
|
|
2835
|
+
// Treat the union as the canonical "applyEdits" gate — adapters
|
|
2836
|
+
// refuse only when every relevant flag is explicitly false.
|
|
2837
|
+
if (advertised.insert === false && advertised.update === false && advertised.delete === false) {
|
|
2838
|
+
return false;
|
|
2839
|
+
}
|
|
2840
|
+
return undefined;
|
|
2841
|
+
default:
|
|
2842
|
+
return undefined;
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
function makeSource(descriptor, caps, _policy, adapters, impl) {
|
|
2846
|
+
function lookupAdapter(kind) {
|
|
2847
|
+
return adapters[kind];
|
|
2848
|
+
}
|
|
2849
|
+
return {
|
|
2850
|
+
descriptor: { ...descriptor, capabilities: caps },
|
|
2851
|
+
capabilities: caps,
|
|
2852
|
+
query: impl.query.bind(impl),
|
|
2853
|
+
queryAll: impl.queryAll.bind(impl),
|
|
2854
|
+
queryAggregate: impl.queryAggregate.bind(impl),
|
|
2855
|
+
queryExtent: impl.queryExtent.bind(impl),
|
|
2856
|
+
stream: impl.stream.bind(impl),
|
|
2857
|
+
queryObjectIds: impl.queryObjectIds.bind(impl),
|
|
2858
|
+
applyEdits: impl.applyEdits.bind(impl),
|
|
2859
|
+
queryRelated: impl.queryRelated.bind(impl),
|
|
2860
|
+
attachments: impl.attachments,
|
|
2861
|
+
protocol: lookupAdapter,
|
|
2862
|
+
adapter: lookupAdapter,
|
|
2863
|
+
};
|
|
2864
|
+
}
|
|
2865
|
+
/**
|
|
2866
|
+
* Helper: build a no-attachment-support `AttachmentApi` whose every method
|
|
2867
|
+
* throws `HonuaCapabilityNotSupportedError` for the descriptor. Used by
|
|
2868
|
+
* adapters that do not advertise `attachments` so the namespace property
|
|
2869
|
+
* is present (callers can always read `source.attachments`) but the calls
|
|
2870
|
+
* themselves participate in capability negotiation.
|
|
2871
|
+
*/
|
|
2872
|
+
function unsupportedAttachmentApi(descriptor) {
|
|
2873
|
+
const fail = () => {
|
|
2874
|
+
throw new HonuaCapabilityNotSupportedError("attachments", descriptor.protocol, descriptor.id);
|
|
2875
|
+
};
|
|
2876
|
+
return {
|
|
2877
|
+
async query() {
|
|
2878
|
+
return fail();
|
|
2879
|
+
},
|
|
2880
|
+
async list() {
|
|
2881
|
+
return fail();
|
|
2882
|
+
},
|
|
2883
|
+
async add() {
|
|
2884
|
+
return fail();
|
|
2885
|
+
},
|
|
2886
|
+
async update() {
|
|
2887
|
+
return fail();
|
|
2888
|
+
},
|
|
2889
|
+
async delete() {
|
|
2890
|
+
return fail();
|
|
2891
|
+
},
|
|
2892
|
+
};
|
|
2893
|
+
}
|
|
2894
|
+
/**
|
|
2895
|
+
* Build a `SourceImplementation` skeleton whose every method throws
|
|
2896
|
+
* `HonuaCapabilityNotSupportedError`. Used by the geometry- and gp-service
|
|
2897
|
+
* sources, which do not host features and only surface protocol-specific
|
|
2898
|
+
* operations through `Source.protocol()`.
|
|
2899
|
+
*/
|
|
2900
|
+
function unsupportedFeatureSurface(descriptor) {
|
|
2901
|
+
const fail = (capability) => {
|
|
2902
|
+
throw new HonuaCapabilityNotSupportedError(capability, descriptor.protocol, descriptor.id);
|
|
2903
|
+
};
|
|
2904
|
+
return {
|
|
2905
|
+
async query() {
|
|
2906
|
+
return fail("query");
|
|
2907
|
+
},
|
|
2908
|
+
async queryAll() {
|
|
2909
|
+
return fail("query");
|
|
2910
|
+
},
|
|
2911
|
+
async queryAggregate() {
|
|
2912
|
+
return fail("queryAggregate");
|
|
2913
|
+
},
|
|
2914
|
+
async queryExtent() {
|
|
2915
|
+
return fail("queryExtent");
|
|
2916
|
+
},
|
|
2917
|
+
// biome-ignore lint/correctness/useYield: utility-only services (Geometry, GP) do not host features; this generator refuses iteration rather than silently complete with no pages
|
|
2918
|
+
async *stream() {
|
|
2919
|
+
fail("stream");
|
|
2920
|
+
},
|
|
2921
|
+
async queryObjectIds() {
|
|
2922
|
+
return fail("queryObjectIds");
|
|
2923
|
+
},
|
|
2924
|
+
async applyEdits() {
|
|
2925
|
+
return fail("applyEdits");
|
|
2926
|
+
},
|
|
2927
|
+
async queryRelated() {
|
|
2928
|
+
return fail("queryRelated");
|
|
2929
|
+
},
|
|
2930
|
+
attachments: unsupportedAttachmentApi(descriptor),
|
|
2931
|
+
};
|
|
2932
|
+
}
|
|
2933
|
+
function ensureCapability(descriptor, caps, capability) {
|
|
2934
|
+
if (caps.has(capability))
|
|
2935
|
+
return;
|
|
2936
|
+
throw new HonuaCapabilityNotSupportedError(capability, descriptor.protocol, descriptor.id);
|
|
2937
|
+
}
|
|
2938
|
+
// `degraded` may only bypass a missing capability when the call site immediately
|
|
2939
|
+
// takes a defined fallback path. Returns true when the descriptor advertises the
|
|
2940
|
+
// capability (use the native path), false when policy is `degraded` and a
|
|
2941
|
+
// fallback will execute, and throws under `strict` when missing.
|
|
2942
|
+
function ensureCapabilityOrFallback(descriptor, caps, capability, policy) {
|
|
2943
|
+
if (caps.has(capability))
|
|
2944
|
+
return true;
|
|
2945
|
+
if (policy === "strict") {
|
|
2946
|
+
throw new HonuaCapabilityNotSupportedError(capability, descriptor.protocol, descriptor.id);
|
|
2947
|
+
}
|
|
2948
|
+
return false;
|
|
2949
|
+
}
|
|
2950
|
+
function requireFeatureServiceLocator(descriptor) {
|
|
2951
|
+
const { serviceId, layerId } = descriptor.locator;
|
|
2952
|
+
if (typeof serviceId !== "string" || typeof layerId !== "number") {
|
|
2953
|
+
throw new Error(`createDataset: source "${descriptor.id}" (geoservices-feature-service) requires locator.serviceId and locator.layerId`);
|
|
2954
|
+
}
|
|
2955
|
+
return { serviceId, layerId };
|
|
2956
|
+
}
|
|
2957
|
+
function requireMapServiceLocator(descriptor) {
|
|
2958
|
+
const { serviceId, layerId } = descriptor.locator;
|
|
2959
|
+
if (typeof serviceId !== "string" || typeof layerId !== "number") {
|
|
2960
|
+
throw new Error(`createDataset: source "${descriptor.id}" (geoservices-map-service) requires locator.serviceId and locator.layerId`);
|
|
2961
|
+
}
|
|
2962
|
+
return { serviceId, layerId };
|
|
2963
|
+
}
|
|
2964
|
+
function requireOgcLocator(descriptor) {
|
|
2965
|
+
const { collectionId } = descriptor.locator;
|
|
2966
|
+
if (collectionId === undefined || collectionId === null || collectionId === "") {
|
|
2967
|
+
throw new Error(`createDataset: source "${descriptor.id}" (ogc-features) requires locator.collectionId`);
|
|
2968
|
+
}
|
|
2969
|
+
return { collectionId };
|
|
2970
|
+
}
|
|
2971
|
+
/**
|
|
2972
|
+
* Map a `SourceLocator.layout` hint onto the OGC API Features endpoint
|
|
2973
|
+
* discovery mode. `undefined` / `honua-facade` keep the fixed facade fast
|
|
2974
|
+
* path; `ogc-api` and `auto` enable spec-driven landing-page discovery.
|
|
2975
|
+
* The STAC-specific modes are not applicable to Features and fall back to
|
|
2976
|
+
* the facade.
|
|
2977
|
+
*/
|
|
2978
|
+
function ogcFeaturesLayoutMode(layout) {
|
|
2979
|
+
if (layout === "ogc-api")
|
|
2980
|
+
return "ogc-api";
|
|
2981
|
+
if (layout === "auto")
|
|
2982
|
+
return "auto";
|
|
2983
|
+
return undefined;
|
|
2984
|
+
}
|
|
2985
|
+
function requireOgcRecordsLocator(descriptor) {
|
|
2986
|
+
const { collectionId } = descriptor.locator;
|
|
2987
|
+
if (collectionId === undefined || collectionId === null || collectionId === "") {
|
|
2988
|
+
throw new Error(`createDataset: source "${descriptor.id}" (ogc-records) requires locator.collectionId`);
|
|
2989
|
+
}
|
|
2990
|
+
return { collectionId };
|
|
2991
|
+
}
|
|
2992
|
+
function requirePmtilesLocator(descriptor) {
|
|
2993
|
+
const { url } = descriptor.locator;
|
|
2994
|
+
if (typeof url !== "string" || url === "") {
|
|
2995
|
+
throw new Error(`createDataset: source "${descriptor.id}" (pmtiles) requires locator.url`);
|
|
2996
|
+
}
|
|
2997
|
+
return url;
|
|
2998
|
+
}
|
|
2999
|
+
function requireImageServiceLocator(descriptor) {
|
|
3000
|
+
const { serviceId } = descriptor.locator;
|
|
3001
|
+
if (typeof serviceId !== "string") {
|
|
3002
|
+
throw new Error(`createDataset: source "${descriptor.id}" (geoservices-image-service) requires locator.serviceId`);
|
|
3003
|
+
}
|
|
3004
|
+
return { serviceId };
|
|
3005
|
+
}
|
|
3006
|
+
/**
|
|
3007
|
+
* Reject canonical `Query` fields the ImageServer catalog endpoint cannot
|
|
3008
|
+
* honor. The Honua Server catalog handler reads `where` / `objectIds` /
|
|
3009
|
+
* `outSR` / `resultOffset` / `resultRecordCount` / `returnGeometry` and
|
|
3010
|
+
* silently ignores `outFields`, `geometry` / `geometryType` / `spatialRel`,
|
|
3011
|
+
* and `orderByFields`. Refusing the request explicitly prevents callers
|
|
3012
|
+
* from receiving an unfiltered, unsorted, or wider result that looks like
|
|
3013
|
+
* a silent data quality bug. Mirrors the OGC adapter's spatialFilter /
|
|
3014
|
+
* spatialRel guards.
|
|
3015
|
+
*/
|
|
3016
|
+
function requireImageServerCompatibleQuery(request) {
|
|
3017
|
+
if (!request)
|
|
3018
|
+
return;
|
|
3019
|
+
if (request.spatialFilter) {
|
|
3020
|
+
throw new Error("geoservices-image-service: Query.spatialFilter is not supported on the raster catalog; the ImageServer catalog endpoint does not accept geometry / geometryType / spatialRel filters. Use Query.where to constrain the catalog or call protocol().identify() / exportImage() on the typed escape hatch.");
|
|
3021
|
+
}
|
|
3022
|
+
if (request.orderBy && request.orderBy.length > 0) {
|
|
3023
|
+
throw new Error("geoservices-image-service: Query.orderBy is not supported on the raster catalog; the ImageServer catalog endpoint does not honor orderByFields. Drop orderBy or sort client-side.");
|
|
3024
|
+
}
|
|
3025
|
+
if (request.outFields && request.outFields.length > 0) {
|
|
3026
|
+
throw new Error("geoservices-image-service: Query.outFields is not supported on the raster catalog; the ImageServer catalog endpoint always returns the full catalog row schema. Drop outFields or project client-side.");
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
function requireGPServiceLocator(descriptor, caps) {
|
|
3030
|
+
const { serviceId, taskName } = descriptor.locator;
|
|
3031
|
+
if (typeof serviceId !== "string") {
|
|
3032
|
+
throw new Error(`createDataset: source "${descriptor.id}" (geoservices-gp-service) requires locator.serviceId`);
|
|
3033
|
+
}
|
|
3034
|
+
// Honua Server publishes submitJob / jobs / cancel / results only under
|
|
3035
|
+
// /rest/services/<serviceId>/GPServer/<taskName>/..., so descriptors that
|
|
3036
|
+
// advertise the `geoprocess` capability must carry a task name. Without
|
|
3037
|
+
// one the lifecycle routes resolve to non-existent paths on the server.
|
|
3038
|
+
// Descriptors with only `connect` (service-root metadata probe) may omit
|
|
3039
|
+
// taskName.
|
|
3040
|
+
if (caps.has("geoprocess") && (typeof taskName !== "string" || taskName.length === 0)) {
|
|
3041
|
+
throw new Error(`createDataset: source "${descriptor.id}" (geoservices-gp-service) advertises "geoprocess" but locator.taskName is missing; GP lifecycle routes require /GPServer/<taskName>/...`);
|
|
3042
|
+
}
|
|
3043
|
+
return { serviceId, taskName };
|
|
3044
|
+
}
|
|
3045
|
+
function requireOgcTilesLocator(descriptor) {
|
|
3046
|
+
const { collectionId, tileMatrixSetId } = descriptor.locator;
|
|
3047
|
+
if (collectionId === undefined || collectionId === null || collectionId === "") {
|
|
3048
|
+
throw new Error(`createDataset: source "${descriptor.id}" (ogc-tiles) requires locator.collectionId`);
|
|
3049
|
+
}
|
|
3050
|
+
return { collectionId, tileMatrixSetId };
|
|
3051
|
+
}
|
|
3052
|
+
function requireWmsLocator(descriptor) {
|
|
3053
|
+
const { serviceId } = descriptor.locator;
|
|
3054
|
+
if (typeof serviceId !== "string" || serviceId.length === 0) {
|
|
3055
|
+
throw new Error(`createDataset: source "${descriptor.id}" (wms) requires locator.serviceId`);
|
|
3056
|
+
}
|
|
3057
|
+
return { serviceId };
|
|
3058
|
+
}
|
|
3059
|
+
function requireWmtsLocator(descriptor) {
|
|
3060
|
+
const { serviceId } = descriptor.locator;
|
|
3061
|
+
if (typeof serviceId !== "string" || serviceId.length === 0) {
|
|
3062
|
+
throw new Error(`createDataset: source "${descriptor.id}" (wmts) requires locator.serviceId`);
|
|
3063
|
+
}
|
|
3064
|
+
return { serviceId };
|
|
3065
|
+
}
|
|
3066
|
+
function wmsRequireLayers(descriptor, locatorTypeName) {
|
|
3067
|
+
const parsed = parseWmsLayerNames(locatorTypeName);
|
|
3068
|
+
if (parsed.length === 0) {
|
|
3069
|
+
throw new Error(`createDataset: source "${descriptor.id}" (wms) requires locator.typeName (the WMS LAYER name) for canonical query()`);
|
|
3070
|
+
}
|
|
3071
|
+
return parsed;
|
|
3072
|
+
}
|
|
3073
|
+
/**
|
|
3074
|
+
* Reject canonical `Query` fields that WMS GetFeatureInfo cannot honor so a
|
|
3075
|
+
* mixed-source caller does not silently receive an unfiltered, reprojected,
|
|
3076
|
+
* or differently-shaped result. WMS GetFeatureInfo only consumes the
|
|
3077
|
+
* (i, j) pixel pair plus the rendered envelope; there is no SQL/CQL filter,
|
|
3078
|
+
* no field projection, no order, no offset paging, no geometry toggle, and
|
|
3079
|
+
* no separate output-SR knob on the wire — honua-server projects the
|
|
3080
|
+
* response in the request CRS itself. `aggregation` is mapped to
|
|
3081
|
+
* `queryAggregate` so the error carries the same capability vocabulary as
|
|
3082
|
+
* the rest of the contract. `pagination.limit` is honored (it maps to
|
|
3083
|
+
* `FEATURE_COUNT`).
|
|
3084
|
+
*/
|
|
3085
|
+
function requireWmsCompatibleQuery(descriptor, request) {
|
|
3086
|
+
if (!request)
|
|
3087
|
+
return;
|
|
3088
|
+
if (request.aggregation) {
|
|
3089
|
+
throw new HonuaCapabilityNotSupportedError("queryAggregate", descriptor.protocol, descriptor.id);
|
|
3090
|
+
}
|
|
3091
|
+
if (typeof request.where === "string" && request.where.length > 0) {
|
|
3092
|
+
throw new Error(`wms: Query.where is not supported on GetFeatureInfo for source "${descriptor.id}"; WMS has no SQL/CQL filter on the wire. Pre-filter via Query.spatialFilter (point) or use a tabular protocol.`);
|
|
3093
|
+
}
|
|
3094
|
+
if (request.outFields && request.outFields.length > 0) {
|
|
3095
|
+
throw new Error(`wms: Query.outFields is not supported on GetFeatureInfo for source "${descriptor.id}"; the server returns the layer's full attribute schema. Project client-side after the result lands.`);
|
|
3096
|
+
}
|
|
3097
|
+
if (request.orderBy && request.orderBy.length > 0) {
|
|
3098
|
+
throw new Error(`wms: Query.orderBy is not supported on GetFeatureInfo for source "${descriptor.id}"; the server has no sort surface. Sort client-side after the result lands.`);
|
|
3099
|
+
}
|
|
3100
|
+
if (typeof request.pagination?.offset === "number" && request.pagination.offset > 0) {
|
|
3101
|
+
throw new Error(`wms: Query.pagination.offset is not supported on GetFeatureInfo for source "${descriptor.id}"; the request returns at most FEATURE_COUNT records and does not paginate. Drop pagination.offset.`);
|
|
3102
|
+
}
|
|
3103
|
+
if (request.returnGeometry === false) {
|
|
3104
|
+
throw new Error(`wms: Query.returnGeometry=false is not supported on GetFeatureInfo for source "${descriptor.id}"; the server controls geometry inclusion via INFO_FORMAT and the layer template. Drop returnGeometry or strip geometry client-side.`);
|
|
3105
|
+
}
|
|
3106
|
+
if (request.outSr !== undefined) {
|
|
3107
|
+
throw new Error(`wms: Query.outSr is not supported on GetFeatureInfo for source "${descriptor.id}"; honua-server projects the response in the request CRS and has no separate output-SR knob. Stamp the spatial filter geometry's spatialReference with the desired CRS instead, or reproject the result client-side.`);
|
|
3108
|
+
}
|
|
3109
|
+
}
|
|
3110
|
+
/**
|
|
3111
|
+
* Extract `(x, y)` and the WMS CRS from `Query.spatialFilter`. WMS only
|
|
3112
|
+
* exposes feature-info on a pixel coordinate, so the canonical
|
|
3113
|
+
* `Source.query()` is restricted to point spatial filters. The CRS is
|
|
3114
|
+
* derived from the geometry's `spatialReference` (`wkid` / `latestWkid` /
|
|
3115
|
+
* `wkt`) and falls back to `CRS:84` — the WMS 1.3.0 longitude/latitude
|
|
3116
|
+
* code that preserves the canonical `(x, y)` axis order — when the
|
|
3117
|
+
* caller did not stamp the geometry with a spatial reference. Non-point
|
|
3118
|
+
* geometries throw `HonuaCapabilityNotSupportedError`. `Query.outSr` is
|
|
3119
|
+
* intentionally not consulted here: it is the **output** spatial
|
|
3120
|
+
* reference for projected results, not the input CRS for the
|
|
3121
|
+
* GetFeatureInfo wire request.
|
|
3122
|
+
*/
|
|
3123
|
+
function wmsExtractPointFromQuery(request, descriptor) {
|
|
3124
|
+
const filter = request?.spatialFilter;
|
|
3125
|
+
if (!filter) {
|
|
3126
|
+
throw new HonuaCapabilityNotSupportedError("query", descriptor.protocol, descriptor.id);
|
|
3127
|
+
}
|
|
3128
|
+
const geom = filter.geometry;
|
|
3129
|
+
if (filter.geometryType !== "esriGeometryPoint") {
|
|
3130
|
+
throw new HonuaCapabilityNotSupportedError("query", descriptor.protocol, descriptor.id);
|
|
3131
|
+
}
|
|
3132
|
+
const crs = wmsCrsFromGeometrySpatialReference(geom?.spatialReference);
|
|
3133
|
+
if (geom && typeof geom.x === "number" && typeof geom.y === "number") {
|
|
3134
|
+
return { x: geom.x, y: geom.y, crs };
|
|
3135
|
+
}
|
|
3136
|
+
if (geom && Array.isArray(geom.coordinates) && geom.coordinates.length >= 2) {
|
|
3137
|
+
const [x, y] = geom.coordinates;
|
|
3138
|
+
if (typeof x === "number" && typeof y === "number")
|
|
3139
|
+
return { x, y, crs };
|
|
3140
|
+
}
|
|
3141
|
+
throw new HonuaCapabilityNotSupportedError("query", descriptor.protocol, descriptor.id);
|
|
3142
|
+
}
|
|
3143
|
+
/**
|
|
3144
|
+
* Translate a geometry's `spatialReference` (Esri-style `{ wkid?,
|
|
3145
|
+
* latestWkid?, wkt? }`) into a WMS-compatible CRS code. `wkid` /
|
|
3146
|
+
* `latestWkid` map to `EPSG:N`. A non-empty `wkt` is passed through
|
|
3147
|
+
* verbatim — the server validates it. When no recognizable hint is
|
|
3148
|
+
* present the function returns `CRS:84` so the canonical (x, y) axis
|
|
3149
|
+
* order is preserved on the wire.
|
|
3150
|
+
*/
|
|
3151
|
+
function wmsCrsFromGeometrySpatialReference(spatialReference) {
|
|
3152
|
+
if (spatialReference && typeof spatialReference === "object") {
|
|
3153
|
+
const sr = spatialReference;
|
|
3154
|
+
if (typeof sr.latestWkid === "number" && Number.isFinite(sr.latestWkid)) {
|
|
3155
|
+
return `EPSG:${sr.latestWkid}`;
|
|
3156
|
+
}
|
|
3157
|
+
if (typeof sr.wkid === "number" && Number.isFinite(sr.wkid)) {
|
|
3158
|
+
return `EPSG:${sr.wkid}`;
|
|
3159
|
+
}
|
|
3160
|
+
if (typeof sr.wkt === "string" && sr.wkt.trim().length > 0) {
|
|
3161
|
+
return sr.wkt.trim();
|
|
3162
|
+
}
|
|
3163
|
+
}
|
|
3164
|
+
return "CRS:84";
|
|
3165
|
+
}
|
|
3166
|
+
function toStacRequest(request, collectionScope) {
|
|
3167
|
+
const out = {};
|
|
3168
|
+
if (request?.where !== undefined) {
|
|
3169
|
+
out.filter = request.where;
|
|
3170
|
+
out.filterLang = "cql2-text";
|
|
3171
|
+
}
|
|
3172
|
+
if (request?.outFields && request.outFields.length > 0) {
|
|
3173
|
+
out.fields = { include: [...request.outFields] };
|
|
3174
|
+
}
|
|
3175
|
+
if (request?.pagination?.limit !== undefined)
|
|
3176
|
+
out.limit = request.pagination.limit;
|
|
3177
|
+
if (request?.pagination?.offset !== undefined)
|
|
3178
|
+
out.offset = request.pagination.offset;
|
|
3179
|
+
if (request?.orderBy && request.orderBy.length > 0) {
|
|
3180
|
+
out.sortby = request.orderBy.map((s) => `${s.direction === "desc" ? "-" : ""}${s.field}`).join(",");
|
|
3181
|
+
}
|
|
3182
|
+
if (request?.spatialFilter) {
|
|
3183
|
+
if (request.spatialFilter.geometryType !== "esriGeometryEnvelope") {
|
|
3184
|
+
throw new Error(`stac: spatialFilter.geometryType "${request.spatialFilter.geometryType}" is not supported; only "esriGeometryEnvelope" translates to STAC bbox.`);
|
|
3185
|
+
}
|
|
3186
|
+
const env = request.spatialFilter.geometry;
|
|
3187
|
+
if (typeof env.xmin === "number" &&
|
|
3188
|
+
typeof env.ymin === "number" &&
|
|
3189
|
+
typeof env.xmax === "number" &&
|
|
3190
|
+
typeof env.ymax === "number") {
|
|
3191
|
+
out.bbox = [env.xmin, env.ymin, env.xmax, env.ymax];
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
3194
|
+
if (collectionScope !== undefined && collectionScope !== "") {
|
|
3195
|
+
out.collections = [String(collectionScope)];
|
|
3196
|
+
}
|
|
3197
|
+
if (request?.signal)
|
|
3198
|
+
out.signal = request.signal;
|
|
3199
|
+
return out;
|
|
3200
|
+
}
|
|
3201
|
+
/**
|
|
3202
|
+
* Project a STAC API search request onto the client-side filter params a
|
|
3203
|
+
* static catalog traversal applies (it has no server-side query grammar).
|
|
3204
|
+
*/
|
|
3205
|
+
function toStacStaticParams(request) {
|
|
3206
|
+
const out = {};
|
|
3207
|
+
if (request.collections && request.collections.length > 0)
|
|
3208
|
+
out.collections = request.collections;
|
|
3209
|
+
if (request.bbox)
|
|
3210
|
+
out.bbox = request.bbox;
|
|
3211
|
+
if (request.datetime !== undefined)
|
|
3212
|
+
out.datetime = request.datetime;
|
|
3213
|
+
if (request.limit !== undefined)
|
|
3214
|
+
out.limit = request.limit;
|
|
3215
|
+
if (request.signal)
|
|
3216
|
+
out.signal = request.signal;
|
|
3217
|
+
return out;
|
|
3218
|
+
}
|
|
3219
|
+
function toTypedFeatureFromStac(feature) {
|
|
3220
|
+
return {
|
|
3221
|
+
attributes: (feature.properties ?? {}),
|
|
3222
|
+
geometry: feature.geometry,
|
|
3223
|
+
};
|
|
3224
|
+
}
|
|
3225
|
+
function toOgcRecordsRequest(request) {
|
|
3226
|
+
if (!request)
|
|
3227
|
+
return {};
|
|
3228
|
+
const out = {};
|
|
3229
|
+
if (request.where !== undefined) {
|
|
3230
|
+
out.filter = request.where;
|
|
3231
|
+
out.filterLang = "cql2-text";
|
|
3232
|
+
}
|
|
3233
|
+
if (request.outFields && request.outFields.length > 0)
|
|
3234
|
+
out.properties = [...request.outFields];
|
|
3235
|
+
if (request.pagination) {
|
|
3236
|
+
if (request.pagination.limit !== undefined)
|
|
3237
|
+
out.limit = request.pagination.limit;
|
|
3238
|
+
if (request.pagination.offset !== undefined)
|
|
3239
|
+
out.offset = request.pagination.offset;
|
|
3240
|
+
}
|
|
3241
|
+
if (request.orderBy && request.orderBy.length > 0) {
|
|
3242
|
+
out.sortby = request.orderBy.map((s) => `${s.direction === "desc" ? "-" : ""}${s.field}`).join(",");
|
|
3243
|
+
}
|
|
3244
|
+
if (request.spatialFilter) {
|
|
3245
|
+
if (request.spatialFilter.geometryType !== "esriGeometryEnvelope") {
|
|
3246
|
+
throw new Error(`ogc-records: spatialFilter.geometryType "${request.spatialFilter.geometryType}" is not supported; only "esriGeometryEnvelope" translates to Records bbox.`);
|
|
3247
|
+
}
|
|
3248
|
+
const rel = request.spatialFilter.spatialRel;
|
|
3249
|
+
if (rel !== undefined && rel !== "esriSpatialRelIntersects" && rel !== "esriSpatialRelEnvelopeIntersects") {
|
|
3250
|
+
throw new Error(`ogc-records: spatialFilter.spatialRel "${rel}" is not supported; the Records bbox parameter only expresses envelope-intersects.`);
|
|
3251
|
+
}
|
|
3252
|
+
const env = request.spatialFilter.geometry;
|
|
3253
|
+
if (typeof env.xmin === "number" &&
|
|
3254
|
+
typeof env.ymin === "number" &&
|
|
3255
|
+
typeof env.xmax === "number" &&
|
|
3256
|
+
typeof env.ymax === "number") {
|
|
3257
|
+
out.bbox = [env.xmin, env.ymin, env.xmax, env.ymax];
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3260
|
+
if (request.signal)
|
|
3261
|
+
out.signal = request.signal;
|
|
3262
|
+
return out;
|
|
3263
|
+
}
|
|
3264
|
+
function toTypedFeatureFromOgcRecord(feature) {
|
|
3265
|
+
return {
|
|
3266
|
+
attributes: (feature.properties ?? {}),
|
|
3267
|
+
geometry: feature.geometry,
|
|
3268
|
+
};
|
|
3269
|
+
}
|
|
3270
|
+
/**
|
|
3271
|
+
* The core paging helpers default `maxPages` to 100, which would silently
|
|
3272
|
+
* truncate the canonical `queryAll()` / `stream()` whose contract is to
|
|
3273
|
+
* drain until the source is exhausted. Override with
|
|
3274
|
+
* `Number.MAX_SAFE_INTEGER` so the helper loop only terminates on an empty
|
|
3275
|
+
* page or short page.
|
|
3276
|
+
*
|
|
3277
|
+
* `normalizeMaxPages` / the inline GeoServices checks require a finite
|
|
3278
|
+
* integer, so `Infinity` cannot be used as the sentinel.
|
|
3279
|
+
*/
|
|
3280
|
+
function withUnboundedMaxPages(params) {
|
|
3281
|
+
return { ...params, maxPages: Number.MAX_SAFE_INTEGER };
|
|
3282
|
+
}
|
|
3283
|
+
/**
|
|
3284
|
+
* `HonuaFeatureLayer.queryFeaturesAll` / `HonuaMapLayer.queryFeaturesAll` only
|
|
3285
|
+
* stop on an empty or short page — they have no total-row cap. When the
|
|
3286
|
+
* canonical `Query.pagination.limit` supplies a cap, derive the per-page size
|
|
3287
|
+
* *and* `maxPages` from it so the paging loop fetches at most `limit + 1`
|
|
3288
|
+
* rows (the extra row lets `applyQueryAllLimit` stamp `exceededTransferLimit`
|
|
3289
|
+
* when more records exist). Without this, `queryAll({ pagination: { limit: 1 } })`
|
|
3290
|
+
* would keep issuing `resultOffset=0,1,2,…` until the source is drained.
|
|
3291
|
+
*/
|
|
3292
|
+
function withPagingBounds(params, limit) {
|
|
3293
|
+
if (typeof limit !== "number" || limit < 0) {
|
|
3294
|
+
return { ...params, maxPages: Number.MAX_SAFE_INTEGER };
|
|
3295
|
+
}
|
|
3296
|
+
const pageSize = Math.max(1, Math.min(Math.max(limit, 1), 2000));
|
|
3297
|
+
const target = limit + 1;
|
|
3298
|
+
const maxPages = Math.max(1, Math.ceil(target / pageSize));
|
|
3299
|
+
return { ...params, pageSize, maxPages };
|
|
3300
|
+
}
|
|
3301
|
+
/**
|
|
3302
|
+
* `HonuaFeatureLayer.queryFeaturesStream` / `HonuaMapLayer.queryFeaturesStream`
|
|
3303
|
+
* derive per-page size from `pageSize` and default to 2000 when it is
|
|
3304
|
+
* omitted. `toFeatureLayerRequest` maps `pagination.limit` to
|
|
3305
|
+
* `resultRecordCount`, which the stream helper then overwrites with its own
|
|
3306
|
+
* `pageSize`. Without this bridge `source.stream({ pagination: { limit: 10 } })`
|
|
3307
|
+
* would still fetch 2000-row pages. Treat `pagination.limit` as the caller's
|
|
3308
|
+
* per-batch budget in streaming mode (it carries the same meaning as
|
|
3309
|
+
* `resultRecordCount` for a single-page `query()`).
|
|
3310
|
+
*/
|
|
3311
|
+
function withStreamPageSize(params, limit) {
|
|
3312
|
+
const base = { ...params, maxPages: Number.MAX_SAFE_INTEGER };
|
|
3313
|
+
if (typeof limit !== "number" || !Number.isFinite(limit) || limit < 1) {
|
|
3314
|
+
return base;
|
|
3315
|
+
}
|
|
3316
|
+
return { ...base, pageSize: Math.max(1, Math.trunc(limit)) };
|
|
3317
|
+
}
|
|
3318
|
+
function applyQueryAllLimit(features, limit) {
|
|
3319
|
+
if (typeof limit !== "number" || limit < 0) {
|
|
3320
|
+
return { features, exceededTransferLimit: false };
|
|
3321
|
+
}
|
|
3322
|
+
if (features.length <= limit) {
|
|
3323
|
+
return { features, exceededTransferLimit: false };
|
|
3324
|
+
}
|
|
3325
|
+
return { features: features.slice(0, limit), exceededTransferLimit: true };
|
|
3326
|
+
}
|
|
3327
|
+
function toFeatureLayerRequest(request) {
|
|
3328
|
+
if (!request)
|
|
3329
|
+
return {};
|
|
3330
|
+
const out = {};
|
|
3331
|
+
if (request.where !== undefined)
|
|
3332
|
+
out.where = request.where;
|
|
3333
|
+
if (request.outFields && request.outFields.length > 0)
|
|
3334
|
+
out.outFields = [...request.outFields];
|
|
3335
|
+
if (request.returnGeometry !== undefined)
|
|
3336
|
+
out.returnGeometry = request.returnGeometry;
|
|
3337
|
+
if (request.outSr !== undefined)
|
|
3338
|
+
out.outSr = request.outSr;
|
|
3339
|
+
if (request.orderBy && request.orderBy.length > 0) {
|
|
3340
|
+
out.orderByFields = request.orderBy.map((s) => `${s.field}${s.direction === "desc" ? " DESC" : ""}`).join(",");
|
|
3341
|
+
}
|
|
3342
|
+
if (request.spatialFilter) {
|
|
3343
|
+
out.geometry = request.spatialFilter.geometry;
|
|
3344
|
+
out.geometryType = request.spatialFilter.geometryType;
|
|
3345
|
+
if (request.spatialFilter.spatialRel)
|
|
3346
|
+
out.spatialRel = request.spatialFilter.spatialRel;
|
|
3347
|
+
}
|
|
3348
|
+
if (request.pagination) {
|
|
3349
|
+
if (request.pagination.offset !== undefined)
|
|
3350
|
+
out.resultOffset = request.pagination.offset;
|
|
3351
|
+
if (request.pagination.limit !== undefined)
|
|
3352
|
+
out.resultRecordCount = request.pagination.limit;
|
|
3353
|
+
}
|
|
3354
|
+
if (request.signal)
|
|
3355
|
+
out.signal = request.signal;
|
|
3356
|
+
return out;
|
|
3357
|
+
}
|
|
3358
|
+
/**
|
|
3359
|
+
* Convert a translated GeoServices request into an extent-only variant. The
|
|
3360
|
+
* caller's spatial filter / outSr / signal / orderByFields must survive; only
|
|
3361
|
+
* paging and geometry-return are dropped, and `returnExtentOnly=true` is
|
|
3362
|
+
* stamped via `extraParams`. The returnCountOnly/ExtentOnly/IdsOnly side
|
|
3363
|
+
* channel lives in the untyped `extraParams` bag because it sits outside
|
|
3364
|
+
* `QueryFeaturesRequest`.
|
|
3365
|
+
*/
|
|
3366
|
+
function toExtentOnlyRequest(params) {
|
|
3367
|
+
const { resultOffset: _offset, resultRecordCount: _count, outFields: _outFields, ...rest } = params;
|
|
3368
|
+
void _offset;
|
|
3369
|
+
void _count;
|
|
3370
|
+
void _outFields;
|
|
3371
|
+
return {
|
|
3372
|
+
...rest,
|
|
3373
|
+
returnGeometry: false,
|
|
3374
|
+
extraParams: {
|
|
3375
|
+
...(rest.extraParams ?? {}),
|
|
3376
|
+
returnExtentOnly: true,
|
|
3377
|
+
},
|
|
3378
|
+
};
|
|
3379
|
+
}
|
|
3380
|
+
/**
|
|
3381
|
+
* True when the canonical `Query` carries constraints that restrict which
|
|
3382
|
+
* records count towards the extent. Used by the degraded OGC fallback to
|
|
3383
|
+
* decide whether the collection metadata bbox (unfiltered) is a safe
|
|
3384
|
+
* shortcut or whether the extent must be computed client-side over the
|
|
3385
|
+
* matching records.
|
|
3386
|
+
*/
|
|
3387
|
+
function hasExtentFilter(request) {
|
|
3388
|
+
if (!request)
|
|
3389
|
+
return false;
|
|
3390
|
+
if (request.where !== undefined && request.where !== "")
|
|
3391
|
+
return true;
|
|
3392
|
+
if (request.spatialFilter)
|
|
3393
|
+
return true;
|
|
3394
|
+
return false;
|
|
3395
|
+
}
|
|
3396
|
+
function computeExtentFromOgcFeatures(features) {
|
|
3397
|
+
let xmin = Number.POSITIVE_INFINITY;
|
|
3398
|
+
let ymin = Number.POSITIVE_INFINITY;
|
|
3399
|
+
let xmax = Number.NEGATIVE_INFINITY;
|
|
3400
|
+
let ymax = Number.NEGATIVE_INFINITY;
|
|
3401
|
+
let saw = false;
|
|
3402
|
+
for (const feature of features) {
|
|
3403
|
+
visitGeometryCoords(feature.geometry, (x, y) => {
|
|
3404
|
+
if (x < xmin)
|
|
3405
|
+
xmin = x;
|
|
3406
|
+
if (y < ymin)
|
|
3407
|
+
ymin = y;
|
|
3408
|
+
if (x > xmax)
|
|
3409
|
+
xmax = x;
|
|
3410
|
+
if (y > ymax)
|
|
3411
|
+
ymax = y;
|
|
3412
|
+
saw = true;
|
|
3413
|
+
});
|
|
3414
|
+
}
|
|
3415
|
+
if (!saw)
|
|
3416
|
+
return { extent: null, count: features.length };
|
|
3417
|
+
return { extent: { xmin, ymin, xmax, ymax }, count: features.length };
|
|
3418
|
+
}
|
|
3419
|
+
function visitGeometryCoords(geometry, visit) {
|
|
3420
|
+
if (typeof geometry !== "object" || geometry === null)
|
|
3421
|
+
return;
|
|
3422
|
+
const geom = geometry;
|
|
3423
|
+
if (Array.isArray(geom.geometries)) {
|
|
3424
|
+
for (const inner of geom.geometries)
|
|
3425
|
+
visitGeometryCoords(inner, visit);
|
|
3426
|
+
return;
|
|
3427
|
+
}
|
|
3428
|
+
visitCoordinates(geom.coordinates, visit);
|
|
3429
|
+
}
|
|
3430
|
+
function visitCoordinates(coords, visit) {
|
|
3431
|
+
if (!Array.isArray(coords))
|
|
3432
|
+
return;
|
|
3433
|
+
if (coords.length >= 2 && typeof coords[0] === "number" && typeof coords[1] === "number") {
|
|
3434
|
+
visit(coords[0], coords[1]);
|
|
3435
|
+
return;
|
|
3436
|
+
}
|
|
3437
|
+
for (const inner of coords)
|
|
3438
|
+
visitCoordinates(inner, visit);
|
|
3439
|
+
}
|
|
3440
|
+
function extractExtentEnvelope(response) {
|
|
3441
|
+
if (typeof response !== "object" || response === null)
|
|
3442
|
+
return { extent: null };
|
|
3443
|
+
const obj = response;
|
|
3444
|
+
const extent = typeof obj.extent === "object" && obj.extent !== null
|
|
3445
|
+
? obj.extent
|
|
3446
|
+
: null;
|
|
3447
|
+
const count = typeof obj.count === "number" && Number.isFinite(obj.count) ? obj.count : undefined;
|
|
3448
|
+
return { extent, count };
|
|
3449
|
+
}
|
|
3450
|
+
function toOgcRequest(request) {
|
|
3451
|
+
if (!request)
|
|
3452
|
+
return {};
|
|
3453
|
+
const out = {};
|
|
3454
|
+
if (request.where !== undefined)
|
|
3455
|
+
out.filter = request.where;
|
|
3456
|
+
if (request.outFields && request.outFields.length > 0)
|
|
3457
|
+
out.properties = [...request.outFields];
|
|
3458
|
+
if (request.outSr !== undefined)
|
|
3459
|
+
out.crs = String(request.outSr);
|
|
3460
|
+
if (request.pagination) {
|
|
3461
|
+
if (request.pagination.limit !== undefined)
|
|
3462
|
+
out.limit = request.pagination.limit;
|
|
3463
|
+
if (request.pagination.offset !== undefined)
|
|
3464
|
+
out.offset = request.pagination.offset;
|
|
3465
|
+
}
|
|
3466
|
+
if (request.orderBy && request.orderBy.length > 0) {
|
|
3467
|
+
out.sortby = request.orderBy.map((s) => `${s.direction === "desc" ? "-" : ""}${s.field}`).join(",");
|
|
3468
|
+
}
|
|
3469
|
+
if (request.spatialFilter) {
|
|
3470
|
+
// OGC API Features only exposes bbox at /items; arbitrary geometry types
|
|
3471
|
+
// would need CQL2 (which this adapter does not yet emit). Refuse the
|
|
3472
|
+
// request explicitly instead of silently dropping the constraint and
|
|
3473
|
+
// returning unfiltered features, which would look like a silent data
|
|
3474
|
+
// quality bug to the caller.
|
|
3475
|
+
if (request.spatialFilter.geometryType !== "esriGeometryEnvelope") {
|
|
3476
|
+
throw new Error(`ogc-features: spatialFilter.geometryType "${request.spatialFilter.geometryType}" is not supported; only "esriGeometryEnvelope" translates to OGC bbox. Convert the geometry to an envelope or use a GeoServices source.`);
|
|
3477
|
+
}
|
|
3478
|
+
// The OGC bbox parameter is defined as an envelope-intersects predicate
|
|
3479
|
+
// (OGC 17-069r4 §7.15.3). `contains`, `within`, `crosses`, etc. would
|
|
3480
|
+
// require CQL2 which this adapter does not yet emit. Refuse the request
|
|
3481
|
+
// rather than fall back to bbox semantics — a "within" request that
|
|
3482
|
+
// silently returns every intersecting feature is a correctness bug.
|
|
3483
|
+
const rel = request.spatialFilter.spatialRel;
|
|
3484
|
+
if (rel !== undefined && rel !== "esriSpatialRelIntersects" && rel !== "esriSpatialRelEnvelopeIntersects") {
|
|
3485
|
+
throw new Error(`ogc-features: spatialFilter.spatialRel "${rel}" is not supported; the OGC bbox parameter only expresses envelope-intersects. Use a GeoServices source or convert to an intersects predicate.`);
|
|
3486
|
+
}
|
|
3487
|
+
const env = request.spatialFilter.geometry;
|
|
3488
|
+
if (typeof env.xmin === "number" &&
|
|
3489
|
+
typeof env.ymin === "number" &&
|
|
3490
|
+
typeof env.xmax === "number" &&
|
|
3491
|
+
typeof env.ymax === "number") {
|
|
3492
|
+
out.bbox = `${env.xmin},${env.ymin},${env.xmax},${env.ymax}`;
|
|
3493
|
+
}
|
|
3494
|
+
}
|
|
3495
|
+
if (request.signal)
|
|
3496
|
+
out.signal = request.signal;
|
|
3497
|
+
return out;
|
|
3498
|
+
}
|
|
3499
|
+
/**
|
|
3500
|
+
* OGC operations that must inspect the full matching record set (`queryAggregate`
|
|
3501
|
+
* and degraded `queryExtent`) cannot inherit caller pagination. `limit` /
|
|
3502
|
+
* `offset` would silently materialize only a window of the match set and break
|
|
3503
|
+
* the whole-result contract those methods promise.
|
|
3504
|
+
*/
|
|
3505
|
+
function toOgcMaterializedRequest(request) {
|
|
3506
|
+
const { limit: _limit, offset: _offset, ...out } = toOgcRequest(request);
|
|
3507
|
+
void _limit;
|
|
3508
|
+
void _offset;
|
|
3509
|
+
return out;
|
|
3510
|
+
}
|
|
3511
|
+
function featureLayerResultFromTyped(response) {
|
|
3512
|
+
return {
|
|
3513
|
+
features: response.features ?? [],
|
|
3514
|
+
exceededTransferLimit: response.exceededTransferLimit ?? false,
|
|
3515
|
+
fields: response.fields,
|
|
3516
|
+
};
|
|
3517
|
+
}
|
|
3518
|
+
function featureLayerResultFromUntyped(response) {
|
|
3519
|
+
const features = (response.features ?? []).map((toTypedFeature));
|
|
3520
|
+
return {
|
|
3521
|
+
features,
|
|
3522
|
+
exceededTransferLimit: response.exceededTransferLimit ?? false,
|
|
3523
|
+
fields: response.fields,
|
|
3524
|
+
};
|
|
3525
|
+
}
|
|
3526
|
+
function toTypedFeature(feature) {
|
|
3527
|
+
return {
|
|
3528
|
+
attributes: feature.attributes,
|
|
3529
|
+
geometry: feature.geometry,
|
|
3530
|
+
};
|
|
3531
|
+
}
|
|
3532
|
+
function toTypedFeatureFromOgc(feature) {
|
|
3533
|
+
return {
|
|
3534
|
+
attributes: (feature.properties ?? {}),
|
|
3535
|
+
geometry: feature.geometry,
|
|
3536
|
+
};
|
|
3537
|
+
}
|
|
3538
|
+
/**
|
|
3539
|
+
* Build the GeoServices aggregation fields that must live on the top-level
|
|
3540
|
+
* `QueryFeaturesRequest` / `MapLayerQueryRequest`. Both the REST serializer
|
|
3541
|
+
* (`client.ts`) and the gRPC adapter (`grpc-adapter.ts`) read `outStatistics`
|
|
3542
|
+
* and `groupByFieldsForStatistics` from the request root; stashing them in
|
|
3543
|
+
* `extraParams` would be silently dropped by the gRPC path.
|
|
3544
|
+
*/
|
|
3545
|
+
function buildAggregationFields(aggregation) {
|
|
3546
|
+
const stats = aggregation.metrics.map((m) => ({
|
|
3547
|
+
statisticType: toGeoServicesStatisticType(m.fn),
|
|
3548
|
+
onStatisticField: m.field,
|
|
3549
|
+
outStatisticFieldName: m.alias ?? `${m.fn}_${m.field}`,
|
|
3550
|
+
}));
|
|
3551
|
+
const out = {
|
|
3552
|
+
outStatistics: stats,
|
|
3553
|
+
returnGeometry: false,
|
|
3554
|
+
aggregateAlias: stats[0]?.outStatisticFieldName,
|
|
3555
|
+
};
|
|
3556
|
+
if (aggregation.groupBy && aggregation.groupBy.length > 0) {
|
|
3557
|
+
out.groupByFieldsForStatistics = aggregation.groupBy.join(",");
|
|
3558
|
+
}
|
|
3559
|
+
return out;
|
|
3560
|
+
}
|
|
3561
|
+
function toGeoServicesStatisticType(fn) {
|
|
3562
|
+
switch (fn) {
|
|
3563
|
+
case "stddev":
|
|
3564
|
+
return "stddev";
|
|
3565
|
+
case "var":
|
|
3566
|
+
return "var";
|
|
3567
|
+
default:
|
|
3568
|
+
return fn;
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
function aggregateResultFromFeatureLayer(response, _aggregateAlias) {
|
|
3572
|
+
const aggregateRows = (response.features ?? []).map((f) => f.attributes);
|
|
3573
|
+
return {
|
|
3574
|
+
features: [],
|
|
3575
|
+
exceededTransferLimit: response.exceededTransferLimit ?? false,
|
|
3576
|
+
aggregateRows,
|
|
3577
|
+
fields: response.fields,
|
|
3578
|
+
};
|
|
3579
|
+
}
|
|
3580
|
+
function aggregateResultFromUntyped(response, _alias) {
|
|
3581
|
+
const aggregateRows = (response.features ?? []).map((f) => f.attributes);
|
|
3582
|
+
return {
|
|
3583
|
+
features: [],
|
|
3584
|
+
exceededTransferLimit: response.exceededTransferLimit ?? false,
|
|
3585
|
+
aggregateRows,
|
|
3586
|
+
fields: response.fields,
|
|
3587
|
+
};
|
|
3588
|
+
}
|
|
3589
|
+
function clientSideAggregate(features, aggregation) {
|
|
3590
|
+
const groupKeys = aggregation.groupBy ?? [];
|
|
3591
|
+
if (groupKeys.length === 0) {
|
|
3592
|
+
return [computeMetrics(features, aggregation.metrics)];
|
|
3593
|
+
}
|
|
3594
|
+
const groups = new Map();
|
|
3595
|
+
for (const feature of features) {
|
|
3596
|
+
const key = groupKeys.map((field) => String(readField(feature.attributes, field) ?? "")).join("\u0000");
|
|
3597
|
+
let bucket = groups.get(key);
|
|
3598
|
+
if (!bucket) {
|
|
3599
|
+
bucket = [];
|
|
3600
|
+
groups.set(key, bucket);
|
|
3601
|
+
}
|
|
3602
|
+
bucket.push(feature);
|
|
3603
|
+
}
|
|
3604
|
+
const rows = [];
|
|
3605
|
+
for (const bucket of groups.values()) {
|
|
3606
|
+
const head = bucket[0];
|
|
3607
|
+
const row = {};
|
|
3608
|
+
for (const field of groupKeys) {
|
|
3609
|
+
row[field] = readField(head.attributes, field);
|
|
3610
|
+
}
|
|
3611
|
+
Object.assign(row, computeMetrics(bucket, aggregation.metrics));
|
|
3612
|
+
rows.push(row);
|
|
3613
|
+
}
|
|
3614
|
+
return rows;
|
|
3615
|
+
}
|
|
3616
|
+
function computeMetrics(features, metrics) {
|
|
3617
|
+
const out = {};
|
|
3618
|
+
for (const metric of metrics) {
|
|
3619
|
+
const alias = metric.alias ?? `${metric.fn}_${metric.field}`;
|
|
3620
|
+
const values = features
|
|
3621
|
+
.map((f) => readField(f.attributes, metric.field))
|
|
3622
|
+
.filter((v) => typeof v === "number" && Number.isFinite(v));
|
|
3623
|
+
out[alias] = applyAggregation(metric.fn, values, features.length);
|
|
3624
|
+
}
|
|
3625
|
+
return out;
|
|
3626
|
+
}
|
|
3627
|
+
function applyAggregation(fn, values, totalLength) {
|
|
3628
|
+
if (fn === "count")
|
|
3629
|
+
return totalLength;
|
|
3630
|
+
if (values.length === 0)
|
|
3631
|
+
return 0;
|
|
3632
|
+
switch (fn) {
|
|
3633
|
+
case "sum":
|
|
3634
|
+
return values.reduce((a, b) => a + b, 0);
|
|
3635
|
+
case "avg":
|
|
3636
|
+
return values.reduce((a, b) => a + b, 0) / values.length;
|
|
3637
|
+
case "min":
|
|
3638
|
+
return Math.min(...values);
|
|
3639
|
+
case "max":
|
|
3640
|
+
return Math.max(...values);
|
|
3641
|
+
case "stddev":
|
|
3642
|
+
case "var": {
|
|
3643
|
+
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
|
3644
|
+
const variance = values.reduce((a, b) => a + (b - mean) ** 2, 0) / values.length;
|
|
3645
|
+
return fn === "var" ? variance : Math.sqrt(variance);
|
|
3646
|
+
}
|
|
3647
|
+
default:
|
|
3648
|
+
return 0;
|
|
3649
|
+
}
|
|
3650
|
+
}
|
|
3651
|
+
function readField(attributes, field) {
|
|
3652
|
+
if (typeof attributes !== "object" || attributes === null)
|
|
3653
|
+
return undefined;
|
|
3654
|
+
return attributes[field];
|
|
3655
|
+
}
|
|
3656
|
+
// ── Edit / related / attachment translators ───────────────────
|
|
3657
|
+
function toApplyEditsRequest(envelope) {
|
|
3658
|
+
const out = {};
|
|
3659
|
+
if (envelope.adds && envelope.adds.length > 0) {
|
|
3660
|
+
out.adds = envelope.adds.map((f) => canonicalToHonuaFeature(f));
|
|
3661
|
+
}
|
|
3662
|
+
if (envelope.updates && envelope.updates.length > 0) {
|
|
3663
|
+
out.updates = envelope.updates.map((f) => canonicalToHonuaFeature(f));
|
|
3664
|
+
}
|
|
3665
|
+
if (envelope.deletes && envelope.deletes.length > 0) {
|
|
3666
|
+
const numericIds = [];
|
|
3667
|
+
let allNumeric = true;
|
|
3668
|
+
for (const id of envelope.deletes) {
|
|
3669
|
+
const parsed = Number(id);
|
|
3670
|
+
if (Number.isFinite(parsed)) {
|
|
3671
|
+
numericIds.push(parsed);
|
|
3672
|
+
}
|
|
3673
|
+
else {
|
|
3674
|
+
allNumeric = false;
|
|
3675
|
+
break;
|
|
3676
|
+
}
|
|
3677
|
+
}
|
|
3678
|
+
out.deletes = allNumeric ? numericIds : envelope.deletes.map(String).join(",");
|
|
3679
|
+
}
|
|
3680
|
+
if (envelope.rollbackOnFailure !== undefined) {
|
|
3681
|
+
out.rollbackOnFailure = envelope.rollbackOnFailure;
|
|
3682
|
+
}
|
|
3683
|
+
if (envelope.signal) {
|
|
3684
|
+
out.signal = envelope.signal;
|
|
3685
|
+
}
|
|
3686
|
+
return out;
|
|
3687
|
+
}
|
|
3688
|
+
function canonicalToHonuaFeature(feature) {
|
|
3689
|
+
const attributes = { ...feature.attributes };
|
|
3690
|
+
// GeoServices addresses updates by `OBJECTID` inside attributes; populate it
|
|
3691
|
+
// from the canonical `id` when the caller supplied one but did not embed it
|
|
3692
|
+
// in attributes themselves. Skip when an OBJECTID is already present so a
|
|
3693
|
+
// mismatch surfaces as a server-side validation error rather than being
|
|
3694
|
+
// silently overwritten.
|
|
3695
|
+
if (feature.id !== undefined && feature.id !== null && attributes.OBJECTID === undefined) {
|
|
3696
|
+
attributes.OBJECTID = feature.id;
|
|
3697
|
+
}
|
|
3698
|
+
const out = { attributes };
|
|
3699
|
+
if (feature.geometry !== undefined) {
|
|
3700
|
+
out.geometry = feature.geometry;
|
|
3701
|
+
}
|
|
3702
|
+
return out;
|
|
3703
|
+
}
|
|
3704
|
+
function canonicalEditResult(response) {
|
|
3705
|
+
return {
|
|
3706
|
+
added: (response.addResults ?? []).map(toEditOutcome),
|
|
3707
|
+
updated: (response.updateResults ?? []).map(toEditOutcome),
|
|
3708
|
+
deleted: (response.deleteResults ?? []).map(toEditOutcome),
|
|
3709
|
+
};
|
|
3710
|
+
}
|
|
3711
|
+
function toEditOutcome(result) {
|
|
3712
|
+
const out = { success: result.success };
|
|
3713
|
+
if (typeof result.objectId === "number" && Number.isFinite(result.objectId)) {
|
|
3714
|
+
out.id = result.objectId;
|
|
3715
|
+
}
|
|
3716
|
+
if (result.error) {
|
|
3717
|
+
out.error = { code: result.error.code, description: result.error.description };
|
|
3718
|
+
}
|
|
3719
|
+
return out;
|
|
3720
|
+
}
|
|
3721
|
+
function toRelatedRecordsRequest(request) {
|
|
3722
|
+
const out = { relationshipId: request.relationshipId };
|
|
3723
|
+
if (request.sourceIds.length > 0) {
|
|
3724
|
+
const numeric = [];
|
|
3725
|
+
let allNumeric = true;
|
|
3726
|
+
for (const id of request.sourceIds) {
|
|
3727
|
+
const parsed = Number(id);
|
|
3728
|
+
if (Number.isFinite(parsed))
|
|
3729
|
+
numeric.push(parsed);
|
|
3730
|
+
else {
|
|
3731
|
+
allNumeric = false;
|
|
3732
|
+
break;
|
|
3733
|
+
}
|
|
3734
|
+
}
|
|
3735
|
+
out.objectIds = allNumeric ? numeric : request.sourceIds.map(String).join(",");
|
|
3736
|
+
}
|
|
3737
|
+
if (request.where !== undefined)
|
|
3738
|
+
out.where = request.where;
|
|
3739
|
+
if (request.outFields && request.outFields.length > 0)
|
|
3740
|
+
out.outFields = [...request.outFields];
|
|
3741
|
+
if (request.returnGeometry !== undefined)
|
|
3742
|
+
out.returnGeometry = request.returnGeometry;
|
|
3743
|
+
if (request.signal)
|
|
3744
|
+
out.signal = request.signal;
|
|
3745
|
+
return out;
|
|
3746
|
+
}
|
|
3747
|
+
function canonicalRelatedResult(response) {
|
|
3748
|
+
const groups = (response.relatedRecordGroups ?? []).map((g) => ({
|
|
3749
|
+
sourceId: g.objectId,
|
|
3750
|
+
features: (g.relatedRecords ?? []).map((f) => ({
|
|
3751
|
+
attributes: f.attributes,
|
|
3752
|
+
geometry: f.geometry ?? null,
|
|
3753
|
+
})),
|
|
3754
|
+
}));
|
|
3755
|
+
const out = { groups };
|
|
3756
|
+
if (response.fields)
|
|
3757
|
+
out.fields = response.fields;
|
|
3758
|
+
return out;
|
|
3759
|
+
}
|
|
3760
|
+
function featureToGeoJsonFeature(feature) {
|
|
3761
|
+
const out = {
|
|
3762
|
+
type: "Feature",
|
|
3763
|
+
geometry: feature.geometry ?? null,
|
|
3764
|
+
properties: { ...feature.attributes },
|
|
3765
|
+
};
|
|
3766
|
+
if (feature.id !== undefined && feature.id !== null) {
|
|
3767
|
+
out.id = feature.id;
|
|
3768
|
+
}
|
|
3769
|
+
return out;
|
|
3770
|
+
}
|
|
3771
|
+
function editErrorFromCatch(err) {
|
|
3772
|
+
if (err instanceof HonuaHttpError) {
|
|
3773
|
+
return { code: err.statusCode, description: err.message };
|
|
3774
|
+
}
|
|
3775
|
+
if (err instanceof Error) {
|
|
3776
|
+
// Fall back to ad-hoc `status` / `code` / `statusCode` shapes that
|
|
3777
|
+
// third-party errors might carry; default to 500 when nothing fits so
|
|
3778
|
+
// the per-item EditOutcome still surfaces a numeric code.
|
|
3779
|
+
const candidate = err;
|
|
3780
|
+
const code = typeof candidate.statusCode === "number"
|
|
3781
|
+
? candidate.statusCode
|
|
3782
|
+
: typeof candidate.status === "number"
|
|
3783
|
+
? candidate.status
|
|
3784
|
+
: typeof candidate.code === "number"
|
|
3785
|
+
? candidate.code
|
|
3786
|
+
: 500;
|
|
3787
|
+
return { code, description: err.message };
|
|
3788
|
+
}
|
|
3789
|
+
return { code: 500, description: String(err) };
|
|
3790
|
+
}
|
|
3791
|
+
function featureLayerAttachmentApi(descriptor, caps, layer) {
|
|
3792
|
+
function ensureAttachments() {
|
|
3793
|
+
if (!caps.has("attachments")) {
|
|
3794
|
+
throw new HonuaCapabilityNotSupportedError("attachments", descriptor.protocol, descriptor.id);
|
|
3795
|
+
}
|
|
3796
|
+
}
|
|
3797
|
+
return {
|
|
3798
|
+
async query(request) {
|
|
3799
|
+
ensureAttachments();
|
|
3800
|
+
const response = await layer.queryAttachments({
|
|
3801
|
+
...(request?.parentIds
|
|
3802
|
+
? { objectIds: request.parentIds.map(toAttachmentNumericId).filter(isFiniteNumberStrict) }
|
|
3803
|
+
: {}),
|
|
3804
|
+
...(request?.where !== undefined ? { where: request.where } : {}),
|
|
3805
|
+
...(request?.signal ? { signal: request.signal } : {}),
|
|
3806
|
+
});
|
|
3807
|
+
return (response.attachmentGroups ?? []).map((group) => ({
|
|
3808
|
+
parentId: group.parentObjectId,
|
|
3809
|
+
attachments: (group.attachmentInfos ?? []).map(toAttachmentInfo(group.parentObjectId)),
|
|
3810
|
+
}));
|
|
3811
|
+
},
|
|
3812
|
+
async list(parentId, options) {
|
|
3813
|
+
ensureAttachments();
|
|
3814
|
+
const response = await layer.listAttachments({
|
|
3815
|
+
objectId: parentId,
|
|
3816
|
+
...(options?.signal ? { signal: options.signal } : {}),
|
|
3817
|
+
});
|
|
3818
|
+
const numericParent = Number(parentId);
|
|
3819
|
+
return (response.attachmentInfos ?? []).map(toAttachmentInfo(Number.isFinite(numericParent) ? numericParent : parentId));
|
|
3820
|
+
},
|
|
3821
|
+
async add(request) {
|
|
3822
|
+
ensureAttachments();
|
|
3823
|
+
const response = await layer.addAttachment({
|
|
3824
|
+
objectId: request.parentId,
|
|
3825
|
+
attachment: request.attachment,
|
|
3826
|
+
...(request.name ? { name: request.name } : {}),
|
|
3827
|
+
...(request.contentType ? { contentType: request.contentType } : {}),
|
|
3828
|
+
...(request.signal ? { signal: request.signal } : {}),
|
|
3829
|
+
});
|
|
3830
|
+
return toAttachmentEditOutcome(response.addAttachmentResult, request.parentId);
|
|
3831
|
+
},
|
|
3832
|
+
async update(request) {
|
|
3833
|
+
ensureAttachments();
|
|
3834
|
+
const response = await layer.updateAttachment({
|
|
3835
|
+
objectId: request.parentId,
|
|
3836
|
+
attachmentId: request.attachmentId,
|
|
3837
|
+
attachment: request.attachment,
|
|
3838
|
+
...(request.name ? { name: request.name } : {}),
|
|
3839
|
+
...(request.contentType ? { contentType: request.contentType } : {}),
|
|
3840
|
+
...(request.signal ? { signal: request.signal } : {}),
|
|
3841
|
+
});
|
|
3842
|
+
return toAttachmentEditOutcome(response.updateAttachmentResult, request.parentId);
|
|
3843
|
+
},
|
|
3844
|
+
async delete(request) {
|
|
3845
|
+
ensureAttachments();
|
|
3846
|
+
const numericIds = request.attachmentIds.map(toAttachmentNumericId).filter(isFiniteNumberStrict);
|
|
3847
|
+
const response = await layer.deleteAttachments({
|
|
3848
|
+
objectId: request.parentId,
|
|
3849
|
+
attachmentIds: numericIds.length === request.attachmentIds.length ? numericIds : request.attachmentIds.map(String).join(","),
|
|
3850
|
+
...(request.signal ? { signal: request.signal } : {}),
|
|
3851
|
+
});
|
|
3852
|
+
return (response.deleteAttachmentResults ?? []).map((r) => toAttachmentEditOutcome(r, request.parentId));
|
|
3853
|
+
},
|
|
3854
|
+
};
|
|
3855
|
+
}
|
|
3856
|
+
function toAttachmentInfo(parentId) {
|
|
3857
|
+
return (info) => {
|
|
3858
|
+
const out = {
|
|
3859
|
+
id: info.id,
|
|
3860
|
+
parentId: info.parentObjectId ?? parentId,
|
|
3861
|
+
};
|
|
3862
|
+
if (info.name !== undefined)
|
|
3863
|
+
out.name = info.name;
|
|
3864
|
+
if (info.contentType !== undefined)
|
|
3865
|
+
out.contentType = info.contentType;
|
|
3866
|
+
if (info.size !== undefined)
|
|
3867
|
+
out.size = info.size;
|
|
3868
|
+
return out;
|
|
3869
|
+
};
|
|
3870
|
+
}
|
|
3871
|
+
function toAttachmentEditOutcome(result, parentId) {
|
|
3872
|
+
const out = { success: result.success };
|
|
3873
|
+
if (parentId !== undefined)
|
|
3874
|
+
out.parentId = parentId;
|
|
3875
|
+
if (typeof result.objectId === "number" && Number.isFinite(result.objectId)) {
|
|
3876
|
+
out.attachmentId = result.objectId;
|
|
3877
|
+
}
|
|
3878
|
+
if (result.error) {
|
|
3879
|
+
out.error = { code: result.error.code, description: result.error.description };
|
|
3880
|
+
}
|
|
3881
|
+
return out;
|
|
3882
|
+
}
|
|
3883
|
+
function toAttachmentNumericId(id) {
|
|
3884
|
+
const parsed = Number(id);
|
|
3885
|
+
return Number.isFinite(parsed) ? parsed : Number.NaN;
|
|
3886
|
+
}
|
|
3887
|
+
function isFiniteNumberStrict(n) {
|
|
3888
|
+
return Number.isFinite(n);
|
|
3889
|
+
}
|
|
3890
|
+
// ── Re-exports for downstream tickets ─────────────────────────
|
|
3891
|
+
/** Sentinel: `Capabilities` set that contains every defined capability. */
|
|
3892
|
+
export const ALL_CAPABILITIES = new Set(CAPABILITIES);
|
|
3893
|
+
/** Tag a `Protocol` as "first-party-supported by this build". */
|
|
3894
|
+
export const FIRST_PARTY_PROTOCOLS = new Set([
|
|
3895
|
+
"geoservices-feature-service",
|
|
3896
|
+
"geoservices-map-service",
|
|
3897
|
+
"geoservices-image-service",
|
|
3898
|
+
"geoservices-geometry-service",
|
|
3899
|
+
"geoservices-gp-service",
|
|
3900
|
+
"ogc-features",
|
|
3901
|
+
"ogc-tiles",
|
|
3902
|
+
"ogc-maps",
|
|
3903
|
+
"ogc-records",
|
|
3904
|
+
"stac",
|
|
3905
|
+
"wfs",
|
|
3906
|
+
"wms",
|
|
3907
|
+
"wmts",
|
|
3908
|
+
"pmtiles",
|
|
3909
|
+
]);
|