@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
@@ -0,0 +1,883 @@
1
+ /**
2
+ * Canonical typed query filter — the protocol-neutral semantic filter AST that
3
+ * `Source.query()` accepts on the stable surface, plus the per-protocol
4
+ * compilers that lower it onto each wire dialect.
5
+ *
6
+ * The AST mirrors the experimental `/query-planner` semantic filter (comparison,
7
+ * list, range, null, pattern, spatial, temporal, boolean composition) with two
8
+ * deliberate contract-layer differences:
9
+ *
10
+ * - Spatial predicates carry the same canonical geometry value the rest of the
11
+ * contract uses (`SpatialFilter`-shaped `geometry` + `geometryType`, produced
12
+ * by `envelope()` / `point()` / `polygon()`), so a filter composes with
13
+ * `Query.spatialFilter` without a second geometry vocabulary.
14
+ * - Compilation is schema-free. The planner's semantic compilers verify a
15
+ * `SourceSchemaV2` before emitting; the canonical surface has no verified
16
+ * schema at query time, so literals are typed by the value the caller wrote.
17
+ *
18
+ * Every compiler in this module fails closed: a construct the target protocol
19
+ * cannot express exactly throws `HonuaCapabilityNotSupportedError` naming the
20
+ * construct and the protocol. Nothing is silently dropped and nothing is
21
+ * silently widened; a widening that is genuinely safe (the GeoParquet bbox
22
+ * reduction) is reported through `Result.degraded` by its adapter.
23
+ *
24
+ * @module
25
+ */
26
+ import { HonuaCapabilityNotSupportedError } from "../core/errors.js";
27
+ // ── Builders ──────────────────────────────────────────────────
28
+ function property(name) {
29
+ return { kind: "property", name };
30
+ }
31
+ function literal(value) {
32
+ return { kind: "literal", value };
33
+ }
34
+ /**
35
+ * Ergonomic constructors for {@link QueryFilterExpression}. Every helper returns
36
+ * plain data, so a filter can equally be written as an object literal, stored as
37
+ * JSON, or produced by the experimental semantic builder.
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * import { envelope, queryFilter } from "@honua/sdk-js";
42
+ *
43
+ * const filter = queryFilter.and(
44
+ * queryFilter.eq("STATUS", "open"),
45
+ * queryFilter.gt("SEVERITY", 3),
46
+ * queryFilter.spatial("intersects", envelope(-158.5, 21.2, -157.6, 21.7)),
47
+ * );
48
+ * ```
49
+ */
50
+ export const queryFilter = /* @__PURE__ */ Object.freeze({
51
+ eq: (field, value) => ({
52
+ kind: "comparison",
53
+ operator: "eq",
54
+ left: property(field),
55
+ right: literal(value),
56
+ }),
57
+ ne: (field, value) => ({
58
+ kind: "comparison",
59
+ operator: "ne",
60
+ left: property(field),
61
+ right: literal(value),
62
+ }),
63
+ lt: (field, value) => ({
64
+ kind: "comparison",
65
+ operator: "lt",
66
+ left: property(field),
67
+ right: literal(value),
68
+ }),
69
+ lte: (field, value) => ({
70
+ kind: "comparison",
71
+ operator: "lte",
72
+ left: property(field),
73
+ right: literal(value),
74
+ }),
75
+ gt: (field, value) => ({
76
+ kind: "comparison",
77
+ operator: "gt",
78
+ left: property(field),
79
+ right: literal(value),
80
+ }),
81
+ gte: (field, value) => ({
82
+ kind: "comparison",
83
+ operator: "gte",
84
+ left: property(field),
85
+ right: literal(value),
86
+ }),
87
+ isIn: (field, values) => ({
88
+ kind: "list",
89
+ operator: "in",
90
+ operand: property(field),
91
+ values: values.map(literal),
92
+ }),
93
+ between: (field, lower, upper) => ({
94
+ kind: "range",
95
+ operator: "between",
96
+ operand: property(field),
97
+ lower: literal(lower),
98
+ upper: literal(upper),
99
+ }),
100
+ isNull: (field) => ({ kind: "null", operator: "is-null", operand: property(field) }),
101
+ isNotNull: (field) => ({
102
+ kind: "null",
103
+ operator: "is-not-null",
104
+ operand: property(field),
105
+ }),
106
+ like: (field, pattern, options = {}) => ({
107
+ kind: "pattern",
108
+ operator: "like",
109
+ operand: property(field),
110
+ pattern,
111
+ ...(options.caseSensitive !== undefined ? { caseSensitive: options.caseSensitive } : {}),
112
+ }),
113
+ spatial: (operator, geometry, options = {}) => ({
114
+ kind: "spatial",
115
+ operator,
116
+ geometry: { geometry: geometry.geometry, geometryType: geometry.geometryType },
117
+ ...(options.property !== undefined ? { property: options.property } : {}),
118
+ }),
119
+ before: (field, instant) => ({
120
+ kind: "temporal",
121
+ operator: "before",
122
+ operand: property(field),
123
+ value: { kind: "temporal-literal", valueType: "instant", value: instant },
124
+ }),
125
+ after: (field, instant) => ({
126
+ kind: "temporal",
127
+ operator: "after",
128
+ operand: property(field),
129
+ value: { kind: "temporal-literal", valueType: "instant", value: instant },
130
+ }),
131
+ during: (field, start, end) => ({
132
+ kind: "temporal",
133
+ operator: "during",
134
+ operand: property(field),
135
+ value: { kind: "temporal-literal", valueType: "interval", value: [start, end] },
136
+ }),
137
+ timeIntersects: (field, start, end) => ({
138
+ kind: "temporal",
139
+ operator: "time-intersects",
140
+ operand: property(field),
141
+ value: { kind: "temporal-literal", valueType: "interval", value: [start, end] },
142
+ }),
143
+ and: (...args) => ({
144
+ kind: "boolean",
145
+ operator: "and",
146
+ args,
147
+ }),
148
+ or: (...args) => ({
149
+ kind: "boolean",
150
+ operator: "or",
151
+ args,
152
+ }),
153
+ not: (arg) => ({ kind: "not", arg }),
154
+ });
155
+ // ── Validation ────────────────────────────────────────────────
156
+ /** Structural bounds mirroring the semantic planner's parser limits. */
157
+ export const MAX_QUERY_FILTER_DEPTH = 32;
158
+ export const MAX_QUERY_FILTER_NODES = 512;
159
+ const IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
160
+ const QUALIFIED_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*(?:[.:][A-Za-z_][A-Za-z0-9_]*)*$/;
161
+ const RFC3339_PATTERN = /^\d{4}-\d{2}-\d{2}(?:[Tt ]\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:[Zz]|[+-]\d{2}:\d{2})?)?$/;
162
+ /** Fail closed, naming the construct and the protocol that cannot express it. */
163
+ export function refuseQueryFilterConstruct(construct, ctx) {
164
+ throw new HonuaCapabilityNotSupportedError(construct, ctx.protocol, ctx.sourceId, {
165
+ context: { construct, filter: true },
166
+ });
167
+ }
168
+ /**
169
+ * Fail closed because this source's evaluated capability evidence excludes the
170
+ * operator, even though its protocol can express it. The refusal names the same
171
+ * construct as a protocol refusal so callers branch on one vocabulary, and
172
+ * stamps `constraint: "capability-profile"` so the two causes stay
173
+ * distinguishable without parsing messages.
174
+ */
175
+ function refuseUnsupportedOperator(construct, ctx) {
176
+ throw new HonuaCapabilityNotSupportedError(construct, ctx.protocol, ctx.sourceId, {
177
+ context: { construct, filter: true, constraint: ctx.constraintSource ?? "capability-profile" },
178
+ });
179
+ }
180
+ /** Gate one operator against the source's evaluated allow list. */
181
+ function assertOperatorAllowed(operator, construct, ctx) {
182
+ if (ctx.supportedFilterOperators && !ctx.supportedFilterOperators.includes(operator)) {
183
+ refuseUnsupportedOperator(construct, ctx);
184
+ }
185
+ }
186
+ const QUERY_FILTER_OPERATORS = new Set([
187
+ "eq",
188
+ "ne",
189
+ "lt",
190
+ "lte",
191
+ "gt",
192
+ "gte",
193
+ "in",
194
+ "between",
195
+ "is-null",
196
+ "is-not-null",
197
+ "like",
198
+ "and",
199
+ "or",
200
+ "not",
201
+ "intersects",
202
+ "contains",
203
+ "within",
204
+ "crosses",
205
+ "touches",
206
+ "overlaps",
207
+ "bbox-intersects",
208
+ "before",
209
+ "after",
210
+ "during",
211
+ "time-intersects",
212
+ ]);
213
+ const QUERY_FILTER_SPATIAL_PREDICATES = new Set([
214
+ "intersects",
215
+ "contains",
216
+ "within",
217
+ "crosses",
218
+ "touches",
219
+ "overlaps",
220
+ "bbox-intersects",
221
+ ]);
222
+ const QUERY_FILTER_TEMPORAL_PREDICATES = new Set([
223
+ "before",
224
+ "after",
225
+ "during",
226
+ "time-intersects",
227
+ ]);
228
+ /**
229
+ * Narrow the evaluated `CapabilityConstraints` of one capability into the
230
+ * allow lists this compiler understands.
231
+ *
232
+ * Vocabularies that the canonical AST cannot name (distance predicates,
233
+ * extension identifiers) are dropped rather than translated, which fails closed
234
+ * by construction: a profile that advertises only constructs outside the AST
235
+ * yields an empty allow list, and every node of that kind is refused. Omitted
236
+ * constraint arrays stay `undefined` — "the evidence says nothing", not "the
237
+ * source supports nothing".
238
+ */
239
+ export function queryFilterConstraints(constraints) {
240
+ const operators = constraints.filterOperators?.filter((value) => QUERY_FILTER_OPERATORS.has(value));
241
+ const spatial = constraints.spatialPredicates?.filter((value) => QUERY_FILTER_SPATIAL_PREDICATES.has(value));
242
+ const temporal = constraints.temporalPredicates?.filter((value) => QUERY_FILTER_TEMPORAL_PREDICATES.has(value));
243
+ return {
244
+ ...(operators ? { supportedFilterOperators: operators } : {}),
245
+ ...(spatial ? { supportedSpatialPredicates: spatial } : {}),
246
+ ...(temporal ? { supportedTemporalPredicates: temporal } : {}),
247
+ };
248
+ }
249
+ /**
250
+ * Validate a caller-supplied filter before any dialect sees it. Rejects
251
+ * malformed nodes, unbounded nesting, and identifiers that are not plain
252
+ * source field names (the only identifiers every dialect can quote safely).
253
+ */
254
+ export function assertQueryFilter(filter, ctx) {
255
+ let nodes = 0;
256
+ const visit = (node, depth) => {
257
+ nodes += 1;
258
+ if (depth > MAX_QUERY_FILTER_DEPTH)
259
+ refuseQueryFilterConstruct("filter.depth", ctx);
260
+ if (nodes > MAX_QUERY_FILTER_NODES)
261
+ refuseQueryFilterConstruct("filter.size", ctx);
262
+ if (!node || typeof node !== "object")
263
+ refuseQueryFilterConstruct("filter.node", ctx);
264
+ switch (node.kind) {
265
+ case "comparison":
266
+ assertFieldName(node.left?.name, ctx);
267
+ assertScalar(node.right, ctx);
268
+ assertOperatorAllowed(node.operator, `filter.comparison.${node.operator}`, ctx);
269
+ return;
270
+ case "list":
271
+ assertFieldName(node.operand?.name, ctx);
272
+ if (!Array.isArray(node.values) || node.values.length === 0)
273
+ refuseQueryFilterConstruct("filter.list.values", ctx);
274
+ for (const value of node.values)
275
+ assertScalar(value, ctx);
276
+ assertOperatorAllowed("in", "filter.list.in", ctx);
277
+ return;
278
+ case "range":
279
+ assertFieldName(node.operand?.name, ctx);
280
+ assertScalar(node.lower, ctx);
281
+ assertScalar(node.upper, ctx);
282
+ assertOperatorAllowed("between", "filter.range.between", ctx);
283
+ return;
284
+ case "null":
285
+ assertFieldName(node.operand?.name, ctx);
286
+ assertOperatorAllowed(node.operator, `filter.null.${node.operator}`, ctx);
287
+ return;
288
+ case "pattern":
289
+ assertFieldName(node.operand?.name, ctx);
290
+ if (typeof node.pattern !== "string" || node.pattern.length === 0)
291
+ refuseQueryFilterConstruct("filter.pattern.value", ctx);
292
+ assertPrintable(node.pattern, "filter.pattern.value", ctx);
293
+ assertOperatorAllowed("like", "filter.pattern.like", ctx);
294
+ return;
295
+ case "spatial":
296
+ if (node.property !== undefined)
297
+ assertFieldName(node.property, ctx, true);
298
+ if (!node.geometry || typeof node.geometry !== "object" || typeof node.geometry.geometry !== "object") {
299
+ refuseQueryFilterConstruct("filter.spatial.geometry", ctx);
300
+ }
301
+ if (ctx.supportedSpatialPredicates && !ctx.supportedSpatialPredicates.includes(node.operator)) {
302
+ refuseUnsupportedOperator(`filter.spatial.${node.operator}`, ctx);
303
+ }
304
+ assertOperatorAllowed(node.operator, `filter.spatial.${node.operator}`, ctx);
305
+ return;
306
+ case "temporal":
307
+ assertFieldName(node.operand?.name, ctx, true);
308
+ assertTemporalLiteral(node.value, ctx);
309
+ if (ctx.supportedTemporalPredicates && !ctx.supportedTemporalPredicates.includes(node.operator)) {
310
+ refuseUnsupportedOperator(`filter.temporal.${node.operator}`, ctx);
311
+ }
312
+ assertOperatorAllowed(node.operator, `filter.temporal.${node.operator}`, ctx);
313
+ return;
314
+ case "boolean":
315
+ if (!Array.isArray(node.args) || node.args.length === 0)
316
+ refuseQueryFilterConstruct("filter.boolean.args", ctx);
317
+ assertOperatorAllowed(node.operator, `filter.boolean.${node.operator}`, ctx);
318
+ for (const arg of node.args)
319
+ visit(arg, depth + 1);
320
+ return;
321
+ case "not":
322
+ assertOperatorAllowed("not", "filter.not", ctx);
323
+ visit(node.arg, depth + 1);
324
+ return;
325
+ default:
326
+ refuseQueryFilterConstruct("filter.node", ctx);
327
+ }
328
+ };
329
+ visit(filter, 1);
330
+ }
331
+ function assertFieldName(name, ctx, qualified = false) {
332
+ const pattern = qualified ? QUALIFIED_IDENTIFIER_PATTERN : IDENTIFIER_PATTERN;
333
+ if (typeof name !== "string" || name.length === 0 || name.length > 128 || !pattern.test(name)) {
334
+ refuseQueryFilterConstruct("filter.property.name", ctx);
335
+ }
336
+ }
337
+ function assertScalar(node, ctx) {
338
+ if (!node || typeof node !== "object" || node.kind !== "literal") {
339
+ refuseQueryFilterConstruct("filter.literal", ctx);
340
+ }
341
+ const value = node.value;
342
+ if (value === null || typeof value === "boolean")
343
+ return;
344
+ if (typeof value === "number") {
345
+ if (!Number.isFinite(value))
346
+ refuseQueryFilterConstruct("filter.literal.number", ctx);
347
+ return;
348
+ }
349
+ if (typeof value === "string") {
350
+ assertPrintable(value, "filter.literal.text", ctx);
351
+ return;
352
+ }
353
+ refuseQueryFilterConstruct("filter.literal", ctx);
354
+ }
355
+ function assertPrintable(value, construct, ctx) {
356
+ if (value.length > 4096)
357
+ refuseQueryFilterConstruct(construct, ctx);
358
+ for (let index = 0; index < value.length; index += 1) {
359
+ const code = value.charCodeAt(index);
360
+ if (code <= 0x1f || code === 0x7f)
361
+ refuseQueryFilterConstruct(construct, ctx);
362
+ }
363
+ }
364
+ function assertTemporalLiteral(value, ctx) {
365
+ const node = value;
366
+ if (!node || node.kind !== "temporal-literal")
367
+ refuseQueryFilterConstruct("filter.temporal.literal", ctx);
368
+ if (node.valueType === "instant") {
369
+ assertInstant(node.value, ctx);
370
+ return;
371
+ }
372
+ if (node.valueType === "interval") {
373
+ if (!Array.isArray(node.value) || node.value.length !== 2)
374
+ refuseQueryFilterConstruct("filter.temporal.interval", ctx);
375
+ assertInstant(node.value[0], ctx);
376
+ assertInstant(node.value[1], ctx);
377
+ return;
378
+ }
379
+ refuseQueryFilterConstruct("filter.temporal.literal", ctx);
380
+ }
381
+ function assertInstant(value, ctx) {
382
+ if (typeof value !== "string" || !RFC3339_PATTERN.test(value))
383
+ refuseQueryFilterConstruct("filter.temporal.instant", ctx);
384
+ }
385
+ /** Validate `Query.temporalFilter` independently of the filter AST. */
386
+ export function assertTemporalFilter(temporal, ctx) {
387
+ if (!temporal || typeof temporal !== "object")
388
+ refuseQueryFilterConstruct("temporalFilter", ctx);
389
+ if (temporal.field !== undefined)
390
+ assertFieldName(temporal.field, ctx, true);
391
+ if (temporal.kind === "instant") {
392
+ assertInstant(temporal.instant, ctx);
393
+ return;
394
+ }
395
+ if (temporal.kind === "interval") {
396
+ if (temporal.start === null && temporal.end === null)
397
+ refuseQueryFilterConstruct("temporalFilter.interval", ctx);
398
+ if (temporal.start !== null)
399
+ assertInstant(temporal.start, ctx);
400
+ if (temporal.end !== null)
401
+ assertInstant(temporal.end, ctx);
402
+ return;
403
+ }
404
+ refuseQueryFilterConstruct("temporalFilter", ctx);
405
+ }
406
+ /**
407
+ * Lower a field-bound `Query.temporalFilter` into a filter node so every
408
+ * dialect compiles temporal intent through the same path. Returns `undefined`
409
+ * when the filter targets the source's own time dimension (no `field`), which
410
+ * the protocol time parameter carries instead.
411
+ */
412
+ export function temporalFilterToExpression(temporal, ctx) {
413
+ assertTemporalFilter(temporal, ctx);
414
+ if (temporal.field === undefined)
415
+ return undefined;
416
+ const field = temporal.field;
417
+ if (temporal.kind === "instant") {
418
+ return {
419
+ kind: "temporal",
420
+ operator: "time-intersects",
421
+ operand: { kind: "property", name: field },
422
+ value: { kind: "temporal-literal", valueType: "instant", value: temporal.instant },
423
+ };
424
+ }
425
+ if (temporal.start !== null && temporal.end !== null) {
426
+ return {
427
+ kind: "temporal",
428
+ operator: "during",
429
+ operand: { kind: "property", name: field },
430
+ value: { kind: "temporal-literal", valueType: "interval", value: [temporal.start, temporal.end] },
431
+ };
432
+ }
433
+ // A half-open canonical interval keeps its closed bound INCLUSIVE, so it
434
+ // lowers to an ordered comparison rather than the strict `after` / `before`
435
+ // temporal predicates. `[start, ..)` means "at or after start".
436
+ if (temporal.start !== null) {
437
+ return {
438
+ kind: "comparison",
439
+ operator: "gte",
440
+ left: { kind: "property", name: field },
441
+ right: { kind: "literal", value: temporal.start },
442
+ };
443
+ }
444
+ if (temporal.end !== null) {
445
+ return {
446
+ kind: "comparison",
447
+ operator: "lte",
448
+ left: { kind: "property", name: field },
449
+ right: { kind: "literal", value: temporal.end },
450
+ };
451
+ }
452
+ return refuseQueryFilterConstruct("temporalFilter.interval", ctx);
453
+ }
454
+ /** Combine a filter and a lowered temporal predicate into one expression. */
455
+ export function andQueryFilters(...parts) {
456
+ const present = parts.filter((part) => part !== undefined);
457
+ if (present.length === 0)
458
+ return undefined;
459
+ if (present.length === 1)
460
+ return present[0];
461
+ return { kind: "boolean", operator: "and", args: present };
462
+ }
463
+ // ── Shared literal helpers ────────────────────────────────────
464
+ function sqlText(value) {
465
+ return `'${value.replaceAll("'", "''")}'`;
466
+ }
467
+ function sqlTimestamp(instant) {
468
+ return `TIMESTAMP ${sqlText(instant)}`;
469
+ }
470
+ function isTemporalLike(value) {
471
+ return typeof value === "string" && RFC3339_PATTERN.test(value) && value.length >= 10 && value.includes("-");
472
+ }
473
+ /** SQL-92 / DuckDB scalar literal. Temporal-shaped strings become timestamps. */
474
+ function sqlLiteral(node, temporal) {
475
+ const value = node.value;
476
+ if (value === null)
477
+ return "NULL";
478
+ if (typeof value === "boolean")
479
+ return value ? "1" : "0";
480
+ if (typeof value === "number")
481
+ return String(value);
482
+ return temporal && isTemporalLike(value) ? sqlTimestamp(value) : sqlText(value);
483
+ }
484
+ const SQL_COMPARISON = {
485
+ eq: "=",
486
+ ne: "<>",
487
+ lt: "<",
488
+ lte: "<=",
489
+ gt: ">",
490
+ gte: ">=",
491
+ };
492
+ const ODATA_COMPARISON = {
493
+ eq: "eq",
494
+ ne: "ne",
495
+ lt: "lt",
496
+ lte: "le",
497
+ gt: "gt",
498
+ gte: "ge",
499
+ };
500
+ /**
501
+ * Resolve a temporal node into bounds plus their strictness.
502
+ *
503
+ * `before` and `after` are *strict*: that is what CQL2 `T_BEFORE` / `T_AFTER`,
504
+ * FES `Before` / `After`, and the schema-verified planner compilers all mean, so
505
+ * the canonical lowering agrees with them rather than quietly widening the
506
+ * window by one instant. `during` and `time-intersects` keep both bounds
507
+ * inclusive, which is the closed-interval reading the reference evaluator and
508
+ * the conformance corpus use.
509
+ */
510
+ function temporalBounds(node, ctx) {
511
+ const value = node.value;
512
+ if (value.valueType === "instant") {
513
+ if (node.operator === "before")
514
+ return { end: { value: value.value, inclusive: false } };
515
+ if (node.operator === "after")
516
+ return { start: { value: value.value, inclusive: false } };
517
+ return { instant: value.value };
518
+ }
519
+ if (node.operator === "before")
520
+ return { end: { value: value.value[0], inclusive: false } };
521
+ if (node.operator === "after")
522
+ return { start: { value: value.value[1], inclusive: false } };
523
+ if (node.operator === "during" || node.operator === "time-intersects") {
524
+ return {
525
+ start: { value: value.value[0], inclusive: true },
526
+ end: { value: value.value[1], inclusive: true },
527
+ };
528
+ }
529
+ return refuseQueryFilterConstruct(`filter.temporal.${node.operator}`, ctx);
530
+ }
531
+ /** Esri spatial relationships the canonical predicates map onto. @internal */
532
+ export const GEOSERVICES_SPATIAL_REL = {
533
+ intersects: "esriSpatialRelIntersects",
534
+ contains: "esriSpatialRelContains",
535
+ within: "esriSpatialRelWithin",
536
+ crosses: "esriSpatialRelCrosses",
537
+ touches: "esriSpatialRelTouches",
538
+ overlaps: "esriSpatialRelOverlaps",
539
+ // `bbox-intersects` means "the feature intersects this envelope", which is an
540
+ // ordinary intersects test against an envelope operand — the same lowering the
541
+ // schema-verified planner compiler uses. `esriSpatialRelEnvelopeIntersects`
542
+ // would instead compare the *feature's* envelope and return a superset.
543
+ "bbox-intersects": "esriSpatialRelIntersects",
544
+ };
545
+ /**
546
+ * Compile to the GeoServices `where` clause plus (at most one) spatial request
547
+ * parameter set. GeoServices expresses exactly one geometry per `query`
548
+ * request, and only conjunctively, so a spatial predicate nested inside `OR` /
549
+ * `NOT` or a second spatial predicate is refused rather than approximated.
550
+ */
551
+ export function compileQueryFilterToSql92(filter, ctx) {
552
+ assertQueryFilter(filter, ctx);
553
+ const spatial = [];
554
+ const dialect = sql92Dialect(ctx);
555
+ const where = splitConjunctiveSpatial(filter, ctx, spatial, (node) => emitSql(node, ctx, dialect));
556
+ if (spatial.length > 1)
557
+ refuseQueryFilterConstruct("filter.spatial.multiple", ctx);
558
+ const node = spatial[0];
559
+ return {
560
+ ...(where !== undefined ? { where } : {}),
561
+ ...(node ? { spatialFilter: geoServicesSpatialFilter(node, ctx) } : {}),
562
+ };
563
+ }
564
+ function geoServicesSpatialFilter(node, ctx) {
565
+ const spatialRel = GEOSERVICES_SPATIAL_REL[node.operator];
566
+ if (!spatialRel)
567
+ refuseQueryFilterConstruct(`filter.spatial.${node.operator}`, ctx);
568
+ return {
569
+ geometry: node.geometry.geometry,
570
+ geometryType: node.geometry.geometryType,
571
+ spatialRel,
572
+ };
573
+ }
574
+ /**
575
+ * Split a top-level conjunction into "spatial predicates carried out of band"
576
+ * and "everything else, compiled by `emit`". A spatial node under `OR` / `NOT`
577
+ * cannot be carried out of band without changing the result set, so it throws.
578
+ */
579
+ function splitConjunctiveSpatial(filter, ctx, spatial, emit) {
580
+ if (filter.kind === "spatial") {
581
+ spatial.push(filter);
582
+ return undefined;
583
+ }
584
+ if (filter.kind === "boolean" && filter.operator === "and") {
585
+ const parts = [];
586
+ for (const arg of filter.args) {
587
+ const compiled = splitConjunctiveSpatial(arg, ctx, spatial, emit);
588
+ if (compiled !== undefined)
589
+ parts.push(compiled);
590
+ }
591
+ if (parts.length === 0)
592
+ return undefined;
593
+ return parts.length === 1 ? parts[0] : parts.map((part) => `(${part})`).join(" AND ");
594
+ }
595
+ if (containsSpatial(filter))
596
+ refuseQueryFilterConstruct("filter.spatial.disjunction", ctx);
597
+ return emit(filter);
598
+ }
599
+ /** True when a filter references geometry anywhere in its tree. */
600
+ export function queryFilterHasSpatial(filter) {
601
+ return containsSpatial(filter);
602
+ }
603
+ function containsSpatial(filter) {
604
+ if (filter.kind === "spatial")
605
+ return true;
606
+ if (filter.kind === "boolean")
607
+ return filter.args.some(containsSpatial);
608
+ if (filter.kind === "not")
609
+ return containsSpatial(filter.arg);
610
+ return false;
611
+ }
612
+ function emitSql(filter, ctx, dialect) {
613
+ const field = (name) => dialect.identifier(name);
614
+ switch (filter.kind) {
615
+ case "comparison": {
616
+ const left = field(filter.left.name);
617
+ if (filter.right.value === null) {
618
+ return filter.operator === "eq"
619
+ ? `${left} IS NULL`
620
+ : filter.operator === "ne"
621
+ ? `${left} IS NOT NULL`
622
+ : refuseQueryFilterConstruct(`filter.comparison.${filter.operator}.null`, ctx);
623
+ }
624
+ return `${left} ${SQL_COMPARISON[filter.operator]} ${sqlLiteral(filter.right, true)}`;
625
+ }
626
+ case "list":
627
+ return `${field(filter.operand.name)} IN (${filter.values.map((value) => sqlLiteral(value, true)).join(", ")})`;
628
+ case "range":
629
+ return `${field(filter.operand.name)} BETWEEN ${sqlLiteral(filter.lower, true)} AND ${sqlLiteral(filter.upper, true)}`;
630
+ case "null":
631
+ return `${field(filter.operand.name)} IS ${filter.operator === "is-null" ? "NULL" : "NOT NULL"}`;
632
+ case "pattern": {
633
+ const pattern = sqlText(filter.pattern);
634
+ const operand = field(filter.operand.name);
635
+ return filter.caseSensitive === false
636
+ ? dialect.caseInsensitiveLike(operand, pattern)
637
+ : `${operand} LIKE ${pattern}`;
638
+ }
639
+ case "temporal": {
640
+ const bounds = temporalBounds(filter, ctx);
641
+ const operand = field(filter.operand.name);
642
+ if (bounds.instant !== undefined)
643
+ return `${operand} = ${sqlTimestamp(bounds.instant)}`;
644
+ const parts = [];
645
+ if (bounds.start !== undefined) {
646
+ parts.push(`${operand} ${bounds.start.inclusive ? ">=" : ">"} ${sqlTimestamp(bounds.start.value)}`);
647
+ }
648
+ if (bounds.end !== undefined) {
649
+ parts.push(`${operand} ${bounds.end.inclusive ? "<=" : "<"} ${sqlTimestamp(bounds.end.value)}`);
650
+ }
651
+ return parts.length === 1 ? parts[0] : `(${parts.join(" AND ")})`;
652
+ }
653
+ case "boolean":
654
+ return filter.args
655
+ .map((arg) => `(${emitSql(arg, ctx, dialect)})`)
656
+ .join(filter.operator === "and" ? " AND " : " OR ");
657
+ case "not":
658
+ return `NOT (${emitSql(filter.arg, ctx, dialect)})`;
659
+ case "spatial":
660
+ return dialect.spatial(filter);
661
+ default:
662
+ return refuseQueryFilterConstruct("filter.node", ctx);
663
+ }
664
+ }
665
+ /** GeoServices carries geometry as request parameters, never inside the SQL. */
666
+ function sql92Dialect(ctx) {
667
+ return {
668
+ identifier: (name) => name,
669
+ caseInsensitiveLike: (operand, pattern) => `UPPER(${operand}) LIKE UPPER(${pattern})`,
670
+ spatial: () => refuseQueryFilterConstruct("filter.spatial.disjunction", ctx),
671
+ };
672
+ }
673
+ /**
674
+ * Compile to a DuckDB `WHERE` fragment. Spatial predicates are emitted through
675
+ * `emitSpatial`, which the GeoParquet driver supplies (it owns the geometry
676
+ * column, its encoding, and the bbox reduction that `Result.degraded` reports).
677
+ */
678
+ export function compileQueryFilterToDuckDbSql(filter, ctx, emitSpatial) {
679
+ assertQueryFilter(filter, ctx);
680
+ let bboxApproximated = false;
681
+ const sql = emitSql(filter, ctx, {
682
+ identifier: (name) => `"${name}"`,
683
+ caseInsensitiveLike: (operand, pattern) => `${operand} ILIKE ${pattern}`,
684
+ spatial: (node) => {
685
+ const compiled = emitSpatial(node);
686
+ if (compiled.bboxApproximated)
687
+ bboxApproximated = true;
688
+ return compiled.sql;
689
+ },
690
+ });
691
+ return { sql, bboxApproximated };
692
+ }
693
+ // ── CQL2 text (OGC API Features / Records / STAC) ─────────────
694
+ const CQL2_SPATIAL = {
695
+ intersects: "S_INTERSECTS",
696
+ contains: "S_CONTAINS",
697
+ within: "S_WITHIN",
698
+ crosses: "S_CROSSES",
699
+ touches: "S_TOUCHES",
700
+ overlaps: "S_OVERLAPS",
701
+ "bbox-intersects": "S_INTERSECTS",
702
+ };
703
+ /**
704
+ * Compile to CQL2 text (OGC API — Features Part 3 / STAC filter extension).
705
+ * `geometryToWkt` is injected so the WKT writer stays in the OData module that
706
+ * already owns it instead of being duplicated here.
707
+ */
708
+ export function compileQueryFilterToCql2Text(filter, ctx, geometryToWkt) {
709
+ assertQueryFilter(filter, ctx);
710
+ const emit = (node) => {
711
+ switch (node.kind) {
712
+ case "comparison": {
713
+ if (node.right.value === null) {
714
+ return node.operator === "eq"
715
+ ? `${node.left.name} IS NULL`
716
+ : node.operator === "ne"
717
+ ? `${node.left.name} IS NOT NULL`
718
+ : refuseQueryFilterConstruct(`filter.comparison.${node.operator}.null`, ctx);
719
+ }
720
+ return `${node.left.name} ${SQL_COMPARISON[node.operator]} ${cql2Literal(node.right)}`;
721
+ }
722
+ case "list":
723
+ return `${node.operand.name} IN (${node.values.map(cql2Literal).join(", ")})`;
724
+ case "range":
725
+ return `${node.operand.name} BETWEEN ${cql2Literal(node.lower)} AND ${cql2Literal(node.upper)}`;
726
+ case "null":
727
+ return `${node.operand.name} IS ${node.operator === "is-null" ? "NULL" : "NOT NULL"}`;
728
+ case "pattern":
729
+ return node.caseSensitive === false
730
+ ? `CASEI(${node.operand.name}) LIKE CASEI(${sqlText(node.pattern)})`
731
+ : `${node.operand.name} LIKE ${sqlText(node.pattern)}`;
732
+ case "spatial": {
733
+ const wkt = geometryToWkt(node.geometry.geometry, node.geometry.geometryType);
734
+ const property = node.property ?? ctx.geometryProperty ?? "geometry";
735
+ return `${CQL2_SPATIAL[node.operator]}(${property}, ${wkt})`;
736
+ }
737
+ case "temporal": {
738
+ const field = node.operand.name;
739
+ const value = node.value;
740
+ if (node.operator === "before") {
741
+ return `T_BEFORE(${field}, ${cql2Instant(value.valueType === "instant" ? value.value : value.value[0])})`;
742
+ }
743
+ if (node.operator === "after") {
744
+ return `T_AFTER(${field}, ${cql2Instant(value.valueType === "instant" ? value.value : value.value[1])})`;
745
+ }
746
+ if (value.valueType === "instant") {
747
+ return `T_INTERSECTS(${field}, ${cql2Instant(value.value)})`;
748
+ }
749
+ const interval = `INTERVAL('${value.value[0]}', '${value.value[1]}')`;
750
+ return node.operator === "during" ? `T_DURING(${field}, ${interval})` : `T_INTERSECTS(${field}, ${interval})`;
751
+ }
752
+ case "boolean":
753
+ return node.args.map((arg) => `(${emit(arg)})`).join(node.operator === "and" ? " AND " : " OR ");
754
+ case "not":
755
+ return `NOT (${emit(node.arg)})`;
756
+ default:
757
+ return refuseQueryFilterConstruct("filter.node", ctx);
758
+ }
759
+ };
760
+ return emit(filter);
761
+ }
762
+ function cql2Literal(node) {
763
+ const value = node.value;
764
+ if (value === null)
765
+ return "NULL";
766
+ if (typeof value === "boolean")
767
+ return value ? "TRUE" : "FALSE";
768
+ if (typeof value === "number")
769
+ return String(value);
770
+ return isTemporalLike(value) ? cql2Instant(value) : sqlText(value);
771
+ }
772
+ function cql2Instant(instant) {
773
+ return instant.length === 10 ? `DATE('${instant}')` : `TIMESTAMP('${instant}')`;
774
+ }
775
+ // ── OData v4 ($filter) ────────────────────────────────────────
776
+ /**
777
+ * Compile to an OData `$filter` expression. Spatial predicates are emitted
778
+ * through `emitSpatial` so the adapter's `$metadata`-aware geometry column and
779
+ * SRID resolution stay in one place.
780
+ */
781
+ export function compileQueryFilterToOData(filter, ctx, emitSpatial) {
782
+ assertQueryFilter(filter, ctx);
783
+ const emit = (node) => {
784
+ switch (node.kind) {
785
+ case "comparison":
786
+ return `${node.left.name} ${ODATA_COMPARISON[node.operator]} ${odataLiteral(node.right)}`;
787
+ case "list":
788
+ return `${node.operand.name} in (${node.values.map(odataLiteral).join(",")})`;
789
+ case "range":
790
+ return `(${node.operand.name} ge ${odataLiteral(node.lower)} and ${node.operand.name} le ${odataLiteral(node.upper)})`;
791
+ case "null":
792
+ return `${node.operand.name} ${node.operator === "is-null" ? "eq" : "ne"} null`;
793
+ case "pattern":
794
+ return odataPattern(node, ctx);
795
+ case "spatial":
796
+ return emitSpatial(node);
797
+ case "temporal": {
798
+ const bounds = temporalBounds(node, ctx);
799
+ const field = node.operand.name;
800
+ if (bounds.instant !== undefined)
801
+ return `${field} eq ${bounds.instant}`;
802
+ const parts = [];
803
+ if (bounds.start !== undefined) {
804
+ parts.push(`${field} ${bounds.start.inclusive ? "ge" : "gt"} ${bounds.start.value}`);
805
+ }
806
+ if (bounds.end !== undefined) {
807
+ parts.push(`${field} ${bounds.end.inclusive ? "le" : "lt"} ${bounds.end.value}`);
808
+ }
809
+ return parts.length === 1 ? parts[0] : `(${parts.join(" and ")})`;
810
+ }
811
+ case "boolean":
812
+ return node.args.map((arg) => `(${emit(arg)})`).join(node.operator === "and" ? " and " : " or ");
813
+ case "not":
814
+ return `not (${emit(node.arg)})`;
815
+ default:
816
+ return refuseQueryFilterConstruct("filter.node", ctx);
817
+ }
818
+ };
819
+ return emit(filter);
820
+ }
821
+ function odataLiteral(node) {
822
+ const value = node.value;
823
+ if (value === null)
824
+ return "null";
825
+ if (typeof value === "boolean")
826
+ return value ? "true" : "false";
827
+ if (typeof value === "number")
828
+ return String(value);
829
+ // Edm.DateTimeOffset literals are unquoted; every other string is quoted.
830
+ return isTemporalLike(value) && value.length > 10 ? value : sqlText(value);
831
+ }
832
+ /**
833
+ * OData has no `LIKE`. Only the three anchored shapes map exactly onto
834
+ * `startswith` / `endswith` / `contains`; anything else (an interior wildcard,
835
+ * a single-character `_`) is refused instead of being widened.
836
+ */
837
+ function odataPattern(node, ctx) {
838
+ if (node.caseSensitive === false)
839
+ refuseQueryFilterConstruct("filter.pattern.case-insensitive", ctx);
840
+ const pattern = node.pattern;
841
+ if (pattern.includes("_"))
842
+ refuseQueryFilterConstruct("filter.pattern.single-character-wildcard", ctx);
843
+ const leading = pattern.startsWith("%");
844
+ const trailing = pattern.endsWith("%");
845
+ const core = pattern.slice(leading ? 1 : 0, trailing ? pattern.length - 1 : pattern.length);
846
+ if (core.includes("%"))
847
+ refuseQueryFilterConstruct("filter.pattern.interior-wildcard", ctx);
848
+ const literalText = sqlText(core);
849
+ if (leading && trailing)
850
+ return `contains(${node.operand.name},${literalText})`;
851
+ if (trailing)
852
+ return `startswith(${node.operand.name},${literalText})`;
853
+ if (leading)
854
+ return `endswith(${node.operand.name},${literalText})`;
855
+ return `${node.operand.name} eq ${literalText}`;
856
+ }
857
+ // ── Protocol time parameters ──────────────────────────────────
858
+ /**
859
+ * GeoServices `time=` value. Instants serialize as epoch milliseconds and
860
+ * intervals as `start,end`; an open bound serializes as `null`, which the
861
+ * FeatureServer `time` parameter defines as "unbounded on that side".
862
+ */
863
+ export function compileTemporalFilterToGeoServicesTime(temporal, ctx) {
864
+ assertTemporalFilter(temporal, ctx);
865
+ if (temporal.kind === "instant")
866
+ return String(epochMillis(temporal.instant, ctx));
867
+ const start = temporal.start === null ? "null" : String(epochMillis(temporal.start, ctx));
868
+ const end = temporal.end === null ? "null" : String(epochMillis(temporal.end, ctx));
869
+ return `${start},${end}`;
870
+ }
871
+ /** OGC / STAC `datetime=` value (RFC 3339 instant or `start/end` interval). */
872
+ export function compileTemporalFilterToOgcDatetime(temporal, ctx) {
873
+ assertTemporalFilter(temporal, ctx);
874
+ if (temporal.kind === "instant")
875
+ return temporal.instant;
876
+ return `${temporal.start ?? ".."}/${temporal.end ?? ".."}`;
877
+ }
878
+ function epochMillis(instant, ctx) {
879
+ const millis = Date.parse(instant.length === 10 ? `${instant}T00:00:00Z` : instant);
880
+ if (!Number.isFinite(millis))
881
+ refuseQueryFilterConstruct("temporalFilter.instant", ctx);
882
+ return millis;
883
+ }