@honua/react 0.1.2-beta.0 → 0.1.4-beta.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 (281) hide show
  1. package/contract/discovery.d.ts +34 -0
  2. package/contract/discovery.d.ts.map +1 -1
  3. package/contract/index.d.ts +7 -5
  4. package/contract/index.d.ts.map +1 -1
  5. package/contract/index.js +3 -2
  6. package/contract/pmtiles.d.ts +10 -1
  7. package/contract/pmtiles.d.ts.map +1 -1
  8. package/contract/pmtiles.js +10 -2
  9. package/contract/protocol-module.d.ts +84 -9
  10. package/contract/protocol-module.d.ts.map +1 -1
  11. package/contract/query-filter-fes.d.ts +18 -0
  12. package/contract/query-filter-fes.d.ts.map +1 -0
  13. package/contract/query-filter-fes.js +122 -0
  14. package/contract/query-filter.d.ts +300 -0
  15. package/contract/query-filter.d.ts.map +1 -0
  16. package/contract/query-filter.js +883 -0
  17. package/contract/schema.d.ts +12 -0
  18. package/contract/schema.d.ts.map +1 -1
  19. package/contract/schema.js +34 -0
  20. package/contract/source-capability-support.d.ts.map +1 -1
  21. package/contract/source-capability-support.js +15 -3
  22. package/contract/source.d.ts +45 -25
  23. package/contract/source.d.ts.map +1 -1
  24. package/contract/source.js +719 -479
  25. package/contract/types.d.ts +87 -18
  26. package/contract/types.d.ts.map +1 -1
  27. package/contract/widget-source.d.ts.map +1 -1
  28. package/core/client.d.ts +45 -21
  29. package/core/client.d.ts.map +1 -1
  30. package/core/client.js +224 -46
  31. package/core/error-classifications.d.ts +6 -0
  32. package/core/error-classifications.d.ts.map +1 -1
  33. package/core/error-classifications.js +6 -0
  34. package/core/error-code-registry.d.ts +6 -0
  35. package/core/error-code-registry.d.ts.map +1 -1
  36. package/core/error-code-registry.js +6 -0
  37. package/core/error-envelope.js +2 -0
  38. package/core/errors.d.ts +1 -1
  39. package/core/errors.js +1 -1
  40. package/core/geoparquet-sql.d.ts +68 -4
  41. package/core/geoparquet-sql.d.ts.map +1 -1
  42. package/core/geoparquet-sql.js +325 -8
  43. package/core/odata-write-codec.d.ts.map +1 -1
  44. package/core/odata-write-codec.js +0 -1
  45. package/core/odata.d.ts +6 -0
  46. package/core/odata.d.ts.map +1 -1
  47. package/core/odata.js +6 -1
  48. package/core/ogc-processes.d.ts +182 -25
  49. package/core/ogc-processes.d.ts.map +1 -1
  50. package/core/ogc-processes.js +602 -30
  51. package/core/process-runner.d.ts +8 -1
  52. package/core/process-runner.d.ts.map +1 -1
  53. package/core/process-runner.js +1 -0
  54. package/core/protocol-transport.d.ts +2 -0
  55. package/core/protocol-transport.d.ts.map +1 -1
  56. package/core/stac.d.ts +31 -0
  57. package/core/stac.d.ts.map +1 -1
  58. package/core/stac.js +136 -19
  59. package/core/surfaces.d.ts.map +1 -1
  60. package/core/surfaces.js +273 -68
  61. package/core/types.d.ts +123 -9
  62. package/core/types.d.ts.map +1 -1
  63. package/core/wfs-axis-order.d.ts +18 -0
  64. package/core/wfs-axis-order.d.ts.map +1 -0
  65. package/core/wfs-axis-order.js +108 -0
  66. package/core/wfs-capabilities.d.ts +65 -24
  67. package/core/wfs-capabilities.d.ts.map +1 -1
  68. package/core/wfs-capabilities.js +237 -27
  69. package/core/wfs-filter.d.ts +26 -3
  70. package/core/wfs-filter.d.ts.map +1 -1
  71. package/core/wfs-filter.js +48 -53
  72. package/core/wfs-protocol-error.d.ts +12 -0
  73. package/core/wfs-protocol-error.d.ts.map +1 -0
  74. package/core/wfs-protocol-error.js +13 -0
  75. package/core/wfs.d.ts +53 -1
  76. package/core/wfs.d.ts.map +1 -1
  77. package/core/wfs.js +251 -29
  78. package/core/wms-feature-info.d.ts +92 -0
  79. package/core/wms-feature-info.d.ts.map +1 -0
  80. package/core/wms-feature-info.js +285 -0
  81. package/core/wms.d.ts +9 -0
  82. package/core/wms.d.ts.map +1 -1
  83. package/core/wms.js +17 -5
  84. package/esri-compat/event-bus.d.ts +20 -0
  85. package/esri-compat/event-bus.d.ts.map +1 -1
  86. package/esri-compat/feature-filter.d.ts +46 -1
  87. package/esri-compat/feature-filter.d.ts.map +1 -1
  88. package/esri-compat/feature-filter.js +49 -0
  89. package/esri-compat/geojson-layer.d.ts +4 -2
  90. package/esri-compat/geojson-layer.d.ts.map +1 -1
  91. package/esri-compat/geojson-layer.js +4 -2
  92. package/esri-compat/identity-manager.js +2 -2
  93. package/esri-compat/imagery-layer.d.ts +2 -1
  94. package/esri-compat/imagery-layer.d.ts.map +1 -1
  95. package/esri-compat/imagery-layer.js +2 -1
  96. package/esri-compat/layer-list.d.ts +8 -1
  97. package/esri-compat/layer-list.d.ts.map +1 -1
  98. package/esri-compat/layer-list.js +11 -4
  99. package/esri-compat/legend.d.ts.map +1 -1
  100. package/esri-compat/legend.js +1 -1
  101. package/esri-compat/locator.d.ts +297 -0
  102. package/esri-compat/locator.d.ts.map +1 -0
  103. package/esri-compat/locator.js +579 -0
  104. package/esri-compat/map-image-layer.d.ts.map +1 -1
  105. package/esri-compat/map-view.d.ts.map +1 -1
  106. package/esri-compat/map-view.js +1 -1
  107. package/esri-compat/scene-view.d.ts.map +1 -1
  108. package/esri-compat/time-slider.d.ts +29 -0
  109. package/esri-compat/time-slider.d.ts.map +1 -1
  110. package/esri-compat/time-slider.js +117 -0
  111. package/esri-compat/wfs-layer.d.ts +2 -1
  112. package/esri-compat/wfs-layer.d.ts.map +1 -1
  113. package/esri-compat/wfs-layer.js +2 -1
  114. package/esri-compat/widget-host.d.ts +42 -6
  115. package/esri-compat/widget-host.d.ts.map +1 -1
  116. package/esri-compat/widget-host.js +99 -10
  117. package/generated-app/projection.d.ts.map +1 -1
  118. package/generated-app/runtime.d.ts.map +1 -1
  119. package/map/data-to-map-bridge.d.ts +1 -1
  120. package/map/data-to-map-bridge.d.ts.map +1 -1
  121. package/map/data-to-map-bridge.js +1 -2
  122. package/map/honua-map.d.ts.map +1 -1
  123. package/map/honua-map.js +1 -1
  124. package/map/index.d.ts +2 -0
  125. package/map/index.d.ts.map +1 -1
  126. package/map/index.js +2 -0
  127. package/map/realtime-reconciliation-adapter.d.ts +98 -0
  128. package/map/realtime-reconciliation-adapter.d.ts.map +1 -0
  129. package/map/realtime-reconciliation-adapter.js +84 -0
  130. package/map/temporal-playback.d.ts +25 -0
  131. package/map/temporal-playback.d.ts.map +1 -1
  132. package/map/temporal-playback.js +29 -1
  133. package/package.json +4 -3
  134. package/query-planner/canonical-filter.d.ts +30 -0
  135. package/query-planner/canonical-filter.d.ts.map +1 -0
  136. package/query-planner/canonical-filter.js +30 -0
  137. package/query-planner/columnar-plan.d.ts +63 -0
  138. package/query-planner/columnar-plan.d.ts.map +1 -0
  139. package/query-planner/columnar-plan.js +94 -0
  140. package/query-planner/diagnostics.d.ts +3 -3
  141. package/query-planner/diagnostics.d.ts.map +1 -1
  142. package/query-planner/diagnostics.js +17 -4
  143. package/query-planner/duckdb.d.ts.map +1 -1
  144. package/query-planner/duckdb.js +16 -2
  145. package/query-planner/execution-context.d.ts.map +1 -1
  146. package/query-planner/execution-context.js +3 -1
  147. package/query-planner/executor.d.ts.map +1 -1
  148. package/query-planner/executor.js +7 -1
  149. package/query-planner/geoservices.d.ts.map +1 -1
  150. package/query-planner/geoservices.js +24 -5
  151. package/query-planner/grpc.d.ts.map +1 -1
  152. package/query-planner/grpc.js +29 -5
  153. package/query-planner/index.d.ts +20 -5
  154. package/query-planner/index.d.ts.map +1 -1
  155. package/query-planner/index.js +16 -3
  156. package/query-planner/ir.d.ts +6 -1
  157. package/query-planner/ir.d.ts.map +1 -1
  158. package/query-planner/ir.js +63 -1
  159. package/query-planner/odata-protocol-module.d.ts +54 -0
  160. package/query-planner/odata-protocol-module.d.ts.map +1 -0
  161. package/query-planner/odata-protocol-module.js +199 -0
  162. package/query-planner/odata-v1.d.ts +23 -0
  163. package/query-planner/odata-v1.d.ts.map +1 -0
  164. package/query-planner/odata-v1.js +202 -0
  165. package/query-planner/odata.d.ts +0 -3
  166. package/query-planner/odata.d.ts.map +1 -1
  167. package/query-planner/odata.js +0 -143
  168. package/query-planner/ogc-features.d.ts.map +1 -1
  169. package/query-planner/ogc-features.js +23 -1
  170. package/query-planner/planner.d.ts +2 -2
  171. package/query-planner/planner.d.ts.map +1 -1
  172. package/query-planner/planner.js +146 -27
  173. package/query-planner/representation.d.ts +59 -0
  174. package/query-planner/representation.d.ts.map +1 -0
  175. package/query-planner/representation.js +140 -0
  176. package/query-planner/types.d.ts +153 -3
  177. package/query-planner/types.d.ts.map +1 -1
  178. package/query-planner/wfs-protocol-module.d.ts +57 -0
  179. package/query-planner/wfs-protocol-module.d.ts.map +1 -0
  180. package/query-planner/wfs-protocol-module.js +731 -0
  181. package/query-planner/wfs-v1.d.ts +41 -0
  182. package/query-planner/wfs-v1.d.ts.map +1 -0
  183. package/query-planner/wfs-v1.js +366 -0
  184. package/query-planner/wfs.d.ts +1 -3
  185. package/query-planner/wfs.d.ts.map +1 -1
  186. package/query-planner/wfs.js +1 -70
  187. package/react/honua-map.d.ts +3 -1
  188. package/react/honua-map.d.ts.map +1 -1
  189. package/react/honua-map.js +11 -1
  190. package/react/hooks.d.ts +1 -1
  191. package/react/hooks.js +1 -1
  192. package/react/selection.d.ts.map +1 -1
  193. package/react/selection.js +0 -0
  194. package/react/use-mounted-source.d.ts +1 -1
  195. package/react/use-mounted-source.js +1 -1
  196. package/realtime/checkpoint-store.d.ts +206 -0
  197. package/realtime/checkpoint-store.d.ts.map +1 -0
  198. package/realtime/checkpoint-store.js +504 -0
  199. package/realtime/contract.d.ts +142 -0
  200. package/realtime/contract.d.ts.map +1 -0
  201. package/realtime/contract.js +154 -0
  202. package/realtime/exploration.d.ts +16 -0
  203. package/realtime/exploration.d.ts.map +1 -0
  204. package/realtime/exploration.js +32 -0
  205. package/realtime/honua-server.d.ts +131 -0
  206. package/realtime/honua-server.d.ts.map +1 -0
  207. package/realtime/honua-server.js +302 -0
  208. package/realtime/index.d.ts +39 -0
  209. package/realtime/index.d.ts.map +1 -0
  210. package/realtime/index.js +26 -0
  211. package/realtime/odata-delta.d.ts +114 -0
  212. package/realtime/odata-delta.d.ts.map +1 -0
  213. package/realtime/odata-delta.js +483 -0
  214. package/realtime/projections.d.ts +30 -0
  215. package/realtime/projections.d.ts.map +1 -0
  216. package/realtime/projections.js +51 -0
  217. package/realtime/reconciliation.d.ts +245 -0
  218. package/realtime/reconciliation.d.ts.map +1 -0
  219. package/realtime/reconciliation.js +377 -0
  220. package/realtime/reducer.d.ts +14 -0
  221. package/realtime/reducer.d.ts.map +1 -0
  222. package/realtime/reducer.js +351 -0
  223. package/realtime/resumable-transport.d.ts +111 -0
  224. package/realtime/resumable-transport.d.ts.map +1 -0
  225. package/realtime/resumable-transport.js +406 -0
  226. package/realtime/resumable.d.ts +104 -0
  227. package/realtime/resumable.d.ts.map +1 -0
  228. package/realtime/resumable.js +632 -0
  229. package/realtime/sse.d.ts +23 -0
  230. package/realtime/sse.d.ts.map +1 -0
  231. package/realtime/sse.js +147 -0
  232. package/realtime/store.d.ts +18 -0
  233. package/realtime/store.d.ts.map +1 -0
  234. package/realtime/store.js +63 -0
  235. package/realtime/types.d.ts +190 -0
  236. package/realtime/types.d.ts.map +1 -0
  237. package/realtime/types.js +10 -0
  238. package/realtime/websocket.d.ts +60 -0
  239. package/realtime/websocket.d.ts.map +1 -0
  240. package/realtime/websocket.js +157 -0
  241. package/realtime/wire.d.ts +28 -0
  242. package/realtime/wire.d.ts.map +1 -0
  243. package/realtime/wire.js +73 -0
  244. package/runtime/index.d.ts +6 -2
  245. package/runtime/index.d.ts.map +1 -1
  246. package/runtime/index.js +3 -1
  247. package/runtime/load-package.d.ts +24 -0
  248. package/runtime/load-package.d.ts.map +1 -1
  249. package/runtime/load-package.js +45 -1
  250. package/runtime/maplibre-renderer.d.ts +11 -0
  251. package/runtime/maplibre-renderer.d.ts.map +1 -1
  252. package/runtime/maplibre-renderer.js +32 -7
  253. package/runtime/offline-region-protocol.d.ts +73 -0
  254. package/runtime/offline-region-protocol.d.ts.map +1 -0
  255. package/runtime/offline-region-protocol.js +63 -0
  256. package/runtime/offline-region-style.d.ts +78 -0
  257. package/runtime/offline-region-style.d.ts.map +1 -0
  258. package/runtime/offline-region-style.js +147 -0
  259. package/runtime/pmtiles-protocol.d.ts +7 -8
  260. package/runtime/pmtiles-protocol.d.ts.map +1 -1
  261. package/runtime/pmtiles-protocol.js +19 -66
  262. package/runtime/protocol-registry.d.ts +55 -0
  263. package/runtime/protocol-registry.d.ts.map +1 -0
  264. package/runtime/protocol-registry.js +103 -0
  265. package/runtime/runtime.d.ts +2 -0
  266. package/runtime/runtime.d.ts.map +1 -1
  267. package/runtime/runtime.js +6 -1
  268. package/studio/index.d.ts +24 -7
  269. package/studio/index.d.ts.map +1 -1
  270. package/studio/index.js +21 -7
  271. package/studio/lifecycle-client.d.ts +164 -0
  272. package/studio/lifecycle-client.d.ts.map +1 -0
  273. package/studio/lifecycle-client.js +299 -0
  274. package/studio/lifecycle-errors.d.ts +91 -0
  275. package/studio/lifecycle-errors.d.ts.map +1 -0
  276. package/studio/lifecycle-errors.js +77 -0
  277. package/studio/lifecycle-types.d.ts +321 -0
  278. package/studio/lifecycle-types.d.ts.map +1 -0
  279. package/studio/lifecycle-types.js +22 -0
  280. package/webmap/convert-label.js +0 -1
  281. package/webmap/convert-symbol.d.ts.map +1 -1
