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