@honua/app-platform 0.1.2-beta.0 → 0.1.4-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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,889 @@
1
+ /**
2
+ * Host-facing edit workflow behind `<honua-feature-editor>` (issue #680).
3
+ *
4
+ * This is the composition layer: it owns a public contract
5
+ * {@link EditSketchWorkflowModel} (which itself owns the edit session, undo /
6
+ * redo, snapping, attachment staging, optimistic hooks, and the single
7
+ * `applyEdits` call) and adds only what an operational editing UI needs on top
8
+ * and the protocol layer deliberately does not model:
9
+ *
10
+ * - **Draft identity.** A draft is scoped to `source` + feature id + the
11
+ * concurrency token observed when it opened, so an unrelated realtime change
12
+ * can be recognised and ignored without disturbing the form
13
+ * ({@link HonuaFeatureEditorWorkflow.applyExternalChange}, REQ-005).
14
+ * - **Subtype-aware pre-transport validation.** Subtype overrides change the
15
+ * valid choices per field. Rather than duplicating the contract validator,
16
+ * {@link HonuaFeatureEditorWorkflow.validate} builds a throwaway
17
+ * `createEditSession` over the *subtype-resolved* field list and runs the
18
+ * contract's own `validate()` against it, merging the result with the live
19
+ * model's session-level verdict. An invalid draft is rejected with
20
+ * `transported: false` — nothing reaches the wire.
21
+ * - **An explicit conflict gate.** A version/etag/precondition failure (or a
22
+ * divergent external change) parks the draft in `"conflict"`; `submit()`
23
+ * refuses to retransport until {@link HonuaFeatureEditorWorkflow.resolveConflict}
24
+ * records a decision (REQ-004 / REQ-005).
25
+ * - **Redacted state.** Snapshots and events describe staged attachments but
26
+ * never carry the payload — a `Blob` or a (possibly credential-bearing)
27
+ * string URL stays inside the edit session.
28
+ *
29
+ * No protocol adapter is imported and no status is ever optimistic about a
30
+ * rejected edit: every non-`"succeeded"` submit result maps to
31
+ * `"rejected"` / `"conflict"` / `"unsupported"` / `"cancelled"`, never
32
+ * `"committed"`.
33
+ *
34
+ * @module
35
+ */
36
+ import { createEditSession, } from "../contract/edit-session.js";
37
+ import { createEditSketchWorkflow, } from "../contract/edit-sketch.js";
38
+ import { buildEditorFormModel, coerceEditorFieldValue, editorFieldsFromSchema, editorOperationAvailability, redactEditorAttachment, resolveActiveEditorSubtype, resolveEditorFields, resolveEditorOperations, } from "./feature-editor-model.js";
39
+ const EMPTY_UNDO = { canUndo: false, canRedo: false, undoDepth: 0, redoDepth: 0 };
40
+ const CONFLICT_CHOICES = ["keep-mine", "discard-mine", "reload"];
41
+ export class HonuaFeatureEditorWorkflow {
42
+ source;
43
+ #options;
44
+ #listeners = new Set();
45
+ #schemaFields;
46
+ #sketchTools;
47
+ #model;
48
+ #selection;
49
+ #operation;
50
+ #status = "idle";
51
+ #identity;
52
+ #baseline;
53
+ #failures = [];
54
+ #attachments = [];
55
+ #conflict;
56
+ #message = "";
57
+ #committedFeatureId;
58
+ #abort;
59
+ /**
60
+ * The model whose in-flight submit was cancelled. Identity, not a boolean:
61
+ * `AbortSignal` is advisory — a transport may ignore it and settle anyway —
62
+ * so a completion has to prove it still owns the workflow before touching
63
+ * shared state.
64
+ */
65
+ #cancelledModel;
66
+ constructor(options) {
67
+ this.source = options.source;
68
+ this.#options = options;
69
+ this.#sketchTools = options.sketchTools;
70
+ this.#schemaFields = resolveSchemaFields(options);
71
+ this.#message = "Pick an operation to start editing.";
72
+ }
73
+ // ── subscription ───────────────────────────────────────────────────────
74
+ /** Subscribes to snapshot changes. Mirrors the kit's `{ remove() }` idiom. */
75
+ subscribe(listener) {
76
+ this.#listeners.add(listener);
77
+ return {
78
+ remove: () => {
79
+ this.#listeners.delete(listener);
80
+ },
81
+ };
82
+ }
83
+ // ── inspection ─────────────────────────────────────────────────────────
84
+ /** The underlying contract sketch workflow, for a sketch adapter to bind to. */
85
+ sketchModel() {
86
+ return this.#model;
87
+ }
88
+ /**
89
+ * Subtype-resolved field metadata for the open draft, or — before one is
90
+ * opened — for the recorded selection. Resolving against the selection
91
+ * matters: a subtype's overrides can flip a field's editability, so using the
92
+ * default subtype here would report the wrong `update` / `delete`
93
+ * availability for the feature the host actually selected.
94
+ */
95
+ fields() {
96
+ return this.#fieldsFor(this.#values());
97
+ }
98
+ /** Truthful per-operation availability for the current source and selection. */
99
+ operations() {
100
+ return this.#operationsFor(this.#values());
101
+ }
102
+ /**
103
+ * Merged pre-transport validation: the contract validator run over the
104
+ * subtype-resolved field list, unioned with the live session's own
105
+ * session-level verdict (capability, identity, attachment support).
106
+ */
107
+ validate() {
108
+ const model = this.#model;
109
+ if (!model || !this.#operation) {
110
+ return { valid: false, errors: [{ code: "required", message: "No edit draft is open." }] };
111
+ }
112
+ const sessionErrors = model.validate().errors;
113
+ const derivedErrors = createEditSession({
114
+ source: this.source,
115
+ kind: this.#operation,
116
+ ...(this.#baseline ? { feature: this.#baseline } : {}),
117
+ values: this.#values(),
118
+ metadata: { fields: this.fields() },
119
+ }).validate().errors;
120
+ const errors = dedupeValidationErrors([...sessionErrors, ...derivedErrors]);
121
+ return { valid: errors.length === 0, errors };
122
+ }
123
+ /** Current UI state. Never contains an attachment payload. */
124
+ snapshot() {
125
+ const model = this.#model;
126
+ const sketch = model?.snapshot().sketch;
127
+ return {
128
+ status: this.#status,
129
+ ...(this.#operation ? { operation: this.#operation } : {}),
130
+ ...(this.#identity ? { identity: this.#identity } : {}),
131
+ operations: this.operations(),
132
+ ...(model && this.#operation
133
+ ? {
134
+ form: buildEditorFormModel({
135
+ operation: this.#operation,
136
+ fields: this.fields(),
137
+ values: this.#values(),
138
+ validation: this.validate(),
139
+ ...(this.#options.subtypes ? { subtypes: this.#options.subtypes } : {}),
140
+ }),
141
+ }
142
+ : {}),
143
+ ...(sketch
144
+ ? {
145
+ sketch: {
146
+ status: sketch.status,
147
+ ...(sketch.activeTool ? { activeTool: sketch.activeTool } : {}),
148
+ tools: sketch.tools,
149
+ geometry: sketch.geometry,
150
+ snapping: { enabled: sketch.snapping.enabled, tolerance: sketch.snapping.tolerance },
151
+ },
152
+ }
153
+ : {}),
154
+ undo: model?.snapshot().undo ?? EMPTY_UNDO,
155
+ attachments: this.#attachments,
156
+ attachmentsSupported: this.source.capabilities.has("attachments"),
157
+ failures: this.#failures,
158
+ ...(this.#conflict ? { conflict: this.#conflict } : {}),
159
+ dirty: model?.dirty() ?? false,
160
+ busy: this.#status === "submitting",
161
+ message: this.#message,
162
+ ...(this.#committedFeatureId !== undefined ? { committedFeatureId: this.#committedFeatureId } : {}),
163
+ };
164
+ }
165
+ // ── draft lifecycle ────────────────────────────────────────────────────
166
+ /**
167
+ * Records the host's current selection (table row, map click, search result)
168
+ * so `update` / `delete` availability reflects it before a draft is opened.
169
+ *
170
+ * Ignored while a draft is open — an open draft owns its identity. A
171
+ * *committed* draft is the one exception: it is finished, and the host's
172
+ * re-read of the record it just wrote carries the only fresh concurrency
173
+ * token there is. Refusing it would leave `selection()` on the pre-commit
174
+ * feature, so the next `update` would reopen on a stale token the service has
175
+ * to reject. Accepting it closes the finished draft without touching the
176
+ * committed status or message — nothing about the commit is retracted.
177
+ */
178
+ setSelection(feature) {
179
+ if (this.#model && this.#status !== "committed")
180
+ return this.snapshot();
181
+ if (this.#model) {
182
+ this.#model = undefined;
183
+ this.#operation = undefined;
184
+ this.#baseline = undefined;
185
+ this.#attachments = [];
186
+ }
187
+ this.#selection = feature ? cloneFeature(feature) : undefined;
188
+ this.#identity = this.#identityFor(feature);
189
+ return this.#notify();
190
+ }
191
+ /** The host selection `update` / `delete` drafts open on. */
192
+ selection() {
193
+ return this.#selection;
194
+ }
195
+ /**
196
+ * Opens a draft, defaulting to the recorded selection. When the requested
197
+ * operation is not truthfully available the draft is refused and the reason
198
+ * recorded — never silently downgraded.
199
+ */
200
+ begin(operation, feature) {
201
+ const target = operation === "create" ? feature : (feature ?? this.#selection);
202
+ this.#abort?.abort();
203
+ // Any in-flight submit now belongs to a superseded draft, cancelled or not.
204
+ this.#cancelledModel = undefined;
205
+ this.#conflict = undefined;
206
+ this.#failures = [];
207
+ this.#attachments = [];
208
+ this.#committedFeatureId = undefined;
209
+ this.#identity = this.#identityFor(target);
210
+ // Resolve availability against the feature this draft would open on, not
211
+ // the standing selection — an explicit `feature` argument may carry a
212
+ // different subtype, and subtypes can flip field editability.
213
+ const availability = editorOperationAvailability(this.#operationsFor((target?.attributes ?? {})), operation);
214
+ if (!availability.available) {
215
+ this.#model = undefined;
216
+ this.#operation = undefined;
217
+ this.#status = isCapabilityGate(availability.code) ? "unsupported" : "rejected";
218
+ this.#message = availability.reason ?? `${operation} is not available for this source.`;
219
+ this.#failures = [
220
+ {
221
+ kind: isCapabilityGate(availability.code) ? "capability" : "validation",
222
+ operation: "session",
223
+ description: this.#message,
224
+ },
225
+ ];
226
+ return this.#notify();
227
+ }
228
+ this.#operation = operation;
229
+ this.#baseline = target ? cloneFeature(target) : undefined;
230
+ this.#model = this.#createModel(operation, target);
231
+ if (operation === "create")
232
+ this.#seedCreateDefaults();
233
+ this.#status = "draft";
234
+ this.#message = draftMessage(operation);
235
+ return this.#notify();
236
+ }
237
+ /** Discards the draft and aborts any in-flight submit. */
238
+ cancel() {
239
+ if (this.#status === "submitting")
240
+ this.#cancelledModel = this.#model;
241
+ this.#abort?.abort();
242
+ this.#abort = undefined;
243
+ this.#model = undefined;
244
+ this.#operation = undefined;
245
+ this.#baseline = undefined;
246
+ this.#conflict = undefined;
247
+ this.#failures = [];
248
+ this.#attachments = [];
249
+ this.#status = "cancelled";
250
+ this.#message = "Edit cancelled. Nothing was sent to the service.";
251
+ return this.#notify();
252
+ }
253
+ /** Reverts the draft to the feature it opened on, keeping the draft open. */
254
+ discardChanges() {
255
+ this.#model?.discard();
256
+ this.#attachments = [];
257
+ this.#failures = [];
258
+ this.#conflict = undefined;
259
+ if (this.#model)
260
+ this.#status = "draft";
261
+ this.#message = "Draft reverted to the last known server values.";
262
+ return this.#notify();
263
+ }
264
+ // ── attribute editing ──────────────────────────────────────────────────
265
+ /**
266
+ * Records raw control input for a field, coerced to the field's own type
267
+ * (coded values resolve back to the domain code's type, so a numeric code
268
+ * never reaches transport as a string).
269
+ */
270
+ setValue(name, raw) {
271
+ const model = this.#model;
272
+ if (!model)
273
+ return this.snapshot();
274
+ const field = this.fields().find((entry) => entry.name === name) ?? { name };
275
+ model.setValue(name, coerceEditorFieldValue(field, raw));
276
+ this.#afterEdit();
277
+ return this.#notify();
278
+ }
279
+ /** Records several already-typed attribute values at once. */
280
+ setValues(values) {
281
+ const model = this.#model;
282
+ if (!model)
283
+ return this.snapshot();
284
+ model.setValues(values);
285
+ this.#afterEdit();
286
+ return this.#notify();
287
+ }
288
+ // ── geometry ───────────────────────────────────────────────────────────
289
+ /** Arms a sketch tool. Unsupported tools report their reason rather than throwing. */
290
+ startSketch(tool) {
291
+ const model = this.#model;
292
+ if (!model)
293
+ return { tool, state: "unsupported", reason: "No edit draft is open." };
294
+ const capability = model.startSketch(tool);
295
+ this.#message =
296
+ capability.state === "supported"
297
+ ? `Sketching a ${tool}. Enter coordinates or draw on the map.`
298
+ : (capability.reason ?? `The ${tool} tool is not available.`);
299
+ this.#notify();
300
+ return capability;
301
+ }
302
+ /** Applies a geometry through the sketch workflow (undoable). */
303
+ setGeometry(tool, geometry) {
304
+ const model = this.#model;
305
+ if (!model)
306
+ return this.snapshot();
307
+ model.setSketchGeometry(tool, geometry);
308
+ this.#afterEdit();
309
+ return this.#notify();
310
+ }
311
+ undo() {
312
+ const undone = this.#model?.undo() ?? false;
313
+ if (undone) {
314
+ this.#afterEdit();
315
+ this.#notify();
316
+ }
317
+ return undone;
318
+ }
319
+ redo() {
320
+ const redone = this.#model?.redo() ?? false;
321
+ if (redone) {
322
+ this.#afterEdit();
323
+ this.#notify();
324
+ }
325
+ return redone;
326
+ }
327
+ // ── attachments ────────────────────────────────────────────────────────
328
+ /**
329
+ * Stages an attachment. The payload is handed straight to the edit session;
330
+ * only redacted metadata enters the snapshot.
331
+ */
332
+ stageAttachment(attachment, options = {}) {
333
+ const model = this.#model;
334
+ if (!model)
335
+ return this.snapshot();
336
+ model.stageAttachmentAdd(attachment, options);
337
+ this.#syncAttachmentDrafts();
338
+ this.#afterEdit();
339
+ return this.#notify();
340
+ }
341
+ /** Stages an attachment deletion. */
342
+ stageAttachmentDelete(attachmentIds, options = {}) {
343
+ const model = this.#model;
344
+ if (!model)
345
+ return this.snapshot();
346
+ model.stageAttachmentDelete(attachmentIds, options);
347
+ this.#syncAttachmentDrafts();
348
+ this.#afterEdit();
349
+ return this.#notify();
350
+ }
351
+ // ── sketch adapter seam ────────────────────────────────────────────────
352
+ /**
353
+ * Replaces the sketch tool capabilities (a sketch adapter such as terra-draw
354
+ * calls this so `rectangle` / `circle` become truthfully supported). An open
355
+ * draft is rebuilt in place, preserving values, geometry, and staged
356
+ * attachments; undo history restarts because tool support is a new baseline.
357
+ */
358
+ configureSketchTools(tools) {
359
+ // Idempotent: re-declaring the same tool support must not rebuild an open
360
+ // draft (a sketch adapter may re-announce it on every attach).
361
+ if (JSON.stringify(this.#sketchTools ?? null) === JSON.stringify(tools))
362
+ return this.snapshot();
363
+ this.#sketchTools = tools;
364
+ const model = this.#model;
365
+ // Never swap the model out from under an in-flight submit: that would make
366
+ // the pending completion look superseded and silently drop its outcome.
367
+ // The new tool support applies to the next draft instead.
368
+ if (model && this.#operation && this.#status !== "submitting") {
369
+ const snapshot = model.snapshot();
370
+ this.#model = this.#createModel(this.#operation, snapshot.feature);
371
+ for (const mutation of snapshot.attachments)
372
+ restageAttachment(this.#model, mutation);
373
+ }
374
+ return this.#notify();
375
+ }
376
+ // ── submit ─────────────────────────────────────────────────────────────
377
+ /**
378
+ * Validates, then applies the draft through the contract edit session.
379
+ *
380
+ * Refuses to transport when the draft is invalid or an unresolved conflict is
381
+ * parked (`transported: false`). Every non-success result maps to a
382
+ * rejected / conflict / unsupported state — never `"committed"`.
383
+ */
384
+ async submit() {
385
+ const model = this.#model;
386
+ const operation = this.#operation;
387
+ if (!model || !operation) {
388
+ return this.#preflightRejection("create", "No edit draft is open.", "validation");
389
+ }
390
+ if (this.#conflict) {
391
+ return this.#preflightRejection(operation, "Resolve the conflicting change before submitting again.", "conflict", "conflict");
392
+ }
393
+ const validation = this.validate();
394
+ if (!validation.valid) {
395
+ this.#status = validation.errors.some((error) => error.code === "unsupported") ? "unsupported" : "rejected";
396
+ this.#failures = validation.errors.map((error) => ({
397
+ kind: error.code === "unsupported" ? "capability" : "validation",
398
+ operation: "session",
399
+ description: error.message,
400
+ }));
401
+ this.#message = "The draft was not sent: fix the highlighted problems first.";
402
+ return this.#commit(operation, false);
403
+ }
404
+ this.#status = "submitting";
405
+ this.#failures = [];
406
+ this.#message = `Applying the ${operation}…`;
407
+ this.#notify();
408
+ const result = await model.submit();
409
+ // An `AbortSignal` is advisory: a transport may settle a cancelled — or
410
+ // superseded — submit anyway. Before touching shared state, prove this
411
+ // completion still owns the workflow. Otherwise a slow first submit could
412
+ // stamp its own outcome (and identity, baseline, and message) onto a draft
413
+ // that was opened after it started.
414
+ const cancelledThisSubmit = this.#cancelledModel === model;
415
+ if (cancelledThisSubmit)
416
+ this.#cancelledModel = undefined;
417
+ if (this.#model !== model) {
418
+ // `begin()` clears `#cancelledModel`, so reaching here with it still set
419
+ // means the cancel was the last thing to touch the workflow.
420
+ if (cancelledThisSubmit && this.#model === undefined) {
421
+ this.#message = "Edit cancelled while it was being applied; treat the outcome as unknown.";
422
+ this.#failures = result.failures.map(normalizeFailure);
423
+ return this.#commit(operation, true);
424
+ }
425
+ return this.#supersededCommit(operation, model, result);
426
+ }
427
+ this.#failures = result.failures.map(normalizeFailure);
428
+ this.#attachments = attachmentDraftsFromResult(model, result.attachmentResults);
429
+ const conflictFailure = result.failures.find((failure) => failure.kind === "conflict");
430
+ if (result.status === "succeeded") {
431
+ this.#status = "committed";
432
+ this.#committedFeatureId = result.committedFeatureId;
433
+ this.#identity = this.#identityAfterCommit(result.committedFeatureId);
434
+ this.#baseline = cloneFeature(model.snapshot().feature);
435
+ this.#message = committedMessage(operation, result.committedFeatureId);
436
+ }
437
+ else if (conflictFailure || result.conflict) {
438
+ const info = conflictFailure?.conflict ?? result.conflict;
439
+ this.#status = "conflict";
440
+ this.#conflict = conflictFrom(info, this.#identity, conflictFailure?.description);
441
+ this.#message = "Someone else changed this feature. Choose how to resolve it.";
442
+ }
443
+ else if (result.status === "unsupported") {
444
+ this.#status = "unsupported";
445
+ this.#message = result.failures[0]?.description ?? "This source does not support the requested edit.";
446
+ }
447
+ else if (result.status === "partial") {
448
+ this.#status = "rejected";
449
+ this.#message = "The edit only partly applied. Review the failures before retrying.";
450
+ }
451
+ else {
452
+ this.#status = "rejected";
453
+ this.#message = result.failures[0]?.description ?? "The service rejected the edit.";
454
+ }
455
+ return this.#commit(operation, true);
456
+ }
457
+ /** Re-submits after a rejection (or after a resolved conflict). */
458
+ async retry() {
459
+ if (this.#status === "submitting") {
460
+ return this.#preflightRejection(this.#operation ?? "create", "An edit is already in flight.", "validation");
461
+ }
462
+ if (this.#status !== "conflict")
463
+ this.#status = "draft";
464
+ return this.submit();
465
+ }
466
+ // ── conflict resolution ────────────────────────────────────────────────
467
+ /**
468
+ * Records an explicit decision for a parked conflict.
469
+ *
470
+ * - `"keep-mine"` keeps the draft and, when the source's concurrency token
471
+ * is known, adopts the observed server token so the retry can pass the
472
+ * service's precondition (an explicit overwrite).
473
+ * - `"discard-mine"` reverts the draft to the feature it opened on.
474
+ * - `"reload"` closes the draft; the host is expected to reopen it on fresh
475
+ * server state.
476
+ */
477
+ resolveConflict(choice) {
478
+ const conflict = this.#conflict;
479
+ if (!conflict)
480
+ return this.snapshot();
481
+ if (choice === "reload") {
482
+ this.#conflict = undefined;
483
+ this.#model = undefined;
484
+ this.#operation = undefined;
485
+ this.#baseline = undefined;
486
+ this.#status = "idle";
487
+ this.#message = "Reload the feature to continue editing the current server version.";
488
+ return this.#notify();
489
+ }
490
+ if (choice === "discard-mine") {
491
+ this.#conflict = undefined;
492
+ this.#model?.discard();
493
+ this.#status = this.#model ? "draft" : "idle";
494
+ this.#message = "Your changes were discarded; the draft matches the last known server values.";
495
+ this.#failures = [];
496
+ return this.#notify();
497
+ }
498
+ this.#conflict = undefined;
499
+ if (conflict.versionField !== undefined && conflict.theirs !== undefined) {
500
+ this.#model?.setValue(conflict.versionField, conflict.theirs);
501
+ if (this.#identity)
502
+ this.#identity = { ...this.#identity, version: conflict.theirs };
503
+ }
504
+ this.#status = "draft";
505
+ this.#failures = [];
506
+ this.#message = "Keeping your changes. Submitting again will overwrite the other edit.";
507
+ return this.#notify();
508
+ }
509
+ /**
510
+ * Adopts the service's post-write concurrency token into a draft that stays
511
+ * open on purpose — the partial outcome where the feature edit applied and
512
+ * only an attachment was refused (issue #680).
513
+ *
514
+ * A protocol like GeoServices returns nothing but `objectId` and `success`
515
+ * from `applyEdits`, so the new token exists only in the host's re-read of the
516
+ * record. Feeding that back through {@link setValue} would read as a user
517
+ * revision and clear the rejected status and the recorded failures — erasing
518
+ * exactly the truth about what the service refused. This does not: status,
519
+ * failures, and staged-attachment outcomes all survive, so {@link retry}
520
+ * re-transports the same recovery against the version now on file instead of
521
+ * the one the accepted write already consumed.
522
+ *
523
+ * The draft's own baseline is deliberately left alone: it is still the values
524
+ * this draft opened on, and moving it could make an unrelated server-assigned
525
+ * field look like a read-only edit the user made.
526
+ *
527
+ * Returns `false` — changing nothing — when no draft is open, when the record
528
+ * is a different feature, or when the source publishes no concurrency token.
529
+ */
530
+ adoptServerState(feature) {
531
+ const model = this.#model;
532
+ const identity = this.#identity;
533
+ if (!model || identity?.featureId === undefined)
534
+ return false;
535
+ if (feature.id === undefined || String(feature.id) !== String(identity.featureId))
536
+ return false;
537
+ const field = identity.versionField;
538
+ if (field === undefined)
539
+ return false;
540
+ const token = feature.attributes[field];
541
+ if (token === undefined)
542
+ return false;
543
+ model.setValue(field, token);
544
+ this.#identity = { ...identity, version: token };
545
+ this.#notify();
546
+ return true;
547
+ }
548
+ // ── realtime reconciliation ────────────────────────────────────────────
549
+ /**
550
+ * Reconciles one externally changed feature against the open draft (REQ-005).
551
+ *
552
+ * - A change to a different feature is `"ignored"`: no state changes and no
553
+ * listener runs, so selection, unsaved values, focus, and geometry survive
554
+ * unrelated realtime traffic untouched.
555
+ * - A change to the drafted feature carrying the same concurrency token is
556
+ * `"reconciled"`: the baseline adopts the server values, and only fields the
557
+ * user has *not* touched adopt the new values.
558
+ * - A change carrying a different token is a `"conflict"`: the draft is
559
+ * untouched and parked until {@link resolveConflict} records a decision.
560
+ */
561
+ applyExternalChange(feature) {
562
+ const model = this.#model;
563
+ const identity = this.#identity;
564
+ if (!model || identity === undefined || identity.featureId === undefined)
565
+ return "ignored";
566
+ if (feature.id === undefined || String(feature.id) !== String(identity.featureId))
567
+ return "ignored";
568
+ const conflictInfo = model.snapshot().metadata.conflict;
569
+ const theirs = tokenOf(feature, conflictInfo);
570
+ const mine = identity.version;
571
+ if (theirs !== undefined && mine !== undefined && String(theirs) !== String(mine)) {
572
+ this.#conflict = {
573
+ reason: "The feature changed on the server while this draft was open.",
574
+ ...(identity.versionField ? { versionField: identity.versionField } : {}),
575
+ mine,
576
+ theirs,
577
+ choices: CONFLICT_CHOICES,
578
+ };
579
+ this.#status = "conflict";
580
+ this.#message = "Someone else changed this feature. Choose how to resolve it.";
581
+ this.#notify();
582
+ return "conflict";
583
+ }
584
+ const baseline = this.#baseline;
585
+ const current = model.snapshot().feature.attributes;
586
+ const incoming = feature.attributes;
587
+ const untouched = {};
588
+ for (const [name, value] of Object.entries(incoming)) {
589
+ const previous = baseline?.attributes?.[name];
590
+ if (sameScalar(current[name], previous))
591
+ untouched[name] = value;
592
+ }
593
+ this.#baseline = cloneFeature(feature);
594
+ if (Object.keys(untouched).length > 0)
595
+ model.setValues(untouched);
596
+ if (theirs !== undefined)
597
+ this.#identity = { ...identity, version: theirs };
598
+ this.#message = "Adopted the latest server values for fields you have not changed.";
599
+ this.#notify();
600
+ return "reconciled";
601
+ }
602
+ // ── internals ──────────────────────────────────────────────────────────
603
+ #createModel(operation, feature) {
604
+ this.#abort = new AbortController();
605
+ return createEditSketchWorkflow({
606
+ source: this.source,
607
+ kind: operation,
608
+ ...(feature ? { feature: cloneFeature(feature) } : {}),
609
+ metadata: {
610
+ fields: this.#baseFields(),
611
+ ...(this.#options.metadata?.primaryKey ? { primaryKey: this.#options.metadata.primaryKey } : {}),
612
+ ...(this.#options.metadata?.relationships ? { relationships: this.#options.metadata.relationships } : {}),
613
+ ...(this.#options.metadata?.conflict ? { conflict: this.#options.metadata.conflict } : {}),
614
+ },
615
+ ...(this.#sketchTools ? { sketchTools: this.#sketchTools } : {}),
616
+ ...(this.#options.snapping ? { snapping: this.#options.snapping } : {}),
617
+ ...(this.#options.rollbackOnFailure !== undefined ? { rollbackOnFailure: this.#options.rollbackOnFailure } : {}),
618
+ ...(this.#options.optimistic ? { optimistic: this.#options.optimistic } : {}),
619
+ signal: this.#abort.signal,
620
+ });
621
+ }
622
+ /**
623
+ * Field list the long-lived session validates against: subtype *domain* but
624
+ * no per-subtype overrides, so the session never rejects a value the active
625
+ * subtype allows. Subtype-specific rules are enforced by {@link validate}
626
+ * over the resolved list before anything is transported.
627
+ */
628
+ #baseFields() {
629
+ const subtypes = this.#options.subtypes;
630
+ return resolveEditorFields(this.#schemaFields, subtypes ? { field: subtypes.field, subtypes: subtypes.subtypes } : undefined, {});
631
+ }
632
+ #seedCreateDefaults() {
633
+ const subtypes = this.#options.subtypes;
634
+ if (!subtypes || subtypes.defaultCode === undefined)
635
+ return;
636
+ const active = resolveActiveEditorSubtype(subtypes, {});
637
+ this.#model?.setValues({ [subtypes.field]: active?.code ?? subtypes.defaultCode });
638
+ }
639
+ /**
640
+ * Attribute values subtype resolution runs against: the open draft's, or the
641
+ * recorded selection's while no draft is open.
642
+ */
643
+ #values() {
644
+ const model = this.#model;
645
+ if (model)
646
+ return model.snapshot().feature.attributes;
647
+ return (this.#selection?.attributes ?? {});
648
+ }
649
+ #fieldsFor(values) {
650
+ return resolveEditorFields(this.#schemaFields, this.#options.subtypes, values);
651
+ }
652
+ #operationsFor(values) {
653
+ return resolveEditorOperations({
654
+ capabilities: { applyEdits: this.source.capabilities.has("applyEdits") ? "supported" : "unsupported" },
655
+ ...(capabilityDecisions(this.source) ? { decisions: capabilityDecisions(this.source) } : {}),
656
+ hasFeatureIdentity: this.#identity?.featureId !== undefined,
657
+ fields: this.#fieldsFor(values),
658
+ ...(this.#options.operations ? { overrides: this.#options.operations } : {}),
659
+ });
660
+ }
661
+ #identityFor(feature) {
662
+ const conflict = conflictInfoFor(this.source, this.#schemaFields, feature, this.#options.metadata);
663
+ const token = feature ? tokenOf(feature, conflict) : undefined;
664
+ return {
665
+ sourceId: this.source.descriptor.id,
666
+ ...(feature?.id !== undefined ? { featureId: feature.id } : {}),
667
+ ...(token !== undefined ? { version: token } : {}),
668
+ ...(conflictFieldName(conflict) ? { versionField: conflictFieldName(conflict) } : {}),
669
+ };
670
+ }
671
+ #identityAfterCommit(committedFeatureId) {
672
+ const identity = this.#identity;
673
+ if (!identity)
674
+ return undefined;
675
+ const model = this.#model;
676
+ const conflict = model?.snapshot().metadata.conflict;
677
+ const token = model && conflict ? tokenOf(model.snapshot().feature, conflict) : identity.version;
678
+ return {
679
+ ...identity,
680
+ ...(committedFeatureId !== undefined ? { featureId: committedFeatureId } : {}),
681
+ ...(token !== undefined ? { version: token } : {}),
682
+ };
683
+ }
684
+ #afterEdit() {
685
+ if (this.#status === "committed" || this.#status === "rejected" || this.#status === "cancelled") {
686
+ this.#status = "draft";
687
+ this.#failures = [];
688
+ this.#message = draftMessage(this.#operation ?? "update");
689
+ }
690
+ }
691
+ #syncAttachmentDrafts() {
692
+ const model = this.#model;
693
+ if (!model)
694
+ return;
695
+ this.#attachments = model
696
+ .snapshot()
697
+ .attachments.map((mutation, index) => redactEditorAttachment(mutation, index, "staged"));
698
+ }
699
+ #preflightRejection(operation, description, kind, status = "rejected") {
700
+ this.#status = status;
701
+ this.#failures = [{ kind, operation: "session", description }];
702
+ this.#message = description;
703
+ this.#notify();
704
+ return {
705
+ status: this.#status,
706
+ operation,
707
+ transported: false,
708
+ failures: this.#failures,
709
+ attachments: this.#attachments,
710
+ snapshot: this.snapshot(),
711
+ };
712
+ }
713
+ /**
714
+ * Result of a submit that no longer owns the workflow. Built entirely from
715
+ * the caller's own locals — shared state belongs to the newer draft and is
716
+ * deliberately left untouched, and no listener is notified.
717
+ */
718
+ #supersededCommit(operation, model, result) {
719
+ return {
720
+ status: "cancelled",
721
+ operation,
722
+ transported: true,
723
+ superseded: true,
724
+ failures: result.failures.map(normalizeFailure),
725
+ attachments: attachmentDraftsFromResult(model, result.attachmentResults),
726
+ snapshot: this.snapshot(),
727
+ };
728
+ }
729
+ #commit(operation, transported) {
730
+ const snapshot = this.#notify();
731
+ return {
732
+ status: this.#status,
733
+ operation,
734
+ transported,
735
+ ...(this.#committedFeatureId !== undefined && this.#status === "committed"
736
+ ? { committedFeatureId: this.#committedFeatureId }
737
+ : {}),
738
+ failures: this.#failures,
739
+ attachments: this.#attachments,
740
+ snapshot,
741
+ };
742
+ }
743
+ #notify() {
744
+ const snapshot = this.snapshot();
745
+ for (const listener of [...this.#listeners])
746
+ listener(snapshot);
747
+ return snapshot;
748
+ }
749
+ }
750
+ /** Creates a {@link HonuaFeatureEditorWorkflow}. */
751
+ export function createFeatureEditorWorkflow(options) {
752
+ return new HonuaFeatureEditorWorkflow(options);
753
+ }
754
+ // ── module helpers ───────────────────────────────────────────────────────
755
+ function resolveSchemaFields(options) {
756
+ const declared = options.metadata?.fields;
757
+ const fields = declared ?? editorFieldsFromSchema(options.source.descriptor.schema?.fields);
758
+ const domains = options.metadata?.domains;
759
+ if (!domains)
760
+ return fields;
761
+ return fields.map((field) => field.domain || !domains[field.name] ? field : { ...field, domain: domains[field.name] });
762
+ }
763
+ function capabilityDecisions(source) {
764
+ // The evaluated profile may live on the capability-aware source, on its
765
+ // descriptor, or nowhere at all; only the coarse capability set is guaranteed.
766
+ const carrier = source;
767
+ const entries = (carrier.capabilityProfile ?? carrier.descriptor?.capabilityProfile)?.entries;
768
+ if (!entries)
769
+ return undefined;
770
+ return entries;
771
+ }
772
+ function isCapabilityGate(code) {
773
+ return (code === "capability-unsupported" ||
774
+ code === "capability-unknown" ||
775
+ code === "authorization-required" ||
776
+ code === "authorization-denied" ||
777
+ code === "policy-disabled" ||
778
+ code === "peer-unavailable");
779
+ }
780
+ function conflictInfoFor(source, fields, feature, metadata) {
781
+ // Reuse the contract's own conflict inference rather than re-deriving which
782
+ // field carries the concurrency token.
783
+ const session = createEditSession({
784
+ source,
785
+ kind: feature?.id === undefined ? "create" : "update",
786
+ ...(feature ? { feature } : {}),
787
+ metadata: { fields, ...(metadata?.conflict ? { conflict: metadata.conflict } : {}) },
788
+ });
789
+ return session.metadata().conflict;
790
+ }
791
+ function conflictFieldName(conflict) {
792
+ return conflict.versionField ?? conflict.etagField ?? conflict.updatedAtField;
793
+ }
794
+ function tokenOf(feature, conflict) {
795
+ const attributes = feature.attributes;
796
+ const field = conflictFieldName(conflict);
797
+ return field ? attributes[field] : undefined;
798
+ }
799
+ function conflictFrom(info, identity, description) {
800
+ const field = info ? conflictFieldName(info) : identity?.versionField;
801
+ return {
802
+ reason: info?.reason ?? description ?? "The service reported a version conflict.",
803
+ ...(field ? { versionField: field } : {}),
804
+ ...(identity?.version !== undefined ? { mine: identity.version } : {}),
805
+ ...(info?.value !== undefined && String(info.value) !== String(identity?.version) ? { theirs: info.value } : {}),
806
+ choices: CONFLICT_CHOICES,
807
+ };
808
+ }
809
+ function normalizeFailure(failure) {
810
+ return {
811
+ kind: failure.kind,
812
+ operation: failure.operation,
813
+ ...(failure.code !== undefined ? { code: failure.code } : {}),
814
+ description: failure.description,
815
+ };
816
+ }
817
+ function attachmentDraftsFromResult(model, results) {
818
+ const staged = model.snapshot().attachments;
819
+ return results.map((result) => {
820
+ const mutation = staged[result.index] ?? { operation: result.operation };
821
+ const failure = result.outcomes.find((outcome) => !outcome.success);
822
+ if (!failure)
823
+ return redactEditorAttachment(mutation, result.index, "applied");
824
+ const skipped = failure.error?.code === 409 && failure.error.description.includes("skipped");
825
+ return redactEditorAttachment(mutation, result.index, skipped ? "skipped" : "failed", failure.error?.description ?? "The attachment was rejected.");
826
+ });
827
+ }
828
+ function restageAttachment(model, mutation) {
829
+ const options = {
830
+ ...(mutation.parentId !== undefined ? { parentId: mutation.parentId } : {}),
831
+ ...(mutation.name !== undefined ? { name: mutation.name } : {}),
832
+ ...(mutation.contentType !== undefined ? { contentType: mutation.contentType } : {}),
833
+ };
834
+ if (mutation.operation === "add" && mutation.attachment !== undefined) {
835
+ model.stageAttachmentAdd(mutation.attachment, options);
836
+ return;
837
+ }
838
+ if (mutation.operation === "update" && mutation.attachment !== undefined && mutation.attachmentId !== undefined) {
839
+ model.stageAttachmentUpdate(mutation.attachmentId, mutation.attachment, options);
840
+ return;
841
+ }
842
+ if (mutation.operation === "delete" && mutation.attachmentIds) {
843
+ model.stageAttachmentDelete(mutation.attachmentIds, options);
844
+ }
845
+ }
846
+ function dedupeValidationErrors(errors) {
847
+ const seen = new Set();
848
+ const out = [];
849
+ for (const error of errors) {
850
+ const key = `${error.code}|${error.fieldName ?? ""}|${error.message}`;
851
+ if (seen.has(key))
852
+ continue;
853
+ seen.add(key);
854
+ out.push(error);
855
+ }
856
+ return out;
857
+ }
858
+ function draftMessage(operation) {
859
+ if (operation === "create")
860
+ return "New feature draft. Complete the required fields, then submit.";
861
+ if (operation === "delete")
862
+ return "Deleting this feature. Submit to confirm.";
863
+ return "Editing the selected feature. Submit to apply your changes.";
864
+ }
865
+ function committedMessage(operation, id) {
866
+ if (operation === "delete")
867
+ return "Feature deleted.";
868
+ const suffix = id === undefined ? "" : ` (id ${String(id)})`;
869
+ return operation === "create" ? `Feature created${suffix}.` : `Feature updated${suffix}.`;
870
+ }
871
+ function cloneFeature(feature) {
872
+ return {
873
+ ...(feature.id !== undefined ? { id: feature.id } : {}),
874
+ attributes: { ...feature.attributes },
875
+ ...(feature.geometry !== undefined ? { geometry: feature.geometry === null ? null : { ...feature.geometry } } : {}),
876
+ };
877
+ }
878
+ function sameScalar(left, right) {
879
+ if (Object.is(left, right))
880
+ return true;
881
+ if (left === undefined || right === undefined)
882
+ return left === right;
883
+ if (left === null || right === null)
884
+ return left === right;
885
+ if (typeof left === "object" || typeof right === "object") {
886
+ return JSON.stringify(left) === JSON.stringify(right);
887
+ }
888
+ return String(left) === String(right);
889
+ }