@honua/react 0.1.1-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
@@ -1,17 +1,92 @@
1
1
  /**
2
2
  * OGC API Processes surface. Process discovery, execution, and async
3
- * job tracking. Per the ticket constraint, async executions return an
3
+ * job tracking against any conformant Part 1 (Core) server — a raw
4
+ * third-party deployment discovered by `discoverOgcProcesses()` just as much
5
+ * as the Honua facade. Per the ticket constraint, executions return an
4
6
  * `IJobRun` (the canonical async-operation surface) rather than an
5
- * OGC-specific job type.
7
+ * OGC-specific job type; a synchronous execution returns an already-terminal
8
+ * `IJobRun` so callers read results through one surface either way.
9
+ *
10
+ * Three properties keep the standalone lane honest:
11
+ *
12
+ * 1. **Nothing is assumed about the layout.** Routes template off the
13
+ * discovered service root, and the job lifecycle prefers the server's own
14
+ * `Location` header and `links[]` over the Core path template.
15
+ * 2. **Capability gaps fail closed.** When the server's conformance
16
+ * declaration (or the process's `jobControlOptions`) is known and does not
17
+ * declare what the caller asked for, the call throws
18
+ * `HonuaCapabilityNotSupportedError` naming the missing construct rather
19
+ * than posting and hoping.
20
+ * 3. **Polling is bounded.** `results()` always runs under a budget — the
21
+ * caller's, the handle's, or a default deadline — and honors an
22
+ * `AbortSignal`.
6
23
  *
7
24
  * @module
8
25
  */
9
26
  import { HonuaJobPollTimeoutError, isJobTerminal } from "../contract/jobs.js";
10
27
  import { HonuaSdkError } from "./error-envelope.js";
28
+ import { HonuaCapabilityNotSupportedError } from "./errors.js";
29
+ import { hasOgcConformanceClass } from "./ogc-conformance.js";
11
30
  import { createOgcMetadataParams, mergeHeaders } from "./wire-shared.js";
12
31
  const DEFAULT_POLL_INTERVAL_MS = 1_000;
32
+ /**
33
+ * Wall-clock ceiling applied to `results()` when neither the caller nor the
34
+ * handle set `deadlineMs` / `maxAttempts`. NFR-001: a status endpoint that
35
+ * never reaches a terminal state must not poll forever. Callers who genuinely
36
+ * want a longer wait pass their own `deadlineMs`.
37
+ */
38
+ const DEFAULT_POLL_DEADLINE_MS = 600_000;
13
39
  /** Honua facade path prefix for OGC API Processes. */
14
40
  const PROCESSES_FACADE_BASE = "/ogc/processes";
