@honua/sdk-esri-compat 0.0.6-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (289) hide show
  1. package/README.md +5 -0
  2. package/contract/edit-session.d.ts +203 -0
  3. package/contract/edit-session.d.ts.map +1 -0
  4. package/contract/edit-session.js +717 -0
  5. package/contract/index.d.ts +20 -0
  6. package/contract/index.d.ts.map +1 -0
  7. package/contract/index.js +15 -0
  8. package/contract/jobs.d.ts +115 -0
  9. package/contract/jobs.d.ts.map +1 -0
  10. package/contract/jobs.js +22 -0
  11. package/contract/source.d.ts +138 -0
  12. package/contract/source.d.ts.map +1 -0
  13. package/contract/source.js +3364 -0
  14. package/contract/spatial-aggregation.d.ts +312 -0
  15. package/contract/spatial-aggregation.d.ts.map +1 -0
  16. package/contract/spatial-aggregation.js +196 -0
  17. package/contract/types.d.ts +542 -0
  18. package/contract/types.d.ts.map +1 -0
  19. package/contract/types.js +171 -0
  20. package/core/batch.d.ts +37 -0
  21. package/core/batch.d.ts.map +1 -0
  22. package/core/batch.js +78 -0
  23. package/core/cache-state.d.ts +76 -0
  24. package/core/cache-state.d.ts.map +1 -0
  25. package/core/cache-state.js +84 -0
  26. package/core/client.d.ts +289 -0
  27. package/core/client.d.ts.map +1 -0
  28. package/core/client.js +2625 -0
  29. package/core/errors.d.ts +68 -0
  30. package/core/errors.d.ts.map +1 -0
  31. package/core/errors.js +116 -0
  32. package/core/grpc-adapter.d.ts +23 -0
  33. package/core/grpc-adapter.d.ts.map +1 -0
  34. package/core/grpc-adapter.js +704 -0
  35. package/core/odata.d.ts +347 -0
  36. package/core/odata.d.ts.map +1 -0
  37. package/core/odata.js +837 -0
  38. package/core/ogc-conformance.d.ts +35 -0
  39. package/core/ogc-conformance.d.ts.map +1 -0
  40. package/core/ogc-conformance.js +121 -0
  41. package/core/ogc-maps.d.ts +45 -0
  42. package/core/ogc-maps.d.ts.map +1 -0
  43. package/core/ogc-maps.js +53 -0
  44. package/core/ogc-processes.d.ts +92 -0
  45. package/core/ogc-processes.d.ts.map +1 -0
  46. package/core/ogc-processes.js +325 -0
  47. package/core/ogc-tiles.d.ts +55 -0
  48. package/core/ogc-tiles.d.ts.map +1 -0
  49. package/core/ogc-tiles.js +81 -0
  50. package/core/pbf-decoder.d.ts +28 -0
  51. package/core/pbf-decoder.d.ts.map +1 -0
  52. package/core/pbf-decoder.js +627 -0
  53. package/core/query-builder.d.ts +261 -0
  54. package/core/query-builder.d.ts.map +1 -0
  55. package/core/query-builder.js +538 -0
  56. package/core/spatial-filter.d.ts +93 -0
  57. package/core/spatial-filter.d.ts.map +1 -0
  58. package/core/spatial-filter.js +153 -0
  59. package/core/stac.d.ts +43 -0
  60. package/core/stac.d.ts.map +1 -0
  61. package/core/stac.js +116 -0
  62. package/core/surfaces.d.ts +464 -0
  63. package/core/surfaces.d.ts.map +1 -0
  64. package/core/surfaces.js +1322 -0
  65. package/core/types.d.ts +1238 -0
  66. package/core/types.d.ts.map +1 -0
  67. package/core/types.js +13 -0
  68. package/core/wfs-capabilities.d.ts +143 -0
  69. package/core/wfs-capabilities.d.ts.map +1 -0
  70. package/core/wfs-capabilities.js +478 -0
  71. package/core/wfs-filter.d.ts +118 -0
  72. package/core/wfs-filter.d.ts.map +1 -0
  73. package/core/wfs-filter.js +586 -0
  74. package/core/wfs.d.ts +219 -0
  75. package/core/wfs.d.ts.map +1 -0
  76. package/core/wfs.js +406 -0
  77. package/core/wms-capabilities.d.ts +110 -0
  78. package/core/wms-capabilities.d.ts.map +1 -0
  79. package/core/wms-capabilities.js +566 -0
  80. package/core/wms-types.d.ts +128 -0
  81. package/core/wms-types.d.ts.map +1 -0
  82. package/core/wms-types.js +32 -0
  83. package/core/wms.d.ts +108 -0
  84. package/core/wms.d.ts.map +1 -0
  85. package/core/wms.js +182 -0
  86. package/core/wmts-capabilities.d.ts +82 -0
  87. package/core/wmts-capabilities.d.ts.map +1 -0
  88. package/core/wmts-capabilities.js +409 -0
  89. package/core/wmts.d.ts +80 -0
  90. package/core/wmts.d.ts.map +1 -0
  91. package/core/wmts.js +173 -0
  92. package/esri-compat/basemap-gallery.d.ts +38 -0
  93. package/esri-compat/basemap-gallery.d.ts.map +1 -0
  94. package/esri-compat/basemap-gallery.js +159 -0
  95. package/esri-compat/basemap-layer-list.d.ts +35 -0
  96. package/esri-compat/basemap-layer-list.d.ts.map +1 -0
  97. package/esri-compat/basemap-layer-list.js +139 -0
  98. package/esri-compat/basemap.d.ts +32 -0
  99. package/esri-compat/basemap.d.ts.map +1 -0
  100. package/esri-compat/basemap.js +84 -0
  101. package/esri-compat/bookmarks.d.ts +36 -0
  102. package/esri-compat/bookmarks.d.ts.map +1 -0
  103. package/esri-compat/bookmarks.js +108 -0
  104. package/esri-compat/class-breaks-renderer.d.ts +50 -0
  105. package/esri-compat/class-breaks-renderer.d.ts.map +1 -0
  106. package/esri-compat/class-breaks-renderer.js +164 -0
  107. package/esri-compat/color.d.ts +22 -0
  108. package/esri-compat/color.d.ts.map +1 -0
  109. package/esri-compat/color.js +106 -0
  110. package/esri-compat/controls.d.ts +174 -0
  111. package/esri-compat/controls.d.ts.map +1 -0
  112. package/esri-compat/controls.js +479 -0
  113. package/esri-compat/coordinate-conversion.d.ts +41 -0
  114. package/esri-compat/coordinate-conversion.d.ts.map +1 -0
  115. package/esri-compat/coordinate-conversion.js +125 -0
  116. package/esri-compat/directions.d.ts +45 -0
  117. package/esri-compat/directions.d.ts.map +1 -0
  118. package/esri-compat/directions.js +123 -0
  119. package/esri-compat/editor.d.ts +47 -0
  120. package/esri-compat/editor.d.ts.map +1 -0
  121. package/esri-compat/editor.js +170 -0
  122. package/esri-compat/esri-config.d.ts +16 -0
  123. package/esri-compat/esri-config.d.ts.map +1 -0
  124. package/esri-compat/esri-config.js +23 -0
  125. package/esri-compat/esri-request.d.ts +17 -0
  126. package/esri-compat/esri-request.d.ts.map +1 -0
  127. package/esri-compat/esri-request.js +66 -0
  128. package/esri-compat/event-bus.d.ts +106 -0
  129. package/esri-compat/event-bus.d.ts.map +1 -0
  130. package/esri-compat/event-bus.js +90 -0
  131. package/esri-compat/expand.d.ts +36 -0
  132. package/esri-compat/expand.d.ts.map +1 -0
  133. package/esri-compat/expand.js +97 -0
  134. package/esri-compat/extent.d.ts +43 -0
  135. package/esri-compat/extent.d.ts.map +1 -0
  136. package/esri-compat/extent.js +140 -0
  137. package/esri-compat/feature-form.d.ts +64 -0
  138. package/esri-compat/feature-form.d.ts.map +1 -0
  139. package/esri-compat/feature-form.js +137 -0
  140. package/esri-compat/feature-layer.d.ts +173 -0
  141. package/esri-compat/feature-layer.d.ts.map +1 -0
  142. package/esri-compat/feature-layer.js +655 -0
  143. package/esri-compat/feature-set.d.ts +32 -0
  144. package/esri-compat/feature-set.d.ts.map +1 -0
  145. package/esri-compat/feature-set.js +80 -0
  146. package/esri-compat/feature-table.d.ts +141 -0
  147. package/esri-compat/feature-table.d.ts.map +1 -0
  148. package/esri-compat/feature-table.js +500 -0
  149. package/esri-compat/feature-templates.d.ts +41 -0
  150. package/esri-compat/feature-templates.d.ts.map +1 -0
  151. package/esri-compat/feature-templates.js +97 -0
  152. package/esri-compat/feature.d.ts +33 -0
  153. package/esri-compat/feature.d.ts.map +1 -0
  154. package/esri-compat/feature.js +83 -0
  155. package/esri-compat/graphic.d.ts +46 -0
  156. package/esri-compat/graphic.d.ts.map +1 -0
  157. package/esri-compat/graphic.js +103 -0
  158. package/esri-compat/graphics-layer.d.ts +49 -0
  159. package/esri-compat/graphics-layer.d.ts.map +1 -0
  160. package/esri-compat/graphics-layer.js +185 -0
  161. package/esri-compat/group-layer.d.ts +48 -0
  162. package/esri-compat/group-layer.d.ts.map +1 -0
  163. package/esri-compat/group-layer.js +212 -0
  164. package/esri-compat/identify.d.ts +71 -0
  165. package/esri-compat/identify.d.ts.map +1 -0
  166. package/esri-compat/identify.js +299 -0
  167. package/esri-compat/identity-manager.d.ts +31 -0
  168. package/esri-compat/identity-manager.d.ts.map +1 -0
  169. package/esri-compat/identity-manager.js +115 -0
  170. package/esri-compat/label-class.d.ts +31 -0
  171. package/esri-compat/label-class.d.ts.map +1 -0
  172. package/esri-compat/label-class.js +102 -0
  173. package/esri-compat/layer-list.d.ts +77 -0
  174. package/esri-compat/layer-list.d.ts.map +1 -0
  175. package/esri-compat/layer-list.js +342 -0
  176. package/esri-compat/legend.d.ts +53 -0
  177. package/esri-compat/legend.d.ts.map +1 -0
  178. package/esri-compat/legend.js +254 -0
  179. package/esri-compat/map-image-layer.d.ts +162 -0
  180. package/esri-compat/map-image-layer.d.ts.map +1 -0
  181. package/esri-compat/map-image-layer.js +704 -0
  182. package/esri-compat/map-view.d.ts +315 -0
  183. package/esri-compat/map-view.d.ts.map +1 -0
  184. package/esri-compat/map-view.js +1110 -0
  185. package/esri-compat/map.d.ts +47 -0
  186. package/esri-compat/map.d.ts.map +1 -0
  187. package/esri-compat/map.js +209 -0
  188. package/esri-compat/measurement-2d.d.ts +61 -0
  189. package/esri-compat/measurement-2d.d.ts.map +1 -0
  190. package/esri-compat/measurement-2d.js +184 -0
  191. package/esri-compat/measurement.d.ts +45 -0
  192. package/esri-compat/measurement.d.ts.map +1 -0
  193. package/esri-compat/measurement.js +193 -0
  194. package/esri-compat/oauth-info.d.ts +35 -0
  195. package/esri-compat/oauth-info.d.ts.map +1 -0
  196. package/esri-compat/oauth-info.js +115 -0
  197. package/esri-compat/picture-marker-symbol.d.ts +35 -0
  198. package/esri-compat/picture-marker-symbol.d.ts.map +1 -0
  199. package/esri-compat/picture-marker-symbol.js +126 -0
  200. package/esri-compat/point.d.ts +31 -0
  201. package/esri-compat/point.d.ts.map +1 -0
  202. package/esri-compat/point.js +102 -0
  203. package/esri-compat/polygon.d.ts +31 -0
  204. package/esri-compat/polygon.d.ts.map +1 -0
  205. package/esri-compat/polygon.js +107 -0
  206. package/esri-compat/polyline.d.ts +31 -0
  207. package/esri-compat/polyline.d.ts.map +1 -0
  208. package/esri-compat/polyline.js +107 -0
  209. package/esri-compat/popup-template.d.ts +55 -0
  210. package/esri-compat/popup-template.d.ts.map +1 -0
  211. package/esri-compat/popup-template.js +128 -0
  212. package/esri-compat/popup.d.ts +54 -0
  213. package/esri-compat/popup.d.ts.map +1 -0
  214. package/esri-compat/popup.js +312 -0
  215. package/esri-compat/print.d.ts +47 -0
  216. package/esri-compat/print.d.ts.map +1 -0
  217. package/esri-compat/print.js +105 -0
  218. package/esri-compat/query.d.ts +46 -0
  219. package/esri-compat/query.d.ts.map +1 -0
  220. package/esri-compat/query.js +119 -0
  221. package/esri-compat/reactive-utils.d.ts +23 -0
  222. package/esri-compat/reactive-utils.d.ts.map +1 -0
  223. package/esri-compat/reactive-utils.js +178 -0
  224. package/esri-compat/request.d.ts +41 -0
  225. package/esri-compat/request.d.ts.map +1 -0
  226. package/esri-compat/request.js +253 -0
  227. package/esri-compat/route-layer.d.ts +59 -0
  228. package/esri-compat/route-layer.d.ts.map +1 -0
  229. package/esri-compat/route-layer.js +223 -0
  230. package/esri-compat/route-task.d.ts +88 -0
  231. package/esri-compat/route-task.d.ts.map +1 -0
  232. package/esri-compat/route-task.js +225 -0
  233. package/esri-compat/scene-view.d.ts +41 -0
  234. package/esri-compat/scene-view.d.ts.map +1 -0
  235. package/esri-compat/scene-view.js +68 -0
  236. package/esri-compat/search.d.ts +112 -0
  237. package/esri-compat/search.d.ts.map +1 -0
  238. package/esri-compat/search.js +820 -0
  239. package/esri-compat/simple-fill-symbol.d.ts +27 -0
  240. package/esri-compat/simple-fill-symbol.d.ts.map +1 -0
  241. package/esri-compat/simple-fill-symbol.js +85 -0
  242. package/esri-compat/simple-line-symbol.d.ts +27 -0
  243. package/esri-compat/simple-line-symbol.d.ts.map +1 -0
  244. package/esri-compat/simple-line-symbol.js +85 -0
  245. package/esri-compat/simple-marker-symbol.d.ts +29 -0
  246. package/esri-compat/simple-marker-symbol.d.ts.map +1 -0
  247. package/esri-compat/simple-marker-symbol.js +92 -0
  248. package/esri-compat/simple-renderer.d.ts +29 -0
  249. package/esri-compat/simple-renderer.d.ts.map +1 -0
  250. package/esri-compat/simple-renderer.js +92 -0
  251. package/esri-compat/sketch.d.ts +64 -0
  252. package/esri-compat/sketch.d.ts.map +1 -0
  253. package/esri-compat/sketch.js +227 -0
  254. package/esri-compat/spatial-reference.d.ts +31 -0
  255. package/esri-compat/spatial-reference.d.ts.map +1 -0
  256. package/esri-compat/spatial-reference.js +102 -0
  257. package/esri-compat/swipe.d.ts +34 -0
  258. package/esri-compat/swipe.d.ts.map +1 -0
  259. package/esri-compat/swipe.js +93 -0
  260. package/esri-compat/table-list.d.ts +36 -0
  261. package/esri-compat/table-list.d.ts.map +1 -0
  262. package/esri-compat/table-list.js +126 -0
  263. package/esri-compat/text-symbol.d.ts +37 -0
  264. package/esri-compat/text-symbol.d.ts.map +1 -0
  265. package/esri-compat/text-symbol.js +123 -0
  266. package/esri-compat/tile-layer.d.ts +52 -0
  267. package/esri-compat/tile-layer.d.ts.map +1 -0
  268. package/esri-compat/tile-layer.js +167 -0
  269. package/esri-compat/time-slider.d.ts +70 -0
  270. package/esri-compat/time-slider.d.ts.map +1 -0
  271. package/esri-compat/time-slider.js +208 -0
  272. package/esri-compat/track.d.ts +51 -0
  273. package/esri-compat/track.d.ts.map +1 -0
  274. package/esri-compat/track.js +174 -0
  275. package/esri-compat/unique-value-renderer.d.ts +41 -0
  276. package/esri-compat/unique-value-renderer.d.ts.map +1 -0
  277. package/esri-compat/unique-value-renderer.js +127 -0
  278. package/esri-compat/url.d.ts +12 -0
  279. package/esri-compat/url.d.ts.map +1 -0
  280. package/esri-compat/url.js +54 -0
  281. package/esri-compat/web-map.d.ts +14 -0
  282. package/esri-compat/web-map.d.ts.map +1 -0
  283. package/esri-compat/web-map.js +24 -0
  284. package/gen/honua/v1/feature_service_pb.d.ts +918 -0
  285. package/gen/honua/v1/feature_service_pb.d.ts.map +1 -0
  286. package/gen/honua/v1/feature_service_pb.js +375 -0
  287. package/index.d.ts +130 -0
  288. package/index.js +65 -0
  289. package/package.json +22 -0
