@honua/app-platform 0.1.12 → 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/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,888 @@
|
|
|
1
|
+
import { assertCredentialFreeManifest, credentialScreenMessage, screenPersistedString } from "./credential-screen.js";
|
|
2
|
+
import { HonuaOfflineRegionError, } from "./types.js";
|
|
3
|
+
const DATABASE_VERSION = 3;
|
|
4
|
+
/**
|
|
5
|
+
* Persisted record-layout version written into {@link SCHEMA_STORE} and, since
|
|
6
|
+
* issue #1046, read back on every open.
|
|
7
|
+
*
|
|
8
|
+
* It is not the IndexedDB database version. The database version governs object
|
|
9
|
+
* stores and indexes; this one governs the shape of the values inside them, and
|
|
10
|
+
* it is the input to the forward migration ladder below.
|
|
11
|
+
*/
|
|
12
|
+
export const HONUA_OFFLINE_REGION_SCHEMA_VERSION = 3;
|
|
13
|
+
const CURRENT_SCHEMA_VERSION = HONUA_OFFLINE_REGION_SCHEMA_VERSION;
|
|
14
|
+
const MAX_RECOVERY_RECORDS = 100_000;
|
|
15
|
+
const MAX_RECOVERY_BYTES = 64 * 1024 * 1024;
|
|
16
|
+
/** Bound on ladder walks so a malformed or cyclic registry cannot spin. */
|
|
17
|
+
const MAX_SCHEMA_MIGRATION_STEPS = 16;
|
|
18
|
+
const INVENTORY_KEY = "current";
|
|
19
|
+
const SCHEMA_STORE = "schema";
|
|
20
|
+
const INVENTORY_STORE = "inventory";
|
|
21
|
+
const REGION_STORE = "regions";
|
|
22
|
+
const RESOURCE_STORE = "resources";
|
|
23
|
+
const RESOURCE_SEPARATOR = "\u0000";
|
|
24
|
+
const STAGING_STORE = "staging";
|
|
25
|
+
const REGION_ID_INDEX = "regionId";
|
|
26
|
+
const TRANSACTION_ID_INDEX = "transactionId";
|
|
27
|
+
const DEFAULT_STAGING_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
28
|
+
/** Stable discriminator and version for region-store recovery reports. */
|
|
29
|
+
export const HONUA_OFFLINE_REGION_STORE_RECOVERY_KIND = "honua.offline-region-store-recovery";
|
|
30
|
+
export const HONUA_OFFLINE_REGION_STORE_RECOVERY_VERSION = 1;
|
|
31
|
+
/**
|
|
32
|
+
* Ordered forward migration ladder for persisted region records, oldest first.
|
|
33
|
+
*
|
|
34
|
+
* Schema 2 and schema 3 describe the same `RegionRecord` layout — database
|
|
35
|
+
* version 3 changed only the staging rows, which
|
|
36
|
+
* {@link migrateLegacyStaging} already backfills — so this step is deliberately
|
|
37
|
+
* the identity on region records. It exists because the machinery has to be
|
|
38
|
+
* real and exercised before the first layout change, not written under the
|
|
39
|
+
* pressure of one: the ladder, its bounds, its identity invariants, and its
|
|
40
|
+
* fail-closed refusal are all proven by a step that provably changes nothing.
|
|
41
|
+
*/
|
|
42
|
+
export const OFFLINE_REGION_SCHEMA_MIGRATIONS = Object.freeze([
|
|
43
|
+
Object.freeze({
|
|
44
|
+
fromVersion: 2,
|
|
45
|
+
toVersion: 3,
|
|
46
|
+
migrate: (record) => ({ ...record }),
|
|
47
|
+
}),
|
|
48
|
+
]);
|
|
49
|
+
/**
|
|
50
|
+
* Persistent browser store for the storage-neutral downloadable-region contract.
|
|
51
|
+
*
|
|
52
|
+
* Each commit uses one read/write IndexedDB transaction over inventory, region
|
|
53
|
+
* metadata, and resource bytes. The inventory revision is checked inside that
|
|
54
|
+
* transaction, so two concurrent downloads cannot both commit an admission plan
|
|
55
|
+
* calculated from the same inventory. Credentials and request URLs are never
|
|
56
|
+
* introduced by this adapter; callers provide only the already-normalized
|
|
57
|
+
* manifest and resource bytes.
|
|
58
|
+
*
|
|
59
|
+
* That is enforced rather than assumed. `beginWrite`, `write`, and `commit` are
|
|
60
|
+
* public exports, so each re-screens the identities it is about to persist and
|
|
61
|
+
* `commit` re-checks the whole manifest. A caller that bypasses
|
|
62
|
+
* `downloadOfflineRegion` cannot persist a credential-bearing endpoint, region
|
|
63
|
+
* id, or resource id.
|
|
64
|
+
*/
|
|
65
|
+
export class IndexedDbOfflineRegionStore {
|
|
66
|
+
#database;
|
|
67
|
+
#stagingMaxAgeMs;
|
|
68
|
+
constructor(options = {}) {
|
|
69
|
+
const factory = options.indexedDB ?? globalThis.indexedDB;
|
|
70
|
+
if (!factory)
|
|
71
|
+
throw new Error("IndexedDB is not available in this runtime.");
|
|
72
|
+
const name = options.name ?? "honua-offline-regions";
|
|
73
|
+
if (name.trim().length === 0)
|
|
74
|
+
throw new Error("IndexedDB database name must be non-empty.");
|
|
75
|
+
const stagingMaxAgeMs = options.stagingMaxAgeMs ?? DEFAULT_STAGING_MAX_AGE_MS;
|
|
76
|
+
if (!Number.isFinite(stagingMaxAgeMs) || stagingMaxAgeMs < 0)
|
|
77
|
+
throw new Error("stagingMaxAgeMs must be a non-negative finite number.");
|
|
78
|
+
this.#stagingMaxAgeMs = stagingMaxAgeMs;
|
|
79
|
+
const migrations = options.migrations ?? OFFLINE_REGION_SCHEMA_MIGRATIONS;
|
|
80
|
+
const onRecovery = options.onRecovery;
|
|
81
|
+
const opened = openDatabase(factory, name).then(async (database) => {
|
|
82
|
+
// Version resolution precedes every mutating pass. A layout this build
|
|
83
|
+
// cannot carry forward refuses here, before `recoverDatabase` could
|
|
84
|
+
// mistake an unrecognized record shape for corruption and delete it.
|
|
85
|
+
const schema = await migrateRegionSchema(database, migrations);
|
|
86
|
+
if (schema.outcome === "unreadable") {
|
|
87
|
+
onRecovery?.(schema.report);
|
|
88
|
+
throw (schema.report.error ?? new HonuaOfflineRegionError("store-unreadable", "Offline region store is unreadable."));
|
|
89
|
+
}
|
|
90
|
+
const discarded = await recoverDatabase(database);
|
|
91
|
+
await cleanupAbandonedStaging(database, stagingMaxAgeMs);
|
|
92
|
+
onRecovery?.(Object.freeze({
|
|
93
|
+
...schema.report,
|
|
94
|
+
discardedCorruptRecords: discarded,
|
|
95
|
+
}));
|
|
96
|
+
return database;
|
|
97
|
+
});
|
|
98
|
+
// A store that refuses to open reports through every method that awaits it.
|
|
99
|
+
// Swallowing the copy here keeps a refusal from also surfacing as an
|
|
100
|
+
// unhandled rejection in a host that has not called anything yet.
|
|
101
|
+
opened.catch(() => undefined);
|
|
102
|
+
this.#database = opened;
|
|
103
|
+
}
|
|
104
|
+
async inventory() {
|
|
105
|
+
const database = await this.#database;
|
|
106
|
+
return runTransaction(database, "readonly", async (transaction) => {
|
|
107
|
+
const inventory = await request(transaction.objectStore(INVENTORY_STORE).get(INVENTORY_KEY));
|
|
108
|
+
const records = await request(transaction.objectStore(REGION_STORE).getAll());
|
|
109
|
+
return {
|
|
110
|
+
revision: inventory?.revision ?? "0",
|
|
111
|
+
regions: records
|
|
112
|
+
.map(toStoredRegion)
|
|
113
|
+
.sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0)),
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
async readResource(regionId, resourceId) {
|
|
118
|
+
if (typeof regionId !== "string" || regionId.length === 0)
|
|
119
|
+
throw new Error("regionId must be non-empty.");
|
|
120
|
+
if (typeof resourceId !== "string" || resourceId.length === 0)
|
|
121
|
+
throw new Error("resourceId must be non-empty.");
|
|
122
|
+
const database = await this.#database;
|
|
123
|
+
return runTransaction(database, "readwrite", async (transaction) => {
|
|
124
|
+
const inventoryStore = transaction.objectStore(INVENTORY_STORE);
|
|
125
|
+
const region = await request(transaction.objectStore(REGION_STORE).get(regionId));
|
|
126
|
+
if (!region)
|
|
127
|
+
return undefined;
|
|
128
|
+
const resource = region.manifest.resources.find((candidate) => candidate.id === resourceId);
|
|
129
|
+
if (!resource)
|
|
130
|
+
return undefined;
|
|
131
|
+
const stored = await request(transaction.objectStore(RESOURCE_STORE).get(resourceKey(regionId, resourceId)));
|
|
132
|
+
if (!stored)
|
|
133
|
+
return undefined;
|
|
134
|
+
if (stored.bytes.byteLength !== resource.byteLength) {
|
|
135
|
+
throw new Error(`Offline resource ${resourceId} failed its stored byte-length check.`);
|
|
136
|
+
}
|
|
137
|
+
const inventory = await request(inventoryStore.get(INVENTORY_KEY));
|
|
138
|
+
transaction.objectStore(REGION_STORE).put({
|
|
139
|
+
...region,
|
|
140
|
+
lastAccessedAt: new Date().toISOString(),
|
|
141
|
+
});
|
|
142
|
+
inventoryStore.put({
|
|
143
|
+
key: INVENTORY_KEY,
|
|
144
|
+
revision: nextRevision(inventory?.revision),
|
|
145
|
+
});
|
|
146
|
+
return {
|
|
147
|
+
regionId,
|
|
148
|
+
manifest: region.manifest,
|
|
149
|
+
resource,
|
|
150
|
+
bytes: Uint8Array.from(stored.bytes),
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
async removeRegion(regionId) {
|
|
155
|
+
requireId(regionId, "regionId");
|
|
156
|
+
const database = await this.#database;
|
|
157
|
+
return runTransaction(database, "readwrite", async (transaction) => {
|
|
158
|
+
const inventoryStore = transaction.objectStore(INVENTORY_STORE);
|
|
159
|
+
const regionStore = transaction.objectStore(REGION_STORE);
|
|
160
|
+
const region = await request(regionStore.get(regionId));
|
|
161
|
+
if (!region)
|
|
162
|
+
return false;
|
|
163
|
+
await deleteResourcesByRegion(transaction, regionId);
|
|
164
|
+
regionStore.delete(regionId);
|
|
165
|
+
const inventory = await request(inventoryStore.get(INVENTORY_KEY));
|
|
166
|
+
inventoryStore.put({ key: INVENTORY_KEY, revision: nextRevision(inventory?.revision) });
|
|
167
|
+
return true;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
async setRegionPinned(regionId, pinned) {
|
|
171
|
+
requireId(regionId, "regionId");
|
|
172
|
+
if (typeof pinned !== "boolean")
|
|
173
|
+
throw new TypeError("pinned must be a boolean.");
|
|
174
|
+
const database = await this.#database;
|
|
175
|
+
return runTransaction(database, "readwrite", async (transaction) => {
|
|
176
|
+
const inventoryStore = transaction.objectStore(INVENTORY_STORE);
|
|
177
|
+
const regionStore = transaction.objectStore(REGION_STORE);
|
|
178
|
+
const region = await request(regionStore.get(regionId));
|
|
179
|
+
if (!region || Boolean(region.pinned) === pinned)
|
|
180
|
+
return false;
|
|
181
|
+
regionStore.put({ ...region, ...(pinned ? { pinned: true } : { pinned: undefined }) });
|
|
182
|
+
const inventory = await request(inventoryStore.get(INVENTORY_KEY));
|
|
183
|
+
inventoryStore.put({ key: INVENTORY_KEY, revision: nextRevision(inventory?.revision) });
|
|
184
|
+
return true;
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
async pruneExpired(now = new Date()) {
|
|
188
|
+
if (!(now instanceof Date) || !Number.isFinite(now.getTime()))
|
|
189
|
+
throw new TypeError("now must be a valid Date.");
|
|
190
|
+
const cutoff = now.getTime();
|
|
191
|
+
const database = await this.#database;
|
|
192
|
+
return runTransaction(database, "readwrite", async (transaction) => {
|
|
193
|
+
const regionStore = transaction.objectStore(REGION_STORE);
|
|
194
|
+
const records = await request(regionStore.getAll());
|
|
195
|
+
const expired = records
|
|
196
|
+
.filter((record) => record.expiresAt !== undefined && Date.parse(record.expiresAt) <= cutoff)
|
|
197
|
+
.map((record) => record.id)
|
|
198
|
+
.sort();
|
|
199
|
+
if (expired.length === 0)
|
|
200
|
+
return expired;
|
|
201
|
+
for (const regionId of expired) {
|
|
202
|
+
await deleteResourcesByRegion(transaction, regionId);
|
|
203
|
+
regionStore.delete(regionId);
|
|
204
|
+
}
|
|
205
|
+
const inventoryStore = transaction.objectStore(INVENTORY_STORE);
|
|
206
|
+
const inventory = await request(inventoryStore.get(INVENTORY_KEY));
|
|
207
|
+
inventoryStore.put({ key: INVENTORY_KEY, revision: nextRevision(inventory?.revision) });
|
|
208
|
+
return expired;
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
async beginWrite(regionId) {
|
|
212
|
+
if (typeof regionId !== "string" || regionId.length === 0)
|
|
213
|
+
throw new Error("regionId must be non-empty.");
|
|
214
|
+
// Staged rows are keyed by this value, so it is screened before any write
|
|
215
|
+
// rather than only at commit.
|
|
216
|
+
assertCredentialFreeIdentity(regionId, "regionId");
|
|
217
|
+
const database = await this.#database;
|
|
218
|
+
// The manifest id is the resume checkpoint identity. It is deterministic,
|
|
219
|
+
// credential-free, and cannot collide across different snapshots.
|
|
220
|
+
const transactionId = regionId;
|
|
221
|
+
const evictions = [];
|
|
222
|
+
return {
|
|
223
|
+
evict: async (id) => {
|
|
224
|
+
if (!evictions.includes(id))
|
|
225
|
+
evictions.push(id);
|
|
226
|
+
},
|
|
227
|
+
readStaged: async (resource) => {
|
|
228
|
+
const staged = await runTransaction(database, "readwrite", async (transaction) => {
|
|
229
|
+
const store = transaction.objectStore(STAGING_STORE);
|
|
230
|
+
const key = resourceKey(transactionId, resource.id);
|
|
231
|
+
const value = await request(store.get(key));
|
|
232
|
+
if (value &&
|
|
233
|
+
(value.regionId !== regionId ||
|
|
234
|
+
value.bytes.byteLength !== resource.byteLength ||
|
|
235
|
+
!Number.isFinite(value.createdAt) ||
|
|
236
|
+
Date.now() - value.createdAt > this.#stagingMaxAgeMs)) {
|
|
237
|
+
store.delete(key);
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
return value;
|
|
241
|
+
});
|
|
242
|
+
if (!staged)
|
|
243
|
+
return undefined;
|
|
244
|
+
return Uint8Array.from(staged.bytes);
|
|
245
|
+
},
|
|
246
|
+
discardStaged: async (resource) => {
|
|
247
|
+
await runTransaction(database, "readwrite", async (transaction) => {
|
|
248
|
+
transaction.objectStore(STAGING_STORE).delete(resourceKey(transactionId, resource.id));
|
|
249
|
+
});
|
|
250
|
+
},
|
|
251
|
+
write: async (resource, bytes) => {
|
|
252
|
+
assertCredentialFreeIdentity(resource.id, "resource.id");
|
|
253
|
+
const copy = Uint8Array.from(bytes);
|
|
254
|
+
if (copy.byteLength !== resource.byteLength) {
|
|
255
|
+
throw new Error(`Resource ${resource.id} byte length does not match its descriptor.`);
|
|
256
|
+
}
|
|
257
|
+
await runTransaction(database, "readwrite", async (transaction) => {
|
|
258
|
+
transaction.objectStore(STAGING_STORE).put({
|
|
259
|
+
key: resourceKey(transactionId, resource.id),
|
|
260
|
+
transactionId,
|
|
261
|
+
regionId,
|
|
262
|
+
resourceId: resource.id,
|
|
263
|
+
bytes: copy,
|
|
264
|
+
createdAt: Date.now(),
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
},
|
|
268
|
+
commit: async (manifest, receipt, guard) => {
|
|
269
|
+
// `commit` is a public export. Re-enforce the credential-free manifest
|
|
270
|
+
// invariant here so a caller that bypassed `downloadOfflineRegion`
|
|
271
|
+
// cannot persist a credential-bearing endpoint or identity.
|
|
272
|
+
assertCredentialFreeManifest(manifest);
|
|
273
|
+
return runTransaction(database, "readwrite", async (transaction) => {
|
|
274
|
+
const inventoryStore = transaction.objectStore(INVENTORY_STORE);
|
|
275
|
+
const regionStore = transaction.objectStore(REGION_STORE);
|
|
276
|
+
const currentInventory = await request(inventoryStore.get(INVENTORY_KEY));
|
|
277
|
+
if ((currentInventory?.revision ?? "0") !== guard.expectedInventoryRevision)
|
|
278
|
+
return "inventory-changed";
|
|
279
|
+
const records = await request(regionStore.getAll());
|
|
280
|
+
const existing = records.find((record) => record.id === manifest.id);
|
|
281
|
+
const evicted = new Set(evictions);
|
|
282
|
+
const remaining = records.filter((record) => record.id !== manifest.id && !evicted.has(record.id));
|
|
283
|
+
const logicalBytesAfter = remaining.reduce((total, record) => total + record.logicalByteLength, 0) + manifest.totalLogicalBytes;
|
|
284
|
+
if (logicalBytesAfter > guard.logicalQuotaBytes ||
|
|
285
|
+
logicalBytesAfter !== guard.admission.logicalBytesAfter ||
|
|
286
|
+
guard.admission.requiredLogicalBytes !== manifest.totalLogicalBytes ||
|
|
287
|
+
guard.admission.replacementLogicalBytes !== (existing?.logicalByteLength ?? 0) ||
|
|
288
|
+
!sameIds(guard.admission.evictRegionIds, evictions)) {
|
|
289
|
+
throw new Error("IndexedDB store rejected inconsistent offline-region admission.");
|
|
290
|
+
}
|
|
291
|
+
await deleteResourcesByRegion(transaction, manifest.id);
|
|
292
|
+
for (const id of evictions)
|
|
293
|
+
await deleteResourcesByRegion(transaction, id);
|
|
294
|
+
for (const id of evictions)
|
|
295
|
+
regionStore.delete(id);
|
|
296
|
+
regionStore.put({
|
|
297
|
+
id: manifest.id,
|
|
298
|
+
manifest,
|
|
299
|
+
receipt,
|
|
300
|
+
logicalByteLength: manifest.totalLogicalBytes,
|
|
301
|
+
lastAccessedAt: receipt.completedAt,
|
|
302
|
+
...(manifest.expiresAt ? { expiresAt: manifest.expiresAt } : {}),
|
|
303
|
+
...(existing?.pinned ? { pinned: true } : {}),
|
|
304
|
+
});
|
|
305
|
+
await moveStagedResources(transaction, transactionId, manifest.id, new Set(manifest.resources.map((resource) => resource.id)));
|
|
306
|
+
inventoryStore.put({
|
|
307
|
+
key: INVENTORY_KEY,
|
|
308
|
+
revision: nextRevision(currentInventory?.revision),
|
|
309
|
+
});
|
|
310
|
+
return "committed";
|
|
311
|
+
});
|
|
312
|
+
},
|
|
313
|
+
rollback: async (options) => {
|
|
314
|
+
if (!options?.preserveStaged)
|
|
315
|
+
await clearStagedResources(database, transactionId);
|
|
316
|
+
evictions.length = 0;
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
export function createIndexedDbOfflineRegionStore(options = {}) {
|
|
322
|
+
return new IndexedDbOfflineRegionStore(options);
|
|
323
|
+
}
|
|
324
|
+
function openDatabase(factory, name) {
|
|
325
|
+
return new Promise((resolve, reject) => {
|
|
326
|
+
const request = factory.open(name, DATABASE_VERSION);
|
|
327
|
+
request.onupgradeneeded = (event) => {
|
|
328
|
+
const database = request.result;
|
|
329
|
+
if (!database.objectStoreNames.contains(INVENTORY_STORE))
|
|
330
|
+
database.createObjectStore(INVENTORY_STORE, { keyPath: "key" });
|
|
331
|
+
if (!database.objectStoreNames.contains(REGION_STORE))
|
|
332
|
+
database.createObjectStore(REGION_STORE, { keyPath: "id" });
|
|
333
|
+
if (!database.objectStoreNames.contains(RESOURCE_STORE))
|
|
334
|
+
database.createObjectStore(RESOURCE_STORE, { keyPath: "key" });
|
|
335
|
+
if (!database.objectStoreNames.contains(STAGING_STORE))
|
|
336
|
+
database.createObjectStore(STAGING_STORE, { keyPath: "key" });
|
|
337
|
+
if (!database.objectStoreNames.contains(SCHEMA_STORE))
|
|
338
|
+
database.createObjectStore(SCHEMA_STORE, { keyPath: "key" });
|
|
339
|
+
const resources = request.transaction?.objectStore(RESOURCE_STORE);
|
|
340
|
+
if (resources && !resources.indexNames.contains(REGION_ID_INDEX))
|
|
341
|
+
resources.createIndex(REGION_ID_INDEX, "regionId");
|
|
342
|
+
const staging = request.transaction?.objectStore(STAGING_STORE);
|
|
343
|
+
if (staging && !staging.indexNames.contains(TRANSACTION_ID_INDEX))
|
|
344
|
+
staging.createIndex(TRANSACTION_ID_INDEX, "transactionId");
|
|
345
|
+
request.transaction
|
|
346
|
+
?.objectStore(SCHEMA_STORE)
|
|
347
|
+
.put({ key: INVENTORY_KEY, version: CURRENT_SCHEMA_VERSION });
|
|
348
|
+
migrateLegacyStaging(request.transaction, event.oldVersion);
|
|
349
|
+
};
|
|
350
|
+
request.onsuccess = () => resolve(request.result);
|
|
351
|
+
request.onerror = () => reject(request.error ?? new Error("Failed to open IndexedDB database."));
|
|
352
|
+
request.onblocked = () => reject(new Error("IndexedDB database upgrade is blocked by another open connection."));
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
function runTransaction(database, mode, body) {
|
|
356
|
+
return new Promise((resolve, reject) => {
|
|
357
|
+
const transaction = database.transaction([INVENTORY_STORE, REGION_STORE, RESOURCE_STORE, STAGING_STORE, SCHEMA_STORE], mode);
|
|
358
|
+
let result;
|
|
359
|
+
let settled = false;
|
|
360
|
+
void body(transaction).then((value) => {
|
|
361
|
+
result = value;
|
|
362
|
+
}, (error) => {
|
|
363
|
+
settled = true;
|
|
364
|
+
try {
|
|
365
|
+
transaction.abort();
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
// The transaction may already have completed or aborted.
|
|
369
|
+
}
|
|
370
|
+
reject(error);
|
|
371
|
+
});
|
|
372
|
+
transaction.oncomplete = () => {
|
|
373
|
+
if (!settled) {
|
|
374
|
+
settled = true;
|
|
375
|
+
resolve(result);
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
transaction.onerror = () => {
|
|
379
|
+
if (!settled) {
|
|
380
|
+
settled = true;
|
|
381
|
+
reject(transaction.error ?? new Error("IndexedDB transaction failed."));
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
transaction.onabort = () => {
|
|
385
|
+
if (!settled) {
|
|
386
|
+
settled = true;
|
|
387
|
+
reject(transaction.error ?? new Error("IndexedDB transaction aborted."));
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
function request(request) {
|
|
393
|
+
return new Promise((resolve, reject) => {
|
|
394
|
+
request.onsuccess = () => resolve(request.result);
|
|
395
|
+
request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed."));
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
function migrateLegacyStaging(transaction, oldVersion) {
|
|
399
|
+
if (!transaction || oldVersion >= DATABASE_VERSION)
|
|
400
|
+
return;
|
|
401
|
+
const store = transaction.objectStore(STAGING_STORE);
|
|
402
|
+
const cursorRequest = store.openCursor();
|
|
403
|
+
let inspected = 0;
|
|
404
|
+
let bytes = 0;
|
|
405
|
+
cursorRequest.onsuccess = () => {
|
|
406
|
+
const cursor = cursorRequest.result;
|
|
407
|
+
if (!cursor)
|
|
408
|
+
return;
|
|
409
|
+
inspected += 1;
|
|
410
|
+
if (inspected > MAX_RECOVERY_RECORDS)
|
|
411
|
+
throw new Error("IndexedDB migration record limit exceeded.");
|
|
412
|
+
const value = cursor.value;
|
|
413
|
+
bytes += value.bytes instanceof Uint8Array ? value.bytes.byteLength : 0;
|
|
414
|
+
if (bytes > MAX_RECOVERY_BYTES)
|
|
415
|
+
throw new Error("IndexedDB migration byte limit exceeded.");
|
|
416
|
+
if (typeof value.createdAt !== "number" || !Number.isFinite(value.createdAt)) {
|
|
417
|
+
cursor.update({ ...value, createdAt: Date.now() });
|
|
418
|
+
}
|
|
419
|
+
cursor.continue();
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
// --- Persisted schema version and forward migration (issue #1046) ------------
|
|
423
|
+
//
|
|
424
|
+
// `CURRENT_SCHEMA_VERSION` used to be written and never read, which left exactly
|
|
425
|
+
// one response to a record shape this build did not recognize: `isValidRegion`
|
|
426
|
+
// returning false and the cursor deleting it. A layout change would therefore
|
|
427
|
+
// have destroyed every cached region on the next open — bytes that are
|
|
428
|
+
// expensive to re-acquire, often over a metered or absent network — with no
|
|
429
|
+
// migration step, no typed outcome, and no count reported to the application
|
|
430
|
+
// that just lost its offline data.
|
|
431
|
+
//
|
|
432
|
+
// The ladder below closes that, following the shape #940 established for the
|
|
433
|
+
// `honua.geoarrow.batch` envelope: ordered `fromVersion` → `toVersion` steps
|
|
434
|
+
// resolved before any record is touched, applied inside one transaction that
|
|
435
|
+
// either completes or leaves the prior version intact, and a fail-closed
|
|
436
|
+
// refusal — never a silent bulk delete — when no path exists.
|
|
437
|
+
/**
|
|
438
|
+
* Resolve the ordered steps that carry `version` forward to `targetVersion`.
|
|
439
|
+
*
|
|
440
|
+
* Exported because a host needs to answer "can this store still be read?"
|
|
441
|
+
* without opening it, and because the unreachable case — a ladder whose chain
|
|
442
|
+
* dead-ends before the target — has to be provable rather than assumed. The
|
|
443
|
+
* walk is bounded and cycle-safe: at most {@link MAX_SCHEMA_MIGRATION_STEPS}
|
|
444
|
+
* steps, and no version is visited twice.
|
|
445
|
+
*/
|
|
446
|
+
export function planOfflineRegionSchemaMigration(version, migrations = OFFLINE_REGION_SCHEMA_MIGRATIONS, targetVersion = CURRENT_SCHEMA_VERSION) {
|
|
447
|
+
if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 0) {
|
|
448
|
+
return { applicable: false, reason: "unknown-version", detail: `"${String(version)}" is not a schema version.` };
|
|
449
|
+
}
|
|
450
|
+
if (version === targetVersion)
|
|
451
|
+
return { applicable: true, steps: Object.freeze([]), migrations: Object.freeze([]) };
|
|
452
|
+
const steps = [];
|
|
453
|
+
const applied = [];
|
|
454
|
+
const visited = new Set([version]);
|
|
455
|
+
let cursor = version;
|
|
456
|
+
while (steps.length < MAX_SCHEMA_MIGRATION_STEPS) {
|
|
457
|
+
const step = migrations.find((candidate) => candidate.fromVersion === cursor);
|
|
458
|
+
if (!step)
|
|
459
|
+
break;
|
|
460
|
+
if (visited.has(step.toVersion))
|
|
461
|
+
break;
|
|
462
|
+
visited.add(step.toVersion);
|
|
463
|
+
steps.push(`${step.fromVersion}->${step.toVersion}`);
|
|
464
|
+
applied.push(step);
|
|
465
|
+
cursor = step.toVersion;
|
|
466
|
+
if (cursor === targetVersion) {
|
|
467
|
+
return { applicable: true, steps: Object.freeze(steps), migrations: Object.freeze(applied) };
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
// The walk stalled. A version the ladder has never heard of is unknown (or
|
|
471
|
+
// ahead of this build); one it knows but cannot carry home is an unreachable
|
|
472
|
+
// path. Both refuse rather than guess at a layout.
|
|
473
|
+
const known = migrations.some((step) => step.fromVersion === version || step.toVersion === version);
|
|
474
|
+
if (steps.length > 0 || known) {
|
|
475
|
+
return {
|
|
476
|
+
applicable: false,
|
|
477
|
+
reason: "unreachable-version",
|
|
478
|
+
detail: `No migration path carries offline region schema version ${version} to ${targetVersion}.`,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
applicable: false,
|
|
483
|
+
reason: version > targetVersion ? "future-version" : "unknown-version",
|
|
484
|
+
detail: `Offline region schema version ${version} has no migration to ${targetVersion}.`,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
/** Report which persisted schema versions this build can still read. */
|
|
488
|
+
export function readableOfflineRegionSchemaVersions(migrations = OFFLINE_REGION_SCHEMA_MIGRATIONS) {
|
|
489
|
+
const versions = new Set([CURRENT_SCHEMA_VERSION]);
|
|
490
|
+
for (const step of migrations) {
|
|
491
|
+
if (planOfflineRegionSchemaMigration(step.fromVersion, migrations).applicable)
|
|
492
|
+
versions.add(step.fromVersion);
|
|
493
|
+
}
|
|
494
|
+
return Object.freeze([...versions].sort((left, right) => left - right));
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Read the persisted schema version and, when the ladder can reach this build's
|
|
498
|
+
* layout, migrate every region record forward inside one transaction.
|
|
499
|
+
*/
|
|
500
|
+
function migrateRegionSchema(database, migrations) {
|
|
501
|
+
return runTransaction(database, "readwrite", async (transaction) => {
|
|
502
|
+
const schemaStore = transaction.objectStore(SCHEMA_STORE);
|
|
503
|
+
const regionStore = transaction.objectStore(REGION_STORE);
|
|
504
|
+
const stored = await request(schemaStore.get(INVENTORY_KEY));
|
|
505
|
+
// Every database whose stores exist went through an upgrade that stamped
|
|
506
|
+
// this row, so an absent one carries no evidence of an older layout. It is
|
|
507
|
+
// restored to the current version rather than guessed at.
|
|
508
|
+
const storedVersion = isRecord(stored) && typeof stored.version === "number" ? stored.version : CURRENT_SCHEMA_VERSION;
|
|
509
|
+
const plan = planOfflineRegionSchemaMigration(storedVersion, migrations);
|
|
510
|
+
if (!plan.applicable) {
|
|
511
|
+
const unsupportedRegions = await request(regionStore.count());
|
|
512
|
+
return {
|
|
513
|
+
outcome: "unreadable",
|
|
514
|
+
report: Object.freeze({
|
|
515
|
+
kind: HONUA_OFFLINE_REGION_STORE_RECOVERY_KIND,
|
|
516
|
+
version: HONUA_OFFLINE_REGION_STORE_RECOVERY_VERSION,
|
|
517
|
+
outcome: "unreadable",
|
|
518
|
+
storedSchemaVersion: storedVersion,
|
|
519
|
+
expectedSchemaVersion: CURRENT_SCHEMA_VERSION,
|
|
520
|
+
appliedMigrations: Object.freeze([]),
|
|
521
|
+
migratedRegions: 0,
|
|
522
|
+
discardedCorruptRecords: 0,
|
|
523
|
+
unsupportedRegions,
|
|
524
|
+
error: new HonuaOfflineRegionError("store-unreadable", `Offline region store is at schema version ${storedVersion}; this build reads ${CURRENT_SCHEMA_VERSION}. ${plan.detail} ${unsupportedRegions} region(s) were left intact.`),
|
|
525
|
+
}),
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
const migratedRegions = plan.steps.length === 0 ? 0 : await migrateRegionRecords(regionStore, plan.migrations);
|
|
529
|
+
schemaStore.put({ key: INVENTORY_KEY, version: CURRENT_SCHEMA_VERSION });
|
|
530
|
+
return {
|
|
531
|
+
outcome: "ready",
|
|
532
|
+
report: Object.freeze({
|
|
533
|
+
kind: HONUA_OFFLINE_REGION_STORE_RECOVERY_KIND,
|
|
534
|
+
version: HONUA_OFFLINE_REGION_STORE_RECOVERY_VERSION,
|
|
535
|
+
outcome: "ready",
|
|
536
|
+
storedSchemaVersion: storedVersion,
|
|
537
|
+
expectedSchemaVersion: CURRENT_SCHEMA_VERSION,
|
|
538
|
+
appliedMigrations: plan.steps,
|
|
539
|
+
migratedRegions,
|
|
540
|
+
discardedCorruptRecords: 0,
|
|
541
|
+
unsupportedRegions: 0,
|
|
542
|
+
}),
|
|
543
|
+
};
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Apply the resolved steps to every region record under the existing recovery
|
|
548
|
+
* ceilings. A step that rewrites an identity aborts the whole transaction, so
|
|
549
|
+
* the prior database version stays readable.
|
|
550
|
+
*/
|
|
551
|
+
function migrateRegionRecords(store, steps) {
|
|
552
|
+
let inspected = 0;
|
|
553
|
+
let bytes = 0;
|
|
554
|
+
let migrated = 0;
|
|
555
|
+
return iterateCursor(store, (cursor) => {
|
|
556
|
+
inspected += 1;
|
|
557
|
+
if (inspected > MAX_RECOVERY_RECORDS) {
|
|
558
|
+
throw new HonuaOfflineRegionError("store-failed", "IndexedDB migration record limit exceeded.");
|
|
559
|
+
}
|
|
560
|
+
const before = cursor.value;
|
|
561
|
+
bytes += typeof before.logicalByteLength === "number" ? before.logicalByteLength : 0;
|
|
562
|
+
if (bytes > MAX_RECOVERY_BYTES) {
|
|
563
|
+
throw new HonuaOfflineRegionError("store-failed", "IndexedDB migration byte limit exceeded.");
|
|
564
|
+
}
|
|
565
|
+
cursor.update(applyOfflineRegionSchemaMigration(before, steps));
|
|
566
|
+
migrated += 1;
|
|
567
|
+
cursor.continue();
|
|
568
|
+
}).then(() => migrated);
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Carry one persisted region record through an ordered chain of steps.
|
|
572
|
+
*
|
|
573
|
+
* Each step sees only a frozen copy of the record — never the store, never a
|
|
574
|
+
* caller's options — and its output is checked before the next step runs, so a
|
|
575
|
+
* migration that rewrites an identity fails the record instead of quietly
|
|
576
|
+
* repartitioning it. Exported so a host can rehearse a ladder against a record
|
|
577
|
+
* without an IndexedDB transaction.
|
|
578
|
+
*/
|
|
579
|
+
export function applyOfflineRegionSchemaMigration(record, steps) {
|
|
580
|
+
let migrated = { ...record };
|
|
581
|
+
for (const step of steps) {
|
|
582
|
+
const next = step.migrate(Object.freeze({ ...migrated }));
|
|
583
|
+
if (!isRecord(next)) {
|
|
584
|
+
throw new HonuaOfflineRegionError("store-failed", `Offline region migration ${step.fromVersion}->${step.toVersion} did not produce a record.`);
|
|
585
|
+
}
|
|
586
|
+
assertStableRegionIdentity(record, next, step);
|
|
587
|
+
migrated = next;
|
|
588
|
+
}
|
|
589
|
+
return migrated;
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* A migrated record keeps exactly the identity it was stored under. The
|
|
593
|
+
* authorization-scope digest partitions every stored key, so re-deriving it
|
|
594
|
+
* would silently repartition another principal's cached bytes.
|
|
595
|
+
*/
|
|
596
|
+
function assertStableRegionIdentity(before, after, step) {
|
|
597
|
+
const label = `${step.fromVersion}->${step.toVersion}`;
|
|
598
|
+
if (after.id !== before.id) {
|
|
599
|
+
throw new HonuaOfflineRegionError("store-failed", `Offline region migration ${label} rewrote a region id.`);
|
|
600
|
+
}
|
|
601
|
+
if (regionScopeDigest(before) !== regionScopeDigest(after)) {
|
|
602
|
+
throw new HonuaOfflineRegionError("store-failed", `Offline region migration ${label} rewrote an authorization-scope digest.`);
|
|
603
|
+
}
|
|
604
|
+
if (!sameIds(regionResourceIds(before), regionResourceIds(after))) {
|
|
605
|
+
throw new HonuaOfflineRegionError("store-failed", `Offline region migration ${label} rewrote a resource id.`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
function regionScopeDigest(record) {
|
|
609
|
+
const manifest = isRecord(record.manifest) ? record.manifest : undefined;
|
|
610
|
+
const source = manifest && isRecord(manifest.source) ? manifest.source : undefined;
|
|
611
|
+
return typeof source?.authorizationScopeDigest === "string" ? source.authorizationScopeDigest : undefined;
|
|
612
|
+
}
|
|
613
|
+
function regionResourceIds(record) {
|
|
614
|
+
const manifest = isRecord(record.manifest) ? record.manifest : undefined;
|
|
615
|
+
const resources = manifest && Array.isArray(manifest.resources) ? manifest.resources : [];
|
|
616
|
+
return resources.map((resource) => (isRecord(resource) && typeof resource.id === "string" ? resource.id : ""));
|
|
617
|
+
}
|
|
618
|
+
/** One bounded pass that discards records this build can prove are unusable. */
|
|
619
|
+
function recoverDatabase(database) {
|
|
620
|
+
return runTransaction(database, "readwrite", async (transaction) => {
|
|
621
|
+
let discarded = 0;
|
|
622
|
+
const inventoryStore = transaction.objectStore(INVENTORY_STORE);
|
|
623
|
+
const inventory = await request(inventoryStore.get(INVENTORY_KEY));
|
|
624
|
+
if (inventory !== undefined && !isValidInventory(inventory)) {
|
|
625
|
+
inventoryStore.put({ key: INVENTORY_KEY, revision: "0" });
|
|
626
|
+
discarded += 1;
|
|
627
|
+
}
|
|
628
|
+
const regionStore = transaction.objectStore(REGION_STORE);
|
|
629
|
+
const resourceStore = transaction.objectStore(RESOURCE_STORE);
|
|
630
|
+
const regions = new Map();
|
|
631
|
+
let inspected = 0;
|
|
632
|
+
let bytes = 0;
|
|
633
|
+
await iterateCursor(regionStore, (cursor) => {
|
|
634
|
+
inspected += 1;
|
|
635
|
+
if (inspected > MAX_RECOVERY_RECORDS)
|
|
636
|
+
throw new Error("IndexedDB recovery record limit exceeded.");
|
|
637
|
+
const region = cursor.value;
|
|
638
|
+
if (!isValidRegion(region)) {
|
|
639
|
+
cursor.delete();
|
|
640
|
+
discarded += 1;
|
|
641
|
+
cursor.continue();
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
const expected = new Map(region.manifest.resources.map((resource) => [resource.id, resource.byteLength]));
|
|
645
|
+
if (expected.size !== region.manifest.resources.length) {
|
|
646
|
+
cursor.delete();
|
|
647
|
+
discarded += 1;
|
|
648
|
+
cursor.continue();
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
bytes += region.logicalByteLength;
|
|
652
|
+
if (bytes > MAX_RECOVERY_BYTES)
|
|
653
|
+
throw new Error("IndexedDB recovery byte limit exceeded.");
|
|
654
|
+
regions.set(region.id, { expected, seen: new Set() });
|
|
655
|
+
cursor.continue();
|
|
656
|
+
});
|
|
657
|
+
await iterateCursor(resourceStore, (cursor) => {
|
|
658
|
+
inspected += 1;
|
|
659
|
+
if (inspected > MAX_RECOVERY_RECORDS)
|
|
660
|
+
throw new Error("IndexedDB recovery record limit exceeded.");
|
|
661
|
+
const resource = cursor.value;
|
|
662
|
+
const region = typeof resource.regionId === "string" ? regions.get(resource.regionId) : undefined;
|
|
663
|
+
const expectedByteLength = region?.expected.get(resource.resourceId ?? "");
|
|
664
|
+
if (!isValidResource(resource) ||
|
|
665
|
+
!region ||
|
|
666
|
+
region.seen.has(resource.resourceId) ||
|
|
667
|
+
expectedByteLength === undefined ||
|
|
668
|
+
resource.key !== resourceKey(resource.regionId, resource.resourceId) ||
|
|
669
|
+
resource.bytes.byteLength !== expectedByteLength) {
|
|
670
|
+
cursor.delete();
|
|
671
|
+
discarded += 1;
|
|
672
|
+
cursor.continue();
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
region.seen.add(resource.resourceId);
|
|
676
|
+
cursor.continue();
|
|
677
|
+
});
|
|
678
|
+
for (const [regionId, region] of regions) {
|
|
679
|
+
if (region.seen.size !== region.expected.size) {
|
|
680
|
+
regionStore.delete(regionId);
|
|
681
|
+
await deleteResourcesByRegion(transaction, regionId);
|
|
682
|
+
discarded += 1;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
const stagingStore = transaction.objectStore(STAGING_STORE);
|
|
686
|
+
await iterateCursor(stagingStore, (cursor) => {
|
|
687
|
+
inspected += 1;
|
|
688
|
+
if (inspected > MAX_RECOVERY_RECORDS)
|
|
689
|
+
throw new Error("IndexedDB recovery record limit exceeded.");
|
|
690
|
+
const staged = cursor.value;
|
|
691
|
+
if (!isValidStagedResource(staged)) {
|
|
692
|
+
cursor.delete();
|
|
693
|
+
discarded += 1;
|
|
694
|
+
}
|
|
695
|
+
cursor.continue();
|
|
696
|
+
});
|
|
697
|
+
return discarded;
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
function iterateCursor(source, visit) {
|
|
701
|
+
return new Promise((resolve, reject) => {
|
|
702
|
+
const cursorRequest = source.openCursor();
|
|
703
|
+
cursorRequest.onerror = () => reject(cursorRequest.error ?? new Error("Failed to enumerate IndexedDB records."));
|
|
704
|
+
cursorRequest.onsuccess = () => {
|
|
705
|
+
const cursor = cursorRequest.result;
|
|
706
|
+
if (!cursor) {
|
|
707
|
+
resolve();
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
try {
|
|
711
|
+
visit(cursor);
|
|
712
|
+
}
|
|
713
|
+
catch (error) {
|
|
714
|
+
reject(error);
|
|
715
|
+
}
|
|
716
|
+
};
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
function isValidInventory(value) {
|
|
720
|
+
return isRecord(value) && value.key === INVENTORY_KEY && typeof value.revision === "string";
|
|
721
|
+
}
|
|
722
|
+
function isValidRegion(value) {
|
|
723
|
+
return (isRecord(value) &&
|
|
724
|
+
typeof value.id === "string" &&
|
|
725
|
+
value.id.length > 0 &&
|
|
726
|
+
isRecord(value.manifest) &&
|
|
727
|
+
Array.isArray(value.manifest.resources) &&
|
|
728
|
+
Number.isSafeInteger(value.logicalByteLength) &&
|
|
729
|
+
(value.logicalByteLength ?? -1) >= 0 &&
|
|
730
|
+
value.logicalByteLength === value.manifest.totalLogicalBytes &&
|
|
731
|
+
typeof value.lastAccessedAt === "string" &&
|
|
732
|
+
Number.isFinite(Date.parse(value.lastAccessedAt)));
|
|
733
|
+
}
|
|
734
|
+
function isValidResource(value) {
|
|
735
|
+
return (isRecord(value) &&
|
|
736
|
+
typeof value.key === "string" &&
|
|
737
|
+
typeof value.regionId === "string" &&
|
|
738
|
+
typeof value.resourceId === "string" &&
|
|
739
|
+
value.bytes instanceof Uint8Array);
|
|
740
|
+
}
|
|
741
|
+
function isValidStagedResource(value) {
|
|
742
|
+
return (isRecord(value) &&
|
|
743
|
+
typeof value.key === "string" &&
|
|
744
|
+
typeof value.regionId === "string" &&
|
|
745
|
+
typeof value.resourceId === "string" &&
|
|
746
|
+
value.bytes instanceof Uint8Array &&
|
|
747
|
+
typeof value.transactionId === "string" &&
|
|
748
|
+
Number.isFinite(value.createdAt));
|
|
749
|
+
}
|
|
750
|
+
function isRecord(value) {
|
|
751
|
+
return typeof value === "object" && value !== null;
|
|
752
|
+
}
|
|
753
|
+
function toStoredRegion(record) {
|
|
754
|
+
return {
|
|
755
|
+
id: record.id,
|
|
756
|
+
logicalByteLength: record.logicalByteLength,
|
|
757
|
+
lastAccessedAt: record.lastAccessedAt,
|
|
758
|
+
...(record.expiresAt ? { expiresAt: record.expiresAt } : {}),
|
|
759
|
+
...(record.pinned ? { pinned: true } : {}),
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
function requireId(value, name) {
|
|
763
|
+
if (typeof value !== "string" || value.length === 0)
|
|
764
|
+
throw new TypeError(`${name} must be non-empty.`);
|
|
765
|
+
}
|
|
766
|
+
/** Refuse a persisted key that carries credential or request-URL shape, without echoing it. */
|
|
767
|
+
function assertCredentialFreeIdentity(value, path) {
|
|
768
|
+
if (typeof value !== "string") {
|
|
769
|
+
throw new HonuaOfflineRegionError("invalid-manifest", `${path} must be a string.`, { path });
|
|
770
|
+
}
|
|
771
|
+
const reason = screenPersistedString(value, "identity");
|
|
772
|
+
if (reason) {
|
|
773
|
+
throw new HonuaOfflineRegionError("invalid-manifest", credentialScreenMessage(path, reason), { path });
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
function resourceKey(regionId, resourceId) {
|
|
777
|
+
return `${regionId}${RESOURCE_SEPARATOR}${resourceId}`;
|
|
778
|
+
}
|
|
779
|
+
function sameIds(left, right) {
|
|
780
|
+
return left.length === right.length && left.every((id, index) => id === right[index]);
|
|
781
|
+
}
|
|
782
|
+
function createTransactionId() {
|
|
783
|
+
return globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(16).slice(2)}`;
|
|
784
|
+
}
|
|
785
|
+
function nextRevision(previous) {
|
|
786
|
+
return `${previous ?? "0"}-${createTransactionId()}`;
|
|
787
|
+
}
|
|
788
|
+
function deleteResourcesByRegion(transaction, regionId) {
|
|
789
|
+
return new Promise((resolve, reject) => {
|
|
790
|
+
const cursorRequest = transaction
|
|
791
|
+
.objectStore(RESOURCE_STORE)
|
|
792
|
+
.index(REGION_ID_INDEX)
|
|
793
|
+
.openCursor(IDBKeyRange.only(regionId));
|
|
794
|
+
cursorRequest.onerror = () => reject(cursorRequest.error ?? new Error("Failed to enumerate offline-region resources."));
|
|
795
|
+
cursorRequest.onsuccess = () => {
|
|
796
|
+
const cursor = cursorRequest.result;
|
|
797
|
+
if (!cursor) {
|
|
798
|
+
resolve();
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
cursor.delete();
|
|
802
|
+
cursor.continue();
|
|
803
|
+
};
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
function moveStagedResources(transaction, transactionId, regionId, expectedResourceIds) {
|
|
807
|
+
return new Promise((resolve, reject) => {
|
|
808
|
+
const resourceStore = transaction.objectStore(RESOURCE_STORE);
|
|
809
|
+
const cursorRequest = transaction
|
|
810
|
+
.objectStore(STAGING_STORE)
|
|
811
|
+
.index(TRANSACTION_ID_INDEX)
|
|
812
|
+
.openCursor(IDBKeyRange.only(transactionId));
|
|
813
|
+
const seen = new Set();
|
|
814
|
+
cursorRequest.onerror = () => reject(cursorRequest.error ?? new Error("Failed to enumerate staged offline-region resources."));
|
|
815
|
+
cursorRequest.onsuccess = () => {
|
|
816
|
+
const cursor = cursorRequest.result;
|
|
817
|
+
if (!cursor) {
|
|
818
|
+
if (seen.size !== expectedResourceIds.size || [...expectedResourceIds].some((id) => !seen.has(id))) {
|
|
819
|
+
reject(new Error("IndexedDB store requires every manifest resource to be written before commit."));
|
|
820
|
+
}
|
|
821
|
+
else {
|
|
822
|
+
resolve();
|
|
823
|
+
}
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
const staged = cursor.value;
|
|
827
|
+
if (staged.regionId !== regionId || !expectedResourceIds.has(staged.resourceId) || seen.has(staged.resourceId)) {
|
|
828
|
+
reject(new Error("IndexedDB store found an unexpected staged offline-region resource."));
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
seen.add(staged.resourceId);
|
|
832
|
+
resourceStore.put({
|
|
833
|
+
key: resourceKey(regionId, staged.resourceId),
|
|
834
|
+
regionId,
|
|
835
|
+
resourceId: staged.resourceId,
|
|
836
|
+
bytes: staged.bytes,
|
|
837
|
+
});
|
|
838
|
+
cursor.delete();
|
|
839
|
+
cursor.continue();
|
|
840
|
+
};
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
function clearStagedResources(database, transactionId) {
|
|
844
|
+
return runTransaction(database, "readwrite", async (transaction) => {
|
|
845
|
+
await deleteStagedResources(transaction, transactionId);
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
function cleanupAbandonedStaging(database, maxAgeMs) {
|
|
849
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
850
|
+
return runTransaction(database, "readwrite", async (transaction) => {
|
|
851
|
+
await deleteStagedResourcesOlderThan(transaction, cutoff);
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
function deleteStagedResourcesOlderThan(transaction, cutoff) {
|
|
855
|
+
return new Promise((resolve, reject) => {
|
|
856
|
+
const cursorRequest = transaction.objectStore(STAGING_STORE).openCursor();
|
|
857
|
+
cursorRequest.onerror = () => reject(cursorRequest.error ?? new Error("Failed to reclaim abandoned staged offline-region resources."));
|
|
858
|
+
cursorRequest.onsuccess = () => {
|
|
859
|
+
const cursor = cursorRequest.result;
|
|
860
|
+
if (!cursor) {
|
|
861
|
+
resolve();
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
const staged = cursor.value;
|
|
865
|
+
if (typeof staged.createdAt !== "number" || staged.createdAt < cutoff)
|
|
866
|
+
cursor.delete();
|
|
867
|
+
cursor.continue();
|
|
868
|
+
};
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
function deleteStagedResources(transaction, transactionId) {
|
|
872
|
+
return new Promise((resolve, reject) => {
|
|
873
|
+
const cursorRequest = transaction
|
|
874
|
+
.objectStore(STAGING_STORE)
|
|
875
|
+
.index(TRANSACTION_ID_INDEX)
|
|
876
|
+
.openCursor(IDBKeyRange.only(transactionId));
|
|
877
|
+
cursorRequest.onerror = () => reject(cursorRequest.error ?? new Error("Failed to clear staged offline-region resources."));
|
|
878
|
+
cursorRequest.onsuccess = () => {
|
|
879
|
+
const cursor = cursorRequest.result;
|
|
880
|
+
if (!cursor) {
|
|
881
|
+
resolve();
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
cursor.delete();
|
|
885
|
+
cursor.continue();
|
|
886
|
+
};
|
|
887
|
+
});
|
|
888
|
+
}
|