@@ -13,19 +13,25 @@
13
13
  * @module
14
14
  */
15
15
  import { HonuaCapabilityNotSupportedError, HonuaHttpError } from "../core/errors.js";
16
- import { HonuaOdataEntitySet, buildOdataSpatialFilter, odataFieldSchema, rewriteWhereToOdataFilter, } from "../core/odata.js";
16
+ import { esriGeometryToGeoJSON } from "../core/esri-geojson.js";
17
+ import { buildOdataSpatialFilter, geometryToWkt, odataFieldSchema, rewriteWhereToOdataFilter, } from "../core/odata.js";
17
18
  import { HonuaOgcCollectionMap, HonuaOgcMaps } from "../core/ogc-maps.js";
18
19
  import { HonuaOgcRecordCollection } from "../core/ogc-records.js";
19
20
  import { HonuaOgcTiles, HonuaOgcTileset } from "../core/ogc-tiles.js";
20
- import { trimTrailingSlashes } from "../core/path-utils.js";
21
21
  import { HonuaStacStaticCatalog } from "../core/stac-static.js";
22
22
  import { HonuaStacSearch } from "../core/stac.js";
23
23
  import { HonuaFeatureLayer, HonuaGeometryService, HonuaGeoprocessingService, HonuaImageService, HonuaMapLayer, HonuaMapService, HonuaOgcFeatureCollection, HonuaOgcFeatures, } from "../core/surfaces.js";
24
- import { UNSUPPORTED_FES, compileSpatialFilter, compileWhere, geoJsonGeometryToGml, serializeFes, } from "../core/wfs-filter.js";
25
- import { HonuaWfsFeatureType } from "../core/wfs.js";
24
+ import { geoJsonGeometryToGml } from "../core/wfs-filter.js";
25
+ import { executeWmsFeatureInfo, resolveWmsQueryCrs } from "../core/wms-feature-info.js";
26
26
  import { HonuaWms, HonuaWmsLayer, parseWmsLayerNames } from "../core/wms.js";
27
27
  import { HonuaWmts, HonuaWmtsLayer, HonuaWmtsTileset } from "../core/wmts.js";
28
+ import { createQueryIr } from "../query-planner/ir.js";
29
+ import { createOdataProtocolCompiledQuery, odataProtocolModule, odataProtocolResultFromPage, } from "../query-planner/odata-protocol-module.js";
30
+ import { HonuaQueryPlanningError, } from "../query-planner/types.js";
31
+ import { HonuaWfsProtocolError, assertWfsPageProgress, buildWfsPostGetFeatureBody, checkedWfsPageEnd, wfsProtocolModule, } from "../query-planner/wfs-protocol-module.js";
32
+ import { wfsSpatialFilterNeedsGeometryProperty, wfsSrsNameFromOutSr } from "../query-planner/wfs-v1.js";
28
33
  import { pmtilesProtocolModule } from "./pmtiles.js";
34
+ import { andQueryFilters, compileQueryFilterToCql2Text, compileQueryFilterToOData, compileQueryFilterToSql92, compileTemporalFilterToGeoServicesTime, compileTemporalFilterToOgcDatetime, queryFilterConstraints, queryFilterHasSpatial, temporalFilterToExpression, } from "./query-filter.js";
29
35
  import { addCapabilitySupport, normalizeCapabilityDescriptor } from "./source-capability-support.js";
30
36
  import { CAPABILITIES, PROTOCOL_DEFAULT_CAPABILITIES, } from "./types.js";
31
37
  /**
@@ -43,7 +49,7 @@ import { CAPABILITIES, PROTOCOL_DEFAULT_CAPABILITIES, } from "./types.js";
43
49
  *
44
50
  * Operations the canonical surface does not cover stay reachable through the typed
45
51
  * `source.protocol(...)` escape hatch (e.g. ImageServer `exportImage`, GeometryServer
46
- * `project`, GPServer `submitJob`, WFS `LockFeature`).
52
+ * `project`, GPServer `submitJob`, WFS `GetPropertyValue`).
47
53
  *
48
54
  * @param options - The dataset id, the `HonuaClient` to talk to, the list of
49
55
  * `SourceDescriptor`s, and (optionally) `capabilityPolicy` and a custom
@@ -71,7 +77,6 @@ import { CAPABILITIES, PROTOCOL_DEFAULT_CAPABILITIES, } from "./types.js";
71
77
  *
72
78
  * const parcels = dataset.source("parcels-fs")!;
73
79
  * const result = await parcels.queryAll({
74
- * where: "STATUS = 'ACTIVE'",
75
80
  * outFields: ["OBJECTID", "NAME"],
76
81
  * returnGeometry: true,
77
82
  * pagination: { limit: 500 },
@@ -81,6 +86,10 @@ import { CAPABILITIES, PROTOCOL_DEFAULT_CAPABILITIES, } from "./types.js";
81
86
  * ```
82
87
  */
