@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,1807 @@
|
|
|
1
|
+
import { HonuaSdkError } from "../core/error-envelope.js";
|
|
2
|
+
import { credentialScreenMessage, screenPersistedString } from "./credential-screen.js";
|
|
3
|
+
export const HONUA_OFFLINE_EDIT_QUEUE_VERSION = "1.0";
|
|
4
|
+
export const DEFAULT_OFFLINE_EDIT_QUEUE_MAX_EDITS = 10_000;
|
|
5
|
+
export const DEFAULT_OFFLINE_EDIT_QUEUE_MAX_PAYLOAD_BYTES = 1024 * 1024;
|
|
6
|
+
export const DEFAULT_OFFLINE_EDIT_QUEUE_MAX_AUDIT_EVENTS = 128;
|
|
7
|
+
export const DEFAULT_OFFLINE_EDIT_QUEUE_MAX_DEPENDENCIES = 256;
|
|
8
|
+
export const MAX_OFFLINE_EDIT_LEASE_DURATION_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
const EDIT_ID_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
|
10
|
+
const DATABASE_VERSION = 2;
|
|
11
|
+
const EDIT_STORE = "edits";
|
|
12
|
+
const METADATA_STORE = "edit-metadata";
|
|
13
|
+
const TOMBSTONE_STORE = "edit-tombstones";
|
|
14
|
+
const PARTITION_ORDER_INDEX = "partition-order";
|
|
15
|
+
const PARTITION_STATE_ORDER_INDEX = "partition-state-order";
|
|
16
|
+
const DEPENDENCY_INDEX = "dependency";
|
|
17
|
+
const encoder = new TextEncoder();
|
|
18
|
+
const ENQUEUE_KEYS = new Set(["authorizationScopeDigest", "sourceId", "idempotencyKey", "edit", "dependencyIds"]);
|
|
19
|
+
const FEATURE_EDIT_KEYS = new Set(["operation", "featureId", "attributes", "geometry"]);
|
|
20
|
+
const PARTITION_KEYS = ["authorizationScopeDigest", "sourceId"];
|
|
21
|
+
const CLAIM_KEYS = new Set([...PARTITION_KEYS, "workerId", "limit", "leaseDurationMs"]);
|
|
22
|
+
const LIST_KEYS = new Set([...PARTITION_KEYS, "limit"]);
|
|
23
|
+
const PRUNE_KEYS = new Set([...PARTITION_KEYS, "terminalBefore", "limit"]);
|
|
24
|
+
const APPLIED_KEYS = new Set(["serverOperationId", "serverGeneration"]);
|
|
25
|
+
const RETRY_KEYS = new Set(["retryAt", "reasonCode"]);
|
|
26
|
+
const CONFLICT_KEYS = new Set(["conflictId", "serverGeneration"]);
|
|
27
|
+
const CANCEL_KEYS = new Set(["reasonCode"]);
|
|
28
|
+
const RESOLVE_CONFLICT_KEYS = new Set(["conflictId", "choice", "resolvedBy", "note"]);
|
|
29
|
+
const QUEUE_STATES = [
|
|
30
|
+
"pending",
|
|
31
|
+
"leased",
|
|
32
|
+
"retryable",
|
|
33
|
+
"applied",
|
|
34
|
+
"conflicted",
|
|
35
|
+
"cancelled",
|
|
36
|
+
];
|
|
37
|
+
function emptyStateCounts() {
|
|
38
|
+
return { pending: 0, leased: 0, retryable: 0, applied: 0, conflicted: 0, cancelled: 0 };
|
|
39
|
+
}
|
|
40
|
+
export class HonuaOfflineEditQueueError extends HonuaSdkError {
|
|
41
|
+
code;
|
|
42
|
+
name = "HonuaOfflineEditQueueError";
|
|
43
|
+
constructor(code, message, options = {}) {
|
|
44
|
+
super(editQueueSdkCode(code), message, {
|
|
45
|
+
...(options.cause === undefined ? {} : { cause: options.cause }),
|
|
46
|
+
context: { reasonCode: code },
|
|
47
|
+
});
|
|
48
|
+
this.code = code;
|
|
49
|
+
this.editId = options.editId;
|
|
50
|
+
this.path = options.path;
|
|
51
|
+
}
|
|
52
|
+
editId;
|
|
53
|
+
path;
|
|
54
|
+
}
|
|
55
|
+
/** Stable discriminator and version for edit-queue recovery reports. */
|
|
56
|
+
export const HONUA_OFFLINE_EDIT_QUEUE_RECOVERY_KIND = "honua.offline-edit-queue-recovery";
|
|
57
|
+
export const HONUA_OFFLINE_EDIT_QUEUE_RECOVERY_VERSION = 1;
|
|
58
|
+
/** Ceilings on one recovery pass. Callers may tighten them, never raise them. */
|
|
59
|
+
export const DEFAULT_OFFLINE_EDIT_QUEUE_MAX_RECOVERY_RECORDS = 100_000;
|
|
60
|
+
export const DEFAULT_OFFLINE_EDIT_QUEUE_MAX_RECOVERY_BYTES = 64 * 1024 * 1024;
|
|
61
|
+
/** Deterministic in-memory implementation for tests and non-persistent hosts. */
|
|
62
|
+
export class MemoryOfflineEditQueue {
|
|
63
|
+
#records = new Map();
|
|
64
|
+
#tombstones = new Map();
|
|
65
|
+
#options;
|
|
66
|
+
constructor(options = {}) {
|
|
67
|
+
this.#options = normalizeOptions(options);
|
|
68
|
+
}
|
|
69
|
+
async enqueue(input) {
|
|
70
|
+
const prepared = await prepareEdit(input, this.#options);
|
|
71
|
+
rejectPrunedIdentity(this.#tombstones.get(prepared.id), prepared);
|
|
72
|
+
const result = enqueueRecord([...this.#records.values()], this.#tombstones, prepared, timestamp(this.#options.now), this.#options);
|
|
73
|
+
if (result.record)
|
|
74
|
+
this.#records.set(result.record.id, result.record);
|
|
75
|
+
return copy(result.result);
|
|
76
|
+
}
|
|
77
|
+
async get(editId, partition) {
|
|
78
|
+
const record = this.#records.get(requiredId(editId, "editId"));
|
|
79
|
+
const normalized = capturePartition(partition, "partition");
|
|
80
|
+
return record && matchesPartition(record, normalized) ? copy(record) : undefined;
|
|
81
|
+
}
|
|
82
|
+
async list(options) {
|
|
83
|
+
const { partition, limit } = captureListOptions(options);
|
|
84
|
+
return copy(sortedRecords([...this.#records.values()].filter((record) => matchesPartition(record, partition))).slice(0, limit));
|
|
85
|
+
}
|
|
86
|
+
async countByState(partition) {
|
|
87
|
+
const normalized = capturePartition(partition, "partition");
|
|
88
|
+
const counts = emptyStateCounts();
|
|
89
|
+
for (const record of this.#records.values()) {
|
|
90
|
+
if (matchesPartition(record, normalized))
|
|
91
|
+
counts[record.state] += 1;
|
|
92
|
+
}
|
|
93
|
+
return Object.freeze(counts);
|
|
94
|
+
}
|
|
95
|
+
async claimReady(options) {
|
|
96
|
+
const result = claimRecords([...this.#records.values()], this.#tombstones, options, this.#options);
|
|
97
|
+
for (const record of result.changed)
|
|
98
|
+
this.#records.set(record.id, record);
|
|
99
|
+
return copy(result.claimed);
|
|
100
|
+
}
|
|
101
|
+
async markApplied(editId, leaseToken, outcome = {}) {
|
|
102
|
+
return this.#transition(editId, leaseToken, "applied", outcome);
|
|
103
|
+
}
|
|
104
|
+
async markRetry(editId, leaseToken, outcome) {
|
|
105
|
+
return this.#transition(editId, leaseToken, "retryable", outcome);
|
|
106
|
+
}
|
|
107
|
+
async markConflicted(editId, leaseToken, outcome) {
|
|
108
|
+
return this.#transition(editId, leaseToken, "conflicted", outcome);
|
|
109
|
+
}
|
|
110
|
+
async cancel(editId, partition, outcome) {
|
|
111
|
+
const id = requiredId(editId, "editId");
|
|
112
|
+
const normalized = capturePartition(partition, "partition");
|
|
113
|
+
const current = this.#records.get(id);
|
|
114
|
+
if (!current || !matchesPartition(current, normalized)) {
|
|
115
|
+
fail("edit-not-found", `Offline edit "${id}" was not found.`, { editId: id });
|
|
116
|
+
}
|
|
117
|
+
const updated = cancelRecord(current, outcome, this.#options);
|
|
118
|
+
this.#records.set(id, updated);
|
|
119
|
+
return copy(updated);
|
|
120
|
+
}
|
|
121
|
+
async resolveConflict(editId, partition, resolution) {
|
|
122
|
+
const id = requiredId(editId, "editId");
|
|
123
|
+
const normalized = capturePartition(partition, "partition");
|
|
124
|
+
const current = this.#records.get(id);
|
|
125
|
+
if (!current || !matchesPartition(current, normalized)) {
|
|
126
|
+
fail("edit-not-found", `Offline edit "${id}" was not found.`, { editId: id });
|
|
127
|
+
}
|
|
128
|
+
const updated = resolveConflictRecord(current, resolution, this.#options);
|
|
129
|
+
this.#records.set(id, updated);
|
|
130
|
+
return copy(updated);
|
|
131
|
+
}
|
|
132
|
+
async pruneTerminal(options) {
|
|
133
|
+
const normalized = capturePruneOptions(options);
|
|
134
|
+
const prunedAt = timestamp(this.#options.now);
|
|
135
|
+
const records = [...this.#records.values()];
|
|
136
|
+
const removed = [];
|
|
137
|
+
for (const record of terminalPruneCandidates(records, normalized)) {
|
|
138
|
+
if (hasActiveDependent(records, record.id))
|
|
139
|
+
continue;
|
|
140
|
+
this.#tombstones.set(record.id, tombstoneFor(record, prunedAt));
|
|
141
|
+
this.#records.delete(record.id);
|
|
142
|
+
removed.push(record.id);
|
|
143
|
+
if (removed.length === normalized.limit)
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
return deepFreeze(removed);
|
|
147
|
+
}
|
|
148
|
+
#transition(editId, leaseToken, state, outcome) {
|
|
149
|
+
const id = requiredId(editId, "editId");
|
|
150
|
+
const current = this.#records.get(id);
|
|
151
|
+
if (!current)
|
|
152
|
+
fail("edit-not-found", `Offline edit "${id}" was not found.`, { editId: id });
|
|
153
|
+
const updated = transitionRecord(current, leaseToken, state, outcome, this.#options);
|
|
154
|
+
this.#records.set(id, updated);
|
|
155
|
+
return copy(updated);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** Persistent browser implementation with atomic IndexedDB claims and transitions. */
|
|
159
|
+
export class IndexedDbOfflineEditQueue {
|
|
160
|
+
#database;
|
|
161
|
+
#options;
|
|
162
|
+
#onRecovery;
|
|
163
|
+
constructor(options = {}) {
|
|
164
|
+
const factory = options.indexedDB ?? globalThis.indexedDB;
|
|
165
|
+
if (!factory)
|
|
166
|
+
throw new Error("IndexedDB is not available in this runtime.");
|
|
167
|
+
const name = options.name ?? "honua-offline-edit-queue";
|
|
168
|
+
if (name.trim().length === 0)
|
|
169
|
+
throw new Error("IndexedDB database name must be non-empty.");
|
|
170
|
+
this.#options = normalizeOptions(options);
|
|
171
|
+
if (options.onRecovery)
|
|
172
|
+
this.#onRecovery = options.onRecovery;
|
|
173
|
+
const limits = options.recovery ?? {};
|
|
174
|
+
const onRecovery = options.onRecovery;
|
|
175
|
+
// Recovery runs once, before any caller can read, so an unreadable record
|
|
176
|
+
// is gone before it can be leased and replayed as though it were a write.
|
|
177
|
+
const opened = openDatabase(factory, name).then(async (database) => {
|
|
178
|
+
await recoverQueueDatabase(database, limits, onRecovery);
|
|
179
|
+
return database;
|
|
180
|
+
});
|
|
181
|
+
// A queue that refuses to open reports through every method that awaits it;
|
|
182
|
+
// swallowing the copy keeps that from also being an unhandled rejection.
|
|
183
|
+
opened.catch(() => undefined);
|
|
184
|
+
this.#database = opened;
|
|
185
|
+
}
|
|
186
|
+
async enqueue(input) {
|
|
187
|
+
const prepared = await prepareEdit(input, this.#options);
|
|
188
|
+
return this.#run("readwrite", async ({ edits, metadata, tombstones }, tally) => {
|
|
189
|
+
const existing = readStoredEdit(await request(edits.get(prepared.id)), prepared.id, tally);
|
|
190
|
+
const tombstone = readStoredTombstone(await request(tombstones.get(prepared.id)), tally);
|
|
191
|
+
rejectPrunedIdentity(tombstone, prepared);
|
|
192
|
+
const dependencies = await Promise.all(prepared.dependencyIds.map(async (id) => {
|
|
193
|
+
const live = readStoredMetadata(await request(metadata.get(id)), tally);
|
|
194
|
+
return live ?? readStoredTombstone(await request(tombstones.get(id)), tally);
|
|
195
|
+
}));
|
|
196
|
+
const queueSize = await request(metadata.count());
|
|
197
|
+
const result = enqueuePreparedRecord(existing, dependencies, queueSize, prepared, timestamp(this.#options.now), this.#options);
|
|
198
|
+
if (result.record) {
|
|
199
|
+
edits.put(result.record);
|
|
200
|
+
metadata.put(metadataFor(result.record));
|
|
201
|
+
}
|
|
202
|
+
return copy(result.result);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
async get(editId, partition) {
|
|
206
|
+
const id = requiredId(editId, "editId");
|
|
207
|
+
const normalized = capturePartition(partition, "partition");
|
|
208
|
+
return this.#run("readonly", async ({ edits }, tally) => {
|
|
209
|
+
const record = readStoredEdit(await request(edits.get(id)), id, tally);
|
|
210
|
+
return record && matchesPartition(record, normalized) ? copy(record) : undefined;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
async list(options) {
|
|
214
|
+
const { partition, limit } = captureListOptions(options);
|
|
215
|
+
return this.#run("readonly", async ({ edits, metadata }, tally) => {
|
|
216
|
+
const ids = await scanPartitionIds(metadata, partition, limit, tally);
|
|
217
|
+
const records = await Promise.all(ids.map(async (id) => readStoredEdit(await request(edits.get(id)), id, tally)));
|
|
218
|
+
return copy(records.filter((record) => record !== undefined));
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
async countByState(partition) {
|
|
222
|
+
const normalized = capturePartition(partition, "partition");
|
|
223
|
+
return this.#run("readonly", async ({ metadata }) => {
|
|
224
|
+
const index = metadata.index(PARTITION_STATE_ORDER_INDEX);
|
|
225
|
+
// Counts read index keys only; the startup pass has already reconciled the
|
|
226
|
+
// index against the edits it projects.
|
|
227
|
+
const counts = emptyStateCounts();
|
|
228
|
+
// The compound partition/state index counts without materializing or
|
|
229
|
+
// reading any edit payload.
|
|
230
|
+
for (const state of QUEUE_STATES) {
|
|
231
|
+
counts[state] = await request(index.count(partitionStateRange(normalized, state)));
|
|
232
|
+
}
|
|
233
|
+
return Object.freeze(counts);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
async claimReady(options) {
|
|
237
|
+
const normalized = captureClaimOptions(options, this.#options);
|
|
238
|
+
return this.#run("readwrite", async ({ edits, metadata, tombstones }, tally) => {
|
|
239
|
+
const candidates = await scanReadyMetadata(metadata, tombstones, normalized, tally);
|
|
240
|
+
const claimed = [];
|
|
241
|
+
for (const candidate of candidates) {
|
|
242
|
+
const record = readStoredEdit(await request(edits.get(candidate.id)), candidate.id, tally);
|
|
243
|
+
if (!record) {
|
|
244
|
+
// An index row whose edit is gone or unreadable is retired here rather
|
|
245
|
+
// than thrown, so one damaged record cannot stall every other claim.
|
|
246
|
+
edits.delete(candidate.id);
|
|
247
|
+
metadata.delete(candidate.id);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const updated = claimRecord(record, normalized, this.#options);
|
|
251
|
+
edits.put(updated);
|
|
252
|
+
metadata.put(metadataFor(updated));
|
|
253
|
+
claimed.push(updated);
|
|
254
|
+
}
|
|
255
|
+
return copy(claimed);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
async markApplied(editId, leaseToken, outcome = {}) {
|
|
259
|
+
return this.#transition(editId, leaseToken, "applied", outcome);
|
|
260
|
+
}
|
|
261
|
+
async markRetry(editId, leaseToken, outcome) {
|
|
262
|
+
return this.#transition(editId, leaseToken, "retryable", outcome);
|
|
263
|
+
}
|
|
264
|
+
async markConflicted(editId, leaseToken, outcome) {
|
|
265
|
+
return this.#transition(editId, leaseToken, "conflicted", outcome);
|
|
266
|
+
}
|
|
267
|
+
async cancel(editId, partition, outcome) {
|
|
268
|
+
const id = requiredId(editId, "editId");
|
|
269
|
+
const normalized = capturePartition(partition, "partition");
|
|
270
|
+
return this.#run("readwrite", async ({ edits, metadata }, tally) => {
|
|
271
|
+
const stored = await request(edits.get(id));
|
|
272
|
+
const current = readStoredEdit(stored, id, tally);
|
|
273
|
+
if (stored !== undefined && !current) {
|
|
274
|
+
fail("record-unreadable", `Offline edit "${id}" is stored in a form this build cannot read.`, { editId: id });
|
|
275
|
+
}
|
|
276
|
+
if (!current || !matchesPartition(current, normalized)) {
|
|
277
|
+
fail("edit-not-found", `Offline edit "${id}" was not found.`, { editId: id });
|
|
278
|
+
}
|
|
279
|
+
const updated = cancelRecord(current, outcome, this.#options);
|
|
280
|
+
edits.put(updated);
|
|
281
|
+
metadata.put(metadataFor(updated));
|
|
282
|
+
return copy(updated);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
async resolveConflict(editId, partition, resolution) {
|
|
286
|
+
const id = requiredId(editId, "editId");
|
|
287
|
+
const normalized = capturePartition(partition, "partition");
|
|
288
|
+
return this.#run("readwrite", async ({ edits, metadata }, tally) => {
|
|
289
|
+
const stored = await request(edits.get(id));
|
|
290
|
+
const current = readStoredEdit(stored, id, tally);
|
|
291
|
+
if (stored !== undefined && !current) {
|
|
292
|
+
fail("record-unreadable", `Offline edit "${id}" is stored in a form this build cannot read.`, { editId: id });
|
|
293
|
+
}
|
|
294
|
+
if (!current || !matchesPartition(current, normalized)) {
|
|
295
|
+
fail("edit-not-found", `Offline edit "${id}" was not found.`, { editId: id });
|
|
296
|
+
}
|
|
297
|
+
const updated = resolveConflictRecord(current, resolution, this.#options);
|
|
298
|
+
edits.put(updated);
|
|
299
|
+
metadata.put(metadataFor(updated));
|
|
300
|
+
return copy(updated);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
async pruneTerminal(options) {
|
|
304
|
+
const normalized = capturePruneOptions(options);
|
|
305
|
+
const prunedAt = timestamp(this.#options.now);
|
|
306
|
+
return this.#run("readwrite", async ({ edits, metadata, tombstones }, tally) => {
|
|
307
|
+
const candidates = await scanTerminalMetadata(metadata, normalized, tally);
|
|
308
|
+
const removed = [];
|
|
309
|
+
for (const candidate of candidates) {
|
|
310
|
+
if (await hasActiveMetadataDependent(metadata, candidate.id))
|
|
311
|
+
continue;
|
|
312
|
+
const record = readStoredEdit(await request(edits.get(candidate.id)), candidate.id, tally);
|
|
313
|
+
if (!record) {
|
|
314
|
+
// Nothing readable to tombstone: retire the rows without inventing an
|
|
315
|
+
// identity claim the queue cannot substantiate.
|
|
316
|
+
edits.delete(candidate.id);
|
|
317
|
+
metadata.delete(candidate.id);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
tombstones.put(tombstoneFor(record, prunedAt));
|
|
321
|
+
edits.delete(candidate.id);
|
|
322
|
+
metadata.delete(candidate.id);
|
|
323
|
+
removed.push(candidate.id);
|
|
324
|
+
if (removed.length === normalized.limit)
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
return deepFreeze(removed);
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
async #transition(editId, leaseToken, state, outcome) {
|
|
331
|
+
const id = requiredId(editId, "editId");
|
|
332
|
+
return this.#run("readwrite", async ({ edits, metadata }, tally) => {
|
|
333
|
+
const stored = await request(edits.get(id));
|
|
334
|
+
const current = readStoredEdit(stored, id, tally);
|
|
335
|
+
if (stored !== undefined && !current) {
|
|
336
|
+
fail("record-unreadable", `Offline edit "${id}" is stored in a form this build cannot read.`, { editId: id });
|
|
337
|
+
}
|
|
338
|
+
if (!current)
|
|
339
|
+
fail("edit-not-found", `Offline edit "${id}" was not found.`, { editId: id });
|
|
340
|
+
const updated = transitionRecord(current, leaseToken, state, outcome, this.#options);
|
|
341
|
+
edits.put(updated);
|
|
342
|
+
metadata.put(metadataFor(updated));
|
|
343
|
+
return copy(updated);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
async #run(mode, body) {
|
|
347
|
+
const tally = emptyTally();
|
|
348
|
+
try {
|
|
349
|
+
const result = await runTransaction(await this.#database, mode, (stores) => body(stores, tally));
|
|
350
|
+
emitRecovery(this.#onRecovery, tally, "read");
|
|
351
|
+
return result;
|
|
352
|
+
}
|
|
353
|
+
catch (cause) {
|
|
354
|
+
if (cause instanceof HonuaOfflineEditQueueError)
|
|
355
|
+
throw cause;
|
|
356
|
+
throw new HonuaOfflineEditQueueError("store-failed", "Offline edit queue storage failed.", { cause });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
export function createMemoryOfflineEditQueue(options = {}) {
|
|
361
|
+
return new MemoryOfflineEditQueue(options);
|
|
362
|
+
}
|
|
363
|
+
export function createIndexedDbOfflineEditQueue(options = {}) {
|
|
364
|
+
return new IndexedDbOfflineEditQueue(options);
|
|
365
|
+
}
|
|
366
|
+
function normalizeOptions(options) {
|
|
367
|
+
return {
|
|
368
|
+
now: options.now ?? (() => new Date()),
|
|
369
|
+
createLeaseToken: options.createLeaseToken ?? defaultLeaseToken,
|
|
370
|
+
maxEdits: tightenedPositive(options.maxEdits, DEFAULT_OFFLINE_EDIT_QUEUE_MAX_EDITS, "maxEdits"),
|
|
371
|
+
maxPayloadBytes: tightenedPositive(options.maxPayloadBytes, DEFAULT_OFFLINE_EDIT_QUEUE_MAX_PAYLOAD_BYTES, "maxPayloadBytes"),
|
|
372
|
+
maxAuditEvents: tightenedPositive(options.maxAuditEvents, DEFAULT_OFFLINE_EDIT_QUEUE_MAX_AUDIT_EVENTS, "maxAuditEvents"),
|
|
373
|
+
maxDependencies: tightenedPositive(options.maxDependencies, DEFAULT_OFFLINE_EDIT_QUEUE_MAX_DEPENDENCIES, "maxDependencies"),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
async function prepareEdit(input, options) {
|
|
377
|
+
const record = plainRecord(input, "input");
|
|
378
|
+
allowedKeys(record, ENQUEUE_KEYS, "input");
|
|
379
|
+
const authorizationScopeDigest = requiredDigest(record.authorizationScopeDigest, "authorizationScopeDigest");
|
|
380
|
+
const sourceId = requiredPersistedIdentity(record.sourceId, "sourceId");
|
|
381
|
+
const idempotencyKey = requiredPersistedIdentity(record.idempotencyKey, "idempotencyKey");
|
|
382
|
+
const edit = captureFeatureEdit(record.edit, options.maxPayloadBytes);
|
|
383
|
+
const dependencyIds = captureDependencies(record.dependencyIds, options.maxDependencies);
|
|
384
|
+
const identity = { authorizationScopeDigest, sourceId, idempotencyKey };
|
|
385
|
+
const request = { ...identity, edit, dependencyIds };
|
|
386
|
+
const [id, requestFingerprint] = await Promise.all([
|
|
387
|
+
sha256(`honua-offline-edit:v1:${canonicalJson(identity)}`),
|
|
388
|
+
sha256(`honua-offline-edit-request:v1:${canonicalJson(request)}`),
|
|
389
|
+
]);
|
|
390
|
+
if (dependencyIds.includes(id))
|
|
391
|
+
fail("invalid-edit", "Offline edit cannot depend on itself.", { path: "dependencyIds" });
|
|
392
|
+
return { id, requestFingerprint, ...request };
|
|
393
|
+
}
|
|
394
|
+
function enqueueRecord(records, tombstones, prepared, now, options) {
|
|
395
|
+
const existing = records.find((record) => record.id === prepared.id);
|
|
396
|
+
const byId = new Map(records.map((record) => [record.id, record]));
|
|
397
|
+
return enqueuePreparedRecord(existing, prepared.dependencyIds.map((id) => byId.get(id) ?? tombstones.get(id)), records.length, prepared, now, options);
|
|
398
|
+
}
|
|
399
|
+
function enqueuePreparedRecord(existing, dependencies, queueSize, prepared, now, options) {
|
|
400
|
+
if (existing) {
|
|
401
|
+
if (existing.requestFingerprint !== prepared.requestFingerprint) {
|
|
402
|
+
fail("idempotency-conflict", "Idempotency key was reused for a different offline edit.", {
|
|
403
|
+
editId: prepared.id,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
return { result: { status: "duplicate", edit: existing } };
|
|
407
|
+
}
|
|
408
|
+
if (queueSize >= options.maxEdits)
|
|
409
|
+
fail("queue-limit-exceeded", "Offline edit queue limit exceeded.");
|
|
410
|
+
for (let index = 0; index < prepared.dependencyIds.length; index += 1) {
|
|
411
|
+
const dependencyId = prepared.dependencyIds[index];
|
|
412
|
+
const dependency = dependencies[index];
|
|
413
|
+
if (dependency?.id !== dependencyId) {
|
|
414
|
+
fail("dependency-not-found", `Offline edit dependency "${dependencyId}" was not found.`, {
|
|
415
|
+
editId: prepared.id,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
if (!matchesPartition(dependency, prepared)) {
|
|
419
|
+
fail("invalid-edit", `Offline edit dependency "${dependencyId}" belongs to another partition.`, {
|
|
420
|
+
editId: prepared.id,
|
|
421
|
+
path: `dependencyIds[${index}]`,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
const state = dependencyState(dependency);
|
|
425
|
+
if (state === "conflicted" || state === "cancelled") {
|
|
426
|
+
fail("invalid-edit", `Offline edit dependency "${dependencyId}" did not complete successfully.`, {
|
|
427
|
+
editId: prepared.id,
|
|
428
|
+
path: `dependencyIds[${index}]`,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const record = deepFreeze({
|
|
433
|
+
version: HONUA_OFFLINE_EDIT_QUEUE_VERSION,
|
|
434
|
+
...prepared,
|
|
435
|
+
state: "pending",
|
|
436
|
+
createdAt: now,
|
|
437
|
+
updatedAt: now,
|
|
438
|
+
attemptCount: 0,
|
|
439
|
+
audit: [{ sequence: 1, kind: "enqueued", at: now, attempt: 0 }],
|
|
440
|
+
});
|
|
441
|
+
return { result: { status: "enqueued", edit: record }, record };
|
|
442
|
+
}
|
|
443
|
+
function claimRecords(records, tombstones, input, options) {
|
|
444
|
+
const normalized = captureClaimOptions(input, options);
|
|
445
|
+
const nowMs = normalized.now.getTime();
|
|
446
|
+
const byId = new Map(records.map((record) => [record.id, record]));
|
|
447
|
+
const eligible = sortedRecords(records).filter((record) => {
|
|
448
|
+
if (!matchesPartition(record, normalized))
|
|
449
|
+
return false;
|
|
450
|
+
if (!record.dependencyIds.every((id) => byId.get(id)?.state === "applied" || tombstones.get(id)?.terminalState === "applied"))
|
|
451
|
+
return false;
|
|
452
|
+
if (record.state === "pending")
|
|
453
|
+
return true;
|
|
454
|
+
if (record.state === "retryable")
|
|
455
|
+
return Date.parse(record.retry?.retryAt ?? "") <= nowMs;
|
|
456
|
+
return record.state === "leased" && Date.parse(record.lease?.expiresAt ?? "") <= nowMs;
|
|
457
|
+
});
|
|
458
|
+
const claimed = [];
|
|
459
|
+
for (const record of eligible.slice(0, normalized.limit))
|
|
460
|
+
claimed.push(claimRecord(record, normalized, options));
|
|
461
|
+
return { claimed, changed: claimed };
|
|
462
|
+
}
|
|
463
|
+
function claimRecord(record, claim, options) {
|
|
464
|
+
const reclaimed = record.state === "leased";
|
|
465
|
+
const token = requiredString(options.createLeaseToken(), "leaseToken");
|
|
466
|
+
const at = claim.now.toISOString();
|
|
467
|
+
const expiresAt = new Date(claim.now.getTime() + claim.leaseDurationMs).toISOString();
|
|
468
|
+
const attemptCount = record.attemptCount + 1;
|
|
469
|
+
return deepFreeze({
|
|
470
|
+
...record,
|
|
471
|
+
state: "leased",
|
|
472
|
+
updatedAt: at,
|
|
473
|
+
attemptCount,
|
|
474
|
+
lease: { token, workerId: claim.workerId, expiresAt },
|
|
475
|
+
retry: undefined,
|
|
476
|
+
audit: appendAudit(record, {
|
|
477
|
+
kind: reclaimed ? "lease-reclaimed" : "claimed",
|
|
478
|
+
at,
|
|
479
|
+
attempt: attemptCount,
|
|
480
|
+
workerId: claim.workerId,
|
|
481
|
+
}, options),
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
function transitionRecord(record, leaseToken, state, input, options) {
|
|
485
|
+
const token = requiredString(leaseToken, "leaseToken");
|
|
486
|
+
const now = validNow(options.now);
|
|
487
|
+
if (record.state !== "leased" || !record.lease) {
|
|
488
|
+
fail("invalid-transition", "Offline edit is not currently leased.", { editId: record.id });
|
|
489
|
+
}
|
|
490
|
+
if (record.lease.token !== token)
|
|
491
|
+
fail("lease-mismatch", "Offline edit lease token does not match.", { editId: record.id });
|
|
492
|
+
if (Date.parse(record.lease.expiresAt) <= now.getTime()) {
|
|
493
|
+
fail("lease-expired", "Offline edit lease has expired.", { editId: record.id });
|
|
494
|
+
}
|
|
495
|
+
const at = now.toISOString();
|
|
496
|
+
if (state === "applied") {
|
|
497
|
+
const outcome = captureApplied(input, at);
|
|
498
|
+
return deepFreeze({
|
|
499
|
+
...record,
|
|
500
|
+
state,
|
|
501
|
+
updatedAt: at,
|
|
502
|
+
lease: undefined,
|
|
503
|
+
retry: undefined,
|
|
504
|
+
applied: outcome,
|
|
505
|
+
audit: appendAudit(record, {
|
|
506
|
+
kind: "applied",
|
|
507
|
+
at,
|
|
508
|
+
attempt: record.attemptCount,
|
|
509
|
+
...(outcome.serverOperationId ? { serverOperationId: outcome.serverOperationId } : {}),
|
|
510
|
+
...(outcome.serverGeneration ? { serverGeneration: outcome.serverGeneration } : {}),
|
|
511
|
+
}, options),
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
if (state === "retryable") {
|
|
515
|
+
const outcome = captureRetry(input, now);
|
|
516
|
+
return deepFreeze({
|
|
517
|
+
...record,
|
|
518
|
+
state,
|
|
519
|
+
updatedAt: at,
|
|
520
|
+
lease: undefined,
|
|
521
|
+
retry: outcome,
|
|
522
|
+
audit: appendAudit(record, { kind: "retry-scheduled", at, attempt: record.attemptCount, reasonCode: outcome.reasonCode }, options),
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
const outcome = captureConflict(input, at);
|
|
526
|
+
return deepFreeze({
|
|
527
|
+
...record,
|
|
528
|
+
state,
|
|
529
|
+
updatedAt: at,
|
|
530
|
+
lease: undefined,
|
|
531
|
+
retry: undefined,
|
|
532
|
+
conflict: outcome,
|
|
533
|
+
// A record is conflicted or resolved, never both: a requeued edit that
|
|
534
|
+
// conflicts again opens a new conflict, and the closure of the previous one
|
|
535
|
+
// stays where the history belongs, in `audit`.
|
|
536
|
+
conflictResolution: undefined,
|
|
537
|
+
audit: appendAudit(record, {
|
|
538
|
+
kind: "conflicted",
|
|
539
|
+
at,
|
|
540
|
+
attempt: record.attemptCount,
|
|
541
|
+
conflictId: outcome.conflictId,
|
|
542
|
+
...(outcome.serverGeneration ? { serverGeneration: outcome.serverGeneration } : {}),
|
|
543
|
+
}, options),
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
function cancelRecord(record, input, options) {
|
|
547
|
+
if (record.state !== "pending" && record.state !== "retryable") {
|
|
548
|
+
fail("invalid-transition", "Only pending or retryable offline edits can be cancelled.", { editId: record.id });
|
|
549
|
+
}
|
|
550
|
+
const value = plainRecord(input, "outcome");
|
|
551
|
+
allowedKeys(value, CANCEL_KEYS, "outcome");
|
|
552
|
+
const reasonCode = requiredString(value.reasonCode, "reasonCode");
|
|
553
|
+
const at = timestamp(options.now);
|
|
554
|
+
return deepFreeze({
|
|
555
|
+
...record,
|
|
556
|
+
state: "cancelled",
|
|
557
|
+
updatedAt: at,
|
|
558
|
+
retry: undefined,
|
|
559
|
+
cancellation: { cancelledAt: at, reasonCode },
|
|
560
|
+
audit: appendAudit(record, { kind: "cancelled", at, attempt: record.attemptCount, reasonCode }, options),
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Close a conflicted record's conflict with a reviewed choice.
|
|
565
|
+
*
|
|
566
|
+
* Three properties make the transition safe to trust:
|
|
567
|
+
*
|
|
568
|
+
* - **It is bound to the conflict it claims to close.** A resolution naming a
|
|
569
|
+
* different `conflictId` than the record carries is refused, so a review of a
|
|
570
|
+
* conflict the queue has already replaced cannot land on the newer one.
|
|
571
|
+
* - **It leaves no conflicted state behind.** `conflict` is cleared and its
|
|
572
|
+
* identity, detection time, and server generation move into the resolution,
|
|
573
|
+
* so nothing that reads conflicted records — a replay pass, the local-first
|
|
574
|
+
* status, the sync-conflict projection — can re-surface a closed conflict.
|
|
575
|
+
* - **It is honest about delivery.** The resolution is recorded as
|
|
576
|
+
* `unacknowledged-by-server`, because nothing here reaches a server. A
|
|
577
|
+
* requeued edit is re-delivered by an ordinary replay pass; a discarded one
|
|
578
|
+
* is terminal and was never delivered at all.
|
|
579
|
+
*/
|
|
580
|
+
function resolveConflictRecord(record, input, options) {
|
|
581
|
+
if (record.state !== "conflicted" || !record.conflict) {
|
|
582
|
+
fail("invalid-transition", "Only a conflicted offline edit can have its conflict resolved.", { editId: record.id });
|
|
583
|
+
}
|
|
584
|
+
const value = plainRecord(input, "resolution");
|
|
585
|
+
allowedKeys(value, RESOLVE_CONFLICT_KEYS, "resolution");
|
|
586
|
+
const conflictId = requiredString(value.conflictId, "resolution.conflictId");
|
|
587
|
+
if (conflictId !== record.conflict.conflictId) {
|
|
588
|
+
fail("invalid-edit", "resolution.conflictId does not match the conflict this edit carries.", {
|
|
589
|
+
editId: record.id,
|
|
590
|
+
path: "resolution.conflictId",
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
const choice = value.choice;
|
|
594
|
+
if (choice !== "accept-client" && choice !== "accept-server" && choice !== "discard") {
|
|
595
|
+
fail("invalid-edit", "resolution.choice must be accept-client, accept-server, or discard.", {
|
|
596
|
+
editId: record.id,
|
|
597
|
+
path: "resolution.choice",
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
const resolvedBy = value.resolvedBy === undefined ? undefined : requiredPersistedIdentity(value.resolvedBy, "resolution.resolvedBy");
|
|
601
|
+
const note = value.note === undefined ? undefined : requiredPersistedLabel(value.note, "resolution.note");
|
|
602
|
+
const at = timestamp(options.now);
|
|
603
|
+
const requeued = choice === "accept-client";
|
|
604
|
+
const resolution = {
|
|
605
|
+
conflictId,
|
|
606
|
+
detectedAt: record.conflict.detectedAt,
|
|
607
|
+
...(record.conflict.serverGeneration === undefined ? {} : { serverGeneration: record.conflict.serverGeneration }),
|
|
608
|
+
choice,
|
|
609
|
+
disposition: requeued ? "requeued" : "discarded",
|
|
610
|
+
acknowledgement: "unacknowledged-by-server",
|
|
611
|
+
resolvedAt: at,
|
|
612
|
+
...(resolvedBy === undefined ? {} : { resolvedBy }),
|
|
613
|
+
...(note === undefined ? {} : { note }),
|
|
614
|
+
};
|
|
615
|
+
const audit = appendAudit(record, { kind: "conflict-resolved", at, attempt: record.attemptCount, conflictId, resolutionChoice: choice }, options);
|
|
616
|
+
if (requeued) {
|
|
617
|
+
return deepFreeze({
|
|
618
|
+
...record,
|
|
619
|
+
state: "pending",
|
|
620
|
+
updatedAt: at,
|
|
621
|
+
lease: undefined,
|
|
622
|
+
retry: undefined,
|
|
623
|
+
conflict: undefined,
|
|
624
|
+
conflictResolution: resolution,
|
|
625
|
+
audit,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
return deepFreeze({
|
|
629
|
+
...record,
|
|
630
|
+
state: "cancelled",
|
|
631
|
+
updatedAt: at,
|
|
632
|
+
lease: undefined,
|
|
633
|
+
retry: undefined,
|
|
634
|
+
conflict: undefined,
|
|
635
|
+
conflictResolution: resolution,
|
|
636
|
+
cancellation: { cancelledAt: at, reasonCode: `conflict-resolved:${choice}` },
|
|
637
|
+
audit,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
function captureFeatureEdit(value, maxPayloadBytes) {
|
|
641
|
+
const record = plainRecord(value, "edit");
|
|
642
|
+
allowedKeys(record, FEATURE_EDIT_KEYS, "edit");
|
|
643
|
+
const operation = record.operation;
|
|
644
|
+
if (operation !== "add" && operation !== "update" && operation !== "delete") {
|
|
645
|
+
fail("invalid-edit", "edit.operation is invalid.", { path: "edit.operation" });
|
|
646
|
+
}
|
|
647
|
+
const featureId = captureFeatureId(record.featureId, operation);
|
|
648
|
+
const budget = { nodes: 0, bytes: 0, maxBytes: maxPayloadBytes };
|
|
649
|
+
const capturedAttributes = record.attributes === undefined ? undefined : cloneJson(record.attributes, "edit.attributes", 0, budget);
|
|
650
|
+
const attributes = capturedAttributes === undefined
|
|
651
|
+
? undefined
|
|
652
|
+
: plainRecord(capturedAttributes, "edit.attributes");
|
|
653
|
+
const geometry = record.geometry === undefined ? undefined : cloneJson(record.geometry, "edit.geometry", 0, budget);
|
|
654
|
+
if (operation === "delete" && (attributes !== undefined || geometry !== undefined)) {
|
|
655
|
+
fail("invalid-edit", "Delete edits cannot contain attributes or geometry.", { path: "edit" });
|
|
656
|
+
}
|
|
657
|
+
if (operation !== "delete" && attributes === undefined && geometry === undefined) {
|
|
658
|
+
fail("invalid-edit", "Add and update edits require attributes and/or geometry.", { path: "edit" });
|
|
659
|
+
}
|
|
660
|
+
const edit = {
|
|
661
|
+
operation,
|
|
662
|
+
...(featureId !== undefined ? { featureId } : {}),
|
|
663
|
+
...(attributes !== undefined ? { attributes } : {}),
|
|
664
|
+
...(geometry !== undefined ? { geometry } : {}),
|
|
665
|
+
};
|
|
666
|
+
if (encoder.encode(canonicalJson(edit)).byteLength > maxPayloadBytes) {
|
|
667
|
+
fail("invalid-edit", "Offline edit payload exceeds maxPayloadBytes.", { path: "edit" });
|
|
668
|
+
}
|
|
669
|
+
return deepFreeze(edit);
|
|
670
|
+
}
|
|
671
|
+
function captureFeatureId(value, operation) {
|
|
672
|
+
if (value === undefined) {
|
|
673
|
+
if (operation !== "add")
|
|
674
|
+
fail("invalid-edit", `${operation} edits require featureId.`, { path: "edit.featureId" });
|
|
675
|
+
return undefined;
|
|
676
|
+
}
|
|
677
|
+
if (typeof value === "number" && Number.isSafeInteger(value))
|
|
678
|
+
return value;
|
|
679
|
+
if (typeof value === "string")
|
|
680
|
+
return requiredString(value, "edit.featureId");
|
|
681
|
+
fail("invalid-edit", "edit.featureId must be a string or safe integer.", { path: "edit.featureId" });
|
|
682
|
+
}
|
|
683
|
+
function captureDependencies(value, maximum) {
|
|
684
|
+
if (value === undefined)
|
|
685
|
+
return [];
|
|
686
|
+
if (!Array.isArray(value)) {
|
|
687
|
+
fail("invalid-edit", "dependencyIds must be an array.", { path: "dependencyIds" });
|
|
688
|
+
}
|
|
689
|
+
if (value.length > maximum) {
|
|
690
|
+
fail("invalid-edit", `dependencyIds must contain at most ${maximum} entries.`, { path: "dependencyIds" });
|
|
691
|
+
}
|
|
692
|
+
const dependencies = denseArray(value, "dependencyIds");
|
|
693
|
+
const ids = dependencies.map((id, index) => requiredDigest(id, `dependencyIds[${index}]`));
|
|
694
|
+
const unique = [...new Set(ids)].sort(compareCodeUnits);
|
|
695
|
+
if (unique.length !== ids.length)
|
|
696
|
+
fail("invalid-edit", "dependencyIds cannot contain duplicates.", { path: "dependencyIds" });
|
|
697
|
+
return deepFreeze(unique);
|
|
698
|
+
}
|
|
699
|
+
function captureApplied(value, appliedAt) {
|
|
700
|
+
const record = plainRecord(value, "outcome");
|
|
701
|
+
allowedKeys(record, APPLIED_KEYS, "outcome");
|
|
702
|
+
return {
|
|
703
|
+
appliedAt,
|
|
704
|
+
...(record.serverOperationId === undefined
|
|
705
|
+
? {}
|
|
706
|
+
: { serverOperationId: requiredString(record.serverOperationId, "serverOperationId") }),
|
|
707
|
+
...(record.serverGeneration === undefined
|
|
708
|
+
? {}
|
|
709
|
+
: { serverGeneration: requiredString(record.serverGeneration, "serverGeneration") }),
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
function captureRetry(value, now) {
|
|
713
|
+
const record = plainRecord(value, "outcome");
|
|
714
|
+
allowedKeys(record, RETRY_KEYS, "outcome");
|
|
715
|
+
const retryAt = normalizedTimestamp(record.retryAt, "retryAt");
|
|
716
|
+
if (Date.parse(retryAt) < now.getTime())
|
|
717
|
+
fail("invalid-edit", "retryAt cannot be in the past.", { path: "retryAt" });
|
|
718
|
+
return { retryAt, reasonCode: requiredString(record.reasonCode, "reasonCode") };
|
|
719
|
+
}
|
|
720
|
+
function captureConflict(value, detectedAt) {
|
|
721
|
+
const record = plainRecord(value, "outcome");
|
|
722
|
+
allowedKeys(record, CONFLICT_KEYS, "outcome");
|
|
723
|
+
return {
|
|
724
|
+
conflictId: requiredString(record.conflictId, "conflictId"),
|
|
725
|
+
detectedAt,
|
|
726
|
+
...(record.serverGeneration === undefined
|
|
727
|
+
? {}
|
|
728
|
+
: { serverGeneration: requiredString(record.serverGeneration, "serverGeneration") }),
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
function appendAudit(record, event, options) {
|
|
732
|
+
const sequence = (record.audit.at(-1)?.sequence ?? 0) + 1;
|
|
733
|
+
const history = [...record.audit, { sequence, ...event }];
|
|
734
|
+
return history.slice(-options.maxAuditEvents);
|
|
735
|
+
}
|
|
736
|
+
function sortedRecords(records) {
|
|
737
|
+
return [...records].sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt) || compareCodeUnits(left.id, right.id));
|
|
738
|
+
}
|
|
739
|
+
function captureClaimOptions(value, options) {
|
|
740
|
+
const record = plainRecord(value, "options");
|
|
741
|
+
allowedKeys(record, CLAIM_KEYS, "options");
|
|
742
|
+
const partition = partitionFromRecord(record, "options");
|
|
743
|
+
const limit = boundedOperationLimit(record.limit, "limit", false);
|
|
744
|
+
const leaseDurationMs = positiveInteger(record.leaseDurationMs, "leaseDurationMs");
|
|
745
|
+
if (leaseDurationMs > MAX_OFFLINE_EDIT_LEASE_DURATION_MS) {
|
|
746
|
+
fail("invalid-edit", "leaseDurationMs cannot exceed 24 hours.", { path: "leaseDurationMs" });
|
|
747
|
+
}
|
|
748
|
+
return {
|
|
749
|
+
...partition,
|
|
750
|
+
workerId: requiredString(record.workerId, "workerId"),
|
|
751
|
+
limit,
|
|
752
|
+
leaseDurationMs,
|
|
753
|
+
now: validNow(options.now),
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
function captureListOptions(value) {
|
|
757
|
+
const record = plainRecord(value, "options");
|
|
758
|
+
allowedKeys(record, LIST_KEYS, "options");
|
|
759
|
+
return {
|
|
760
|
+
partition: partitionFromRecord(record, "options"),
|
|
761
|
+
limit: boundedOperationLimit(record.limit, "limit", true),
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
function capturePruneOptions(value) {
|
|
765
|
+
const record = plainRecord(value, "options");
|
|
766
|
+
allowedKeys(record, PRUNE_KEYS, "options");
|
|
767
|
+
const terminalBefore = normalizedTimestamp(record.terminalBefore, "terminalBefore");
|
|
768
|
+
return {
|
|
769
|
+
...partitionFromRecord(record, "options"),
|
|
770
|
+
terminalBefore,
|
|
771
|
+
terminalBeforeMs: Date.parse(terminalBefore),
|
|
772
|
+
limit: boundedOperationLimit(record.limit, "limit", true),
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
function capturePartition(value, path) {
|
|
776
|
+
const record = plainRecord(value, path);
|
|
777
|
+
allowedKeys(record, new Set(PARTITION_KEYS), path);
|
|
778
|
+
return partitionFromRecord(record, path);
|
|
779
|
+
}
|
|
780
|
+
function partitionFromRecord(record, path) {
|
|
781
|
+
return {
|
|
782
|
+
authorizationScopeDigest: requiredDigest(record.authorizationScopeDigest, `${path}.authorizationScopeDigest`),
|
|
783
|
+
sourceId: requiredPersistedIdentity(record.sourceId, `${path}.sourceId`),
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
function boundedOperationLimit(value, path, optional) {
|
|
787
|
+
const limit = value === undefined && optional ? 100 : positiveInteger(value, path);
|
|
788
|
+
if (limit > 100)
|
|
789
|
+
fail("invalid-edit", `${path} cannot exceed 100.`, { path });
|
|
790
|
+
return limit;
|
|
791
|
+
}
|
|
792
|
+
function matchesPartition(record, partition) {
|
|
793
|
+
return (record.authorizationScopeDigest === partition.authorizationScopeDigest && record.sourceId === partition.sourceId);
|
|
794
|
+
}
|
|
795
|
+
function metadataFor(record) {
|
|
796
|
+
return {
|
|
797
|
+
id: record.id,
|
|
798
|
+
authorizationScopeDigest: record.authorizationScopeDigest,
|
|
799
|
+
sourceId: record.sourceId,
|
|
800
|
+
state: record.state,
|
|
801
|
+
createdAt: record.createdAt,
|
|
802
|
+
updatedAt: record.updatedAt,
|
|
803
|
+
dependencyIds: record.dependencyIds,
|
|
804
|
+
...(record.retry ? { retryAt: record.retry.retryAt } : {}),
|
|
805
|
+
...(record.lease ? { leaseExpiresAt: record.lease.expiresAt } : {}),
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
function tombstoneFor(record, prunedAt) {
|
|
809
|
+
if (!isTerminal(record.state)) {
|
|
810
|
+
fail("invalid-transition", "Only terminal offline edits can be pruned.", { editId: record.id });
|
|
811
|
+
}
|
|
812
|
+
return {
|
|
813
|
+
id: record.id,
|
|
814
|
+
requestFingerprint: record.requestFingerprint,
|
|
815
|
+
authorizationScopeDigest: record.authorizationScopeDigest,
|
|
816
|
+
sourceId: record.sourceId,
|
|
817
|
+
prunedAt,
|
|
818
|
+
terminalState: record.state,
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
function rejectPrunedIdentity(tombstone, prepared) {
|
|
822
|
+
if (!tombstone)
|
|
823
|
+
return;
|
|
824
|
+
if (tombstone.requestFingerprint !== prepared.requestFingerprint) {
|
|
825
|
+
fail("idempotency-conflict", "Idempotency key was reused for a different pruned offline edit.", {
|
|
826
|
+
editId: prepared.id,
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
fail("edit-pruned", "Offline edit identity was already completed and pruned; it cannot be enqueued again.", {
|
|
830
|
+
editId: prepared.id,
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
function dependencyState(evidence) {
|
|
834
|
+
return "terminalState" in evidence ? evidence.terminalState : evidence.state;
|
|
835
|
+
}
|
|
836
|
+
async function scanPartitionIds(store, partition, limit, tally) {
|
|
837
|
+
const index = store.index(PARTITION_ORDER_INDEX);
|
|
838
|
+
let cursor = await request(index.openCursor(partitionRange(partition)));
|
|
839
|
+
const ids = [];
|
|
840
|
+
while (cursor && ids.length < limit) {
|
|
841
|
+
const metadata = readStoredMetadata(cursor.value, tally);
|
|
842
|
+
if (metadata)
|
|
843
|
+
ids.push(metadata.id);
|
|
844
|
+
cursor = await continueCursor(cursor);
|
|
845
|
+
}
|
|
846
|
+
return ids;
|
|
847
|
+
}
|
|
848
|
+
async function scanReadyMetadata(metadataStore, tombstoneStore, options, tally) {
|
|
849
|
+
const states = ["pending", "retryable", "leased"];
|
|
850
|
+
const cache = new Map();
|
|
851
|
+
const candidates = (await Promise.all(states.map((state) => scanReadyState(metadataStore, tombstoneStore, state, options, cache, tally)))).flat();
|
|
852
|
+
return candidates.sort(compareMetadataCreated).slice(0, options.limit);
|
|
853
|
+
}
|
|
854
|
+
async function scanReadyState(metadataStore, tombstoneStore, state, options, cache, tally) {
|
|
855
|
+
const index = metadataStore.index(PARTITION_STATE_ORDER_INDEX);
|
|
856
|
+
let cursor = await request(index.openCursor(partitionStateRange(options, state)));
|
|
857
|
+
const candidates = [];
|
|
858
|
+
while (cursor && candidates.length < options.limit) {
|
|
859
|
+
const metadata = readStoredMetadata(cursor.value, tally);
|
|
860
|
+
const eligibleAt = metadata !== undefined &&
|
|
861
|
+
(state === "pending"
|
|
862
|
+
? true
|
|
863
|
+
: Date.parse(state === "retryable" ? (metadata.retryAt ?? "") : (metadata.leaseExpiresAt ?? "")) <=
|
|
864
|
+
options.now.getTime());
|
|
865
|
+
if (metadata &&
|
|
866
|
+
eligibleAt &&
|
|
867
|
+
(await dependenciesAreApplied(metadataStore, tombstoneStore, metadata.dependencyIds, cache, tally))) {
|
|
868
|
+
candidates.push(metadata);
|
|
869
|
+
}
|
|
870
|
+
cursor = await continueCursor(cursor);
|
|
871
|
+
}
|
|
872
|
+
return candidates;
|
|
873
|
+
}
|
|
874
|
+
async function dependenciesAreApplied(metadataStore, tombstoneStore, dependencyIds, cache, tally) {
|
|
875
|
+
for (const id of dependencyIds) {
|
|
876
|
+
let dependency = cache.get(id);
|
|
877
|
+
if (!cache.has(id)) {
|
|
878
|
+
dependency = readStoredMetadata(await request(metadataStore.get(id)), tally);
|
|
879
|
+
dependency ??= readStoredTombstone(await request(tombstoneStore.get(id)), tally);
|
|
880
|
+
cache.set(id, dependency);
|
|
881
|
+
}
|
|
882
|
+
if (!dependency || dependencyState(dependency) !== "applied")
|
|
883
|
+
return false;
|
|
884
|
+
}
|
|
885
|
+
return true;
|
|
886
|
+
}
|
|
887
|
+
function terminalPruneCandidates(records, options) {
|
|
888
|
+
return records
|
|
889
|
+
.filter((record) => matchesPartition(record, options) &&
|
|
890
|
+
isTerminal(record.state) &&
|
|
891
|
+
Date.parse(record.updatedAt) <= options.terminalBeforeMs)
|
|
892
|
+
.sort(compareUpdated);
|
|
893
|
+
}
|
|
894
|
+
function hasActiveDependent(records, editId) {
|
|
895
|
+
return records.some((record) => !isTerminal(record.state) && record.dependencyIds.includes(editId));
|
|
896
|
+
}
|
|
897
|
+
async function scanTerminalMetadata(store, options, tally) {
|
|
898
|
+
const candidates = [];
|
|
899
|
+
for (const state of ["applied", "conflicted", "cancelled"]) {
|
|
900
|
+
const index = store.index(PARTITION_STATE_ORDER_INDEX);
|
|
901
|
+
let cursor = await request(index.openCursor(partitionStateRange(options, state)));
|
|
902
|
+
while (cursor) {
|
|
903
|
+
const metadata = readStoredMetadata(cursor.value, tally);
|
|
904
|
+
if (metadata && Date.parse(metadata.updatedAt) <= options.terminalBeforeMs) {
|
|
905
|
+
candidates.push(metadata);
|
|
906
|
+
}
|
|
907
|
+
cursor = await continueCursor(cursor);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
return candidates.sort(compareMetadataUpdated);
|
|
911
|
+
}
|
|
912
|
+
async function hasActiveMetadataDependent(store, editId) {
|
|
913
|
+
let cursor = await request(store.index(DEPENDENCY_INDEX).openCursor(IDBKeyRange.only(editId)));
|
|
914
|
+
while (cursor) {
|
|
915
|
+
if (!isTerminal(cursor.value.state))
|
|
916
|
+
return true;
|
|
917
|
+
cursor = await continueCursor(cursor);
|
|
918
|
+
}
|
|
919
|
+
return false;
|
|
920
|
+
}
|
|
921
|
+
function partitionRange(partition) {
|
|
922
|
+
return compoundPrefixRange([partition.authorizationScopeDigest, partition.sourceId]);
|
|
923
|
+
}
|
|
924
|
+
function partitionStateRange(partition, state) {
|
|
925
|
+
return compoundPrefixRange([partition.authorizationScopeDigest, partition.sourceId, state]);
|
|
926
|
+
}
|
|
927
|
+
function compoundPrefixRange(prefix) {
|
|
928
|
+
return IDBKeyRange.bound([...prefix], [...prefix, []]);
|
|
929
|
+
}
|
|
930
|
+
function continueCursor(cursor) {
|
|
931
|
+
const cursorRequest = cursor.request;
|
|
932
|
+
return new Promise((resolve, reject) => {
|
|
933
|
+
cursorRequest.onsuccess = () => resolve(cursorRequest.result);
|
|
934
|
+
cursorRequest.onerror = () => reject(cursorRequest.error ?? new Error("Offline edit queue cursor failed."));
|
|
935
|
+
cursor.continue();
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
function isTerminal(state) {
|
|
939
|
+
return state === "applied" || state === "conflicted" || state === "cancelled";
|
|
940
|
+
}
|
|
941
|
+
function compareMetadataCreated(left, right) {
|
|
942
|
+
return Date.parse(left.createdAt) - Date.parse(right.createdAt) || compareCodeUnits(left.id, right.id);
|
|
943
|
+
}
|
|
944
|
+
function compareMetadataUpdated(left, right) {
|
|
945
|
+
return Date.parse(left.updatedAt) - Date.parse(right.updatedAt) || compareCodeUnits(left.id, right.id);
|
|
946
|
+
}
|
|
947
|
+
function compareUpdated(left, right) {
|
|
948
|
+
return Date.parse(left.updatedAt) - Date.parse(right.updatedAt) || compareCodeUnits(left.id, right.id);
|
|
949
|
+
}
|
|
950
|
+
const AUDIT_KINDS = new Set([
|
|
951
|
+
"enqueued",
|
|
952
|
+
"claimed",
|
|
953
|
+
"lease-reclaimed",
|
|
954
|
+
"retry-scheduled",
|
|
955
|
+
"applied",
|
|
956
|
+
"conflicted",
|
|
957
|
+
"conflict-resolved",
|
|
958
|
+
"cancelled",
|
|
959
|
+
]);
|
|
960
|
+
const RESOLUTION_CHOICES = new Set(["accept-client", "accept-server", "discard"]);
|
|
961
|
+
const RESOLUTION_DISPOSITIONS = new Set(["requeued", "discarded"]);
|
|
962
|
+
const STORED_STATES = new Set(QUEUE_STATES);
|
|
963
|
+
const TERMINAL_STATES = new Set(["applied", "conflicted", "cancelled"]);
|
|
964
|
+
/** Depth ceiling for byte accounting only; validation never descends a payload. */
|
|
965
|
+
const RECOVERY_MEASURE_DEPTH = 64;
|
|
966
|
+
/**
|
|
967
|
+
* Validate one persisted edit record before anything may act on it.
|
|
968
|
+
*
|
|
969
|
+
* The version gate runs first: a record written by another SDK build is
|
|
970
|
+
* `foreign-version` and is never returned as though it were current, because a
|
|
971
|
+
* field this build reads may mean something else in the layout that wrote it.
|
|
972
|
+
*/
|
|
973
|
+
export function inspectStoredOfflineEdit(value) {
|
|
974
|
+
if (!isStoredRecord(value))
|
|
975
|
+
return invalidRecord("corrupt-record");
|
|
976
|
+
if (value.version !== HONUA_OFFLINE_EDIT_QUEUE_VERSION)
|
|
977
|
+
return invalidRecord("foreign-version");
|
|
978
|
+
if (!isDigest(value.id) || !isDigest(value.requestFingerprint) || !isDigest(value.authorizationScopeDigest)) {
|
|
979
|
+
return invalidRecord("corrupt-record");
|
|
980
|
+
}
|
|
981
|
+
if (!isPersistedText(value.sourceId) || !isPersistedText(value.idempotencyKey))
|
|
982
|
+
return invalidRecord("corrupt-record");
|
|
983
|
+
if (screenPersistedString(value.sourceId, "identity") || screenPersistedString(value.idempotencyKey, "identity")) {
|
|
984
|
+
return invalidRecord("credential-screened");
|
|
985
|
+
}
|
|
986
|
+
if (typeof value.state !== "string" || !STORED_STATES.has(value.state))
|
|
987
|
+
return invalidRecord("corrupt-record");
|
|
988
|
+
if (!isNormalizedTimestamp(value.createdAt) || !isNormalizedTimestamp(value.updatedAt)) {
|
|
989
|
+
return invalidRecord("corrupt-record");
|
|
990
|
+
}
|
|
991
|
+
if (!Number.isSafeInteger(value.attemptCount) || value.attemptCount < 0) {
|
|
992
|
+
return invalidRecord("corrupt-record");
|
|
993
|
+
}
|
|
994
|
+
if (!isStoredDependencyIds(value.dependencyIds, value.id))
|
|
995
|
+
return invalidRecord("corrupt-record");
|
|
996
|
+
if (!isStoredFeatureEdit(value.edit))
|
|
997
|
+
return invalidRecord("corrupt-record");
|
|
998
|
+
if (!isStoredOutcome(value))
|
|
999
|
+
return invalidRecord("corrupt-record");
|
|
1000
|
+
// The reviewer-authored members of a resolution are the only free text the
|
|
1001
|
+
// queue persists, so they are screened on the way back in as well as on the
|
|
1002
|
+
// way out: a database another tab or build wrote is not trusted here either.
|
|
1003
|
+
if (isCredentialShapedResolution(value.conflictResolution))
|
|
1004
|
+
return invalidRecord("credential-screened");
|
|
1005
|
+
if (!isStoredAudit(value.audit))
|
|
1006
|
+
return invalidRecord("corrupt-record");
|
|
1007
|
+
return { status: "valid", record: value };
|
|
1008
|
+
}
|
|
1009
|
+
/** Validate one persisted metadata index row. */
|
|
1010
|
+
export function inspectStoredOfflineEditMetadata(value) {
|
|
1011
|
+
if (!isStoredRecord(value))
|
|
1012
|
+
return invalidRecord("corrupt-record");
|
|
1013
|
+
if (!isDigest(value.id) || !isDigest(value.authorizationScopeDigest))
|
|
1014
|
+
return invalidRecord("corrupt-record");
|
|
1015
|
+
if (!isPersistedText(value.sourceId))
|
|
1016
|
+
return invalidRecord("corrupt-record");
|
|
1017
|
+
if (screenPersistedString(value.sourceId, "identity"))
|
|
1018
|
+
return invalidRecord("credential-screened");
|
|
1019
|
+
if (typeof value.state !== "string" || !STORED_STATES.has(value.state))
|
|
1020
|
+
return invalidRecord("corrupt-record");
|
|
1021
|
+
if (!isNormalizedTimestamp(value.createdAt) || !isNormalizedTimestamp(value.updatedAt)) {
|
|
1022
|
+
return invalidRecord("corrupt-record");
|
|
1023
|
+
}
|
|
1024
|
+
if (!isStoredDependencyIds(value.dependencyIds, value.id))
|
|
1025
|
+
return invalidRecord("corrupt-record");
|
|
1026
|
+
if (!isOptionalTimestamp(value.retryAt) || !isOptionalTimestamp(value.leaseExpiresAt)) {
|
|
1027
|
+
return invalidRecord("corrupt-record");
|
|
1028
|
+
}
|
|
1029
|
+
return { status: "valid", record: value };
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Validate one persisted tombstone.
|
|
1033
|
+
*
|
|
1034
|
+
* Tombstones exist precisely so a pruned identity cannot be re-enqueued, so a
|
|
1035
|
+
* valid one is always preserved by recovery rather than treated as an orphan.
|
|
1036
|
+
*/
|
|
1037
|
+
export function inspectStoredOfflineEditTombstone(value) {
|
|
1038
|
+
if (!isStoredRecord(value))
|
|
1039
|
+
return invalidRecord("corrupt-record");
|
|
1040
|
+
if (!isDigest(value.id) || !isDigest(value.requestFingerprint) || !isDigest(value.authorizationScopeDigest)) {
|
|
1041
|
+
return invalidRecord("corrupt-record");
|
|
1042
|
+
}
|
|
1043
|
+
if (!isPersistedText(value.sourceId))
|
|
1044
|
+
return invalidRecord("corrupt-record");
|
|
1045
|
+
if (screenPersistedString(value.sourceId, "identity"))
|
|
1046
|
+
return invalidRecord("credential-screened");
|
|
1047
|
+
if (!isNormalizedTimestamp(value.prunedAt))
|
|
1048
|
+
return invalidRecord("corrupt-record");
|
|
1049
|
+
if (typeof value.terminalState !== "string" || !TERMINAL_STATES.has(value.terminalState)) {
|
|
1050
|
+
return invalidRecord("corrupt-record");
|
|
1051
|
+
}
|
|
1052
|
+
return { status: "valid", record: value };
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Plan one bounded recovery pass over every persisted row.
|
|
1056
|
+
*
|
|
1057
|
+
* Pure and deterministic, so the same rows always produce the same mutation
|
|
1058
|
+
* set, and so a healthy database plans nothing at all. The edit/metadata
|
|
1059
|
+
* relationship is repaired rather than abandoned in both directions: a metadata
|
|
1060
|
+
* row whose edit is gone is deleted, and an edit whose metadata row is missing
|
|
1061
|
+
* or disagrees has its index row re-derived from the edit itself. That is what
|
|
1062
|
+
* keeps a claim from throwing `Offline edit metadata references missing edit`
|
|
1063
|
+
* halfway through a replay pass.
|
|
1064
|
+
*/
|
|
1065
|
+
export function planOfflineEditQueueRecovery(rows, limits = {}) {
|
|
1066
|
+
const budget = {
|
|
1067
|
+
records: 0,
|
|
1068
|
+
bytes: 0,
|
|
1069
|
+
maxRecords: tightenedPositive(limits.maxRecords, DEFAULT_OFFLINE_EDIT_QUEUE_MAX_RECOVERY_RECORDS, "recovery.maxRecords"),
|
|
1070
|
+
maxBytes: tightenedPositive(limits.maxBytes, DEFAULT_OFFLINE_EDIT_QUEUE_MAX_RECOVERY_BYTES, "recovery.maxBytes"),
|
|
1071
|
+
};
|
|
1072
|
+
const tally = emptyTally();
|
|
1073
|
+
const deleteEditKeys = [];
|
|
1074
|
+
const deleteMetadataKeys = [];
|
|
1075
|
+
const deleteTombstoneKeys = [];
|
|
1076
|
+
const putMetadata = [];
|
|
1077
|
+
const live = new Map();
|
|
1078
|
+
for (const row of rows.edits) {
|
|
1079
|
+
charge(row, budget, tally);
|
|
1080
|
+
const inspection = inspectStoredOfflineEdit(row.value);
|
|
1081
|
+
// A record stored under a key other than its own id cannot be addressed by
|
|
1082
|
+
// the id it claims, so it is unusable however well-formed it looks.
|
|
1083
|
+
if (inspection.status === "invalid" || inspection.record.id !== row.key) {
|
|
1084
|
+
deleteEditKeys.push(row.key);
|
|
1085
|
+
discard(tally, inspection.status === "invalid" ? inspection.reason : "corrupt-record");
|
|
1086
|
+
continue;
|
|
1087
|
+
}
|
|
1088
|
+
live.set(inspection.record.id, inspection.record);
|
|
1089
|
+
}
|
|
1090
|
+
const indexed = new Set();
|
|
1091
|
+
for (const row of rows.metadata) {
|
|
1092
|
+
charge(row, budget, tally);
|
|
1093
|
+
const inspection = inspectStoredOfflineEditMetadata(row.value);
|
|
1094
|
+
if (inspection.status === "invalid" || inspection.record.id !== row.key) {
|
|
1095
|
+
deleteMetadataKeys.push(row.key);
|
|
1096
|
+
discard(tally, inspection.status === "invalid" ? inspection.reason : "corrupt-record");
|
|
1097
|
+
continue;
|
|
1098
|
+
}
|
|
1099
|
+
const edit = live.get(inspection.record.id);
|
|
1100
|
+
if (!edit) {
|
|
1101
|
+
deleteMetadataKeys.push(row.key);
|
|
1102
|
+
discard(tally, "orphaned-metadata");
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
1105
|
+
indexed.add(edit.id);
|
|
1106
|
+
if (!sameMetadata(inspection.record, metadataFor(edit)))
|
|
1107
|
+
repairMetadata(putMetadata, tally, edit);
|
|
1108
|
+
}
|
|
1109
|
+
for (const edit of live.values()) {
|
|
1110
|
+
if (!indexed.has(edit.id))
|
|
1111
|
+
repairMetadata(putMetadata, tally, edit);
|
|
1112
|
+
}
|
|
1113
|
+
for (const row of rows.tombstones) {
|
|
1114
|
+
charge(row, budget, tally);
|
|
1115
|
+
const inspection = inspectStoredOfflineEditTombstone(row.value);
|
|
1116
|
+
if (inspection.status === "invalid" || inspection.record.id !== row.key) {
|
|
1117
|
+
deleteTombstoneKeys.push(row.key);
|
|
1118
|
+
discard(tally, inspection.status === "invalid" ? inspection.reason : "corrupt-record");
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
return {
|
|
1122
|
+
report: recoveryReport(tally, "open"),
|
|
1123
|
+
deleteEditKeys,
|
|
1124
|
+
deleteMetadataKeys,
|
|
1125
|
+
deleteTombstoneKeys,
|
|
1126
|
+
putMetadata,
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
function repairMetadata(target, tally, edit) {
|
|
1130
|
+
target.push(metadataFor(edit));
|
|
1131
|
+
tally.repaired += 1;
|
|
1132
|
+
tally.repairedByReason["restored-metadata"] += 1;
|
|
1133
|
+
}
|
|
1134
|
+
function emptyTally() {
|
|
1135
|
+
return {
|
|
1136
|
+
inspected: 0,
|
|
1137
|
+
discarded: 0,
|
|
1138
|
+
repaired: 0,
|
|
1139
|
+
discardedByReason: {
|
|
1140
|
+
"foreign-version": 0,
|
|
1141
|
+
"corrupt-record": 0,
|
|
1142
|
+
"credential-screened": 0,
|
|
1143
|
+
"orphaned-metadata": 0,
|
|
1144
|
+
},
|
|
1145
|
+
repairedByReason: { "restored-metadata": 0 },
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
function discard(tally, reason) {
|
|
1149
|
+
tally.discarded += 1;
|
|
1150
|
+
tally.discardedByReason[reason] += 1;
|
|
1151
|
+
}
|
|
1152
|
+
function charge(row, budget, tally) {
|
|
1153
|
+
budget.records += 1;
|
|
1154
|
+
tally.inspected += 1;
|
|
1155
|
+
if (budget.records > budget.maxRecords) {
|
|
1156
|
+
fail("queue-limit-exceeded", `Offline edit queue recovery exceeded ${budget.maxRecords} records.`);
|
|
1157
|
+
}
|
|
1158
|
+
measureBytes(row.value, budget, 0);
|
|
1159
|
+
if (budget.bytes > budget.maxBytes) {
|
|
1160
|
+
fail("queue-limit-exceeded", `Offline edit queue recovery exceeded ${budget.maxBytes} bytes.`);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* Accumulate an approximate persisted size with an explicit ceiling, so a
|
|
1165
|
+
* damaged or hostile database cannot make recovery scan unboundedly. It reads
|
|
1166
|
+
* sizes only — nothing is copied, decoded, or reported — and it stops as soon as
|
|
1167
|
+
* the budget is spent, so no oversized string is ever materialized.
|
|
1168
|
+
*/
|
|
1169
|
+
function measureBytes(value, budget, depth) {
|
|
1170
|
+
if (budget.bytes > budget.maxBytes || depth > RECOVERY_MEASURE_DEPTH)
|
|
1171
|
+
return;
|
|
1172
|
+
if (typeof value === "string") {
|
|
1173
|
+
budget.bytes += value.length * 2 + 2;
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
if (value === null || typeof value !== "object") {
|
|
1177
|
+
budget.bytes += 8;
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
if (ArrayBuffer.isView(value)) {
|
|
1181
|
+
budget.bytes += value.byteLength;
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
budget.bytes += 2;
|
|
1185
|
+
if (Array.isArray(value)) {
|
|
1186
|
+
for (const entry of value) {
|
|
1187
|
+
measureBytes(entry, budget, depth + 1);
|
|
1188
|
+
if (budget.bytes > budget.maxBytes)
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
for (const key of Object.keys(value)) {
|
|
1194
|
+
budget.bytes += key.length * 2 + 3;
|
|
1195
|
+
measureBytes(value[key], budget, depth + 1);
|
|
1196
|
+
if (budget.bytes > budget.maxBytes)
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
function recoveryReport(tally, operation) {
|
|
1201
|
+
return deepFreeze({
|
|
1202
|
+
kind: HONUA_OFFLINE_EDIT_QUEUE_RECOVERY_KIND,
|
|
1203
|
+
version: HONUA_OFFLINE_EDIT_QUEUE_RECOVERY_VERSION,
|
|
1204
|
+
operation,
|
|
1205
|
+
inspectedRecords: tally.inspected,
|
|
1206
|
+
discardedRecords: tally.discarded,
|
|
1207
|
+
repairedRecords: tally.repaired,
|
|
1208
|
+
discardedByReason: { ...tally.discardedByReason },
|
|
1209
|
+
repairedByReason: { ...tally.repairedByReason },
|
|
1210
|
+
error: new HonuaOfflineEditQueueError("record-unreadable", `Offline edit queue recovery discarded ${tally.discarded} unreadable record(s) and repaired ${tally.repaired}.`),
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
function emitRecovery(onRecovery, tally, operation) {
|
|
1214
|
+
if (!onRecovery || tally.discarded + tally.repaired === 0)
|
|
1215
|
+
return;
|
|
1216
|
+
onRecovery(recoveryReport(tally, operation));
|
|
1217
|
+
}
|
|
1218
|
+
function invalidRecord(reason) {
|
|
1219
|
+
return { status: "invalid", reason };
|
|
1220
|
+
}
|
|
1221
|
+
function isStoredRecord(value) {
|
|
1222
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1223
|
+
}
|
|
1224
|
+
function isDigest(value) {
|
|
1225
|
+
return typeof value === "string" && EDIT_ID_PATTERN.test(value);
|
|
1226
|
+
}
|
|
1227
|
+
/** Same shape `requiredString` enforces on write, without throwing. */
|
|
1228
|
+
function isPersistedText(value) {
|
|
1229
|
+
return (typeof value === "string" &&
|
|
1230
|
+
value.length > 0 &&
|
|
1231
|
+
value.length <= 1024 &&
|
|
1232
|
+
value.trim() === value &&
|
|
1233
|
+
encoder.encode(value).byteLength <= 1024);
|
|
1234
|
+
}
|
|
1235
|
+
function isNormalizedTimestamp(value) {
|
|
1236
|
+
if (typeof value !== "string")
|
|
1237
|
+
return false;
|
|
1238
|
+
const parsed = new Date(value);
|
|
1239
|
+
return Number.isFinite(parsed.getTime()) && parsed.toISOString() === value;
|
|
1240
|
+
}
|
|
1241
|
+
function isOptionalTimestamp(value) {
|
|
1242
|
+
return value === undefined || isNormalizedTimestamp(value);
|
|
1243
|
+
}
|
|
1244
|
+
function isStoredDependencyIds(value, id) {
|
|
1245
|
+
if (!Array.isArray(value) || value.length > DEFAULT_OFFLINE_EDIT_QUEUE_MAX_DEPENDENCIES)
|
|
1246
|
+
return false;
|
|
1247
|
+
const seen = new Set();
|
|
1248
|
+
for (const entry of value) {
|
|
1249
|
+
if (!isDigest(entry) || entry === id || seen.has(entry))
|
|
1250
|
+
return false;
|
|
1251
|
+
seen.add(entry);
|
|
1252
|
+
}
|
|
1253
|
+
return true;
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Validate the edit envelope without reading a payload value. Only the presence
|
|
1257
|
+
* of `attributes`/`geometry` is consulted, because the delete-shape rule is
|
|
1258
|
+
* defined over presence; their contents are never inspected.
|
|
1259
|
+
*/
|
|
1260
|
+
function isStoredFeatureEdit(value) {
|
|
1261
|
+
if (!isStoredRecord(value))
|
|
1262
|
+
return false;
|
|
1263
|
+
const operation = value.operation;
|
|
1264
|
+
if (operation !== "add" && operation !== "update" && operation !== "delete")
|
|
1265
|
+
return false;
|
|
1266
|
+
const featureId = value.featureId;
|
|
1267
|
+
if (featureId === undefined) {
|
|
1268
|
+
if (operation !== "add")
|
|
1269
|
+
return false;
|
|
1270
|
+
}
|
|
1271
|
+
else if (!isPersistedText(featureId) && !Number.isSafeInteger(featureId)) {
|
|
1272
|
+
return false;
|
|
1273
|
+
}
|
|
1274
|
+
const hasAttributes = value.attributes !== undefined;
|
|
1275
|
+
const hasGeometry = value.geometry !== undefined;
|
|
1276
|
+
if (operation === "delete")
|
|
1277
|
+
return !hasAttributes && !hasGeometry;
|
|
1278
|
+
return hasAttributes || hasGeometry;
|
|
1279
|
+
}
|
|
1280
|
+
/** A state must carry exactly the outcome its transition wrote, and no other. */
|
|
1281
|
+
function isStoredOutcome(value) {
|
|
1282
|
+
const state = value.state;
|
|
1283
|
+
const lease = value.lease;
|
|
1284
|
+
if (state === "leased") {
|
|
1285
|
+
if (!isStoredRecord(lease) || !isPersistedText(lease.token) || !isPersistedText(lease.workerId))
|
|
1286
|
+
return false;
|
|
1287
|
+
if (!isNormalizedTimestamp(lease.expiresAt))
|
|
1288
|
+
return false;
|
|
1289
|
+
}
|
|
1290
|
+
else if (lease !== undefined) {
|
|
1291
|
+
return false;
|
|
1292
|
+
}
|
|
1293
|
+
const retry = value.retry;
|
|
1294
|
+
if (state === "retryable") {
|
|
1295
|
+
if (!isStoredRecord(retry) || !isNormalizedTimestamp(retry.retryAt) || !isPersistedText(retry.reasonCode)) {
|
|
1296
|
+
return false;
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
else if (retry !== undefined) {
|
|
1300
|
+
return false;
|
|
1301
|
+
}
|
|
1302
|
+
const applied = value.applied;
|
|
1303
|
+
if (state === "applied") {
|
|
1304
|
+
if (!isStoredRecord(applied) || !isNormalizedTimestamp(applied.appliedAt))
|
|
1305
|
+
return false;
|
|
1306
|
+
}
|
|
1307
|
+
else if (applied !== undefined) {
|
|
1308
|
+
return false;
|
|
1309
|
+
}
|
|
1310
|
+
const conflict = value.conflict;
|
|
1311
|
+
if (state === "conflicted") {
|
|
1312
|
+
if (!isStoredRecord(conflict) || !isPersistedText(conflict.conflictId))
|
|
1313
|
+
return false;
|
|
1314
|
+
if (!isNormalizedTimestamp(conflict.detectedAt))
|
|
1315
|
+
return false;
|
|
1316
|
+
}
|
|
1317
|
+
else if (conflict !== undefined) {
|
|
1318
|
+
return false;
|
|
1319
|
+
}
|
|
1320
|
+
const cancellation = value.cancellation;
|
|
1321
|
+
if (state === "cancelled") {
|
|
1322
|
+
if (!isStoredRecord(cancellation) || !isNormalizedTimestamp(cancellation.cancelledAt))
|
|
1323
|
+
return false;
|
|
1324
|
+
if (!isPersistedText(cancellation.reasonCode))
|
|
1325
|
+
return false;
|
|
1326
|
+
}
|
|
1327
|
+
else if (cancellation !== undefined) {
|
|
1328
|
+
return false;
|
|
1329
|
+
}
|
|
1330
|
+
// A resolution is not gated on one state — a requeued edit carries it while it
|
|
1331
|
+
// is delivered again — but it is gated against `conflicted`, because a record
|
|
1332
|
+
// that is both conflicted and resolved contradicts itself and would let a
|
|
1333
|
+
// closed conflict be surfaced as open.
|
|
1334
|
+
return isStoredConflictResolution(value.conflictResolution, state);
|
|
1335
|
+
}
|
|
1336
|
+
/** Screens an already shape-validated resolution's reviewer-authored strings. */
|
|
1337
|
+
function isCredentialShapedResolution(value) {
|
|
1338
|
+
if (!isStoredRecord(value))
|
|
1339
|
+
return false;
|
|
1340
|
+
const resolvedBy = value.resolvedBy;
|
|
1341
|
+
if (typeof resolvedBy === "string" && screenPersistedString(resolvedBy, "identity"))
|
|
1342
|
+
return true;
|
|
1343
|
+
const note = value.note;
|
|
1344
|
+
return typeof note === "string" && screenPersistedString(note, "label") !== undefined;
|
|
1345
|
+
}
|
|
1346
|
+
function isStoredConflictResolution(value, state) {
|
|
1347
|
+
if (value === undefined)
|
|
1348
|
+
return true;
|
|
1349
|
+
if (state === "conflicted")
|
|
1350
|
+
return false;
|
|
1351
|
+
if (!isStoredRecord(value))
|
|
1352
|
+
return false;
|
|
1353
|
+
if (!isPersistedText(value.conflictId) || !isNormalizedTimestamp(value.detectedAt))
|
|
1354
|
+
return false;
|
|
1355
|
+
if (value.serverGeneration !== undefined && !isPersistedText(value.serverGeneration))
|
|
1356
|
+
return false;
|
|
1357
|
+
if (typeof value.choice !== "string" || !RESOLUTION_CHOICES.has(value.choice))
|
|
1358
|
+
return false;
|
|
1359
|
+
if (typeof value.disposition !== "string" || !RESOLUTION_DISPOSITIONS.has(value.disposition))
|
|
1360
|
+
return false;
|
|
1361
|
+
if (value.acknowledgement !== "unacknowledged-by-server")
|
|
1362
|
+
return false;
|
|
1363
|
+
if (!isNormalizedTimestamp(value.resolvedAt))
|
|
1364
|
+
return false;
|
|
1365
|
+
if (value.resolvedBy !== undefined && !isPersistedText(value.resolvedBy))
|
|
1366
|
+
return false;
|
|
1367
|
+
if (value.note !== undefined && !isPersistedText(value.note))
|
|
1368
|
+
return false;
|
|
1369
|
+
// `accept-client` is the only choice that keeps the edit deliverable; the
|
|
1370
|
+
// other two abandon it. A record whose disposition disagrees with its choice
|
|
1371
|
+
// cannot be trusted to describe what the queue actually did.
|
|
1372
|
+
return value.disposition === (value.choice === "accept-client" ? "requeued" : "discarded");
|
|
1373
|
+
}
|
|
1374
|
+
function isStoredAudit(value) {
|
|
1375
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > DEFAULT_OFFLINE_EDIT_QUEUE_MAX_AUDIT_EVENTS) {
|
|
1376
|
+
return false;
|
|
1377
|
+
}
|
|
1378
|
+
let previous = 0;
|
|
1379
|
+
for (const entry of value) {
|
|
1380
|
+
if (!isStoredRecord(entry))
|
|
1381
|
+
return false;
|
|
1382
|
+
if (!Number.isSafeInteger(entry.sequence) || entry.sequence <= previous)
|
|
1383
|
+
return false;
|
|
1384
|
+
previous = entry.sequence;
|
|
1385
|
+
if (typeof entry.kind !== "string" || !AUDIT_KINDS.has(entry.kind))
|
|
1386
|
+
return false;
|
|
1387
|
+
if (!isNormalizedTimestamp(entry.at))
|
|
1388
|
+
return false;
|
|
1389
|
+
if (!Number.isSafeInteger(entry.attempt) || entry.attempt < 0)
|
|
1390
|
+
return false;
|
|
1391
|
+
}
|
|
1392
|
+
return true;
|
|
1393
|
+
}
|
|
1394
|
+
function sameMetadata(left, right) {
|
|
1395
|
+
return (left.id === right.id &&
|
|
1396
|
+
left.authorizationScopeDigest === right.authorizationScopeDigest &&
|
|
1397
|
+
left.sourceId === right.sourceId &&
|
|
1398
|
+
left.state === right.state &&
|
|
1399
|
+
left.createdAt === right.createdAt &&
|
|
1400
|
+
left.updatedAt === right.updatedAt &&
|
|
1401
|
+
left.retryAt === right.retryAt &&
|
|
1402
|
+
left.leaseExpiresAt === right.leaseExpiresAt &&
|
|
1403
|
+
left.dependencyIds.length === right.dependencyIds.length &&
|
|
1404
|
+
left.dependencyIds.every((id, index) => id === right.dependencyIds[index]));
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* Read one persisted edit, counting rather than trusting a record that fails
|
|
1408
|
+
* validation. Returns `undefined` for a record that must not be acted on, so a
|
|
1409
|
+
* record written between recovery passes — by another tab, or by a partially
|
|
1410
|
+
* applied write — can never be leased unvalidated.
|
|
1411
|
+
*/
|
|
1412
|
+
function readStoredEdit(value, id, tally) {
|
|
1413
|
+
if (value === undefined)
|
|
1414
|
+
return undefined;
|
|
1415
|
+
tally.inspected += 1;
|
|
1416
|
+
const inspection = inspectStoredOfflineEdit(value);
|
|
1417
|
+
if (inspection.status === "invalid" || inspection.record.id !== id) {
|
|
1418
|
+
discard(tally, inspection.status === "invalid" ? inspection.reason : "corrupt-record");
|
|
1419
|
+
return undefined;
|
|
1420
|
+
}
|
|
1421
|
+
return inspection.record;
|
|
1422
|
+
}
|
|
1423
|
+
function readStoredTombstone(value, tally) {
|
|
1424
|
+
if (value === undefined)
|
|
1425
|
+
return undefined;
|
|
1426
|
+
tally.inspected += 1;
|
|
1427
|
+
const inspection = inspectStoredOfflineEditTombstone(value);
|
|
1428
|
+
if (inspection.status === "invalid") {
|
|
1429
|
+
discard(tally, inspection.reason);
|
|
1430
|
+
return undefined;
|
|
1431
|
+
}
|
|
1432
|
+
return inspection.record;
|
|
1433
|
+
}
|
|
1434
|
+
function readStoredMetadata(value, tally) {
|
|
1435
|
+
if (value === undefined)
|
|
1436
|
+
return undefined;
|
|
1437
|
+
tally.inspected += 1;
|
|
1438
|
+
const inspection = inspectStoredOfflineEditMetadata(value);
|
|
1439
|
+
if (inspection.status === "invalid") {
|
|
1440
|
+
discard(tally, inspection.reason);
|
|
1441
|
+
return undefined;
|
|
1442
|
+
}
|
|
1443
|
+
return inspection.record;
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* One bounded startup pass over edits, metadata, and tombstones.
|
|
1447
|
+
*
|
|
1448
|
+
* Reads are capped at the record ceiling before anything is materialized, so a
|
|
1449
|
+
* database past its bounds fails with a typed `queue-limit-exceeded` rather
|
|
1450
|
+
* than being scanned. Everything the plan decides is applied inside the single
|
|
1451
|
+
* transaction that read it.
|
|
1452
|
+
*/
|
|
1453
|
+
async function recoverQueueDatabase(database, limits, onRecovery) {
|
|
1454
|
+
const ceiling = tightenedPositive(limits.maxRecords, DEFAULT_OFFLINE_EDIT_QUEUE_MAX_RECOVERY_RECORDS, "recovery.maxRecords") + 1;
|
|
1455
|
+
const report = await runTransaction(database, "readwrite", async ({ edits, metadata, tombstones }) => {
|
|
1456
|
+
const rows = {
|
|
1457
|
+
edits: await readRows(edits, ceiling),
|
|
1458
|
+
metadata: await readRows(metadata, ceiling),
|
|
1459
|
+
tombstones: await readRows(tombstones, ceiling),
|
|
1460
|
+
};
|
|
1461
|
+
const plan = planOfflineEditQueueRecovery(rows, limits);
|
|
1462
|
+
for (const key of plan.deleteEditKeys)
|
|
1463
|
+
edits.delete(key);
|
|
1464
|
+
for (const key of plan.deleteMetadataKeys)
|
|
1465
|
+
metadata.delete(key);
|
|
1466
|
+
for (const key of plan.deleteTombstoneKeys)
|
|
1467
|
+
tombstones.delete(key);
|
|
1468
|
+
for (const row of plan.putMetadata)
|
|
1469
|
+
metadata.put(row);
|
|
1470
|
+
return plan.report;
|
|
1471
|
+
});
|
|
1472
|
+
if (onRecovery && report.discardedRecords + report.repairedRecords > 0)
|
|
1473
|
+
onRecovery(report);
|
|
1474
|
+
}
|
|
1475
|
+
async function readRows(store, ceiling) {
|
|
1476
|
+
const [keys, values] = await Promise.all([
|
|
1477
|
+
request(store.getAllKeys(null, ceiling)),
|
|
1478
|
+
request(store.getAll(null, ceiling)),
|
|
1479
|
+
]);
|
|
1480
|
+
if (keys.length !== values.length) {
|
|
1481
|
+
fail("store-failed", "Offline edit queue recovery read mismatched keys and records.");
|
|
1482
|
+
}
|
|
1483
|
+
return keys.map((key, index) => ({ key: String(key), value: values[index] }));
|
|
1484
|
+
}
|
|
1485
|
+
function openDatabase(factory, name) {
|
|
1486
|
+
return new Promise((resolve, reject) => {
|
|
1487
|
+
const open = factory.open(name, DATABASE_VERSION);
|
|
1488
|
+
open.onupgradeneeded = () => {
|
|
1489
|
+
if (!open.result.objectStoreNames.contains(EDIT_STORE)) {
|
|
1490
|
+
open.result.createObjectStore(EDIT_STORE, { keyPath: "id" });
|
|
1491
|
+
}
|
|
1492
|
+
const metadata = open.result.objectStoreNames.contains(METADATA_STORE)
|
|
1493
|
+
? open.transaction?.objectStore(METADATA_STORE)
|
|
1494
|
+
: open.result.createObjectStore(METADATA_STORE, { keyPath: "id" });
|
|
1495
|
+
if (metadata && !metadata.indexNames.contains(PARTITION_ORDER_INDEX)) {
|
|
1496
|
+
metadata.createIndex(PARTITION_ORDER_INDEX, ["authorizationScopeDigest", "sourceId", "createdAt", "id"]);
|
|
1497
|
+
}
|
|
1498
|
+
if (metadata && !metadata.indexNames.contains(PARTITION_STATE_ORDER_INDEX)) {
|
|
1499
|
+
metadata.createIndex(PARTITION_STATE_ORDER_INDEX, [
|
|
1500
|
+
"authorizationScopeDigest",
|
|
1501
|
+
"sourceId",
|
|
1502
|
+
"state",
|
|
1503
|
+
"createdAt",
|
|
1504
|
+
"id",
|
|
1505
|
+
]);
|
|
1506
|
+
}
|
|
1507
|
+
if (metadata && !metadata.indexNames.contains(DEPENDENCY_INDEX)) {
|
|
1508
|
+
metadata.createIndex(DEPENDENCY_INDEX, "dependencyIds", { multiEntry: true });
|
|
1509
|
+
}
|
|
1510
|
+
if (!open.result.objectStoreNames.contains(TOMBSTONE_STORE)) {
|
|
1511
|
+
open.result.createObjectStore(TOMBSTONE_STORE, { keyPath: "id" });
|
|
1512
|
+
}
|
|
1513
|
+
};
|
|
1514
|
+
open.onsuccess = () => {
|
|
1515
|
+
open.result.onversionchange = () => open.result.close();
|
|
1516
|
+
resolve(open.result);
|
|
1517
|
+
};
|
|
1518
|
+
open.onerror = () => reject(open.error ?? new Error("Failed to open offline edit queue."));
|
|
1519
|
+
open.onblocked = () => reject(new Error("Offline edit queue database upgrade is blocked."));
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
function runTransaction(database, mode, body) {
|
|
1523
|
+
return new Promise((resolve, reject) => {
|
|
1524
|
+
const transaction = database.transaction([EDIT_STORE, METADATA_STORE, TOMBSTONE_STORE], mode);
|
|
1525
|
+
const stores = {
|
|
1526
|
+
edits: transaction.objectStore(EDIT_STORE),
|
|
1527
|
+
metadata: transaction.objectStore(METADATA_STORE),
|
|
1528
|
+
tombstones: transaction.objectStore(TOMBSTONE_STORE),
|
|
1529
|
+
};
|
|
1530
|
+
let result;
|
|
1531
|
+
let settled = false;
|
|
1532
|
+
void body(stores).then((value) => {
|
|
1533
|
+
result = value;
|
|
1534
|
+
}, (error) => {
|
|
1535
|
+
settled = true;
|
|
1536
|
+
try {
|
|
1537
|
+
transaction.abort();
|
|
1538
|
+
}
|
|
1539
|
+
catch {
|
|
1540
|
+
// The transaction may already have completed or aborted.
|
|
1541
|
+
}
|
|
1542
|
+
reject(error);
|
|
1543
|
+
});
|
|
1544
|
+
transaction.oncomplete = () => {
|
|
1545
|
+
if (!settled) {
|
|
1546
|
+
settled = true;
|
|
1547
|
+
resolve(result);
|
|
1548
|
+
}
|
|
1549
|
+
};
|
|
1550
|
+
transaction.onerror = () => {
|
|
1551
|
+
if (!settled) {
|
|
1552
|
+
settled = true;
|
|
1553
|
+
reject(transaction.error ?? new Error("Offline edit queue transaction failed."));
|
|
1554
|
+
}
|
|
1555
|
+
};
|
|
1556
|
+
transaction.onabort = () => {
|
|
1557
|
+
if (!settled) {
|
|
1558
|
+
settled = true;
|
|
1559
|
+
reject(transaction.error ?? new Error("Offline edit queue transaction aborted."));
|
|
1560
|
+
}
|
|
1561
|
+
};
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
function request(value) {
|
|
1565
|
+
return new Promise((resolve, reject) => {
|
|
1566
|
+
value.onsuccess = () => resolve(value.result);
|
|
1567
|
+
value.onerror = () => reject(value.error ?? new Error("Offline edit queue request failed."));
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
function cloneJson(value, path, depth, budget) {
|
|
1571
|
+
budget.nodes += 1;
|
|
1572
|
+
if (budget.nodes > 100_000 || depth > 64)
|
|
1573
|
+
fail("invalid-edit", `${path} exceeds JSON structure limits.`, { path });
|
|
1574
|
+
if (value === null) {
|
|
1575
|
+
addJsonBytes(budget, 4, path);
|
|
1576
|
+
return value;
|
|
1577
|
+
}
|
|
1578
|
+
if (typeof value === "boolean") {
|
|
1579
|
+
addJsonBytes(budget, value ? 4 : 5, path);
|
|
1580
|
+
return value;
|
|
1581
|
+
}
|
|
1582
|
+
if (typeof value === "string") {
|
|
1583
|
+
addJsonStringBytes(budget, value, path);
|
|
1584
|
+
return value;
|
|
1585
|
+
}
|
|
1586
|
+
if (typeof value === "number") {
|
|
1587
|
+
if (!Number.isFinite(value))
|
|
1588
|
+
fail("invalid-edit", `${path} contains a non-finite number.`, { path });
|
|
1589
|
+
addJsonBytes(budget, String(value).length, path);
|
|
1590
|
+
return value;
|
|
1591
|
+
}
|
|
1592
|
+
if (Array.isArray(value)) {
|
|
1593
|
+
if (value.length > 100_000 - budget.nodes) {
|
|
1594
|
+
fail("invalid-edit", `${path} exceeds JSON structure limits.`, { path });
|
|
1595
|
+
}
|
|
1596
|
+
const array = denseArray(value, path);
|
|
1597
|
+
addJsonBytes(budget, 2 + Math.max(0, array.length - 1), path);
|
|
1598
|
+
const out = [];
|
|
1599
|
+
for (let index = 0; index < array.length; index += 1) {
|
|
1600
|
+
const descriptor = Object.getOwnPropertyDescriptor(array, index);
|
|
1601
|
+
if (!descriptor || !("value" in descriptor))
|
|
1602
|
+
fail("invalid-edit", `${path} must be dense data.`, { path });
|
|
1603
|
+
out.push(cloneJson(descriptor.value, `${path}[${index}]`, depth + 1, budget));
|
|
1604
|
+
}
|
|
1605
|
+
return out;
|
|
1606
|
+
}
|
|
1607
|
+
const record = plainRecord(value, path);
|
|
1608
|
+
const keys = Object.keys(record).sort(compareCodeUnits);
|
|
1609
|
+
addJsonBytes(budget, 2 + Math.max(0, keys.length - 1) + keys.length, path);
|
|
1610
|
+
const out = Object.create(null);
|
|
1611
|
+
for (const key of keys) {
|
|
1612
|
+
if (key === "__proto__" || key === "prototype" || key === "constructor") {
|
|
1613
|
+
fail("invalid-edit", `${path}.${key} is not allowed.`, { path: `${path}.${key}` });
|
|
1614
|
+
}
|
|
1615
|
+
const descriptor = Object.getOwnPropertyDescriptor(record, key);
|
|
1616
|
+
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
|
|
1617
|
+
fail("invalid-edit", `${path}.${key} must be an enumerable data property.`, { path: `${path}.${key}` });
|
|
1618
|
+
}
|
|
1619
|
+
addJsonStringBytes(budget, key, `${path}.${key}`);
|
|
1620
|
+
out[key] = cloneJson(descriptor.value, `${path}.${key}`, depth + 1, budget);
|
|
1621
|
+
}
|
|
1622
|
+
return out;
|
|
1623
|
+
}
|
|
1624
|
+
function canonicalJson(value) {
|
|
1625
|
+
if (value === null || typeof value !== "object")
|
|
1626
|
+
return JSON.stringify(value);
|
|
1627
|
+
if (Array.isArray(value))
|
|
1628
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
1629
|
+
const record = value;
|
|
1630
|
+
return `{${Object.keys(record)
|
|
1631
|
+
.sort(compareCodeUnits)
|
|
1632
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
|
|
1633
|
+
.join(",")}}`;
|
|
1634
|
+
}
|
|
1635
|
+
async function sha256(value) {
|
|
1636
|
+
const subtle = globalThis.crypto?.subtle;
|
|
1637
|
+
if (!subtle)
|
|
1638
|
+
fail("invalid-edit", "Offline edit identity requires Web Crypto SHA-256.", { path: "crypto" });
|
|
1639
|
+
const digest = await subtle.digest("SHA-256", encoder.encode(value));
|
|
1640
|
+
return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
1641
|
+
}
|
|
1642
|
+
function defaultLeaseToken() {
|
|
1643
|
+
const token = globalThis.crypto?.randomUUID?.();
|
|
1644
|
+
if (!token)
|
|
1645
|
+
fail("invalid-edit", "Offline edit leases require crypto.randomUUID().", { path: "crypto" });
|
|
1646
|
+
return token;
|
|
1647
|
+
}
|
|
1648
|
+
function validNow(now) {
|
|
1649
|
+
const value = now();
|
|
1650
|
+
if (!(value instanceof Date) || !Number.isFinite(value.getTime()))
|
|
1651
|
+
fail("invalid-edit", "Queue clock is invalid.");
|
|
1652
|
+
return new Date(value.getTime());
|
|
1653
|
+
}
|
|
1654
|
+
function timestamp(now) {
|
|
1655
|
+
return validNow(now).toISOString();
|
|
1656
|
+
}
|
|
1657
|
+
function normalizedTimestamp(value, path) {
|
|
1658
|
+
const raw = requiredString(value, path);
|
|
1659
|
+
const date = new Date(raw);
|
|
1660
|
+
if (!Number.isFinite(date.getTime()) || date.toISOString() !== raw) {
|
|
1661
|
+
fail("invalid-edit", `${path} must be a normalized ISO-8601 timestamp.`, { path });
|
|
1662
|
+
}
|
|
1663
|
+
return raw;
|
|
1664
|
+
}
|
|
1665
|
+
function requiredString(value, path) {
|
|
1666
|
+
if (typeof value !== "string" ||
|
|
1667
|
+
value.length === 0 ||
|
|
1668
|
+
value.length > 1024 ||
|
|
1669
|
+
value.trim() !== value ||
|
|
1670
|
+
encoder.encode(value).byteLength > 1024) {
|
|
1671
|
+
fail("invalid-edit", `${path} must be a normalized non-empty string of at most 1024 UTF-8 bytes.`, { path });
|
|
1672
|
+
}
|
|
1673
|
+
return value;
|
|
1674
|
+
}
|
|
1675
|
+
/**
|
|
1676
|
+
* A durable partition key that is persisted verbatim and used as an index key.
|
|
1677
|
+
* Screened with the same denylist that governs endpoint normalization; the
|
|
1678
|
+
* message names the path and never echoes the rejected value.
|
|
1679
|
+
*/
|
|
1680
|
+
function requiredPersistedIdentity(value, path) {
|
|
1681
|
+
const normalized = requiredString(value, path);
|
|
1682
|
+
const reason = screenPersistedString(normalized, "identity");
|
|
1683
|
+
if (reason)
|
|
1684
|
+
fail("invalid-edit", credentialScreenMessage(path, reason), { path });
|
|
1685
|
+
return normalized;
|
|
1686
|
+
}
|
|
1687
|
+
/**
|
|
1688
|
+
* Reviewer-authored prose that is persisted verbatim. Screened with the label
|
|
1689
|
+
* strictness — a note may contain punctuation a machine identity may not, but
|
|
1690
|
+
* it must still not be a request URL or carry credential-shaped material.
|
|
1691
|
+
*/
|
|
1692
|
+
function requiredPersistedLabel(value, path) {
|
|
1693
|
+
const normalized = requiredString(value, path);
|
|
1694
|
+
const reason = screenPersistedString(normalized, "label");
|
|
1695
|
+
if (reason)
|
|
1696
|
+
fail("invalid-edit", credentialScreenMessage(path, reason), { path });
|
|
1697
|
+
return normalized;
|
|
1698
|
+
}
|
|
1699
|
+
function requiredDigest(value, path) {
|
|
1700
|
+
if (typeof value !== "string" || !EDIT_ID_PATTERN.test(value)) {
|
|
1701
|
+
fail("invalid-edit", `${path} must be a lowercase SHA-256 digest.`, { path });
|
|
1702
|
+
}
|
|
1703
|
+
return value;
|
|
1704
|
+
}
|
|
1705
|
+
function requiredId(value, path) {
|
|
1706
|
+
return requiredDigest(value, path);
|
|
1707
|
+
}
|
|
1708
|
+
function positiveInteger(value, path) {
|
|
1709
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
|
|
1710
|
+
fail("invalid-edit", `${path} must be a positive safe integer.`, { path });
|
|
1711
|
+
}
|
|
1712
|
+
return value;
|
|
1713
|
+
}
|
|
1714
|
+
function tightenedPositive(value, ceiling, path) {
|
|
1715
|
+
return Math.min(value === undefined ? ceiling : positiveInteger(value, path), ceiling);
|
|
1716
|
+
}
|
|
1717
|
+
function addJsonStringBytes(budget, value, path) {
|
|
1718
|
+
if (value.length > budget.maxBytes - budget.bytes) {
|
|
1719
|
+
fail("invalid-edit", `${path} exceeds the offline edit payload byte limit.`, { path });
|
|
1720
|
+
}
|
|
1721
|
+
addJsonBytes(budget, encoder.encode(JSON.stringify(value)).byteLength, path);
|
|
1722
|
+
}
|
|
1723
|
+
function addJsonBytes(budget, bytes, path) {
|
|
1724
|
+
budget.bytes += bytes;
|
|
1725
|
+
if (budget.bytes > budget.maxBytes) {
|
|
1726
|
+
fail("invalid-edit", `${path} exceeds the offline edit payload byte limit.`, { path });
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
function plainRecord(value, path) {
|
|
1730
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1731
|
+
fail("invalid-edit", `${path} must be a plain object.`, { path });
|
|
1732
|
+
}
|
|
1733
|
+
const prototype = Object.getPrototypeOf(value);
|
|
1734
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
1735
|
+
fail("invalid-edit", `${path} must be a plain object.`, { path });
|
|
1736
|
+
}
|
|
1737
|
+
if (Object.getOwnPropertySymbols(value).length > 0) {
|
|
1738
|
+
fail("invalid-edit", `${path} must not contain symbol properties.`, { path });
|
|
1739
|
+
}
|
|
1740
|
+
for (const key of Object.getOwnPropertyNames(value)) {
|
|
1741
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
1742
|
+
if (!descriptor || !("value" in descriptor)) {
|
|
1743
|
+
fail("invalid-edit", `${path}.${key} must be a data property.`, { path: `${path}.${key}` });
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
return value;
|
|
1747
|
+
}
|
|
1748
|
+
function allowedKeys(record, allowed, path) {
|
|
1749
|
+
for (const key of Object.getOwnPropertyNames(record)) {
|
|
1750
|
+
if (!allowed.has(key))
|
|
1751
|
+
fail("invalid-edit", `${path}.${key} is not supported.`, { path: `${path}.${key}` });
|
|
1752
|
+
const descriptor = Object.getOwnPropertyDescriptor(record, key);
|
|
1753
|
+
if (!descriptor?.enumerable) {
|
|
1754
|
+
fail("invalid-edit", `${path}.${key} must be enumerable.`, { path: `${path}.${key}` });
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
function denseArray(value, path) {
|
|
1759
|
+
if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) {
|
|
1760
|
+
fail("invalid-edit", `${path} must be a plain array.`, { path });
|
|
1761
|
+
}
|
|
1762
|
+
if (Object.getOwnPropertySymbols(value).length > 0) {
|
|
1763
|
+
fail("invalid-edit", `${path} must not contain symbol properties.`, { path });
|
|
1764
|
+
}
|
|
1765
|
+
for (const key of Object.getOwnPropertyNames(value)) {
|
|
1766
|
+
if (key === "length")
|
|
1767
|
+
continue;
|
|
1768
|
+
const index = Number(key);
|
|
1769
|
+
if (!Number.isSafeInteger(index) || index < 0 || index >= value.length || String(index) !== key) {
|
|
1770
|
+
fail("invalid-edit", `${path} must not contain named properties.`, { path: `${path}.${key}` });
|
|
1771
|
+
}
|
|
1772
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
1773
|
+
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
|
|
1774
|
+
fail("invalid-edit", `${path}[${key}] must be an enumerable data property.`, { path: `${path}[${key}]` });
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
1778
|
+
if (!Object.hasOwn(value, index)) {
|
|
1779
|
+
fail("invalid-edit", `${path} must not contain sparse entries.`, { path: `${path}[${index}]` });
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
return value;
|
|
1783
|
+
}
|
|
1784
|
+
function compareCodeUnits(left, right) {
|
|
1785
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
1786
|
+
}
|
|
1787
|
+
function copy(value) {
|
|
1788
|
+
return deepFreeze(structuredClone(value));
|
|
1789
|
+
}
|
|
1790
|
+
function deepFreeze(value) {
|
|
1791
|
+
if (typeof value !== "object" || value === null || Object.isFrozen(value))
|
|
1792
|
+
return value;
|
|
1793
|
+
Object.freeze(value);
|
|
1794
|
+
for (const child of Object.values(value))
|
|
1795
|
+
deepFreeze(child);
|
|
1796
|
+
return value;
|
|
1797
|
+
}
|
|
1798
|
+
function editQueueSdkCode(code) {
|
|
1799
|
+
if (code === "store-failed")
|
|
1800
|
+
return "offline.storage.failure";
|
|
1801
|
+
if (code === "lease-mismatch" || code === "lease-expired")
|
|
1802
|
+
return "offline.storage.concurrent";
|
|
1803
|
+
return "offline.replica-sync.validation";
|
|
1804
|
+
}
|
|
1805
|
+
function fail(code, message, options) {
|
|
1806
|
+
throw new HonuaOfflineEditQueueError(code, message, options);
|
|
1807
|
+
}
|