41
+ /**
42
+ * OGC API — Processes Part 1 (1.0) conformance classes the SDK gates on, and
43
+ * the Core link relations the job lifecycle follows. These stay module-private
44
+ * per the `ogc-conformance` rule that conformance URIs are never top-level SDK
45
+ * types; they surface only as diagnostic strings inside capability errors, so a
46
+ * refusal can name the exact construct the server did not declare.
47
+ */
48
+ const PROCESSES_CONFORMANCE = {
49
+ core: "http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/core",
50
+ dismiss: "http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/dismiss",
51
+ };
52
+ /** Substring matchers tolerant of OGC re-issuing the class URIs under a new part number. */
53
+ const PROCESSES_CONFORMANCE_MATCH = {
54
+ core: "processes-1/1.0/conf/core",
55
+ dismiss: "processes-1/1.0/conf/dismiss",
56
+ };
57
+ const PROCESSES_LINK_REL = {
58
+ results: "http://www.opengis.net/def/rel/ogc/1.0/results",
59
+ status: "self",
60
+ };
61
+ /** Job-control options a process declares in its description (Core §7.9). */
62
+ const JOB_CONTROL = {
63
+ sync: "sync-execute",
64
+ async: "async-execute",
65
+ dismiss: "dismiss",
66
+ };
67
+ /**
68
+ * Fail-closed refusal naming the construct the server did not declare. The
69
+ * conformance-class URI / link relation rides in the error context so telemetry
70
+ * keeps it after redaction.
71
+ *
72
+ * The context key for a class URI is `missingClass`, not `conformanceClass`:
73
+ * the error-envelope redactor drops any key matching `form` (for form bodies),
74
+ * which "con**form**anceClass" hits — the value would ship as `[REDACTED]` and
75
+ * the refusal would name nothing.
76
+ */
77
+ function capabilityRefusal(capability, sourceId, context) {
78
+ return new HonuaCapabilityNotSupportedError(capability, "ogc-processes", sourceId, { context });
79
+ }
80
+ /** `true` when the declaration list contains `option` (case-insensitive). */
81
+ function declares(options, option) {
82
+ if (!options)
83
+ return false;
84
+ for (const entry of options) {
85
+ if (typeof entry === "string" && entry.toLowerCase() === option)
86
+ return true;
87
+ }
88
+ return false;
89
+ }
15
90
  /**
16
91
  * Resolve the OGC API Processes path prefix: the caller-supplied raw `basePath`
17
92
  * (a `discoverOgcProcesses()`-discovered third-party service root) or the Honua
@@ -29,56 +104,293 @@ function processesBase(request) {
29
104
  end--;
30
105
  return base.slice(0, end);
31
106
  }
32
- /** Cache-key discriminator so a discovered root never collides with the facade. */
107
+ /**
108
+ * Cache-key discriminator so a discovered root never collides with the facade.
109
+ *
110
+ * The base path is percent-encoded because cache keys join their components
111
+ * with `:` and a service root may legally contain one. Without encoding,
112
+ * `{ basePath: "/a", processId: "b:c" }` and `{ basePath: "/a:b", processId: "c" }`
113
+ * would produce the same key for two different request URLs, so a cached
114
+ * `describe()` could answer with the wrong process's metadata.
115
+ */
33
116
  function processesBaseKey(request) {
34
117
  const base = processesBase(request);
35
- return base === PROCESSES_FACADE_BASE ? "" : `${base}:`;
118
+ return base === PROCESSES_FACADE_BASE ? "" : `${encodeURIComponent(base)}:`;
119
+ }
120
+ function isAbsoluteHttpUrl(value) {
121
+ return /^https?:\/\//i.test(value);
122
+ }
123
+ /**
124
+ * Absolute URL of a request the SDK issues against `baseUrl`. Link hrefs in the
125
+ * response are relative to the document that carried them, so this is the base
126
+ * a `links[]` entry resolves against.
127
+ */
128
+ function requestUrlFor(baseUrl, path) {
129
+ if (!isAbsoluteHttpUrl(baseUrl))
130
+ return undefined;
131
+ try {
132
+ return new URL(`${baseUrl}${path}`).toString();
133
+ }
134
+ catch {
135
+ return undefined;
136
+ }
137
+ }
138
+ /**
139
+ * Reduce a server-advertised href to a same-origin request path.
140
+ *
141
+ * The SDK attaches credentials by header, so a link that leaves the configured
142
+ * origin is dropped rather than followed — the caller falls back to the Core
143
+ * route template (or, under `"strict"`, gets a fail-closed refusal). Relative
144
+ * hrefs resolve against the document that carried them.
145
+ */
146
+ function advertisedRoutePath(href, documentUrl, baseUrl) {
147
+ if (typeof href !== "string" || href.length === 0)
148
+ return undefined;
149
+ if (!isAbsoluteHttpUrl(baseUrl))
150
+ return undefined;
151
+ let resolved;
152
+ try {
153
+ resolved = new URL(href, documentUrl ?? `${baseUrl}/`);
154
+ }
155
+ catch {
156
+ return undefined;
157
+ }
158
+ let origin;
159
+ try {
160
+ origin = new URL(baseUrl).origin;
161
+ }
162
+ catch {
163
+ return undefined;
164
+ }
165
+ if (resolved.origin !== origin)
166
+ return undefined;
167
+ return `${resolved.pathname}${resolved.search}`;
168
+ }
169
+ /** Core-mandated job route (§7.12) under a service root. */
170
+ function templatedJobPath(basePath, jobId) {
171
+ return `${processesBase({ ...(basePath !== undefined ? { basePath } : {}) })}/jobs/${encodeURIComponent(jobId)}`;
172
+ }
173
+ /**
174
+ * Append metadata params to a route that may already carry a query string —
175
+ * an advertised job link often does. The server's own query is preserved
176
+ * verbatim (never re-encoded) and a param it already declares is never
177
+ * duplicated, so following a link cannot corrupt the URL the server published.
178
+ */
179
+ function withParams(path, params) {
180
+ const queryIndex = path.indexOf("?");
181
+ if (queryIndex < 0) {
182
+ const query = params.toString();
183
+ return query.length === 0 ? path : `${path}?${query}`;
184
+ }
185
+ const existing = new URLSearchParams(path.slice(queryIndex + 1));
186
+ const additions = new URLSearchParams();
187
+ for (const [key, value] of params) {
188
+ if (!existing.has(key))
189
+ additions.append(key, value);
190
+ }
191
+ const query = additions.toString();
192
+ return query.length === 0 ? path : `${path}&${query}`;
193
+ }
194
+ /**
195
+ * First same-origin path advertised under one of `rels`, if any. A relation
196
+ * matches on the full URI or on its last path segment, the same tolerance
197
+ * `findOgcLink` applies — servers publish both
198
+ * `http://www.opengis.net/def/rel/ogc/1.0/results` and a bare `results`.
199
+ */
200
+ function linkPath(links, documentUrl, baseUrl, ...rels) {
201
+ if (!links)
202
+ return undefined;
203
+ const wanted = new Set();
204
+ for (const rel of rels) {
205
+ const lower = rel.toLowerCase();
206
+ wanted.add(lower);
207
+ wanted.add(lower.slice(lower.lastIndexOf("/") + 1));
208
+ }
209
+ for (const link of links) {
210
+ const rel = (link.rel ?? "").toLowerCase();
211
+ const tail = rel.slice(rel.lastIndexOf("/") + 1);
212
+ if (!wanted.has(rel) && !(tail && wanted.has(tail)))
213
+ continue;
214
+ const path = advertisedRoutePath(link.href, documentUrl, baseUrl);
215
+ if (path)
216
+ return path;
217
+ }
218
+ return undefined;
36
219
  }
