@honua/app-platform 0.1.11-beta.0 → 0.1.13
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/control-plane/generated/admin-operations.d.ts +3 -3
- package/control-plane/generated/admin-operations.d.ts.map +1 -1
- package/control-plane/generated/admin-operations.js +3 -3
- 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 +10 -6
- 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/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,252 @@
|
|
|
1
|
+
import { HonuaCapabilityNotSupportedError } from "../core/errors.js";
|
|
2
|
+
import { sha256 } from "./digest.js";
|
|
3
|
+
import { verifyOfflineRegionManifest } from "./region.js";
|
|
4
|
+
import { OFFLINE_REGION_PROTOCOL, canonicalizeOfflineRegionQuery, compareOfflineRegionBounds, offlineRegionAuthorizationScopeDigest, offlineRegionQueryFingerprint, offlineRegionResourceId, } from "./selection.js";
|
|
5
|
+
import { HonuaOfflineRegionError, } from "./types.js";
|
|
6
|
+
/**
|
|
7
|
+
* The one gate every offline read passes through.
|
|
8
|
+
*
|
|
9
|
+
* Feature batches, tiles, assets, and metadata documents are different payloads
|
|
10
|
+
* answering different questions, but they are admitted by exactly the same
|
|
11
|
+
* discipline and in exactly the same order: region identity, then the caller's
|
|
12
|
+
* authorization scope, then version expectations, then extent, then expiry, then
|
|
13
|
+
* content-addressed lookup, then integrity. Keeping that order in one module is
|
|
14
|
+
* what makes "the narrowest true reason" a property of the contract rather than
|
|
15
|
+
* of whichever read path a caller happened to use.
|
|
16
|
+
*
|
|
17
|
+
* @experimental
|
|
18
|
+
*/
|
|
19
|
+
export const HONUA_OFFLINE_REGION_READ_KIND = "honua.offline-region-read";
|
|
20
|
+
export const HONUA_OFFLINE_REGION_READ_VERSION = "1.0";
|
|
21
|
+
/** Default age at which a region's observation is reported stale. */
|
|
22
|
+
export const DEFAULT_OFFLINE_REGION_READ_STALE_AFTER_MS = 15 * 60 * 1000;
|
|
23
|
+
/**
|
|
24
|
+
* Admit a read against a persisted region, or fail with the narrowest true reason.
|
|
25
|
+
*
|
|
26
|
+
* Nothing here touches stored bytes: every failure this raises is knowable from
|
|
27
|
+
* the manifest and the caller's own inputs, so an inadmissible read never causes
|
|
28
|
+
* a storage access at all.
|
|
29
|
+
*/
|
|
30
|
+
export async function openOfflineRegionRead(inputManifest, options) {
|
|
31
|
+
throwIfAborted(options.signal);
|
|
32
|
+
const manifest = await verifyOfflineRegionManifest(inputManifest);
|
|
33
|
+
const sourceId = manifest.source.id;
|
|
34
|
+
const authorizationScopeDigest = await offlineRegionAuthorizationScopeDigest(options.authorizationScopeFingerprint);
|
|
35
|
+
if (authorizationScopeDigest !== manifest.source.authorizationScopeDigest) {
|
|
36
|
+
// The single most important invariant: a scope change never reads another
|
|
37
|
+
// principal's cached bytes, and the failure names no scope material.
|
|
38
|
+
throw new HonuaOfflineRegionError("scope-mismatch", "Offline region belongs to a different authorization scope than this read.", { path: "authorizationScopeFingerprint" });
|
|
39
|
+
}
|
|
40
|
+
assertExpectedVersion(options.expect?.sourceVersion, manifest.source.sourceVersion, "sourceVersion");
|
|
41
|
+
assertExpectedVersion(options.expect?.schemaVersion, manifest.source.schemaVersion, "schemaVersion");
|
|
42
|
+
assertExpectedVersion(options.expect?.planVersion, manifest.source.planVersion, "planVersion");
|
|
43
|
+
if (options.bounds !== undefined) {
|
|
44
|
+
const relation = compareOfflineRegionBounds(options.bounds, manifest.bounds);
|
|
45
|
+
if (relation === "outside") {
|
|
46
|
+
throw new HonuaOfflineRegionError("out-of-region", "Requested extent is not covered by this offline region's bounds.", { path: "bounds" });
|
|
47
|
+
}
|
|
48
|
+
if (relation === "contained") {
|
|
49
|
+
// Narrowing to a sub-extent means evaluating a spatial predicate over the
|
|
50
|
+
// stored payload. Returning the wider snapshot instead would silently widen
|
|
51
|
+
// the caller's question, so the read refuses rather than approximates.
|
|
52
|
+
throw new HonuaCapabilityNotSupportedError("querySubExtent", OFFLINE_REGION_PROTOCOL, sourceId);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const scope = canonicalizeOfflineRegionQuery(options.query, sourceId);
|
|
56
|
+
const queryFingerprint = await offlineRegionQueryFingerprint(scope.canonical);
|
|
57
|
+
const now = (options.now ?? (() => new Date()))();
|
|
58
|
+
const nowMs = now.getTime();
|
|
59
|
+
if (!Number.isFinite(nowMs))
|
|
60
|
+
throw invalid("now must produce a valid Date.", "now");
|
|
61
|
+
if (manifest.expiresAt !== undefined && Date.parse(manifest.expiresAt) <= nowMs) {
|
|
62
|
+
throw new HonuaOfflineRegionError("expired", `Offline region "${manifest.id}" has expired.`, { path: "expiresAt" });
|
|
63
|
+
}
|
|
64
|
+
const observedAtMs = Date.parse(manifest.source.observation.observedAt);
|
|
65
|
+
const ageMs = Number.isFinite(observedAtMs) ? Math.max(0, nowMs - observedAtMs) : 0;
|
|
66
|
+
const staleAfterMs = normalizeStaleAfterMs(options.staleAfterMs);
|
|
67
|
+
return {
|
|
68
|
+
manifest,
|
|
69
|
+
sourceId,
|
|
70
|
+
authorizationScopeDigest,
|
|
71
|
+
selection: {
|
|
72
|
+
sourceId,
|
|
73
|
+
endpoint: manifest.source.endpoint,
|
|
74
|
+
authorizationScopeDigest,
|
|
75
|
+
sourceVersion: manifest.source.sourceVersion,
|
|
76
|
+
schemaVersion: manifest.source.schemaVersion,
|
|
77
|
+
planVersion: manifest.source.planVersion,
|
|
78
|
+
bounds: manifest.bounds,
|
|
79
|
+
...(manifest.minZoom !== undefined ? { minZoom: manifest.minZoom } : {}),
|
|
80
|
+
...(manifest.maxZoom !== undefined ? { maxZoom: manifest.maxZoom } : {}),
|
|
81
|
+
},
|
|
82
|
+
queryFingerprint,
|
|
83
|
+
pagination: scope.pagination,
|
|
84
|
+
now,
|
|
85
|
+
ageMs,
|
|
86
|
+
staleAfterMs,
|
|
87
|
+
freshness: ageMs >= staleAfterMs ? "stale" : "fresh",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Resolve a resource identity against the store and verify what comes back.
|
|
92
|
+
*
|
|
93
|
+
* The identity is content-addressed from the admitted selection, so a lookup can
|
|
94
|
+
* only ever find bytes stored for exactly this selection, kind, and selector; an
|
|
95
|
+
* absent entry is a miss rather than an empty success, and a stored payload that
|
|
96
|
+
* disagrees with its descriptor is an integrity failure rather than data.
|
|
97
|
+
*/
|
|
98
|
+
export async function readGatedOfflineRegionResource(gate, options) {
|
|
99
|
+
const resourceId = await offlineRegionResourceId({
|
|
100
|
+
kind: options.kind,
|
|
101
|
+
selection: gate.selection,
|
|
102
|
+
queryFingerprint: gate.queryFingerprint,
|
|
103
|
+
...(options.selector !== undefined ? { selector: options.selector } : {}),
|
|
104
|
+
});
|
|
105
|
+
throwIfAborted(options.signal);
|
|
106
|
+
let read;
|
|
107
|
+
try {
|
|
108
|
+
read = await options.store.readResource(gate.manifest.id, resourceId);
|
|
109
|
+
}
|
|
110
|
+
catch (cause) {
|
|
111
|
+
if (cause instanceof HonuaOfflineRegionError)
|
|
112
|
+
throw cause;
|
|
113
|
+
throw new HonuaOfflineRegionError("store-failed", "Failed to read the offline region resource.", { cause });
|
|
114
|
+
}
|
|
115
|
+
if (!read) {
|
|
116
|
+
throw new HonuaOfflineRegionError("cache-miss", "This offline region holds no stored answer for the requested selection.", { resourceId });
|
|
117
|
+
}
|
|
118
|
+
if (read.regionId !== gate.manifest.id || read.resource.id !== resourceId) {
|
|
119
|
+
throw new HonuaOfflineRegionError("store-failed", "Offline region store returned a foreign resource.", {
|
|
120
|
+
resourceId,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
if (read.resource.kind !== options.kind) {
|
|
124
|
+
throw new HonuaOfflineRegionError("cache-miss", `Stored resource for this selection is not a ${options.kind} resource.`, { resourceId });
|
|
125
|
+
}
|
|
126
|
+
const bytes = read.bytes;
|
|
127
|
+
if (bytes.byteLength !== read.resource.byteLength || (await sha256(bytes)) !== read.resource.integrity) {
|
|
128
|
+
throw new HonuaOfflineRegionError("integrity-mismatch", `Stored resource "${resourceId}" failed verification.`, {
|
|
129
|
+
resourceId,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return { resource: read.resource, bytes };
|
|
133
|
+
}
|
|
134
|
+
/** Report the persistent-cache decision for one admitted read. */
|
|
135
|
+
export function createOfflineRegionReadCacheDecision(gate, resources, completeness) {
|
|
136
|
+
return {
|
|
137
|
+
kind: HONUA_OFFLINE_REGION_READ_KIND,
|
|
138
|
+
version: HONUA_OFFLINE_REGION_READ_VERSION,
|
|
139
|
+
policy: "prefer-cache",
|
|
140
|
+
action: "reuse",
|
|
141
|
+
state: "offline",
|
|
142
|
+
freshness: gate.freshness,
|
|
143
|
+
completeness,
|
|
144
|
+
reason: gate.freshness === "stale" ? "stale-entry" : "offline-entry",
|
|
145
|
+
regionId: gate.manifest.id,
|
|
146
|
+
queryFingerprint: gate.queryFingerprint,
|
|
147
|
+
authorizationScopeDigest: gate.authorizationScopeDigest,
|
|
148
|
+
observedAt: gate.manifest.source.observation.observedAt,
|
|
149
|
+
ageMs: gate.ageMs,
|
|
150
|
+
staleAfterMs: gate.staleAfterMs,
|
|
151
|
+
resources: resources.map((resource) => ({
|
|
152
|
+
id: resource.id,
|
|
153
|
+
kind: resource.kind,
|
|
154
|
+
byteLength: resource.byteLength,
|
|
155
|
+
integrity: resource.integrity,
|
|
156
|
+
})),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
export function createOfflineRegionReadProvenance(gate) {
|
|
160
|
+
const manifest = gate.manifest;
|
|
161
|
+
return {
|
|
162
|
+
sourceId: gate.sourceId,
|
|
163
|
+
endpoint: manifest.source.endpoint,
|
|
164
|
+
authorizationScopeDigest: gate.authorizationScopeDigest,
|
|
165
|
+
sourceVersion: manifest.source.sourceVersion,
|
|
166
|
+
schemaVersion: manifest.source.schemaVersion,
|
|
167
|
+
planVersion: manifest.source.planVersion,
|
|
168
|
+
observation: manifest.source.observation,
|
|
169
|
+
...(manifest.source.validator ? { validator: manifest.source.validator } : {}),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Every offline answer is a snapshot, so every offline answer says so.
|
|
174
|
+
*
|
|
175
|
+
* `capability` names the canonical capability the cached bytes stand in for — a
|
|
176
|
+
* tile read stands in for `tiles`, a rendering asset for `render`, and a feature
|
|
177
|
+
* or metadata read for `query` — so a consumer that checks degradation before
|
|
178
|
+
* treating an answer as authoritative learns which live capability it replaced.
|
|
179
|
+
*/
|
|
180
|
+
export function offlineRegionCachedSnapshotDegradation(gate, capability) {
|
|
181
|
+
const manifest = gate.manifest;
|
|
182
|
+
return {
|
|
183
|
+
capability,
|
|
184
|
+
reason: `Served from persisted offline region ${manifest.id} observed at ${manifest.source.observation.observedAt} (${gate.freshness}, ${gate.ageMs}ms old). This is a cached snapshot, not a live read.`,
|
|
185
|
+
sourceId: gate.sourceId,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/** Attribution for exactly the resources that answered a read. */
|
|
189
|
+
export function pickOfflineRegionAttribution(manifest, resources) {
|
|
190
|
+
const out = {};
|
|
191
|
+
for (const resource of resources) {
|
|
192
|
+
for (const id of resource.attributionIds) {
|
|
193
|
+
const text = manifest.attribution[id];
|
|
194
|
+
if (typeof text === "string")
|
|
195
|
+
out[id] = text;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Cache input binding a query plan to a persisted region.
|
|
202
|
+
*
|
|
203
|
+
* A fresh region carries its manifest identity as a `fingerprint` validator, so
|
|
204
|
+
* the plan's own fingerprint changes when the region does. A stale region carries
|
|
205
|
+
* freshness only: reporting a validator would claim a revalidation the offline
|
|
206
|
+
* read will never perform.
|
|
207
|
+
*/
|
|
208
|
+
export function createOfflineRegionQueryPlanCache(manifest, options = {}) {
|
|
209
|
+
const nowMs = (options.now ?? new Date()).getTime();
|
|
210
|
+
const observedAtMs = Date.parse(manifest.source?.observation?.observedAt ?? "");
|
|
211
|
+
const staleAfterMs = normalizeStaleAfterMs(options.staleAfterMs);
|
|
212
|
+
const ageMs = Number.isFinite(observedAtMs) && Number.isFinite(nowMs) ? Math.max(0, nowMs - observedAtMs) : 0;
|
|
213
|
+
if (ageMs >= staleAfterMs)
|
|
214
|
+
return { policy: "prefer-cache", freshness: "stale" };
|
|
215
|
+
return {
|
|
216
|
+
policy: "prefer-cache",
|
|
217
|
+
freshness: "fresh",
|
|
218
|
+
validator: { kind: "fingerprint", fingerprint: manifest.id },
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
export function assertExpectedVersion(expected, actual, path) {
|
|
222
|
+
if (expected === undefined || expected === actual)
|
|
223
|
+
return;
|
|
224
|
+
throw new HonuaOfflineRegionError("out-of-region", `Offline region was captured at a different ${path} than this read expects.`, { path });
|
|
225
|
+
}
|
|
226
|
+
export function normalizeStaleAfterMs(value) {
|
|
227
|
+
if (value === undefined)
|
|
228
|
+
return DEFAULT_OFFLINE_REGION_READ_STALE_AFTER_MS;
|
|
229
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
230
|
+
throw invalid("staleAfterMs must be a non-negative safe integer.", "staleAfterMs");
|
|
231
|
+
}
|
|
232
|
+
return value;
|
|
233
|
+
}
|
|
234
|
+
export function throwIfAborted(signal) {
|
|
235
|
+
if (signal?.aborted) {
|
|
236
|
+
throw new HonuaOfflineRegionError("aborted", "Offline region read was aborted.", { cause: signal.reason });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
export function invalid(message, path) {
|
|
240
|
+
return new HonuaOfflineRegionError("invalid-manifest", message, { path });
|
|
241
|
+
}
|
|
242
|
+
/** Freeze a returned read. Typed arrays are caller-owned copies and stay writable. */
|
|
243
|
+
export function deepFreezeRead(value) {
|
|
244
|
+
if (typeof value !== "object" || value === null || Object.isFrozen(value))
|
|
245
|
+
return value;
|
|
246
|
+
if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer)
|
|
247
|
+
return value;
|
|
248
|
+
Object.freeze(value);
|
|
249
|
+
for (const child of Object.values(value))
|
|
250
|
+
deepFreezeRead(child);
|
|
251
|
+
return value;
|
|
252
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { QueryTileKey, QueryTileKeyInput } from "../contract/tiles.js";
|
|
2
|
+
import type { Capability, DegradedReason } from "../contract/types.js";
|
|
3
|
+
import type { QueryPlanCacheOptions } from "../query-planner/types.js";
|
|
4
|
+
import { HONUA_OFFLINE_REGION_READ_KIND, HONUA_OFFLINE_REGION_READ_VERSION, type OfflineRegionReadCacheDecisionV1, type OfflineRegionReadGateOptions, type OfflineRegionReadProvenanceV1, type OfflineRegionStoreReadOptions } from "./read-gate.js";
|
|
5
|
+
import { type OfflineRegionResourceSelector, type OfflineRegionTileSelectorOptions } from "./selection.js";
|
|
6
|
+
import { type OfflineRegionManifestV1, type OfflineRegionResourceKind } from "./types.js";
|
|
7
|
+
/** Options shared by every non-feature read. */
|
|
8
|
+
export interface ReadOfflineRegionResourceOptions extends OfflineRegionStoreReadOptions {
|
|
9
|
+
readonly kind: OfflineRegionResourceKind;
|
|
10
|
+
readonly selector?: OfflineRegionResourceSelector;
|
|
11
|
+
/** Canonical capability the cached bytes stand in for, reported on `degraded`. */
|
|
12
|
+
readonly capability?: Capability;
|
|
13
|
+
}
|
|
14
|
+
/** One opaque payload served from a persisted region. */
|
|
15
|
+
export interface OfflineRegionResourceReadV1 {
|
|
16
|
+
readonly kind: typeof HONUA_OFFLINE_REGION_READ_KIND;
|
|
17
|
+
readonly version: typeof HONUA_OFFLINE_REGION_READ_VERSION;
|
|
18
|
+
readonly regionId: `sha256:${string}`;
|
|
19
|
+
readonly resourceKind: OfflineRegionResourceKind;
|
|
20
|
+
readonly resourceId: string;
|
|
21
|
+
/** Caller-owned bytes exactly as they were persisted; never re-encoded. */
|
|
22
|
+
readonly bytes: Uint8Array;
|
|
23
|
+
readonly contentType?: string;
|
|
24
|
+
readonly cache: OfflineRegionReadCacheDecisionV1;
|
|
25
|
+
readonly provenance: OfflineRegionReadProvenanceV1;
|
|
26
|
+
readonly attribution: Readonly<Record<string, string>>;
|
|
27
|
+
/**
|
|
28
|
+
* Same never-silently-live discipline a `Result` carries, in the same
|
|
29
|
+
* vocabulary. A binary read has no `Result` to stamp, so the degradation
|
|
30
|
+
* travels here.
|
|
31
|
+
*/
|
|
32
|
+
readonly degraded: readonly DegradedReason[];
|
|
33
|
+
readonly planCache: QueryPlanCacheOptions;
|
|
34
|
+
}
|
|
35
|
+
/** A metadata document, plus its parsed form when it declares a JSON media type. */
|
|
36
|
+
export interface OfflineRegionMetadataReadV1 extends OfflineRegionResourceReadV1 {
|
|
37
|
+
readonly resourceKind: "metadata";
|
|
38
|
+
/** Present only for a JSON media type; a non-JSON document stays bytes. */
|
|
39
|
+
readonly document?: unknown;
|
|
40
|
+
}
|
|
41
|
+
/** A tile payload, plus the canonical coordinate that addressed it. */
|
|
42
|
+
export interface OfflineRegionTileReadV1 extends OfflineRegionResourceReadV1 {
|
|
43
|
+
readonly resourceKind: "tile";
|
|
44
|
+
readonly tile: QueryTileKey;
|
|
45
|
+
}
|
|
46
|
+
export interface ReadOfflineRegionTileOptions extends OfflineRegionStoreReadOptions, OfflineRegionTileSelectorOptions {
|
|
47
|
+
readonly tile: QueryTileKeyInput;
|
|
48
|
+
}
|
|
49
|
+
export interface ReadOfflineRegionAssetOptions extends OfflineRegionStoreReadOptions {
|
|
50
|
+
/** Stable asset key: a style id, glyph range, sprite name, or attachment id. */
|
|
51
|
+
readonly asset: string;
|
|
52
|
+
/** Canonical capability the cached bytes stand in for. Defaults to `render`. */
|
|
53
|
+
readonly capability?: Capability;
|
|
54
|
+
}
|
|
55
|
+
export interface ReadOfflineRegionMetadataOptions extends OfflineRegionStoreReadOptions {
|
|
56
|
+
/** Stable document name: a descriptor id, `landing-page`, `collections`, and so on. */
|
|
57
|
+
readonly document: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the content-addressed identity of one resource without reading it.
|
|
61
|
+
*
|
|
62
|
+
* This is what a host's own {@link OfflineRegionResourceMatcher} calls: the host
|
|
63
|
+
* parses its request URL into a tile key, asset key, or document name, asks for
|
|
64
|
+
* the identity, and hands it to the existing storage-backed fetch handler. The
|
|
65
|
+
* region is still verified, scoped, and expiry-checked here, so a matcher can
|
|
66
|
+
* never produce an identity for a region the caller may not read.
|
|
67
|
+
*/
|
|
68
|
+
export declare function resolveOfflineRegionResourceId(inputManifest: OfflineRegionManifestV1, options: OfflineRegionReadGateOptions & {
|
|
69
|
+
readonly kind: OfflineRegionResourceKind;
|
|
70
|
+
readonly selector?: OfflineRegionResourceSelector;
|
|
71
|
+
}): Promise<string>;
|
|
72
|
+
/**
|
|
73
|
+
* Read one stored resource of any kind from a persisted region.
|
|
74
|
+
*
|
|
75
|
+
* This is the primitive the tile, asset, and metadata reads are written in terms
|
|
76
|
+
* of; use it directly for the `attribution` kind or for a caller-defined selector.
|
|
77
|
+
*/
|
|
78
|
+
export declare function readOfflineRegionResource(inputManifest: OfflineRegionManifestV1, options: ReadOfflineRegionResourceOptions): Promise<OfflineRegionResourceReadV1>;
|
|
79
|
+
/**
|
|
80
|
+
* Read one tile from a persisted region.
|
|
81
|
+
*
|
|
82
|
+
* Two coverage facts are knowable before any lookup and are reported as such: a
|
|
83
|
+
* zoom outside the region's own `minZoom`/`maxZoom`, and — when the region's CRS
|
|
84
|
+
* is provably WGS84 lon/lat — a tile whose envelope does not meet the region's
|
|
85
|
+
* bounds. Both are `out-of-region`, which is a truer answer than the cache miss
|
|
86
|
+
* that would otherwise follow, and neither touches storage.
|
|
87
|
+
*/
|
|
88
|
+
export declare function readOfflineRegionTile(inputManifest: OfflineRegionManifestV1, options: ReadOfflineRegionTileOptions): Promise<OfflineRegionTileReadV1>;
|
|
89
|
+
/** Read one opaque asset — a style document, glyph range, sprite sheet, or attachment. */
|
|
90
|
+
export declare function readOfflineRegionAsset(inputManifest: OfflineRegionManifestV1, options: ReadOfflineRegionAssetOptions): Promise<OfflineRegionResourceReadV1>;
|
|
91
|
+
/**
|
|
92
|
+
* Read one metadata document — a source descriptor or a discovery response.
|
|
93
|
+
*
|
|
94
|
+
* A document whose stored media type declares JSON is parsed as well as returned;
|
|
95
|
+
* bytes that verified against their digest but cannot be parsed as the media type
|
|
96
|
+
* they declare are an integrity failure, not a document.
|
|
97
|
+
*/
|
|
98
|
+
export declare function readOfflineRegionMetadata(inputManifest: OfflineRegionManifestV1, options: ReadOfflineRegionMetadataOptions): Promise<OfflineRegionMetadataReadV1>;
|
|
99
|
+
//# sourceMappingURL=read-resource.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"read-resource.d.ts","sourceRoot":"","sources":["../../../src/offline/read-resource.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { HONUA_OFFLINE_REGION_READ_KIND, HONUA_OFFLINE_REGION_READ_VERSION, createOfflineRegionQueryPlanCache, createOfflineRegionReadCacheDecision, createOfflineRegionReadProvenance, deepFreezeRead, offlineRegionCachedSnapshotDegradation, openOfflineRegionRead, pickOfflineRegionAttribution, readGatedOfflineRegionResource, } from "./read-gate.js";
|
|
2
|
+
import { isWgs84LonLatCrs, normalizeOfflineRegionTileKey, offlineRegionAssetSelector, offlineRegionMetadataSelector, offlineRegionResourceId, offlineRegionTileEnvelope, offlineRegionTileSelector, } from "./selection.js";
|
|
3
|
+
import { HonuaOfflineRegionError } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Serving tiles, assets, and metadata documents from a persisted region.
|
|
6
|
+
*
|
|
7
|
+
* These are the read kinds the region model has always stored but nothing could
|
|
8
|
+
* read back: raster and vector tiles, style/glyph/sprite and attachment assets,
|
|
9
|
+
* and descriptor or discovery documents. They pass the same gate a feature read
|
|
10
|
+
* does — region identity, authorization scope, versions, extent, expiry,
|
|
11
|
+
* content-addressed lookup, integrity — and they answer with opaque bytes and
|
|
12
|
+
* their declared media type. The SDK never re-encodes a tile or an asset, and
|
|
13
|
+
* never synthesizes one it does not hold.
|
|
14
|
+
*
|
|
15
|
+
* @experimental
|
|
16
|
+
*/
|
|
17
|
+
const JSON_MEDIA_TYPES = new Set([
|
|
18
|
+
"application/json",
|
|
19
|
+
"application/geo+json",
|
|
20
|
+
"application/schema+json",
|
|
21
|
+
"application/vnd.mapbox-vector-tile+json",
|
|
22
|
+
"text/json",
|
|
23
|
+
]);
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the content-addressed identity of one resource without reading it.
|
|
26
|
+
*
|
|
27
|
+
* This is what a host's own {@link OfflineRegionResourceMatcher} calls: the host
|
|
28
|
+
* parses its request URL into a tile key, asset key, or document name, asks for
|
|
29
|
+
* the identity, and hands it to the existing storage-backed fetch handler. The
|
|
30
|
+
* region is still verified, scoped, and expiry-checked here, so a matcher can
|
|
31
|
+
* never produce an identity for a region the caller may not read.
|
|
32
|
+
*/
|
|
33
|
+
export async function resolveOfflineRegionResourceId(inputManifest, options) {
|
|
34
|
+
const gate = await openOfflineRegionRead(inputManifest, options);
|
|
35
|
+
return offlineRegionResourceId({
|
|
36
|
+
kind: options.kind,
|
|
37
|
+
selection: gate.selection,
|
|
38
|
+
queryFingerprint: gate.queryFingerprint,
|
|
39
|
+
...(options.selector !== undefined ? { selector: options.selector } : {}),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Read one stored resource of any kind from a persisted region.
|
|
44
|
+
*
|
|
45
|
+
* This is the primitive the tile, asset, and metadata reads are written in terms
|
|
46
|
+
* of; use it directly for the `attribution` kind or for a caller-defined selector.
|
|
47
|
+
*/
|
|
48
|
+
export async function readOfflineRegionResource(inputManifest, options) {
|
|
49
|
+
const gate = await openOfflineRegionRead(inputManifest, options);
|
|
50
|
+
return completeResourceRead(gate, options, options.kind, options.capability ?? "query");
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Read one tile from a persisted region.
|
|
54
|
+
*
|
|
55
|
+
* Two coverage facts are knowable before any lookup and are reported as such: a
|
|
56
|
+
* zoom outside the region's own `minZoom`/`maxZoom`, and — when the region's CRS
|
|
57
|
+
* is provably WGS84 lon/lat — a tile whose envelope does not meet the region's
|
|
58
|
+
* bounds. Both are `out-of-region`, which is a truer answer than the cache miss
|
|
59
|
+
* that would otherwise follow, and neither touches storage.
|
|
60
|
+
*/
|
|
61
|
+
export async function readOfflineRegionTile(inputManifest, options) {
|
|
62
|
+
const gate = await openOfflineRegionRead(inputManifest, options);
|
|
63
|
+
const tile = normalizeOfflineRegionTileKey(options.tile, options);
|
|
64
|
+
assertTileWithinRegion(gate, tile);
|
|
65
|
+
const selector = offlineRegionTileSelector(tile, {
|
|
66
|
+
...(options.tileMatrixSetId !== undefined ? { tileMatrixSetId: options.tileMatrixSetId } : {}),
|
|
67
|
+
});
|
|
68
|
+
const read = await completeResourceRead(gate, { ...options, selector }, "tile", "tiles");
|
|
69
|
+
return deepFreezeRead({ ...read, resourceKind: "tile", tile });
|
|
70
|
+
}
|
|
71
|
+
/** Read one opaque asset — a style document, glyph range, sprite sheet, or attachment. */
|
|
72
|
+
export async function readOfflineRegionAsset(inputManifest, options) {
|
|
73
|
+
const gate = await openOfflineRegionRead(inputManifest, options);
|
|
74
|
+
const selector = offlineRegionAssetSelector(options.asset);
|
|
75
|
+
return completeResourceRead(gate, { ...options, selector }, "asset", options.capability ?? "render");
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Read one metadata document — a source descriptor or a discovery response.
|
|
79
|
+
*
|
|
80
|
+
* A document whose stored media type declares JSON is parsed as well as returned;
|
|
81
|
+
* bytes that verified against their digest but cannot be parsed as the media type
|
|
82
|
+
* they declare are an integrity failure, not a document.
|
|
83
|
+
*/
|
|
84
|
+
export async function readOfflineRegionMetadata(inputManifest, options) {
|
|
85
|
+
const gate = await openOfflineRegionRead(inputManifest, options);
|
|
86
|
+
const selector = offlineRegionMetadataSelector(options.document);
|
|
87
|
+
const read = await completeResourceRead(gate, { ...options, selector }, "metadata", "query");
|
|
88
|
+
const document = isJsonMediaType(read.contentType) ? parseJsonDocument(read.bytes, read.resourceId) : undefined;
|
|
89
|
+
return deepFreezeRead({
|
|
90
|
+
...read,
|
|
91
|
+
resourceKind: "metadata",
|
|
92
|
+
...(document === undefined ? {} : { document }),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
async function completeResourceRead(gate, options, kind, capability) {
|
|
96
|
+
const { resource, bytes } = await readGatedOfflineRegionResource(gate, {
|
|
97
|
+
store: options.store,
|
|
98
|
+
kind,
|
|
99
|
+
...(options.selector !== undefined ? { selector: options.selector } : {}),
|
|
100
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
101
|
+
});
|
|
102
|
+
return deepFreezeRead({
|
|
103
|
+
kind: HONUA_OFFLINE_REGION_READ_KIND,
|
|
104
|
+
version: HONUA_OFFLINE_REGION_READ_VERSION,
|
|
105
|
+
regionId: gate.manifest.id,
|
|
106
|
+
resourceKind: kind,
|
|
107
|
+
resourceId: resource.id,
|
|
108
|
+
bytes,
|
|
109
|
+
...(resource.contentType ? { contentType: resource.contentType } : {}),
|
|
110
|
+
// A single opaque payload is either present in full or absent: there is no
|
|
111
|
+
// partial tile, so completeness is never inferred from byte counts.
|
|
112
|
+
cache: createOfflineRegionReadCacheDecision(gate, [resource], "complete"),
|
|
113
|
+
provenance: createOfflineRegionReadProvenance(gate),
|
|
114
|
+
attribution: pickOfflineRegionAttribution(gate.manifest, [resource]),
|
|
115
|
+
degraded: [offlineRegionCachedSnapshotDegradation(gate, capability)],
|
|
116
|
+
planCache: createOfflineRegionQueryPlanCache(gate.manifest, {
|
|
117
|
+
now: gate.now,
|
|
118
|
+
staleAfterMs: gate.staleAfterMs,
|
|
119
|
+
}),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
function assertTileWithinRegion(gate, tile) {
|
|
123
|
+
const manifest = gate.manifest;
|
|
124
|
+
if (manifest.minZoom !== undefined && tile.z < manifest.minZoom) {
|
|
125
|
+
throw new HonuaOfflineRegionError("out-of-region", `Requested zoom ${tile.z} is below this offline region's minimum zoom ${manifest.minZoom}.`, { path: "tile.z" });
|
|
126
|
+
}
|
|
127
|
+
if (manifest.maxZoom !== undefined && tile.z > manifest.maxZoom) {
|
|
128
|
+
throw new HonuaOfflineRegionError("out-of-region", `Requested zoom ${tile.z} is above this offline region's maximum zoom ${manifest.maxZoom}.`, { path: "tile.z" });
|
|
129
|
+
}
|
|
130
|
+
// A CRS the SDK cannot prove is WGS84 lon/lat gets no geometric claim at all;
|
|
131
|
+
// identity still gates the read, so an uncovered tile is simply a miss.
|
|
132
|
+
if (!isWgs84LonLatCrs(manifest.bounds.crs))
|
|
133
|
+
return;
|
|
134
|
+
const envelope = offlineRegionTileEnvelope(tile);
|
|
135
|
+
const disjoint = envelope.maxX <= manifest.bounds.minX ||
|
|
136
|
+
envelope.minX >= manifest.bounds.maxX ||
|
|
137
|
+
envelope.maxY <= manifest.bounds.minY ||
|
|
138
|
+
envelope.minY >= manifest.bounds.maxY;
|
|
139
|
+
if (disjoint) {
|
|
140
|
+
throw new HonuaOfflineRegionError("out-of-region", `Requested tile ${tile.z}/${tile.x}/${tile.y} lies outside this offline region's bounds.`, { path: "tile" });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function isJsonMediaType(contentType) {
|
|
144
|
+
if (!contentType)
|
|
145
|
+
return false;
|
|
146
|
+
const essence = contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
|
147
|
+
return JSON_MEDIA_TYPES.has(essence) || essence.endsWith("+json");
|
|
148
|
+
}
|
|
149
|
+
function parseJsonDocument(bytes, resourceId) {
|
|
150
|
+
try {
|
|
151
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
152
|
+
}
|
|
153
|
+
catch (cause) {
|
|
154
|
+
throw new HonuaOfflineRegionError("integrity-mismatch", `Stored metadata document "${resourceId}" is not valid JSON for its declared media type.`, { cause, resourceId });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Query, Result } from "../contract/types.js";
|
|
2
|
+
import type { QueryPlanCacheOptions } from "../query-planner/types.js";
|
|
3
|
+
import { HONUA_OFFLINE_REGION_READ_KIND, HONUA_OFFLINE_REGION_READ_VERSION, type OfflineRegionReadCacheDecisionV1, type OfflineRegionReadProvenanceV1, type OfflineRegionStoreReadOptions } from "./read-gate.js";
|
|
4
|
+
import type { OfflineRegionResourceSelector } from "./selection.js";
|
|
5
|
+
import { type OfflineRegionManifestV1 } from "./types.js";
|
|
6
|
+
/**
|
|
7
|
+
* Answering a protocol-neutral `Query` from a persisted region.
|
|
8
|
+
*
|
|
9
|
+
* The read path is deliberately narrow. It replays the snapshot a selection
|
|
10
|
+
* captured and refines only the pagination window, because that is the one
|
|
11
|
+
* refinement a stored batch can honour exactly. Everything else fails closed: a
|
|
12
|
+
* construct the region cannot answer raises `HonuaCapabilityNotSupportedError`,
|
|
13
|
+
* and a selection the region does not cover raises a typed cache miss. Nothing
|
|
14
|
+
* here reaches the network, revalidates, or widens a query, and no result is ever
|
|
15
|
+
* presented as live.
|
|
16
|
+
*
|
|
17
|
+
* @experimental
|
|
18
|
+
*/
|
|
19
|
+
export interface ReadOfflineRegionQueryOptions<T = Record<string, unknown>> extends Omit<OfflineRegionStoreReadOptions, "query"> {
|
|
20
|
+
/** Protocol-neutral query intent. Omitted means the snapshot's own selection. */
|
|
21
|
+
readonly query?: Query<T>;
|
|
22
|
+
/** Discriminator when a selection stores several feature batches. */
|
|
23
|
+
readonly selector?: OfflineRegionResourceSelector;
|
|
24
|
+
}
|
|
25
|
+
export interface OfflineRegionQueryReadV1<T = Record<string, unknown>> {
|
|
26
|
+
readonly kind: typeof HONUA_OFFLINE_REGION_READ_KIND;
|
|
27
|
+
readonly version: typeof HONUA_OFFLINE_REGION_READ_VERSION;
|
|
28
|
+
readonly regionId: `sha256:${string}`;
|
|
29
|
+
readonly result: Result<T>;
|
|
30
|
+
readonly cache: OfflineRegionReadCacheDecisionV1;
|
|
31
|
+
readonly provenance: OfflineRegionReadProvenanceV1;
|
|
32
|
+
/** Attribution for the resources that answered the read. */
|
|
33
|
+
readonly attribution: Readonly<Record<string, string>>;
|
|
34
|
+
/**
|
|
35
|
+
* Cache input for `explainQuery()`, so the query plan reports this read.
|
|
36
|
+
* A fresh region carries its manifest identity as the plan's validator; a stale
|
|
37
|
+
* one deliberately carries none, because a region can be reported stale but
|
|
38
|
+
* never revalidated without the network the caller does not have.
|
|
39
|
+
*/
|
|
40
|
+
readonly planCache: QueryPlanCacheOptions;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Answer `query` from a persisted region, or fail closed.
|
|
44
|
+
*
|
|
45
|
+
* Order matters: the region's identity, then the caller's authorization scope,
|
|
46
|
+
* then version expectations, then the extent, then the query's constructs, then
|
|
47
|
+
* expiry — each failing with the narrowest true reason before any bytes are read.
|
|
48
|
+
*/
|
|
49
|
+
export declare function readOfflineRegionQuery<T = Record<string, unknown>>(inputManifest: OfflineRegionManifestV1, options: ReadOfflineRegionQueryOptions<T>): Promise<OfflineRegionQueryReadV1<T>>;
|
|
50
|
+
//# sourceMappingURL=read.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"read.d.ts","sourceRoot":"","sources":["../../../src/offline/read.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAE1D,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAC;AACvE,OAAO,EACL,8BAA8B,EAC9B,iCAAiC,EACjC,KAAK,gCAAgC,EAErC,KAAK,6BAA6B,EAClC,KAAK,6BAA6B,EASnC,MAAM,gBAAgB,CAAC;AACxB,OAAO,KAAK,EAAE,6BAA6B,EAAE,MAAM,gBAAgB,CAAC;AAEpE,OAAO,EAA2B,KAAK,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAEnF;;;;;;;;;;;;GAYG;AAEH,MAAM,WAAW,6BAA6B,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CACxE,SAAQ,IAAI,CAAC,6BAA6B,EAAE,OAAO,CAAC;IACpD,iFAAiF;IACjF,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC;IAC1B,qEAAqE;IACrE,QAAQ,CAAC,QAAQ,CAAC,EAAE,6BAA6B,CAAC;CACnD;AAED,MAAM,WAAW,wBAAwB,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IACnE,QAAQ,CAAC,IAAI,EAAE,OAAO,8BAA8B,CAAC;IACrD,QAAQ,CAAC,OAAO,EAAE,OAAO,iCAAiC,CAAC;IAC3D,QAAQ,CAAC,QAAQ,EAAE,UAAU,MAAM,EAAE,CAAC;IACtC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,gCAAgC,CAAC;IACjD,QAAQ,CAAC,UAAU,EAAE,6BAA6B,CAAC;IACnD,4DAA4D;IAC5D,QAAQ,CAAC,WAAW,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACvD;;;;;OAKG;IACH,QAAQ,CAAC,SAAS,EAAE,qBAAqB,CAAC;CAC3C;AAED;;;;;;GAMG;AACH,wBAAsB,sBAAsB,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACtE,aAAa,EAAE,uBAAuB,EACtC,OAAO,EAAE,6BAA6B,CAAC,CAAC,CAAC,GACxC,OAAO,CAAC,wBAAwB,CAAC,CAAC,CAAC,CAAC,CAiCtC"}
|
package/offline/read.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { HONUA_OFFLINE_REGION_READ_KIND, HONUA_OFFLINE_REGION_READ_VERSION, createOfflineRegionQueryPlanCache, createOfflineRegionReadCacheDecision, createOfflineRegionReadProvenance, deepFreezeRead, offlineRegionCachedSnapshotDegradation, openOfflineRegionRead, pickOfflineRegionAttribution, readGatedOfflineRegionResource, } from "./read-gate.js";
|
|
2
|
+
import { decodeOfflineRegionFeatureBatch } from "./snapshot.js";
|
|
3
|
+
import { HonuaOfflineRegionError } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Answer `query` from a persisted region, or fail closed.
|
|
6
|
+
*
|
|
7
|
+
* Order matters: the region's identity, then the caller's authorization scope,
|
|
8
|
+
* then version expectations, then the extent, then the query's constructs, then
|
|
9
|
+
* expiry — each failing with the narrowest true reason before any bytes are read.
|
|
10
|
+
*/
|
|
11
|
+
export async function readOfflineRegionQuery(inputManifest, options) {
|
|
12
|
+
const gate = await openOfflineRegionRead(inputManifest, options);
|
|
13
|
+
const { resource, bytes } = await readGatedOfflineRegionResource(gate, {
|
|
14
|
+
store: options.store,
|
|
15
|
+
kind: "features",
|
|
16
|
+
...(options.selector !== undefined ? { selector: options.selector } : {}),
|
|
17
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
18
|
+
});
|
|
19
|
+
const batch = decodeOfflineRegionFeatureBatch(bytes);
|
|
20
|
+
const window = resolveWindow(batch, gate.pagination, resource.id);
|
|
21
|
+
const result = {
|
|
22
|
+
features: window.features,
|
|
23
|
+
exceededTransferLimit: window.exceededTransferLimit,
|
|
24
|
+
...(batch.totalCount === undefined ? {} : { totalCount: batch.totalCount }),
|
|
25
|
+
...(batch.fields === undefined ? {} : { fields: batch.fields }),
|
|
26
|
+
degraded: [offlineRegionCachedSnapshotDegradation(gate, "query")],
|
|
27
|
+
};
|
|
28
|
+
return deepFreezeRead({
|
|
29
|
+
kind: HONUA_OFFLINE_REGION_READ_KIND,
|
|
30
|
+
version: HONUA_OFFLINE_REGION_READ_VERSION,
|
|
31
|
+
regionId: gate.manifest.id,
|
|
32
|
+
result,
|
|
33
|
+
cache: createOfflineRegionReadCacheDecision(gate, [resource], batch.exceededTransferLimit ? "partial" : "complete"),
|
|
34
|
+
provenance: createOfflineRegionReadProvenance(gate),
|
|
35
|
+
attribution: pickOfflineRegionAttribution(gate.manifest, [resource]),
|
|
36
|
+
planCache: createOfflineRegionQueryPlanCache(gate.manifest, {
|
|
37
|
+
now: gate.now,
|
|
38
|
+
staleAfterMs: gate.staleAfterMs,
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Slice the stored batch to the requested window, or refuse.
|
|
44
|
+
*
|
|
45
|
+
* A stored batch covers `[offset, offset + features.length)` and, when it did not
|
|
46
|
+
* exceed its transfer limit, everything after that too — because nothing else
|
|
47
|
+
* matched. A request outside that coverage is a miss, never a short answer.
|
|
48
|
+
*/
|
|
49
|
+
function resolveWindow(batch, requested, resourceId) {
|
|
50
|
+
const storedStart = batch.pagination.offset;
|
|
51
|
+
const storedEnd = storedStart + batch.features.length;
|
|
52
|
+
const requestedStart = requested.offset;
|
|
53
|
+
const requestedEnd = requested.limit === undefined ? Number.POSITIVE_INFINITY : requestedStart + requested.limit;
|
|
54
|
+
const coveredEnd = batch.exceededTransferLimit ? storedEnd : Number.POSITIVE_INFINITY;
|
|
55
|
+
if (requestedStart < storedStart || requestedEnd > coveredEnd) {
|
|
56
|
+
throw new HonuaOfflineRegionError("cache-miss", "Requested page is outside the window this offline region captured.", { resourceId });
|
|
57
|
+
}
|
|
58
|
+
const sliceStart = Math.min(requestedStart - storedStart, batch.features.length);
|
|
59
|
+
const sliceEnd = requested.limit === undefined
|
|
60
|
+
? batch.features.length
|
|
61
|
+
: Math.min(sliceStart + requested.limit, batch.features.length);
|
|
62
|
+
const features = batch.features.slice(sliceStart, sliceEnd);
|
|
63
|
+
const moreInBatch = sliceEnd < batch.features.length;
|
|
64
|
+
return { features, exceededTransferLimit: moreInBatch || batch.exceededTransferLimit };
|
|
65
|
+
}
|