@honua/app-platform 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 (383) hide show
  1. package/collaboration/client.d.ts.map +1 -1
  2. package/connect-url-safety.d.ts +53 -0
  3. package/connect-url-safety.js +189 -0
  4. package/contract/discovery.d.ts +34 -0
  5. package/contract/discovery.d.ts.map +1 -1
  6. package/contract/index.d.ts +7 -5
  7. package/contract/index.d.ts.map +1 -1
  8. package/contract/index.js +3 -2
  9. package/contract/pmtiles.d.ts +10 -1
  10. package/contract/pmtiles.d.ts.map +1 -1
  11. package/contract/pmtiles.js +10 -2
  12. package/contract/protocol-module.d.ts +84 -9
  13. package/contract/protocol-module.d.ts.map +1 -1
  14. package/contract/query-filter-fes.d.ts +18 -0
  15. package/contract/query-filter-fes.d.ts.map +1 -0
  16. package/contract/query-filter-fes.js +122 -0
  17. package/contract/query-filter.d.ts +300 -0
  18. package/contract/query-filter.d.ts.map +1 -0
  19. package/contract/query-filter.js +883 -0
  20. package/contract/schema.d.ts +12 -0
  21. package/contract/schema.d.ts.map +1 -1
  22. package/contract/schema.js +34 -0
  23. package/contract/source-capability-support.d.ts.map +1 -1
  24. package/contract/source-capability-support.js +15 -3
  25. package/contract/source.d.ts +45 -25
  26. package/contract/source.d.ts.map +1 -1
  27. package/contract/source.js +719 -479
  28. package/contract/types.d.ts +87 -18
  29. package/contract/types.d.ts.map +1 -1
  30. package/contract/widget-source.d.ts.map +1 -1
  31. package/controls/basemap-switcher.d.ts +19 -11
  32. package/controls/basemap-switcher.d.ts.map +1 -1
  33. package/controls/basemap-switcher.js +58 -25
  34. package/controls/catalog.d.ts +105 -0
  35. package/controls/catalog.d.ts.map +1 -0
  36. package/controls/catalog.js +341 -0
  37. package/controls/element-utils.d.ts +9 -0
  38. package/controls/element-utils.d.ts.map +1 -1
  39. package/controls/element-utils.js +17 -0
  40. package/controls/index.d.ts +29 -8
  41. package/controls/index.d.ts.map +1 -1
  42. package/controls/index.js +26 -8
  43. package/controls/layer-list.d.ts +13 -5
  44. package/controls/layer-list.d.ts.map +1 -1
  45. package/controls/layer-list.js +72 -15
  46. package/controls/legend.d.ts +32 -5
  47. package/controls/legend.d.ts.map +1 -1
  48. package/controls/legend.js +95 -25
  49. package/controls/qualification.d.ts +152 -0
  50. package/controls/qualification.d.ts.map +1 -0
  51. package/controls/qualification.js +1265 -0
  52. package/controls/registry.d.ts +104 -0
  53. package/controls/registry.d.ts.map +1 -0
  54. package/controls/registry.js +150 -0
  55. package/controls/swipe-control.d.ts +6 -1
  56. package/controls/swipe-control.d.ts.map +1 -1
  57. package/controls/swipe-control.js +82 -20
  58. package/core/client.d.ts +45 -21
  59. package/core/client.d.ts.map +1 -1
  60. package/core/client.js +224 -46
  61. package/core/error-classifications.d.ts +6 -0
  62. package/core/error-classifications.d.ts.map +1 -1
  63. package/core/error-classifications.js +6 -0
  64. package/core/error-code-registry.d.ts +6 -0
  65. package/core/error-code-registry.d.ts.map +1 -1
  66. package/core/error-code-registry.js +6 -0
  67. package/core/error-envelope.js +2 -0
  68. package/core/errors.d.ts +1 -1
  69. package/core/errors.js +1 -1
  70. package/core/geoparquet-sql.d.ts +68 -4
  71. package/core/geoparquet-sql.d.ts.map +1 -1
  72. package/core/geoparquet-sql.js +325 -8
  73. package/core/odata-write-codec.d.ts.map +1 -1
  74. package/core/odata-write-codec.js +0 -1
  75. package/core/odata.d.ts +6 -0
  76. package/core/odata.d.ts.map +1 -1
  77. package/core/odata.js +6 -1
  78. package/core/ogc-processes.d.ts +182 -25
  79. package/core/ogc-processes.d.ts.map +1 -1
  80. package/core/ogc-processes.js +602 -30
  81. package/core/process-runner.d.ts +8 -1
  82. package/core/process-runner.d.ts.map +1 -1
  83. package/core/process-runner.js +1 -0
  84. package/core/protocol-transport.d.ts +2 -0
  85. package/core/protocol-transport.d.ts.map +1 -1
  86. package/core/stac.d.ts +31 -0
  87. package/core/stac.d.ts.map +1 -1
  88. package/core/stac.js +136 -19
  89. package/core/surfaces.d.ts.map +1 -1
  90. package/core/surfaces.js +273 -68
  91. package/core/types.d.ts +123 -9
  92. package/core/types.d.ts.map +1 -1
  93. package/core/wfs-axis-order.d.ts +18 -0
  94. package/core/wfs-axis-order.d.ts.map +1 -0
  95. package/core/wfs-axis-order.js +108 -0
  96. package/core/wfs-capabilities.d.ts +65 -24
  97. package/core/wfs-capabilities.d.ts.map +1 -1
  98. package/core/wfs-capabilities.js +237 -27
  99. package/core/wfs-filter.d.ts +26 -3
  100. package/core/wfs-filter.d.ts.map +1 -1
  101. package/core/wfs-filter.js +48 -53
  102. package/core/wfs-protocol-error.d.ts +12 -0
  103. package/core/wfs-protocol-error.d.ts.map +1 -0
  104. package/core/wfs-protocol-error.js +13 -0
  105. package/core/wfs.d.ts +53 -1
  106. package/core/wfs.d.ts.map +1 -1
  107. package/core/wfs.js +251 -29
  108. package/core/wms-feature-info.d.ts +92 -0
  109. package/core/wms-feature-info.d.ts.map +1 -0
  110. package/core/wms-feature-info.js +285 -0
  111. package/core/wms.d.ts +9 -0
  112. package/core/wms.d.ts.map +1 -1
  113. package/core/wms.js +17 -5
  114. package/esri-compat/event-bus.d.ts +20 -0
  115. package/esri-compat/event-bus.d.ts.map +1 -1
  116. package/esri-compat/feature-filter.d.ts +46 -1
  117. package/esri-compat/feature-filter.d.ts.map +1 -1
  118. package/esri-compat/feature-filter.js +49 -0
  119. package/esri-compat/geojson-layer.d.ts +4 -2
  120. package/esri-compat/geojson-layer.d.ts.map +1 -1
  121. package/esri-compat/geojson-layer.js +4 -2
  122. package/esri-compat/identity-manager.js +2 -2
  123. package/esri-compat/imagery-layer.d.ts +2 -1
  124. package/esri-compat/imagery-layer.d.ts.map +1 -1
  125. package/esri-compat/imagery-layer.js +2 -1
  126. package/esri-compat/layer-list.d.ts +8 -1
  127. package/esri-compat/layer-list.d.ts.map +1 -1
  128. package/esri-compat/layer-list.js +11 -4
  129. package/esri-compat/legend.d.ts.map +1 -1
  130. package/esri-compat/legend.js +1 -1
  131. package/esri-compat/locator.d.ts +297 -0
  132. package/esri-compat/locator.d.ts.map +1 -0
  133. package/esri-compat/locator.js +579 -0
  134. package/esri-compat/map-image-layer.d.ts.map +1 -1
  135. package/esri-compat/map-view.d.ts.map +1 -1
  136. package/esri-compat/map-view.js +1 -1
  137. package/esri-compat/scene-view.d.ts.map +1 -1
  138. package/esri-compat/time-slider.d.ts +29 -0
  139. package/esri-compat/time-slider.d.ts.map +1 -1
  140. package/esri-compat/time-slider.js +117 -0
  141. package/esri-compat/wfs-layer.d.ts +2 -1
  142. package/esri-compat/wfs-layer.d.ts.map +1 -1
  143. package/esri-compat/wfs-layer.js +2 -1
  144. package/esri-compat/widget-host.d.ts +42 -6
  145. package/esri-compat/widget-host.d.ts.map +1 -1
  146. package/esri-compat/widget-host.js +99 -10
  147. package/generated-app/projection.d.ts.map +1 -1
  148. package/generated-app/runtime.d.ts.map +1 -1
  149. package/map/data-to-map-bridge.d.ts +1 -1
  150. package/map/data-to-map-bridge.d.ts.map +1 -1
  151. package/map/data-to-map-bridge.js +1 -2
  152. package/map/honua-map.d.ts.map +1 -1
  153. package/map/honua-map.js +1 -1
  154. package/map/index.d.ts +2 -0
  155. package/map/index.d.ts.map +1 -1
  156. package/map/index.js +2 -0
  157. package/map/realtime-reconciliation-adapter.d.ts +98 -0
  158. package/map/realtime-reconciliation-adapter.d.ts.map +1 -0
  159. package/map/realtime-reconciliation-adapter.js +84 -0
  160. package/map/temporal-playback.d.ts +25 -0
  161. package/map/temporal-playback.d.ts.map +1 -1
  162. package/map/temporal-playback.js +29 -1
  163. package/operator/workspace/workspace.d.ts.map +1 -1
  164. package/operator/workspace/workspace.js +0 -2
  165. package/package.json +4 -3
  166. package/query-planner/canonical-filter.d.ts +30 -0
  167. package/query-planner/canonical-filter.d.ts.map +1 -0
  168. package/query-planner/canonical-filter.js +30 -0
  169. package/query-planner/columnar-plan.d.ts +63 -0
  170. package/query-planner/columnar-plan.d.ts.map +1 -0
  171. package/query-planner/columnar-plan.js +94 -0
  172. package/query-planner/diagnostics.d.ts +3 -3
  173. package/query-planner/diagnostics.d.ts.map +1 -1
  174. package/query-planner/diagnostics.js +17 -4
  175. package/query-planner/duckdb.d.ts.map +1 -1
  176. package/query-planner/duckdb.js +16 -2
  177. package/query-planner/execution-context.d.ts.map +1 -1
  178. package/query-planner/execution-context.js +3 -1
  179. package/query-planner/executor.d.ts.map +1 -1
  180. package/query-planner/executor.js +7 -1
  181. package/query-planner/geoservices.d.ts.map +1 -1
  182. package/query-planner/geoservices.js +24 -5
  183. package/query-planner/grpc.d.ts.map +1 -1
  184. package/query-planner/grpc.js +29 -5
  185. package/query-planner/index.d.ts +20 -5
  186. package/query-planner/index.d.ts.map +1 -1
  187. package/query-planner/index.js +16 -3
  188. package/query-planner/ir.d.ts +6 -1
  189. package/query-planner/ir.d.ts.map +1 -1
  190. package/query-planner/ir.js +63 -1
  191. package/query-planner/odata-protocol-module.d.ts +54 -0
  192. package/query-planner/odata-protocol-module.d.ts.map +1 -0
  193. package/query-planner/odata-protocol-module.js +199 -0
  194. package/query-planner/odata-v1.d.ts +23 -0
  195. package/query-planner/odata-v1.d.ts.map +1 -0
  196. package/query-planner/odata-v1.js +202 -0
  197. package/query-planner/odata.d.ts +0 -3
  198. package/query-planner/odata.d.ts.map +1 -1
  199. package/query-planner/odata.js +0 -143
  200. package/query-planner/ogc-features.d.ts.map +1 -1
  201. package/query-planner/ogc-features.js +23 -1
  202. package/query-planner/planner.d.ts +2 -2
  203. package/query-planner/planner.d.ts.map +1 -1
  204. package/query-planner/planner.js +146 -27
  205. package/query-planner/representation.d.ts +59 -0
  206. package/query-planner/representation.d.ts.map +1 -0
  207. package/query-planner/representation.js +140 -0
  208. package/query-planner/types.d.ts +153 -3
  209. package/query-planner/types.d.ts.map +1 -1
  210. package/query-planner/wfs-protocol-module.d.ts +57 -0
  211. package/query-planner/wfs-protocol-module.d.ts.map +1 -0
  212. package/query-planner/wfs-protocol-module.js +731 -0
  213. package/query-planner/wfs-v1.d.ts +41 -0
  214. package/query-planner/wfs-v1.d.ts.map +1 -0
  215. package/query-planner/wfs-v1.js +366 -0
  216. package/query-planner/wfs.d.ts +1 -3
  217. package/query-planner/wfs.d.ts.map +1 -1
  218. package/query-planner/wfs.js +1 -70
  219. package/realtime/checkpoint-store.d.ts +206 -0
  220. package/realtime/checkpoint-store.d.ts.map +1 -0
  221. package/realtime/checkpoint-store.js +504 -0
  222. package/realtime/honua-server.d.ts +8 -0
  223. package/realtime/honua-server.d.ts.map +1 -1
  224. package/realtime/honua-server.js +123 -14
  225. package/realtime/index.d.ts +4 -0
  226. package/realtime/index.d.ts.map +1 -1
  227. package/realtime/index.js +2 -0
  228. package/realtime/reconciliation.d.ts +245 -0
  229. package/realtime/reconciliation.d.ts.map +1 -0
  230. package/realtime/reconciliation.js +377 -0
  231. package/realtime/resumable-transport.d.ts.map +1 -1
  232. package/realtime/sse.d.ts.map +1 -1
  233. package/realtime/websocket.d.ts.map +1 -1
  234. package/realtime/wire.d.ts +2 -1
  235. package/realtime/wire.d.ts.map +1 -1
  236. package/realtime/wire.js +24 -8
  237. package/replica-sync/errors.d.ts +19 -1
  238. package/replica-sync/errors.d.ts.map +1 -1
  239. package/replica-sync/errors.js +16 -0
  240. package/replica-sync/geoservices.d.ts +260 -0
  241. package/replica-sync/geoservices.d.ts.map +1 -0
  242. package/replica-sync/geoservices.js +1093 -0
  243. package/replica-sync/index.d.ts +5 -1
  244. package/replica-sync/index.d.ts.map +1 -1
  245. package/replica-sync/index.js +3 -1
  246. package/replica-sync/transport-conformance.d.ts +58 -0
  247. package/replica-sync/transport-conformance.d.ts.map +1 -0
  248. package/replica-sync/transport-conformance.js +265 -0
  249. package/replica-sync/types.d.ts +22 -0
  250. package/replica-sync/types.d.ts.map +1 -1
  251. package/runtime/index.d.ts +6 -2
  252. package/runtime/index.d.ts.map +1 -1
  253. package/runtime/index.js +3 -1
  254. package/runtime/load-package.d.ts +24 -0
  255. package/runtime/load-package.d.ts.map +1 -1
  256. package/runtime/load-package.js +45 -1
  257. package/runtime/maplibre-renderer.d.ts +11 -0
  258. package/runtime/maplibre-renderer.d.ts.map +1 -1
  259. package/runtime/maplibre-renderer.js +32 -7
  260. package/runtime/offline-region-protocol.d.ts +73 -0
  261. package/runtime/offline-region-protocol.d.ts.map +1 -0
  262. package/runtime/offline-region-protocol.js +63 -0
  263. package/runtime/offline-region-style.d.ts +78 -0
  264. package/runtime/offline-region-style.d.ts.map +1 -0
  265. package/runtime/offline-region-style.js +147 -0
  266. package/runtime/pmtiles-protocol.d.ts +7 -8
  267. package/runtime/pmtiles-protocol.d.ts.map +1 -1
  268. package/runtime/pmtiles-protocol.js +19 -66
  269. package/runtime/protocol-registry.d.ts +55 -0
  270. package/runtime/protocol-registry.d.ts.map +1 -0
  271. package/runtime/protocol-registry.js +103 -0
  272. package/runtime/runtime.d.ts +2 -0
  273. package/runtime/runtime.d.ts.map +1 -1
  274. package/runtime/runtime.js +6 -1
  275. package/scene-workspace/analysis-widgets.d.ts +4 -2
  276. package/scene-workspace/analysis-widgets.d.ts.map +1 -1
  277. package/scene-workspace/analysis-widgets.js +4 -2
  278. package/scene-workspace/camera-correspondence.d.ts +151 -0
  279. package/scene-workspace/camera-correspondence.d.ts.map +1 -0
  280. package/scene-workspace/camera-correspondence.js +220 -0
  281. package/scene-workspace/cesium-adapter.d.ts +230 -29
  282. package/scene-workspace/cesium-adapter.d.ts.map +1 -1
  283. package/scene-workspace/cesium-adapter.js +741 -56
  284. package/scene-workspace/cesium-mount.d.ts +205 -0
  285. package/scene-workspace/cesium-mount.d.ts.map +1 -0
  286. package/scene-workspace/cesium-mount.js +392 -0
  287. package/scene-workspace/cesium-scene-owner.d.ts +160 -0
  288. package/scene-workspace/cesium-scene-owner.d.ts.map +1 -0
  289. package/scene-workspace/cesium-scene-owner.js +205 -0
  290. package/scene-workspace/cesium-state-sync.d.ts +101 -0
  291. package/scene-workspace/cesium-state-sync.d.ts.map +1 -0
  292. package/scene-workspace/cesium-state-sync.js +432 -0
  293. package/scene-workspace/cesium-time.d.ts +346 -0
  294. package/scene-workspace/cesium-time.d.ts.map +1 -0
  295. package/scene-workspace/cesium-time.js +403 -0
  296. package/scene-workspace/index.d.ts +42 -10
  297. package/scene-workspace/index.d.ts.map +1 -1
  298. package/scene-workspace/index.js +32 -7
  299. package/scene-workspace/maplibre-state-sync.d.ts +112 -0
  300. package/scene-workspace/maplibre-state-sync.d.ts.map +1 -0
  301. package/scene-workspace/maplibre-state-sync.js +400 -0
  302. package/scene-workspace/primitives.d.ts +237 -3
  303. package/scene-workspace/primitives.d.ts.map +1 -1
  304. package/scene-workspace/primitives.js +1087 -17
  305. package/scene-workspace/scene-discovery.d.ts +5 -2
  306. package/scene-workspace/scene-discovery.d.ts.map +1 -1
  307. package/scene-workspace/scene-discovery.js +5 -2
  308. package/scene-workspace/scene-view.d.ts +5 -2
  309. package/scene-workspace/scene-view.d.ts.map +1 -1
  310. package/scene-workspace/scene-view.js +5 -2
  311. package/scene-workspace/source-to-cesium.d.ts +141 -8
  312. package/scene-workspace/source-to-cesium.d.ts.map +1 -1
  313. package/scene-workspace/source-to-cesium.js +497 -72
  314. package/scene-workspace/state-sync-port-kit.d.ts +156 -0
  315. package/scene-workspace/state-sync-port-kit.d.ts.map +1 -0
  316. package/scene-workspace/state-sync-port-kit.js +319 -0
  317. package/scene-workspace/state-sync.d.ts +34 -1
  318. package/scene-workspace/state-sync.d.ts.map +1 -1
  319. package/scene-workspace/state-sync.js +48 -0
  320. package/scene-workspace/types.d.ts +8 -0
  321. package/scene-workspace/types.d.ts.map +1 -1
  322. package/scene-workspace/types.js +2 -0
  323. package/scene-workspace/workspace.d.ts +12 -1
  324. package/scene-workspace/workspace.d.ts.map +1 -1
  325. package/scene-workspace/workspace.js +13 -0
  326. package/studio/index.d.ts +24 -7
  327. package/studio/index.d.ts.map +1 -1
  328. package/studio/index.js +21 -7
  329. package/studio/lifecycle-client.d.ts +164 -0
  330. package/studio/lifecycle-client.d.ts.map +1 -0
  331. package/studio/lifecycle-client.js +299 -0
  332. package/studio/lifecycle-errors.d.ts +91 -0
  333. package/studio/lifecycle-errors.d.ts.map +1 -0
  334. package/studio/lifecycle-errors.js +77 -0
  335. package/studio/lifecycle-types.d.ts +321 -0
  336. package/studio/lifecycle-types.d.ts.map +1 -0
  337. package/studio/lifecycle-types.js +22 -0
  338. package/web-components/controller.d.ts.map +1 -1
  339. package/web-components/csp-styles.d.ts +6 -0
  340. package/web-components/csp-styles.d.ts.map +1 -0
  341. package/web-components/csp-styles.js +53 -0
  342. package/web-components/elements.d.ts +158 -2
  343. package/web-components/elements.d.ts.map +1 -1
  344. package/web-components/elements.js +1302 -215
  345. package/web-components/export-redaction.d.ts +252 -0
  346. package/web-components/export-redaction.d.ts.map +1 -0
  347. package/web-components/export-redaction.js +588 -0
  348. package/web-components/export.d.ts +417 -0
  349. package/web-components/export.d.ts.map +1 -0
  350. package/web-components/export.js +719 -0
  351. package/web-components/feature-editor-model.d.ts +265 -0
  352. package/web-components/feature-editor-model.d.ts.map +1 -0
  353. package/web-components/feature-editor-model.js +438 -0
  354. package/web-components/feature-editor-sketch.d.ts +125 -0
  355. package/web-components/feature-editor-sketch.d.ts.map +1 -0
  356. package/web-components/feature-editor-sketch.js +194 -0
  357. package/web-components/feature-editor-workflow.d.ts +286 -0
  358. package/web-components/feature-editor-workflow.d.ts.map +1 -0
  359. package/web-components/feature-editor-workflow.js +889 -0
  360. package/web-components/feature-editor.d.ts +83 -0
  361. package/web-components/feature-editor.d.ts.map +1 -0
  362. package/web-components/feature-editor.js +829 -0
  363. package/web-components/feature-table-engine.d.ts +465 -0
  364. package/web-components/feature-table-engine.d.ts.map +1 -0
  365. package/web-components/feature-table-engine.js +1360 -0
  366. package/web-components/feature-table-view.d.ts +95 -0
  367. package/web-components/feature-table-view.d.ts.map +1 -0
  368. package/web-components/feature-table-view.js +343 -0
  369. package/web-components/index.d.ts +34 -3
  370. package/web-components/index.d.ts.map +1 -1
  371. package/web-components/index.js +35 -2
  372. package/web-components/maplibre-renderer.d.ts.map +1 -1
  373. package/web-components/maplibre-renderer.js +13 -4
  374. package/web-components/measurement.d.ts +3 -1
  375. package/web-components/measurement.d.ts.map +1 -1
  376. package/web-components/measurement.js +62 -18
  377. package/web-components/time-slider.d.ts +122 -0
  378. package/web-components/time-slider.d.ts.map +1 -0
  379. package/web-components/time-slider.js +740 -0
  380. package/web-components/types.d.ts +310 -0
  381. package/web-components/types.d.ts.map +1 -1
  382. package/webmap/convert-label.js +0 -1
  383. package/webmap/convert-symbol.d.ts.map +1 -1