37
220
  /** Top-level OGC API Processes handle. */
38
221
  export class HonuaOgcProcesses {
39
222
  client;
223
+ basePath;
224
+ capabilityPolicy;
225
+ pollBudget;
226
+ declaredConformance;
227
+ conformancePromise;
228
+ /**
229
+ * `jobControlOptions` learned from `describe()` calls made through this
230
+ * handle. Callers that follow the natural discover → describe → execute flow
231
+ * therefore get process-level gating for free, with no extra round trip.
232
+ */
233
+ jobControlByProcess = new Map();
40
234
  constructor(options) {
41
235
  this.client = options.client;
236
+ this.basePath = options.basePath;
237
+ this.capabilityPolicy = options.capabilityPolicy ?? "advertised";
238
+ this.pollBudget = options.pollBudget;
239
+ this.declaredConformance = options.conformance;
240
+ }
241
+ withBase(request) {
242
+ return this.basePath !== undefined ? { ...request, basePath: this.basePath } : request;
42
243
  }
43
244
  async landing(request = {}) {
44
- return this.client.getOgcProcessesLanding(request);
245
+ return this.client.getOgcProcessesLanding(this.withBase(request));
45
246
  }
46
247
  async conformance(request = {}) {
47
- return this.client.getOgcProcessesConformance(request);
248
+ const response = await this.client.getOgcProcessesConformance(this.withBase(request));
249
+ if (Array.isArray(response?.conformsTo))
250
+ this.declaredConformance = response;
251
+ return response;
48
252
  }
49
253
  async list(request = {}) {
50
- return this.client.listOgcProcesses(request);
254
+ const response = await this.client.listOgcProcesses(this.withBase(request));
255
+ for (const process of response?.processes ?? []) {
256
+ if (process?.id && process.jobControlOptions) {
257
+ this.jobControlByProcess.set(process.id, [...process.jobControlOptions]);
258
+ }
259
+ }
260
+ return response;
51
261
  }
52
262
  async describe(processId, request = {}) {
53
- return this.client.getOgcProcess({ ...request, processId });
263
+ const description = await this.client.getOgcProcess(this.withBase({ ...request, processId }));
264
+ if (description?.jobControlOptions) {
265
+ this.jobControlByProcess.set(processId, [...description.jobControlOptions]);
266
+ }
267
+ return description;
54
268
  }
55
269
  /**
56
- * Submit a process for execution. Always returns an `IJobRun`: honua-server
57
- * is async-only (advertises `jobControlOptions: ['async-execute', 'dismiss']`),
58
- * so the runner polls for the terminal snapshot regardless of whether
59
- * `mode` is `"async"` or `"auto"`.
270
+ * Submit a process for execution and return an `IJobRun` — always, so the
271
+ * caller reads results through one surface whichever Core response shape the
272
+ * server chose:
273
+ *
274
+ * - asynchronous (`Prefer: respond-async`, or the server's own default) →
275
+ * a pollable run bound to the advertised job routes.
276
+ * - synchronous (`200` with the results document) → an already-terminal run
277
+ * whose `results()` resolves immediately with zero further requests.
278
+ *
279
+ * Before anything is posted, `mode` is gated against what the server has
280
+ * declared: the Core conformance class when a conformance declaration is in
281
+ * hand, and the process's `jobControlOptions` when the description is (from
282
+ * `request.jobControlOptions`, an earlier `describe()`/`list()` on this
283
+ * handle, or — under `capabilityPolicy: "strict"` — a cached `describe()`
284
+ * performed here). An undeclared mode fails closed with
285
+ * `HonuaCapabilityNotSupportedError`.
60
286
  */
61
287
  async execute(request) {
62
- const accepted = await this.client.executeOgcProcess(request);
288
+ const executeRequest = this.withBase(request);
289
+ const conformance = await this.assertExecutionDeclared(request);
290
+ const accepted = await this.client.executeOgcProcess(executeRequest);
291
+ const processId = accepted.processID ?? request.processId;
292
+ const jobControlOptions = request.jobControlOptions ?? this.jobControlByProcess.get(request.processId);
293
+ if (accepted.synchronous === true) {
294
+ // Core assigns no job identifier to a synchronous execution; the results
295
+ // document is already in hand, so the run starts terminal.
296
+ return new HonuaOgcProcessSyncRun(processId, (accepted.results ?? {}));
297
+ }
63
298
  return new HonuaOgcProcessJobRun({
64
299
  client: this.client,
65
300
  jobId: accepted.jobID,
66
- processId: accepted.processID ?? request.processId,
301
+ processId,
67
302
  initialStatus: accepted.statusInfo ?? {
68
303
  jobID: accepted.jobID,
69
- processID: accepted.processID ?? request.processId,
304
+ processID: processId,
70
305
  status: accepted.status,
71
306
  },
307
+ // The job routes live under the same root the execution was posted to;
308
+ // a job created against a discovered root must never poll the facade.
309
+ ...(executeRequest.basePath !== undefined ? { basePath: executeRequest.basePath } : {}),
310
+ ...(accepted.statusPath !== undefined ? { statusPath: accepted.statusPath } : {}),
311
+ ...(this.pollBudget !== undefined ? { pollBudget: this.pollBudget } : {}),
312
+ ...(conformance !== undefined ? { conformance } : {}),
313
+ ...(jobControlOptions !== undefined ? { jobControlOptions } : {}),
314
+ capabilityPolicy: this.capabilityPolicy,
72
315
  });
73
316
  }
74
317
  /** Adopt an existing job by id (useful when reconnecting after navigation). */
75
318
  job(jobId, options = {}) {
319
+ const { basePath } = this.withBase(options);
320
+ const jobControlOptions = options.processId ? this.jobControlByProcess.get(options.processId) : undefined;
76
321
  return new HonuaOgcProcessJobRun({
77
322
  client: this.client,
78
323
  jobId,
79
324
  processId: options.processId,
325
+ ...(basePath !== undefined ? { basePath } : {}),
326
+ ...(options.statusPath !== undefined ? { statusPath: options.statusPath } : {}),
327
+ ...(options.resultsPath !== undefined ? { resultsPath: options.resultsPath } : {}),
328
+ ...(this.pollBudget !== undefined ? { pollBudget: this.pollBudget } : {}),
329
+ ...(this.declaredConformance !== undefined ? { conformance: this.declaredConformance } : {}),
330
+ ...(jobControlOptions !== undefined ? { jobControlOptions } : {}),
331
+ capabilityPolicy: this.capabilityPolicy,
80
332
  });
81
333
  }
334
+ /**
335
+ * Resolve the conformance declaration. Under `"advertised"` only what the
336
+ * caller already supplied (or a `conformance()` call made through this
337
+ * handle) counts; under `"strict"` the declaration is fetched once through
338
+ * the metadata cache so a refusal can be grounded in the server's own words.
339
+ */
340
+ async resolveConformance(signal) {
341
+ if (this.declaredConformance)
342
+ return this.declaredConformance;
343
+ if (this.capabilityPolicy !== "strict")
344
+ return undefined;
345
+ if (!this.conformancePromise) {
346
+ const request = signal ? { signal } : {};
347
+ this.conformancePromise = this.client
348
+ .getOgcProcessesConformance(this.withBase(request))
349
+ .then((response) => {
350
+ this.declaredConformance = response;
351
+ return response;
352
+ })
353
+ .catch((error) => {
354
+ this.conformancePromise = undefined;
355
+ throw error;
356
+ });
357
+ }
358
+ return this.conformancePromise;
359
+ }
360
+ /**
361
+ * Fail closed when the server has not declared what the caller asked for.
362
+ * Returns the conformance declaration (when one is in hand) so the created
363
+ * job run can reuse it to gate `cancel()`.
364
+ */
365
+ async assertExecutionDeclared(request) {
366
+ const conformance = await this.resolveConformance(request.signal);
367
+ if (conformance?.conformsTo && !hasOgcConformanceClass(conformance, PROCESSES_CONFORMANCE_MATCH.core)) {
368
+ throw capabilityRefusal("processes.execute", request.processId, {
369
+ missingClass: PROCESSES_CONFORMANCE.core,
370
+ construct: "execute",
371
+ });
372
+ }
373
+ const mode = request.mode ?? "auto";
374
+ // `auto` asserts nothing about sync vs async: the server picks, and both
375
+ // shapes land on the same `IJobRun`. Only an explicit preference is gated.
376
+ if (mode === "auto")
377
+ return conformance;
378
+ let jobControlOptions = request.jobControlOptions ?? this.jobControlByProcess.get(request.processId);
379
+ if (!jobControlOptions && this.capabilityPolicy === "strict") {
380
+ const description = await this.describe(request.processId, request.signal ? { signal: request.signal } : {});
381
+ jobControlOptions = description.jobControlOptions;
382
+ }
383
+ if (!jobControlOptions)
384
+ return conformance;
385
+ const required = mode === "sync" ? JOB_CONTROL.sync : JOB_CONTROL.async;
386
+ if (!declares(jobControlOptions, required)) {
387
+ throw capabilityRefusal(`processes.${required}`, request.processId, {
388
+ construct: required,
389
+ declaredJobControlOptions: jobControlOptions.join(","),
390
+ });
391
+ }
392
+ return conformance;
393
+ }
82
394
  }