83
88
  export function createDataset(options) {
89
+ return createDatasetWithAdapterSeeds(options);
90
+ }
91
+ /** @internal Reuse metadata already reviewed by top-level connect() adapters. */
92
+ export function createDatasetWithAdapterSeeds(options, seeds = {}) {
84
93
  const { id, client, sources, resolveSource } = options;
85
94
  const policy = options.capabilityPolicy ?? "strict";
86
95
  const descriptors = new Map();
@@ -97,7 +106,7 @@ export function createDataset(options) {
97
106
  const cached = handles.get(descriptor.id);
98
107
  if (cached)
99
108
  return cached;
100
- const builtIn = buildBuiltInSource(descriptor, client, policy);
109
+ const builtIn = buildBuiltInSource(descriptor, client, policy, seeds);
101
110
  const built = builtIn ?? resolveSource?.(descriptor, { client, capabilityPolicy: policy });
102
111
  if (!built) {
103
112
  throw new HonuaCapabilityNotSupportedError("query", descriptor.protocol, descriptor.id);
@@ -136,7 +145,7 @@ export function createDataset(options) {
136
145
  };
137
146
  }
138
147
  // ── Built-in resolver ─────────────────────────────────────────
139
- function buildBuiltInSource(descriptor, client, policy) {
148
+ function buildBuiltInSource(descriptor, client, policy, seeds) {
140
149
  switch (descriptor.protocol) {
141
150
  case "grpc":
142
151
  // Honua gRPC is a transport-selectable fast path over the same
@@ -176,7 +185,11 @@ function buildBuiltInSource(descriptor, client, policy) {
176
185
  case "odata":
177
186
  return odataSource(descriptor, client, policy);
178
187
  case "pmtiles":
179
- return pmtilesSource(descriptor, client, policy);
188
+ return pmtilesSource(descriptor, client, policy, {
189
+ ...(seeds.pmtilesDescriptions?.get(descriptor.id)
190
+ ? { preloadedDescription: seeds.pmtilesDescriptions.get(descriptor.id) }
191
+ : {}),
192
+ });
180
193
  default:
181
194
  return undefined;
182
195
  }
@@ -202,8 +215,8 @@ function buildBuiltInSource(descriptor, client, policy) {
202
215
  * }],
203
216
  * });
204
217
  * const result = await dataset.source("parcels-fs")!.queryAll({
205
- * where: "STATUS = 'ACTIVE'",
206
218
  * outFields: ["OBJECTID", "NAME"],
219
+ * pagination: { limit: 500 },
207
220
  * });
208
221
  * ```
209
222
  */
@@ -224,7 +237,7 @@ export function geoServicesFeatureSource(descriptor, client, policy) {
224
237
  return makeSource(descriptor, caps, policy, adapterRegistry, {
225
238
  async query(request) {
226
239
  ensureCapability(descriptor, caps, "query");
227
- const requestParams = toFeatureLayerRequest(request);
240
+ const requestParams = toFeatureLayerRequest(request, descriptor);
228
241
  if (request?.aggregation) {
229
242
  ensureCapability(descriptor, caps, "queryAggregate");
230
243
  const { aggregateAlias, ...aggFields } = buildAggregationFields(request.aggregation);
@@ -235,7 +248,7 @@ export function geoServicesFeatureSource(descriptor, client, policy) {
235
248
  },
236
249
  async queryAll(request) {
237
250
  ensureCapability(descriptor, caps, "query");
238
- const params = withPagingBounds(toFeatureLayerRequest(request), request?.pagination?.limit);
251
+ const params = withPagingBounds(toFeatureLayerRequest(request, descriptor), request?.pagination?.limit);
239
252
  const features = await layer.queryFeaturesAll(params);
240
253
  const { features: limited, exceededTransferLimit } = applyQueryAllLimit(features, request?.pagination?.limit);
241
254
  return {
@@ -246,19 +259,19 @@ export function geoServicesFeatureSource(descriptor, client, policy) {
246
259
  },
247
260
  async queryAggregate(request) {
248
261
  ensureCapability(descriptor, caps, "queryAggregate");
249
- const requestParams = toFeatureLayerRequest(request);
262
+ const requestParams = toFeatureLayerRequest(request, descriptor);
250
263
  const { aggregateAlias, ...aggFields } = buildAggregationFields(request.aggregation);
251
264
  const response = await layer.queryFeatures({ ...requestParams, ...aggFields });
252
265
  return aggregateResultFromFeatureLayer(response, aggregateAlias);
253
266
  },
254
267
  async queryExtent(request) {
255
268
  ensureCapability(descriptor, caps, "queryExtent");
256
- const response = await layer.queryFeatures(toExtentOnlyRequest(toFeatureLayerRequest(request)));
269
+ const response = await layer.queryFeatures(toExtentOnlyRequest(toFeatureLayerRequest(request, descriptor)));
257
270
  return extractExtentEnvelope(response);
258
271
  },
259
272
  async *stream(request) {
260
273
  ensureCapability(descriptor, caps, "stream");
261
- const stream = layer.queryFeaturesStream(withStreamPageSize(toFeatureLayerRequest(request), request?.pagination?.limit));
274
+ const stream = layer.queryFeaturesStream(withStreamPageSize(toFeatureLayerRequest(request, descriptor), request?.pagination?.limit));
262
275
  for await (const page of stream) {
263
276
  yield {
264
277
  features: page,
@@ -268,7 +281,7 @@ export function geoServicesFeatureSource(descriptor, client, policy) {
268
281
  },
269
282
  async queryObjectIds(request) {
270
283
  ensureCapability(descriptor, caps, "queryObjectIds");
271
- return layer.queryObjectIds(toFeatureLayerRequest(request));
284
+ return layer.queryObjectIds(toFeatureLayerRequest(request, descriptor));
272
285
  },
273
286
  async applyEdits(envelope) {
274
287
  ensureCapability(descriptor, caps, "applyEdits");
@@ -318,7 +331,7 @@ export function geoServicesMapServiceSource(descriptor, client, policy) {
318
331
  return makeSource(descriptor, caps, policy, adapterRegistry, {
319
332
  async query(request) {
320
333
  ensureCapability(descriptor, caps, "query");
321
- const params = toFeatureLayerRequest(request);
334
+ const params = toFeatureLayerRequest(request, descriptor);
322
335
  if (request?.aggregation) {
323
336
  ensureCapability(descriptor, caps, "queryAggregate");
324
337
  const { aggregateAlias, ...aggFields } = buildAggregationFields(request.aggregation);
@@ -329,7 +342,7 @@ export function geoServicesMapServiceSource(descriptor, client, policy) {
329
342
  },
330
343
  async queryAll(request) {
331
344
  ensureCapability(descriptor, caps, "query");
332
- const params = withPagingBounds(toFeatureLayerRequest(request), request?.pagination?.limit);
345
+ const params = withPagingBounds(toFeatureLayerRequest(request, descriptor), request?.pagination?.limit);
333
346
  const features = await layer.queryFeaturesAll(params);
334
347
  const typed = features.map((toTypedFeature));
335
348
  const { features: limited, exceededTransferLimit } = applyQueryAllLimit(typed, request?.pagination?.limit);
@@ -341,19 +354,19 @@ export function geoServicesMapServiceSource(descriptor, client, policy) {
341
354
  },
342
355
  async queryAggregate(request) {
343
356
  ensureCapability(descriptor, caps, "queryAggregate");
344
- const params = toFeatureLayerRequest(request);
357
+ const params = toFeatureLayerRequest(request, descriptor);
345
358
  const { aggregateAlias, ...aggFields } = buildAggregationFields(request.aggregation);
346
359
  const response = await layer.queryFeatures({ ...params, ...aggFields });
347
360
  return aggregateResultFromUntyped(response, aggregateAlias);
348
361
  },
349
362
  async queryExtent(request) {
350
363
  ensureCapability(descriptor, caps, "queryExtent");
351
- const response = await layer.queryFeatures(toExtentOnlyRequest(toFeatureLayerRequest(request)));
364
+ const response = await layer.queryFeatures(toExtentOnlyRequest(toFeatureLayerRequest(request, descriptor)));
352
365
  return extractExtentEnvelope(response);
353
366
  },
354
367
  async *stream(request) {
355
368
  ensureCapability(descriptor, caps, "stream");
356
- const stream = layer.queryFeaturesStream(withStreamPageSize(toFeatureLayerRequest(request), request?.pagination?.limit));
369
+ const stream = layer.queryFeaturesStream(withStreamPageSize(toFeatureLayerRequest(request, descriptor), request?.pagination?.limit));
357
370
  for await (const page of stream) {
358
371
  yield {
359
372
  features: page.map((toTypedFeature)),
@@ -363,7 +376,7 @@ export function geoServicesMapServiceSource(descriptor, client, policy) {
363
376
  },
364
377
  async queryObjectIds(request) {
365
378
  ensureCapability(descriptor, caps, "queryObjectIds");
366
- return layer.queryObjectIds(toFeatureLayerRequest(request));
379
+ return layer.queryObjectIds(toFeatureLayerRequest(request, descriptor));
367
380
  },
368
381
  async applyEdits() {
369
382
  // MapServer is read-only; applyEdits exists only on the FeatureServer
@@ -395,7 +408,6 @@ export function geoServicesMapServiceSource(descriptor, client, policy) {
395
408
  * }],
396
409
  * });
397
410
  * const result = await dataset.source("addresses-ogc")!.queryAll({
398
- * where: "city = 'Honolulu'",
399
411
  * pagination: { limit: 200 },
400
412
  * });
401
413
  * ```
@@ -424,7 +436,7 @@ export function ogcFeaturesSource(descriptor, client, policy) {
424
436
  // degradable capability here.
425
437
  ensureCapabilityOrFallback(descriptor, caps, "queryAggregate", policy);
426
438
  }
427
- const response = await collection.items(toOgcRequest(request));
439
+ const response = await collection.items(toOgcRequest(request, descriptor));
428
440
  const features = response.features.map((toTypedFeatureFromOgc));
429
441
  const totalCount = response.numberMatched;
430
442
  const degraded = [];
@@ -460,7 +472,7 @@ export function ogcFeaturesSource(descriptor, client, policy) {
460
472
  // exist. Mirrors the GeoServices `withPagingBounds` + `applyQueryAllLimit`
461
473
  // pattern so `queryAll({ pagination: { limit } })` has the same
462
474
  // contract across protocols.
463
- const ogcRequest = toOgcRequest(request);
475
+ const ogcRequest = toOgcRequest(request, descriptor);
464
476
  if (typeof limit === "number" && Number.isFinite(limit) && limit >= 0) {
465
477
  ogcRequest.limit = limit + 1;
466
478
  }
@@ -478,7 +490,7 @@ export function ogcFeaturesSource(descriptor, client, policy) {
478
490
  // the descriptor must advertise `queryAggregate`; under `degraded` the
479
491
  // fallback runs unconditionally.
480
492
  ensureCapabilityOrFallback(descriptor, caps, "queryAggregate", policy);
481
- const all = await collection.itemsAll(withUnboundedMaxPages(toOgcMaterializedRequest(request)));
493
+ const all = await collection.itemsAll(withUnboundedMaxPages(toOgcMaterializedRequest(request, descriptor)));
482
494
  const features = all.map((toTypedFeatureFromOgc));
483
495
  return {
484
496
  features,
@@ -512,12 +524,12 @@ export function ogcFeaturesSource(descriptor, client, policy) {
512
524
  const [xmin, ymin, xmax, ymax] = bbox;
513
525
  return { extent: { xmin, ymin, xmax, ymax } };
514
526
  }
515
- const all = await collection.itemsAll(withUnboundedMaxPages(toOgcMaterializedRequest(request)));
527
+ const all = await collection.itemsAll(withUnboundedMaxPages(toOgcMaterializedRequest(request, descriptor)));
516
528
  return computeExtentFromOgcFeatures(all);
517
529
  },
518
530
  async *stream(request) {
519
531
  ensureCapability(descriptor, caps, "stream");
520
- const stream = collection.itemsStream(withUnboundedMaxPages(toOgcRequest(request)));
532
+ const stream = collection.itemsStream(withUnboundedMaxPages(toOgcRequest(request, descriptor)));
521
533
  for await (const page of stream) {
522
534
  yield {
523
535
  features: page.map((toTypedFeatureFromOgc)),
@@ -530,7 +542,7 @@ export function ogcFeaturesSource(descriptor, client, policy) {
530
542
  // OGC `/items` does not expose a server-side ids-only mode; drain the
531
543
  // matching set and project the GeoJSON `id`. Callers that need a
532
544
  // bounded scan should pass `pagination.limit`.
533
- const all = await collection.itemsAll(withUnboundedMaxPages(toOgcRequest(request)));
545
+ const all = await collection.itemsAll(withUnboundedMaxPages(toOgcRequest(request, descriptor)));
534
546
  const ids = [];
535
547
  for (const feature of all) {
536
548
  if (feature.id !== undefined && feature.id !== null) {
@@ -635,13 +647,13 @@ export function geoServicesImageSource(descriptor, client, policy) {
635
647
  // ImageServer query returns the raster catalog. The GeoServices
636
648
  // request shape is identical to FeatureServer query so we reuse
637
649
  // `toFeatureLayerRequest` and dispatch through the service.
638
- const response = await service.queryRasterCatalog(toFeatureLayerRequest(request));
650
+ const response = await service.queryRasterCatalog(toFeatureLayerRequest(request, descriptor));
639
651
  return featureLayerResultFromUntyped(response);
640
652
  },
641
653
  async queryAll(request) {
642
654
  ensureCapability(descriptor, caps, "query");
643
655
  requireImageServerCompatibleQuery(request);
644
- const baseParams = toFeatureLayerRequest(request);
656
+ const baseParams = toFeatureLayerRequest(request, descriptor);
645
657
  const limit = request?.pagination?.limit;
646
658
  // ImageServer's catalog endpoint supports `resultOffset`/`resultRecordCount`
647
659
  // but the wrapper does not expose a `queryAllRasterCatalog` helper, so
@@ -691,7 +703,7 @@ export function geoServicesImageSource(descriptor, client, policy) {
691
703
  async queryExtent(request) {
692
704
  ensureCapability(descriptor, caps, "queryExtent");
693
705
  requireImageServerCompatibleQuery(request);
694
- const response = await service.queryRasterCatalog(toExtentOnlyRequest(toFeatureLayerRequest(request)));
706
+ const response = await service.queryRasterCatalog(toExtentOnlyRequest(toFeatureLayerRequest(request, descriptor)));
695
707
  return extractExtentEnvelope(response);
696
708
  },
697
709
  // biome-ignore lint/correctness/useYield: ImageServer has no streaming raster-catalog mode; this generator refuses iteration rather than silently emit a single page
@@ -701,7 +713,7 @@ export function geoServicesImageSource(descriptor, client, policy) {
701
713
  async queryObjectIds(request) {
702
714
  ensureCapability(descriptor, caps, "queryObjectIds");
703
715
  requireImageServerCompatibleQuery(request);
704
- return service.queryRasterCatalogObjectIds(toFeatureLayerRequest(request));
716
+ return service.queryRasterCatalogObjectIds(toFeatureLayerRequest(request, descriptor));
705
717
  },
706
718
  async applyEdits() {
707
719
  throw new HonuaCapabilityNotSupportedError("applyEdits", descriptor.protocol, descriptor.id);
@@ -828,9 +840,9 @@ export function ogcTilesSource(descriptor, client, policy) {
828
840
  * console.log(info.bounds, info.vectorLayers.map((layer) => layer.id));
829
841
  * ```
830
842
  */
831
- export function pmtilesSource(descriptor, _client, policy) {
843
+ export function pmtilesSource(descriptor, _client, policy, deps = {}) {
832
844
  descriptor = normalizeCapabilityDescriptor(descriptor);
833
- const discovered = pmtilesProtocolModule().discover(descriptor);
845
+ const discovered = pmtilesProtocolModule(deps).discover(descriptor);
834
846
  if (discovered instanceof Promise) {
835
847
  // PMTiles discovery is always synchronous (the reader opens lazily on the
836
848
  // handle's own first `describe()` call); a module that started returning
@@ -886,7 +898,7 @@ export function ogcMapsSource(descriptor, client, policy) {
886
898
  * @example
887
899
  * ```ts
888
900
  * const records = dataset.source("catalog")!;
889
- * const result = await records.query({ where: "type = 'dataset' AND q = 'parcels'" });
901
+ * const result = await records.query({ pagination: { limit: 100 } });
890
902
  * ```
891
903
  */
892
904
  export function ogcRecordsSource(descriptor, client, policy) {
@@ -908,7 +920,7 @@ export function ogcRecordsSource(descriptor, client, policy) {
908
920
  if (request?.aggregation) {
909
921
  throw new HonuaCapabilityNotSupportedError("queryAggregate", descriptor.protocol, descriptor.id);
910
922
  }
911
- const response = await collection.search(toOgcRecordsRequest(request));
923
+ const response = await collection.search(toOgcRecordsRequest(request, descriptor));
912
924
  const features = (response.features ?? []).map((toTypedFeatureFromOgcRecord));
913
925
  const totalCount = response.numberMatched;
914
926
  return {
@@ -921,7 +933,7 @@ export function ogcRecordsSource(descriptor, client, policy) {
921
933
  ensureCapability(descriptor, caps, "query");
922
934
  const limit = request?.pagination?.limit;
923
935
  const records = await collection.searchAll({
924
- ...toOgcRecordsRequest(request),
936
+ ...toOgcRecordsRequest(request, descriptor),
925
937
  ...withPagingBounds({}, limit),
926
938
  });
927
939
  const typed = records.map((toTypedFeatureFromOgcRecord));
@@ -942,7 +954,7 @@ export function ogcRecordsSource(descriptor, client, policy) {
942
954
  ensureCapability(descriptor, caps, "stream");
943
955
  const limit = request?.pagination?.limit;
944
956
  const stream = collection.searchStream({
945
- ...toOgcRecordsRequest(request),
957
+ ...toOgcRecordsRequest(request, descriptor),
946
958
  pageSize: limit !== undefined ? Math.max(1, limit) : undefined,
947
959
  maxPages: Number.MAX_SAFE_INTEGER,
948
960
  });
@@ -957,7 +969,7 @@ export function ogcRecordsSource(descriptor, client, policy) {
957
969
  ensureCapability(descriptor, caps, "queryObjectIds");
958
970
  const limit = request?.pagination?.limit;
959
971
  const records = await collection.searchAll({
960
- ...toOgcRecordsRequest(request),
972
+ ...toOgcRecordsRequest(request, descriptor),
961
973
  ...withPagingBounds({}, limit),
962
974
  });
963
975
  const ids = [];
@@ -1012,14 +1024,21 @@ export function wmsSource(descriptor, client, policy) {
1012
1024
  descriptor = normalizeCapabilityDescriptor(descriptor);
1013
1025
  const advertisedCaps = descriptor.capabilities ?? PROTOCOL_DEFAULT_CAPABILITIES.wms;
1014
1026
  if (typeof descriptor.locator.serviceId !== "string" || descriptor.locator.serviceId.length === 0) {
1015
- if (descriptor.locator.raster?.kind !== "wms-kvp")
1027
+ const featureInfoBinding = descriptor.locator.featureInfo;
1028
+ if (descriptor.locator.raster?.kind !== "wms-kvp" && featureInfoBinding?.kind !== "wms-kvp") {
1016
1029
  requireWmsLocator(descriptor);
1030
+ }
1017
1031
  // Raw third-party discovery is renderable through the existing descriptor
1018
- // → MapLibre raster projection. The existing canonical FeatureInfo adapter
1019
- // is Honua-service-id based, so keep that surface fail-closed here.
1020
- const caps = new Set([...advertisedCaps].filter((capability) => capability !== "query"));
1021
- descriptor = { ...descriptor, capabilities: caps };
1022
- return makeSource(descriptor, caps, policy, {}, unsupportedFeatureSurface(descriptor));
1032
+ // → MapLibre raster projection, and queryable whenever discovery reviewed a
1033
+ // GetFeatureInfo operation (queryable layer + projectable INFO_FORMAT +
1034
+ // provable CRS axis order). Without that binding the query surface stays
1035
+ // fail-closed.
1036
+ if (featureInfoBinding?.kind !== "wms-kvp" || !advertisedCaps.has("query")) {
1037
+ const caps = new Set([...advertisedCaps].filter((capability) => capability !== "query"));
1038
+ descriptor = { ...descriptor, capabilities: caps };
1039
+ return makeSource(descriptor, caps, policy, {}, unsupportedFeatureSurface(descriptor));
1040
+ }
1041
+ return standaloneWmsSource({ ...descriptor, capabilities: advertisedCaps }, featureInfoBinding, client, policy, advertisedCaps);
1023
1042
  }
1024
1043
  const { serviceId } = requireWmsLocator(descriptor);
1025
1044
  const layerName = descriptor.locator.typeName;
@@ -1049,24 +1068,11 @@ export function wmsSource(descriptor, client, policy) {
1049
1068
  ensureCapability(descriptor, caps, "query");
1050
1069
  requireWmsCompatibleQuery(descriptor, request);
1051
1070
  const layers = wmsRequireLayers(descriptor, layerName);
1052
- const { x: px, y: py, crs } = wmsExtractPointFromQuery(request, descriptor);
1053
- const bboxRadius = 0.0001; // tiny envelope around the point keeps the request a 1×1 image.
1054
- const widthHeight = 1;
1055
- const featureCount = request?.pagination?.limit;
1071
+ const point = wmsExtractPointFromQuery(request, descriptor);
1056
1072
  const response = await client.getWmsFeatureInfo({
1057
1073
  serviceId,
1058
- layers,
1059
- ...(styleId !== undefined ? { styles: [styleId] } : {}),
1060
- queryLayers: layers,
1061
- crs,
1062
- bbox: [px - bboxRadius, py - bboxRadius, px + bboxRadius, py + bboxRadius],
1063
- width: widthHeight,
1064
- height: widthHeight,
1065
- i: 0,
1066
- j: 0,
1074
+ ...wmsPointFeatureInfoRequest({ layers, styleId, crs: point.crs, x: point.x, y: point.y, request }),
1067
1075
  infoFormat: "application/json",
1068
- ...(featureCount !== undefined ? { featureCount } : {}),
1069
- ...(request?.signal ? { signal: request.signal } : {}),
1070
1076
  });
1071
1077
  return {
1072
1078
  features: response.features ?? [],
@@ -1102,6 +1108,97 @@ export function wmsSource(descriptor, client, policy) {
1102
1108
  attachments: unsupportedAttachmentApi(descriptor),
1103
1109
  });
1104
1110
  }
1111
+ /**
1112
+ * Radius of the envelope drawn around the queried point. WMS has no point
1113
+ * request: `GetFeatureInfo` addresses a pixel inside a rendered image, so the
1114
+ * canonical point query renders a 1×1 image whose BBOX is this tiny envelope
1115
+ * and reads pixel (0, 0).
1116
+ */
1117
+ const WMS_FEATURE_INFO_POINT_RADIUS = 0.0001;
1118
+ /**
1119
+ * Build the shared `GetFeatureInfo` envelope for a canonical point query. Both
1120
+ * the Honua service-id path and the capabilities-driven third-party path use
1121
+ * it, so LAYERS / QUERY_LAYERS / STYLES / BBOX / WIDTH / HEIGHT / I / J /
1122
+ * FEATURE_COUNT are derived once.
1123
+ */
1124
+ function wmsPointFeatureInfoRequest(options) {
1125
+ const radius = WMS_FEATURE_INFO_POINT_RADIUS;
1126
+ const featureCount = options.request?.pagination?.limit;
1127
+ const signal = options.request?.signal;
1128
+ return {
1129
+ layers: [...options.layers],
1130
+ ...(options.styleId !== undefined ? { styles: [options.styleId] } : {}),
1131
+ queryLayers: [...options.layers],
1132
+ crs: options.crs,
1133
+ bbox: [options.x - radius, options.y - radius, options.x + radius, options.y + radius],
1134
+ width: 1,
1135
+ height: 1,
1136
+ i: 0,
1137
+ j: 0,
1138
+ ...(featureCount !== undefined ? { featureCount } : {}),
1139
+ ...(signal ? { signal } : {}),
1140
+ };
1141
+ }
1142
+ /**
1143
+ * `Source` adapter for a third-party WMS 1.3.0 endpoint that `connect()`
1144
+ * reviewed a `GetFeatureInfo` binding for. Canonical `Source.query()` issues
1145
+ * the point request against the capabilities-advertised operation URL under the
1146
+ * same same-origin, credential-free policy discovery applied to `GetMap`.
1147
+ *
1148
+ * The raw `Source.protocol("wms")` handles stay unavailable here: they are
1149
+ * Honua-service-id bound. Render/tiles continue to flow through the descriptor
1150
+ * → MapLibre raster projection.
1151
+ */
1152
+ function standaloneWmsSource(descriptor, binding, client, policy, caps) {
1153
+ const layerName = descriptor.locator.typeName;
1154
+ const styleId = descriptor.locator.styleId;
1155
+ const runQuery = async (request) => {
1156
+ ensureCapability(descriptor, caps, "query");
1157
+ requireWmsCompatibleQuery(descriptor, request);
1158
+ const layers = wmsRequireLayers(descriptor, layerName);
1159
+ const point = wmsExtractPointFromQuery(request, descriptor);
1160
+ const crs = resolveWmsQueryCrs(binding.crs, point.crs);
1161
+ if (crs === undefined) {
1162
+ throw new HonuaCapabilityNotSupportedError("query", descriptor.protocol, `${descriptor.id} (GetFeatureInfo CRS "${point.crs}" is not one of the layer's reviewed CRS identifiers: ${binding.crs.join(", ")})`);
1163
+ }
1164
+ const features = await executeWmsFeatureInfo(client, {
1165
+ binding: { url: binding.url, format: binding.format },
1166
+ request: wmsPointFeatureInfoRequest({ layers, styleId, crs, x: point.x, y: point.y, request }),
1167
+ sourceId: descriptor.id,
1168
+ });
1169
+ return { features, exceededTransferLimit: false };
1170
+ };
1171
+ return makeSource(descriptor, caps, policy, {}, {
1172
+ async query(request) {
1173
+ return runQuery(request);
1174
+ },
1175
+ async queryAll(request) {
1176
+ // GetFeatureInfo returns a single response set; queryAll degenerates to
1177
+ // query() because there is no paging on the wire.
1178
+ return runQuery(request);
1179
+ },
1180
+ async queryAggregate() {
1181
+ throw new HonuaCapabilityNotSupportedError("queryAggregate", descriptor.protocol, descriptor.id);
1182
+ },
1183
+ async queryExtent() {
1184
+ throw new HonuaCapabilityNotSupportedError("queryExtent", descriptor.protocol, descriptor.id);
1185
+ },
1186
+ // biome-ignore lint/correctness/useYield: WMS exposes only single-shot GetFeatureInfo, which is delivered through query()
1187
+ async *stream() {
1188
+ throw new HonuaCapabilityNotSupportedError("stream", descriptor.protocol, descriptor.id);
1189
+ },
1190
+ async queryObjectIds() {
1191
+ throw new HonuaCapabilityNotSupportedError("queryObjectIds", descriptor.protocol, descriptor.id);
1192
+ },
1193
+ async applyEdits() {
1194
+ throw new HonuaCapabilityNotSupportedError("applyEdits", descriptor.protocol, descriptor.id);
1195
+ },
1196
+ async queryRelated() {
1197
+ throw new HonuaCapabilityNotSupportedError("queryRelated", descriptor.protocol, descriptor.id);
1198
+ },
1199
+ attachments: unsupportedAttachmentApi(descriptor),
1200
+ });
1201
+ }
1105
1202
  // ── WMTS 1.0 ──────────────────────────────────────────────────
1106
1203
  /**
1107
1204
  * Render-only `Source` adapter for first-party WMTS 1.0.0 services.
@@ -1159,10 +1256,14 @@ export function wmtsSource(descriptor, client, policy) {
1159
1256
  /**
1160
1257
  * Adapter factory for a STAC API search endpoint.
1161
1258
  *
1162
- * The canonical `Source.query()` runs a `POST /search` against the STAC root,
1163
- * with `Query.spatialFilter` (e.g. an `envelope(...)` bounding box) and
1164
- * `Query.where` translated into STAC's `bbox` / `datetime` / `filter`
1165
- * parameters.
1259
+ * The canonical `Source.query()` runs a search against the STAC root, using
1260
+ * `POST /search` when the landing page advertises a `rel="search"` link with
1261
+ * `method: "POST"` (long CQL2 filters and `intersects` geometries overflow GET
1262
+ * URL limits) and falling back to the universally supported `GET /search`
1263
+ * otherwise. `Query.spatialFilter` translates to STAC's `bbox` for an
1264
+ * `envelope(...)` and to `intersects` for any other GeoJSON-convertible
1265
+ * geometry. The deprecated source-native `Query.where` migration member maps
1266
+ * to STAC `datetime` / `filter` parameters.
1166
1267
  *
1167
1268
  * @example
1168
1269
  * ```ts
@@ -1179,8 +1280,8 @@ export function wmtsSource(descriptor, client, policy) {
1179
1280
  * }],
1180
1281
  * });
1181
1282
  * const result = await dataset.source("stac-search")!.query({
1182
- * where: "collections IN ('landsat-c2-l2')",
1183
1283
  * spatialFilter: envelope(-158.5, 21.2, -157.6, 21.7),
1284
+ * pagination: { limit: 100 },
1184
1285
  * });
1185
1286
  * ```
1186
1287
  */
@@ -1201,13 +1302,24 @@ export function stacSearchSource(descriptor, client, policy) {
1201
1302
  const adapterRegistry = {
1202
1303
  stac,
1203
1304
  };
1305
+ /**
1306
+ * Build the wire search request, negotiating the HTTP method once per
1307
+ * source: `POST /search` when the API advertises it, `GET /search`
1308
+ * otherwise. A static catalog has no search endpoint, so it never probes.
1309
+ */
1310
+ const toNegotiatedStacRequest = async (request) => {
1311
+ const stacRequest = toStacRequest(request, collectionScope, descriptor);
1312
+ if (!staticCatalog && (await stac.supportsPostSearch()))
1313
+ stacRequest.usePost = true;
1314
+ return stacRequest;
1315
+ };
1204
1316
  return makeSource(descriptor, caps, policy, adapterRegistry, {
1205
1317
  async query(request) {
1206
1318
  ensureCapability(descriptor, caps, "query");
1207
1319
  if (request?.aggregation) {
1208
1320
  throw new HonuaCapabilityNotSupportedError("queryAggregate", descriptor.protocol, descriptor.id);
1209
1321
  }
1210
- const stacRequest = toStacRequest(request, collectionScope);
1322
+ const stacRequest = await toNegotiatedStacRequest(request);
1211
1323
  const response = staticCatalog
1212
1324
  ? await staticCatalog.search(toStacStaticParams(stacRequest))
1213
1325
  : await stac.search(stacRequest);
@@ -1224,13 +1336,13 @@ export function stacSearchSource(descriptor, client, policy) {
1224
1336
  ensureCapability(descriptor, caps, "query");
1225
1337
  const limit = request?.pagination?.limit;
1226
1338
  if (staticCatalog) {
1227
- const response = await staticCatalog.search(toStacStaticParams(toStacRequest(request, collectionScope)));
1339
+ const response = await staticCatalog.search(toStacStaticParams(toStacRequest(request, collectionScope, descriptor)));
1228
1340
  const typed = (response.features ?? []).map((toTypedFeatureFromStac));
1229
1341
  const { features, exceededTransferLimit } = applyQueryAllLimit(typed, limit);
1230
1342
  return { features, exceededTransferLimit, totalCount: features.length };
1231
1343
  }
1232
1344
  const items = await stac.searchAll({
1233
- ...toStacRequest(request, collectionScope),
1345
+ ...(await toNegotiatedStacRequest(request)),
1234
1346
  ...withPagingBounds({}, limit),
1235
1347
  });
1236
1348
  const typed = items.map((toTypedFeatureFromStac));
@@ -1253,7 +1365,7 @@ export function stacSearchSource(descriptor, client, policy) {
1253
1365
  if (staticCatalog) {
1254
1366
  // A static catalog has no server-side paging; yield the whole
1255
1367
  // filtered set as one page.
1256
- const response = await staticCatalog.search(toStacStaticParams(toStacRequest(request, collectionScope)));
1368
+ const response = await staticCatalog.search(toStacStaticParams(toStacRequest(request, collectionScope, descriptor)));
1257
1369
  yield {
1258
1370
  features: (response.features ?? []).map((toTypedFeatureFromStac)),
1259
1371
  exceededTransferLimit: false,
@@ -1261,7 +1373,7 @@ export function stacSearchSource(descriptor, client, policy) {
1261
1373
  return;
1262
1374
  }
1263
1375
  const stream = stac.searchStream({
1264
- ...toStacRequest(request, collectionScope),
1376
+ ...(await toNegotiatedStacRequest(request)),
1265
1377
  pageSize: limit !== undefined ? Math.max(1, limit) : undefined,
1266
1378
  maxPages: Number.MAX_SAFE_INTEGER,
1267
1379
  });
@@ -1280,9 +1392,10 @@ export function stacSearchSource(descriptor, client, policy) {
1280
1392
  // fetches at most `limit + 1` rows, never a full-catalog scan.
1281
1393
  const limit = request?.pagination?.limit;
1282
1394
  const items = staticCatalog
1283
- ? ((await staticCatalog.search(toStacStaticParams(toStacRequest(request, collectionScope)))).features ?? [])
1395
+ ? ((await staticCatalog.search(toStacStaticParams(toStacRequest(request, collectionScope, descriptor))))
1396
+ .features ?? [])
1284
1397
  : await stac.searchAll({
1285
- ...toStacRequest(request, collectionScope),
1398
+ ...(await toNegotiatedStacRequest(request)),
1286
1399
  ...withPagingBounds({}, limit),
1287
1400
  });
1288
1401
  const ids = [];
@@ -1306,22 +1419,28 @@ export function stacSearchSource(descriptor, client, policy) {
1306
1419
  });
1307
1420
  }
1308
1421
  // ── WFS 2.0 ───────────────────────────────────────────────────
1309
- /**
1310
- * Threshold above which the WFS adapter switches to POST GetFeature with a
1311
- * `<fes:Filter>` body. URL budget under 7000 chars stays GET-friendly; longer
1312
- * filters are routed through POST so we do not stress middleboxes that
1313
- * trim long query strings. The threshold is intentionally a single
1314
- * constant we can revise after telemetry lands.
1315
- */
1316
- const WFS_GET_FILTER_BUDGET = 7000;
1317
- const DEFAULT_WFS_GEOMETRY_PROPERTY = "the_geom";
1318
1422
  /**
1319
1423
  * Adapter factory for a WFS 2.0 endpoint.
1320
1424
  *
1321
- * `Query.where` and `Query.spatialFilter` compile to FES 2.0; GeoJSON is
1322
- * preferred over GML via `OperationsMetadata` negotiation. `applyEdits()`
1323
- * builds `<wfs:Transaction>` bodies. Reach `Source.protocol("wfs")` for raw
1324
- * GML / `LockFeature` / stored-query access.
1425
+ * `Query.spatialFilter` and the deprecated source-native `Query.where`
1426
+ * migration member compile to FES 2.0; GeoJSON is preferred over GML via
1427
+ * `OperationsMetadata` negotiation. `applyEdits()` builds
1428
+ * `<wfs:Transaction>` bodies. Reach `Source.protocol("wfs")` for raw GML,
1429
+ * custom FES filters, `GetPropertyValue`, raw `<wfs:Transaction>` bodies, and
1430
+ * stored queries.
1431
+ *
1432
+ * Locking (`LockFeature` / `GetFeatureWithLock`) has no typed helper anywhere in
1433
+ * the SDK. Callers that need locks must send their own request XML through
1434
+ * `Source.protocol("wfs")!.root.requestText(...)` and parse the response
1435
+ * themselves; no lock id is extracted and no lock state is tracked.
1436
+ *
1437
+ * FES spatial predicates and transaction geometry name the feature type's
1438
+ * geometry property, which is server-specific (`the_geom` on
1439
+ * PostGIS-via-GeoServer, `msGeometry` on MapServer, arbitrary per-schema names
1440
+ * elsewhere). The adapter resolves it from `DescribeFeatureType` on first use
1441
+ * and caches it; `locator.geometryName` pins it explicitly. Resolution failure
1442
+ * fails closed rather than guessing a default that would silently match
1443
+ * nothing.
1325
1444
  *
1326
1445
  * @example
1327
1446
  * ```ts
@@ -1336,17 +1455,21 @@ const DEFAULT_WFS_GEOMETRY_PROPERTY = "the_geom";
1336
1455
  * }],
1337
1456
  * });
1338
1457
  * const result = await dataset.source("parcels")!.queryAll({
1339
- * where: "STATUS = 'ACTIVE'",
1340
1458
  * pagination: { limit: 500 },
1341
1459
  * });
1342
1460
  * ```
1343
1461
  */
1344
1462
  export function wfsSource(descriptor, client, policy) {
1345
1463
  descriptor = normalizeCapabilityDescriptor(descriptor);
1346
- const { url, typeName, featureNamespace, srsName: wfsSrsName } = requireWfsLocator(descriptor);
1347
- const root = client.wfs(url);
1348
- const featureType = new HonuaWfsFeatureType({ root, typeName });
1349
- const caps = descriptor.capabilities ?? PROTOCOL_DEFAULT_CAPABILITIES.wfs;
1464
+ const { typeName, featureNamespace, srsName: wfsSrsName, geometryName } = requireWfsLocator(descriptor);
1465
+ const module = wfsProtocolModule(client);
1466
+ const discovered = module.discover(descriptor);
1467
+ if (discovered instanceof Promise) {
1468
+ throw new Error("wfs: built-in source construction requires synchronous protocol-module discovery");
1469
+ }
1470
+ const featureType = discovered.adapter;
1471
+ const root = featureType.root;
1472
+ const caps = discovered.capabilities;
1350
1473
  void policy;
1351
1474
  const adapterRegistry = {
1352
1475
  wfs: featureType,
@@ -1371,6 +1494,65 @@ export function wfsSource(descriptor, client, policy) {
1371
1494
  cachedFormat = choice;
1372
1495
  return choice;
1373
1496
  }
1497
+ /**
1498
+ * Geometry property of this feature type, from `locator.geometryName` or —
1499
+ * once, lazily — from `DescribeFeatureType`. Throws
1500
+ * `HonuaWfsProtocolError("unresolved-geometry-property")` when neither
1501
+ * names it, so a spatial filter or transaction never targets a guessed
1502
+ * property that silently matches nothing.
1503
+ */
1504
+ async function resolveGeometryProperty(signal) {
1505
+ if (geometryName !== undefined)
1506
+ return geometryName;
1507
+ return featureType.geometryPropertyName(signal ? { signal } : undefined);
1508
+ }
1509
+ /**
1510
+ * Descriptor whose locator carries the resolved geometry property, so the
1511
+ * compiler emits it on every reference in the request it plans.
1512
+ */
1513
+ let geometryResolvedDescriptor;
1514
+ async function geometryBoundDescriptor(signal) {
1515
+ if (geometryName !== undefined)
1516
+ return descriptor;
1517
+ if (!geometryResolvedDescriptor) {
1518
+ const resolved = await resolveGeometryProperty(signal);
1519
+ geometryResolvedDescriptor = { ...descriptor, locator: { ...descriptor.locator, geometryName: resolved } };
1520
+ }
1521
+ return geometryResolvedDescriptor;
1522
+ }
1523
+ async function compileSourceQuery(request, operation) {
1524
+ try {
1525
+ const normalizedRequest = normalizeWfsModuleQuery(request);
1526
+ const buildIr = (source) => normalizedRequest === undefined
1527
+ ? createQueryIr({ descriptor: source })
1528
+ : createQueryIr({ descriptor: source, query: normalizedRequest });
1529
+ // The legacy WFS Source accepted an explicitly empty `where`. Preserve
1530
+ // its wire distinction from an omitted `where`: with a spatial filter,
1531
+ // the former takes the FES path while the latter may use the bbox KVP.
1532
+ const canonicalQuery = (ir) => request?.where === ""
1533
+ ? Object.freeze({
1534
+ ...ir.query,
1535
+ where: Object.freeze({ kind: "source-native", expression: "" }),
1536
+ })
1537
+ : ir.query;
1538
+ let ir = buildIr(descriptor);
1539
+ let query = canonicalQuery(ir);
1540
+ // Only an FES spatial predicate names the geometry property on the wire;
1541
+ // the `bbox=` shortcut addresses the default geometry positionally. Pay
1542
+ // for schema resolution exactly when the request depends on the name.
1543
+ if (ir.source.geometryProperty === undefined && wfsSpatialFilterNeedsGeometryProperty(query)) {
1544
+ ir = buildIr(await geometryBoundDescriptor(request?.signal));
1545
+ query = canonicalQuery(ir);
1546
+ }
1547
+ return module.compile({ source: ir.source, query, operation });
1548
+ }
1549
+ catch (error) {
1550
+ if (error instanceof HonuaQueryPlanningError && error.code === "unsupported-query") {
1551
+ throw new HonuaCapabilityNotSupportedError("query", descriptor.protocol, `${descriptor.id} (${error.message})`);
1552
+ }
1553
+ throw error;
1554
+ }
1555
+ }
1374
1556
  return makeSource(descriptor, caps, policy, adapterRegistry, {
1375
1557
  async query(request) {
1376
1558
  ensureCapability(descriptor, caps, "query");
@@ -1379,48 +1561,25 @@ export function wfsSource(descriptor, client, policy) {
1379
1561
  // silent partial result.
1380
1562
  throw new HonuaCapabilityNotSupportedError("queryAggregate", descriptor.protocol, descriptor.id);
1381
1563
  }
1382
- // `pagination.limit === 0` is an explicit zero cap, not "unbounded";
1383
- // short-circuit before the wire call so we do not silently widen to the
1384
- // server's default page size.
1385
- if (request?.pagination?.limit === 0) {
1386
- return { features: [], exceededTransferLimit: false };
1387
- }
1388
- const choice = await negotiateJsonOrThrow();
1389
- const json = await runGetFeatureJson(featureType, typeName, choice, request, request?.pagination?.limit);
1390
- return resultFromGeoJson(json);
1564
+ return module.execute(discovered, {
1565
+ compiled: await compileSourceQuery(request, "query"),
1566
+ operation: "query",
1567
+ query: {},
1568
+ ...(request?.signal ? { signal: request.signal } : {}),
1569
+ });
1391
1570
  },
1392
1571
  async queryAll(request) {
1393
1572
  ensureCapability(descriptor, caps, "query");
1394
- const choice = await negotiateJsonOrThrow();
1395
- const limit = request?.pagination?.limit;
1396
- const target = typeof limit === "number" && limit >= 0 ? limit + 1 : Number.POSITIVE_INFINITY;
1397
- const collected = [];
1398
- let totalCount;
1399
- let offset = request?.pagination?.offset ?? 0;
1400
- // Mirror `withPagingBounds`: a finite limit (including 0) sets the
1401
- // lookahead page size to `limit + 1` so we can stamp
1402
- // `exceededTransferLimit` without overfetching a full default page.
1403
- const pageSize = typeof limit === "number" && limit >= 0 ? Math.max(1, Math.min(limit + 1, 2000)) : 2000;
1404
- while (collected.length < target) {
1405
- const pageRequest = {
1406
- ...(request ?? {}),
1407
- pagination: { offset, limit: pageSize },
1408
- };
1409
- const json = await runGetFeatureJson(featureType, typeName, choice, pageRequest, pageSize);
1410
- const result = resultFromGeoJson(json);
1411
- if (result.totalCount !== undefined)
1412
- totalCount = result.totalCount;
1413
- collected.push(...result.features);
1414
- if (result.features.length < pageSize)
1415
- break;
1416
- offset += result.features.length;
1417
- }
1418
- const { features, exceededTransferLimit } = applyQueryAllLimit(collected, limit);
1419
- return {
1420
- features,
1421
- exceededTransferLimit,
1422
- totalCount: totalCount ?? features.length,
1423
- };
1573
+ const requestedLimit = request?.pagination?.limit;
1574
+ const limit = typeof requestedLimit === "number" && requestedLimit >= 0 ? requestedLimit : undefined;
1575
+ return module.execute(discovered, {
1576
+ compiled: await compileSourceQuery(request, "queryAll"),
1577
+ operation: "queryAll",
1578
+ query: {
1579
+ ...(limit !== undefined ? { logicalLimit: limit } : {}),
1580
+ },
1581
+ ...(request?.signal ? { signal: request.signal } : {}),
1582
+ });
1424
1583
  },
1425
1584
  async queryAggregate() {
1426
1585
  throw new HonuaCapabilityNotSupportedError("queryAggregate", descriptor.protocol, descriptor.id);
@@ -1455,7 +1614,7 @@ export function wfsSource(descriptor, client, policy) {
1455
1614
  ...extentRequest,
1456
1615
  pagination: { offset, limit: drainPageSize },
1457
1616
  };
1458
- const json = await runGetFeatureJson(featureType, typeName, choice, pageRequest, drainPageSize);
1617
+ const json = await runGetFeatureJson(featureType, choice, featureNamespace, await compileSourceQuery(pageRequest, "query"));
1459
1618
  const page = computeExtentFromFeatureCollection(json);
1460
1619
  count += page.count;
1461
1620
  if (page.extent) {
@@ -1484,25 +1643,46 @@ export function wfsSource(descriptor, client, policy) {
1484
1643
  // rather than silently fall back to the default 2000-row page size.
1485
1644
  if (limit === 0)
1486
1645
  return;
1487
- const choice = await negotiateJsonOrThrow();
1488
1646
  const pageSize = typeof limit === "number" && limit > 0 ? Math.max(1, limit) : 2000;
1489
1647
  let offset = request?.pagination?.offset ?? 0;
1648
+ let totalCount;
1649
+ const seenPages = new Set();
1490
1650
  while (true) {
1491
1651
  const pageRequest = {
1492
1652
  ...(request ?? {}),
1493
1653
  pagination: { offset, limit: pageSize },
1494
1654
  };
1495
- const json = await runGetFeatureJson(featureType, typeName, choice, pageRequest, pageSize);
1496
- const result = resultFromGeoJson(json);
1497
- if (result.features.length === 0)
1655
+ const result = await module.execute(discovered, {
1656
+ compiled: await compileSourceQuery(pageRequest, "query"),
1657
+ operation: "query",
1658
+ query: {},
1659
+ ...(request?.signal ? { signal: request.signal } : {}),
1660
+ });
1661
+ if (result.totalCount !== undefined) {
1662
+ if (totalCount !== undefined && totalCount !== result.totalCount) {
1663
+ throw new HonuaWfsProtocolError("invalid-feature-response", "WFS GetFeature numberMatched changed while streaming one query");
1664
+ }
1665
+ totalCount = result.totalCount;
1666
+ }
1667
+ const pageEnd = checkedWfsPageEnd(offset, result.features.length);
1668
+ if (totalCount !== undefined && pageEnd > totalCount) {
1669
+ throw new HonuaWfsProtocolError("invalid-feature-response", "WFS GetFeature returned more rows than numberMatched permits");
1670
+ }
1671
+ if (result.features.length === 0) {
1672
+ if (totalCount !== undefined && offset < totalCount) {
1673
+ throw new HonuaWfsProtocolError("paging-stalled", `WFS GetFeature returned no rows at startIndex ${offset} while numberMatched=${totalCount}`);
1674
+ }
1498
1675
  break;
1676
+ }
1677
+ assertWfsPageProgress(result, seenPages, offset);
1499
1678
  yield {
1500
1679
  features: result.features,
1501
- exceededTransferLimit: false,
1680
+ exceededTransferLimit: totalCount !== undefined ? pageEnd < totalCount : result.exceededTransferLimit,
1681
+ ...(totalCount !== undefined ? { totalCount } : {}),
1502
1682
  };
1503
- if (result.features.length < pageSize)
1683
+ offset = pageEnd;
1684
+ if (totalCount !== undefined && offset >= totalCount)
1504
1685
  break;
1505
- offset += result.features.length;
1506
1686
  }
1507
1687
  },
1508
1688
  async queryObjectIds(request) {
@@ -1532,7 +1712,7 @@ export function wfsSource(descriptor, client, policy) {
1532
1712
  ...idsRequest,
1533
1713
  pagination: { offset, limit: pageSize },
1534
1714
  };
1535
- const json = await runGetFeatureJson(featureType, typeName, choice, pageRequest, pageSize);
1715
+ const json = await runGetFeatureJson(featureType, choice, featureNamespace, await compileSourceQuery(pageRequest, "query"));
1536
1716
  const collection = json;
1537
1717
  const features = collection.features ?? [];
1538
1718
  for (const feature of features) {
@@ -1582,7 +1762,13 @@ export function wfsSource(descriptor, client, policy) {
1582
1762
  // same root, so the capabilities promise is already settled there.
1583
1763
  await root.capabilities(envelope.signal ? { signal: envelope.signal } : undefined);
1584
1764
  const filtered = { ...envelope, updates: validUpdates };
1585
- const body = buildTransactionBody(typeName, filtered, featureNamespace, wfsSrsName);
1765
+ // Attribute-only edits and deletes never name the geometry property,
1766
+ // so only a geometry-bearing transaction pays for (and fails closed
1767
+ // on) schema resolution.
1768
+ const writesGeometry = adds.some((add) => add.geometry !== undefined && add.geometry !== null) ||
1769
+ validUpdates.some((update) => update.geometry !== undefined && update.geometry !== null);
1770
+ const geometryProperty = writesGeometry ? await resolveGeometryProperty(envelope.signal) : undefined;
1771
+ const body = buildTransactionBody(typeName, filtered, featureNamespace, wfsSrsName, geometryProperty);
1586
1772
  const transactionOptions = { body };
1587
1773
  if (envelope.signal)
1588
1774
  transactionOptions.signal = envelope.signal;
@@ -1596,8 +1782,32 @@ export function wfsSource(descriptor, client, policy) {
1596
1782
  attachments: unsupportedAttachmentApi(descriptor),
1597
1783
  });
1598
1784
  }
1785
+ function normalizeWfsModuleQuery(request) {
1786
+ if (request === undefined)
1787
+ return undefined;
1788
+ const omitWhere = request.where === "";
1789
+ const pagination = request.pagination;
1790
+ const omitLimit = pagination?.limit !== undefined && pagination.limit < 0;
1791
+ const omitOffset = pagination?.offset !== undefined && pagination.offset < 0;
1792
+ if (!omitWhere && !omitLimit && !omitOffset)
1793
+ return request;
1794
+ const { where: _where, pagination: _pagination, ...rest } = request;
1795
+ void _where;
1796
+ void _pagination;
1797
+ const normalizedPagination = pagination === undefined
1798
+ ? undefined
1799
+ : {
1800
+ ...(!omitOffset && pagination.offset !== undefined ? { offset: pagination.offset } : {}),
1801
+ ...(!omitLimit && pagination.limit !== undefined ? { limit: pagination.limit } : {}),
1802
+ };
1803
+ return {
1804
+ ...rest,
1805
+ ...(!omitWhere && request.where !== undefined ? { where: request.where } : {}),
1806
+ ...(normalizedPagination !== undefined ? { pagination: normalizedPagination } : {}),
1807
+ };
1808
+ }
1599
1809
  function requireWfsLocator(descriptor) {
1600
- const { url, typeName, featureNamespace, srsName } = descriptor.locator;
1810
+ const { url, typeName, featureNamespace, srsName, geometryName } = descriptor.locator;
1601
1811
  if (typeof url !== "string" || url.length === 0) {
1602
1812
  throw new Error(`createDataset: source "${descriptor.id}" (wfs) requires locator.url`);
1603
1813
  }
@@ -1607,13 +1817,23 @@ function requireWfsLocator(descriptor) {
1607
1817
  if (featureNamespace !== undefined && (typeof featureNamespace !== "string" || featureNamespace.length === 0)) {
1608
1818
  throw new Error(`createDataset: source "${descriptor.id}" (wfs) locator.featureNamespace must be a non-empty string`);
1609
1819
  }
1820
+ // The geometry property is spliced into FES `<fes:ValueReference>` and
1821
+ // transaction element names, so an override must be a plain XML name.
1822
+ if (geometryName !== undefined && (typeof geometryName !== "string" || !isWfsPropertyName(geometryName))) {
1823
+ throw new Error(`createDataset: source "${descriptor.id}" (wfs) locator.geometryName must be a valid geometry property name`);
1824
+ }
1610
1825
  return {
1611
1826
  url,
1612
1827
  typeName,
1613
1828
  featureNamespace: typeof featureNamespace === "string" ? featureNamespace : undefined,
1614
1829
  srsName: wfsSrsNameFromOutSr(srsName),
1830
+ geometryName: typeof geometryName === "string" ? geometryName : undefined,
1615
1831
  };
1616
1832
  }
1833
+ /** Optionally prefixed XML name, the only shape a WFS property reference takes. */
1834
+ function isWfsPropertyName(value) {
1835
+ return /^[A-Za-z_][A-Za-z0-9_.-]*(?::[A-Za-z_][A-Za-z0-9_.-]*)?$/.test(value);
1836
+ }
1617
1837
  function toWfsExtentDrainRequest(request) {
1618
1838
  // The drain computes a bbox over geometries, so a caller-supplied
1619
1839
  // `returnGeometry: false` would suppress geometry on every page and
@@ -1644,211 +1864,51 @@ function toWfsObjectIdsDrainRequest(request) {
1644
1864
  return idsRequest;
1645
1865
  }
1646
1866
  /**
1647
- * Compile `Query.where` and `Query.spatialFilter` into a (possibly empty)
1648
- * FES filter and route the request through GET or POST GetFeature based on
1649
- * the URL budget. Returns the decoded GeoJSON FeatureCollection.
1867
+ * Execute a compiler-approved WFS GetFeature request and return its decoded
1868
+ * GeoJSON FeatureCollection. The protocol compiler owns filter, projection,
1869
+ * paging, CRS, and transport selection so legacy drains do not duplicate it.
1650
1870
  */
1651
- async function runGetFeatureJson(featureType, typeName, choice, request, pageSize) {
1652
- const filterNodes = [];
1653
- let needsPostBody = false;
1654
- let bbox;
1655
- if (request?.where !== undefined && request.where !== "") {
1656
- const compiled = compileWhere(request.where);
1657
- if (compiled === UNSUPPORTED_FES) {
1658
- throw new HonuaCapabilityNotSupportedError("query", "wfs", `where clause is not expressible in FES; route the request through Source.protocol("wfs") to emit a custom filter`);
1659
- }
1660
- if (compiled.kind !== "and" || compiled.operands.length > 0) {
1661
- filterNodes.push(compiled);
1662
- }
1663
- }
1664
- if (request?.spatialFilter) {
1665
- const spatialRel = request.spatialFilter.spatialRel;
1666
- // The `bbox=` KVP and `<fes:BBOX>` both encode envelope-intersects
1667
- // semantics (OGC 09-026r2). Only take the bbox shortcut when the caller
1668
- // wants intersects (default), envelope-intersects, or did not specify.
1669
- // Anything else (Contains, Within, Crosses, Overlaps, Touches) needs an
1670
- // FES predicate that preserves the requested relation, which means
1671
- // routing the envelope through compileSpatialFilter so the geometry is
1672
- // serialized as a polygon under the correct spatial op.
1673
- const isEnvelopeIntersects = spatialRel === undefined ||
1674
- spatialRel === "esriSpatialRelIntersects" ||
1675
- spatialRel === "esriSpatialRelEnvelopeIntersects";
1676
- if (request.spatialFilter.geometryType === "esriGeometryEnvelope" &&
1677
- isEnvelopeIntersects &&
1678
- filterNodes.length === 0 &&
1679
- request.where === undefined) {
1680
- // BBox-only requests are short and travel safely as a `bbox=` KVP, no
1681
- // FES emission required. Saves a round-trip through XML serialization
1682
- // and keeps the URL human-debuggable.
1683
- const env = request.spatialFilter.geometry;
1684
- if (typeof env.xmin === "number" &&
1685
- typeof env.ymin === "number" &&
1686
- typeof env.xmax === "number" &&
1687
- typeof env.ymax === "number") {
1688
- bbox = `${env.xmin},${env.ymin},${env.xmax},${env.ymax}`;
1689
- }
1690
- }
1691
- else {
1692
- const filterSrsName = wfsSrsNameFromOutSr(request.outSr);
1693
- const compiled = compileSpatialFilter(request.spatialFilter, {
1694
- geometryProperty: DEFAULT_WFS_GEOMETRY_PROPERTY,
1695
- ...(filterSrsName !== undefined ? { srsName: filterSrsName } : {}),
1696
- });
1697
- if (compiled === UNSUPPORTED_FES) {
1698
- 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")`);
1699
- }
1700
- filterNodes.push(compiled);
1701
- }
1702
- }
1703
- const filterXml = filterNodes.length > 0 ? serializeFes(filterNodes, { typeName }) : undefined;
1704
- if (filterXml !== undefined) {
1705
- const encoded = encodeURIComponent(filterXml);
1706
- if (encoded.length > WFS_GET_FILTER_BUDGET)
1707
- needsPostBody = true;
1708
- }
1709
- const params = {};
1710
- if (bbox !== undefined)
1711
- params.bbox = bbox;
1712
- if (filterXml !== undefined && !needsPostBody)
1713
- params.filter = filterXml;
1714
- // WFS `propertyName=` drops every property the caller does not list,
1715
- // including the geometry column. Honor the canonical contract: when
1716
- // `outFields` is set and `returnGeometry !== false`, append the
1717
- // geometry property so geometry is preserved; when
1718
- // `returnGeometry === false`, omit the geometry property; when
1719
- // `returnGeometry === false` is asked without an `outFields` list,
1720
- // refuse the request because WFS cannot suppress geometry without
1721
- // enumerating every non-geometry property — silently widening to
1722
- // "geometry included" would break the canonical contract.
1723
- const callerOutFields = request?.outFields && request.outFields.length > 0 ? request.outFields : undefined;
1724
- const wantsGeometry = request?.returnGeometry !== false;
1725
- let propertyNames;
1726
- if (callerOutFields !== undefined) {
1727
- if (wantsGeometry) {
1728
- const merged = [...callerOutFields];
1729
- if (!merged.includes(DEFAULT_WFS_GEOMETRY_PROPERTY))
1730
- merged.push(DEFAULT_WFS_GEOMETRY_PROPERTY);
1731
- propertyNames = merged;
1732
- }
1733
- else {
1734
- propertyNames = [...callerOutFields];
1735
- }
1736
- }
1737
- else if (request?.returnGeometry === false) {
1738
- 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")`);
1739
- }
1740
- if (propertyNames)
1741
- params.propertyName = [...propertyNames];
1742
- const sortBy = request?.orderBy && request.orderBy.length > 0
1743
- ? request.orderBy.map((s) => `${s.field}${s.direction === "desc" ? " D" : " A"}`).join(",")
1871
+ async function runGetFeatureJson(featureType, choice, featureNamespace, compiled) {
1872
+ const typePrefix = compiled.typeName.includes(":")
1873
+ ? compiled.typeName.slice(0, compiled.typeName.indexOf(":"))
1744
1874
  : undefined;
1745
- if (sortBy !== undefined)
1746
- params.sortBy = sortBy;
1747
- if (typeof pageSize === "number" && pageSize > 0) {
1748
- params.count = pageSize;
1749
- }
1750
- else if (typeof request?.pagination?.limit === "number" && request.pagination.limit > 0) {
1751
- params.count = request.pagination.limit;
1752
- }
1753
- if (typeof request?.pagination?.offset === "number" && request.pagination.offset > 0) {
1754
- params.startIndex = request.pagination.offset;
1755
- }
1756
- const srsName = wfsSrsNameFromOutSr(request?.outSr);
1757
- if (srsName !== undefined)
1758
- params.srsName = srsName;
1759
- params.outputFormat = choice.format;
1760
- if (request?.signal)
1761
- params.signal = request.signal;
1762
- if (needsPostBody && filterXml !== undefined) {
1763
- params.method = "POST";
1764
- const postOptions = {
1765
- typeName,
1766
- filter: filterXml,
1767
- count: params.count,
1768
- startIndex: params.startIndex,
1875
+ const namespace = typePrefix !== undefined && featureNamespace !== undefined
1876
+ ? { prefix: typePrefix, uri: featureNamespace }
1877
+ : undefined;
1878
+ const params = {
1879
+ method: compiled.method,
1880
+ outputFormat: choice.format,
1881
+ ...(namespace !== undefined ? { namespace } : {}),
1882
+ ...(compiled.method === "GET" && compiled.filter !== undefined ? { filter: compiled.filter } : {}),
1883
+ ...(compiled.bbox !== undefined ? { bbox: compiled.bbox } : {}),
1884
+ ...(compiled.propertyName !== undefined ? { propertyName: [...compiled.propertyName] } : {}),
1885
+ ...(compiled.sortBy !== undefined ? { sortBy: compiled.sortBy } : {}),
1886
+ ...(compiled.count !== undefined ? { count: compiled.count } : {}),
1887
+ ...(compiled.startIndex !== undefined && compiled.startIndex > 0 ? { startIndex: compiled.startIndex } : {}),
1888
+ ...(compiled.srsName !== undefined ? { srsName: compiled.srsName } : {}),
1889
+ };
1890
+ if (compiled.method === "POST" && compiled.filter !== undefined) {
1891
+ params.body = buildWfsPostGetFeatureBody({
1892
+ typeName: compiled.typeName,
1893
+ filter: compiled.filter,
1894
+ count: compiled.count,
1895
+ startIndex: compiled.startIndex !== undefined && compiled.startIndex > 0 ? compiled.startIndex : undefined,
1769
1896
  outputFormat: choice.format,
1770
- };
1771
- if (propertyNames)
1772
- postOptions.propertyNames = propertyNames;
1773
- if (sortBy !== undefined)
1774
- postOptions.sortBy = sortBy;
1775
- if (srsName !== undefined)
1776
- postOptions.srsName = srsName;
1777
- params.body = buildPostGetFeatureBody(postOptions);
1897
+ ...(namespace !== undefined ? { namespace } : {}),
1898
+ ...(compiled.propertyName !== undefined ? { propertyNames: compiled.propertyName } : {}),
1899
+ ...(compiled.sortBy !== undefined ? { sortBy: compiled.sortBy } : {}),
1900
+ ...(compiled.srsName !== undefined ? { srsName: compiled.srsName } : {}),
1901
+ });
1778
1902
  }
1779
1903
  const response = await featureType.getFeature(params);
1780
1904
  if (response.kind !== "json") {
1781
- throw new HonuaCapabilityNotSupportedError("query", "wfs", `WFS GetFeature returned ${response.contentType}; canonical surface only carries JSON. Reach raw output through Source.protocol("wfs")`);
1905
+ throw new HonuaCapabilityNotSupportedError("query", "wfs", `WFS GetFeature returned ${response.contentType}; canonical surface only carries JSON. Reach the wire through Source.protocol("wfs")`);
1782
1906
  }
1783
1907
  return response.data;
1784
1908
  }
1785
- function buildPostGetFeatureBody(options) {
1786
- const countAttr = typeof options.count === "number" ? ` count="${options.count}"` : "";
1787
- const startAttr = typeof options.startIndex === "number" ? ` startIndex="${options.startIndex}"` : "";
1788
- const outputAttr = ` outputFormat="${escapeXmlAttr(options.outputFormat)}"`;
1789
- const queryAttrs = options.srsName !== undefined ? ` srsName="${escapeXmlAttr(options.srsName)}"` : "";
1790
- const propertyXml = options.propertyNames && options.propertyNames.length > 0
1791
- ? options.propertyNames.map((name) => `<wfs:PropertyName>${escapeXmlText(name)}</wfs:PropertyName>`).join("")
1792
- : "";
1793
- const sortByXml = options.sortBy !== undefined ? buildSortByXml(options.sortBy) : "";
1794
- 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>`;
1795
- }
1796
- /**
1797
- * Build a `<fes:SortBy>` block from the same comma-separated `sortBy` string
1798
- * the GET path emits (`FIELD A,OTHER D`). Empty entries are skipped.
1799
- */
1800
- function buildSortByXml(sortBy) {
1801
- const entries = sortBy
1802
- .split(",")
1803
- .map((entry) => entry.trim())
1804
- .filter((entry) => entry.length > 0);
1805
- if (entries.length === 0)
1806
- return "";
1807
- const properties = entries.map((entry) => {
1808
- const parts = entry.split(/\s+/);
1809
- const field = parts[0];
1810
- const direction = parts[1]?.toUpperCase() === "D" ? "DESC" : "ASC";
1811
- return `<fes:SortProperty><fes:ValueReference>${escapeXmlText(field)}</fes:ValueReference><fes:SortOrder>${direction}</fes:SortOrder></fes:SortProperty>`;
1812
- });
1813
- return `<fes:SortBy>${properties.join("")}</fes:SortBy>`;
1814
- }
1815
- /**
1816
- * Translate a canonical `Query.outSr` (string CRS URI / EPSG token, or numeric
1817
- * WKID) into the WFS `srsName` form. Numeric WKIDs become the OGC URN form
1818
- * `urn:ogc:def:crs:EPSG::<wkid>` so cross-server interop matches the format
1819
- * advertised in `OperationsMetadata` / `Filter_Capabilities`.
1820
- */
1821
- function wfsSrsNameFromOutSr(outSr) {
1822
- if (typeof outSr === "string")
1823
- return outSr.length > 0 ? outSr : undefined;
1824
- if (typeof outSr === "number" && Number.isFinite(outSr))
1825
- return `urn:ogc:def:crs:EPSG::${outSr}`;
1826
- return undefined;
1827
- }
1828
1909
  function escapeXmlAttr(value) {
1829
1910
  return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
1830
1911
  }
1831
- function resultFromGeoJson(data) {
1832
- if (typeof data !== "object" || data === null) {
1833
- return { features: [], exceededTransferLimit: false };
1834
- }
1835
- const collection = data;
1836
- const features = (collection.features ?? []).map((f) => ({
1837
- attributes: (f.properties ?? {}),
1838
- geometry: f.geometry,
1839
- }));
1840
- const totalCount = typeof collection.numberMatched === "number" && Number.isFinite(collection.numberMatched)
1841
- ? collection.numberMatched
1842
- : undefined;
1843
- const exceededTransferLimit = totalCount !== undefined && features.length < totalCount;
1844
- const out = {
1845
- features,
1846
- exceededTransferLimit,
1847
- };
1848
- if (totalCount !== undefined)
1849
- out.totalCount = totalCount;
1850
- return out;
1851
- }
1852
1912
  function computeExtentFromFeatureCollection(data) {
1853
1913
  if (typeof data !== "object" || data === null)
1854
1914
  return { extent: null, count: 0 };
@@ -1877,7 +1937,7 @@ function computeExtentFromFeatureCollection(data) {
1877
1937
  return { extent: null, count };
1878
1938
  return { extent: { xmin, ymin, xmax, ymax }, count };
1879
1939
  }
1880
- function buildTransactionBody(typeName, envelope, featureNamespace, srsName) {
1940
+ function buildTransactionBody(typeName, envelope, featureNamespace, srsName, geometryProperty) {
1881
1941
  const releaseAction = envelope.rollbackOnFailure ? "ALL" : "SOME";
1882
1942
  const { prefix } = splitTypeName(typeName);
1883
1943
  // Bind the feature-namespace prefix on the Transaction root so prefixed
@@ -1897,12 +1957,12 @@ function buildTransactionBody(typeName, envelope, featureNamespace, srsName) {
1897
1957
  // result-mapping side cannot drift.
1898
1958
  const adds = envelope.adds ?? [];
1899
1959
  for (let i = 0; i < adds.length; i += 1) {
1900
- blocks.push(buildInsertBlock(typeName, adds[i], wfsInsertHandle(i), srsName));
1960
+ blocks.push(buildInsertBlock(typeName, adds[i], wfsInsertHandle(i), srsName, geometryProperty));
1901
1961
  }
1902
1962
  let handleCounter = adds.length;
1903
1963
  for (const update of envelope.updates ?? []) {
1904
1964
  handleCounter += 1;
1905
- blocks.push(buildUpdateBlock(typeName, update, `upd-${handleCounter}`, srsName));
1965
+ blocks.push(buildUpdateBlock(typeName, update, `upd-${handleCounter}`, srsName, geometryProperty));
1906
1966
  }
1907
1967
  for (const id of envelope.deletes ?? []) {
1908
1968
  handleCounter += 1;
@@ -1931,30 +1991,41 @@ function splitTypeName(typeName) {
1931
1991
  function syntheticFeatureNamespace(prefix) {
1932
1992
  return `urn:honua:wfs:feature-namespace:${prefix}`;
1933
1993
  }
1934
- function buildInsertBlock(typeName, feature, handle, srsName) {
1994
+ function buildInsertBlock(typeName, feature, handle, srsName, geometryProperty) {
1935
1995
  const attributes = feature.attributes;
1936
1996
  const propertyXml = Object.entries(attributes)
1937
1997
  .filter(([, value]) => value !== undefined)
1938
1998
  .map(([key, value]) => `<${escapeXmlElement(key)}>${escapeXmlText(formatLiteral(value))}</${escapeXmlElement(key)}>`)
1939
1999
  .join("");
1940
2000
  const geometryXml = feature.geometry ? (geoJsonGeometryToGml(feature.geometry, srsName) ?? "") : "";
1941
- const geometryProperty = escapeXmlElement(DEFAULT_WFS_GEOMETRY_PROPERTY);
1942
- const featureXml = `<${escapeXmlElement(typeName)}>${propertyXml}${geometryXml ? `<${geometryProperty}>${geometryXml}</${geometryProperty}>` : ""}</${escapeXmlElement(typeName)}>`;
2001
+ const property = geometryXml ? escapeXmlElement(requireTransactionGeometryProperty(geometryProperty)) : "";
2002
+ const featureXml = `<${escapeXmlElement(typeName)}>${propertyXml}${geometryXml ? `<${property}>${geometryXml}</${property}>` : ""}</${escapeXmlElement(typeName)}>`;
1943
2003
  return `<wfs:Insert handle="${handle}">${featureXml}</wfs:Insert>`;
1944
2004
  }
1945
- function buildUpdateBlock(typeName, feature, handle, srsName) {
2005
+ function buildUpdateBlock(typeName, feature, handle, srsName, geometryProperty) {
1946
2006
  const attributes = feature.attributes;
1947
2007
  const properties = Object.entries(attributes)
1948
2008
  .filter(([, value]) => value !== undefined)
1949
2009
  .map(([key, value]) => `<wfs:Property><wfs:ValueReference>${escapeXmlText(key)}</wfs:ValueReference><wfs:Value>${escapeXmlText(formatLiteral(value))}</wfs:Value></wfs:Property>`)
1950
2010
  .join("");
1951
- const geometryProperty = feature.geometry
1952
- ? `<wfs:Property><wfs:ValueReference>${escapeXmlText(DEFAULT_WFS_GEOMETRY_PROPERTY)}</wfs:ValueReference><wfs:Value>${geoJsonGeometryToGml(feature.geometry, srsName) ?? ""}</wfs:Value></wfs:Property>`
2011
+ const geometryXml = feature.geometry
2012
+ ? `<wfs:Property><wfs:ValueReference>${escapeXmlText(requireTransactionGeometryProperty(geometryProperty))}</wfs:ValueReference><wfs:Value>${geoJsonGeometryToGml(feature.geometry, srsName) ?? ""}</wfs:Value></wfs:Property>`
1953
2013
  : "";
1954
2014
  const filter = feature.id !== undefined
1955
2015
  ? `<fes:Filter><fes:ResourceId rid="${escapeXmlAttr(String(feature.id))}"/></fes:Filter>`
1956
2016
  : "";
1957
- return `<wfs:Update handle="${handle}" typeName="${escapeXmlAttr(typeName)}">${properties}${geometryProperty}${filter}</wfs:Update>`;
2017
+ return `<wfs:Update handle="${handle}" typeName="${escapeXmlAttr(typeName)}">${properties}${geometryXml}${filter}</wfs:Update>`;
2018
+ }
2019
+ /**
2020
+ * Fail closed rather than write geometry into a guessed property: a
2021
+ * transaction that targets the wrong element name is accepted by some servers
2022
+ * and silently drops the geometry.
2023
+ */
2024
+ function requireTransactionGeometryProperty(geometryProperty) {
2025
+ if (geometryProperty === undefined) {
2026
+ throw new HonuaWfsProtocolError("unresolved-geometry-property", "WFS transaction geometry requires the feature type's geometry property; set locator.geometryName or make DescribeFeatureType reachable");
2027
+ }
2028
+ return geometryProperty;
1958
2029
  }
1959
2030
  function buildDeleteBlock(typeName, id, handle) {
1960
2031
  return `<wfs:Delete handle="${handle}" typeName="${escapeXmlAttr(typeName)}"><fes:Filter><fes:ResourceId rid="${escapeXmlAttr(String(id))}"/></fes:Filter></wfs:Delete>`;
@@ -2046,10 +2117,11 @@ function loadOdataWriteCodec() {
2046
2117
  /**
2047
2118
  * Adapter factory for an OData v4 entity set.
2048
2119
  *
2049
- * `Query.where` compiles to OData `$filter`, `Query.outFields` becomes
2050
- * `$select`, `Query.orderBy` becomes `$orderby`, and `Query.pagination`
2051
- * becomes `$top`/`$skip`. Geospatial filters translate to OData's
2052
- * `geo.intersects` / `geo.distance` family.
2120
+ * The deprecated source-native `Query.where` migration member compiles to
2121
+ * OData `$filter`, `Query.outFields` becomes `$select`, `Query.orderBy`
2122
+ * becomes `$orderby`, and `Query.pagination` becomes `$top`/`$skip`.
2123
+ * Geospatial filters translate to OData's `geo.intersects` /
2124
+ * `geo.distance` family.
2053
2125
  *
2054
2126
  * @example
2055
2127
  * ```ts
@@ -2064,7 +2136,6 @@ function loadOdataWriteCodec() {
2064
2136
  * }],
2065
2137
  * });
2066
2138
  * const result = await dataset.source("incidents")!.queryAll({
2067
- * where: "Severity ge 3",
2068
2139
  * orderBy: [{ field: "ReportedAt", direction: "desc" }],
2069
2140
  * pagination: { limit: 100 },
2070
2141
  * });
@@ -2072,9 +2143,16 @@ function loadOdataWriteCodec() {
2072
2143
  */
2073
2144
  export function odataSource(descriptor, client, policy, options = {}) {
2074
2145
  descriptor = normalizeCapabilityDescriptor(descriptor);
2075
- const { entitySet, basePath } = requireOdataLocator(descriptor);
2076
- const entity = new HonuaOdataEntitySet({ client, entitySet, basePath });
2077
- const declaredCaps = descriptor.capabilities ?? PROTOCOL_DEFAULT_CAPABILITIES.odata;
2146
+ const module = odataProtocolModule(client);
2147
+ const discovered = module.discover(descriptor);
2148
+ if (discovered instanceof Promise) {
2149
+ // OData discovery only binds an explicitly injected client to an entity
2150
+ // set. `$metadata` remains lazy, so built-in Source construction is
2151
+ // intentionally synchronous.
2152
+ throw new Error("odata: built-in source construction requires synchronous protocol-module discovery");
2153
+ }
2154
+ const entity = discovered.adapter;
2155
+ const declaredCaps = discovered.capabilities;
2078
2156
  const negotiation = new OdataCapabilityNegotiator(entity, declaredCaps);
2079
2157
  const writeEncoding = options.writeEncoding ?? "legacy";
2080
2158
  if (writeEncoding !== "legacy" && writeEncoding !== "lossless-json") {
@@ -2089,8 +2167,17 @@ export function odataSource(descriptor, client, policy, options = {}) {
2089
2167
  await negotiation.ensureAdvertised(descriptor, "query");
2090
2168
  const geomColumn = await resolveOdataGeometryColumn(entity, descriptor);
2091
2169
  const params = await buildOdataParams(entity, descriptor, request, { count: true, geomColumn });
2092
- const page = await entity.query(params);
2093
- return odataResultFromPage(descriptor, page, await negotiation.fieldsFor(descriptor.id), request?.returnGeometry, geomColumn);
2170
+ return module.execute(discovered, {
2171
+ compiled: createOdataProtocolCompiledQuery(entity.entitySet, "query", params),
2172
+ operation: "query",
2173
+ query: {
2174
+ count: true,
2175
+ fields: await negotiation.fieldsFor(descriptor.id),
2176
+ ...(request?.returnGeometry !== undefined ? { returnGeometry: request.returnGeometry } : {}),
2177
+ ...(geomColumn ? { geometryColumn: geomColumn } : {}),
2178
+ },
2179
+ ...(request?.signal ? { signal: request.signal } : {}),
2180
+ });
2094
2181
  },
2095
2182
  async queryAll(request) {
2096
2183
  ensureCapability(descriptor, declaredCaps, "query");
@@ -2098,30 +2185,20 @@ export function odataSource(descriptor, client, policy, options = {}) {
2098
2185
  const geomColumn = await resolveOdataGeometryColumn(entity, descriptor);
2099
2186
  const params = await buildOdataParams(entity, descriptor, request, { count: true, geomColumn });
2100
2187
  const limit = request?.pagination?.limit;
2101
- // Lookahead row: ask the server for `limit + 1` so we can prove
2102
- // truncation by collecting more than `limit` rows even when the
2103
- // response carries no `@odata.nextLink`. Mirrors the GeoServices
2104
- // and OGC `queryAll` truncation pattern.
2105
- if (typeof limit === "number" && limit >= 0) {
2106
- params.top = limit + 1;
2107
- }
2108
- const drained = await entity.queryAll(params);
2109
- const allFeatures = drained.rows.map((row) => odataRowToFeature(row, descriptor, request?.returnGeometry, geomColumn));
2110
- const { features: limited, exceededTransferLimit: collectedExceeded } = applyQueryAllLimit(allFeatures, limit);
2111
- // Belt-and-braces: if the server respected `$top` exactly but
2112
- // reports `@odata.count > limited.length`, that also proves
2113
- // truncation. Either signal sets the flag.
2114
- const countExceeded = typeof drained.totalCount === "number" && typeof limit === "number" && drained.totalCount > limited.length;
2115
- const exceededTransferLimit = collectedExceeded || countExceeded;
2116
- const fields = await negotiation.fieldsFor(descriptor.id);
2117
- return {
2118
- features: limited,
2119
- exceededTransferLimit,
2120
- ...(typeof drained.totalCount === "number"
2121
- ? { totalCount: drained.totalCount }
2122
- : { totalCount: limited.length }),
2123
- ...(fields.length > 0 ? { fields } : {}),
2124
- };
2188
+ return module.execute(discovered, {
2189
+ // The operation-bound compiler adds the one-row queryAll lookahead;
2190
+ // the runtime query context retains the caller-visible logical limit.
2191
+ compiled: createOdataProtocolCompiledQuery(entity.entitySet, "queryAll", params),
2192
+ operation: "queryAll",
2193
+ query: {
2194
+ count: true,
2195
+ fields: await negotiation.fieldsFor(descriptor.id),
2196
+ ...(request?.returnGeometry !== undefined ? { returnGeometry: request.returnGeometry } : {}),
2197
+ ...(geomColumn ? { geometryColumn: geomColumn } : {}),
2198
+ ...(limit !== undefined ? { limit } : {}),
2199
+ },
2200
+ ...(request?.signal ? { signal: request.signal } : {}),
2201
+ });
2125
2202
  },
2126
2203
  async queryAggregate() {
2127
2204
  // OData aggregation is dialect-specific (`$apply` pipeline). The
@@ -2144,7 +2221,7 @@ export function odataSource(descriptor, client, policy, options = {}) {
2144
2221
  const fields = await negotiation.fieldsFor(descriptor.id);
2145
2222
  const stream = entity.queryStream(params);
2146
2223
  for await (const page of stream) {
2147
- yield odataResultFromPage(descriptor, page, fields, request?.returnGeometry, geomColumn);
2224
+ yield odataProtocolResultFromPage(descriptor, page, fields, request?.returnGeometry, geomColumn);
2148
2225
  }
2149
2226
  },
2150
2227
  async queryObjectIds(request) {
@@ -2565,42 +2642,6 @@ function describeBatchError(body, status) {
2565
2642
  function stripLeadingSlashLocal(url) {
2566
2643
  return url.startsWith("/") ? url.slice(1) : url;
2567
2644
  }
2568
- function requireOdataLocator(descriptor) {
2569
- const { entitySet, url, layerId } = descriptor.locator;
2570
- // Resolve the entity-set token. Honua Server's `SourceLocator` only
2571
- // carries `url`, `serviceId`, and `layerId`, so server-produced OData
2572
- // bindings arrive without `entitySet`. The canonical server route is
2573
- // layer-scoped — `/odata/Layers(<layerId>)/Features` — so derive that
2574
- // path when only `layerId` is provided. SDK callers that already pass
2575
- // `entitySet` (the historical path) continue to win.
2576
- let resolvedEntitySet;
2577
- if (typeof entitySet === "string" && entitySet !== "") {
2578
- resolvedEntitySet = entitySet;
2579
- }
2580
- else if (typeof layerId === "number" && Number.isFinite(layerId)) {
2581
- resolvedEntitySet = `Layers(${layerId})/Features`;
2582
- }
2583
- if (resolvedEntitySet === undefined) {
2584
- throw new Error(`createDataset: source "${descriptor.id}" (odata) requires locator.entitySet or locator.layerId`);
2585
- }
2586
- // `locator.url` is informational on the canonical surface; the request
2587
- // path is built from `basePath/<entitySet>`. When `locator.url` carries
2588
- // a path, treat its trailing path component as the basePath so a
2589
- // descriptor pointing at `https://srv/odata/v4` still resolves correctly.
2590
- const basePath = url ? extractOdataBasePath(url) : "/odata";
2591
- return { entitySet: resolvedEntitySet, basePath };
2592
- }
2593
- function extractOdataBasePath(url) {
2594
- try {
2595
- const parsed = new URL(url);
2596
- const pathname = trimTrailingSlashes(parsed.pathname);
2597
- return pathname === "" ? "/odata" : pathname;
2598
- }
2599
- catch {
2600
- // Relative URL: take it as the basePath verbatim.
2601
- return url.startsWith("/") ? trimTrailingSlashes(url) || "/odata" : `/${trimTrailingSlashes(url)}`;
2602
- }
2603
- }
2604
2645
  /**
2605
2646
  * Resolve the OData geometry column for a descriptor. Prefers
2606
2647
  * `descriptor.schema.fields` (where a field of type
@@ -2712,6 +2753,9 @@ async function buildOdataParams(entity, descriptor, request, options) {
2712
2753
  if (rewritten !== "")
2713
2754
  filterParts.push(rewritten);
2714
2755
  }
2756
+ const typedFilter = await compileOdataQueryFilter(entity, descriptor, request, options.geomColumn);
2757
+ if (typedFilter !== undefined)
2758
+ filterParts.push(typedFilter);
2715
2759
  if (request.spatialFilter) {
2716
2760
  // `$metadata` is advisory here — it only refines the geometry column
2717
2761
  // and SRID context. When it is missing or failing, fall back to the
@@ -2781,52 +2825,49 @@ async function buildOdataParams(entity, descriptor, request, options) {
2781
2825
  }
2782
2826
  if (request.signal)
2783
2827
  out.signal = request.signal;
2784
- void descriptor;
2785
2828
  return out;
2786
2829
  }
2787
- function odataResultFromPage(descriptor, page, fields, returnGeometry, geomColumn) {
2788
- const features = page.rows.map((row) => odataRowToFeature(row, descriptor, returnGeometry, geomColumn));
2789
- const exceededTransferLimit = typeof page.totalCount === "number" ? features.length < page.totalCount : Boolean(page.nextLink);
2790
- return {
2791
- features,
2792
- exceededTransferLimit,
2793
- ...(typeof page.totalCount === "number" ? { totalCount: page.totalCount } : {}),
2794
- ...(fields.length > 0 ? { fields } : {}),
2795
- };
2796
- }
2797
2830
  /**
2798
- * Convert one OData JSON row into a canonical `HonuaTypedFeature`. The
2799
- * geometry column (when the row carries one as GeoJSON via Honua Server's
2800
- * spatial encoding) is split out from the attributes envelope. When
2801
- * `returnGeometry === false`, the geometry column is dropped from both
2802
- * the attributes envelope and the `geometry` field — defensive against
2803
- * servers that ignore the `$select` exclusion (or callers that bypassed
2804
- * metadata-derived selects). The geometry column is sourced from the
2805
- * caller-resolved `geomColumn` (descriptor schema → metadata
2806
- * `isSpatial`) and falls back to the canonical name guesses on the
2807
- * row keys when neither declares one.
2831
+ * Compile `Query.filter` / `Query.temporalFilter` into an OData `$filter`
2832
+ * fragment. OData has no protocol-level time parameter, so a temporal filter
2833
+ * must name its field; without one the adapter refuses rather than dropping the
2834
+ * constraint. Spatial nodes reuse the `$metadata`-aware `geo.*` emitter.
2808
2835
  */
2809
- function odataRowToFeature(row, descriptor, returnGeometry, geomColumn) {
2810
- const geometryKey = geomColumn ??
2811
- descriptor.schema?.fields?.find((f) => f.type === "esriFieldTypeGeometry")?.name ??
2812
- findGeometryKey(row);
2813
- if (geometryKey && row[geometryKey] !== undefined) {
2814
- const { [geometryKey]: geometry, ...rest } = row;
2815
- return {
2816
- attributes: rest,
2817
- geometry: returnGeometry === false ? null : (geometry ?? null),
2818
- };
2819
- }
2820
- return { attributes: row, geometry: null };
2821
- }
2822
- function findGeometryKey(row) {
2823
- for (const key of Object.keys(row)) {
2824
- const lower = key.toLowerCase();
2825
- if (lower === "geometry" || lower === "geography" || lower === "shape")
2826
- return key;
2836
+ async function compileOdataQueryFilter(entity, descriptor, request, geomColumn) {
2837
+ const ctx = queryFilterContext(descriptor, geomColumn);
2838
+ const temporalExpression = request.temporalFilter
2839
+ ? temporalFilterToExpression(request.temporalFilter, ctx)
2840
+ : undefined;
2841
+ if (request.temporalFilter && temporalExpression === undefined) {
2842
+ throw new HonuaCapabilityNotSupportedError("temporalFilter.field", descriptor.protocol, descriptor.id);
2827
2843
  }
2828
- return undefined;
2844
+ const expression = andQueryFilters(request.filter, temporalExpression);
2845
+ if (!expression)
2846
+ return undefined;
2847
+ const meta = await entity.metadata().catch(() => undefined);
2848
+ const typeName = meta ? meta.entitySets[entity.entitySetName] : undefined;
2849
+ const spatialFields = (typeName && meta ? meta.fields[typeName] : undefined) ?? [];
2850
+ const spatialContext = {
2851
+ ...(geomColumn ? { geometryColumn: geomColumn } : {}),
2852
+ ...(spatialFields.length > 0 ? { geometryFields: spatialFields } : {}),
2853
+ };
2854
+ return compileQueryFilterToOData(expression, ctx, (node) => {
2855
+ const spatialRel = ODATA_SPATIAL_REL[node.operator];
2856
+ if (!spatialRel) {
2857
+ throw new HonuaCapabilityNotSupportedError(`filter.spatial.${node.operator}`, descriptor.protocol, descriptor.id);
2858
+ }
2859
+ return buildOdataSpatialFilter({ geometry: node.geometry.geometry, geometryType: node.geometry.geometryType, spatialRel }, spatialContext);
2860
+ });
2829
2861
  }
2862
+ /**
2863
+ * OData v4 standard geo functions express only `geo.intersects` (and the
2864
+ * distance family). Every other topological predicate is refused by name so a
2865
+ * `within` never silently returns intersecting rows.
2866
+ */
2867
+ const ODATA_SPATIAL_REL = {
2868
+ intersects: "esriSpatialRelIntersects",
2869
+ "bbox-intersects": "esriSpatialRelEnvelopeIntersects",
2870
+ };
2830
2871
  function featureToOdataBody(feature) {
2831
2872
  const body = { ...feature.attributes };
2832
2873
  if (feature.geometry !== undefined && feature.geometry !== null) {
@@ -3212,8 +3253,11 @@ function requireImageServiceLocator(descriptor) {
3212
3253
  function requireImageServerCompatibleQuery(request) {
3213
3254
  if (!request)
3214
3255
  return;
3256
+ if (request.filter !== undefined && queryFilterHasSpatial(request.filter)) {
3257
+ throw new Error("geoservices-image-service: a spatial filter node is not supported on the raster catalog; the ImageServer catalog endpoint does not accept geometry / geometryType / spatialRel filters. Attribute and temporal predicates are supported; for geometry, call protocol().identify() / exportImage() on the typed escape hatch.");
3258
+ }
3215
3259
  if (request.spatialFilter) {
3216
- 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.");
3260
+ 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. During migration, the deprecated source-native Query.where member can constrain the catalog; otherwise call protocol().identify() / exportImage() on the typed escape hatch.");
3217
3261
  }
3218
3262
  if (request.orderBy && request.orderBy.length > 0) {
3219
3263
  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.");
@@ -3285,6 +3329,14 @@ function requireWmsCompatibleQuery(descriptor, request) {
3285
3329
  if (typeof request.where === "string" && request.where.length > 0) {
3286
3330
  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.`);
3287
3331
  }
3332
+ if (request.filter !== undefined) {
3333
+ throw new HonuaCapabilityNotSupportedError("filter", descriptor.protocol, descriptor.id);
3334
+ }
3335
+ if (request.temporalFilter !== undefined) {
3336
+ // WMS carries time as a dimension on the typed `WmsMapRequest` envelope,
3337
+ // not as a GetFeatureInfo attribute predicate.
3338
+ throw new HonuaCapabilityNotSupportedError("temporalFilter", descriptor.protocol, descriptor.id);
3339
+ }
3288
3340
  if (request.outFields && request.outFields.length > 0) {
3289
3341
  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.`);
3290
3342
  }
@@ -3357,12 +3409,24 @@ function wmsCrsFromGeometrySpatialReference(spatialReference) {
3357
3409
  }
3358
3410
  return "CRS:84";
3359
3411
  }
3360
- function toStacRequest(request, collectionScope) {
3412
+ function toStacRequest(request, collectionScope, descriptor) {
3361
3413
  const out = {};
3362
- if (request?.where !== undefined) {
3414
+ const cql2 = request ? compileCql2QueryFilter(request, descriptor) : {};
3415
+ const filterParts = [];
3416
+ if (request?.where !== undefined && request.where !== "")
3417
+ filterParts.push(request.where);
3418
+ if (cql2.filter !== undefined)
3419
+ filterParts.push(cql2.filter);
3420
+ if (filterParts.length > 0) {
3421
+ out.filter = filterParts.length === 1 ? filterParts[0] : filterParts.map((part) => `(${part})`).join(" AND ");
3422
+ out.filterLang = "cql2-text";
3423
+ }
3424
+ else if (request?.where !== undefined) {
3363
3425
  out.filter = request.where;
3364
3426
  out.filterLang = "cql2-text";
3365
3427
  }
3428
+ if (cql2.datetime !== undefined)
3429
+ out.datetime = cql2.datetime;
3366
3430
  if (request?.outFields && request.outFields.length > 0) {
3367
3431
  out.fields = { include: [...request.outFields] };
3368
3432
  }
@@ -3374,15 +3438,30 @@ function toStacRequest(request, collectionScope) {
3374
3438
  out.sortby = request.orderBy.map((s) => `${s.direction === "desc" ? "-" : ""}${s.field}`).join(",");
3375
3439
  }
3376
3440
  if (request?.spatialFilter) {
3377
- if (request.spatialFilter.geometryType !== "esriGeometryEnvelope") {
3378
- throw new Error(`stac: spatialFilter.geometryType "${request.spatialFilter.geometryType}" is not supported; only "esriGeometryEnvelope" translates to STAC bbox.`);
3441
+ if (request.spatialFilter.geometryType === "esriGeometryEnvelope") {
3442
+ const env = request.spatialFilter.geometry;
3443
+ if (typeof env.xmin === "number" &&
3444
+ typeof env.ymin === "number" &&
3445
+ typeof env.xmax === "number" &&
3446
+ typeof env.ymax === "number") {
3447
+ out.bbox = [env.xmin, env.ymin, env.xmax, env.ymax];
3448
+ }
3379
3449
  }
3380
- const env = request.spatialFilter.geometry;
3381
- if (typeof env.xmin === "number" &&
3382
- typeof env.ymin === "number" &&
3383
- typeof env.xmax === "number" &&
3384
- typeof env.ymax === "number") {
3385
- out.bbox = [env.xmin, env.ymin, env.xmax, env.ymax];
3450
+ else {
3451
+ // STAC Item Search takes an arbitrary GeoJSON geometry on `intersects`
3452
+ // (OGC 21-047 §7.2), so a polygon AOI does not have to be degraded to
3453
+ // its envelope. `intersects` is the only predicate the parameter
3454
+ // expresses; refuse other spatial relationships instead of silently
3455
+ // widening them.
3456
+ const rel = request.spatialFilter.spatialRel;
3457
+ if (rel !== undefined && rel !== "esriSpatialRelIntersects" && rel !== "esriSpatialRelEnvelopeIntersects") {
3458
+ throw new Error(`stac: spatialFilter.spatialRel "${rel}" is not supported; STAC search only expresses an intersects predicate.`);
3459
+ }
3460
+ const geometry = toStacIntersectsGeometry(request.spatialFilter.geometry);
3461
+ if (geometry === null) {
3462
+ throw new Error(`stac: spatialFilter.geometryType "${request.spatialFilter.geometryType}" could not be converted to a GeoJSON geometry for STAC intersects.`);
3463
+ }
3464
+ out.intersects = geometry;
3386
3465
  }
3387
3466
  }
3388
3467
  if (collectionScope !== undefined && collectionScope !== "") {
@@ -3392,11 +3471,35 @@ function toStacRequest(request, collectionScope) {
3392
3471
  out.signal = request.signal;
3393
3472
  return out;
3394
3473
  }
3474
+ /**
3475
+ * Normalize a canonical `spatialFilter.geometry` into the GeoJSON geometry
3476
+ * STAC `intersects` expects. Esri-shaped geometries (the shape the
3477
+ * `polygon()` / `spatialIntersects()` builders emit) are converted; a
3478
+ * geometry that is already GeoJSON passes through untouched.
3479
+ */
3480
+ function toStacIntersectsGeometry(geometry) {
3481
+ if (typeof geometry.type === "string" && (geometry.coordinates !== undefined || geometry.geometries !== undefined)) {
3482
+ return geometry;
3483
+ }
3484
+ const converted = esriGeometryToGeoJSON(geometry);
3485
+ return converted === null ? null : converted;
3486
+ }
3395
3487
  /**
3396
3488
  * Project a STAC API search request onto the client-side filter params a
3397
3489
  * static catalog traversal applies (it has no server-side query grammar).
3398
3490
  */
3399
3491
  function toStacStaticParams(request) {
3492
+ if (request.intersects !== undefined) {
3493
+ // A static catalog has no search endpoint; traversal only applies the
3494
+ // bbox / datetime / collection filters below. Silently dropping the
3495
+ // geometry would return an unfiltered superset.
3496
+ throw new Error("stac-static: an `intersects` geometry filter is not supported by static catalog traversal; use an envelope spatial filter.");
3497
+ }
3498
+ if (request.filter !== undefined) {
3499
+ // Static traversal has no CQL2 evaluator. Dropping the predicate would
3500
+ // return every item in the catalog, so refuse instead.
3501
+ throw new Error("stac-static: a CQL2 attribute filter (Query.filter or Query.where) is not supported by static catalog traversal; use a STAC API endpoint or filter client-side after the result lands.");
3502
+ }
3400
3503
  const out = {};
3401
3504
  if (request.collections && request.collections.length > 0)
3402
3505
  out.collections = request.collections;
@@ -3416,14 +3519,26 @@ function toTypedFeatureFromStac(feature) {
3416
3519
  geometry: feature.geometry,
3417
3520
  };
3418
3521
  }
3419
- function toOgcRecordsRequest(request) {
3522
+ function toOgcRecordsRequest(request, descriptor) {
3420
3523
  if (!request)
3421
3524
  return {};
3422
3525
  const out = {};
3423
- if (request.where !== undefined) {
3526
+ const cql2 = compileCql2QueryFilter(request, descriptor);
3527
+ const filterParts = [];
3528
+ if (request.where !== undefined && request.where !== "")
3529
+ filterParts.push(request.where);
3530
+ if (cql2.filter !== undefined)
3531
+ filterParts.push(cql2.filter);
3532
+ if (filterParts.length > 0) {
3533
+ out.filter = filterParts.length === 1 ? filterParts[0] : filterParts.map((part) => `(${part})`).join(" AND ");
3534
+ out.filterLang = "cql2-text";
3535
+ }
3536
+ else if (request.where !== undefined) {
3424
3537
  out.filter = request.where;
3425
3538
  out.filterLang = "cql2-text";
3426
3539
  }
3540
+ if (cql2.datetime !== undefined)
3541
+ out.datetime = cql2.datetime;
3427
3542
  if (request.outFields && request.outFields.length > 0)
3428
3543
  out.properties = [...request.outFields];
3429
3544
  if (request.pagination) {
@@ -3518,12 +3633,27 @@ function applyQueryAllLimit(features, limit) {
3518
3633
  }
3519
3634
  return { features: features.slice(0, limit), exceededTransferLimit: true };
3520
3635
  }
3521
- function toFeatureLayerRequest(request) {
3636
+ function toFeatureLayerRequest(request, descriptor) {
3522
3637
  if (!request)
3523
3638
  return {};
3524
3639
  const out = {};
3525
- if (request.where !== undefined)
3640
+ const ctx = queryFilterContext(descriptor);
3641
+ // The typed filter, the deprecated source-native `where`, and a field-bound
3642
+ // temporal predicate are conjunctive: each contributes a SQL-92 fragment and
3643
+ // the adapter ANDs them. A spatial node inside the typed filter leaves the
3644
+ // SQL and becomes the request's geometry parameters.
3645
+ const compiled = compileGeoServicesQueryFilter(request, ctx);
3646
+ const whereParts = [];
3647
+ if (request.where !== undefined && request.where !== "")
3648
+ whereParts.push(request.where);
3649
+ if (compiled.where !== undefined)
3650
+ whereParts.push(compiled.where);
3651
+ if (whereParts.length > 0) {
3652
+ out.where = whereParts.length === 1 ? whereParts[0] : whereParts.map((part) => `(${part})`).join(" AND ");
3653
+ }
3654
+ else if (request.where !== undefined) {
3526
3655
  out.where = request.where;
3656
+ }
3527
3657
  if (request.outFields && request.outFields.length > 0)
3528
3658
  out.outFields = [...request.outFields];
3529
3659
  if (request.returnGeometry !== undefined)
@@ -3533,11 +3663,23 @@ function toFeatureLayerRequest(request) {
3533
3663
  if (request.orderBy && request.orderBy.length > 0) {
3534
3664
  out.orderByFields = request.orderBy.map((s) => `${s.field}${s.direction === "desc" ? " DESC" : ""}`).join(",");
3535
3665
  }
3536
- if (request.spatialFilter) {
3537
- out.geometry = request.spatialFilter.geometry;
3538
- out.geometryType = request.spatialFilter.geometryType;
3539
- if (request.spatialFilter.spatialRel)
3540
- out.spatialRel = request.spatialFilter.spatialRel;
3666
+ const spatialFilter = request.spatialFilter ?? compiled.spatialFilter;
3667
+ if (request.spatialFilter && compiled.spatialFilter) {
3668
+ // One `query` request carries one geometry. Refuse rather than drop either
3669
+ // constraint or silently intersect them client-side.
3670
+ throw new HonuaCapabilityNotSupportedError("filter.spatial.multiple", descriptor.protocol, descriptor.id);
3671
+ }
3672
+ if (spatialFilter) {
3673
+ out.geometry = spatialFilter.geometry;
3674
+ out.geometryType = spatialFilter.geometryType;
3675
+ if (spatialFilter.spatialRel)
3676
+ out.spatialRel = spatialFilter.spatialRel;
3677
+ }
3678
+ if (compiled.time !== undefined) {
3679
+ out.extraParams = {
3680
+ ...(out.extraParams ?? {}),
3681
+ time: compiled.time,
3682
+ };
3541
3683
  }
3542
3684
  if (request.pagination) {
3543
3685
  if (request.pagination.offset !== undefined)
@@ -3549,6 +3691,58 @@ function toFeatureLayerRequest(request) {
3549
3691
  out.signal = request.signal;
3550
3692
  return out;
3551
3693
  }
3694
+ /** Filter-compilation identity used when refusing a construct. */
3695
+ function queryFilterContext(descriptor, geometryProperty) {
3696
+ return {
3697
+ protocol: descriptor.protocol,
3698
+ sourceId: descriptor.id,
3699
+ ...(geometryProperty !== undefined ? { geometryProperty } : {}),
3700
+ ...descriptorQueryFilterConstraints(descriptor),
3701
+ };
3702
+ }
3703
+ /**
3704
+ * Bind this source's evaluated capability profile into the filter gate.
3705
+ *
3706
+ * A profile is per-source evidence, so it narrows — never widens — what the
3707
+ * protocol's grammar allows: a GeoServices layer whose evidence advertises only
3708
+ * `esriSpatialRelIntersects`, or an OGC collection whose queryables list omits
3709
+ * `LIKE`, refuses those constructs by name even though the protocol could carry
3710
+ * them. Sources without a profile keep the protocol-level behaviour, because
3711
+ * absent evidence is not evidence of absence.
3712
+ */
3713
+ function descriptorQueryFilterConstraints(descriptor) {
3714
+ const constraints = descriptor.capabilityProfile?.entries.find((entry) => entry.id === "query")?.constraints;
3715
+ if (!constraints)
3716
+ return {};
3717
+ const bound = queryFilterConstraints(constraints);
3718
+ if (bound.supportedFilterOperators === undefined &&
3719
+ bound.supportedSpatialPredicates === undefined &&
3720
+ bound.supportedTemporalPredicates === undefined) {
3721
+ return {};
3722
+ }
3723
+ return { ...bound, constraintSource: "capability-profile" };
3724
+ }
3725
+ /**
3726
+ * Lower `Query.filter` + `Query.temporalFilter` onto the GeoServices request
3727
+ * vocabulary: a SQL-92 `where` fragment, at most one geometry parameter set,
3728
+ * and the `time=` parameter when the temporal filter targets the layer's own
3729
+ * time dimension.
3730
+ */
3731
+ function compileGeoServicesQueryFilter(request, ctx) {
3732
+ const temporalExpression = request.temporalFilter
3733
+ ? temporalFilterToExpression(request.temporalFilter, ctx)
3734
+ : undefined;
3735
+ const expression = andQueryFilters(request.filter, temporalExpression);
3736
+ const compiled = expression ? compileQueryFilterToSql92(expression, ctx) : {};
3737
+ const time = request.temporalFilter && temporalExpression === undefined
3738
+ ? compileTemporalFilterToGeoServicesTime(request.temporalFilter, ctx)
3739
+ : undefined;
3740
+ return {
3741
+ ...(compiled.where !== undefined ? { where: compiled.where } : {}),
3742
+ ...(compiled.spatialFilter ? { spatialFilter: compiled.spatialFilter } : {}),
3743
+ ...(time !== undefined ? { time } : {}),
3744
+ };
3745
+ }
3552
3746
  /**
3553
3747
  * Convert a translated GeoServices request into an extent-only variant. The
3554
3748
  * caller's spatial filter / outSr / signal / orderByFields must survive; only
@@ -3585,6 +3779,10 @@ function hasExtentFilter(request) {
3585
3779
  return true;
3586
3780
  if (request.spatialFilter)
3587
3781
  return true;
3782
+ if (request.filter !== undefined)
3783
+ return true;
3784
+ if (request.temporalFilter !== undefined)
3785
+ return true;
3588
3786
  return false;
3589
3787
  }
3590
3788
  function computeExtentFromOgcFeatures(features) {
@@ -3641,14 +3839,26 @@ function extractExtentEnvelope(response) {
3641
3839
  const count = typeof obj.count === "number" && Number.isFinite(obj.count) ? obj.count : undefined;
3642
3840
  return { extent, count };
3643
3841
  }
3644
- function toOgcRequest(request) {
3842
+ function toOgcRequest(request, descriptor) {
3645
3843
  if (!request)
3646
3844
  return {};
3647
3845
  const out = {};
3648
- if (request.where !== undefined) {
3846
+ const cql2 = compileCql2QueryFilter(request, descriptor);
3847
+ const filterParts = [];
3848
+ if (request.where !== undefined && request.where !== "")
3849
+ filterParts.push(request.where);
3850
+ if (cql2.filter !== undefined)
3851
+ filterParts.push(cql2.filter);
3852
+ if (filterParts.length > 0) {
3853
+ out.filter = filterParts.length === 1 ? filterParts[0] : filterParts.map((part) => `(${part})`).join(" AND ");
3854
+ out.filterLang = "cql2-text";
3855
+ }
3856
+ else if (request.where !== undefined) {
3649
3857
  out.filter = request.where;
3650
3858
  out.filterLang = "cql2-text";
3651
3859
  }
3860
+ if (cql2.datetime !== undefined)
3861
+ out.datetime = cql2.datetime;
3652
3862
  if (request.outFields && request.outFields.length > 0)
3653
3863
  out.properties = [...request.outFields];
3654
3864
  if (request.outSr !== undefined)
@@ -3698,12 +3908,42 @@ function toOgcRequest(request) {
3698
3908
  * `offset` would silently materialize only a window of the match set and break
3699
3909
  * the whole-result contract those methods promise.
3700
3910
  */
3701
- function toOgcMaterializedRequest(request) {
3702
- const { limit: _limit, offset: _offset, ...out } = toOgcRequest(request);
3911
+ function toOgcMaterializedRequest(request, descriptor) {
3912
+ const { limit: _limit, offset: _offset, ...out } = toOgcRequest(request, descriptor);
3703
3913
  void _limit;
3704
3914
  void _offset;
3705
3915
  return out;
3706
3916
  }
3917
+ /**
3918
+ * Compile `Query.filter` / `Query.temporalFilter` for the CQL2 protocols (OGC
3919
+ * API Features, OGC API Records, STAC). A temporal filter without a field is
3920
+ * the protocol's own `datetime=` parameter; with a field it becomes a CQL2
3921
+ * temporal predicate so the constraint stays exact.
3922
+ */
3923
+ function compileCql2QueryFilter(request, descriptor) {
3924
+ const ctx = queryFilterContext(descriptor, ogcGeometryProperty(descriptor));
3925
+ const temporalExpression = request.temporalFilter
3926
+ ? temporalFilterToExpression(request.temporalFilter, ctx)
3927
+ : undefined;
3928
+ const expression = andQueryFilters(request.filter, temporalExpression);
3929
+ const filter = expression ? compileQueryFilterToCql2Text(expression, ctx, geometryToWkt) : undefined;
3930
+ const datetime = request.temporalFilter && temporalExpression === undefined
3931
+ ? compileTemporalFilterToOgcDatetime(request.temporalFilter, ctx)
3932
+ : undefined;
3933
+ return {
3934
+ ...(filter !== undefined ? { filter } : {}),
3935
+ ...(datetime !== undefined ? { datetime } : {}),
3936
+ };
3937
+ }
3938
+ /**
3939
+ * Geometry property a CQL2 spatial predicate references. OGC collections
3940
+ * default to the GeoJSON `geometry` member; `locator.geometryName` pins a
3941
+ * server that publishes a differently named queryable.
3942
+ */
3943
+ function ogcGeometryProperty(descriptor) {
3944
+ const declared = descriptor.locator.geometryName;
3945
+ return typeof declared === "string" && declared.length > 0 ? declared : "geometry";
3946
+ }
3707
3947
  function featureLayerResultFromTyped(response) {
3708
3948
  return {
3709
3949
  features: response.features ?? [],