@@ -0,0 +1,1093 @@
1
+ /**
2
+ * GeoServices FeatureServer replica-sync transport.
3
+ *
4
+ * This is the real HTTP transport behind {@link ReplicaSyncTransport}: it speaks
5
+ * the GeoServices FeatureServer REST dialect that honua-server implements for
6
+ * disconnected replicas, plus the durable conflict review/resolution API that
7
+ * ships beside it. It is the production counterpart to
8
+ * `FixtureReplicaSyncTransport`, and it passes the same
9
+ * `REPLICA_SYNC_TRANSPORT_CONFORMANCE_CASES` suite.
10
+ *
11
+ * ## Endpoint contract
12
+ *
13
+ * Two route families, deliberately distinct:
14
+ *
15
+ * | Transport call | Server endpoint |
16
+ * | ------------------------- | ---------------------------------------------------------------------------- |
17
+ * | `capabilities` | `GET {rest}/services/{serviceId}/FeatureServer[/{layerId}]?f=json` |
18
+ * | `listReplicas` | `GET {admin}/services/{serviceId}/replicas` |
19
+ * | `getReplica` | `GET {admin}/services/{serviceId}/replicas/{replicaId}` |
20
+ * | `listConflicts` | `GET {admin}/services/{serviceId}/replicas/{replicaId}/conflicts[?status=]` |
21
+ * | `getConflict` | `GET {admin}/services/{serviceId}/replicas/{replicaId}/conflicts/{conflictId}`|
22
+ * | `resolveConflict` | `POST {admin}/…/conflicts/{conflictId}/resolve` |
23
+ * | `createReplica` | `POST {rest}/services/{serviceId}/FeatureServer/createReplica` |
24
+ * | `synchronizeReplica` | `POST {rest}/services/{serviceId}/FeatureServer/synchronizeReplica` |
25
+ * | `unregisterReplica` | `POST {rest}/services/{serviceId}/FeatureServer/unRegisterReplica` |
26
+ * | `applyEdits` | `POST {rest}/services/{serviceId}/FeatureServer/{layerId}/applyEdits` |
27
+ *
28
+ * ## Capability gating
29
+ *
30
+ * Nothing is attempted before the service advertises it. `capabilities()` reads
31
+ * the FeatureServer metadata resource and refuses with
32
+ * {@link HonuaCapabilityNotSupportedError} naming `Sync` when the service does
33
+ * not advertise sync. The admin replica routes sit behind the server's
34
+ * `sync.offline` experimental capability gate, which answers a disabled
35
+ * deployment with HTTP 404 and an `application/problem+json` body whose `type`
36
+ * is `honua:capability-experimental-disabled`. That 404 is recognized and
37
+ * re-raised as `HonuaCapabilityNotSupportedError("sync.offline", …)` — a
38
+ * disabled capability is never surfaced as a missing replica or as corruption.
39
+ *
40
+ * ## Fail closed on drift
41
+ *
42
+ * Every response member this transport reads is validated. An unknown conflict
43
+ * classification, an unknown lifecycle status, an unknown per-feature edit error
44
+ * code, a non-integer-safe generation cursor, or a missing required member
45
+ * raises `HonuaReplicaSyncError("response-drift")` naming the member that
46
+ * drifted. There is no fallback mapping and no partially-populated contract
47
+ * object: the GA hardening tracked in honua-server#2430 may move this surface,
48
+ * and a silent guess would be worse than a refusal.
49
+ *
50
+ * ## Credential discipline
51
+ *
52
+ * Authentication rides the caller's `HonuaClient` header plumbing (API key,
53
+ * bearer token, or auth provider) and is never re-read here. No request URL, no
54
+ * token, and no header value is copied into a conflict record, a replica record,
55
+ * or an error `details` payload; every string this transport places in a durable
56
+ * or reported position is screened with `screenPersistedString`.
57
+ *
58
+ * @experimental
59
+ * @module
60
+ */
61
+ import { screenPersistedString } from "../connect-url-safety.js";
62
+ import { HonuaCapabilityNotSupportedError, HonuaHttpError } from "../core/errors.js";
63
+ import { encodeServiceIdPath } from "../core/path-utils.js";
64
+ import { HonuaReplicaSyncError } from "./errors.js";
65
+ /** The server capability descriptor that gates the disconnected-sync surface. */
66
+ export const GEOSERVICES_REPLICA_SYNC_CAPABILITY = "sync.offline";
67
+ /** The FeatureServer capability token that advertises replica synchronization. */
68
+ export const GEOSERVICES_SYNC_CAPABILITY_TOKEN = "Sync";
69
+ /** Protocol name reported on capability refusals raised by this transport. */
70
+ export const GEOSERVICES_REPLICA_SYNC_PROTOCOL = "geoservices";
71
+ /**
72
+ * `application/problem+json` `type` the server's capability gate returns when an
73
+ * experimental capability is disabled. It arrives on an HTTP 404, which is why
74
+ * the transport must inspect the body before concluding "not found".
75
+ */
76
+ const EXPERIMENTAL_DISABLED_PROBLEM_TYPE = "honua:capability-experimental-disabled";
77
+ const DEFAULT_ADMIN_BASE_PATH = "/api/v1/admin";
78
+ const DEFAULT_REST_BASE_PATH = "/rest";
79
+ /**
80
+ * Stable per-feature edit error codes the FeatureServer `applyEdits` HTTP-200
81
+ * envelope carries (honua-server#2251). The Esri wire shape only has a numeric
82
+ * `code` and a free-form `description`; these codes are the machine-readable
83
+ * classification, and the transport keys on them rather than on the prose.
84
+ */
85
+ export const GEOSERVICES_EDIT_ERROR_CODES = {
86
+ genericFailure: 1000,
87
+ invalidObjectId: 1001,
88
+ notFound: 1002,
89
+ deleteNotFound: 1003,
90
+ updateConflict: 1004,
91
+ featureLocked: 1005,
92
+ validationFailed: 1006,
93
+ notPermitted: 1007,
94
+ operationRolledBack: 1008,
95
+ };
96
+ /**
97
+ * Classification table for the stable per-feature codes. Every code the server
98
+ * publishes is listed; anything absent is drift and is refused rather than
99
+ * folded into the catch-all.
100
+ */
101
+ const EDIT_CODE_OUTCOMES = {
102
+ // Unclassified provider failure during the write — transient by construction.
103
+ 1000: { outcome: "retryable", reason: "genericFailure" },
104
+ // Request-shape errors: the same payload fails identically on resubmission.
105
+ 1001: { outcome: "rejected", reason: "invalidObjectId" },
106
+ // The "not-found" class: no row to update.
107
+ 1002: { outcome: "conflicted", reason: "notFound" },
108
+ // The "delete-delete" class: concurrent or repeated delete.
109
+ 1003: { outcome: "conflicted", reason: "deleteNotFound" },
110
+ // The "update-update" class: optimistic-concurrency race.
111
+ 1004: { outcome: "conflicted", reason: "updateConflict" },
112
+ // The "lock/locked" class: another editor holds the feature.
113
+ 1005: { outcome: "retryable", reason: "featureLocked" },
114
+ 1006: { outcome: "rejected", reason: "validationFailed" },
115
+ 1007: { outcome: "rejected", reason: "notPermitted" },
116
+ // A sibling operation failed under rollbackOnFailure; the batch can be retried.
117
+ 1008: { outcome: "retryable", reason: "operationRolledBack" },
118
+ };
119
+ /** Per-side feature operation implied by each conflict classification. */
120
+ const CLASSIFICATION_OPERATIONS = {
121
+ attribute: { client: "update", server: "update", geometry: false },
122
+ geometry: { client: "update", server: "update", geometry: true },
123
+ deleteUpdate: { client: "delete", server: "update", geometry: false },
124
+ updateDelete: { client: "update", server: "delete", geometry: false },
125
+ duplicateInsert: { client: "create", server: "create", geometry: false },
126
+ // An attachment or related-record conflict is a feature-level update on both
127
+ // sides; the exact sub-kind is preserved verbatim in `metadata.geoServices`.
128
+ attachment: { client: "update", server: "update", geometry: false },
129
+ relationship: { client: "update", server: "update", geometry: false },
130
+ };
131
+ /** Ordinal encoding used by the inline `synchronizeReplica` conflict summaries. */
132
+ const CLASSIFICATION_ORDINALS = [
133
+ "attribute",
134
+ "geometry",
135
+ "deleteUpdate",
136
+ "updateDelete",
137
+ "duplicateInsert",
138
+ "attachment",
139
+ "relationship",
140
+ ];
141
+ /** Server lifecycle status → SDK status. `deferred` is still an open conflict. */
142
+ const CONFLICT_STATUSES = {
143
+ pending: "pending",
144
+ resolved: "resolved",
145
+ deferred: "pending",
146
+ };
147
+ /** Server resolution action → SDK choice. `defer` closes nothing, so it has none. */
148
+ const RESOLUTION_ACTION_CHOICES = {
149
+ acceptClient: "accept-client",
150
+ keepServer: "accept-server",
151
+ mergeFields: "merge",
152
+ chooseGeometry: "merge",
153
+ rejectClient: "discard",
154
+ defer: undefined,
155
+ };
156
+ /** SDK choice → server action. `merge` has no lossless encoding; see below. */
157
+ const CHOICE_ACTIONS = {
158
+ "accept-client": "acceptClient",
159
+ "accept-server": "keepServer",
160
+ discard: "rejectClient",
161
+ merge: undefined,
162
+ };
163
+ const REPLICA_STATES = {
164
+ active: "active",
165
+ expired: "expired",
166
+ };
167
+ export function createGeoServicesReplicaSyncTransport(options) {
168
+ return new GeoServicesReplicaSyncTransport(options);
169
+ }
170
+ export class GeoServicesReplicaSyncTransport {
171
+ #client;
172
+ #serviceId;
173
+ #adminBasePath;
174
+ #restBasePath;
175
+ #sourceIdForLayer;
176
+ /** conflictId → owning (serviceId, replicaId), learned from listings. */
177
+ #conflictRoutes = new Map();
178
+ constructor(options) {
179
+ this.#client = options.client;
180
+ this.#serviceId = requireNonEmpty(options.serviceId, "options.serviceId");
181
+ this.#adminBasePath = trimTrailingSlash(options.adminBasePath ?? DEFAULT_ADMIN_BASE_PATH);
182
+ this.#restBasePath = trimTrailingSlash(options.restBasePath ?? DEFAULT_REST_BASE_PATH);
183
+ this.#sourceIdForLayer = options.sourceIdForLayer;
184
+ }
185
+ // ── Capability discovery ───────────────────────────────────────────────────
186
+ /**
187
+ * Read the FeatureServer metadata resource and project its advertised sync
188
+ * surface onto {@link ReplicaSyncCapabilities}.
189
+ *
190
+ * `conflictReview` / `conflictResolution` are observed, not assumed: the
191
+ * durable review route is probed against the first registered replica. When
192
+ * the service has no replica yet the provider's answer is unobservable, and
193
+ * the flags report the admin surface's reachability — `listConflicts` and
194
+ * `getConflict` still fail closed with `unsupported-conflict-review` if the
195
+ * provider denies review.
196
+ */
197
+ async capabilities(datasetId, sourceId) {
198
+ const serviceId = requireNonEmpty(datasetId, "datasetId");
199
+ const layerSuffix = layerPathSuffix(sourceId);
200
+ const metadata = await this.#geoServices({
201
+ path: `${this.#restBasePath}/services/${encodeServiceIdPath(serviceId)}/FeatureServer${layerSuffix}`,
202
+ });
203
+ if (!isRecord(metadata)) {
204
+ throw drift("FeatureServer", "the metadata resource was not a JSON object");
205
+ }
206
+ if (!advertisesSync(metadata)) {
207
+ throw new HonuaCapabilityNotSupportedError(GEOSERVICES_SYNC_CAPABILITY_TOKEN, GEOSERVICES_REPLICA_SYNC_PROTOCOL, serviceId);
208
+ }
209
+ const directions = syncDirections(metadata.syncCapabilities);
210
+ const review = await this.#probeConflictReview(serviceId);
211
+ return {
212
+ sync: true,
213
+ createReplica: true,
214
+ synchronizeReplica: true,
215
+ conflictReview: review,
216
+ conflictResolution: review,
217
+ // The upload pipeline commits the client edit and records a reviewable
218
+ // conflict; that is last-writer-wins, plus manual review when the
219
+ // provider retains durable records.
220
+ conflictPolicies: review ? ["last-writer-wins", "manual"] : ["last-writer-wins"],
221
+ directions,
222
+ };
223
+ }
224
+ // ── Replica listing ────────────────────────────────────────────────────────
225
+ async listReplicas(request = {}) {
226
+ if (request.ownerId !== undefined) {
227
+ // The replica registry records no owner; filtering on one would silently
228
+ // return the wrong set either way.
229
+ throw new HonuaCapabilityNotSupportedError("replica-sync.listReplicas.ownerId", GEOSERVICES_REPLICA_SYNC_PROTOCOL, request.datasetId ?? this.#serviceId);
230
+ }
231
+ const serviceId = request.datasetId ?? this.#serviceId;
232
+ const summaries = await this.#listReplicaSummaries(serviceId, request.signal);
233
+ const matched = summaries.filter((summary) => {
234
+ if (request.states !== undefined && !request.states.includes(summary.state))
235
+ return false;
236
+ return true;
237
+ });
238
+ const page = paginate(matched, request.limit, request.cursor);
239
+ // Hydrate only the requested page: the listing carries no generation cursor
240
+ // and no conflict count, both of which the contract requires.
241
+ const items = [];
242
+ for (const summary of page.items) {
243
+ items.push(await this.#hydrateReplica(serviceId, summary, request.signal));
244
+ }
245
+ // `sourceId` filtering happens after hydration because the projection is the
246
+ // only place a layer id becomes a SourceId.
247
+ const filtered = request.sourceId === undefined ? items : items.filter((replica) => replica.sourceId === request.sourceId);
248
+ return {
249
+ items: filtered,
250
+ ...(page.cursor === undefined ? {} : { cursor: page.cursor }),
251
+ totalCount: matched.length,
252
+ };
253
+ }
254
+ async getReplica(replicaId, options = {}) {
255
+ const serviceId = this.#serviceId;
256
+ const summary = await this.#getReplicaSummary(serviceId, replicaId, options.signal);
257
+ return this.#hydrateReplica(serviceId, summary, options.signal, summary);
258
+ }
259
+ // ── Conflict review ────────────────────────────────────────────────────────
260
+ async listConflicts(request = {}) {
261
+ const serviceId = request.datasetId ?? this.#serviceId;
262
+ const replicaIds = request.replicaId === undefined
263
+ ? (await this.#listReplicaSummaries(serviceId, request.signal)).map((summary) => summary.replicaId)
264
+ : [request.replicaId];
265
+ if (request.replicaId !== undefined) {
266
+ // Surface an unknown replica as `replica-not-found` rather than an empty
267
+ // conflict page, which would read as "this replica is clean".
268
+ await this.#getReplicaSummary(serviceId, request.replicaId, request.signal);
269
+ }
270
+ const statusFilter = singleServerStatus(request.statuses);
271
+ const collected = [];
272
+ for (const replicaId of replicaIds) {
273
+ const conflicts = await this.#listReplicaConflicts(serviceId, replicaId, statusFilter, request.signal);
274
+ for (const conflict of conflicts)
275
+ collected.push(conflict);
276
+ }
277
+ const matched = collected.filter((conflict) => {
278
+ if (request.statuses !== undefined && !request.statuses.includes(conflict.status))
279
+ return false;
280
+ if (request.kinds !== undefined && !request.kinds.includes(conflict.kind))
281
+ return false;
282
+ return true;
283
+ });
284
+ return paginate(matched, request.limit, request.cursor);
285
+ }
286
+ async getConflict(conflictId, options = {}) {
287
+ const route = await this.#resolveConflictRoute(conflictId, options.signal);
288
+ const detail = await this.#adminJson("GET", `${this.#conflictPath(route.serviceId, route.replicaId, conflictId)}`, undefined, options.signal, { conflictId });
289
+ return this.#projectConflictDetail(route.serviceId, route.replicaId, unwrapApiData(detail, "conflict"));
290
+ }
291
+ async resolveConflict(resolution) {
292
+ const action = CHOICE_ACTIONS[resolution.choice];
293
+ if (action === undefined) {
294
+ // The resolve endpoint's request body carries a single `action` member and
295
+ // no merge payload, so an operator-authored merge cannot be transmitted.
296
+ // Sending `mergeFields` would commit the server's merge, not the caller's.
297
+ throw new HonuaReplicaSyncError("unsupported-conflict-resolution", 'The GeoServices conflict-resolution endpoint accepts only an "action" and cannot carry mergedAttributes or mergedGeometry, so a "merge" resolution cannot be submitted through this transport.', { details: { conflictId: screened(resolution.conflictId, "conflictId"), choice: resolution.choice } });
298
+ }
299
+ const route = await this.#resolveConflictRoute(resolution.conflictId);
300
+ const body = await this.#adminJson("POST", `${this.#conflictPath(route.serviceId, route.replicaId, resolution.conflictId)}/resolve`, { action }, undefined, { conflictId: resolution.conflictId });
301
+ const payload = unwrapApiData(body, "resolution");
302
+ if (!isRecord(payload))
303
+ throw drift("resolution", "the resolution response carried no object payload");
304
+ if (typeof payload.committedNewServerState !== "boolean") {
305
+ throw drift("resolution.committedNewServerState", "expected a boolean");
306
+ }
307
+ const conflict = payload.conflict;
308
+ if (!isRecord(conflict))
309
+ throw drift("resolution.conflict", "expected the resolved conflict object");
310
+ const detail = this.#projectConflictDetail(route.serviceId, route.replicaId, conflict);
311
+ const record = detail.resolution;
312
+ if (record === undefined) {
313
+ throw drift("resolution.conflict.resolutionAction", "the server acknowledged the resolution but returned no resolution evidence");
314
+ }
315
+ return {
316
+ ...record,
317
+ // The caller's choice is authoritative for the record it gets back; the
318
+ // server's action is preserved on the detail's metadata.
319
+ choice: resolution.choice,
320
+ status: resolution.choice === "discard" ? "discarded" : "resolved",
321
+ ...(resolution.note === undefined ? {} : { note: resolution.note }),
322
+ ...(resolution.resolvedBy === undefined ? {} : { resolvedBy: resolution.resolvedBy }),
323
+ };
324
+ }
325
+ async resolveConflicts(resolutions) {
326
+ const records = [];
327
+ const failures = [];
328
+ for (const resolution of resolutions) {
329
+ try {
330
+ records.push(await this.resolveConflict(resolution));
331
+ }
332
+ catch (error) {
333
+ failures.push({
334
+ conflictId: resolution.conflictId,
335
+ reason: error instanceof Error ? error.message : String(error),
336
+ });
337
+ }
338
+ }
339
+ return { records, failures };
340
+ }
341
+ // ── Replica lifecycle and delta exchange ───────────────────────────────────
342
+ /** Register a disconnected replica (`createReplica`). */
343
+ async createReplica(request) {
344
+ const serviceId = request.datasetId ?? this.#serviceId;
345
+ const body = await this.#geoServices({
346
+ method: "POST",
347
+ path: `${this.#restBasePath}/services/${encodeServiceIdPath(serviceId)}/FeatureServer/createReplica`,
348
+ body: {
349
+ replicaName: requireNonEmpty(request.replicaName, "replicaName"),
350
+ layers: request.layers.join(","),
351
+ syncModel: request.syncModel ?? "perReplica",
352
+ },
353
+ ...(request.signal === undefined ? {} : { signal: request.signal }),
354
+ });
355
+ if (!isRecord(body))
356
+ throw drift("createReplica", "expected a JSON object response");
357
+ const replicaId = readString(body, "replicaID", "createReplica.replicaID");
358
+ return {
359
+ replicaId,
360
+ ...(typeof body.replicaName === "string" ? { replicaName: body.replicaName } : {}),
361
+ ...(typeof body.syncModel === "string" ? { syncModel: body.syncModel } : {}),
362
+ serverGen: readCursor(body, "serverGen", "createReplica.serverGen"),
363
+ };
364
+ }
365
+ /** Exchange a delta with the server (`synchronizeReplica`). */
366
+ async synchronizeReplica(request) {
367
+ const serviceId = request.datasetId ?? this.#serviceId;
368
+ const payload = {
369
+ replicaID: requireNonEmpty(request.replicaId, "replicaId"),
370
+ syncDirection: request.direction,
371
+ };
372
+ if (request.replicaServerGen !== undefined) {
373
+ payload.replicaServerGen = requireIntegerCursor(request.replicaServerGen, "replicaServerGen");
374
+ }
375
+ if (request.edits !== undefined) {
376
+ // The sync `edits` parameter is a JSON *string* holding the per-layer array.
377
+ payload.edits = JSON.stringify(request.edits);
378
+ }
379
+ if (request.rollbackOnFailure !== undefined)
380
+ payload.rollbackOnFailure = request.rollbackOnFailure;
381
+ const body = await this.#geoServices({
382
+ method: "POST",
383
+ path: `${this.#restBasePath}/services/${encodeServiceIdPath(serviceId)}/FeatureServer/synchronizeReplica`,
384
+ body: payload,
385
+ ...(request.signal === undefined ? {} : { signal: request.signal }),
386
+ });
387
+ if (!isRecord(body))
388
+ throw drift("synchronizeReplica", "expected a JSON object response");
389
+ if (body.success !== true) {
390
+ throw drift("synchronizeReplica.success", "the server did not report a successful synchronization");
391
+ }
392
+ const direction = readString(body, "syncDirection", "synchronizeReplica.syncDirection");
393
+ if (direction !== "upload" && direction !== "download" && direction !== "bidirectional") {
394
+ throw drift("synchronizeReplica.syncDirection", "unknown sync direction", direction);
395
+ }
396
+ return {
397
+ replicaId: readString(body, "replicaID", "synchronizeReplica.replicaID"),
398
+ direction,
399
+ serverGen: readCursor(body, "serverGen", "synchronizeReplica.serverGen"),
400
+ ...optionalCount(body.appliedAdds, "appliedAdds", "synchronizeReplica.appliedAdds"),
401
+ ...optionalCount(body.appliedUpdates, "appliedUpdates", "synchronizeReplica.appliedUpdates"),
402
+ ...optionalCount(body.appliedDeletes, "appliedDeletes", "synchronizeReplica.appliedDeletes"),
403
+ conflicts: this.#projectSyncConflicts(body.conflicts),
404
+ ...(Array.isArray(body.edits) ? { edits: body.edits } : {}),
405
+ };
406
+ }
407
+ /** Release a replica's server-side registration (`unRegisterReplica`). */
408
+ async unregisterReplica(replicaId, options = {}) {
409
+ const serviceId = options.datasetId ?? this.#serviceId;
410
+ const body = await this.#geoServices({
411
+ method: "POST",
412
+ path: `${this.#restBasePath}/services/${encodeServiceIdPath(serviceId)}/FeatureServer/unRegisterReplica`,
413
+ body: { replicaID: requireNonEmpty(replicaId, "replicaId") },
414
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
415
+ });
416
+ if (!isRecord(body) || body.success !== true) {
417
+ throw drift("unRegisterReplica.success", "the server did not acknowledge the unregistration");
418
+ }
419
+ }
420
+ /**
421
+ * Apply edits directly to a layer and classify every per-feature result.
422
+ *
423
+ * `applyEdits` answers HTTP 200 even when individual features fail; the
424
+ * per-feature `error.code` is the stable classification. Each result is
425
+ * projected onto the offline replay acknowledgement vocabulary so a caller can
426
+ * branch on conflict versus retry versus reject without parsing prose.
427
+ */
428
+ async applyEdits(request) {
429
+ const serviceId = request.datasetId ?? this.#serviceId;
430
+ const payload = {};
431
+ if (request.adds !== undefined)
432
+ payload.adds = request.adds;
433
+ if (request.updates !== undefined)
434
+ payload.updates = request.updates;
435
+ if (request.deletes !== undefined)
436
+ payload.deletes = request.deletes;
437
+ if (request.rollbackOnFailure !== undefined)
438
+ payload.rollbackOnFailure = request.rollbackOnFailure;
439
+ const body = await this.#geoServices({
440
+ method: "POST",
441
+ path: `${this.#restBasePath}/services/${encodeServiceIdPath(serviceId)}/FeatureServer/${request.layerId}/applyEdits`,
442
+ body: payload,
443
+ ...(request.signal === undefined ? {} : { signal: request.signal }),
444
+ });
445
+ if (!isRecord(body))
446
+ throw drift("applyEdits", "expected a JSON object response");
447
+ return [
448
+ ...classifyEditResults(body.addResults, "add"),
449
+ ...classifyEditResults(body.updateResults, "update"),
450
+ ...classifyEditResults(body.deleteResults, "delete"),
451
+ ];
452
+ }
453
+ // ── Internals ──────────────────────────────────────────────────────────────
454
+ #conflictPath(serviceId, replicaId, conflictId) {
455
+ return `${this.#replicaPath(serviceId, replicaId)}/conflicts/${encodePathSegment(conflictId)}`;
456
+ }
457
+ #replicaPath(serviceId, replicaId) {
458
+ return `${this.#adminBasePath}/services/${encodeServiceIdPath(serviceId)}/replicas/${encodePathSegment(replicaId)}`;
459
+ }
460
+ async #probeConflictReview(serviceId) {
461
+ try {
462
+ const summaries = await this.#listReplicaSummaries(serviceId);
463
+ const probe = summaries[0];
464
+ // Without a replica the provider's answer is unobservable; the admin
465
+ // surface answered, which is the only fact available.
466
+ if (probe === undefined)
467
+ return true;
468
+ await this.#listReplicaConflicts(serviceId, probe.replicaId, "pending");
469
+ return true;
470
+ }
471
+ catch (error) {
472
+ // The provider retains no durable records.
473
+ if (error instanceof HonuaReplicaSyncError && error.code === "unsupported-conflict-review")
474
+ return false;
475
+ // The `sync.offline` gate covers the admin review surface only — the
476
+ // FeatureServer replica endpoints are not behind it. A deployment with the
477
+ // capability disabled still creates and synchronizes replicas, so the
478
+ // review flags go false rather than failing the whole capability read.
479
+ // `listReplicas` / `listConflicts` still raise the capability refusal,
480
+ // which is where a caller needs to see it.
481
+ if (error instanceof HonuaCapabilityNotSupportedError)
482
+ return false;
483
+ throw error;
484
+ }
485
+ }
486
+ async #listReplicaSummaries(serviceId, signal) {
487
+ const body = await this.#adminJson("GET", `${this.#adminBasePath}/services/${encodeServiceIdPath(serviceId)}/replicas`, undefined, signal, {});
488
+ const payload = unwrapApiData(body, "replicas");
489
+ if (!isRecord(payload))
490
+ throw drift("replicas", "the replica listing carried no object payload");
491
+ const replicas = payload.replicas;
492
+ if (!Array.isArray(replicas))
493
+ throw drift("replicas.replicas", "expected an array");
494
+ return replicas.map((entry, index) => readReplicaSummary(entry, `replicas.replicas[${index}]`));
495
+ }
496
+ async #getReplicaSummary(serviceId, replicaId, signal) {
497
+ const body = await this.#adminJson("GET", this.#replicaPath(serviceId, replicaId), undefined, signal, {
498
+ replicaId,
499
+ });
500
+ const payload = unwrapApiData(body, "replica");
501
+ return readReplicaDetail(payload, "replica");
502
+ }
503
+ async #hydrateReplica(serviceId, summary, signal, known) {
504
+ const detail = known ?? (await this.#getReplicaSummary(serviceId, summary.replicaId, signal));
505
+ const openConflicts = await this.#countOpenConflicts(serviceId, summary.replicaId, signal);
506
+ const sourceId = this.#sourceId(serviceId, detail.layerIds);
507
+ return {
508
+ id: detail.replicaId,
509
+ ...(detail.replicaName === undefined ? {} : { name: detail.replicaName }),
510
+ datasetId: serviceId,
511
+ ...(sourceId === undefined ? {} : { sourceId }),
512
+ state: detail.state,
513
+ // GeoServices chooses the sync direction per `synchronizeReplica` call, not
514
+ // per replica; a registered replica supports the full surface.
515
+ direction: "bidirectional",
516
+ // The upload pipeline commits the client edit and records a reviewable
517
+ // conflict — last-writer-wins with durable review.
518
+ conflictPolicy: "last-writer-wins",
519
+ createdAt: detail.createdAt,
520
+ status: {
521
+ serverGen: detail.lastSyncGeneration,
522
+ ...(detail.lastSyncTime === undefined ? {} : { lastSyncedAt: detail.lastSyncTime }),
523
+ // Replica synchronization runs inline (`supportsAsync: false`), so no
524
+ // registered replica is ever mid-sync from the server's perspective.
525
+ inProgress: false,
526
+ openConflicts,
527
+ },
528
+ metadata: { geoServices: { syncModel: detail.syncModel, layerIds: detail.layerIds, status: detail.rawStatus } },
529
+ };
530
+ }
531
+ async #countOpenConflicts(serviceId, replicaId, signal) {
532
+ try {
533
+ const conflicts = await this.#listReplicaConflicts(serviceId, replicaId, "pending", signal);
534
+ return conflicts.length;
535
+ }
536
+ catch (error) {
537
+ // A provider that retains no durable records has no reviewable conflict —
538
+ // zero is the observed count, not a fabricated one.
539
+ if (error instanceof HonuaReplicaSyncError && error.code === "unsupported-conflict-review")
540
+ return 0;
541
+ throw error;
542
+ }
543
+ }
544
+ async #listReplicaConflicts(serviceId, replicaId, statusFilter, signal) {
545
+ const query = statusFilter === undefined ? "" : `?status=${encodeURIComponent(statusFilter)}`;
546
+ const body = await this.#adminJson("GET", `${this.#replicaPath(serviceId, replicaId)}/conflicts${query}`, undefined, signal, { replicaId });
547
+ const payload = unwrapApiData(body, "conflicts");
548
+ if (!isRecord(payload))
549
+ throw drift("conflicts", "the conflict listing carried no object payload");
550
+ const rows = payload.conflicts;
551
+ if (!Array.isArray(rows))
552
+ throw drift("conflicts.conflicts", "expected an array");
553
+ return rows.map((entry, index) => {
554
+ const summary = this.#projectConflictSummary(serviceId, replicaId, entry, `conflicts.conflicts[${index}]`);
555
+ this.#conflictRoutes.set(summary.id, { serviceId, replicaId });
556
+ return summary;
557
+ });
558
+ }
559
+ async #resolveConflictRoute(conflictId, signal) {
560
+ const known = this.#conflictRoutes.get(conflictId);
561
+ if (known !== undefined)
562
+ return known;
563
+ // A conflict is addressed by (service, replica, conflict); the id alone is
564
+ // not routable. Learn the binding from the listings rather than probing.
565
+ const serviceId = this.#serviceId;
566
+ const summaries = await this.#listReplicaSummaries(serviceId, signal);
567
+ for (const summary of summaries) {
568
+ await this.#listReplicaConflicts(serviceId, summary.replicaId, undefined, signal);
569
+ const found = this.#conflictRoutes.get(conflictId);
570
+ if (found !== undefined)
571
+ return found;
572
+ }
573
+ throw new HonuaReplicaSyncError("conflict-not-found", `Sync conflict "${screened(conflictId, "conflictId")}" was not found.`);
574
+ }
575
+ #sourceId(serviceId, layerIds) {
576
+ if (this.#sourceIdForLayer === undefined)
577
+ return undefined;
578
+ // A multi-layer replica has no single source; the contract's `sourceId` is
579
+ // singular, so it stays absent rather than naming an arbitrary layer.
580
+ const only = layerIds.length === 1 ? layerIds[0] : undefined;
581
+ return only === undefined ? undefined : this.#sourceIdForLayer(serviceId, only);
582
+ }
583
+ #projectSyncConflicts(value) {
584
+ if (value === undefined || value === null)
585
+ return [];
586
+ if (!Array.isArray(value))
587
+ throw drift("synchronizeReplica.conflicts", "expected an array");
588
+ return value.map((entry, index) => {
589
+ const path = `synchronizeReplica.conflicts[${index}]`;
590
+ if (!isRecord(entry))
591
+ throw drift(path, "expected an object");
592
+ const ordinal = entry.conflictType;
593
+ if (typeof ordinal !== "number" || !Number.isInteger(ordinal)) {
594
+ throw drift(`${path}.conflictType`, "expected an integer classification ordinal", ordinal);
595
+ }
596
+ const classification = CLASSIFICATION_ORDINALS[ordinal];
597
+ if (classification === undefined) {
598
+ throw drift(`${path}.conflictType`, "unknown conflict classification ordinal", ordinal);
599
+ }
600
+ if (typeof entry.applied !== "boolean")
601
+ throw drift(`${path}.applied`, "expected a boolean");
602
+ return {
603
+ layerId: readInteger(entry, "layerId", `${path}.layerId`),
604
+ featureId: String(readInteger(entry, "objectId", `${path}.objectId`)),
605
+ classification,
606
+ applied: entry.applied,
607
+ ...(typeof entry.conflictId === "string" && entry.conflictId.length > 0
608
+ ? { conflictId: entry.conflictId }
609
+ : {}),
610
+ };
611
+ });
612
+ }
613
+ #projectConflictSummary(serviceId, replicaId, entry, path) {
614
+ if (!isRecord(entry))
615
+ throw drift(path, "expected an object");
616
+ const classification = readClassification(entry, `${path}.conflictType`);
617
+ const operations = CLASSIFICATION_OPERATIONS[classification];
618
+ const layerId = readInteger(entry, "layerId", `${path}.layerId`);
619
+ const sourceId = this.#sourceId(serviceId, [layerId]);
620
+ return {
621
+ id: readString(entry, "conflictId", `${path}.conflictId`),
622
+ replicaId,
623
+ datasetId: serviceId,
624
+ ...(sourceId === undefined ? {} : { sourceId }),
625
+ layerId,
626
+ featureId: String(readInteger(entry, "objectId", `${path}.objectId`)),
627
+ // The durable records this API serves are all disconnected-replica sync
628
+ // conflicts; named-version reconcile conflicts live on another surface.
629
+ kind: "replica-sync",
630
+ status: readStatus(entry, `${path}.status`),
631
+ clientOperation: operations.client,
632
+ serverOperation: operations.server,
633
+ detectedAt: readTimestamp(entry, "detectedAt", `${path}.detectedAt`),
634
+ fieldConflictCount: 0,
635
+ hasGeometryConflict: classification === "geometry",
636
+ };
637
+ }
638
+ #projectConflictDetail(serviceId, replicaId, entry) {
639
+ const path = "conflict";
640
+ if (!isRecord(entry))
641
+ throw drift(path, "expected an object");
642
+ const classification = readClassification(entry, `${path}.conflictType`);
643
+ const operations = CLASSIFICATION_OPERATIONS[classification];
644
+ const summary = this.#projectConflictSummary(serviceId, replicaId, entry, path);
645
+ const fieldConflicts = readFieldConflicts(entry.fieldChanges, `${path}.fieldChanges`);
646
+ const geometryChanged = readOptionalBoolean(entry.geometryChanged, `${path}.geometryChanged`);
647
+ const client = readActor(entry.userId);
648
+ const device = readDevice(entry.deviceId);
649
+ const resolution = readResolutionRecord(entry, summary.id, path);
650
+ const rawStatus = readString(entry, "status", `${path}.status`);
651
+ this.#conflictRoutes.set(summary.id, { serviceId, replicaId });
652
+ return {
653
+ ...summary,
654
+ ...(client === undefined ? {} : { client }),
655
+ ...(device === undefined ? {} : { device }),
656
+ fieldConflictCount: fieldConflicts.length,
657
+ hasGeometryConflict: geometryChanged === true || classification === "geometry",
658
+ serverGen: readCursor(entry, "serverGeneration", `${path}.serverGeneration`),
659
+ base: readFeatureState(entry.baseState, operations.client, `${path}.baseState`),
660
+ clientState: readFeatureState(entry.clientState, operations.client, `${path}.clientState`),
661
+ serverState: readFeatureState(entry.serverState, operations.server, `${path}.serverState`),
662
+ fieldConflicts,
663
+ ...(geometryChanged === true || classification === "geometry"
664
+ ? {
665
+ geometryConflict: {
666
+ changed: true,
667
+ clientChanged: true,
668
+ serverChanged: true,
669
+ note: "The server records a single client-versus-server geometry divergence; it does not attribute the change to one side independently.",
670
+ },
671
+ }
672
+ : {}),
673
+ resolutionOptions: resolutionOptions(),
674
+ ...(resolution === undefined ? {} : { resolution }),
675
+ metadata: {
676
+ geoServices: {
677
+ conflictType: classification,
678
+ status: rawStatus,
679
+ ...(typeof entry.syncOperationId === "string" ? { syncOperationId: entry.syncOperationId } : {}),
680
+ ...(typeof entry.resolutionAction === "string" ? { resolutionAction: entry.resolutionAction } : {}),
681
+ },
682
+ },
683
+ };
684
+ }
685
+ async #geoServices(request) {
686
+ const raw = {
687
+ path: request.path,
688
+ method: request.method ?? "GET",
689
+ ...(request.body === undefined
690
+ ? {}
691
+ : {
692
+ headers: { "Content-Type": "application/json" },
693
+ body: JSON.stringify({ ...request.body, f: "json" }),
694
+ }),
695
+ ...(request.signal === undefined ? {} : { signal: request.signal }),
696
+ };
697
+ try {
698
+ return await this.#client.request(raw);
699
+ }
700
+ catch (error) {
701
+ throw this.#translate(error, {});
702
+ }
703
+ }
704
+ async #adminJson(method, path, body, signal, context) {
705
+ try {
706
+ return await this.#client.pipelineRequestJson(method, path, body === undefined
707
+ ? undefined
708
+ : { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }, signal);
709
+ }
710
+ catch (error) {
711
+ throw this.#translate(error, context);
712
+ }
713
+ }
714
+ /**
715
+ * Map a transport-level failure onto the replica-sync error vocabulary.
716
+ *
717
+ * The capability gate answers a disabled deployment with HTTP 404 carrying an
718
+ * `application/problem+json` body, so a 404 is only "not found" once its body
719
+ * has been ruled out as a capability refusal.
720
+ */
721
+ #translate(error, context) {
722
+ if (!(error instanceof HonuaHttpError))
723
+ return error;
724
+ if (isExperimentalDisabledProblem(error.body)) {
725
+ return new HonuaCapabilityNotSupportedError(GEOSERVICES_REPLICA_SYNC_CAPABILITY, GEOSERVICES_REPLICA_SYNC_PROTOCOL, this.#serviceId, { cause: error });
726
+ }
727
+ switch (error.statusCode) {
728
+ case 404:
729
+ if (context.conflictId !== undefined) {
730
+ return new HonuaReplicaSyncError("conflict-not-found", `Sync conflict "${screened(context.conflictId, "conflictId")}" was not found.`, { cause: error });
731
+ }
732
+ if (context.replicaId !== undefined) {
733
+ return new HonuaReplicaSyncError("replica-not-found", `Replica "${screened(context.replicaId, "replicaId")}" was not found.`, { cause: error });
734
+ }
735
+ return new HonuaReplicaSyncError("replica-not-found", "The requested replica resource was not found.", {
736
+ cause: error,
737
+ });
738
+ case 409:
739
+ return new HonuaReplicaSyncError("conflict-already-resolved", `Sync conflict "${screened(context.conflictId ?? "", "conflictId")}" is already resolved.`, { cause: error });
740
+ case 501:
741
+ return new HonuaReplicaSyncError("unsupported-conflict-review", "Manual conflict review is not available: the deployment's feature provider retains no durable conflict records.", { cause: error });
742
+ case 401:
743
+ case 403:
744
+ return new HonuaReplicaSyncError("permission-denied", "The replica-sync request was not authorized.", {
745
+ cause: error,
746
+ });
747
+ default:
748
+ return new HonuaReplicaSyncError("transport-failure", "The replica-sync request failed.", { cause: error });
749
+ }
750
+ }
751
+ }
752
+ // ── Pure projections (exported for direct testing) ───────────────────────────
753
+ /**
754
+ * Classify one per-feature `applyEdits` result. A code outside the published
755
+ * table is drift and is refused; it is never folded into the generic class,
756
+ * because a future code could mean the opposite of "retry".
757
+ */
758
+ export function classifyGeoServicesEditResult(result, kind, path = "editResult") {
759
+ if (!isRecord(result))
760
+ throw drift(path, "expected an object");
761
+ const featureId = typeof result.objectId === "number" && Number.isSafeInteger(result.objectId)
762
+ ? String(result.objectId)
763
+ : typeof result.globalId === "string" && result.globalId.length > 0
764
+ ? result.globalId
765
+ : undefined;
766
+ if (result.success === true) {
767
+ return { outcome: "applied", kind, ...(featureId === undefined ? {} : { featureId }) };
768
+ }
769
+ if (result.success !== false)
770
+ throw drift(`${path}.success`, "expected a boolean");
771
+ const error = result.error;
772
+ if (!isRecord(error))
773
+ throw drift(`${path}.error`, "a failed edit result carried no error object");
774
+ const code = error.code;
775
+ if (typeof code !== "number" || !Number.isInteger(code)) {
776
+ throw drift(`${path}.error.code`, "expected an integer classification code", code);
777
+ }
778
+ const mapped = EDIT_CODE_OUTCOMES[code];
779
+ if (mapped === undefined) {
780
+ throw drift(`${path}.error.code`, "unknown per-feature edit classification code; refusing to guess whether it is retryable", code);
781
+ }
782
+ return {
783
+ outcome: mapped.outcome,
784
+ kind,
785
+ ...(featureId === undefined ? {} : { featureId }),
786
+ code,
787
+ reason: mapped.reason,
788
+ };
789
+ }
790
+ function classifyEditResults(value, kind) {
791
+ if (value === undefined || value === null)
792
+ return [];
793
+ if (!Array.isArray(value))
794
+ throw drift(`applyEdits.${kind}Results`, "expected an array");
795
+ return value.map((entry, index) => classifyGeoServicesEditResult(entry, kind, `applyEdits.${kind}Results[${index}]`));
796
+ }
797
+ function readReplicaSummary(entry, path) {
798
+ if (!isRecord(entry))
799
+ throw drift(path, "expected an object");
800
+ return {
801
+ replicaId: readString(entry, "replicaId", `${path}.replicaId`),
802
+ state: readReplicaState(entry, `${path}.status`),
803
+ };
804
+ }
805
+ function readReplicaDetail(entry, path) {
806
+ if (!isRecord(entry))
807
+ throw drift(path, "expected an object");
808
+ const layerIds = entry.layerIds;
809
+ if (!Array.isArray(layerIds))
810
+ throw drift(`${path}.layerIds`, "expected an array");
811
+ return {
812
+ ...readReplicaSummary(entry, path),
813
+ replicaName: typeof entry.replicaName === "string" ? entry.replicaName : undefined,
814
+ syncModel: readString(entry, "syncModel", `${path}.syncModel`),
815
+ layerIds: layerIds.map((value, index) => {
816
+ if (typeof value !== "number" || !Number.isInteger(value)) {
817
+ throw drift(`${path}.layerIds[${index}]`, "expected an integer layer id", value);
818
+ }
819
+ return value;
820
+ }),
821
+ createdAt: readTimestamp(entry, "createdAt", `${path}.createdAt`),
822
+ lastSyncTime: typeof entry.lastSyncTime === "string" ? readTimestamp(entry, "lastSyncTime", `${path}.lastSyncTime`) : undefined,
823
+ lastSyncGeneration: readCursor(entry, "lastSyncGeneration", `${path}.lastSyncGeneration`),
824
+ rawStatus: readString(entry, "status", `${path}.status`),
825
+ };
826
+ }
827
+ function readReplicaState(entry, path) {
828
+ const raw = entry.status;
829
+ if (typeof raw !== "string")
830
+ throw drift(path, "expected a status string");
831
+ const state = REPLICA_STATES[raw];
832
+ if (state === undefined)
833
+ throw drift(path, "unknown replica status", raw);
834
+ return state;
835
+ }
836
+ function readClassification(entry, path) {
837
+ const raw = entry.conflictType;
838
+ if (typeof raw !== "string")
839
+ throw drift(path, "expected a classification string");
840
+ if (!Object.hasOwn(CLASSIFICATION_OPERATIONS, raw)) {
841
+ throw drift(path, "unknown conflict classification", raw);
842
+ }
843
+ return raw;
844
+ }
845
+ function readStatus(entry, path) {
846
+ const raw = entry.status;
847
+ if (typeof raw !== "string")
848
+ throw drift(path, "expected a status string");
849
+ const status = CONFLICT_STATUSES[raw];
850
+ if (status === undefined)
851
+ throw drift(path, "unknown conflict lifecycle status", raw);
852
+ return status;
853
+ }
854
+ function readFieldConflicts(value, path) {
855
+ if (value === undefined || value === null)
856
+ return [];
857
+ if (!Array.isArray(value))
858
+ throw drift(path, "expected an array");
859
+ return value.map((entry, index) => {
860
+ const at = `${path}[${index}]`;
861
+ if (!isRecord(entry))
862
+ throw drift(at, "expected an object");
863
+ if (typeof entry.changedOnClient !== "boolean")
864
+ throw drift(`${at}.changedOnClient`, "expected a boolean");
865
+ if (typeof entry.changedOnServer !== "boolean")
866
+ throw drift(`${at}.changedOnServer`, "expected a boolean");
867
+ return {
868
+ field: readString(entry, "field", `${at}.field`),
869
+ ...(entry.baseValue === undefined ? {} : { baseValue: entry.baseValue }),
870
+ ...(entry.clientValue === undefined ? {} : { clientValue: entry.clientValue }),
871
+ ...(entry.serverValue === undefined ? {} : { serverValue: entry.serverValue }),
872
+ // Divergence is both sides moving off the common ancestor; a one-sided
873
+ // change is a clean edit, not a conflict.
874
+ diverged: entry.changedOnClient && entry.changedOnServer,
875
+ };
876
+ });
877
+ }
878
+ function readFeatureState(value, operation, path) {
879
+ if (value === undefined || value === null) {
880
+ return { operation, ...(operation === "delete" ? { deleted: true } : {}) };
881
+ }
882
+ if (!isRecord(value))
883
+ throw drift(path, "expected an object");
884
+ return {
885
+ operation,
886
+ ...(isRecord(value.attributes) ? { attributes: value.attributes } : {}),
887
+ ...(value.geometry === undefined ? {} : { geometry: value.geometry }),
888
+ ...(operation === "delete" ? { deleted: true } : {}),
889
+ };
890
+ }
891
+ function readResolutionRecord(entry, conflictId, path) {
892
+ const action = entry.resolutionAction;
893
+ if (action === undefined || action === null)
894
+ return undefined;
895
+ if (typeof action !== "string")
896
+ throw drift(`${path}.resolutionAction`, "expected a resolution action string");
897
+ if (!Object.hasOwn(RESOLUTION_ACTION_CHOICES, action)) {
898
+ throw drift(`${path}.resolutionAction`, "unknown resolution action", action);
899
+ }
900
+ const choice = RESOLUTION_ACTION_CHOICES[action];
901
+ // `defer` is a postponement, not a closure: the conflict stays reviewable and
902
+ // carries no resolution record.
903
+ if (choice === undefined)
904
+ return undefined;
905
+ return {
906
+ conflictId,
907
+ choice,
908
+ status: choice === "discard" ? "discarded" : "resolved",
909
+ resolvedAt: readTimestamp(entry, "resolvedAt", `${path}.resolvedAt`),
910
+ ...(typeof entry.resolvedBy === "string" && entry.resolvedBy.length > 0
911
+ ? { resolvedBy: { id: entry.resolvedBy } }
912
+ : {}),
913
+ ...(entry.resolvedServerGeneration === undefined || entry.resolvedServerGeneration === null
914
+ ? {}
915
+ : { serverGen: readCursor(entry, "resolvedServerGeneration", `${path}.resolvedServerGeneration`) }),
916
+ };
917
+ }
918
+ /**
919
+ * The options this transport can actually execute. They are derived from the
920
+ * shipped resolve-request shape, not from a server advertisement — the server
921
+ * publishes no per-conflict option list.
922
+ */
923
+ function resolutionOptions() {
924
+ return [
925
+ { choice: "accept-client", available: true },
926
+ { choice: "accept-server", available: true },
927
+ {
928
+ choice: "merge",
929
+ available: false,
930
+ reason: 'The GeoServices conflict-resolution endpoint accepts only an "action" and cannot carry merged attributes or geometry.',
931
+ },
932
+ { choice: "discard", available: true },
933
+ ];
934
+ }
935
+ function readActor(value) {
936
+ return typeof value === "string" && value.length > 0 ? { id: value, kind: "user" } : undefined;
937
+ }
938
+ function readDevice(value) {
939
+ return typeof value === "string" && value.length > 0 ? { id: value } : undefined;
940
+ }
941
+ function readString(entry, key, path) {
942
+ const value = entry[key];
943
+ if (typeof value !== "string" || value.length === 0)
944
+ throw drift(path, "expected a non-empty string");
945
+ return value;
946
+ }
947
+ function readInteger(entry, key, path) {
948
+ const value = entry[key];
949
+ if (typeof value !== "number" || !Number.isSafeInteger(value)) {
950
+ throw drift(path, "expected an integer safely representable in JavaScript", value);
951
+ }
952
+ return value;
953
+ }
954
+ function readTimestamp(entry, key, path) {
955
+ const value = readString(entry, key, path);
956
+ const parsed = Date.parse(value);
957
+ if (Number.isNaN(parsed))
958
+ throw drift(path, "expected an ISO-8601 timestamp");
959
+ return new Date(parsed).toISOString();
960
+ }
961
+ /**
962
+ * Read a server generation as a {@link ServerGenerationCursor}. The wire carries
963
+ * a 64-bit integer; a value outside JavaScript's safe integer range has already
964
+ * lost precision by the time it is parsed, so it is refused rather than silently
965
+ * rounded into a cursor that would order edits wrongly.
966
+ */
967
+ function readCursor(entry, key, path) {
968
+ const value = entry[key];
969
+ if (typeof value !== "number" || !Number.isInteger(value))
970
+ throw drift(path, "expected an integer generation");
971
+ if (!Number.isSafeInteger(value)) {
972
+ throw drift(path, "the generation cursor exceeds JavaScript's safe integer range and cannot be read losslessly");
973
+ }
974
+ return String(value);
975
+ }
976
+ function requireIntegerCursor(cursor, label) {
977
+ const parsed = Number(cursor);
978
+ if (!Number.isSafeInteger(parsed)) {
979
+ throw new HonuaReplicaSyncError("response-drift", `${label} must be a decimal server generation cursor produced by this transport.`, { details: { member: label } });
980
+ }
981
+ return parsed;
982
+ }
983
+ function optionalCount(value, key, path) {
984
+ if (value === undefined || value === null)
985
+ return {};
986
+ if (typeof value !== "number" || !Number.isInteger(value))
987
+ throw drift(path, "expected an integer count", value);
988
+ return { [key]: value };
989
+ }
990
+ function readOptionalBoolean(value, path) {
991
+ if (value === undefined || value === null)
992
+ return undefined;
993
+ if (typeof value !== "boolean")
994
+ throw drift(path, "expected a boolean");
995
+ return value;
996
+ }
997
+ /** Unwrap the admin API's `{ success, data }` envelope. */
998
+ function unwrapApiData(body, path) {
999
+ if (!isRecord(body))
1000
+ throw drift(path, "expected a JSON object response");
1001
+ if (body.success !== true)
1002
+ throw drift(`${path}.success`, "the server did not report a successful response");
1003
+ if (!Object.hasOwn(body, "data"))
1004
+ throw drift(`${path}.data`, "the response envelope carried no data member");
1005
+ return body.data;
1006
+ }
1007
+ // ── Metadata helpers ─────────────────────────────────────────────────────────
1008
+ function advertisesSync(metadata) {
1009
+ if (metadata.syncEnabled === true)
1010
+ return true;
1011
+ const capabilities = metadata.capabilities;
1012
+ if (typeof capabilities !== "string")
1013
+ return false;
1014
+ return capabilities
1015
+ .split(",")
1016
+ .some((token) => token.trim().toLowerCase() === GEOSERVICES_SYNC_CAPABILITY_TOKEN.toLowerCase());
1017
+ }
1018
+ function syncDirections(value) {
1019
+ // Without direction control the server picks; only the full round trip is
1020
+ // guaranteed to be honoured.
1021
+ if (!isRecord(value) || value.supportsSyncDirectionControl !== true)
1022
+ return ["bidirectional"];
1023
+ return ["bidirectional", "upload", "download"];
1024
+ }
1025
+ function layerPathSuffix(sourceId) {
1026
+ if (sourceId === undefined)
1027
+ return "";
1028
+ // GeoServices addresses a layer by its service-local integer id. A source id
1029
+ // that is not one names no layer resource, so the service resource answers.
1030
+ return /^\d+$/.test(sourceId) ? `/${sourceId}` : "";
1031
+ }
1032
+ function isExperimentalDisabledProblem(body) {
1033
+ return isRecord(body) && body.type === EXPERIMENTAL_DISABLED_PROBLEM_TYPE;
1034
+ }
1035
+ // ── Shared utilities ─────────────────────────────────────────────────────────
1036
+ function singleServerStatus(statuses) {
1037
+ if (statuses === undefined || statuses.length !== 1)
1038
+ return undefined;
1039
+ // Only `pending` and `resolved` round-trip to a server-side filter; the rest
1040
+ // are filtered locally after projection.
1041
+ const only = statuses[0];
1042
+ return only === "pending" || only === "resolved" ? only : undefined;
1043
+ }
1044
+ function paginate(items, limit, cursor) {
1045
+ const start = cursor === undefined ? 0 : Math.max(0, Number.parseInt(cursor, 10) || 0);
1046
+ const end = limit === undefined ? items.length : start + limit;
1047
+ const page = items.slice(start, end);
1048
+ const nextCursor = end < items.length ? String(end) : undefined;
1049
+ return { items: page, ...(nextCursor === undefined ? {} : { cursor: nextCursor }), totalCount: items.length };
1050
+ }
1051
+ function isRecord(value) {
1052
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1053
+ }
1054
+ function requireNonEmpty(value, label) {
1055
+ if (typeof value !== "string" || value.trim().length === 0) {
1056
+ throw new HonuaReplicaSyncError("response-drift", `${label} must be a non-empty string.`, {
1057
+ details: { member: label },
1058
+ });
1059
+ }
1060
+ return value;
1061
+ }
1062
+ function trimTrailingSlash(value) {
1063
+ return value.endsWith("/") ? value.slice(0, -1) : value;
1064
+ }
1065
+ function encodePathSegment(value) {
1066
+ return encodeURIComponent(value);
1067
+ }
1068
+ /**
1069
+ * Screen a string before it reaches a message or an error `details` payload. A
1070
+ * credential- or URL-shaped identifier is replaced by a fixed placeholder rather
1071
+ * than echoed, so a hostile or misconfigured id cannot smuggle a token into an
1072
+ * error report.
1073
+ */
1074
+ function screened(value, label) {
1075
+ return screenPersistedString(value, "identity") === undefined ? value : `<${label} withheld: credential-shaped>`;
1076
+ }
1077
+ /**
1078
+ * Refuse a drifted response. The message names the member and what was expected;
1079
+ * the observed value is included only when it is a short, screened scalar, so a
1080
+ * refusal never becomes an exfiltration channel.
1081
+ */
1082
+ function drift(member, detail, observed) {
1083
+ const described = observed === undefined ? undefined : describeObserved(observed);
1084
+ return new HonuaReplicaSyncError("response-drift", `The GeoServices replica-sync response drifted at "${member}": ${detail}${described === undefined ? "" : ` (observed: ${described})`}.`, { details: { member, ...(described === undefined ? {} : { observed: described }) } });
1085
+ }
1086
+ function describeObserved(value) {
1087
+ if (typeof value === "number" || typeof value === "boolean")
1088
+ return String(value);
1089
+ if (typeof value !== "string")
1090
+ return typeof value;
1091
+ const truncated = value.length > 64 ? `${value.slice(0, 64)}…` : value;
1092
+ return screenPersistedString(truncated, "identity") === undefined ? truncated : "<withheld: credential-shaped>";
1093
+ }