83
395
  /**
84
396
  * `IJobRun` implementation backed by OGC API Processes 1.0 status / result
@@ -91,8 +403,16 @@ export class HonuaOgcProcessJobRun {
91
403
  id;
92
404
  type;
93
405
  client;
406
+ basePath;
94
407
  pollIntervalMs;
95
408
  pollFn;
409
+ pollBudget;
410
+ conformance;
411
+ jobControlOptions;
412
+ capabilityPolicy;
413
+ /** Server-advertised routes, refreshed from every `links[]` the job reports. */
414
+ statusPath;
415
+ resultsPath;
96
416
  currentStatus;
97
417
  currentProgress;
98
418
  terminalSnapshot;
@@ -102,12 +422,53 @@ export class HonuaOgcProcessJobRun {
102
422
  this.client = options.client;
103
423
  this.id = options.jobId;
104
424
  this.type = options.processId ?? options.initialStatus?.processID ?? "unknown";
425
+ this.basePath = options.basePath;
105
426
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
106
- this.pollFn = options.pollFn ?? ((jobId, signal) => options.client.getOgcProcessJob({ jobId, signal }));
427
+ this.pollBudget = options.pollBudget;
428
+ this.conformance = options.conformance;
429
+ this.jobControlOptions = options.jobControlOptions;
430
+ this.capabilityPolicy = options.capabilityPolicy ?? "advertised";
431
+ this.statusPath = options.statusPath;
432
+ this.resultsPath = options.resultsPath;
433
+ this.pollFn = options.pollFn ?? ((jobId, signal) => this.fetchStatus(jobId, signal));
107
434
  const initial = options.initialStatus;
435
+ this.absorbLinks(initial?.links);
108
436
  this.currentStatus = initial?.status ?? "accepted";
109
437
  this.currentProgress = progressFromOgcStatus(initial);
110
438
  }
