@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,1007 @@
|
|
|
1
|
+
import { normalizeDiscoveryEndpoint } from "../contract/discovery.js";
|
|
2
|
+
import { credentialScreenMessage, screenPersistedString, } from "./credential-screen.js";
|
|
3
|
+
import { canonicalJson, compareCodeUnits, sha256 } from "./digest.js";
|
|
4
|
+
import { captureOfflineStorageBudget, isStorageQuotaPressureError } from "./quota.js";
|
|
5
|
+
import { DEFAULT_OFFLINE_REGION_MAX_ATTRIBUTIONS, DEFAULT_OFFLINE_REGION_MAX_LOGICAL_BYTES, DEFAULT_OFFLINE_REGION_MAX_METADATA_ENTRIES, DEFAULT_OFFLINE_REGION_MAX_RESOURCES, DEFAULT_OFFLINE_REGION_MAX_STRING_BYTES, HONUA_OFFLINE_REGION_DIAGNOSTIC_KIND, HONUA_OFFLINE_REGION_DIAGNOSTIC_VERSION, HONUA_OFFLINE_REGION_KIND, HONUA_OFFLINE_REGION_VERSION, HonuaOfflineRegionError, } from "./types.js";
|
|
6
|
+
const INTEGRITY_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
|
7
|
+
const RESOURCE_KINDS = new Set(["metadata", "features", "tile", "asset", "attribution"]);
|
|
8
|
+
const encoder = new TextEncoder();
|
|
9
|
+
const MANIFEST_KEYS = new Set([
|
|
10
|
+
"kind",
|
|
11
|
+
"version",
|
|
12
|
+
"id",
|
|
13
|
+
"name",
|
|
14
|
+
"source",
|
|
15
|
+
"bounds",
|
|
16
|
+
"minZoom",
|
|
17
|
+
"maxZoom",
|
|
18
|
+
"expiresAt",
|
|
19
|
+
"attribution",
|
|
20
|
+
"resources",
|
|
21
|
+
"totalLogicalBytes",
|
|
22
|
+
]);
|
|
23
|
+
const SOURCE_KEYS = new Set([
|
|
24
|
+
"id",
|
|
25
|
+
"endpoint",
|
|
26
|
+
"authorizationScopeDigest",
|
|
27
|
+
"sourceVersion",
|
|
28
|
+
"schemaVersion",
|
|
29
|
+
"planVersion",
|
|
30
|
+
"observation",
|
|
31
|
+
"validator",
|
|
32
|
+
]);
|
|
33
|
+
const RESOURCE_KEYS = new Set([
|
|
34
|
+
"id",
|
|
35
|
+
"kind",
|
|
36
|
+
"byteLength",
|
|
37
|
+
"integrity",
|
|
38
|
+
"contentType",
|
|
39
|
+
"sourceVersion",
|
|
40
|
+
"schemaVersion",
|
|
41
|
+
"planVersion",
|
|
42
|
+
"attributionIds",
|
|
43
|
+
]);
|
|
44
|
+
const CREATE_KEYS = new Set([
|
|
45
|
+
"name",
|
|
46
|
+
"sourceId",
|
|
47
|
+
"endpoint",
|
|
48
|
+
"authorizationScopeFingerprint",
|
|
49
|
+
"bounds",
|
|
50
|
+
"minZoom",
|
|
51
|
+
"maxZoom",
|
|
52
|
+
"sourceVersion",
|
|
53
|
+
"schemaVersion",
|
|
54
|
+
"planVersion",
|
|
55
|
+
"observation",
|
|
56
|
+
"validator",
|
|
57
|
+
"expiresAt",
|
|
58
|
+
"attribution",
|
|
59
|
+
"resources",
|
|
60
|
+
"limits",
|
|
61
|
+
]);
|
|
62
|
+
const BOUNDS_KEYS = new Set(["minX", "minY", "maxX", "maxY", "crs"]);
|
|
63
|
+
const OBSERVATION_KEYS = new Set(["state", "observedAt", "validAt"]);
|
|
64
|
+
const VALIDATOR_KEYS = new Set(["etag", "lastModified"]);
|
|
65
|
+
const LIMIT_KEYS = new Set([
|
|
66
|
+
"maxResources",
|
|
67
|
+
"maxLogicalBytes",
|
|
68
|
+
"maxStringBytes",
|
|
69
|
+
"maxAttributions",
|
|
70
|
+
"maxMetadataEntries",
|
|
71
|
+
]);
|
|
72
|
+
class TrustBudget {
|
|
73
|
+
limits;
|
|
74
|
+
#stringBytes = 0;
|
|
75
|
+
#metadataEntries = 0;
|
|
76
|
+
constructor(limits) {
|
|
77
|
+
this.limits = limits;
|
|
78
|
+
}
|
|
79
|
+
string(value, path, normalizedRequired) {
|
|
80
|
+
if (typeof value !== "string")
|
|
81
|
+
invalid(`${path} must be a string.`, path);
|
|
82
|
+
const remaining = this.limits.maxStringBytes - this.#stringBytes;
|
|
83
|
+
if (value.length > remaining)
|
|
84
|
+
limit(`${path} exceeds the remaining descriptor string budget.`, path);
|
|
85
|
+
const inputBytes = encoder.encode(value).byteLength;
|
|
86
|
+
if (inputBytes > remaining)
|
|
87
|
+
limit(`${path} exceeds the remaining descriptor string budget.`, path);
|
|
88
|
+
const normalized = value.trim();
|
|
89
|
+
if (normalized.length === 0)
|
|
90
|
+
invalid(`${path} must be non-empty.`, path);
|
|
91
|
+
if (normalizedRequired && normalized !== value)
|
|
92
|
+
invalid(`${path} is not normalized.`, path);
|
|
93
|
+
this.#stringBytes += inputBytes;
|
|
94
|
+
return normalized;
|
|
95
|
+
}
|
|
96
|
+
/** A persisted machine key: also refused when it carries credential or request-URL shape. */
|
|
97
|
+
identity(value, path, normalizedRequired) {
|
|
98
|
+
return this.#screened(value, path, normalizedRequired, "identity");
|
|
99
|
+
}
|
|
100
|
+
/** Persisted human prose: refused only for embedded credential assignments and URLs. */
|
|
101
|
+
label(value, path, normalizedRequired) {
|
|
102
|
+
return this.#screened(value, path, normalizedRequired, "label");
|
|
103
|
+
}
|
|
104
|
+
metadata(count, path) {
|
|
105
|
+
if (count < 0 || this.#metadataEntries > this.limits.maxMetadataEntries - count) {
|
|
106
|
+
limit(`${path} exceeds the metadata entry limit.`, path);
|
|
107
|
+
}
|
|
108
|
+
this.#metadataEntries += count;
|
|
109
|
+
}
|
|
110
|
+
// Screening runs on the normalized value and before any caller-visible echo of
|
|
111
|
+
// it, so a rejected secret is never repeated back in a message (NFR-002).
|
|
112
|
+
#screened(value, path, normalizedRequired, strictness) {
|
|
113
|
+
const normalized = this.string(value, path, normalizedRequired);
|
|
114
|
+
const reason = screenPersistedString(normalized, strictness);
|
|
115
|
+
if (reason)
|
|
116
|
+
invalid(credentialScreenMessage(path, reason), path);
|
|
117
|
+
return normalized;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Create an immutable, deterministic, credential-free downloadable-region manifest. */
|
|
121
|
+
export async function createOfflineRegionManifest(input) {
|
|
122
|
+
const snapshot = captureCreationInput(input);
|
|
123
|
+
const authorizationScopeDigest = await sha256(`honua-offline-scope:v1:${snapshot.authorizationScopeFingerprint}`);
|
|
124
|
+
const identity = {
|
|
125
|
+
kind: HONUA_OFFLINE_REGION_KIND,
|
|
126
|
+
version: HONUA_OFFLINE_REGION_VERSION,
|
|
127
|
+
name: snapshot.name,
|
|
128
|
+
source: {
|
|
129
|
+
id: snapshot.sourceId,
|
|
130
|
+
endpoint: snapshot.endpoint,
|
|
131
|
+
authorizationScopeDigest,
|
|
132
|
+
sourceVersion: snapshot.sourceVersion,
|
|
133
|
+
schemaVersion: snapshot.schemaVersion,
|
|
134
|
+
planVersion: snapshot.planVersion,
|
|
135
|
+
observation: snapshot.observation,
|
|
136
|
+
...(snapshot.validator ? { validator: snapshot.validator } : {}),
|
|
137
|
+
},
|
|
138
|
+
bounds: snapshot.bounds,
|
|
139
|
+
...(snapshot.minZoom !== undefined ? { minZoom: snapshot.minZoom } : {}),
|
|
140
|
+
...(snapshot.maxZoom !== undefined ? { maxZoom: snapshot.maxZoom } : {}),
|
|
141
|
+
...(snapshot.expiresAt !== undefined ? { expiresAt: snapshot.expiresAt } : {}),
|
|
142
|
+
attribution: snapshot.attribution,
|
|
143
|
+
resources: snapshot.resources,
|
|
144
|
+
totalLogicalBytes: snapshot.totalLogicalBytes,
|
|
145
|
+
};
|
|
146
|
+
const canonicalIdentity = canonicalJson(identity);
|
|
147
|
+
const id = await sha256(`honua-offline-region:v1:${canonicalIdentity}`);
|
|
148
|
+
return deepFreeze({ ...identity, id });
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Validate an untrusted manifest and prove its identity digest still matches its
|
|
152
|
+
* normalized contents, returning the normalized value.
|
|
153
|
+
*
|
|
154
|
+
* A store hands back whatever was persisted, so any consumer that is about to
|
|
155
|
+
* trust a stored manifest's provenance — versions, attribution, authorization
|
|
156
|
+
* scope — verifies it here first rather than reading fields off unvalidated
|
|
157
|
+
* input.
|
|
158
|
+
*/
|
|
159
|
+
export async function verifyOfflineRegionManifest(inputManifest) {
|
|
160
|
+
const prepared = captureManifest(inputManifest);
|
|
161
|
+
const expectedId = await sha256(`honua-offline-region:v1:${prepared.canonicalIdentity}`);
|
|
162
|
+
if (prepared.manifest.id !== expectedId) {
|
|
163
|
+
invalid("Offline region identity does not match its normalized contents.", "id");
|
|
164
|
+
}
|
|
165
|
+
return prepared.manifest;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Produce a deterministic logical-byte quota decision. Expired entries are
|
|
169
|
+
* evicted first, then least-recently-used entries, with code-unit id ordering.
|
|
170
|
+
*/
|
|
171
|
+
export function planOfflineRegionAdmission(inputManifest, inventory, options) {
|
|
172
|
+
const prepared = captureManifest(inputManifest);
|
|
173
|
+
return planPreparedAdmission(prepared.manifest, inventory, options);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Explain persistent cache state and quota admission without reading payloads,
|
|
177
|
+
* mutating storage, or exposing endpoint credentials and raw validators.
|
|
178
|
+
*/
|
|
179
|
+
export async function createOfflineRegionDiagnostic(inputManifest, inputInventory, options) {
|
|
180
|
+
// Capture both untrusted inputs before the first digest await.
|
|
181
|
+
const prepared = captureManifest(inputManifest);
|
|
182
|
+
const manifest = prepared.manifest;
|
|
183
|
+
const inventory = normalizeInventory(inputInventory);
|
|
184
|
+
const logicalQuotaBytes = nonNegativeInteger(options.logicalQuotaBytes, "logicalQuotaBytes");
|
|
185
|
+
const staleAfterMs = nonNegativeInteger(options.staleAfterMs, "staleAfterMs");
|
|
186
|
+
const nowMs = options.now instanceof Date ? options.now.getTime() : Number.NaN;
|
|
187
|
+
if (!Number.isFinite(nowMs))
|
|
188
|
+
invalid("now must be a valid Date.", "now");
|
|
189
|
+
const [expectedId, endpointFingerprint, etagFingerprint] = await Promise.all([
|
|
190
|
+
sha256(`honua-offline-region:v1:${prepared.canonicalIdentity}`),
|
|
191
|
+
sha256(`honua-offline-endpoint:v1:${manifest.source.endpoint}`),
|
|
192
|
+
manifest.source.validator?.etag
|
|
193
|
+
? sha256(`honua-offline-validator:v1:${manifest.source.validator.etag}`)
|
|
194
|
+
: Promise.resolve(undefined),
|
|
195
|
+
]);
|
|
196
|
+
if (manifest.id !== expectedId)
|
|
197
|
+
invalid("Offline region identity does not match its normalized contents.", "id");
|
|
198
|
+
const storage = options.storage === undefined ? undefined : captureOfflineStorageBudget(options.storage, "storage");
|
|
199
|
+
let admission;
|
|
200
|
+
try {
|
|
201
|
+
admission = {
|
|
202
|
+
status: "accepted",
|
|
203
|
+
plan: planPreparedAdmission(manifest, inventory, { logicalQuotaBytes, now: new Date(nowMs) }),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (error instanceof HonuaOfflineRegionError && (error.code === "expired" || error.code === "quota-exceeded")) {
|
|
208
|
+
admission = {
|
|
209
|
+
status: "rejected",
|
|
210
|
+
reason: error.code,
|
|
211
|
+
logicalQuotaBytes,
|
|
212
|
+
...(error.admission ? { attempted: error.admission } : {}),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const stored = inventory.regions.find((region) => region.id === manifest.id);
|
|
220
|
+
const observedAtMs = Date.parse(manifest.source.observation.observedAt);
|
|
221
|
+
const ageMs = Math.max(0, nowMs - observedAtMs);
|
|
222
|
+
const expiresAt = stored?.expiresAt ?? manifest.expiresAt;
|
|
223
|
+
const expired = expiresAt !== undefined && Date.parse(expiresAt) <= nowMs;
|
|
224
|
+
const freshness = expired ? "expired" : ageMs >= staleAfterMs ? "stale" : "fresh";
|
|
225
|
+
const completeness = stored
|
|
226
|
+
? stored.logicalByteLength === manifest.totalLogicalBytes
|
|
227
|
+
? "complete"
|
|
228
|
+
: "partial"
|
|
229
|
+
: "missing";
|
|
230
|
+
const reason = !stored
|
|
231
|
+
? "cache-miss"
|
|
232
|
+
: expired
|
|
233
|
+
? "expired-entry"
|
|
234
|
+
: completeness === "partial"
|
|
235
|
+
? "partial-entry"
|
|
236
|
+
: freshness === "stale"
|
|
237
|
+
? "stale-entry"
|
|
238
|
+
: "offline-entry";
|
|
239
|
+
const resourceKinds = {
|
|
240
|
+
metadata: 0,
|
|
241
|
+
features: 0,
|
|
242
|
+
tile: 0,
|
|
243
|
+
asset: 0,
|
|
244
|
+
attribution: 0,
|
|
245
|
+
};
|
|
246
|
+
for (const resource of manifest.resources)
|
|
247
|
+
resourceKinds[resource.kind] += 1;
|
|
248
|
+
return deepFreeze({
|
|
249
|
+
kind: HONUA_OFFLINE_REGION_DIAGNOSTIC_KIND,
|
|
250
|
+
version: HONUA_OFFLINE_REGION_DIAGNOSTIC_VERSION,
|
|
251
|
+
generatedAt: new Date(nowMs).toISOString(),
|
|
252
|
+
regionId: manifest.id,
|
|
253
|
+
inventoryRevision: inventory.revision,
|
|
254
|
+
cache: {
|
|
255
|
+
state: stored ? "offline" : "missing",
|
|
256
|
+
freshness,
|
|
257
|
+
completeness,
|
|
258
|
+
reason,
|
|
259
|
+
readable: Boolean(stored && completeness === "complete" && !expired),
|
|
260
|
+
observedAt: manifest.source.observation.observedAt,
|
|
261
|
+
ageMs,
|
|
262
|
+
staleAfterMs,
|
|
263
|
+
expectedLogicalBytes: manifest.totalLogicalBytes,
|
|
264
|
+
...(stored ? { storedLogicalBytes: stored.logicalByteLength, lastAccessedAt: stored.lastAccessedAt } : {}),
|
|
265
|
+
...(expiresAt ? { expiresAt } : {}),
|
|
266
|
+
pinned: stored?.pinned === true,
|
|
267
|
+
},
|
|
268
|
+
provenance: {
|
|
269
|
+
sourceId: manifest.source.id,
|
|
270
|
+
endpointFingerprint,
|
|
271
|
+
authorizationScopeDigest: manifest.source.authorizationScopeDigest,
|
|
272
|
+
sourceVersion: manifest.source.sourceVersion,
|
|
273
|
+
schemaVersion: manifest.source.schemaVersion,
|
|
274
|
+
planVersion: manifest.source.planVersion,
|
|
275
|
+
observation: manifest.source.observation,
|
|
276
|
+
...(manifest.source.validator
|
|
277
|
+
? {
|
|
278
|
+
validator: {
|
|
279
|
+
...(etagFingerprint ? { etagFingerprint } : {}),
|
|
280
|
+
...(manifest.source.validator.lastModified
|
|
281
|
+
? { lastModified: manifest.source.validator.lastModified }
|
|
282
|
+
: {}),
|
|
283
|
+
},
|
|
284
|
+
}
|
|
285
|
+
: {}),
|
|
286
|
+
},
|
|
287
|
+
contents: {
|
|
288
|
+
resourceCount: manifest.resources.length,
|
|
289
|
+
logicalBytes: manifest.totalLogicalBytes,
|
|
290
|
+
resourceKinds,
|
|
291
|
+
attributionIds: ownKeys(manifest.attribution),
|
|
292
|
+
},
|
|
293
|
+
admission,
|
|
294
|
+
...(storage ? { storage } : {}),
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
function planPreparedAdmission(manifest, inventory, options) {
|
|
298
|
+
const logicalQuotaBytes = nonNegativeInteger(options.logicalQuotaBytes, "logicalQuotaBytes");
|
|
299
|
+
const requiredLogicalBytes = nonNegativeInteger(manifest.totalLogicalBytes, "manifest.totalLogicalBytes");
|
|
300
|
+
const nowMs = (options.now ?? new Date()).getTime();
|
|
301
|
+
if (!Number.isFinite(nowMs))
|
|
302
|
+
invalid("now must be a valid Date.", "now");
|
|
303
|
+
if (manifest.expiresAt !== undefined &&
|
|
304
|
+
Date.parse(normalizeTimestamp(manifest.expiresAt, "expiresAt", true)) <= nowMs) {
|
|
305
|
+
throw new HonuaOfflineRegionError("expired", `Offline region "${manifest.id}" has expired.`);
|
|
306
|
+
}
|
|
307
|
+
const normalized = normalizeInventory(inventory);
|
|
308
|
+
const logicalBytesBefore = normalized.regions.reduce((total, region) => safeAdd(total, region.logicalByteLength, "inventory.regions"), 0);
|
|
309
|
+
const existing = normalized.regions.find((region) => region.id === manifest.id);
|
|
310
|
+
const replacementLogicalBytes = existing?.logicalByteLength ?? 0;
|
|
311
|
+
let projected = logicalBytesBefore - replacementLogicalBytes + requiredLogicalBytes;
|
|
312
|
+
const evictions = [];
|
|
313
|
+
const candidates = normalized.regions
|
|
314
|
+
.filter((region) => region.id !== manifest.id && region.pinned !== true)
|
|
315
|
+
.sort((left, right) => {
|
|
316
|
+
const expiryOrder = Number(isExpired(left, nowMs)) - Number(isExpired(right, nowMs));
|
|
317
|
+
return (-expiryOrder ||
|
|
318
|
+
Date.parse(left.lastAccessedAt) - Date.parse(right.lastAccessedAt) ||
|
|
319
|
+
compareCodeUnits(left.id, right.id));
|
|
320
|
+
});
|
|
321
|
+
for (const candidate of candidates) {
|
|
322
|
+
if (projected <= logicalQuotaBytes)
|
|
323
|
+
break;
|
|
324
|
+
evictions.push(candidate);
|
|
325
|
+
projected -= candidate.logicalByteLength;
|
|
326
|
+
}
|
|
327
|
+
// Built before the verdict so a refusal can report exactly what was attempted
|
|
328
|
+
// (REQ-004). A refused plan is a proposal: nothing in it has been evicted.
|
|
329
|
+
const plan = deepFreeze({
|
|
330
|
+
logicalQuotaBytes,
|
|
331
|
+
logicalBytesBefore,
|
|
332
|
+
replacementLogicalBytes,
|
|
333
|
+
requiredLogicalBytes,
|
|
334
|
+
evictRegionIds: evictions.map((region) => region.id),
|
|
335
|
+
evictedLogicalBytes: evictions.reduce((total, region) => safeAdd(total, region.logicalByteLength, "inventory.regions"), 0),
|
|
336
|
+
logicalBytesAfter: projected,
|
|
337
|
+
});
|
|
338
|
+
if (projected > logicalQuotaBytes) {
|
|
339
|
+
const pinned = normalized.regions
|
|
340
|
+
.filter((region) => region.id !== manifest.id && region.pinned === true)
|
|
341
|
+
.reduce((total, region) => safeAdd(total, region.logicalByteLength, "inventory.regions"), 0);
|
|
342
|
+
throw new HonuaOfflineRegionError("quota-exceeded", `Offline region requires ${requiredLogicalBytes} logical bytes but only ${Math.max(0, logicalQuotaBytes - pinned)} remain after preserving pinned data.`, { admission: plan });
|
|
343
|
+
}
|
|
344
|
+
return plan;
|
|
345
|
+
}
|
|
346
|
+
/** Download and atomically commit a manifest through caller-injected adapters. */
|
|
347
|
+
export async function downloadOfflineRegion(inputManifest, options) {
|
|
348
|
+
// This entire owned snapshot is captured synchronously before the first await.
|
|
349
|
+
const prepared = captureManifest(inputManifest);
|
|
350
|
+
const manifest = prepared.manifest;
|
|
351
|
+
throwIfAborted(options.signal);
|
|
352
|
+
const expectedId = await sha256(`honua-offline-region:v1:${prepared.canonicalIdentity}`);
|
|
353
|
+
if (manifest.id !== expectedId)
|
|
354
|
+
invalid("Offline region identity does not match its normalized contents.", "id");
|
|
355
|
+
const now = options.now ?? (() => new Date());
|
|
356
|
+
let inventory;
|
|
357
|
+
try {
|
|
358
|
+
inventory = await options.store.inventory();
|
|
359
|
+
}
|
|
360
|
+
catch (cause) {
|
|
361
|
+
throw storeFailure("Failed to inspect the offline region store.", cause);
|
|
362
|
+
}
|
|
363
|
+
throwIfAborted(options.signal);
|
|
364
|
+
const normalizedInventory = normalizeInventory(inventory);
|
|
365
|
+
const plan = planPreparedAdmission(manifest, normalizedInventory, {
|
|
366
|
+
logicalQuotaBytes: options.logicalQuotaBytes,
|
|
367
|
+
now: now(),
|
|
368
|
+
});
|
|
369
|
+
emit(options, {
|
|
370
|
+
phase: "planned",
|
|
371
|
+
completedResources: 0,
|
|
372
|
+
totalResources: manifest.resources.length,
|
|
373
|
+
completedLogicalBytes: 0,
|
|
374
|
+
totalLogicalBytes: manifest.totalLogicalBytes,
|
|
375
|
+
evictionRegionIds: plan.evictRegionIds,
|
|
376
|
+
});
|
|
377
|
+
let transaction;
|
|
378
|
+
try {
|
|
379
|
+
transaction = await options.store.beginWrite(manifest.id);
|
|
380
|
+
}
|
|
381
|
+
catch (cause) {
|
|
382
|
+
throw storeFailure("Failed to begin an offline region transaction.", cause, plan);
|
|
383
|
+
}
|
|
384
|
+
let settled = false;
|
|
385
|
+
try {
|
|
386
|
+
for (const regionId of plan.evictRegionIds) {
|
|
387
|
+
throwIfAborted(options.signal);
|
|
388
|
+
await transaction.evict(regionId);
|
|
389
|
+
}
|
|
390
|
+
let completedResources = 0;
|
|
391
|
+
let completedLogicalBytes = 0;
|
|
392
|
+
for (const resource of manifest.resources) {
|
|
393
|
+
throwIfAborted(options.signal);
|
|
394
|
+
emit(options, {
|
|
395
|
+
phase: "downloading",
|
|
396
|
+
completedResources,
|
|
397
|
+
totalResources: manifest.resources.length,
|
|
398
|
+
completedLogicalBytes,
|
|
399
|
+
totalLogicalBytes: manifest.totalLogicalBytes,
|
|
400
|
+
resourceId: resource.id,
|
|
401
|
+
});
|
|
402
|
+
const stagedBytes = transaction.readStaged ? await transaction.readStaged(resource) : undefined;
|
|
403
|
+
let ownedBytes = stagedBytes;
|
|
404
|
+
if (ownedBytes !== undefined && (await sha256(ownedBytes)) !== resource.integrity) {
|
|
405
|
+
await transaction.discardStaged?.(resource);
|
|
406
|
+
ownedBytes = undefined;
|
|
407
|
+
}
|
|
408
|
+
if (ownedBytes === undefined)
|
|
409
|
+
ownedBytes = await loadOwnedResource(resource, manifest, options);
|
|
410
|
+
throwIfAborted(options.signal);
|
|
411
|
+
if (ownedBytes.byteLength !== resource.byteLength) {
|
|
412
|
+
throw new HonuaOfflineRegionError("integrity-mismatch", `Resource "${resource.id}" declared ${resource.byteLength} bytes but loaded ${ownedBytes.byteLength}.`, { resourceId: resource.id });
|
|
413
|
+
}
|
|
414
|
+
const integrity = await sha256(ownedBytes);
|
|
415
|
+
if (integrity !== resource.integrity) {
|
|
416
|
+
throw new HonuaOfflineRegionError("integrity-mismatch", `Resource "${resource.id}" failed SHA-256 verification.`, {
|
|
417
|
+
resourceId: resource.id,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
throwIfAborted(options.signal);
|
|
421
|
+
emit(options, {
|
|
422
|
+
phase: "writing",
|
|
423
|
+
completedResources,
|
|
424
|
+
totalResources: manifest.resources.length,
|
|
425
|
+
completedLogicalBytes,
|
|
426
|
+
totalLogicalBytes: manifest.totalLogicalBytes,
|
|
427
|
+
resourceId: resource.id,
|
|
428
|
+
});
|
|
429
|
+
await transaction.write(resource, ownedBytes);
|
|
430
|
+
completedResources += 1;
|
|
431
|
+
completedLogicalBytes += ownedBytes.byteLength;
|
|
432
|
+
}
|
|
433
|
+
throwIfAborted(options.signal);
|
|
434
|
+
emit(options, {
|
|
435
|
+
phase: "committing",
|
|
436
|
+
completedResources: manifest.resources.length,
|
|
437
|
+
totalResources: manifest.resources.length,
|
|
438
|
+
completedLogicalBytes: manifest.totalLogicalBytes,
|
|
439
|
+
totalLogicalBytes: manifest.totalLogicalBytes,
|
|
440
|
+
});
|
|
441
|
+
const receipt = deepFreeze({
|
|
442
|
+
regionId: manifest.id,
|
|
443
|
+
resourceCount: manifest.resources.length,
|
|
444
|
+
logicalByteLength: manifest.totalLogicalBytes,
|
|
445
|
+
evictedRegionIds: [...plan.evictRegionIds],
|
|
446
|
+
integrity: "verified",
|
|
447
|
+
quotaAccounting: "logical-payload-bytes",
|
|
448
|
+
completedAt: normalizeTimestamp(now().toISOString(), "completedAt", false),
|
|
449
|
+
});
|
|
450
|
+
const commit = await transaction.commit(manifest, receipt, {
|
|
451
|
+
expectedInventoryRevision: normalizedInventory.revision,
|
|
452
|
+
logicalQuotaBytes: plan.logicalQuotaBytes,
|
|
453
|
+
admission: plan,
|
|
454
|
+
});
|
|
455
|
+
if (commit === "inventory-changed") {
|
|
456
|
+
throw new HonuaOfflineRegionError("inventory-changed", "Offline region inventory changed before commit; retry against a fresh quota snapshot.");
|
|
457
|
+
}
|
|
458
|
+
if (commit !== "committed") {
|
|
459
|
+
throw new HonuaOfflineRegionError("store-failed", "Offline region store returned an invalid commit result.");
|
|
460
|
+
}
|
|
461
|
+
settled = true;
|
|
462
|
+
emit(options, {
|
|
463
|
+
phase: "complete",
|
|
464
|
+
completedResources: manifest.resources.length,
|
|
465
|
+
totalResources: manifest.resources.length,
|
|
466
|
+
completedLogicalBytes: manifest.totalLogicalBytes,
|
|
467
|
+
totalLogicalBytes: manifest.totalLogicalBytes,
|
|
468
|
+
});
|
|
469
|
+
return receipt;
|
|
470
|
+
}
|
|
471
|
+
catch (cause) {
|
|
472
|
+
if (!settled) {
|
|
473
|
+
try {
|
|
474
|
+
await transaction.rollback({ preserveStaged: true });
|
|
475
|
+
}
|
|
476
|
+
catch (rollbackCause) {
|
|
477
|
+
throw storeFailure("Offline region rollback failed.", new AggregateError([cause, rollbackCause]), plan);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (cause instanceof HonuaOfflineRegionError) {
|
|
481
|
+
// A store adapter that wrapped a full device in its own untyped failure
|
|
482
|
+
// must not defeat the quota class the caller recovers from.
|
|
483
|
+
if (cause.code !== "store-failed" || !isStorageQuotaPressureError(cause))
|
|
484
|
+
throw cause;
|
|
485
|
+
throw quotaPressure(cause, plan, cause.resourceId);
|
|
486
|
+
}
|
|
487
|
+
throw storeFailure("Offline region transaction failed.", cause, plan);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Classify one adapter failure. A platform storage-pressure failure raised while
|
|
492
|
+
* staging, writing, or committing is a `quota-exceeded` / `offline.region.quota`
|
|
493
|
+
* failure carrying the attempted plan (REQ-003, REQ-004) — a full device is an
|
|
494
|
+
* actionable caller condition, not the internal `store-failed` class.
|
|
495
|
+
*/
|
|
496
|
+
function storeFailure(message, cause, plan) {
|
|
497
|
+
if (isStorageQuotaPressureError(cause))
|
|
498
|
+
return quotaPressure(cause, plan);
|
|
499
|
+
return new HonuaOfflineRegionError("store-failed", message, { cause });
|
|
500
|
+
}
|
|
501
|
+
function quotaPressure(cause, plan, resourceId) {
|
|
502
|
+
return new HonuaOfflineRegionError("quota-exceeded", "Browser storage refused the offline region write; the device is out of space. Evict or unpin a region, or request persistent storage, then retry.", { cause, ...(plan ? { admission: plan } : {}), ...(resourceId ? { resourceId } : {}) });
|
|
503
|
+
}
|
|
504
|
+
function captureCreationInput(input) {
|
|
505
|
+
try {
|
|
506
|
+
const record = plainRecord(input, "input");
|
|
507
|
+
allowedKeys(record, CREATE_KEYS, "input");
|
|
508
|
+
const limits = normalizeLimits(record.limits);
|
|
509
|
+
const budget = new TrustBudget(limits);
|
|
510
|
+
const name = budget.label(record.name, "name", false);
|
|
511
|
+
const sourceId = budget.identity(record.sourceId, "sourceId", false);
|
|
512
|
+
// The fingerprint is never persisted; only its domain-separated digest is,
|
|
513
|
+
// so it is deliberately budgeted without being screened or normalized.
|
|
514
|
+
const authorizationScopeFingerprint = budget.string(record.authorizationScopeFingerprint, "authorizationScopeFingerprint", false);
|
|
515
|
+
const endpoint = credentialFreeEndpoint(record.endpoint, budget, false);
|
|
516
|
+
const sourceVersion = budget.identity(record.sourceVersion, "sourceVersion", false);
|
|
517
|
+
const schemaVersion = budget.identity(record.schemaVersion, "schemaVersion", false);
|
|
518
|
+
const planVersion = budget.identity(record.planVersion, "planVersion", false);
|
|
519
|
+
const observation = normalizeObservation(record.observation, budget, false);
|
|
520
|
+
const validator = normalizeValidator(record.validator, budget, false);
|
|
521
|
+
const bounds = normalizeBounds(record.bounds, budget, false);
|
|
522
|
+
const minZoom = normalizeZoom(record.minZoom, "minZoom");
|
|
523
|
+
const maxZoom = normalizeZoom(record.maxZoom, "maxZoom");
|
|
524
|
+
if (minZoom !== undefined && maxZoom !== undefined && minZoom > maxZoom) {
|
|
525
|
+
invalid("minZoom must be less than or equal to maxZoom.", "minZoom");
|
|
526
|
+
}
|
|
527
|
+
const expiresAt = record.expiresAt === undefined ? undefined : normalizeTimestamp(record.expiresAt, "expiresAt", false, budget);
|
|
528
|
+
const attribution = normalizeAttribution(record.attribution ?? {}, budget, limits, false);
|
|
529
|
+
const resources = normalizeResources(record.resources, { sourceVersion, schemaVersion, planVersion, attributionIds: ownKeys(attribution) }, budget, limits, false);
|
|
530
|
+
const totalLogicalBytes = resources.reduce((total, resource) => safeAdd(total, resource.byteLength, "resources"), 0);
|
|
531
|
+
if (totalLogicalBytes > limits.maxLogicalBytes) {
|
|
532
|
+
limit(`Offline region requires ${totalLogicalBytes} logical bytes; maximum is ${limits.maxLogicalBytes}.`, "resources");
|
|
533
|
+
}
|
|
534
|
+
return deepFreeze({
|
|
535
|
+
name,
|
|
536
|
+
sourceId,
|
|
537
|
+
endpoint,
|
|
538
|
+
authorizationScopeFingerprint,
|
|
539
|
+
bounds,
|
|
540
|
+
...(minZoom !== undefined ? { minZoom } : {}),
|
|
541
|
+
...(maxZoom !== undefined ? { maxZoom } : {}),
|
|
542
|
+
sourceVersion,
|
|
543
|
+
schemaVersion,
|
|
544
|
+
planVersion,
|
|
545
|
+
observation,
|
|
546
|
+
...(validator ? { validator } : {}),
|
|
547
|
+
...(expiresAt ? { expiresAt } : {}),
|
|
548
|
+
attribution,
|
|
549
|
+
resources,
|
|
550
|
+
totalLogicalBytes,
|
|
551
|
+
limits,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
catch (cause) {
|
|
555
|
+
throwStructured(cause, "Invalid offline region creation input.");
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
function captureManifest(input) {
|
|
559
|
+
try {
|
|
560
|
+
const limits = defaultLimits();
|
|
561
|
+
const budget = new TrustBudget(limits);
|
|
562
|
+
const record = plainRecord(input, "manifest");
|
|
563
|
+
allowedKeys(record, MANIFEST_KEYS, "manifest");
|
|
564
|
+
if (record.kind !== HONUA_OFFLINE_REGION_KIND)
|
|
565
|
+
invalid("Unsupported offline region kind.", "kind");
|
|
566
|
+
if (record.version !== HONUA_OFFLINE_REGION_VERSION)
|
|
567
|
+
invalid("Unsupported offline region version.", "version");
|
|
568
|
+
const id = integrityString(record.id, "id", budget, true);
|
|
569
|
+
const name = budget.label(record.name, "name", true);
|
|
570
|
+
const sourceRecord = plainRecord(record.source, "source");
|
|
571
|
+
allowedKeys(sourceRecord, SOURCE_KEYS, "source");
|
|
572
|
+
const sourceId = budget.identity(sourceRecord.id, "source.id", true);
|
|
573
|
+
const endpoint = credentialFreeEndpoint(sourceRecord.endpoint, budget, true);
|
|
574
|
+
const authorizationScopeDigest = integrityString(sourceRecord.authorizationScopeDigest, "source.authorizationScopeDigest", budget, true);
|
|
575
|
+
const sourceVersion = budget.identity(sourceRecord.sourceVersion, "source.sourceVersion", true);
|
|
576
|
+
const schemaVersion = budget.identity(sourceRecord.schemaVersion, "source.schemaVersion", true);
|
|
577
|
+
const planVersion = budget.identity(sourceRecord.planVersion, "source.planVersion", true);
|
|
578
|
+
const observation = normalizeObservation(sourceRecord.observation, budget, true);
|
|
579
|
+
const validator = normalizeValidator(sourceRecord.validator, budget, true);
|
|
580
|
+
const bounds = normalizeBounds(record.bounds, budget, true);
|
|
581
|
+
const minZoom = normalizeZoom(record.minZoom, "minZoom");
|
|
582
|
+
const maxZoom = normalizeZoom(record.maxZoom, "maxZoom");
|
|
583
|
+
if (minZoom !== undefined && maxZoom !== undefined && minZoom > maxZoom) {
|
|
584
|
+
invalid("minZoom must be less than or equal to maxZoom.", "minZoom");
|
|
585
|
+
}
|
|
586
|
+
const expiresAt = record.expiresAt === undefined ? undefined : normalizeTimestamp(record.expiresAt, "expiresAt", true, budget);
|
|
587
|
+
const attribution = normalizeAttribution(record.attribution, budget, limits, true);
|
|
588
|
+
const resources = normalizeResources(record.resources, { sourceVersion, schemaVersion, planVersion, attributionIds: ownKeys(attribution) }, budget, limits, true);
|
|
589
|
+
const totalLogicalBytes = nonNegativeInteger(record.totalLogicalBytes, "totalLogicalBytes");
|
|
590
|
+
const summed = resources.reduce((total, resource) => safeAdd(total, resource.byteLength, "resources"), 0);
|
|
591
|
+
if (summed !== totalLogicalBytes)
|
|
592
|
+
invalid("totalLogicalBytes does not match resource payload lengths.", "totalLogicalBytes");
|
|
593
|
+
if (summed > limits.maxLogicalBytes)
|
|
594
|
+
limit("Offline region exceeds the logical-byte limit.", "totalLogicalBytes");
|
|
595
|
+
const identity = {
|
|
596
|
+
kind: HONUA_OFFLINE_REGION_KIND,
|
|
597
|
+
version: HONUA_OFFLINE_REGION_VERSION,
|
|
598
|
+
name,
|
|
599
|
+
source: {
|
|
600
|
+
id: sourceId,
|
|
601
|
+
endpoint,
|
|
602
|
+
authorizationScopeDigest,
|
|
603
|
+
sourceVersion,
|
|
604
|
+
schemaVersion,
|
|
605
|
+
planVersion,
|
|
606
|
+
observation,
|
|
607
|
+
...(validator ? { validator } : {}),
|
|
608
|
+
},
|
|
609
|
+
bounds,
|
|
610
|
+
...(minZoom !== undefined ? { minZoom } : {}),
|
|
611
|
+
...(maxZoom !== undefined ? { maxZoom } : {}),
|
|
612
|
+
...(expiresAt ? { expiresAt } : {}),
|
|
613
|
+
attribution,
|
|
614
|
+
resources,
|
|
615
|
+
totalLogicalBytes,
|
|
616
|
+
};
|
|
617
|
+
const manifest = deepFreeze({ ...identity, id });
|
|
618
|
+
return { manifest, canonicalIdentity: canonicalJson(identity) };
|
|
619
|
+
}
|
|
620
|
+
catch (cause) {
|
|
621
|
+
throwStructured(cause, "Invalid offline region manifest.");
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
async function loadOwnedResource(resource, manifest, options) {
|
|
625
|
+
try {
|
|
626
|
+
const loaded = await options.load(resource, {
|
|
627
|
+
manifest,
|
|
628
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
629
|
+
});
|
|
630
|
+
if (!(loaded instanceof ArrayBuffer) && !(loaded instanceof Uint8Array)) {
|
|
631
|
+
invalid(`Loader returned an unsupported payload for resource "${resource.id}".`, "load");
|
|
632
|
+
}
|
|
633
|
+
const view = loaded instanceof Uint8Array ? loaded : new Uint8Array(loaded);
|
|
634
|
+
// Establish coordinator ownership before hashing or exposing progress/write.
|
|
635
|
+
return Uint8Array.from(view);
|
|
636
|
+
}
|
|
637
|
+
catch (cause) {
|
|
638
|
+
throwIfAborted(options.signal);
|
|
639
|
+
if (cause instanceof HonuaOfflineRegionError)
|
|
640
|
+
throw cause;
|
|
641
|
+
throw new HonuaOfflineRegionError("resource-load-failed", `Failed to load resource "${resource.id}".`, {
|
|
642
|
+
cause,
|
|
643
|
+
resourceId: resource.id,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
function normalizeResources(value, defaults, budget, limits, normalizedRequired) {
|
|
648
|
+
const input = denseArray(value, "resources", limits.maxResources);
|
|
649
|
+
const ids = new Set();
|
|
650
|
+
const out = [];
|
|
651
|
+
let cumulativeLogicalBytes = 0;
|
|
652
|
+
let previousId;
|
|
653
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
654
|
+
const path = `resources[${index}]`;
|
|
655
|
+
const record = plainRecord(input[index], path);
|
|
656
|
+
allowedKeys(record, RESOURCE_KEYS, path);
|
|
657
|
+
budget.metadata(1, path);
|
|
658
|
+
const id = budget.identity(record.id, `${path}.id`, normalizedRequired);
|
|
659
|
+
if (ids.has(id))
|
|
660
|
+
invalid(`Duplicate resource id "${id}".`, `${path}.id`);
|
|
661
|
+
ids.add(id);
|
|
662
|
+
if (normalizedRequired && previousId !== undefined && compareCodeUnits(previousId, id) >= 0) {
|
|
663
|
+
invalid("resources must be in deterministic code-unit order.", path);
|
|
664
|
+
}
|
|
665
|
+
previousId = id;
|
|
666
|
+
if (typeof record.kind !== "string" || !RESOURCE_KINDS.has(record.kind))
|
|
667
|
+
invalid(`${path}.kind is invalid.`, `${path}.kind`);
|
|
668
|
+
const byteLength = nonNegativeInteger(record.byteLength, `${path}.byteLength`);
|
|
669
|
+
cumulativeLogicalBytes = safeAdd(cumulativeLogicalBytes, byteLength, `${path}.byteLength`);
|
|
670
|
+
if (cumulativeLogicalBytes > limits.maxLogicalBytes) {
|
|
671
|
+
limit(`${path} exceeds the cumulative logical-byte limit.`, `${path}.byteLength`);
|
|
672
|
+
}
|
|
673
|
+
const integrity = integrityString(record.integrity, `${path}.integrity`, budget, normalizedRequired);
|
|
674
|
+
const contentType = record.contentType === undefined
|
|
675
|
+
? undefined
|
|
676
|
+
: budget.identity(record.contentType, `${path}.contentType`, normalizedRequired);
|
|
677
|
+
const sourceVersion = budget.identity(record.sourceVersion ?? defaults.sourceVersion, `${path}.sourceVersion`, normalizedRequired);
|
|
678
|
+
const schemaVersion = budget.identity(record.schemaVersion ?? defaults.schemaVersion, `${path}.schemaVersion`, normalizedRequired);
|
|
679
|
+
const planVersion = budget.identity(record.planVersion ?? defaults.planVersion, `${path}.planVersion`, normalizedRequired);
|
|
680
|
+
if (normalizedRequired &&
|
|
681
|
+
(record.sourceVersion === undefined || record.schemaVersion === undefined || record.planVersion === undefined)) {
|
|
682
|
+
invalid(`${path} must carry explicit source, schema, and plan versions.`, path);
|
|
683
|
+
}
|
|
684
|
+
const rawAttributionIds = record.attributionIds ?? defaults.attributionIds;
|
|
685
|
+
const attributionInput = denseArray(rawAttributionIds, `${path}.attributionIds`, limits.maxAttributions);
|
|
686
|
+
budget.metadata(attributionInput.length, `${path}.attributionIds`);
|
|
687
|
+
const attributionIds = [];
|
|
688
|
+
let previousAttribution;
|
|
689
|
+
const seenAttribution = new Set();
|
|
690
|
+
for (let item = 0; item < attributionInput.length; item += 1) {
|
|
691
|
+
const attributionId = budget.identity(attributionInput[item], `${path}.attributionIds[${item}]`, normalizedRequired);
|
|
692
|
+
if (!defaults.attributionIds.includes(attributionId)) {
|
|
693
|
+
invalid(`Resource "${id}" references unknown attribution "${attributionId}".`, `${path}.attributionIds[${item}]`);
|
|
694
|
+
}
|
|
695
|
+
if (seenAttribution.has(attributionId))
|
|
696
|
+
invalid("Duplicate attribution id.", `${path}.attributionIds[${item}]`);
|
|
697
|
+
if (normalizedRequired &&
|
|
698
|
+
previousAttribution !== undefined &&
|
|
699
|
+
compareCodeUnits(previousAttribution, attributionId) >= 0) {
|
|
700
|
+
invalid("attributionIds must be in deterministic code-unit order.", `${path}.attributionIds`);
|
|
701
|
+
}
|
|
702
|
+
seenAttribution.add(attributionId);
|
|
703
|
+
previousAttribution = attributionId;
|
|
704
|
+
attributionIds.push(attributionId);
|
|
705
|
+
}
|
|
706
|
+
if (!normalizedRequired)
|
|
707
|
+
attributionIds.sort(compareCodeUnits);
|
|
708
|
+
out.push({
|
|
709
|
+
id,
|
|
710
|
+
kind: record.kind,
|
|
711
|
+
byteLength,
|
|
712
|
+
integrity,
|
|
713
|
+
...(contentType ? { contentType } : {}),
|
|
714
|
+
sourceVersion,
|
|
715
|
+
schemaVersion,
|
|
716
|
+
planVersion,
|
|
717
|
+
attributionIds,
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
if (!normalizedRequired)
|
|
721
|
+
out.sort((left, right) => compareCodeUnits(left.id, right.id));
|
|
722
|
+
return out;
|
|
723
|
+
}
|
|
724
|
+
function normalizeAttribution(value, budget, limits, normalizedRequired) {
|
|
725
|
+
const record = plainRecord(value, "attribution");
|
|
726
|
+
const entries = [];
|
|
727
|
+
for (const rawId in record) {
|
|
728
|
+
if (!Object.hasOwn(record, rawId))
|
|
729
|
+
continue;
|
|
730
|
+
if (entries.length >= limits.maxAttributions)
|
|
731
|
+
limit("attribution exceeds the entry limit.", "attribution");
|
|
732
|
+
budget.metadata(1, `attribution.${rawId}`);
|
|
733
|
+
const id = budget.identity(rawId, "attribution id", normalizedRequired);
|
|
734
|
+
const text = budget.label(record[rawId], `attribution.${id}`, normalizedRequired);
|
|
735
|
+
entries.push([id, text]);
|
|
736
|
+
}
|
|
737
|
+
entries.sort(([left], [right]) => compareCodeUnits(left, right));
|
|
738
|
+
return Object.fromEntries(entries);
|
|
739
|
+
}
|
|
740
|
+
function normalizeObservation(value, budget, normalizedRequired) {
|
|
741
|
+
const record = plainRecord(value, "observation");
|
|
742
|
+
allowedKeys(record, OBSERVATION_KEYS, "observation");
|
|
743
|
+
budget.metadata(1, "observation");
|
|
744
|
+
if (record.state !== "live" && record.state !== "cached" && record.state !== "replayed") {
|
|
745
|
+
invalid("observation.state must be live, cached, or replayed.", "observation.state");
|
|
746
|
+
}
|
|
747
|
+
return {
|
|
748
|
+
state: record.state,
|
|
749
|
+
observedAt: normalizeTimestamp(record.observedAt, "observation.observedAt", normalizedRequired, budget),
|
|
750
|
+
...(record.validAt !== undefined
|
|
751
|
+
? { validAt: normalizeTimestamp(record.validAt, "observation.validAt", normalizedRequired, budget) }
|
|
752
|
+
: {}),
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
function normalizeValidator(value, budget, normalizedRequired) {
|
|
756
|
+
if (value === undefined)
|
|
757
|
+
return undefined;
|
|
758
|
+
const record = plainRecord(value, "validator");
|
|
759
|
+
allowedKeys(record, VALIDATOR_KEYS, "validator");
|
|
760
|
+
budget.metadata(1, "validator");
|
|
761
|
+
const etag = record.etag === undefined ? undefined : budget.string(record.etag, "validator.etag", normalizedRequired);
|
|
762
|
+
const lastModified = record.lastModified === undefined
|
|
763
|
+
? undefined
|
|
764
|
+
: normalizeTimestamp(record.lastModified, "validator.lastModified", normalizedRequired, budget);
|
|
765
|
+
if (etag === undefined && lastModified === undefined)
|
|
766
|
+
invalid("validator must include etag or lastModified.", "validator");
|
|
767
|
+
return { ...(etag ? { etag } : {}), ...(lastModified ? { lastModified } : {}) };
|
|
768
|
+
}
|
|
769
|
+
function normalizeBounds(value, budget, normalizedRequired) {
|
|
770
|
+
const record = plainRecord(value, "bounds");
|
|
771
|
+
allowedKeys(record, BOUNDS_KEYS, "bounds");
|
|
772
|
+
const minX = finiteNumber(record.minX, "bounds.minX");
|
|
773
|
+
const minY = finiteNumber(record.minY, "bounds.minY");
|
|
774
|
+
const maxX = finiteNumber(record.maxX, "bounds.maxX");
|
|
775
|
+
const maxY = finiteNumber(record.maxY, "bounds.maxY");
|
|
776
|
+
if (minX >= maxX || minY >= maxY) {
|
|
777
|
+
invalid("bounds must have positive width and height; antimeridian crossing is not supported in v1.", "bounds");
|
|
778
|
+
}
|
|
779
|
+
return { minX, minY, maxX, maxY, crs: budget.string(record.crs, "bounds.crs", normalizedRequired) };
|
|
780
|
+
}
|
|
781
|
+
function normalizeInventory(value) {
|
|
782
|
+
try {
|
|
783
|
+
const limits = defaultLimits();
|
|
784
|
+
const budget = new TrustBudget(limits);
|
|
785
|
+
const record = plainRecord(value, "inventory");
|
|
786
|
+
allowedKeys(record, new Set(["revision", "regions"]), "inventory");
|
|
787
|
+
const revision = budget.string(record.revision, "inventory.revision", true);
|
|
788
|
+
const input = denseArray(record.regions, "inventory.regions", limits.maxResources);
|
|
789
|
+
const ids = new Set();
|
|
790
|
+
const regions = [];
|
|
791
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
792
|
+
const path = `inventory.regions[${index}]`;
|
|
793
|
+
const item = plainRecord(input[index], path);
|
|
794
|
+
allowedKeys(item, new Set(["id", "logicalByteLength", "lastAccessedAt", "expiresAt", "pinned"]), path);
|
|
795
|
+
const id = budget.string(item.id, `${path}.id`, true);
|
|
796
|
+
if (ids.has(id))
|
|
797
|
+
invalid(`Duplicate stored region id "${id}".`, `${path}.id`);
|
|
798
|
+
ids.add(id);
|
|
799
|
+
regions.push({
|
|
800
|
+
id,
|
|
801
|
+
logicalByteLength: nonNegativeInteger(item.logicalByteLength, `${path}.logicalByteLength`),
|
|
802
|
+
lastAccessedAt: normalizeTimestamp(item.lastAccessedAt, `${path}.lastAccessedAt`, true, budget),
|
|
803
|
+
...(item.expiresAt !== undefined
|
|
804
|
+
? { expiresAt: normalizeTimestamp(item.expiresAt, `${path}.expiresAt`, true, budget) }
|
|
805
|
+
: {}),
|
|
806
|
+
...(item.pinned !== undefined ? { pinned: requiredBoolean(item.pinned, `${path}.pinned`) } : {}),
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
return deepFreeze({ revision, regions });
|
|
810
|
+
}
|
|
811
|
+
catch (cause) {
|
|
812
|
+
throwStructured(cause, "Invalid offline region inventory.");
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
function normalizeLimits(value) {
|
|
816
|
+
if (value === undefined)
|
|
817
|
+
return defaultLimits();
|
|
818
|
+
const record = plainRecord(value, "limits");
|
|
819
|
+
allowedKeys(record, LIMIT_KEYS, "limits");
|
|
820
|
+
return {
|
|
821
|
+
maxResources: tightened(record.maxResources, DEFAULT_OFFLINE_REGION_MAX_RESOURCES, "limits.maxResources"),
|
|
822
|
+
maxLogicalBytes: tightened(record.maxLogicalBytes, DEFAULT_OFFLINE_REGION_MAX_LOGICAL_BYTES, "limits.maxLogicalBytes"),
|
|
823
|
+
maxStringBytes: tightened(record.maxStringBytes, DEFAULT_OFFLINE_REGION_MAX_STRING_BYTES, "limits.maxStringBytes"),
|
|
824
|
+
maxAttributions: tightened(record.maxAttributions, DEFAULT_OFFLINE_REGION_MAX_ATTRIBUTIONS, "limits.maxAttributions"),
|
|
825
|
+
maxMetadataEntries: tightened(record.maxMetadataEntries, DEFAULT_OFFLINE_REGION_MAX_METADATA_ENTRIES, "limits.maxMetadataEntries"),
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
function defaultLimits() {
|
|
829
|
+
return {
|
|
830
|
+
maxResources: DEFAULT_OFFLINE_REGION_MAX_RESOURCES,
|
|
831
|
+
maxLogicalBytes: DEFAULT_OFFLINE_REGION_MAX_LOGICAL_BYTES,
|
|
832
|
+
maxStringBytes: DEFAULT_OFFLINE_REGION_MAX_STRING_BYTES,
|
|
833
|
+
maxAttributions: DEFAULT_OFFLINE_REGION_MAX_ATTRIBUTIONS,
|
|
834
|
+
maxMetadataEntries: DEFAULT_OFFLINE_REGION_MAX_METADATA_ENTRIES,
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
function tightened(value, ceiling, path) {
|
|
838
|
+
return Math.min(value === undefined ? ceiling : nonNegativeInteger(value, path), ceiling);
|
|
839
|
+
}
|
|
840
|
+
function plainRecord(value, path) {
|
|
841
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
842
|
+
invalid(`${path} must be a plain object.`, path);
|
|
843
|
+
const prototype = Object.getPrototypeOf(value);
|
|
844
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
845
|
+
invalid(`${path} must be a plain object.`, path);
|
|
846
|
+
if (Object.getOwnPropertySymbols(value).length > 0)
|
|
847
|
+
invalid(`${path} must not contain symbol properties.`, path);
|
|
848
|
+
for (const key in value) {
|
|
849
|
+
if (!Object.hasOwn(value, key))
|
|
850
|
+
continue;
|
|
851
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
852
|
+
if (!descriptor || !("value" in descriptor))
|
|
853
|
+
invalid(`${path}.${key} must be a data property.`, `${path}.${key}`);
|
|
854
|
+
}
|
|
855
|
+
return value;
|
|
856
|
+
}
|
|
857
|
+
function allowedKeys(record, allowed, path) {
|
|
858
|
+
for (const key in record) {
|
|
859
|
+
if (Object.hasOwn(record, key) && !allowed.has(key))
|
|
860
|
+
invalid(`${path}.${key} is not supported.`, `${path}.${key}`);
|
|
861
|
+
}
|
|
862
|
+
for (const key of allowed) {
|
|
863
|
+
const descriptor = Object.getOwnPropertyDescriptor(record, key);
|
|
864
|
+
if (descriptor && (!descriptor.enumerable || !("value" in descriptor))) {
|
|
865
|
+
invalid(`${path}.${key} must be an enumerable data property.`, `${path}.${key}`);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
function denseArray(value, path, maximum) {
|
|
870
|
+
if (!Array.isArray(value))
|
|
871
|
+
invalid(`${path} must be an array.`, path);
|
|
872
|
+
if (Object.getPrototypeOf(value) !== Array.prototype || Object.getOwnPropertySymbols(value).length > 0) {
|
|
873
|
+
invalid(`${path} must be a plain array.`, path);
|
|
874
|
+
}
|
|
875
|
+
if (value.length > maximum)
|
|
876
|
+
limit(`${path} contains ${value.length} entries; maximum is ${maximum}.`, path);
|
|
877
|
+
for (const key in value) {
|
|
878
|
+
if (!Object.hasOwn(value, key))
|
|
879
|
+
continue;
|
|
880
|
+
const index = Number(key);
|
|
881
|
+
if (!Number.isSafeInteger(index) || index < 0 || index >= value.length || String(index) !== key) {
|
|
882
|
+
invalid(`${path} must not contain named properties.`, `${path}.${key}`);
|
|
883
|
+
}
|
|
884
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
885
|
+
if (!descriptor || !("value" in descriptor))
|
|
886
|
+
invalid(`${path}[${key}] must be a data property.`, `${path}[${key}]`);
|
|
887
|
+
}
|
|
888
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
889
|
+
if (!Object.hasOwn(value, index))
|
|
890
|
+
invalid(`${path} must not contain sparse entries.`, `${path}[${index}]`);
|
|
891
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, index);
|
|
892
|
+
if (!descriptor || !("value" in descriptor)) {
|
|
893
|
+
invalid(`${path}[${index}] must be a data property.`, `${path}[${index}]`);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
return value;
|
|
897
|
+
}
|
|
898
|
+
function ownKeys(record) {
|
|
899
|
+
const keys = [];
|
|
900
|
+
for (const key in record)
|
|
901
|
+
if (Object.hasOwn(record, key))
|
|
902
|
+
keys.push(key);
|
|
903
|
+
return keys;
|
|
904
|
+
}
|
|
905
|
+
function credentialFreeEndpoint(value, budget, normalizedRequired) {
|
|
906
|
+
if (typeof value !== "string" && !(value instanceof URL))
|
|
907
|
+
invalid("endpoint must be a string or URL.", "endpoint");
|
|
908
|
+
const raw = value instanceof URL ? value.href : value;
|
|
909
|
+
budget.string(raw, "endpoint", false);
|
|
910
|
+
try {
|
|
911
|
+
const normalized = normalizeDiscoveryEndpoint(value);
|
|
912
|
+
if (normalizedRequired && raw !== normalized)
|
|
913
|
+
invalid("endpoint contains credentials or is not normalized.", "endpoint");
|
|
914
|
+
return normalized;
|
|
915
|
+
}
|
|
916
|
+
catch (cause) {
|
|
917
|
+
if (cause instanceof HonuaOfflineRegionError)
|
|
918
|
+
throw cause;
|
|
919
|
+
throw new HonuaOfflineRegionError("invalid-manifest", "endpoint must be a valid absolute URL.", {
|
|
920
|
+
cause,
|
|
921
|
+
path: "endpoint",
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
function integrityString(value, path, budget, normalizedRequired) {
|
|
926
|
+
const normalized = budget.string(value, path, normalizedRequired);
|
|
927
|
+
if (!INTEGRITY_PATTERN.test(normalized))
|
|
928
|
+
invalid(`${path} must be a lowercase SHA-256 digest.`, path);
|
|
929
|
+
return normalized;
|
|
930
|
+
}
|
|
931
|
+
function normalizeTimestamp(value, path, normalizedRequired, budget) {
|
|
932
|
+
const raw = budget ? budget.string(value, path, normalizedRequired) : typeof value === "string" ? value : "";
|
|
933
|
+
if (raw.length === 0)
|
|
934
|
+
invalid(`${path} must be an ISO-8601 timestamp.`, path);
|
|
935
|
+
const date = new Date(raw);
|
|
936
|
+
if (!Number.isFinite(date.getTime()))
|
|
937
|
+
invalid(`${path} must be an ISO-8601 timestamp.`, path);
|
|
938
|
+
const normalized = date.toISOString();
|
|
939
|
+
if (normalizedRequired && normalized !== raw)
|
|
940
|
+
invalid(`${path} is not a normalized ISO-8601 timestamp.`, path);
|
|
941
|
+
return normalized;
|
|
942
|
+
}
|
|
943
|
+
function normalizeZoom(value, path) {
|
|
944
|
+
if (value === undefined)
|
|
945
|
+
return undefined;
|
|
946
|
+
const zoom = nonNegativeInteger(value, path);
|
|
947
|
+
if (zoom > 30)
|
|
948
|
+
invalid(`${path} must be between 0 and 30.`, path);
|
|
949
|
+
return zoom;
|
|
950
|
+
}
|
|
951
|
+
function finiteNumber(value, path) {
|
|
952
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
953
|
+
invalid(`${path} must be finite.`, path);
|
|
954
|
+
return value;
|
|
955
|
+
}
|
|
956
|
+
function nonNegativeInteger(value, path) {
|
|
957
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
958
|
+
invalid(`${path} must be a non-negative safe integer.`, path);
|
|
959
|
+
}
|
|
960
|
+
return value;
|
|
961
|
+
}
|
|
962
|
+
function requiredBoolean(value, path) {
|
|
963
|
+
if (typeof value !== "boolean")
|
|
964
|
+
invalid(`${path} must be a boolean.`, path);
|
|
965
|
+
return value;
|
|
966
|
+
}
|
|
967
|
+
function safeAdd(left, right, path) {
|
|
968
|
+
const result = left + right;
|
|
969
|
+
if (!Number.isSafeInteger(result))
|
|
970
|
+
invalid(`${path} logical-byte accounting exceeds Number.MAX_SAFE_INTEGER.`, path);
|
|
971
|
+
return result;
|
|
972
|
+
}
|
|
973
|
+
function isExpired(region, nowMs) {
|
|
974
|
+
return region.expiresAt !== undefined && Date.parse(region.expiresAt) <= nowMs;
|
|
975
|
+
}
|
|
976
|
+
function throwIfAborted(signal) {
|
|
977
|
+
if (signal?.aborted) {
|
|
978
|
+
throw new HonuaOfflineRegionError("aborted", "Offline region download was aborted.", { cause: signal.reason });
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
function emit(options, progress) {
|
|
982
|
+
try {
|
|
983
|
+
options.onProgress?.(deepFreeze(progress));
|
|
984
|
+
}
|
|
985
|
+
catch {
|
|
986
|
+
// Progress is observational and must not influence atomic transaction state.
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
function invalid(message, path) {
|
|
990
|
+
throw new HonuaOfflineRegionError("invalid-manifest", message, { path });
|
|
991
|
+
}
|
|
992
|
+
function limit(message, path) {
|
|
993
|
+
throw new HonuaOfflineRegionError("resource-limit-exceeded", message, { path });
|
|
994
|
+
}
|
|
995
|
+
function throwStructured(cause, message) {
|
|
996
|
+
if (cause instanceof HonuaOfflineRegionError)
|
|
997
|
+
throw cause;
|
|
998
|
+
throw new HonuaOfflineRegionError("invalid-manifest", message, { cause });
|
|
999
|
+
}
|
|
1000
|
+
function deepFreeze(value) {
|
|
1001
|
+
if (typeof value !== "object" || value === null || Object.isFrozen(value))
|
|
1002
|
+
return value;
|
|
1003
|
+
Object.freeze(value);
|
|
1004
|
+
for (const child of Object.values(value))
|
|
1005
|
+
deepFreeze(child);
|
|
1006
|
+
return value;
|
|
1007
|
+
}
|