@honua/app-platform 0.1.2-beta.0 → 0.1.7-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.
- package/app/index.d.ts.map +1 -1
- package/app/index.js +4 -0
- package/collaboration/client.d.ts.map +1 -1
- package/connect-url-safety.d.ts +53 -0
- package/connect-url-safety.js +189 -0
- package/contract/discovery.d.ts +34 -0
- package/contract/discovery.d.ts.map +1 -1
- package/contract/index.d.ts +7 -5
- package/contract/index.d.ts.map +1 -1
- package/contract/index.js +3 -2
- package/contract/pmtiles.d.ts +10 -1
- package/contract/pmtiles.d.ts.map +1 -1
- package/contract/pmtiles.js +10 -2
- package/contract/protocol-module.d.ts +84 -9
- package/contract/protocol-module.d.ts.map +1 -1
- package/contract/query-filter-fes.d.ts +18 -0
- package/contract/query-filter-fes.d.ts.map +1 -0
- package/contract/query-filter-fes.js +122 -0
- package/contract/query-filter.d.ts +300 -0
- package/contract/query-filter.d.ts.map +1 -0
- package/contract/query-filter.js +883 -0
- package/contract/schema.d.ts +12 -0
- package/contract/schema.d.ts.map +1 -1
- package/contract/schema.js +34 -0
- package/contract/source-capability-support.d.ts.map +1 -1
- package/contract/source-capability-support.js +15 -3
- package/contract/source.d.ts +45 -25
- package/contract/source.d.ts.map +1 -1
- package/contract/source.js +719 -479
- package/contract/types.d.ts +88 -19
- package/contract/types.d.ts.map +1 -1
- package/contract/types.js +4 -0
- package/contract/widget-source.d.ts.map +1 -1
- package/controls/basemap-switcher.d.ts +19 -11
- package/controls/basemap-switcher.d.ts.map +1 -1
- package/controls/basemap-switcher.js +58 -25
- package/controls/catalog.d.ts +105 -0
- package/controls/catalog.d.ts.map +1 -0
- package/controls/catalog.js +341 -0
- package/controls/element-utils.d.ts +9 -0
- package/controls/element-utils.d.ts.map +1 -1
- package/controls/element-utils.js +17 -0
- package/controls/index.d.ts +29 -8
- package/controls/index.d.ts.map +1 -1
- package/controls/index.js +26 -8
- package/controls/layer-list.d.ts +13 -5
- package/controls/layer-list.d.ts.map +1 -1
- package/controls/layer-list.js +72 -15
- package/controls/legend.d.ts +32 -5
- package/controls/legend.d.ts.map +1 -1
- package/controls/legend.js +95 -25
- package/controls/qualification.d.ts +152 -0
- package/controls/qualification.d.ts.map +1 -0
- package/controls/qualification.js +1265 -0
- package/controls/registry.d.ts +104 -0
- package/controls/registry.d.ts.map +1 -0
- package/controls/registry.js +150 -0
- package/controls/swipe-control.d.ts +6 -1
- package/controls/swipe-control.d.ts.map +1 -1
- package/controls/swipe-control.js +82 -20
- package/core/client.d.ts +51 -21
- package/core/client.d.ts.map +1 -1
- package/core/client.js +234 -54
- package/core/error-classifications.d.ts +23 -0
- package/core/error-classifications.d.ts.map +1 -1
- package/core/error-classifications.js +23 -0
- package/core/error-code-registry.d.ts +24 -1
- package/core/error-code-registry.d.ts.map +1 -1
- package/core/error-code-registry.js +23 -0
- package/core/error-envelope.js +2 -0
- package/core/errors.d.ts +2 -2
- package/core/errors.d.ts.map +1 -1
- package/core/errors.js +1 -1
- package/core/geoparquet-sql.d.ts +69 -5
- package/core/geoparquet-sql.d.ts.map +1 -1
- package/core/geoparquet-sql.js +327 -10
- package/core/odata-write-codec.d.ts.map +1 -1
- package/core/odata-write-codec.js +0 -1
- package/core/odata.d.ts +6 -0
- package/core/odata.d.ts.map +1 -1
- package/core/odata.js +6 -1
- package/core/ogc-processes.d.ts +182 -25
- package/core/ogc-processes.d.ts.map +1 -1
- package/core/ogc-processes.js +602 -30
- package/core/process-runner.d.ts +8 -1
- package/core/process-runner.d.ts.map +1 -1
- package/core/process-runner.js +1 -0
- package/core/protocol-transport.d.ts +2 -0
- package/core/protocol-transport.d.ts.map +1 -1
- package/core/stac.d.ts +32 -0
- package/core/stac.d.ts.map +1 -1
- package/core/stac.js +158 -23
- package/core/surfaces.d.ts.map +1 -1
- package/core/surfaces.js +273 -68
- package/core/types.d.ts +138 -11
- package/core/types.d.ts.map +1 -1
- package/core/wfs-axis-order.d.ts +18 -0
- package/core/wfs-axis-order.d.ts.map +1 -0
- package/core/wfs-axis-order.js +108 -0
- package/core/wfs-capabilities.d.ts +65 -24
- package/core/wfs-capabilities.d.ts.map +1 -1
- package/core/wfs-capabilities.js +237 -27
- package/core/wfs-filter.d.ts +26 -3
- package/core/wfs-filter.d.ts.map +1 -1
- package/core/wfs-filter.js +48 -53
- package/core/wfs-protocol-error.d.ts +12 -0
- package/core/wfs-protocol-error.d.ts.map +1 -0
- package/core/wfs-protocol-error.js +13 -0
- package/core/wfs.d.ts +53 -1
- package/core/wfs.d.ts.map +1 -1
- package/core/wfs.js +251 -29
- package/core/wms-feature-info.d.ts +92 -0
- package/core/wms-feature-info.d.ts.map +1 -0
- package/core/wms-feature-info.js +285 -0
- package/core/wms.d.ts +9 -0
- package/core/wms.d.ts.map +1 -1
- package/core/wms.js +17 -5
- package/esri-compat/event-bus.d.ts +20 -0
- package/esri-compat/event-bus.d.ts.map +1 -1
- package/esri-compat/feature-filter.d.ts +46 -1
- package/esri-compat/feature-filter.d.ts.map +1 -1
- package/esri-compat/feature-filter.js +49 -0
- package/esri-compat/geojson-layer.d.ts +4 -2
- package/esri-compat/geojson-layer.d.ts.map +1 -1
- package/esri-compat/geojson-layer.js +4 -2
- package/esri-compat/identity-manager.js +2 -2
- package/esri-compat/imagery-layer.d.ts +2 -1
- package/esri-compat/imagery-layer.d.ts.map +1 -1
- package/esri-compat/imagery-layer.js +2 -1
- package/esri-compat/layer-list.d.ts +8 -1
- package/esri-compat/layer-list.d.ts.map +1 -1
- package/esri-compat/layer-list.js +11 -4
- package/esri-compat/legend.d.ts.map +1 -1
- package/esri-compat/legend.js +1 -1
- package/esri-compat/locator.d.ts +297 -0
- package/esri-compat/locator.d.ts.map +1 -0
- package/esri-compat/locator.js +579 -0
- package/esri-compat/map-image-layer.d.ts.map +1 -1
- package/esri-compat/map-view.d.ts.map +1 -1
- package/esri-compat/map-view.js +1 -1
- package/esri-compat/scene-view.d.ts.map +1 -1
- package/esri-compat/time-slider.d.ts +29 -0
- package/esri-compat/time-slider.d.ts.map +1 -1
- package/esri-compat/time-slider.js +117 -0
- package/esri-compat/wfs-layer.d.ts +2 -1
- package/esri-compat/wfs-layer.d.ts.map +1 -1
- package/esri-compat/wfs-layer.js +2 -1
- package/esri-compat/widget-host.d.ts +42 -6
- package/esri-compat/widget-host.d.ts.map +1 -1
- package/esri-compat/widget-host.js +99 -10
- package/generated-app/projection.d.ts.map +1 -1
- package/generated-app/runtime.d.ts.map +1 -1
- package/interactions/declarative-contract.d.ts +149 -0
- package/interactions/declarative-contract.d.ts.map +1 -0
- package/interactions/declarative-contract.js +293 -0
- package/interactions/declarative.d.ts +198 -0
- package/interactions/declarative.d.ts.map +1 -0
- package/interactions/declarative.js +470 -0
- package/interactions/feature-state.d.ts +7 -0
- package/interactions/feature-state.d.ts.map +1 -1
- package/interactions/feature-state.js +10 -2
- package/map/data-to-map-bridge.d.ts +1 -1
- package/map/data-to-map-bridge.d.ts.map +1 -1
- package/map/data-to-map-bridge.js +1 -2
- package/map/honua-map.d.ts.map +1 -1
- package/map/honua-map.js +1 -1
- package/map/index.d.ts +2 -0
- package/map/index.d.ts.map +1 -1
- package/map/index.js +2 -0
- package/map/maplibre-target.js +1 -1
- package/map/realtime-reconciliation-adapter.d.ts +98 -0
- package/map/realtime-reconciliation-adapter.d.ts.map +1 -0
- package/map/realtime-reconciliation-adapter.js +84 -0
- package/map/temporal-playback.d.ts +25 -0
- package/map/temporal-playback.d.ts.map +1 -1
- package/map/temporal-playback.js +29 -1
- package/operator/workspace/workspace.d.ts.map +1 -1
- package/operator/workspace/workspace.js +0 -2
- package/package.json +4 -3
- package/query-planner/canonical-filter.d.ts +30 -0
- package/query-planner/canonical-filter.d.ts.map +1 -0
- package/query-planner/canonical-filter.js +30 -0
- package/query-planner/columnar-plan.d.ts +63 -0
- package/query-planner/columnar-plan.d.ts.map +1 -0
- package/query-planner/columnar-plan.js +94 -0
- package/query-planner/diagnostics.d.ts +3 -3
- package/query-planner/diagnostics.d.ts.map +1 -1
- package/query-planner/diagnostics.js +17 -4
- package/query-planner/duckdb.d.ts.map +1 -1
- package/query-planner/duckdb.js +16 -2
- package/query-planner/execution-context.d.ts.map +1 -1
- package/query-planner/execution-context.js +3 -1
- package/query-planner/executor.d.ts.map +1 -1
- package/query-planner/executor.js +7 -1
- package/query-planner/geoservices.d.ts.map +1 -1
- package/query-planner/geoservices.js +24 -5
- package/query-planner/grpc.d.ts.map +1 -1
- package/query-planner/grpc.js +29 -5
- package/query-planner/index.d.ts +20 -5
- package/query-planner/index.d.ts.map +1 -1
- package/query-planner/index.js +16 -3
- package/query-planner/ir.d.ts +6 -1
- package/query-planner/ir.d.ts.map +1 -1
- package/query-planner/ir.js +63 -1
- package/query-planner/odata-protocol-module.d.ts +54 -0
- package/query-planner/odata-protocol-module.d.ts.map +1 -0
- package/query-planner/odata-protocol-module.js +199 -0
- package/query-planner/odata-v1.d.ts +23 -0
- package/query-planner/odata-v1.d.ts.map +1 -0
- package/query-planner/odata-v1.js +202 -0
- package/query-planner/odata.d.ts +0 -3
- package/query-planner/odata.d.ts.map +1 -1
- package/query-planner/odata.js +0 -143
- package/query-planner/ogc-features.d.ts.map +1 -1
- package/query-planner/ogc-features.js +23 -1
- package/query-planner/planner.d.ts +2 -2
- package/query-planner/planner.d.ts.map +1 -1
- package/query-planner/planner.js +146 -27
- package/query-planner/representation.d.ts +59 -0
- package/query-planner/representation.d.ts.map +1 -0
- package/query-planner/representation.js +140 -0
- package/query-planner/types.d.ts +153 -3
- package/query-planner/types.d.ts.map +1 -1
- package/query-planner/wfs-protocol-module.d.ts +57 -0
- package/query-planner/wfs-protocol-module.d.ts.map +1 -0
- package/query-planner/wfs-protocol-module.js +731 -0
- package/query-planner/wfs-v1.d.ts +41 -0
- package/query-planner/wfs-v1.d.ts.map +1 -0
- package/query-planner/wfs-v1.js +366 -0
- package/query-planner/wfs.d.ts +1 -3
- package/query-planner/wfs.d.ts.map +1 -1
- package/query-planner/wfs.js +1 -70
- package/realtime/checkpoint-store.d.ts +206 -0
- package/realtime/checkpoint-store.d.ts.map +1 -0
- package/realtime/checkpoint-store.js +504 -0
- package/realtime/honua-server.d.ts +8 -0
- package/realtime/honua-server.d.ts.map +1 -1
- package/realtime/honua-server.js +123 -14
- package/realtime/index.d.ts +4 -0
- package/realtime/index.d.ts.map +1 -1
- package/realtime/index.js +2 -0
- package/realtime/reconciliation.d.ts +245 -0
- package/realtime/reconciliation.d.ts.map +1 -0
- package/realtime/reconciliation.js +377 -0
- package/realtime/resumable-transport.d.ts.map +1 -1
- package/realtime/sse.d.ts.map +1 -1
- package/realtime/sse.js +7 -1
- package/realtime/websocket.d.ts.map +1 -1
- package/realtime/wire.d.ts +2 -1
- package/realtime/wire.d.ts.map +1 -1
- package/realtime/wire.js +24 -8
- package/replica-sync/errors.d.ts +19 -1
- package/replica-sync/errors.d.ts.map +1 -1
- package/replica-sync/errors.js +16 -0
- package/replica-sync/geoservices.d.ts +260 -0
- package/replica-sync/geoservices.d.ts.map +1 -0
- package/replica-sync/geoservices.js +1093 -0
- package/replica-sync/index.d.ts +5 -1
- package/replica-sync/index.d.ts.map +1 -1
- package/replica-sync/index.js +3 -1
- package/replica-sync/transport-conformance.d.ts +58 -0
- package/replica-sync/transport-conformance.d.ts.map +1 -0
- package/replica-sync/transport-conformance.js +265 -0
- package/replica-sync/types.d.ts +22 -0
- package/replica-sync/types.d.ts.map +1 -1
- package/runtime/index.d.ts +6 -2
- package/runtime/index.d.ts.map +1 -1
- package/runtime/index.js +3 -1
- package/runtime/load-package.d.ts +24 -0
- package/runtime/load-package.d.ts.map +1 -1
- package/runtime/load-package.js +45 -1
- package/runtime/maplibre-renderer.d.ts +11 -0
- package/runtime/maplibre-renderer.d.ts.map +1 -1
- package/runtime/maplibre-renderer.js +32 -7
- package/runtime/offline-region-protocol.d.ts +73 -0
- package/runtime/offline-region-protocol.d.ts.map +1 -0
- package/runtime/offline-region-protocol.js +63 -0
- package/runtime/offline-region-style.d.ts +78 -0
- package/runtime/offline-region-style.d.ts.map +1 -0
- package/runtime/offline-region-style.js +147 -0
- package/runtime/pmtiles-protocol.d.ts +7 -8
- package/runtime/pmtiles-protocol.d.ts.map +1 -1
- package/runtime/pmtiles-protocol.js +19 -66
- package/runtime/protocol-registry.d.ts +55 -0
- package/runtime/protocol-registry.d.ts.map +1 -0
- package/runtime/protocol-registry.js +103 -0
- package/runtime/runtime.d.ts +2 -0
- package/runtime/runtime.d.ts.map +1 -1
- package/runtime/runtime.js +6 -1
- package/runtime/style-compose.d.ts.map +1 -1
- package/runtime/style-compose.js +78 -9
- package/scene-workspace/analysis-widgets.d.ts +4 -2
- package/scene-workspace/analysis-widgets.d.ts.map +1 -1
- package/scene-workspace/analysis-widgets.js +4 -2
- package/scene-workspace/camera-correspondence.d.ts +151 -0
- package/scene-workspace/camera-correspondence.d.ts.map +1 -0
- package/scene-workspace/camera-correspondence.js +220 -0
- package/scene-workspace/cesium-adapter.d.ts +230 -29
- package/scene-workspace/cesium-adapter.d.ts.map +1 -1
- package/scene-workspace/cesium-adapter.js +741 -56
- package/scene-workspace/cesium-mount.d.ts +205 -0
- package/scene-workspace/cesium-mount.d.ts.map +1 -0
- package/scene-workspace/cesium-mount.js +392 -0
- package/scene-workspace/cesium-scene-owner.d.ts +160 -0
- package/scene-workspace/cesium-scene-owner.d.ts.map +1 -0
- package/scene-workspace/cesium-scene-owner.js +205 -0
- package/scene-workspace/cesium-state-sync.d.ts +101 -0
- package/scene-workspace/cesium-state-sync.d.ts.map +1 -0
- package/scene-workspace/cesium-state-sync.js +432 -0
- package/scene-workspace/cesium-time.d.ts +346 -0
- package/scene-workspace/cesium-time.d.ts.map +1 -0
- package/scene-workspace/cesium-time.js +403 -0
- package/scene-workspace/index.d.ts +42 -10
- package/scene-workspace/index.d.ts.map +1 -1
- package/scene-workspace/index.js +32 -7
- package/scene-workspace/maplibre-state-sync.d.ts +112 -0
- package/scene-workspace/maplibre-state-sync.d.ts.map +1 -0
- package/scene-workspace/maplibre-state-sync.js +400 -0
- package/scene-workspace/primitives.d.ts +237 -3
- package/scene-workspace/primitives.d.ts.map +1 -1
- package/scene-workspace/primitives.js +1087 -17
- package/scene-workspace/scene-discovery.d.ts +5 -2
- package/scene-workspace/scene-discovery.d.ts.map +1 -1
- package/scene-workspace/scene-discovery.js +5 -2
- package/scene-workspace/scene-view.d.ts +5 -2
- package/scene-workspace/scene-view.d.ts.map +1 -1
- package/scene-workspace/scene-view.js +5 -2
- package/scene-workspace/source-to-cesium.d.ts +141 -8
- package/scene-workspace/source-to-cesium.d.ts.map +1 -1
- package/scene-workspace/source-to-cesium.js +497 -72
- package/scene-workspace/state-sync-port-kit.d.ts +156 -0
- package/scene-workspace/state-sync-port-kit.d.ts.map +1 -0
- package/scene-workspace/state-sync-port-kit.js +319 -0
- package/scene-workspace/state-sync.d.ts +34 -1
- package/scene-workspace/state-sync.d.ts.map +1 -1
- package/scene-workspace/state-sync.js +48 -0
- package/scene-workspace/types.d.ts +8 -0
- package/scene-workspace/types.d.ts.map +1 -1
- package/scene-workspace/types.js +2 -0
- package/scene-workspace/workspace.d.ts +12 -1
- package/scene-workspace/workspace.d.ts.map +1 -1
- package/scene-workspace/workspace.js +13 -0
- package/studio/index.d.ts +24 -7
- package/studio/index.d.ts.map +1 -1
- package/studio/index.js +21 -7
- package/studio/lifecycle-client.d.ts +164 -0
- package/studio/lifecycle-client.d.ts.map +1 -0
- package/studio/lifecycle-client.js +299 -0
- package/studio/lifecycle-errors.d.ts +91 -0
- package/studio/lifecycle-errors.d.ts.map +1 -0
- package/studio/lifecycle-errors.js +77 -0
- package/studio/lifecycle-types.d.ts +321 -0
- package/studio/lifecycle-types.d.ts.map +1 -0
- package/studio/lifecycle-types.js +22 -0
- package/web-components/controller.d.ts.map +1 -1
- package/web-components/csp-styles.d.ts +6 -0
- package/web-components/csp-styles.d.ts.map +1 -0
- package/web-components/csp-styles.js +53 -0
- package/web-components/elements.d.ts +158 -2
- package/web-components/elements.d.ts.map +1 -1
- package/web-components/elements.js +1302 -215
- package/web-components/export-redaction.d.ts +252 -0
- package/web-components/export-redaction.d.ts.map +1 -0
- package/web-components/export-redaction.js +588 -0
- package/web-components/export.d.ts +417 -0
- package/web-components/export.d.ts.map +1 -0
- package/web-components/export.js +719 -0
- package/web-components/feature-editor-model.d.ts +265 -0
- package/web-components/feature-editor-model.d.ts.map +1 -0
- package/web-components/feature-editor-model.js +438 -0
- package/web-components/feature-editor-sketch.d.ts +125 -0
- package/web-components/feature-editor-sketch.d.ts.map +1 -0
- package/web-components/feature-editor-sketch.js +194 -0
- package/web-components/feature-editor-workflow.d.ts +286 -0
- package/web-components/feature-editor-workflow.d.ts.map +1 -0
- package/web-components/feature-editor-workflow.js +889 -0
- package/web-components/feature-editor.d.ts +83 -0
- package/web-components/feature-editor.d.ts.map +1 -0
- package/web-components/feature-editor.js +829 -0
- package/web-components/feature-table-engine.d.ts +465 -0
- package/web-components/feature-table-engine.d.ts.map +1 -0
- package/web-components/feature-table-engine.js +1360 -0
- package/web-components/feature-table-view.d.ts +95 -0
- package/web-components/feature-table-view.d.ts.map +1 -0
- package/web-components/feature-table-view.js +343 -0
- package/web-components/index.d.ts +34 -3
- package/web-components/index.d.ts.map +1 -1
- package/web-components/index.js +35 -2
- package/web-components/maplibre-renderer.d.ts.map +1 -1
- package/web-components/maplibre-renderer.js +13 -4
- package/web-components/measurement.d.ts +3 -1
- package/web-components/measurement.d.ts.map +1 -1
- package/web-components/measurement.js +62 -18
- package/web-components/time-slider.d.ts +122 -0
- package/web-components/time-slider.d.ts.map +1 -0
- package/web-components/time-slider.js +740 -0
- package/web-components/types.d.ts +310 -0
- package/web-components/types.d.ts.map +1 -1
- package/webmap/convert-label.js +0 -1
- 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
|
+
}
|