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