439
+ async fetchStatus(jobId, signal) {
440
+ if (this.capabilityPolicy === "strict" && this.statusPath === undefined) {
441
+ throw capabilityRefusal("processes.jobStatusLink", this.type, {
442
+ construct: "job status route",
443
+ linkRelation: PROCESSES_LINK_REL.status,
444
+ });
445
+ }
446
+ return this.client.getOgcProcessJob({
447
+ jobId,
448
+ ...(signal ? { signal } : {}),
449
+ ...(this.basePath !== undefined ? { basePath: this.basePath } : {}),
450
+ ...(this.statusPath !== undefined ? { routePath: this.statusPath } : {}),
451
+ });
452
+ }
453
+ /**
454
+ * Adopt the routes the server advertises on a job document. Every poll can
455
+ * refresh them, so a server that only publishes its results link once the
456
+ * job succeeds is still followed rather than path-guessed.
457
+ */
458
+ absorbLinks(links) {
459
+ if (!links || links.length === 0)
460
+ return;
461
+ const baseUrl = this.client.serverBaseUrl;
462
+ const documentUrl = requestUrlFor(baseUrl, this.statusPath ?? templatedJobPath(this.basePath, this.id));
463
+ const results = linkPath(links, documentUrl, baseUrl, PROCESSES_LINK_REL.results);
464
+ if (results)
465
+ this.resultsPath = results;
466
+ if (this.statusPath === undefined) {
467
+ const status = linkPath(links, documentUrl, baseUrl, PROCESSES_LINK_REL.status, "status", "monitor");
468
+ if (status)
469
+ this.statusPath = status;
470
+ }
471
+ }
111
472
  get status() {
112
473
  return this.currentStatus;
113
474
  }