package/core/odata.js ADDED
@@ -0,0 +1,837 @@
1
+ /**
2
+ * OData v4 entity-set adapter. Wraps an OData entity set behind the shared
3
+ * `Source<T>` contract surface in `src/contract/source.ts`.
4
+ *
5
+ * Library posture is recorded in
6
+ * `docs/decisions/odata-library-selection.md`: the canonical surface is a
7
+ * thin URL/JSON serializer over `HonuaClient.pipelineFetch` /
8
+ * `pipelineRequestJson` (rather than the GeoServices-shaped
9
+ * `request()` helper, which would inject `f=json` and trip Honua
10
+ * Server's OData validators) so that the auth / retry / telemetry
11
+ * pipeline stays centralized. The dialect-specific `$batch` / `$apply`
12
+ * / `$search` / `$deltatoken` operations live behind the typed escape
13
+ * hatch returned by `Source.protocol("odata")`.
14
+ *
15
+ * @module
16
+ */
17
+ import { createHonuaCacheState, honuaCacheValidatorFromHeaders, honuaMetadataRequestHeaders, normalizeHonuaMetadataRequestOptions, withHonuaCacheState, withoutHonuaCacheState, } from "./cache-state.js";
18
+ // ── Runtime class ─────────────────────────────────────────────
19
+ /**
20
+ * Runtime handle for one OData entity set. Returned by
21
+ * `Source.protocol("odata")` so callers can reach the dialect-specific
22
+ * surface (`$batch`, `$apply`, `$search`, `$deltatoken`) without leaking
23
+ * OData-shaped types into the canonical `Source` API.
24
+ */
25
+ export class HonuaOdataEntitySet {
26
+ client;
27
+ entitySet;
28
+ /**
29
+ * Unqualified entity-set name used as the CSDL metadata-lookup key.
30
+ * Equals `entitySet` for direct paths like `"Parcels"`. For
31
+ * layer-scoped navigation paths like `"Layers(1)/Features"` it is the
32
+ * navigation suffix `"Features"` so capability and schema lookups
33
+ * still find the entry the server emits in `<EntitySet Name="…">`.
34
+ */
35
+ entitySetName;
36
+ basePath;
37
+ cachedMetadata;
38
+ inflightMetadata;
39
+ constructor(options) {
40
+ this.client = options.client;
41
+ this.entitySet = options.entitySet;
42
+ this.entitySetName = extractEntitySetName(options.entitySet);
43
+ this.basePath = normalizeBasePath(options.basePath);
44
+ }
45
+ /**
46
+ * Fetch (and cache) the `$metadata` document. The cache is per
47
+ * `HonuaOdataEntitySet` instance so callers can pin a metadata snapshot
48
+ * to a long-lived `Source` without re-parsing on every request.
49
+ */
50
+ async metadata(options = {}) {
51
+ const metadataOptions = normalizeHonuaMetadataRequestOptions(options);
52
+ const bypass = metadataOptions.cache === "bypass";
53
+ if (!bypass && !metadataOptions.refresh && this.cachedMetadata) {
54
+ return withOdataMetadataCacheState(this.cachedMetadata, "hit", {
55
+ now: Date.now(),
56
+ ttlMs: metadataOptions.ttlMs,
57
+ staleIfErrorMs: metadataOptions.staleIfErrorMs,
58
+ });
59
+ }
60
+ if (!bypass && !metadataOptions.refresh && this.inflightMetadata)
61
+ return this.inflightMetadata;
62
+ const previous = this.cachedMetadata;
63
+ const promise = this.fetchMetadata(metadataOptions, previous).then((entry) => {
64
+ if (!bypass) {
65
+ this.cachedMetadata = entry;
66
+ }
67
+ const status = bypass ? "bypass" : entry.status;
68
+ return withOdataMetadataCacheState(entry, status, {
69
+ now: Date.now(),
70
+ ttlMs: metadataOptions.ttlMs,
71
+ staleIfErrorMs: metadataOptions.staleIfErrorMs,
72
+ ...(status === "refreshed" ? { revalidatedAt: new Date().toISOString() } : {}),
73
+ });
74
+ });
75
+ if (!bypass && !metadataOptions.refresh) {
76
+ this.inflightMetadata = promise.finally(() => {
77
+ if (this.inflightMetadata === promise)
78
+ this.inflightMetadata = undefined;
79
+ });
80
+ }
81
+ try {
82
+ return await promise;
83
+ }
84
+ catch (error) {
85
+ if (!bypass && metadataOptions.staleIfError && previous) {
86
+ return withOdataMetadataCacheState(previous, "stale", {
87
+ now: Date.now(),
88
+ ttlMs: metadataOptions.ttlMs,
89
+ staleIfErrorMs: metadataOptions.staleIfErrorMs,
90
+ refreshErrorId: odataMetadataRefreshErrorId(error),
91
+ });
92
+ }
93
+ throw error;
94
+ }
95
+ }
96
+ /** Fetch one page of the entity set. */
97
+ async query(params = {}) {
98
+ const path = this.entitySetPath();
99
+ const query = serializeQueryParams(params);
100
+ const body = await this.requestJson("GET", path, query, params.signal);
101
+ return {
102
+ rows: body.value ?? [],
103
+ ...(typeof body["@odata.count"] === "number" ? { totalCount: body["@odata.count"] } : {}),
104
+ ...(typeof body["@odata.nextLink"] === "string" ? { nextLink: body["@odata.nextLink"] } : {}),
105
+ ...(typeof body["@odata.deltaLink"] === "string" ? { deltaLink: body["@odata.deltaLink"] } : {}),
106
+ };
107
+ }
108
+ /**
109
+ * Drain server-driven `@odata.nextLink` pages. When `params.top` is
110
+ * supplied it is treated as a hard cap on collected rows. Callers that
111
+ * want a lookahead row to detect truncation must add it themselves; the
112
+ * canonical `Source.queryAll` path does this with `top = limit + 1`,
113
+ * while ids-only projections enforce their own result cap.
114
+ * Returning `top + 1` here would double-count for callers that already
115
+ * added the lookahead, so this helper respects `top` exactly.
116
+ */
117
+ async queryAll(params = {}) {
118
+ const cap = typeof params.top === "number" && params.top >= 0 ? params.top : undefined;
119
+ const collected = [];
120
+ let totalCount;
121
+ let next;
122
+ let firstPageParams = params;
123
+ while (true) {
124
+ const page = next
125
+ ? await this.followNextLink(next, params.signal)
126
+ : await this.query(firstPageParams ?? {});
127
+ firstPageParams = undefined;
128
+ if (totalCount === undefined && typeof page.totalCount === "number") {
129
+ totalCount = page.totalCount;
130
+ }
131
+ for (const row of page.rows) {
132
+ if (cap !== undefined && collected.length >= cap)
133
+ break;
134
+ collected.push(row);
135
+ if (cap !== undefined && collected.length >= cap)
136
+ break;
137
+ }
138
+ if (cap !== undefined && collected.length >= cap)
139
+ break;
140
+ if (!page.nextLink)
141
+ break;
142
+ next = page.nextLink;
143
+ }
144
+ return totalCount === undefined ? { rows: collected } : { rows: collected, totalCount };
145
+ }
146
+ /** Yield one page per server response. Used by `Source.stream()`. */
147
+ async *queryStream(params = {}) {
148
+ let page = await this.query(params);
149
+ yield page;
150
+ while (page.nextLink) {
151
+ page = await this.followNextLink(page.nextLink, params.signal);
152
+ yield page;
153
+ }
154
+ }
155
+ /** Insert a row via `POST /<entitySet>`. */
156
+ async add(body, options = {}) {
157
+ const path = this.entitySetPath();
158
+ return this.requestJson("POST", path, undefined, options.signal, JSON.stringify(body));
159
+ }
160
+ /**
161
+ * Update a row via `PATCH /<entitySet>(<key>)`. The OData server in
162
+ * Honua Server intentionally does not implement `PUT`; the SDK issues
163
+ * `PATCH` with the full canonical body to match a `PUT` replacement on
164
+ * the canonical surface (documented in
165
+ * `docs/protocol-capability-matrix.md` under the OData section).
166
+ */
167
+ async update(key, body, options = {}) {
168
+ const path = `${this.entitySetPath()}(${key})`;
169
+ return this.requestJson("PATCH", path, undefined, options.signal, JSON.stringify(body));
170
+ }
171
+ /** Delete a row via `DELETE /<entitySet>(<key>)`. */
172
+ async delete(key, options = {}) {
173
+ const path = `${this.entitySetPath()}(${key})`;
174
+ await this.requestJson("DELETE", path, undefined, options.signal);
175
+ }
176
+ /**
177
+ * Submit a `$batch` request using the OData JSON batch format.
178
+ * Multipart/mixed fallback is intentionally not implemented; Honua
179
+ * Server emits both formats but the JSON envelope is sufficient for
180
+ * the canonical surface and avoids the multipart parser.
181
+ *
182
+ * `atomicity: "all"` stamps the same `atomicityGroup` token on every
183
+ * request so the server runs them in one change-set with rollback on
184
+ * any failure (per OData v4 §11.7.7.3 and Honua Server's
185
+ * `ODataBatchHandler`, which groups by `request.AtomicityGroup`).
186
+ * `atomicity: "none"` (default) leaves the field unset so each request
187
+ * runs independently.
188
+ */
189
+ async batch(operations, options = {}) {
190
+ if (operations.length === 0)
191
+ return { responses: [] };
192
+ const groupId = options.atomicity === "all" ? "g1" : undefined;
193
+ const requests = operations.map((op, index) => ({
194
+ id: op.id ?? String(index + 1),
195
+ method: op.method,
196
+ url: stripLeadingSlash(op.url),
197
+ ...(op.headers
198
+ ? { headers: { "Content-Type": "application/json", ...op.headers } }
199
+ : {
200
+ headers: { "Content-Type": "application/json" },
201
+ }),
202
+ ...(op.body !== undefined ? { body: op.body } : {}),
203
+ ...(groupId !== undefined ? { atomicityGroup: groupId } : {}),
204
+ }));
205
+ const response = await this.requestJson("POST", `${this.basePath}/$batch`, undefined, options.signal, JSON.stringify({ requests }), { "Content-Type": "application/json" });
206
+ return { responses: response.responses ?? [] };
207
+ }
208
+ /**
209
+ * Run an `$apply` aggregation. The OData server applies the
210
+ * transformation pipeline (`aggregate`, `groupby`, `filter`,
211
+ * `compute`); the SDK returns the rows unchanged so callers can shape
212
+ * them onto the canonical `Result.aggregateRows`.
213
+ */
214
+ async apply(transformations, params = {}) {
215
+ const path = this.entitySetPath();
216
+ const query = serializeQueryParams({ apply: transformations, filter: params.filter });
217
+ const body = await this.requestJson("GET", path, query, params.signal);
218
+ return {
219
+ rows: body.value ?? [],
220
+ ...(typeof body["@odata.count"] === "number" ? { totalCount: body["@odata.count"] } : {}),
221
+ };
222
+ }
223
+ /**
224
+ * Run a `$search` full-text query. Returns an `HonuaOdataPage` so the
225
+ * canonical `Result<T>` projection is symmetric with `query()`.
226
+ */
227
+ async search(text, params = {}) {
228
+ return this.query({ ...params, search: text });
229
+ }
230
+ /**
231
+ * `$deltatoken`-driven change feed. Yields `HonuaOdataDeltaPage` per
232
+ * server response. The final page carries `deltaLink`; callers persist
233
+ * the encoded token for the next call to `delta({ since })`.
234
+ */
235
+ async *delta(options = {}) {
236
+ const params = options.since ? { deltatoken: options.since } : {};
237
+ if (options.signal)
238
+ params.signal = options.signal;
239
+ let page = await this.query(params);
240
+ yield {
241
+ rows: page.rows,
242
+ ...(page.nextLink ? { nextLink: page.nextLink } : {}),
243
+ ...(page.deltaLink ? { deltaLink: page.deltaLink } : {}),
244
+ };
245
+ while (page.nextLink) {
246
+ page = await this.followNextLink(page.nextLink, options.signal);
247
+ yield {
248
+ rows: page.rows,
249
+ ...(page.nextLink ? { nextLink: page.nextLink } : {}),
250
+ ...(page.deltaLink ? { deltaLink: page.deltaLink } : {}),
251
+ };
252
+ }
253
+ }
254
+ /**
255
+ * Last-resort raw passthrough — flows through `HonuaClient.pipelineFetch`
256
+ * so auth headers, retry, timeout, interceptors, telemetry, and
257
+ * normalized error mapping all apply. The returned `Response` is
258
+ * unconsumed; callers pick `.json()`, `.text()`, or `.arrayBuffer()`.
259
+ */
260
+ async raw(method, path, init = {}) {
261
+ const normalized = method.toUpperCase();
262
+ return this.client.pipelineFetch(normalized, ensureLeadingSlash(path), init);
263
+ }
264
+ // ── Internal ────────────────────────────────────────────────
265
+ entitySetPath() {
266
+ return `${this.basePath}/${this.entitySet}`;
267
+ }
268
+ async fetchMetadata(options, cached) {
269
+ // Route through pipelineFetch so auth headers, interceptors, retry,
270
+ // timeout, and normalized error handling all apply. The default
271
+ // pipeline does not impose an `Accept` header — we ask for XML
272
+ // explicitly because the OData `$metadata` route emits CSDL.
273
+ const response = await this.client.pipelineFetch("GET", `${this.basePath}/$metadata`, {
274
+ headers: honuaMetadataRequestHeaders({
275
+ accept: "application/xml",
276
+ refresh: options.refresh,
277
+ bypass: options.cache === "bypass",
278
+ validator: cached?.validator,
279
+ }),
280
+ }, options.signal, { okStatuses: [304] });
281
+ if (response.status === 304 && cached) {
282
+ const validator = honuaCacheValidatorFromHeaders(response.headers) ?? cached.validator;
283
+ return {
284
+ ...cached,
285
+ cachedAtMs: Date.now(),
286
+ ...(validator ? { validator } : {}),
287
+ status: "refreshed",
288
+ };
289
+ }
290
+ const xml = await response.text();
291
+ const validator = honuaCacheValidatorFromHeaders(response.headers);
292
+ const sourceUpdatedAt = response.headers.get("last-modified") ?? undefined;
293
+ return {
294
+ metadata: withoutHonuaCacheState(parseOdataMetadata(xml)),
295
+ cachedAtMs: Date.now(),
296
+ keyFingerprint: `metadata:odata:${this.basePath}:$metadata`,
297
+ status: options.cache === "bypass" ? "bypass" : cached ? "refreshed" : "miss",
298
+ ...(validator ? { validator } : {}),
299
+ ...(sourceUpdatedAt ? { sourceUpdatedAt } : {}),
300
+ };
301
+ }
302
+ async followNextLink(nextLink, signal) {
303
+ // OData servers may emit either an absolute URL or a relative one.
304
+ // Normalize through the same fetch path used by `query()` so retry,
305
+ // auth interceptors, and telemetry still apply.
306
+ const { path, query } = parseNextLink(nextLink, this.client.serverBaseUrl, this.basePath);
307
+ const body = await this.requestJson("GET", path, query, signal);
308
+ return {
309
+ rows: body.value ?? [],
310
+ ...(typeof body["@odata.count"] === "number" ? { totalCount: body["@odata.count"] } : {}),
311
+ ...(typeof body["@odata.nextLink"] === "string" ? { nextLink: body["@odata.nextLink"] } : {}),
312
+ ...(typeof body["@odata.deltaLink"] === "string" ? { deltaLink: body["@odata.deltaLink"] } : {}),
313
+ };
314
+ }
315
+ /**
316
+ * Wrap `HonuaClient.pipelineRequestJson` with OData-shaped query
317
+ * serialization so callers can pass parsed query bags. Critically this
318
+ * does **not** go through `HonuaClient.request` — that helper injects
319
+ * `f=json`, which Honua Server's OData validators reject as
320
+ * `InvalidQueryOption`. The OData server defaults to JSON when the
321
+ * request advertises `Accept: application/json` (the pipeline default).
322
+ */
323
+ async requestJson(method, path, query, signal, body, headers) {
324
+ const params = new URLSearchParams();
325
+ if (query) {
326
+ for (const [key, value] of Object.entries(query)) {
327
+ params.set(key, String(value));
328
+ }
329
+ }
330
+ const pathWithQuery = params.size > 0 ? `${path}?${params.toString()}` : path;
331
+ return this.client.pipelineRequestJson(method, pathWithQuery, {
332
+ ...(headers ? { headers } : {}),
333
+ ...(body !== undefined ? { body } : {}),
334
+ }, signal);
335
+ }
336
+ }
337
+ // ── Helpers ───────────────────────────────────────────────────
338
+ function withOdataMetadataCacheState(entry, status, options) {
339
+ return withHonuaCacheState(entry.metadata, createHonuaCacheState({
340
+ scope: "metadata",
341
+ status,
342
+ keyFingerprint: entry.keyFingerprint,
343
+ ageMs: Math.max(0, options.now - entry.cachedAtMs),
344
+ ...(options.ttlMs !== undefined ? { ttlMs: options.ttlMs } : {}),
345
+ ...(options.staleIfErrorMs !== undefined ? { staleIfErrorMs: options.staleIfErrorMs } : {}),
346
+ ...(options.revalidatedAt ? { revalidatedAt: options.revalidatedAt } : {}),
347
+ ...(entry.sourceUpdatedAt ? { sourceUpdatedAt: entry.sourceUpdatedAt } : {}),
348
+ ...(entry.validator ? { validator: entry.validator } : {}),
349
+ ...(options.refreshErrorId ? { refreshErrorId: options.refreshErrorId } : {}),
350
+ }));
351
+ }
352
+ function odataMetadataRefreshErrorId(error) {
353
+ if (error instanceof Error && error.name) {
354
+ return error.name;
355
+ }
356
+ return "unknown";
357
+ }
358
+ function normalizeBasePath(path) {
359
+ if (path === undefined || path === "")
360
+ return "/odata";
361
+ return path.startsWith("/") ? path.replace(/\/+$/, "") || "/" : `/${path.replace(/\/+$/, "")}`;
362
+ }
363
+ /**
364
+ * Extract the unqualified entity-set name from a path-shaped token.
365
+ * Direct identifiers (`"Parcels"`) round-trip unchanged; navigation paths
366
+ * (`"Layers(1)/Features"`) collapse to the trailing segment so CSDL
367
+ * metadata lookups (which key by `<EntitySet Name="…">`) still hit.
368
+ */
369
+ function extractEntitySetName(path) {
370
+ const slash = path.lastIndexOf("/");
371
+ return slash === -1 ? path : path.slice(slash + 1);
372
+ }
373
+ function ensureLeadingSlash(path) {
374
+ return path.startsWith("/") ? path : `/${path}`;
375
+ }
376
+ function stripLeadingSlash(path) {
377
+ return path.startsWith("/") ? path.slice(1) : path;
378
+ }
379
+ /**
380
+ * Translate canonical query params onto the OData `$`-prefixed query
381
+ * string. Empty strings, undefined values, and zero-length lists are
382
+ * dropped so the URL stays minimal.
383
+ */
384
+ function serializeQueryParams(params) {
385
+ const out = {};
386
+ if (params.filter !== undefined && params.filter !== "")
387
+ out.$filter = params.filter;
388
+ if (params.select && params.select.length > 0)
389
+ out.$select = params.select.join(",");
390
+ if (params.orderBy && params.orderBy.length > 0)
391
+ out.$orderby = params.orderBy.join(",");
392
+ if (typeof params.top === "number" && Number.isFinite(params.top))
393
+ out.$top = params.top;
394
+ if (typeof params.skip === "number" && Number.isFinite(params.skip) && params.skip > 0)
395
+ out.$skip = params.skip;
396
+ if (params.expand && params.expand.length > 0)
397
+ out.$expand = params.expand.join(",");
398
+ if (params.count)
399
+ out.$count = "true";
400
+ if (params.skiptoken !== undefined && params.skiptoken !== "")
401
+ out.$skiptoken = params.skiptoken;
402
+ if (params.search !== undefined && params.search !== "")
403
+ out.$search = params.search;
404
+ if (params.apply !== undefined && params.apply !== "")
405
+ out.$apply = params.apply;
406
+ if (params.deltatoken !== undefined && params.deltatoken !== "")
407
+ out.$deltatoken = params.deltatoken;
408
+ if (params.extra)
409
+ Object.assign(out, params.extra);
410
+ return out;
411
+ }
412
+ /**
413
+ * `@odata.nextLink` is either an absolute URL (preferred by spec) or a
414
+ * service-relative URL. Reduce it to the path + query bag the same fetch
415
+ * pipeline expects.
416
+ */
417
+ function parseNextLink(link, serverBaseUrl, basePath) {
418
+ const absolute = link.startsWith("http://") || link.startsWith("https://")
419
+ ? new URL(link)
420
+ : new URL(link, `${ensureTrailingSlash(serverBaseUrl)}${stripLeadingSlash(basePath)}/`);
421
+ const path = absolute.pathname;
422
+ const query = {};
423
+ for (const [key, value] of absolute.searchParams)
424
+ query[key] = value;
425
+ return { path, query };
426
+ }
427
+ function ensureTrailingSlash(url) {
428
+ return url.endsWith("/") ? url : `${url}/`;
429
+ }
430
+ // ── EDMX (CSDL) parsing ───────────────────────────────────────
431
+ /**
432
+ * Parse the subset of CSDL we need to power capability negotiation and
433
+ * key resolution: entity sets, entity-type keys, property names + types,
434
+ * `Edm.Geography` / `Edm.Geometry` typing, and `Capabilities.*`
435
+ * annotations. The XML is read via small regular expressions because we
436
+ * intentionally do not pull a DOM dep — the surface we read is
437
+ * well-defined by OData CSDL §6 and the Honua Server spec page.
438
+ */
439
+ export function parseOdataMetadata(xml) {
440
+ const entitySets = {};
441
+ const keys = {};
442
+ const fields = {};
443
+ const capabilities = {};
444
+ // Sibling annotations: <Annotations Target="<schema>.<container>/<entitySet>">
445
+ // …</Annotations>. Honua Server emits Capabilities.* this way (next to
446
+ // the EntityContainer) rather than nesting them inside <EntitySet>, so
447
+ // capability negotiation must merge both shapes.
448
+ const siblingByEntitySet = new Map();
449
+ const siblingAnnotationsRe = /<Annotations\s+([^>]*?)>([\s\S]*?)<\/Annotations>/g;
450
+ for (const match of xml.matchAll(siblingAnnotationsRe)) {
451
+ const attrs = match[1] ?? "";
452
+ const target = readAttr(attrs, "Target");
453
+ if (!target)
454
+ continue;
455
+ const slash = target.lastIndexOf("/");
456
+ if (slash === -1)
457
+ continue;
458
+ const setName = target.slice(slash + 1);
459
+ if (!setName)
460
+ continue;
461
+ const body = match[2] ?? "";
462
+ const existing = siblingByEntitySet.get(setName);
463
+ siblingByEntitySet.set(setName, existing === undefined ? body : `${existing}\n${body}`);
464
+ }
465
+ // Entity sets: <EntitySet Name="Foo" EntityType="ns.FooEntity"/>
466
+ const entitySetRe = /<EntitySet\s+([^>]*?)\/>|<EntitySet\s+([^>]*?)>([\s\S]*?)<\/EntitySet>/g;
467
+ for (const match of xml.matchAll(entitySetRe)) {
468
+ const attrs = match[1] ?? match[2] ?? "";
469
+ const name = readAttr(attrs, "Name");
470
+ const type = readAttr(attrs, "EntityType");
471
+ if (!name || !type)
472
+ continue;
473
+ entitySets[name] = stripNamespace(type);
474
+ const inlineBody = match[3] ?? "";
475
+ const siblingBody = siblingByEntitySet.get(name) ?? "";
476
+ capabilities[name] = parseEntitySetAnnotations(`${inlineBody}\n${siblingBody}`);
477
+ siblingByEntitySet.delete(name);
478
+ }
479
+ // Capture annotations for entity sets that were not declared inline (e.g.
480
+ // when a server emits only the sibling block). The simple-name suffix
481
+ // from the Target still keys a capability entry so callers can find it.
482
+ for (const [setName, body] of siblingByEntitySet) {
483
+ capabilities[setName] = parseEntitySetAnnotations(body);
484
+ }
485
+ // Entity types: <EntityType Name="FooEntity">…<Key><PropertyRef Name="Id"/></Key><Property…/>…</EntityType>
486
+ const entityTypeRe = /<EntityType\s+([^>]*?)>([\s\S]*?)<\/EntityType>/g;
487
+ for (const match of xml.matchAll(entityTypeRe)) {
488
+ const attrs = match[1] ?? "";
489
+ const body = match[2] ?? "";
490
+ const name = readAttr(attrs, "Name");
491
+ if (!name)
492
+ continue;
493
+ const keyNames = [];
494
+ const keyBlock = /<Key>([\s\S]*?)<\/Key>/.exec(body);
495
+ if (keyBlock) {
496
+ for (const ref of keyBlock[1].matchAll(/<PropertyRef\s+([^>]*?)\/>/g)) {
497
+ const refName = readAttr(ref[1], "Name");
498
+ if (refName)
499
+ keyNames.push(refName);
500
+ }
501
+ }
502
+ keys[name] = keyNames;
503
+ const props = [];
504
+ for (const propMatch of body.matchAll(/<Property\s+([^>]*?)\/>|<Property\s+([^>]*?)>/g)) {
505
+ const propAttrs = propMatch[1] ?? propMatch[2] ?? "";
506
+ const propName = readAttr(propAttrs, "Name");
507
+ const propType = readAttr(propAttrs, "Type");
508
+ if (!propName || !propType)
509
+ continue;
510
+ const nullable = readAttr(propAttrs, "Nullable");
511
+ const sridAttr = readAttr(propAttrs, "SRID");
512
+ const isSpatial = propType.startsWith("Edm.Geography") || propType.startsWith("Edm.Geometry");
513
+ const info = { name: propName, type: propType };
514
+ if (nullable !== undefined)
515
+ info.nullable = nullable !== "false";
516
+ if (isSpatial)
517
+ info.isSpatial = true;
518
+ if (sridAttr !== undefined) {
519
+ const srid = Number(sridAttr);
520
+ if (Number.isFinite(srid))
521
+ info.srid = srid;
522
+ }
523
+ props.push(info);
524
+ }
525
+ fields[name] = props;
526
+ }
527
+ return { entitySets, keys, fields, capabilities };
528
+ }
529
+ /**
530
+ * Convenience: project the field schema for one entity set into the
531
+ * canonical `HonuaFieldInfo[]` shape reused by `Result.fields`.
532
+ */
533
+ export function odataFieldSchema(metadata, entitySet) {
534
+ const typeName = metadata.entitySets[entitySet];
535
+ if (!typeName)
536
+ return [];
537
+ const props = metadata.fields[typeName];
538
+ if (!props)
539
+ return [];
540
+ return props.map((p) => ({ name: p.name, type: edmToHonuaFieldType(p.type) }));
541
+ }
542
+ function edmToHonuaFieldType(edm) {
543
+ if (edm.startsWith("Edm.Geography") || edm.startsWith("Edm.Geometry"))
544
+ return "esriFieldTypeGeometry";
545
+ if (edm === "Edm.String")
546
+ return "esriFieldTypeString";
547
+ if (edm === "Edm.Int32")
548
+ return "esriFieldTypeInteger";
549
+ if (edm === "Edm.Int64")
550
+ return "esriFieldTypeOID";
551
+ if (edm === "Edm.Double")
552
+ return "esriFieldTypeDouble";
553
+ if (edm === "Edm.Single")
554
+ return "esriFieldTypeSingle";
555
+ if (edm === "Edm.Boolean")
556
+ return "esriFieldTypeSmallInteger";
557
+ if (edm === "Edm.DateTimeOffset" || edm === "Edm.Date")
558
+ return "esriFieldTypeDate";
559
+ return "esriFieldTypeString";
560
+ }
561
+ function parseEntitySetAnnotations(body) {
562
+ const out = {};
563
+ for (const match of body.matchAll(/<Annotation\s+([^>]*?)(?:\/>|>([\s\S]*?)<\/Annotation>)/g)) {
564
+ const attrs = match[1] ?? "";
565
+ const term = readAttr(attrs, "Term");
566
+ if (!term || !term.includes("Capabilities."))
567
+ continue;
568
+ const inner = (match[2] ?? "").trim();
569
+ const boolValue = readAttr(attrs, "Bool") ??
570
+ matchBool(inner) ??
571
+ matchPropertyValueBool(inner, "Insertable") ??
572
+ matchPropertyValueBool(inner, "Updatable") ??
573
+ matchPropertyValueBool(inner, "Deletable") ??
574
+ matchPropertyValueBool(inner, "Searchable") ??
575
+ matchPropertyValueBool(inner, "Filterable") ??
576
+ matchPropertyValueBool(inner, "Expandable") ??
577
+ matchPropertyValueBool(inner, "Selectable") ??
578
+ matchPropertyValueBool(inner, "Countable") ??
579
+ // Honua Server uses `Supported` for ChangeTracking and other
580
+ // capability records; without this fallback, `<PropertyValue
581
+ // Property="Supported" Bool="false"/>` would be silently treated
582
+ // as supported.
583
+ matchPropertyValueBool(inner, "Supported");
584
+ if (term.endsWith("InsertRestrictions")) {
585
+ out.insert = matchPropertyValueBool(inner, "Insertable") !== "false";
586
+ }
587
+ else if (term.endsWith("UpdateRestrictions")) {
588
+ out.update = matchPropertyValueBool(inner, "Updatable") !== "false";
589
+ }
590
+ else if (term.endsWith("DeleteRestrictions")) {
591
+ out.delete = matchPropertyValueBool(inner, "Deletable") !== "false";
592
+ }
593
+ else if (term.endsWith("BatchSupported") || term.endsWith("BatchSupport")) {
594
+ out.batch = boolValue !== "false";
595
+ }
596
+ else if (term.endsWith("FilterRestrictions") || term.endsWith("FilterFunctions")) {
597
+ out.filter = boolValue !== "false";
598
+ }
599
+ else if (term.endsWith("ExpandRestrictions")) {
600
+ out.expand = boolValue !== "false";
601
+ }
602
+ else if (term.endsWith("SelectSupport")) {
603
+ out.select = boolValue !== "false";
604
+ }
605
+ else if (term.endsWith("SearchRestrictions")) {
606
+ out.search = boolValue !== "false";
607
+ }
608
+ else if (term.endsWith("CountRestrictions")) {
609
+ out.count = boolValue !== "false";
610
+ }
611
+ else if (term.endsWith("ChangeTracking")) {
612
+ out.delta = boolValue !== "false";
613
+ }
614
+ else if (term.endsWith("ApplySupported") || term.endsWith("ApplyRestrictions")) {
615
+ out.apply = boolValue !== "false";
616
+ }
617
+ }
618
+ return out;
619
+ }
620
+ function readAttr(attrs, name) {
621
+ const re = new RegExp(`${name}\\s*=\\s*"([^"]*)"`);
622
+ const match = re.exec(attrs);
623
+ return match ? match[1] : undefined;
624
+ }
625
+ function stripNamespace(name) {
626
+ const idx = name.lastIndexOf(".");
627
+ return idx === -1 ? name : name.slice(idx + 1);
628
+ }
629
+ function matchBool(inner) {
630
+ const m = /<Bool>(true|false)<\/Bool>/.exec(inner);
631
+ return m ? m[1] : undefined;
632
+ }
633
+ function matchPropertyValueBool(inner, property) {
634
+ const re = new RegExp(`<PropertyValue\\s+Property="${property}"\\s+Bool="(true|false)"`);
635
+ const match = re.exec(inner);
636
+ return match ? match[1] : undefined;
637
+ }
638
+ const UNSUPPORTED_OPERATOR_RES = [
639
+ { token: "has", re: /\bhas\b/i },
640
+ { token: "in", re: /\bin\s*\(/i },
641
+ { token: "any", re: /\bany\s*\(/i },
642
+ { token: "all", re: /\ball\s*\(/i },
643
+ { token: "cast", re: /\bcast\s*\(/i },
644
+ { token: "isof", re: /\bisof\s*\(/i },
645
+ ];
646
+ // ── SQL → OData filter rewrite ────────────────────────────────
647
+ /**
648
+ * `Query.where` is documented as SQL-92 / CQL2 on the canonical surface.
649
+ * The OData `$filter` grammar is close to but not identical to SQL-92;
650
+ * this minimal rewriter handles the cases that would otherwise produce a
651
+ * server-side parse error rather than a translated query. Full SQL→OData
652
+ * translation is tracked as follow-up work in
653
+ * `docs/decisions/odata-library-selection.md`.
654
+ *
655
+ * Rewrites applied:
656
+ * - `IS NULL` / `IS NOT NULL` → `eq null` / `ne null`
657
+ * - `<col> = '...'`, `<col> = N` → `<col> eq '...'` / `<col> eq N`
658
+ * - SQL-style `<>` → `ne`
659
+ * - SQL comparison operators `>=` / `<=` / `>` / `<` → `ge` / `le` /
660
+ * `gt` / `lt` (Honua Server's OData lexer rejects bare punctuation
661
+ * comparisons as `InvalidQueryOption`).
662
+ *
663
+ * Predicates whose syntax already matches OData (e.g. `STATE eq 'CA'`)
664
+ * pass through unchanged. Unsupported operators (`HAS`, `IN`, `ANY`,
665
+ * `ALL`, `CAST`, `ISOF` from the parity matrix) are detected and
666
+ * rejected with a typed error rather than left to the server.
667
+ */
668
+ export function rewriteWhereToOdataFilter(where) {
669
+ if (where === "" || where === "1=1")
670
+ return "";
671
+ // Tokenize into alternating non-literal / literal spans so the rewriter
672
+ // and unsupported-operator detector skip the contents of quoted string
673
+ // literals. Without this split, a value like `NAME = 'A=B'` would have
674
+ // its embedded `=` rewritten to ` eq `, corrupting the literal before it
675
+ // reaches `$filter`. OData uses single-quoted strings with `''` for
676
+ // embedded quotes (per OData v4 §5.1.1.6.1).
677
+ const spans = splitOdataLiteralSpans(where);
678
+ for (let idx = 0; idx < spans.length; idx += 2) {
679
+ const segment = spans[idx];
680
+ if (segment === "")
681
+ continue;
682
+ for (const banned of UNSUPPORTED_OPERATOR_RES) {
683
+ if (banned.re.test(segment)) {
684
+ throw new Error(`odata: $filter does not support "${banned.token}"; the parity matrix lists this operator as unsupported on Honua Server.`);
685
+ }
686
+ }
687
+ let out = segment;
688
+ out = out.replace(/\bIS\s+NOT\s+NULL\b/gi, "ne null");
689
+ out = out.replace(/\bIS\s+NULL\b/gi, "eq null");
690
+ // `<>` must be rewritten before bare `<` / `>` so the not-equal
691
+ // operator does not get split into `lt` / `gt`. Two-char operators
692
+ // (`>=` / `<=`) likewise come before bare `>` / `<`.
693
+ out = out.replace(/<>/g, "ne");
694
+ out = out.replace(/(\b\w+\b)\s*>=\s*/g, (_match, lhs) => `${lhs} ge `);
695
+ out = out.replace(/(\b\w+\b)\s*<=\s*/g, (_match, lhs) => `${lhs} le `);
696
+ out = out.replace(/(\b\w+\b)\s*>\s*/g, (_match, lhs) => `${lhs} gt `);
697
+ out = out.replace(/(\b\w+\b)\s*<\s*/g, (_match, lhs) => `${lhs} lt `);
698
+ out = out.replace(/(\b\w+\b)\s*=\s*/g, (_match, lhs) => `${lhs} eq `);
699
+ out = out.replace(/\bAND\b/gi, "and");
700
+ out = out.replace(/\bOR\b/gi, "or");
701
+ out = out.replace(/\bNOT\b/gi, "not");
702
+ spans[idx] = out;
703
+ }
704
+ return spans.join("").trim();
705
+ }
706
+ /**
707
+ * Split an OData `$filter`-style expression into alternating non-literal
708
+ * and literal spans. Even indices contain code; odd indices contain
709
+ * single-quoted string literals (with quotes preserved). Embedded `''`
710
+ * escape sequences inside a literal are kept verbatim. An unterminated
711
+ * literal is treated as if it ran to the end of the input.
712
+ */
713
+ function splitOdataLiteralSpans(input) {
714
+ const spans = [];
715
+ let buf = "";
716
+ let i = 0;
717
+ while (i < input.length) {
718
+ const ch = input[i];
719
+ if (ch !== "'") {
720
+ buf += ch;
721
+ i += 1;
722
+ continue;
723
+ }
724
+ spans.push(buf);
725
+ let literal = "'";
726
+ i += 1;
727
+ while (i < input.length) {
728
+ const c = input[i];
729
+ if (c === "'") {
730
+ // OData string literals double the quote to embed it.
731
+ if (input[i + 1] === "'") {
732
+ literal += "''";
733
+ i += 2;
734
+ continue;
735
+ }
736
+ literal += "'";
737
+ i += 1;
738
+ break;
739
+ }
740
+ literal += c;
741
+ i += 1;
742
+ }
743
+ spans.push(literal);
744
+ buf = "";
745
+ }
746
+ spans.push(buf);
747
+ return spans;
748
+ }
749
+ export function buildOdataSpatialFilter(spatialFilter, context = {}) {
750
+ const rel = spatialFilter.spatialRel ?? "esriSpatialRelIntersects";
751
+ if (rel !== "esriSpatialRelIntersects" &&
752
+ rel !== "esriSpatialRelEnvelopeIntersects" &&
753
+ rel !== "esriSpatialRelDistance") {
754
+ throw new Error(`odata: spatialRel "${rel}" is not supported; OData parity matrix only implements geo.intersects (intersects/envelope-intersects) and geo.distance.`);
755
+ }
756
+ const column = resolveGeometryColumn(context);
757
+ const wkt = geometryToWkt(spatialFilter.geometry, spatialFilter.geometryType);
758
+ const inputSrid = resolveInputSrid(spatialFilter.geometry, context);
759
+ const literal = wktLiteral(wkt, inputSrid);
760
+ if (rel === "esriSpatialRelDistance") {
761
+ if (typeof spatialFilter.distance !== "number" || !Number.isFinite(spatialFilter.distance)) {
762
+ throw new Error('odata: spatialRel "esriSpatialRelDistance" requires a numeric distance (meters for geography).');
763
+ }
764
+ return `geo.distance(${column},${literal}) le ${spatialFilter.distance}`;
765
+ }
766
+ return `geo.intersects(${column},${literal})`;
767
+ }
768
+ function resolveGeometryColumn(context) {
769
+ if (context.geometryColumn)
770
+ return context.geometryColumn;
771
+ const spatial = context.geometryFields?.find((f) => f.isSpatial);
772
+ if (!spatial) {
773
+ throw new Error("odata: cannot translate spatialFilter — no geometry column was supplied and metadata exposes no Edm.Geography field.");
774
+ }
775
+ return spatial.name;
776
+ }
777
+ function resolveInputSrid(geometry, context) {
778
+ if (context.inputSrid !== undefined && context.inputSrid !== null && context.inputSrid !== "") {
779
+ return context.inputSrid;
780
+ }
781
+ const sr = geometry.spatialReference;
782
+ if (sr?.wkid !== undefined && Number.isFinite(sr.wkid))
783
+ return sr.wkid;
784
+ if (sr?.latestWkid !== undefined && Number.isFinite(sr.latestWkid))
785
+ return sr.latestWkid;
786
+ // Prefer the SRID of the *selected* geometry column when a name was
787
+ // pinned by the caller — entity types with multiple spatial fields
788
+ // can declare different SRIDs per column, and the WKT must be stamped
789
+ // with the SRID of the column the predicate is being evaluated
790
+ // against. Falls back to the first spatial field only when no column
791
+ // was pinned (or the pinned column is absent from the supplied
792
+ // metadata fields).
793
+ const selected = context.geometryColumn !== undefined
794
+ ? context.geometryFields?.find((f) => f.name === context.geometryColumn)
795
+ : undefined;
796
+ if (selected?.srid !== undefined && Number.isFinite(selected.srid))
797
+ return selected.srid;
798
+ if (context.geometryColumn !== undefined && selected)
799
+ return undefined;
800
+ const spatial = context.geometryFields?.find((f) => f.isSpatial);
801
+ if (spatial?.srid !== undefined && Number.isFinite(spatial.srid))
802
+ return spatial.srid;
803
+ return undefined;
804
+ }
805
+ function wktLiteral(wkt, srid) {
806
+ if (srid === undefined || srid === null || srid === "") {
807
+ return `geography'${wkt}'`;
808
+ }
809
+ return `geography'SRID=${srid};${wkt}'`;
810
+ }
811
+ function geometryToWkt(geometry, geometryType) {
812
+ if (geometryType === "esriGeometryEnvelope") {
813
+ const { xmin, ymin, xmax, ymax } = geometry;
814
+ return `POLYGON((${xmin} ${ymin}, ${xmax} ${ymin}, ${xmax} ${ymax}, ${xmin} ${ymax}, ${xmin} ${ymin}))`;
815
+ }
816
+ if (geometryType === "esriGeometryPoint") {
817
+ const { x, y } = geometry;
818
+ return `POINT(${x} ${y})`;
819
+ }
820
+ if (geometryType === "esriGeometryPolygon") {
821
+ const rings = geometry.rings;
822
+ if (!Array.isArray(rings) || rings.length === 0) {
823
+ throw new Error("odata: polygon spatialFilter has no rings");
824
+ }
825
+ const inner = rings.map((ring) => `(${ring.map((pt) => `${pt[0]} ${pt[1]}`).join(", ")})`).join(", ");
826
+ return `POLYGON(${inner})`;
827
+ }
828
+ if (geometryType === "esriGeometryPolyline") {
829
+ const paths = geometry.paths;
830
+ if (!Array.isArray(paths) || paths.length === 0) {
831
+ throw new Error("odata: polyline spatialFilter has no paths");
832
+ }
833
+ const path = paths[0];
834
+ return `LINESTRING(${path.map((pt) => `${pt[0]} ${pt[1]}`).join(", ")})`;
835
+ }
836
+ throw new Error(`odata: spatialFilter.geometryType "${geometryType}" is not supported.`);
837
+ }