@honua/app-platform 0.1.12 → 0.1.14
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/columnar/apache-arrow.d.ts +17 -0
- package/columnar/apache-arrow.d.ts.map +1 -0
- package/columnar/apache-arrow.js +1036 -0
- package/columnar/batch-cache-conformance.d.ts +66 -0
- package/columnar/batch-cache-conformance.d.ts.map +1 -0
- package/columnar/batch-cache-conformance.js +317 -0
- package/columnar/batch-cache-indexeddb.d.ts +31 -0
- package/columnar/batch-cache-indexeddb.d.ts.map +1 -0
- package/columnar/batch-cache-indexeddb.js +148 -0
- package/columnar/batch-cache.d.ts +298 -0
- package/columnar/batch-cache.d.ts.map +1 -0
- package/columnar/batch-cache.js +757 -0
- package/columnar/geoarrow-aggregate.d.ts +178 -0
- package/columnar/geoarrow-aggregate.d.ts.map +1 -0
- package/columnar/geoarrow-aggregate.js +976 -0
- package/columnar/geoarrow-patch.d.ts +382 -0
- package/columnar/geoarrow-patch.d.ts.map +1 -0
- package/columnar/geoarrow-patch.js +1656 -0
- package/columnar/geoarrow-reproject.d.ts +22 -0
- package/columnar/geoarrow-reproject.d.ts.map +1 -0
- package/columnar/geoarrow-reproject.js +119 -0
- package/columnar/geoarrow-serialization.d.ts +68 -0
- package/columnar/geoarrow-serialization.d.ts.map +1 -0
- package/columnar/geoarrow-serialization.js +331 -0
- package/columnar/geoarrow-types.d.ts +271 -0
- package/columnar/geoarrow-types.d.ts.map +1 -0
- package/columnar/geoarrow-types.js +15 -0
- package/columnar/geoarrow.d.ts +52 -0
- package/columnar/geoarrow.d.ts.map +1 -0
- package/columnar/geoarrow.js +1469 -0
- package/columnar/index.d.ts +43 -0
- package/columnar/index.d.ts.map +1 -0
- package/columnar/index.js +29 -0
- package/columnar/operators.d.ts +70 -0
- package/columnar/operators.d.ts.map +1 -0
- package/columnar/operators.js +307 -0
- package/columnar/result.d.ts +369 -0
- package/columnar/result.d.ts.map +1 -0
- package/columnar/result.js +815 -0
- package/columnar/telemetry.d.ts +152 -0
- package/columnar/telemetry.d.ts.map +1 -0
- package/columnar/telemetry.js +265 -0
- package/columnar/transfer.d.ts +28 -0
- package/columnar/transfer.d.ts.map +1 -0
- package/columnar/transfer.js +669 -0
- package/columnar/types.d.ts +175 -0
- package/columnar/types.d.ts.map +1 -0
- package/columnar/types.js +22 -0
- package/columnar/worker.d.ts +157 -0
- package/columnar/worker.d.ts.map +1 -0
- package/columnar/worker.js +1059 -0
- package/contract/pmtiles.d.ts +6 -5
- package/contract/pmtiles.d.ts.map +1 -1
- package/contract/pmtiles.js +30 -5
- package/contract/source.d.ts.map +1 -1
- package/contract/source.js +11 -8
- package/controls/catalog.d.ts.map +1 -1
- package/controls/catalog.js +110 -0
- package/controls/index.d.ts +1 -1
- package/controls/index.js +1 -1
- package/controls/types.d.ts +1 -1
- package/controls/types.js +1 -1
- package/core/client.d.ts.map +1 -1
- package/core/client.js +87 -19
- package/core/deprecation-notice.d.ts +28 -0
- package/core/deprecation-notice.d.ts.map +1 -0
- package/core/deprecation-notice.js +22 -0
- package/core/esri-geojson.d.ts.map +1 -1
- package/core/esri-geojson.js +14 -8
- package/core/optional-grpc-peer.d.ts +15 -0
- package/core/optional-grpc-peer.d.ts.map +1 -0
- package/core/optional-grpc-peer.js +60 -0
- package/esri-compat/basemap-gallery.d.ts +2 -0
- package/esri-compat/basemap-gallery.d.ts.map +1 -1
- package/esri-compat/basemap-gallery.js +12 -0
- package/esri-compat/basemap.d.ts +4 -0
- package/esri-compat/basemap.d.ts.map +1 -1
- package/esri-compat/basemap.js +70 -0
- package/esri-compat/bookmarks.d.ts +2 -0
- package/esri-compat/bookmarks.d.ts.map +1 -1
- package/esri-compat/bookmarks.js +12 -0
- package/esri-compat/controls.d.ts +18 -0
- package/esri-compat/controls.d.ts.map +1 -1
- package/esri-compat/controls.js +120 -57
- package/esri-compat/directions.d.ts +4 -0
- package/esri-compat/directions.d.ts.map +1 -1
- package/esri-compat/directions.js +30 -20
- package/esri-compat/editor.d.ts +4 -0
- package/esri-compat/editor.d.ts.map +1 -1
- package/esri-compat/editor.js +16 -0
- package/esri-compat/esri-config.d.ts +2 -1
- package/esri-compat/esri-config.d.ts.map +1 -1
- package/esri-compat/feature-effect.d.ts +23 -0
- package/esri-compat/feature-effect.d.ts.map +1 -0
- package/esri-compat/feature-effect.js +12 -0
- package/esri-compat/feature-form.d.ts +6 -0
- package/esri-compat/feature-form.d.ts.map +1 -1
- package/esri-compat/feature-form.js +63 -0
- package/esri-compat/feature-layer.d.ts +21 -2
- package/esri-compat/feature-layer.d.ts.map +1 -1
- package/esri-compat/feature-layer.js +172 -1
- package/esri-compat/feature-table.d.ts +5 -0
- package/esri-compat/feature-table.d.ts.map +1 -1
- package/esri-compat/feature-table.js +23 -0
- package/esri-compat/identity-manager.d.ts +1 -1
- package/esri-compat/identity-manager.js +1 -1
- package/esri-compat/legend.d.ts +2 -0
- package/esri-compat/legend.d.ts.map +1 -1
- package/esri-compat/legend.js +98 -0
- package/esri-compat/locator.d.ts +1 -1
- package/esri-compat/locator.js +1 -1
- package/esri-compat/map-view-mount.d.ts +1 -0
- package/esri-compat/map-view-mount.d.ts.map +1 -1
- package/esri-compat/map-view-mount.js +46 -25
- package/esri-compat/map-view.d.ts +13 -0
- package/esri-compat/map-view.d.ts.map +1 -1
- package/esri-compat/map-view.js +94 -8
- package/esri-compat/map.d.ts +5 -1
- package/esri-compat/map.d.ts.map +1 -1
- package/esri-compat/map.js +10 -0
- package/esri-compat/measurement.d.ts +2 -0
- package/esri-compat/measurement.d.ts.map +1 -1
- package/esri-compat/measurement.js +14 -0
- package/esri-compat/point.d.ts +7 -0
- package/esri-compat/point.d.ts.map +1 -1
- package/esri-compat/point.js +33 -3
- package/esri-compat/popup.d.ts +2 -0
- package/esri-compat/popup.d.ts.map +1 -1
- package/esri-compat/popup.js +12 -0
- package/esri-compat/portal.d.ts +1 -1
- package/esri-compat/portal.js +1 -1
- package/esri-compat/print.d.ts +3 -0
- package/esri-compat/print.d.ts.map +1 -1
- package/esri-compat/print.js +15 -0
- package/esri-compat/search.d.ts +4 -0
- package/esri-compat/search.d.ts.map +1 -1
- package/esri-compat/search.js +25 -28
- package/esri-compat/sketch.d.ts +2 -0
- package/esri-compat/sketch.d.ts.map +1 -1
- package/esri-compat/sketch.js +12 -0
- package/esri-compat/widget-host.d.ts +46 -17
- package/esri-compat/widget-host.d.ts.map +1 -1
- package/esri-compat/widget-host.js +84 -20
- package/esri-compat/widget-shell-hosts.d.ts +61 -0
- package/esri-compat/widget-shell-hosts.d.ts.map +1 -0
- package/esri-compat/widget-shell-hosts.js +76 -0
- package/kernel/renderer.d.ts +82 -0
- package/kernel/renderer.js +1 -0
- package/offline/conflict-resolution.d.ts +183 -0
- package/offline/conflict-resolution.d.ts.map +1 -0
- package/offline/conflict-resolution.js +286 -0
- package/offline/credential-screen.d.ts +34 -0
- package/offline/credential-screen.d.ts.map +1 -0
- package/offline/credential-screen.js +78 -0
- package/offline/digest.d.ts +15 -0
- package/offline/digest.d.ts.map +1 -0
- package/offline/digest.js +58 -0
- package/offline/edit-queue.d.ts +403 -0
- package/offline/edit-queue.d.ts.map +1 -0
- package/offline/edit-queue.js +1807 -0
- package/offline/edit-replay.d.ts +116 -0
- package/offline/edit-replay.d.ts.map +1 -0
- package/offline/edit-replay.js +392 -0
- package/offline/fetch-handler.d.ts +12 -0
- package/offline/fetch-handler.d.ts.map +1 -0
- package/offline/fetch-handler.js +65 -0
- package/offline/index.d.ts +52 -0
- package/offline/index.d.ts.map +1 -0
- package/offline/index.js +34 -0
- package/offline/indexeddb.d.ts +135 -0
- package/offline/indexeddb.d.ts.map +1 -0
- package/offline/indexeddb.js +888 -0
- package/offline/memory-store.d.ts +17 -0
- package/offline/memory-store.d.ts.map +1 -0
- package/offline/memory-store.js +183 -0
- package/offline/quota.d.ts +161 -0
- package/offline/quota.d.ts.map +1 -0
- package/offline/quota.js +370 -0
- package/offline/read-gate.d.ts +175 -0
- package/offline/read-gate.d.ts.map +1 -0
- package/offline/read-gate.js +252 -0
- package/offline/read-resource.d.ts +99 -0
- package/offline/read-resource.d.ts.map +1 -0
- package/offline/read-resource.js +156 -0
- package/offline/read.d.ts +50 -0
- package/offline/read.d.ts.map +1 -0
- package/offline/read.js +65 -0
- package/offline/region.d.ts +29 -0
- package/offline/region.d.ts.map +1 -0
- package/offline/region.js +1007 -0
- package/offline/replay-conflict.d.ts +183 -0
- package/offline/replay-conflict.d.ts.map +1 -0
- package/offline/replay-conflict.js +482 -0
- package/offline/selection.d.ts +140 -0
- package/offline/selection.d.ts.map +1 -0
- package/offline/selection.js +391 -0
- package/offline/snapshot.d.ts +132 -0
- package/offline/snapshot.d.ts.map +1 -0
- package/offline/snapshot.js +312 -0
- package/offline/status.d.ts +136 -0
- package/offline/status.d.ts.map +1 -0
- package/offline/status.js +479 -0
- package/offline/store-conformance.d.ts +48 -0
- package/offline/store-conformance.d.ts.map +1 -0
- package/offline/store-conformance.js +233 -0
- package/offline/tile-protocol.d.ts +81 -0
- package/offline/tile-protocol.d.ts.map +1 -0
- package/offline/tile-protocol.js +119 -0
- package/offline/types.d.ts +349 -0
- package/offline/types.d.ts.map +1 -0
- package/offline/types.js +75 -0
- package/package.json +14 -2
- package/realtime/odata-delta.d.ts +1 -1
- package/realtime/odata-delta.d.ts.map +1 -1
- package/realtime/odata-delta.js +38 -10
- package/scene-workspace/index.d.ts +2 -2
- package/scene-workspace/index.d.ts.map +1 -1
- package/scene-workspace/index.js +1 -1
- package/scene-workspace/scene-discovery.d.ts +111 -91
- package/scene-workspace/scene-discovery.d.ts.map +1 -1
- package/scene-workspace/scene-discovery.js +340 -92
- package/scene-workspace/scene-view.d.ts +6 -6
- package/scene-workspace/scene-view.d.ts.map +1 -1
- package/scene-workspace/scene-view.js +32 -6
- package/web-components/elements.d.ts +34 -0
- package/web-components/elements.d.ts.map +1 -1
- package/web-components/elements.js +82 -8
- package/web-components/index.d.ts +2 -0
- package/web-components/index.d.ts.map +1 -1
- package/web-components/index.js +3 -1
- package/web-components/map-chrome.d.ts +103 -0
- package/web-components/map-chrome.d.ts.map +1 -0
- package/web-components/map-chrome.js +325 -0
- package/web-components/widget-shell.d.ts +68 -0
- package/web-components/widget-shell.d.ts.map +1 -0
- package/web-components/widget-shell.js +224 -0
- package/widget-capabilities.d.ts +67 -0
- package/widget-capabilities.js +152 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projection from a durable offline replay conflict onto the shipped
|
|
3
|
+
* sync-conflict contracts.
|
|
4
|
+
*
|
|
5
|
+
* `replayOfflineEditPass` records a conflicted acknowledgement as an opaque
|
|
6
|
+
* `conflictId` plus an optional server generation on the queued edit. The SDK
|
|
7
|
+
* separately ships a product-level conflict vocabulary — `SyncConflictDetail`,
|
|
8
|
+
* `SyncConflictId`, `SyncConflictKind`, `ServerGenerationCursor` — for conflict
|
|
9
|
+
* review. Without a projection an application holds two disjoint vocabularies
|
|
10
|
+
* for the same event and has to invent the mapping itself.
|
|
11
|
+
*
|
|
12
|
+
* This module is that mapping, and nothing more:
|
|
13
|
+
*
|
|
14
|
+
* - **Pure.** No I/O, no clock, no mutation. One frozen record in, one frozen
|
|
15
|
+
* record out. The durable queue stays the source of truth.
|
|
16
|
+
* - **Type-only coupling.** The contract types are imported with `import type`,
|
|
17
|
+
* so `dist/src/offline/**` gains no runtime edge into `src/replica-sync` and
|
|
18
|
+
* neither module imports the other's implementation.
|
|
19
|
+
* - **Fail-closed.** An input this build cannot map yields a typed refusal
|
|
20
|
+
* naming the reason. It never yields a guessed contract object.
|
|
21
|
+
* - **No invented server semantics.** Members of `SyncConflictDetail` that only
|
|
22
|
+
* a live server can observe or adjudicate — the common base, the server's
|
|
23
|
+
* side, the field-level diff, the resolution options — are enumerated as
|
|
24
|
+
* explicitly unavailable rather than filled with a plausible value.
|
|
25
|
+
*
|
|
26
|
+
* What this does *not* do is claim a hosted guarantee. The replay transport is
|
|
27
|
+
* application-owned and end-to-end exactly-once delivery remains a server
|
|
28
|
+
* property; this slice only makes the two vocabularies agree.
|
|
29
|
+
*
|
|
30
|
+
* @experimental
|
|
31
|
+
* @module
|
|
32
|
+
*/
|
|
33
|
+
import type { ReplicaId, SyncActor, SyncConflictDetail } from "../replica-sync/types.js";
|
|
34
|
+
import { type OfflineEditConflictResolutionAcknowledgement, type OfflineEditConflictResolutionChoice, type OfflineEditConflictResolutionDisposition, type OfflineQueuedEdit } from "./edit-queue.js";
|
|
35
|
+
/** Stable discriminator and version for offline replay conflict projections. */
|
|
36
|
+
export declare const HONUA_OFFLINE_REPLAY_SYNC_CONFLICT_KIND: "honua.offline-replay-sync-conflict";
|
|
37
|
+
export declare const HONUA_OFFLINE_REPLAY_SYNC_CONFLICT_VERSION: "1.0";
|
|
38
|
+
/**
|
|
39
|
+
* The replica identity a conflict is attributed to.
|
|
40
|
+
*
|
|
41
|
+
* `SyncConflictSummary` requires a replica and a dataset; the durable edit
|
|
42
|
+
* queue partitions by an authorization-scope digest and a source id and knows
|
|
43
|
+
* neither. Rather than derive a replica identity the SDK cannot observe, the
|
|
44
|
+
* binding is an explicit caller input — the application registered the replica,
|
|
45
|
+
* so the application is the only honest source for it.
|
|
46
|
+
*/
|
|
47
|
+
export interface OfflineReplaySyncConflictBinding {
|
|
48
|
+
readonly replicaId: ReplicaId;
|
|
49
|
+
readonly datasetId: string;
|
|
50
|
+
}
|
|
51
|
+
export interface ProjectOfflineReplaySyncConflictInput {
|
|
52
|
+
/** A durable queued edit, treated as untrusted persisted data. */
|
|
53
|
+
readonly edit: OfflineQueuedEdit;
|
|
54
|
+
readonly replica: OfflineReplaySyncConflictBinding;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The members of `SyncConflictDetail` an offline replay can fill without a
|
|
58
|
+
* server, structurally assignable into the shipped contract once the
|
|
59
|
+
* server-owned members arrive.
|
|
60
|
+
*
|
|
61
|
+
* `clientState` is deliberately payload-free: it carries the operation, the
|
|
62
|
+
* delete marker, and the local authoring time, and never the edit's attributes
|
|
63
|
+
* or geometry. Every other projection in `src/offline` is payload-free for the
|
|
64
|
+
* same reason, and widening the vocabulary must not widen what is exposed.
|
|
65
|
+
*/
|
|
66
|
+
export type OfflineReplayProjectedSyncConflict = Pick<SyncConflictDetail, "id" | "replicaId" | "datasetId" | "sourceId" | "featureId" | "kind" | "status" | "clientOperation" | "detectedAt" | "clientState" | "serverGen">;
|
|
67
|
+
/**
|
|
68
|
+
* A resolution the durable queue recorded locally.
|
|
69
|
+
*
|
|
70
|
+
* Deliberately *not* a `SyncConflictResolutionRecord`: that contract member is
|
|
71
|
+
* the server's acknowledgement of a committed resolution, and publishing a
|
|
72
|
+
* locally recorded choice in its place would claim a commit that never
|
|
73
|
+
* happened. `resolution` therefore stays listed as `server-owned` in
|
|
74
|
+
* {@link OfflineReplaySyncConflictProjectedV1.unavailable} even when this
|
|
75
|
+
* member is present, and `acknowledgement` states the difference in the shape
|
|
76
|
+
* itself.
|
|
77
|
+
*/
|
|
78
|
+
export interface OfflineReplayLocalConflictResolutionV1 {
|
|
79
|
+
readonly choice: OfflineEditConflictResolutionChoice;
|
|
80
|
+
readonly disposition: OfflineEditConflictResolutionDisposition;
|
|
81
|
+
readonly acknowledgement: OfflineEditConflictResolutionAcknowledgement;
|
|
82
|
+
readonly resolvedAt: string;
|
|
83
|
+
/** Identity only; a `SyncActor`'s display name and kind are never persisted. */
|
|
84
|
+
readonly resolvedBy?: SyncActor;
|
|
85
|
+
readonly note?: string;
|
|
86
|
+
}
|
|
87
|
+
/** A `SyncConflictDetail` member this projection declines to fill. */
|
|
88
|
+
export type OfflineReplaySyncConflictUnavailableMember = "base" | "serverState" | "serverOperation" | "fieldConflicts" | "fieldConflictCount" | "hasGeometryConflict" | "geometryConflict" | "resolutionOptions" | "resolution" | "serverGen" | "layerId" | "client" | "device" | "metadata" | "clientState.attributes" | "clientState.geometry";
|
|
89
|
+
export type OfflineReplaySyncConflictUnavailableReason =
|
|
90
|
+
/** Only a live server observes the value or adjudicates the choice. */
|
|
91
|
+
"server-owned"
|
|
92
|
+
/** The durable queue record does not carry it; inventing it would be a claim. */
|
|
93
|
+
| "not-recorded"
|
|
94
|
+
/** Withheld on purpose so the projection stays payload-free. */
|
|
95
|
+
| "payload-free";
|
|
96
|
+
export interface OfflineReplaySyncConflictUnavailableV1 {
|
|
97
|
+
readonly member: OfflineReplaySyncConflictUnavailableMember;
|
|
98
|
+
readonly reason: OfflineReplaySyncConflictUnavailableReason;
|
|
99
|
+
}
|
|
100
|
+
/** Why a well-formed input still has no conflict this build will publish. */
|
|
101
|
+
export type OfflineReplaySyncConflictRefusalReason =
|
|
102
|
+
/** The edit is neither conflicted nor carrying a recorded resolution. */
|
|
103
|
+
"not-conflicted"
|
|
104
|
+
/** `conflicted` without a durable conflict outcome: a record this build will not trust. */
|
|
105
|
+
| "missing-conflict-record"
|
|
106
|
+
/** The edit carries no feature id, and `SyncConflictSummary.featureId` is required. */
|
|
107
|
+
| "unidentified-feature"
|
|
108
|
+
/** The persisted record is not a readable `OfflineQueuedEdit`. */
|
|
109
|
+
| "unreadable-edit";
|
|
110
|
+
export interface OfflineReplaySyncConflictProjectedV1 {
|
|
111
|
+
readonly kind: typeof HONUA_OFFLINE_REPLAY_SYNC_CONFLICT_KIND;
|
|
112
|
+
readonly version: typeof HONUA_OFFLINE_REPLAY_SYNC_CONFLICT_VERSION;
|
|
113
|
+
readonly outcome: "projected";
|
|
114
|
+
readonly editId: `sha256:${string}`;
|
|
115
|
+
readonly conflict: OfflineReplayProjectedSyncConflict;
|
|
116
|
+
/**
|
|
117
|
+
* Present once a reviewer's choice has been recorded against the edit. The
|
|
118
|
+
* conflict's `status` moves with it — `resolving` for a requeued edit,
|
|
119
|
+
* `discarded` for an abandoned one — and never reaches `resolved`, because
|
|
120
|
+
* only a server commit makes a resolution true.
|
|
121
|
+
*/
|
|
122
|
+
readonly localResolution?: OfflineReplayLocalConflictResolutionV1;
|
|
123
|
+
/** Ordered, stable, and complete: every contract member left unfilled. */
|
|
124
|
+
readonly unavailable: readonly OfflineReplaySyncConflictUnavailableV1[];
|
|
125
|
+
}
|
|
126
|
+
export interface OfflineReplaySyncConflictRefusedV1 {
|
|
127
|
+
readonly kind: typeof HONUA_OFFLINE_REPLAY_SYNC_CONFLICT_KIND;
|
|
128
|
+
readonly version: typeof HONUA_OFFLINE_REPLAY_SYNC_CONFLICT_VERSION;
|
|
129
|
+
readonly outcome: "refused";
|
|
130
|
+
readonly reason: OfflineReplaySyncConflictRefusalReason;
|
|
131
|
+
/** Dotted path on the input that could not be mapped. */
|
|
132
|
+
readonly path: string;
|
|
133
|
+
/** Absent only when the record's own identity is unreadable. */
|
|
134
|
+
readonly editId?: `sha256:${string}`;
|
|
135
|
+
}
|
|
136
|
+
export type OfflineReplaySyncConflictProjectionV1 = OfflineReplaySyncConflictProjectedV1 | OfflineReplaySyncConflictRefusedV1;
|
|
137
|
+
/** What became of one replay-side field. */
|
|
138
|
+
export type OfflineReplaySyncConflictFieldDisposition =
|
|
139
|
+
/** Copied verbatim into every listed target. */
|
|
140
|
+
"carried"
|
|
141
|
+
/** Every listed target is computed from it. */
|
|
142
|
+
| "derived"
|
|
143
|
+
/** Withheld so the projection stays payload-free. */
|
|
144
|
+
| "omitted-payload-free"
|
|
145
|
+
/** Local queue bookkeeping with no counterpart in the conflict contract. */
|
|
146
|
+
| "omitted-local-only";
|
|
147
|
+
export interface OfflineReplaySyncConflictFieldMappingV1 {
|
|
148
|
+
/** Dotted path on the durable queued edit. */
|
|
149
|
+
readonly source: string;
|
|
150
|
+
/** Dotted paths on the projection; empty for an omission. */
|
|
151
|
+
readonly targets: readonly string[];
|
|
152
|
+
readonly disposition: OfflineReplaySyncConflictFieldDisposition;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The losslessness ledger: every field of a durable queued edit, and where it
|
|
156
|
+
* lands or why it does not.
|
|
157
|
+
*
|
|
158
|
+
* A conflicted acknowledgement's own members reach the projection through this
|
|
159
|
+
* table: `conflictId` and `serverGeneration` are recorded by `markConflicted`
|
|
160
|
+
* as `conflict.conflictId` and `conflict.serverGeneration`, and `editId`,
|
|
161
|
+
* `requestFingerprint`, and `idempotencyKey` are identity checks the replay
|
|
162
|
+
* pass has already matched against the queued edit before any transition.
|
|
163
|
+
*/
|
|
164
|
+
export declare const OFFLINE_REPLAY_SYNC_CONFLICT_FIELD_MAP: readonly OfflineReplaySyncConflictFieldMappingV1[];
|
|
165
|
+
/**
|
|
166
|
+
* Project one durable conflicted queued edit onto the shipped sync-conflict
|
|
167
|
+
* vocabulary.
|
|
168
|
+
*
|
|
169
|
+
* Two failure modes are kept apart on purpose. The caller's own argument — the
|
|
170
|
+
* replica binding — is a programming input, so a malformed one throws. The
|
|
171
|
+
* queued edit is persisted, possibly corrupt data, so an unmappable one is
|
|
172
|
+
* returned as a typed refusal that the caller can render beside the conflicts
|
|
173
|
+
* that did project. Neither path ever returns a partially guessed conflict.
|
|
174
|
+
*/
|
|
175
|
+
export declare function projectOfflineReplaySyncConflict(input: ProjectOfflineReplaySyncConflictInput): OfflineReplaySyncConflictProjectionV1;
|
|
176
|
+
/**
|
|
177
|
+
* Validate a caller-supplied replica binding. Shared with the replay pass so
|
|
178
|
+
* one malformed binding is rejected identically wherever it is supplied.
|
|
179
|
+
*
|
|
180
|
+
* @internal
|
|
181
|
+
*/
|
|
182
|
+
export declare function normalizeOfflineReplaySyncConflictBinding(value: unknown, path: string): OfflineReplaySyncConflictBinding;
|
|
183
|
+
//# sourceMappingURL=replay-conflict.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"replay-conflict.d.ts","sourceRoot":"","sources":["../../../src/offline/replay-conflict.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projection from a durable offline replay conflict onto the shipped
|
|
3
|
+
* sync-conflict contracts.
|
|
4
|
+
*
|
|
5
|
+
* `replayOfflineEditPass` records a conflicted acknowledgement as an opaque
|
|
6
|
+
* `conflictId` plus an optional server generation on the queued edit. The SDK
|
|
7
|
+
* separately ships a product-level conflict vocabulary — `SyncConflictDetail`,
|
|
8
|
+
* `SyncConflictId`, `SyncConflictKind`, `ServerGenerationCursor` — for conflict
|
|
9
|
+
* review. Without a projection an application holds two disjoint vocabularies
|
|
10
|
+
* for the same event and has to invent the mapping itself.
|
|
11
|
+
*
|
|
12
|
+
* This module is that mapping, and nothing more:
|
|
13
|
+
*
|
|
14
|
+
* - **Pure.** No I/O, no clock, no mutation. One frozen record in, one frozen
|
|
15
|
+
* record out. The durable queue stays the source of truth.
|
|
16
|
+
* - **Type-only coupling.** The contract types are imported with `import type`,
|
|
17
|
+
* so `dist/src/offline/**` gains no runtime edge into `src/replica-sync` and
|
|
18
|
+
* neither module imports the other's implementation.
|
|
19
|
+
* - **Fail-closed.** An input this build cannot map yields a typed refusal
|
|
20
|
+
* naming the reason. It never yields a guessed contract object.
|
|
21
|
+
* - **No invented server semantics.** Members of `SyncConflictDetail` that only
|
|
22
|
+
* a live server can observe or adjudicate — the common base, the server's
|
|
23
|
+
* side, the field-level diff, the resolution options — are enumerated as
|
|
24
|
+
* explicitly unavailable rather than filled with a plausible value.
|
|
25
|
+
*
|
|
26
|
+
* What this does *not* do is claim a hosted guarantee. The replay transport is
|
|
27
|
+
* application-owned and end-to-end exactly-once delivery remains a server
|
|
28
|
+
* property; this slice only makes the two vocabularies agree.
|
|
29
|
+
*
|
|
30
|
+
* @experimental
|
|
31
|
+
* @module
|
|
32
|
+
*/
|
|
33
|
+
import { HonuaOfflineEditQueueError, } from "./edit-queue.js";
|
|
34
|
+
/** Stable discriminator and version for offline replay conflict projections. */
|
|
35
|
+
export const HONUA_OFFLINE_REPLAY_SYNC_CONFLICT_KIND = "honua.offline-replay-sync-conflict";
|
|
36
|
+
export const HONUA_OFFLINE_REPLAY_SYNC_CONFLICT_VERSION = "1.0";
|
|
37
|
+
/**
|
|
38
|
+
* The losslessness ledger: every field of a durable queued edit, and where it
|
|
39
|
+
* lands or why it does not.
|
|
40
|
+
*
|
|
41
|
+
* A conflicted acknowledgement's own members reach the projection through this
|
|
42
|
+
* table: `conflictId` and `serverGeneration` are recorded by `markConflicted`
|
|
43
|
+
* as `conflict.conflictId` and `conflict.serverGeneration`, and `editId`,
|
|
44
|
+
* `requestFingerprint`, and `idempotencyKey` are identity checks the replay
|
|
45
|
+
* pass has already matched against the queued edit before any transition.
|
|
46
|
+
*/
|
|
47
|
+
export const OFFLINE_REPLAY_SYNC_CONFLICT_FIELD_MAP = deepFreeze([
|
|
48
|
+
{ source: "version", targets: [], disposition: "omitted-local-only" },
|
|
49
|
+
{ source: "id", targets: ["editId"], disposition: "carried" },
|
|
50
|
+
{ source: "requestFingerprint", targets: [], disposition: "omitted-local-only" },
|
|
51
|
+
{ source: "authorizationScopeDigest", targets: [], disposition: "omitted-local-only" },
|
|
52
|
+
{ source: "sourceId", targets: ["conflict.sourceId"], disposition: "carried" },
|
|
53
|
+
{ source: "idempotencyKey", targets: [], disposition: "omitted-local-only" },
|
|
54
|
+
{
|
|
55
|
+
source: "edit.operation",
|
|
56
|
+
targets: ["conflict.clientOperation", "conflict.clientState.operation", "conflict.clientState.deleted"],
|
|
57
|
+
disposition: "derived",
|
|
58
|
+
},
|
|
59
|
+
{ source: "edit.featureId", targets: ["conflict.featureId"], disposition: "carried" },
|
|
60
|
+
{ source: "edit.attributes", targets: [], disposition: "omitted-payload-free" },
|
|
61
|
+
{ source: "edit.geometry", targets: [], disposition: "omitted-payload-free" },
|
|
62
|
+
{ source: "dependencyIds", targets: [], disposition: "omitted-local-only" },
|
|
63
|
+
{ source: "state", targets: ["conflict.status"], disposition: "derived" },
|
|
64
|
+
{ source: "createdAt", targets: ["conflict.clientState.editedAt"], disposition: "carried" },
|
|
65
|
+
{ source: "updatedAt", targets: [], disposition: "omitted-local-only" },
|
|
66
|
+
{ source: "attemptCount", targets: [], disposition: "omitted-local-only" },
|
|
67
|
+
{ source: "lease", targets: [], disposition: "omitted-local-only" },
|
|
68
|
+
{ source: "retry", targets: [], disposition: "omitted-local-only" },
|
|
69
|
+
{ source: "applied", targets: [], disposition: "omitted-local-only" },
|
|
70
|
+
{ source: "conflict.conflictId", targets: ["conflict.id"], disposition: "carried" },
|
|
71
|
+
{ source: "conflict.detectedAt", targets: ["conflict.detectedAt"], disposition: "carried" },
|
|
72
|
+
{ source: "conflict.serverGeneration", targets: ["conflict.serverGen"], disposition: "carried" },
|
|
73
|
+
{ source: "conflictResolution.conflictId", targets: ["conflict.id"], disposition: "carried" },
|
|
74
|
+
{ source: "conflictResolution.detectedAt", targets: ["conflict.detectedAt"], disposition: "carried" },
|
|
75
|
+
{ source: "conflictResolution.serverGeneration", targets: ["conflict.serverGen"], disposition: "carried" },
|
|
76
|
+
{
|
|
77
|
+
source: "conflictResolution.choice",
|
|
78
|
+
targets: ["localResolution.choice"],
|
|
79
|
+
disposition: "carried",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
source: "conflictResolution.disposition",
|
|
83
|
+
targets: ["conflict.status", "localResolution.disposition"],
|
|
84
|
+
disposition: "derived",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
source: "conflictResolution.acknowledgement",
|
|
88
|
+
targets: ["localResolution.acknowledgement"],
|
|
89
|
+
disposition: "carried",
|
|
90
|
+
},
|
|
91
|
+
{ source: "conflictResolution.resolvedAt", targets: ["localResolution.resolvedAt"], disposition: "carried" },
|
|
92
|
+
{ source: "conflictResolution.resolvedBy", targets: ["localResolution.resolvedBy"], disposition: "derived" },
|
|
93
|
+
{ source: "conflictResolution.note", targets: ["localResolution.note"], disposition: "carried" },
|
|
94
|
+
{ source: "cancellation", targets: [], disposition: "omitted-local-only" },
|
|
95
|
+
{ source: "audit", targets: [], disposition: "omitted-local-only" },
|
|
96
|
+
]);
|
|
97
|
+
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
|
98
|
+
const MAX_STRING_BYTES = 1024;
|
|
99
|
+
const encoder = new TextEncoder();
|
|
100
|
+
const INPUT_KEYS = new Set(["edit", "replica"]);
|
|
101
|
+
const BINDING_KEYS = new Set(["replicaId", "datasetId"]);
|
|
102
|
+
const QUEUED_EDIT_KEYS = new Set([
|
|
103
|
+
"version",
|
|
104
|
+
"id",
|
|
105
|
+
"requestFingerprint",
|
|
106
|
+
"authorizationScopeDigest",
|
|
107
|
+
"sourceId",
|
|
108
|
+
"idempotencyKey",
|
|
109
|
+
"edit",
|
|
110
|
+
"dependencyIds",
|
|
111
|
+
"state",
|
|
112
|
+
"createdAt",
|
|
113
|
+
"updatedAt",
|
|
114
|
+
"attemptCount",
|
|
115
|
+
"lease",
|
|
116
|
+
"retry",
|
|
117
|
+
"applied",
|
|
118
|
+
"conflict",
|
|
119
|
+
"conflictResolution",
|
|
120
|
+
"cancellation",
|
|
121
|
+
"audit",
|
|
122
|
+
]);
|
|
123
|
+
const FEATURE_EDIT_KEYS = new Set(["operation", "featureId", "attributes", "geometry"]);
|
|
124
|
+
const CONFLICT_OUTCOME_KEYS = new Set(["conflictId", "detectedAt", "serverGeneration"]);
|
|
125
|
+
const CONFLICT_RESOLUTION_KEYS = new Set([
|
|
126
|
+
...CONFLICT_OUTCOME_KEYS,
|
|
127
|
+
"choice",
|
|
128
|
+
"disposition",
|
|
129
|
+
"acknowledgement",
|
|
130
|
+
"resolvedAt",
|
|
131
|
+
"resolvedBy",
|
|
132
|
+
"note",
|
|
133
|
+
]);
|
|
134
|
+
const RESOLUTION_CHOICES = new Set(["accept-client", "accept-server", "discard"]);
|
|
135
|
+
const EDIT_STATES = new Set(["pending", "leased", "retryable", "applied", "conflicted", "cancelled"]);
|
|
136
|
+
/**
|
|
137
|
+
* A closed conflict's status, decided by what the local record can substantiate.
|
|
138
|
+
*
|
|
139
|
+
* `resolved` is unreachable on purpose: it means the resolution was committed,
|
|
140
|
+
* and no local record can know that. A requeued edit is `resolving` — the
|
|
141
|
+
* choice is made, the delivery is not — and an abandoned one is `discarded`,
|
|
142
|
+
* which the client can say of its own edit without any server.
|
|
143
|
+
*/
|
|
144
|
+
const RESOLVED_STATUSES = Object.freeze({
|
|
145
|
+
requeued: "resolving",
|
|
146
|
+
discarded: "discarded",
|
|
147
|
+
});
|
|
148
|
+
/**
|
|
149
|
+
* An offline `add` is the disconnected form of a server `create`; `update` and
|
|
150
|
+
* `delete` keep their names. The queue's spelling and the conflict contract's
|
|
151
|
+
* spelling are both closed unions, so the map is total in both directions.
|
|
152
|
+
*/
|
|
153
|
+
const CLIENT_OPERATIONS = Object.freeze({
|
|
154
|
+
add: "create",
|
|
155
|
+
update: "update",
|
|
156
|
+
delete: "delete",
|
|
157
|
+
});
|
|
158
|
+
/**
|
|
159
|
+
* Every member left unfilled, in a stable order. `serverGen` is included only
|
|
160
|
+
* when the acknowledgement carried no cursor: its presence in this list is how
|
|
161
|
+
* an application learns the conflict cannot yet be ordered against replica sync
|
|
162
|
+
* state, instead of discovering an absent optional member by accident.
|
|
163
|
+
*/
|
|
164
|
+
const UNFILLED_MEMBERS = deepFreeze([
|
|
165
|
+
{ member: "base", reason: "server-owned" },
|
|
166
|
+
{ member: "serverState", reason: "server-owned" },
|
|
167
|
+
{ member: "serverOperation", reason: "server-owned" },
|
|
168
|
+
{ member: "fieldConflicts", reason: "server-owned" },
|
|
169
|
+
{ member: "fieldConflictCount", reason: "server-owned" },
|
|
170
|
+
{ member: "hasGeometryConflict", reason: "server-owned" },
|
|
171
|
+
{ member: "geometryConflict", reason: "server-owned" },
|
|
172
|
+
{ member: "resolutionOptions", reason: "server-owned" },
|
|
173
|
+
{ member: "resolution", reason: "server-owned" },
|
|
174
|
+
{ member: "layerId", reason: "not-recorded" },
|
|
175
|
+
{ member: "client", reason: "not-recorded" },
|
|
176
|
+
{ member: "device", reason: "not-recorded" },
|
|
177
|
+
{ member: "metadata", reason: "not-recorded" },
|
|
178
|
+
{ member: "clientState.attributes", reason: "payload-free" },
|
|
179
|
+
{ member: "clientState.geometry", reason: "payload-free" },
|
|
180
|
+
]);
|
|
181
|
+
const UNAVAILABLE_WITHOUT_SERVER_GENERATION = deepFreeze([
|
|
182
|
+
...UNFILLED_MEMBERS,
|
|
183
|
+
{ member: "serverGen", reason: "not-recorded" },
|
|
184
|
+
]);
|
|
185
|
+
/**
|
|
186
|
+
* Project one durable conflicted queued edit onto the shipped sync-conflict
|
|
187
|
+
* vocabulary.
|
|
188
|
+
*
|
|
189
|
+
* Two failure modes are kept apart on purpose. The caller's own argument — the
|
|
190
|
+
* replica binding — is a programming input, so a malformed one throws. The
|
|
191
|
+
* queued edit is persisted, possibly corrupt data, so an unmappable one is
|
|
192
|
+
* returned as a typed refusal that the caller can render beside the conflicts
|
|
193
|
+
* that did project. Neither path ever returns a partially guessed conflict.
|
|
194
|
+
*/
|
|
195
|
+
export function projectOfflineReplaySyncConflict(input) {
|
|
196
|
+
const record = argumentRecord(input, "input");
|
|
197
|
+
allowedArgumentKeys(record, INPUT_KEYS, "input");
|
|
198
|
+
const replica = normalizeOfflineReplaySyncConflictBinding(record.replica, "input.replica");
|
|
199
|
+
const read = readConflictedEdit(record.edit, "input.edit");
|
|
200
|
+
if (!read.ok) {
|
|
201
|
+
return deepFreeze({
|
|
202
|
+
kind: HONUA_OFFLINE_REPLAY_SYNC_CONFLICT_KIND,
|
|
203
|
+
version: HONUA_OFFLINE_REPLAY_SYNC_CONFLICT_VERSION,
|
|
204
|
+
outcome: "refused",
|
|
205
|
+
reason: read.reason,
|
|
206
|
+
path: read.path,
|
|
207
|
+
...(read.editId === undefined ? {} : { editId: read.editId }),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
const value = read.value;
|
|
211
|
+
const clientOperation = CLIENT_OPERATIONS[value.operation];
|
|
212
|
+
const clientState = {
|
|
213
|
+
operation: clientOperation,
|
|
214
|
+
editedAt: value.createdAt,
|
|
215
|
+
...(clientOperation === "delete" ? { deleted: true } : {}),
|
|
216
|
+
};
|
|
217
|
+
return deepFreeze({
|
|
218
|
+
kind: HONUA_OFFLINE_REPLAY_SYNC_CONFLICT_KIND,
|
|
219
|
+
version: HONUA_OFFLINE_REPLAY_SYNC_CONFLICT_VERSION,
|
|
220
|
+
outcome: "projected",
|
|
221
|
+
editId: value.editId,
|
|
222
|
+
conflict: {
|
|
223
|
+
id: value.conflictId,
|
|
224
|
+
replicaId: replica.replicaId,
|
|
225
|
+
datasetId: replica.datasetId,
|
|
226
|
+
sourceId: value.sourceId,
|
|
227
|
+
featureId: value.featureId,
|
|
228
|
+
// A replay conflict is always the disconnected-replica model, never a
|
|
229
|
+
// named-version reconcile/post conflict; review UI routes on this.
|
|
230
|
+
kind: "replica-sync",
|
|
231
|
+
// An unresolved record is awaiting review; a resolved one says what the
|
|
232
|
+
// reviewer chose, and never that a server committed it.
|
|
233
|
+
status: value.resolution === undefined ? "pending" : RESOLVED_STATUSES[value.resolution.disposition],
|
|
234
|
+
clientOperation,
|
|
235
|
+
detectedAt: value.detectedAt,
|
|
236
|
+
clientState,
|
|
237
|
+
...(value.serverGeneration === undefined ? {} : { serverGen: value.serverGeneration }),
|
|
238
|
+
},
|
|
239
|
+
...(value.resolution === undefined ? {} : { localResolution: value.resolution }),
|
|
240
|
+
unavailable: value.serverGeneration === undefined ? UNAVAILABLE_WITHOUT_SERVER_GENERATION : UNFILLED_MEMBERS,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Validate a caller-supplied replica binding. Shared with the replay pass so
|
|
245
|
+
* one malformed binding is rejected identically wherever it is supplied.
|
|
246
|
+
*
|
|
247
|
+
* @internal
|
|
248
|
+
*/
|
|
249
|
+
export function normalizeOfflineReplaySyncConflictBinding(value, path) {
|
|
250
|
+
const record = argumentRecord(value, path);
|
|
251
|
+
allowedArgumentKeys(record, BINDING_KEYS, path);
|
|
252
|
+
return Object.freeze({
|
|
253
|
+
replicaId: argumentString(record.replicaId, `${path}.replicaId`),
|
|
254
|
+
datasetId: argumentString(record.datasetId, `${path}.datasetId`),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
function readConflictedEdit(value, path) {
|
|
258
|
+
if (!isPlainRecord(value))
|
|
259
|
+
return { ok: false, reason: "unreadable-edit", path };
|
|
260
|
+
const record = value;
|
|
261
|
+
const editId = optionalDigest(record.id);
|
|
262
|
+
if (editId === undefined)
|
|
263
|
+
return { ok: false, reason: "unreadable-edit", path: `${path}.id` };
|
|
264
|
+
for (const key of Object.keys(record)) {
|
|
265
|
+
if (!QUEUED_EDIT_KEYS.has(key))
|
|
266
|
+
return { ok: false, reason: "unreadable-edit", path: `${path}.${key}`, editId };
|
|
267
|
+
}
|
|
268
|
+
const state = record.state;
|
|
269
|
+
if (typeof state !== "string" || !EDIT_STATES.has(state)) {
|
|
270
|
+
return { ok: false, reason: "unreadable-edit", path: `${path}.state`, editId };
|
|
271
|
+
}
|
|
272
|
+
if (state === "conflicted") {
|
|
273
|
+
// Conflicted and resolved at once contradicts itself; the queue's own record
|
|
274
|
+
// validator rejects the same combination.
|
|
275
|
+
if (record.conflictResolution !== undefined) {
|
|
276
|
+
return { ok: false, reason: "unreadable-edit", path: `${path}.conflictResolution`, editId };
|
|
277
|
+
}
|
|
278
|
+
if (record.conflict === undefined) {
|
|
279
|
+
return { ok: false, reason: "missing-conflict-record", path: `${path}.conflict`, editId };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
// A non-conflicted record carrying a conflict outcome contradicts itself in
|
|
284
|
+
// the other direction.
|
|
285
|
+
if (record.conflict !== undefined) {
|
|
286
|
+
return { ok: false, reason: "unreadable-edit", path: `${path}.conflict`, editId };
|
|
287
|
+
}
|
|
288
|
+
if (record.conflictResolution === undefined) {
|
|
289
|
+
return { ok: false, reason: "not-conflicted", path: `${path}.state`, editId };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const sourceId = optionalString(record.sourceId);
|
|
293
|
+
if (sourceId === undefined)
|
|
294
|
+
return { ok: false, reason: "unreadable-edit", path: `${path}.sourceId`, editId };
|
|
295
|
+
const createdAt = optionalTimestamp(record.createdAt);
|
|
296
|
+
if (createdAt === undefined)
|
|
297
|
+
return { ok: false, reason: "unreadable-edit", path: `${path}.createdAt`, editId };
|
|
298
|
+
if (!isPlainRecord(record.edit))
|
|
299
|
+
return { ok: false, reason: "unreadable-edit", path: `${path}.edit`, editId };
|
|
300
|
+
const edit = record.edit;
|
|
301
|
+
for (const key of Object.keys(edit)) {
|
|
302
|
+
if (!FEATURE_EDIT_KEYS.has(key)) {
|
|
303
|
+
return { ok: false, reason: "unreadable-edit", path: `${path}.edit.${key}`, editId };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const operation = edit.operation;
|
|
307
|
+
if (typeof operation !== "string" || !Object.hasOwn(CLIENT_OPERATIONS, operation)) {
|
|
308
|
+
return { ok: false, reason: "unreadable-edit", path: `${path}.edit.operation`, editId };
|
|
309
|
+
}
|
|
310
|
+
// One reader for both shapes: an open conflict and the record of a closed one
|
|
311
|
+
// carry the same identity and timing, and the closed one adds the choice.
|
|
312
|
+
const resolved = state !== "conflicted";
|
|
313
|
+
const outcomePath = resolved ? `${path}.conflictResolution` : `${path}.conflict`;
|
|
314
|
+
const source = resolved ? record.conflictResolution : record.conflict;
|
|
315
|
+
if (!isPlainRecord(source)) {
|
|
316
|
+
return { ok: false, reason: "unreadable-edit", path: outcomePath, editId };
|
|
317
|
+
}
|
|
318
|
+
const conflict = source;
|
|
319
|
+
const allowed = resolved ? CONFLICT_RESOLUTION_KEYS : CONFLICT_OUTCOME_KEYS;
|
|
320
|
+
for (const key of Object.keys(conflict)) {
|
|
321
|
+
if (!allowed.has(key)) {
|
|
322
|
+
return { ok: false, reason: "unreadable-edit", path: `${outcomePath}.${key}`, editId };
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const conflictId = optionalString(conflict.conflictId);
|
|
326
|
+
if (conflictId === undefined) {
|
|
327
|
+
return { ok: false, reason: "unreadable-edit", path: `${outcomePath}.conflictId`, editId };
|
|
328
|
+
}
|
|
329
|
+
const detectedAt = optionalTimestamp(conflict.detectedAt);
|
|
330
|
+
if (detectedAt === undefined) {
|
|
331
|
+
return { ok: false, reason: "unreadable-edit", path: `${outcomePath}.detectedAt`, editId };
|
|
332
|
+
}
|
|
333
|
+
let serverGeneration;
|
|
334
|
+
if (conflict.serverGeneration !== undefined) {
|
|
335
|
+
serverGeneration = optionalString(conflict.serverGeneration);
|
|
336
|
+
if (serverGeneration === undefined) {
|
|
337
|
+
return { ok: false, reason: "unreadable-edit", path: `${outcomePath}.serverGeneration`, editId };
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
let resolution;
|
|
341
|
+
if (resolved) {
|
|
342
|
+
const read = readLocalResolution(conflict, outcomePath, editId);
|
|
343
|
+
if (!read.ok)
|
|
344
|
+
return read;
|
|
345
|
+
resolution = read.value;
|
|
346
|
+
}
|
|
347
|
+
// Checked last: a feature-less local create is a well-formed conflict this
|
|
348
|
+
// build simply cannot name in a contract whose `featureId` is required.
|
|
349
|
+
const featureId = readFeatureId(edit.featureId);
|
|
350
|
+
if (featureId === undefined) {
|
|
351
|
+
return { ok: false, reason: "unidentified-feature", path: `${path}.edit.featureId`, editId };
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
ok: true,
|
|
355
|
+
value: {
|
|
356
|
+
editId,
|
|
357
|
+
sourceId,
|
|
358
|
+
operation: operation,
|
|
359
|
+
featureId,
|
|
360
|
+
createdAt,
|
|
361
|
+
conflictId,
|
|
362
|
+
detectedAt,
|
|
363
|
+
...(serverGeneration === undefined ? {} : { serverGeneration }),
|
|
364
|
+
...(resolution === undefined ? {} : { resolution }),
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
function readLocalResolution(record, path, editId) {
|
|
369
|
+
const refuse = (member) => ({
|
|
370
|
+
ok: false,
|
|
371
|
+
reason: "unreadable-edit",
|
|
372
|
+
path: `${path}.${member}`,
|
|
373
|
+
editId,
|
|
374
|
+
});
|
|
375
|
+
const choice = record.choice;
|
|
376
|
+
if (typeof choice !== "string" || !RESOLUTION_CHOICES.has(choice))
|
|
377
|
+
return refuse("choice");
|
|
378
|
+
const disposition = record.disposition;
|
|
379
|
+
// A record whose disposition disagrees with its choice cannot be trusted to
|
|
380
|
+
// describe what the queue did, and the status is derived from the disposition.
|
|
381
|
+
if (disposition !== (choice === "accept-client" ? "requeued" : "discarded"))
|
|
382
|
+
return refuse("disposition");
|
|
383
|
+
if (record.acknowledgement !== "unacknowledged-by-server")
|
|
384
|
+
return refuse("acknowledgement");
|
|
385
|
+
const resolvedAt = optionalTimestamp(record.resolvedAt);
|
|
386
|
+
if (resolvedAt === undefined)
|
|
387
|
+
return refuse("resolvedAt");
|
|
388
|
+
let resolvedBy;
|
|
389
|
+
if (record.resolvedBy !== undefined) {
|
|
390
|
+
resolvedBy = optionalString(record.resolvedBy);
|
|
391
|
+
if (resolvedBy === undefined)
|
|
392
|
+
return refuse("resolvedBy");
|
|
393
|
+
}
|
|
394
|
+
let note;
|
|
395
|
+
if (record.note !== undefined) {
|
|
396
|
+
note = optionalString(record.note);
|
|
397
|
+
if (note === undefined)
|
|
398
|
+
return refuse("note");
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
ok: true,
|
|
402
|
+
value: {
|
|
403
|
+
choice: choice,
|
|
404
|
+
disposition: disposition,
|
|
405
|
+
acknowledgement: "unacknowledged-by-server",
|
|
406
|
+
resolvedAt,
|
|
407
|
+
...(resolvedBy === undefined ? {} : { resolvedBy: { id: resolvedBy } }),
|
|
408
|
+
...(note === undefined ? {} : { note }),
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
function readFeatureId(value) {
|
|
413
|
+
if (typeof value === "number")
|
|
414
|
+
return Number.isFinite(value) ? value : undefined;
|
|
415
|
+
return optionalString(value);
|
|
416
|
+
}
|
|
417
|
+
function isPlainRecord(value) {
|
|
418
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
419
|
+
return false;
|
|
420
|
+
const prototype = Object.getPrototypeOf(value);
|
|
421
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
422
|
+
return false;
|
|
423
|
+
if (Object.getOwnPropertySymbols(value).length > 0)
|
|
424
|
+
return false;
|
|
425
|
+
for (const [, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
|
|
426
|
+
if (!("value" in descriptor))
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
431
|
+
function optionalString(value) {
|
|
432
|
+
if (typeof value !== "string" ||
|
|
433
|
+
value.length === 0 ||
|
|
434
|
+
value.length > MAX_STRING_BYTES ||
|
|
435
|
+
value.trim() !== value ||
|
|
436
|
+
encoder.encode(value).byteLength > MAX_STRING_BYTES) {
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
439
|
+
return value;
|
|
440
|
+
}
|
|
441
|
+
function optionalDigest(value) {
|
|
442
|
+
const result = optionalString(value);
|
|
443
|
+
return result !== undefined && DIGEST_PATTERN.test(result) ? result : undefined;
|
|
444
|
+
}
|
|
445
|
+
function optionalTimestamp(value) {
|
|
446
|
+
const result = optionalString(value);
|
|
447
|
+
if (result === undefined)
|
|
448
|
+
return undefined;
|
|
449
|
+
const time = Date.parse(result);
|
|
450
|
+
if (!Number.isFinite(time) || new Date(time).toISOString() !== result)
|
|
451
|
+
return undefined;
|
|
452
|
+
return result;
|
|
453
|
+
}
|
|
454
|
+
function argumentRecord(value, path) {
|
|
455
|
+
if (!isPlainRecord(value))
|
|
456
|
+
invalid(`${path} must be a plain data object.`, path);
|
|
457
|
+
return value;
|
|
458
|
+
}
|
|
459
|
+
function allowedArgumentKeys(record, allowed, path) {
|
|
460
|
+
for (const key of Object.keys(record)) {
|
|
461
|
+
if (!allowed.has(key))
|
|
462
|
+
invalid(`${path}.${key} is not allowed.`, `${path}.${key}`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
function argumentString(value, path) {
|
|
466
|
+
const result = optionalString(value);
|
|
467
|
+
if (result === undefined) {
|
|
468
|
+
invalid(`${path} must be a normalized non-empty string of at most 1024 UTF-8 bytes.`, path);
|
|
469
|
+
}
|
|
470
|
+
return result;
|
|
471
|
+
}
|
|
472
|
+
function invalid(message, path) {
|
|
473
|
+
throw new HonuaOfflineEditQueueError("invalid-edit", message, { path });
|
|
474
|
+
}
|
|
475
|
+
function deepFreeze(value) {
|
|
476
|
+
if (typeof value !== "object" || value === null || Object.isFrozen(value))
|
|
477
|
+
return value;
|
|
478
|
+
Object.freeze(value);
|
|
479
|
+
for (const child of Object.values(value))
|
|
480
|
+
deepFreeze(child);
|
|
481
|
+
return value;
|
|
482
|
+
}
|