@@ -130,19 +491,34 @@ export class HonuaOgcProcessJobRun {
130
491
  // Reset the cached promise if the poll loop rejects (abort / deadline /
131
492
  // attempt cap) so a later results() call can retry rather than being
132
493
  // permanently poisoned by a transient cancellation.
133
- this.terminalPromise = this.runUntilTerminal(options).catch((error) => {
494
+ this.terminalPromise = this.runUntilTerminal(this.resolveBudget(options)).catch((error) => {
134
495
  this.terminalPromise = undefined;
135
496
  throw error;
136
497
  });
137
498
  }
138
499
  return this.terminalPromise;
139
500
  }
501
+ /**
502
+ * Layer the caller's bounds over the handle's default budget, then guarantee
503
+ * a bound exists. NFR-001: `results()` is never an unbounded retry loop, so a
504
+ * caller that names neither a deadline nor an attempt cap still inherits
505
+ * {@link DEFAULT_POLL_DEADLINE_MS}.
506
+ */
507
+ resolveBudget(options) {
508
+ const merged = { ...this.pollBudget, ...options };
509
+ if (merged.deadlineMs !== undefined || merged.maxAttempts !== undefined)
510
+ return merged;
511
+ return { ...merged, deadlineMs: DEFAULT_POLL_DEADLINE_MS };
512
+ }
140
513
  async cancel() {
141
514
  if (this.terminalSnapshot) {
142
515
  return this.currentStatus;
143
516
  }
517
+ this.assertDismissDeclared();
144
518
  try {
145
- const cancelled = await this.client.cancelOgcProcessJob({ jobId: this.id });
519
+ // DELETE targets the job resource itself, so it follows the same
520
+ // advertised route the status polls use rather than re-templating it.
521
+ const cancelled = await this.client.cancelOgcProcessJob(this.jobRequest(this.statusPath));
146
522
  const snapshot = await this.handleOgcStatus(cancelled);
147
523
  return snapshot.status;
148
524
  }
@@ -238,13 +614,21 @@ export class HonuaOgcProcessJobRun {
238
614
  const progress = progressFromOgcStatus(ogcStatus);
239
615
  this.currentStatus = status;
240
616
  this.currentProgress = progress;
617
+ // Routes the server publishes on this document win over the Core template.
618
+ this.absorbLinks(ogcStatus.links);
241
619
  if (status === "successful") {
242
620
  try {
621
+ if (this.capabilityPolicy === "strict" && this.resultsPath === undefined) {
622
+ throw capabilityRefusal("processes.jobResultsLink", this.type, {
623
+ construct: "job results route",
624
+ linkRelation: PROCESSES_LINK_REL.results,
625
+ });
626
+ }
243
627
  // Per OGC API Processes §7.11.1 the document-mode result body is the
244
628
  // outputs map itself (honua-server returns `{}` for the canonical
245
629
  // V1 process; populated maps are keyed by output id). Wrap it into
246
630
  // the canonical `JobResult.outputs` envelope.
247
- const results = await this.client.getOgcProcessJobResults({ jobId: this.id });
631
+ const results = await this.client.getOgcProcessJobResults(this.jobRequest(this.resultsPath));
248
632
  const snapshot = {
249
633
  status: "successful",
250
634
  progress,
@@ -255,6 +639,12 @@ export class HonuaOgcProcessJobRun {
255
639
  return snapshot;
256
640
  }
257
641
  catch (error) {
642
+ // A fail-closed capability refusal is not a job failure: the job may
643
+ // well have succeeded, the SDK simply refuses to guess the route.
644
+ // Surfacing it as `failed` would launder a client-side refusal into a
645
+ // server-side outcome.
646
+ if (error instanceof HonuaCapabilityNotSupportedError)
647
+ throw error;
258
648
  const failure = {
259
649
  status: "failed",
260
650
  progress,
@@ -284,6 +674,44 @@ export class HonuaOgcProcessJobRun {
284
674
  this.notify(snapshot);
285
675
  return snapshot;
286
676
  }
677
+ /**
678
+ * Job-route envelope pinned to the root the job was created against,
679
+ * preferring a route the server advertised over the Core path template.
680
+ */
681
+ jobRequest(routePath) {
682
+ return {
683
+ jobId: this.id,
684
+ ...(this.basePath !== undefined ? { basePath: this.basePath } : {}),
685
+ ...(routePath !== undefined ? { routePath } : {}),
686
+ };
687
+ }
688
+ /**
689
+ * Dismissal is an optional Core extension. When the server's declaration is
690
+ * in hand and neither the Dismiss conformance class nor the process's
691
+ * `jobControlOptions` includes `dismiss`, refuse before issuing a DELETE the
692
+ * server never advertised.
693
+ */
694
+ assertDismissDeclared() {
695
+ // Under `"strict"` the DELETE route must have been advertised, exactly as
696
+ // the status route must be, so dismissal is never sent to a guessed path.
697
+ if (this.capabilityPolicy === "strict" && this.statusPath === undefined) {
698
+ throw capabilityRefusal("processes.jobStatusLink", this.type, {
699
+ construct: "job status route",
700
+ linkRelation: PROCESSES_LINK_REL.status,
701
+ });
702
+ }
703
+ if (declares(this.jobControlOptions, JOB_CONTROL.dismiss))
704
+ return;
705
+ const conformsTo = this.conformance?.conformsTo;
706
+ if (!conformsTo)
707
+ return;
708
+ if (hasOgcConformanceClass(this.conformance, PROCESSES_CONFORMANCE_MATCH.dismiss))
709
+ return;
710
+ throw capabilityRefusal("processes.dismiss", this.type, {
711
+ missingClass: PROCESSES_CONFORMANCE.dismiss,
712
+ construct: JOB_CONTROL.dismiss,
713
+ });
714
+ }
287
715
  notify(snapshot) {
288
716
  for (const listener of this.listeners) {
289
717
  try {
@@ -295,6 +723,42 @@ export class HonuaOgcProcessJobRun {
295
723
  }
296
724
  }
297
725
  }
726
+ /**
727
+ * `IJobRun` for a synchronous execution (Core §7.11: `200` with the results
728
+ * document inline). No job resource exists on the server, so the run starts
729
+ * terminal: `results()` resolves from memory, `poll()` replays the terminal
730
+ * snapshot, and `cancel()` is a no-op that reports the outcome that already
731
+ * happened. Callers therefore read a sync and an async execution through one
732
+ * surface.
733
+ */
734
+ export class HonuaOgcProcessSyncRun {
735
+ /** Empty: OGC Processes assigns no job identifier to a synchronous execution. */
736
+ id = "";
737
+ type;
738
+ status = "successful";
739
+ progress = { percent: 100 };
740
+ snapshot;
741
+ constructor(processId, outputs) {
742
+ this.type = processId;
743
+ this.snapshot = { status: "successful", progress: this.progress, result: { outputs } };
744
+ }
745
+ async poll() {
746
+ return this.snapshot;
747
+ }
748
+ watch(listener) {
749
+ // The only transition already happened; replay it so watchers registered
750
+ // after a synchronous execution still observe the terminal snapshot.
751
+ listener(this.snapshot);
752
+ return () => { };
753
+ }
754
+ async results() {
755
+ // Non-null: the constructor always builds a `successful` snapshot with a result.
756
+ return this.snapshot.result;
757
+ }
758
+ async cancel() {
759
+ return "successful";
760
+ }
761
+ }
298
762
  /** Error thrown when `IJobRun.results()` resolves a non-success terminal. */
299
763
  export class HonuaJobFailedError extends HonuaSdkError {
300
764
  status;
@@ -411,37 +875,145 @@ export async function listOgcProcesses(transport, request = {}) {
411
875
  }
412
876
  export async function getOgcProcess(transport, request) {
413
877
  const params = createOgcMetadataParams(request);
414
- return transport.requestCachedMetadataJson(`ogc-processes:process:${request.processId}:${params.toString()}`, `/ogc/processes/processes/${encodeURIComponent(request.processId)}?${params.toString()}`, request);
878
+ const base = processesBase(request);
879
+ return transport.requestCachedMetadataJson(
880
+ // Both components are percent-encoded so neither can smuggle the `:`
881
+ // separator and alias a different (basePath, processId) pair.
882
+ `ogc-processes:process:${processesBaseKey(request)}${encodeURIComponent(request.processId)}:${params.toString()}`, `${base}/processes/${encodeURIComponent(request.processId)}?${params.toString()}`, request);
415
883
  }
884
+ /**
885
+ * `POST /processes/{processId}/execution` (Core §7.11).
886
+ *
887
+ * Core defines two success shapes and a conformant server may choose either:
888
+ * `200` with the results document (synchronous) or `201` + `Location` with a
889
+ * `statusInfo` body (asynchronous). This reads the response rather than
890
+ * assuming one, so the same call works against a raw third-party server and
891
+ * against the async-only Honua facade, and reports which shape came back.
892
+ */
416
893
  export async function executeOgcProcess(transport, request) {
417
894
  const headers = mergeHeaders({ "Content-Type": "application/json", Accept: "application/json" }, request.headers, preferHeaderForExecute(request));
418
- const path = `/ogc/processes/processes/${encodeURIComponent(request.processId)}/execution`;
419
- // honua-server only supports `response: "document"` and rejects "raw"
420
- // with HTTP 501; the SDK pins the supported value here.
895
+ const path = `${processesBase(request)}/processes/${encodeURIComponent(request.processId)}/execution`;
896
+ // `document` is the only response mode the SDK decodes: its body is the
897
+ // output-id-keyed map that `JobResult.outputs` is defined over. (honua-server
898
+ // additionally rejects `raw` with HTTP 501.)
421
899
  const body = JSON.stringify({
422
900
  inputs: request.inputs ?? {},
423
901
  outputs: request.outputs,
424
902
  response: "document",
425
903
  });
426
- return transport.requestJson("POST", path, { headers, body }, request.signal);
904
+ // pipelineFetch over requestJson: Core distinguishes the synchronous and
905
+ // asynchronous outcomes by status code and `Location`, both of which a
906
+ // JSON-only helper discards.
907
+ const response = await transport.pipelineFetch("POST", path, { headers, body }, request.signal);
908
+ const payload = await readExecutionPayload(response);
909
+ const statusInfo = asJobStatusInfo(payload, request.processId);
910
+ const documentUrl = requestUrlFor(transport.baseUrl, path);
911
+ const location = advertisedRoutePath(response.headers.get("Location") ?? undefined, documentUrl, transport.baseUrl);
912
+ if (statusInfo) {
913
+ const statusPath = location ?? linkPath(statusInfo.links, documentUrl, transport.baseUrl, PROCESSES_LINK_REL.status, "status");
914
+ return {
915
+ jobID: statusInfo.jobID,
916
+ status: statusInfo.status,
917
+ processID: statusInfo.processID ?? request.processId,
918
+ ...(statusInfo.links ? { links: [...statusInfo.links] } : {}),
919
+ statusInfo,
920
+ ...(statusPath !== undefined ? { statusPath } : {}),
921
+ synchronous: false,
922
+ };
923
+ }
924
+ if (location !== undefined) {
925
+ // `201 Location` with no (or an unrecognized) body: the job exists and the
926
+ // server named its route. Derive the id from the advertised route's last
927
+ // segment rather than inventing one.
928
+ const jobId = jobIdFromRoute(location);
929
+ return {
930
+ jobID: jobId,
931
+ status: "accepted",
932
+ processID: request.processId,
933
+ statusPath: location,
934
+ synchronous: false,
935
+ };
936
+ }
937
+ // No job identifier and no job route: Core leaves only the synchronous shape,
938
+ // whose body IS the results document.
939
+ return {
940
+ jobID: "",
941
+ status: "successful",
942
+ processID: request.processId,
943
+ results: (payload ?? {}),
944
+ synchronous: true,
945
+ };
946
+ }
947
+ /**
948
+ * Decode an execution response body. A `204` (or empty body) yields
949
+ * `undefined`; a non-JSON body is refused rather than guessed at, because the
950
+ * SDK asked for `response: "document"` and can only project a JSON outputs map
951
+ * onto `JobResult.outputs`.
952
+ */
953
+ async function readExecutionPayload(response) {
954
+ if (response.status === 204)
955
+ return undefined;
956
+ const text = await response.text();
957
+ if (text.trim().length === 0)
958
+ return undefined;
959
+ try {
960
+ return JSON.parse(text);
961
+ }
962
+ catch {
963
+ throw capabilityRefusal("processes.documentResponse", undefined, {
964
+ construct: "document-mode execution response",
965
+ contentType: response.headers.get("Content-Type") ?? "unknown",
966
+ });
967
+ }
968
+ }
969
+ /** Recognize an async `statusInfo` body: Core requires a non-empty `jobID`. */
970
+ function asJobStatusInfo(payload, processId) {
971
+ if (!payload || typeof payload !== "object" || Array.isArray(payload))
972
+ return undefined;
973
+ const candidate = payload;
974
+ if (typeof candidate.jobID !== "string" || candidate.jobID.length === 0)
975
+ return undefined;
976
+ return {
977
+ ...candidate,
978
+ jobID: candidate.jobID,
979
+ processID: candidate.processID ?? processId,
980
+ status: candidate.status ?? "accepted",
981
+ };
982
+ }
983
+ /** Last path segment of an advertised job route, decoded. */
984
+ function jobIdFromRoute(routePath) {
985
+ const withoutQuery = routePath.split("?", 1)[0] ?? routePath;
986
+ const trimmed = withoutQuery.endsWith("/") ? withoutQuery.slice(0, -1) : withoutQuery;
987
+ const segment = trimmed.slice(trimmed.lastIndexOf("/") + 1);
988
+ try {
989
+ return decodeURIComponent(segment);
990
+ }
991
+ catch {
992
+ return segment;
993
+ }
427
994
  }
428
995
  export async function getOgcProcessJob(transport, request) {
429
996
  const params = createOgcMetadataParams(request);
430
- return transport.requestJson("GET", `/ogc/processes/jobs/${encodeURIComponent(request.jobId)}?${params.toString()}`, undefined, request.signal);
997
+ const route = request.routePath ?? `${processesBase(request)}/jobs/${encodeURIComponent(request.jobId)}`;
998
+ return transport.requestJson("GET", withParams(route, params), undefined, request.signal);
431
999
  }
432
1000
  export async function getOgcProcessJobResults(transport, request) {
433
1001
  const params = createOgcMetadataParams(request);
434
- return transport.requestJson("GET", `/ogc/processes/jobs/${encodeURIComponent(request.jobId)}/results?${params.toString()}`, undefined, request.signal);
1002
+ const route = request.routePath ?? `${processesBase(request)}/jobs/${encodeURIComponent(request.jobId)}/results`;
1003
+ return transport.requestJson("GET", withParams(route, params), undefined, request.signal);
435
1004
  }
436
1005
  export async function cancelOgcProcessJob(transport, request) {
437
1006
  const params = createOgcMetadataParams(request);
438
- return transport.requestJson("DELETE", `/ogc/processes/jobs/${encodeURIComponent(request.jobId)}?${params.toString()}`, undefined, request.signal);
1007
+ const route = request.routePath ?? `${processesBase(request)}/jobs/${encodeURIComponent(request.jobId)}`;
1008
+ return transport.requestJson("DELETE", withParams(route, params), undefined, request.signal);
439
1009
  }
440
1010
  /**
441
1011
  * Build the `Prefer` header for an OGC API Processes execution request.
442
- * honua-server is async-only: `mode: "async"` sends an explicit
443
- * `Prefer: respond-async` so OGC-conformance checkers see the header;
444
- * `mode: "auto"` (or unset) omits it and lets the server default apply.
1012
+ * `mode: "async"` sends an explicit `Prefer: respond-async` (RFC 7240) so a
1013
+ * server that supports both shapes returns a job and OGC conformance checkers
1014
+ * see the header. `"sync"` and `"auto"` omit it: Core defines no header that
1015
+ * forces synchronous execution, so `"sync"` is expressed by *not* preferring
1016
+ * async plus the `sync-execute` capability assertion made before the post.
445
1017
  */
446
1018
  function preferHeaderForExecute(request) {
447
1019
  if (request.mode === "async") {