@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,757 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded persistent cache for serialized GeoArrow batches (issue #940).
|
|
3
|
+
*
|
|
4
|
+
* Epic #394 requires batch identity to bind "plan hash, source/schema version,
|
|
5
|
+
* authorization scope, ordering, and freshness". `ColumnarBatchIdentityV1`
|
|
6
|
+
* carried that information but nothing turned it into a storage key, so a cache
|
|
7
|
+
* built on top of the serialization envelope would have been free to key a
|
|
8
|
+
* batch by source id alone — a cross-tenant read shape. This module lands the
|
|
9
|
+
* key contract and the store together, with the same discipline the offline
|
|
10
|
+
* region store and the realtime checkpoint store already use:
|
|
11
|
+
*
|
|
12
|
+
* 1. **Identity-bound keys.** {@link columnarBatchCacheKey} digests the source,
|
|
13
|
+
* plan, schema, ordering, and freshness *validators* together with a digest
|
|
14
|
+
* of the authorization scope. The raw scope never appears in the key or in a
|
|
15
|
+
* persisted record, and a change to any keyed component addresses a
|
|
16
|
+
* different entry rather than reusing this one.
|
|
17
|
+
* 2. **Versioned envelope with a migration ladder.** Payloads are written
|
|
18
|
+
* through `serializeGeoArrowBatch` and read through
|
|
19
|
+
* `deserializeGeoArrowBatch`, so an entry written by an older layout is
|
|
20
|
+
* migrated forward on read and an unreadable one is discarded explicitly —
|
|
21
|
+
* never misread. The record additionally stamps its own
|
|
22
|
+
* `honua.columnar-batch-cache/…` format; a record with a foreign format is
|
|
23
|
+
* treated as corrupt and deleted.
|
|
24
|
+
* 3. **Verified before served.** Every read recomputes the SHA-256 of the
|
|
25
|
+
* stored envelope bytes and compares it to the digest recorded at write
|
|
26
|
+
* time. A mismatch is a miss *and* a delete; a batch is never returned from
|
|
27
|
+
* bytes that were not proven intact.
|
|
28
|
+
* 4. **Bounded with deterministic eviction.** A byte quota and a record cap are
|
|
29
|
+
* both enforced by {@link planColumnarBatchCacheAdmission}, a pure function
|
|
30
|
+
* that evicts oldest-first with a code-unit tie-break, and the write plus its
|
|
31
|
+
* evictions are handed to the backend as one atomic operation.
|
|
32
|
+
*
|
|
33
|
+
* **Persistence is opt-in and the default is no cache.** Nothing in the SDK
|
|
34
|
+
* constructs one of these: a batch is cached only when an application creates a
|
|
35
|
+
* store, chooses a backend, and calls {@link ColumnarBatchCacheHandle.write}.
|
|
36
|
+
*
|
|
37
|
+
* The store is backend-agnostic — it holds no IndexedDB, filesystem, or Node
|
|
38
|
+
* reference — so it runs unchanged in a worker, on a server, and in a browser.
|
|
39
|
+
* `batch-cache-indexeddb.ts` supplies the browser backend, and
|
|
40
|
+
* {@link createMemoryColumnarBatchCacheStorage} supplies an in-process one with
|
|
41
|
+
* identical semantics.
|
|
42
|
+
*
|
|
43
|
+
* @experimental
|
|
44
|
+
*/
|
|
45
|
+
import { credentialScreenMessage, screenPersistedString } from "../connect-url-safety.js";
|
|
46
|
+
// The offline stores are this repo's canonical persistent-cache discipline;
|
|
47
|
+
// reusing their digest and quota-pressure primitives keeps one implementation
|
|
48
|
+
// of "canonical identity" and one classification of "the device is full".
|
|
49
|
+
import { canonicalJson, sha256 } from "../offline/digest.js";
|
|
50
|
+
import { isStorageQuotaPressureError } from "../offline/quota.js";
|
|
51
|
+
import { HONUA_GEOARROW_ENVELOPE_VERSION, deserializeGeoArrowBatch, serializeGeoArrowBatch, } from "./geoarrow-serialization.js";
|
|
52
|
+
import { HonuaGeoArrowError } from "./geoarrow-types.js";
|
|
53
|
+
import { beginColumnarSpan, columnarAuthorizationScopeDigest } from "./telemetry.js";
|
|
54
|
+
/**
|
|
55
|
+
* Versioned format string stamped on every persisted record. A record whose
|
|
56
|
+
* format differs is corrupt to this build and is discarded on read, so a future
|
|
57
|
+
* record layout can never be misinterpreted as this one.
|
|
58
|
+
*/
|
|
59
|
+
export const HONUA_COLUMNAR_BATCH_CACHE_FORMAT = "honua.columnar-batch-cache/1.0";
|
|
60
|
+
/** Default byte quota across stored envelopes. Callers should size it explicitly. */
|
|
61
|
+
export const DEFAULT_COLUMNAR_BATCH_CACHE_QUOTA_BYTES = 64 * 1024 * 1024;
|
|
62
|
+
/** Absolute ceiling on `quotaBytes`. Callers may tighten it, never raise it. */
|
|
63
|
+
export const MAX_COLUMNAR_BATCH_CACHE_QUOTA_BYTES = 1024 * 1024 * 1024;
|
|
64
|
+
/** Default cap on retained entries; the oldest is evicted first. */
|
|
65
|
+
export const DEFAULT_COLUMNAR_BATCH_CACHE_MAX_RECORDS = 64;
|
|
66
|
+
/** Absolute ceiling on `maxRecords`. */
|
|
67
|
+
export const MAX_COLUMNAR_BATCH_CACHE_MAX_RECORDS = 4_096;
|
|
68
|
+
/**
|
|
69
|
+
* Default maximum entry age. A batch older than this is reported stale even
|
|
70
|
+
* when its identity still matches, because a source may change without changing
|
|
71
|
+
* a validator the client can see.
|
|
72
|
+
*/
|
|
73
|
+
export const DEFAULT_COLUMNAR_BATCH_CACHE_MAX_AGE_MS = 60 * 60 * 1000;
|
|
74
|
+
/** Absolute ceiling on `maxAgeMs`. */
|
|
75
|
+
export const MAX_COLUMNAR_BATCH_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
76
|
+
const INVALID_KEY = `sha256:${"0".repeat(64)}`;
|
|
77
|
+
/**
|
|
78
|
+
* Deterministic cache key for one batch identity.
|
|
79
|
+
*
|
|
80
|
+
* The key digests source id, source version, schema version, plan id, the
|
|
81
|
+
* ordering contract, the freshness *validators*, and the authorization-scope
|
|
82
|
+
* digest. Two identities that differ in any of those address different entries,
|
|
83
|
+
* and the output is a hex digest, so the raw authorization scope can never
|
|
84
|
+
* appear in it.
|
|
85
|
+
*
|
|
86
|
+
* `freshness.observedAt` and `freshness.staleAfter` are deliberately excluded:
|
|
87
|
+
* they describe *when* a producer observed the source, not *what* was asked
|
|
88
|
+
* for. Keying on them would mint a new entry for every fetch and make the cache
|
|
89
|
+
* unreadable by a caller that does not already hold the answer. Expiry is
|
|
90
|
+
* enforced on read instead, against the record's own recorded freshness.
|
|
91
|
+
*/
|
|
92
|
+
export async function columnarBatchCacheKey(identity) {
|
|
93
|
+
const identityRecord = assertIdentity(identity);
|
|
94
|
+
return sha256(`honua-columnar-batch-cache-key:v1:${canonicalJson({
|
|
95
|
+
authorizationScopeDigest: await columnarAuthorizationScopeDigest(identityRecord.authorizationScope),
|
|
96
|
+
freshness: freshnessValidators(identityRecord.freshness),
|
|
97
|
+
ordering: orderingIdentity(identityRecord),
|
|
98
|
+
planId: identityRecord.planId,
|
|
99
|
+
schemaVersion: identityRecord.schemaVersion,
|
|
100
|
+
sourceId: identityRecord.sourceId,
|
|
101
|
+
sourceVersion: identityRecord.sourceVersion,
|
|
102
|
+
})}`);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Digest of the ordering contract a batch was produced under.
|
|
106
|
+
*
|
|
107
|
+
* Recorded beside the batch so a reader can tell "same rows, different sort"
|
|
108
|
+
* apart from "same batch" without trusting the key alone.
|
|
109
|
+
*/
|
|
110
|
+
export async function columnarBatchCacheOrderingDigest(identity) {
|
|
111
|
+
return sha256(`honua-columnar-batch-cache-ordering:v1:${canonicalJson(orderingIdentity(identity))}`);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Plan the evictions that admit `incoming` under `limits`.
|
|
115
|
+
*
|
|
116
|
+
* Pure and deterministic: candidates are ordered by write time and then by key
|
|
117
|
+
* in code-unit order, so two hosts with the same records always evict the same
|
|
118
|
+
* entries. An entry larger than the whole quota is refused outright rather than
|
|
119
|
+
* emptying the store for something that still would not fit.
|
|
120
|
+
*/
|
|
121
|
+
export function planColumnarBatchCacheAdmission(records, incoming, limits) {
|
|
122
|
+
const others = records.filter((record) => record.key !== incoming.key);
|
|
123
|
+
const replacedBytes = records
|
|
124
|
+
.filter((record) => record.key === incoming.key)
|
|
125
|
+
.reduce((total, record) => total + record.byteLength, 0);
|
|
126
|
+
const bytesBefore = records.reduce((total, record) => total + record.byteLength, 0);
|
|
127
|
+
const base = {
|
|
128
|
+
quotaBytes: limits.quotaBytes,
|
|
129
|
+
maxRecords: limits.maxRecords,
|
|
130
|
+
incomingBytes: incoming.byteLength,
|
|
131
|
+
bytesBefore,
|
|
132
|
+
replacedBytes,
|
|
133
|
+
};
|
|
134
|
+
if (incoming.byteLength > limits.quotaBytes) {
|
|
135
|
+
return Object.freeze({
|
|
136
|
+
...base,
|
|
137
|
+
evictKeys: Object.freeze([]),
|
|
138
|
+
evictedBytes: 0,
|
|
139
|
+
bytesAfter: bytesBefore,
|
|
140
|
+
recordsAfter: records.length,
|
|
141
|
+
admitted: false,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
const ordered = [...others].sort((left, right) => compareCodeUnits(left.observedAt, right.observedAt) || compareCodeUnits(left.key, right.key));
|
|
145
|
+
const evictKeys = [];
|
|
146
|
+
let evictedBytes = 0;
|
|
147
|
+
let bytes = others.reduce((total, record) => total + record.byteLength, 0) + incoming.byteLength;
|
|
148
|
+
let count = others.length + 1;
|
|
149
|
+
for (const record of ordered) {
|
|
150
|
+
if (bytes <= limits.quotaBytes && count <= limits.maxRecords)
|
|
151
|
+
break;
|
|
152
|
+
evictKeys.push(record.key);
|
|
153
|
+
evictedBytes += record.byteLength;
|
|
154
|
+
bytes -= record.byteLength;
|
|
155
|
+
count -= 1;
|
|
156
|
+
}
|
|
157
|
+
return Object.freeze({
|
|
158
|
+
...base,
|
|
159
|
+
evictKeys: Object.freeze(evictKeys),
|
|
160
|
+
evictedBytes,
|
|
161
|
+
bytesAfter: bytes,
|
|
162
|
+
recordsAfter: count,
|
|
163
|
+
admitted: bytes <= limits.quotaBytes && count <= limits.maxRecords,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Bind the key, screening, integrity, freshness, and eviction logic to any
|
|
168
|
+
* {@link ColumnarBatchCacheStorage}.
|
|
169
|
+
*
|
|
170
|
+
* The returned handle never throws into a data path: a rejected backend call, a
|
|
171
|
+
* refused value, or a corrupt record settles as a miss or a refusal and reports
|
|
172
|
+
* a {@link ColumnarBatchCacheDiagnosticV1}, so a caller falls back to fetching
|
|
173
|
+
* rather than failing.
|
|
174
|
+
*/
|
|
175
|
+
export function createColumnarBatchCache(storage, options = {}) {
|
|
176
|
+
const normalized = normalizeOptions(options);
|
|
177
|
+
let disposed = false;
|
|
178
|
+
const handle = {
|
|
179
|
+
key: (identity) => columnarBatchCacheKey(identity),
|
|
180
|
+
read: async (identity, readOptions = {}) => {
|
|
181
|
+
const telemetry = normalized.telemetry;
|
|
182
|
+
if (telemetry === undefined) {
|
|
183
|
+
if (disposed)
|
|
184
|
+
return miss(normalized, INVALID_KEY, "disposed", "This columnar batch cache was disposed.");
|
|
185
|
+
return readEntry(storage, normalized, identity, readOptions.signal);
|
|
186
|
+
}
|
|
187
|
+
const span = beginColumnarSpan(telemetry, "columnar-cache-read", identity);
|
|
188
|
+
try {
|
|
189
|
+
const outcome = disposed
|
|
190
|
+
? miss(normalized, INVALID_KEY, "disposed", "This columnar batch cache was disposed.")
|
|
191
|
+
: await readEntry(storage, normalized, identity, readOptions.signal);
|
|
192
|
+
span?.finish(cacheReadDetail(outcome));
|
|
193
|
+
return outcome;
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
span?.fail(error);
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
write: async (batch, writeOptions = {}) => {
|
|
201
|
+
const telemetry = normalized.telemetry;
|
|
202
|
+
if (telemetry === undefined) {
|
|
203
|
+
if (disposed) {
|
|
204
|
+
return refuse(normalized, INVALID_KEY, "disposed", "This columnar batch cache was disposed.");
|
|
205
|
+
}
|
|
206
|
+
return writeEntry(storage, normalized, batch, writeOptions.signal);
|
|
207
|
+
}
|
|
208
|
+
const span = beginColumnarSpan(telemetry, "columnar-cache-write", batchIdentity(batch), {
|
|
209
|
+
batchId: readableBatchId(batch),
|
|
210
|
+
});
|
|
211
|
+
try {
|
|
212
|
+
const outcome = disposed
|
|
213
|
+
? refuse(normalized, INVALID_KEY, "disposed", "This columnar batch cache was disposed.")
|
|
214
|
+
: await writeEntry(storage, normalized, batch, writeOptions.signal);
|
|
215
|
+
span?.finish(cacheWriteDetail(outcome));
|
|
216
|
+
return outcome;
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
span?.fail(error);
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
delete: async (identity) => {
|
|
224
|
+
if (disposed)
|
|
225
|
+
return;
|
|
226
|
+
let key;
|
|
227
|
+
try {
|
|
228
|
+
key = await columnarBatchCacheKey(identity);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
await storage.remove(key);
|
|
235
|
+
}
|
|
236
|
+
catch (cause) {
|
|
237
|
+
report(normalized, "delete", "storage-failed", key, describe("Unable to delete the cached batch.", cause));
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
records: async () => {
|
|
241
|
+
if (disposed)
|
|
242
|
+
return Object.freeze([]);
|
|
243
|
+
try {
|
|
244
|
+
return (await storage.summaries()).filter((value) => isCacheRecord(value));
|
|
245
|
+
}
|
|
246
|
+
catch (cause) {
|
|
247
|
+
report(normalized, "read", "storage-failed", INVALID_KEY, describe("Unable to enumerate cached batches.", cause));
|
|
248
|
+
return Object.freeze([]);
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
clear: async () => {
|
|
252
|
+
if (disposed)
|
|
253
|
+
return;
|
|
254
|
+
try {
|
|
255
|
+
await storage.clear();
|
|
256
|
+
}
|
|
257
|
+
catch (cause) {
|
|
258
|
+
report(normalized, "delete", "storage-failed", INVALID_KEY, describe("Unable to clear the cache.", cause));
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
dispose: () => {
|
|
262
|
+
if (disposed)
|
|
263
|
+
return;
|
|
264
|
+
disposed = true;
|
|
265
|
+
storage.dispose?.();
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
return handle;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* In-process storage with the persistent backend's exact semantics: atomic
|
|
272
|
+
* write-plus-eviction, caller-owned reads, and no aliasing of a caller's bytes.
|
|
273
|
+
*/
|
|
274
|
+
export function createMemoryColumnarBatchCacheStorage() {
|
|
275
|
+
const entries = new Map();
|
|
276
|
+
return {
|
|
277
|
+
summaries: async () => [...entries.values()].map((entry) => entry.record),
|
|
278
|
+
read: async (key) => {
|
|
279
|
+
const entry = entries.get(key);
|
|
280
|
+
return entry ? { record: entry.record, envelope: entry.envelope.slice() } : undefined;
|
|
281
|
+
},
|
|
282
|
+
write: async (entry, evictKeys) => {
|
|
283
|
+
for (const key of evictKeys)
|
|
284
|
+
entries.delete(key);
|
|
285
|
+
entries.set(entry.record.key, { record: entry.record, envelope: entry.envelope.slice() });
|
|
286
|
+
},
|
|
287
|
+
remove: async (key) => {
|
|
288
|
+
entries.delete(key);
|
|
289
|
+
},
|
|
290
|
+
clear: async () => entries.clear(),
|
|
291
|
+
dispose: () => entries.clear(),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
async function readEntry(storage, options, identity, signal) {
|
|
295
|
+
if (signal?.aborted)
|
|
296
|
+
return miss(options, INVALID_KEY, "aborted", "The read was aborted before it started.");
|
|
297
|
+
let key;
|
|
298
|
+
let scopeDigest;
|
|
299
|
+
try {
|
|
300
|
+
key = await columnarBatchCacheKey(identity);
|
|
301
|
+
scopeDigest = await columnarAuthorizationScopeDigest(identity.authorizationScope);
|
|
302
|
+
}
|
|
303
|
+
catch (cause) {
|
|
304
|
+
return miss(options, INVALID_KEY, "corrupt-record", describe("The identity could not be keyed.", cause));
|
|
305
|
+
}
|
|
306
|
+
const settled = await settle(storage.read(key), signal);
|
|
307
|
+
if (settled.status === "aborted")
|
|
308
|
+
return miss(options, key, "aborted", "The read was aborted.");
|
|
309
|
+
if (settled.status === "failed") {
|
|
310
|
+
return miss(options, key, "storage-failed", describe("Unable to read the cached batch.", settled.error));
|
|
311
|
+
}
|
|
312
|
+
const entry = settled.value;
|
|
313
|
+
if (entry === undefined || entry === null) {
|
|
314
|
+
return { outcome: "miss", key, reason: "absent" };
|
|
315
|
+
}
|
|
316
|
+
if (!isStoredEntry(entry)) {
|
|
317
|
+
return discard(storage, options, key, "corrupt-record", "The stored cache entry is unreadable.");
|
|
318
|
+
}
|
|
319
|
+
const record = entry.record;
|
|
320
|
+
if (record.format !== HONUA_COLUMNAR_BATCH_CACHE_FORMAT) {
|
|
321
|
+
return discard(storage, options, key, "foreign-format", "The stored record was written in another format.");
|
|
322
|
+
}
|
|
323
|
+
if (!isCacheRecord(record) || record.key !== key) {
|
|
324
|
+
return discard(storage, options, key, "corrupt-record", "The stored record is corrupt.");
|
|
325
|
+
}
|
|
326
|
+
if (record.authorizationScopeDigest !== scopeDigest) {
|
|
327
|
+
// The key already binds the scope digest, so a mismatch here means the
|
|
328
|
+
// stored bytes were rewritten under this key. Serving them would be exactly
|
|
329
|
+
// the cross-scope read the key model exists to prevent.
|
|
330
|
+
return discard(storage, options, key, "authorization-scope-mismatch", "The stored batch was produced under a different authorization scope.");
|
|
331
|
+
}
|
|
332
|
+
const mismatch = identityMismatch(record, identity);
|
|
333
|
+
if (mismatch)
|
|
334
|
+
return discard(storage, options, key, "identity-mismatch", mismatch);
|
|
335
|
+
if (entry.envelope.byteLength !== record.byteLength) {
|
|
336
|
+
return discard(storage, options, key, "integrity-mismatch", "The stored payload length does not match the record.");
|
|
337
|
+
}
|
|
338
|
+
let integrity;
|
|
339
|
+
try {
|
|
340
|
+
integrity = await sha256(entry.envelope);
|
|
341
|
+
}
|
|
342
|
+
catch (cause) {
|
|
343
|
+
// Without a digest the payload cannot be proven intact, and unverified
|
|
344
|
+
// bytes are never served.
|
|
345
|
+
return miss(options, key, "digest-unavailable", describe("Cache integrity requires Web Crypto SHA-256.", cause));
|
|
346
|
+
}
|
|
347
|
+
if (integrity !== record.integrity) {
|
|
348
|
+
return discard(storage, options, key, "integrity-mismatch", "The stored payload failed its integrity check.");
|
|
349
|
+
}
|
|
350
|
+
if (signal?.aborted)
|
|
351
|
+
return miss(options, key, "aborted", "The read was aborted.");
|
|
352
|
+
const stale = staleness(record, options);
|
|
353
|
+
if (stale) {
|
|
354
|
+
report(options, "read", stale, key, `The cached batch is ${stale.replace("-", " ")}.`);
|
|
355
|
+
return { outcome: "stale", key, reason: stale, record };
|
|
356
|
+
}
|
|
357
|
+
let restored;
|
|
358
|
+
try {
|
|
359
|
+
restored = deserializeGeoArrowBatch(entry.envelope, options.serialization);
|
|
360
|
+
}
|
|
361
|
+
catch (cause) {
|
|
362
|
+
const reason = cause instanceof HonuaGeoArrowError && cause.code === "unsupported-serialization"
|
|
363
|
+
? "unsupported-serialization"
|
|
364
|
+
: "corrupt-record";
|
|
365
|
+
return discard(storage, options, key, reason, describe("The stored envelope could not be materialized.", cause));
|
|
366
|
+
}
|
|
367
|
+
const restoredIdentity = restored.batch.identity;
|
|
368
|
+
if (!restoredIdentity || restoredIdentity.authorizationScope !== record.authorizationScopeDigest) {
|
|
369
|
+
return discard(storage, options, key, "authorization-scope-mismatch", "The stored envelope identity does not match its record.");
|
|
370
|
+
}
|
|
371
|
+
if (restored.batch.rowCount !== record.rowCount) {
|
|
372
|
+
return discard(storage, options, key, "corrupt-record", "The stored envelope row count does not match its record.");
|
|
373
|
+
}
|
|
374
|
+
// Safe only because the scope digest above already matched this exact scope:
|
|
375
|
+
// the caller gets its own scope back, never a value read out of storage.
|
|
376
|
+
const batch = Object.freeze({
|
|
377
|
+
...restored.batch,
|
|
378
|
+
identity: Object.freeze({ ...restoredIdentity, authorizationScope: identity.authorizationScope }),
|
|
379
|
+
});
|
|
380
|
+
return { outcome: "hit", key, batch, metrics: restored.metrics, record };
|
|
381
|
+
}
|
|
382
|
+
async function writeEntry(storage, options, batch, signal) {
|
|
383
|
+
if (signal?.aborted)
|
|
384
|
+
return refuse(options, INVALID_KEY, "aborted", "The write was aborted before it started.");
|
|
385
|
+
let identity;
|
|
386
|
+
try {
|
|
387
|
+
identity = assertIdentity(batch?.identity);
|
|
388
|
+
}
|
|
389
|
+
catch (cause) {
|
|
390
|
+
return refuse(options, INVALID_KEY, "invalid-batch", describe("The batch carries no cacheable identity.", cause));
|
|
391
|
+
}
|
|
392
|
+
const leak = findIdentityCredentialLeak(batch, identity);
|
|
393
|
+
if (leak) {
|
|
394
|
+
// Refusing to persist is deliberate: the in-memory batch is unaffected and
|
|
395
|
+
// the next read simply refetches.
|
|
396
|
+
return refuse(options, INVALID_KEY, "credential-screened", credentialScreenMessage(leak.path, leak.reason));
|
|
397
|
+
}
|
|
398
|
+
let key;
|
|
399
|
+
let scopeDigest;
|
|
400
|
+
try {
|
|
401
|
+
key = await columnarBatchCacheKey(identity);
|
|
402
|
+
scopeDigest = await columnarAuthorizationScopeDigest(identity.authorizationScope);
|
|
403
|
+
}
|
|
404
|
+
catch (cause) {
|
|
405
|
+
return refuse(options, INVALID_KEY, "digest-unavailable", describe("The identity could not be keyed.", cause));
|
|
406
|
+
}
|
|
407
|
+
let envelope;
|
|
408
|
+
try {
|
|
409
|
+
// The raw authorization scope is replaced by its digest before anything is
|
|
410
|
+
// serialized, so no persisted byte carries the caller's scope value.
|
|
411
|
+
const persisted = {
|
|
412
|
+
...batch,
|
|
413
|
+
identity: { ...identity, authorizationScope: scopeDigest },
|
|
414
|
+
};
|
|
415
|
+
envelope = serializeGeoArrowBatch(persisted, options.serialization);
|
|
416
|
+
}
|
|
417
|
+
catch (cause) {
|
|
418
|
+
const reason = cause instanceof HonuaGeoArrowError && cause.code === "serialization-limit-exceeded"
|
|
419
|
+
? "serialization-limit-exceeded"
|
|
420
|
+
: "invalid-batch";
|
|
421
|
+
return refuse(options, key, reason, describe("The batch could not be serialized for the cache.", cause));
|
|
422
|
+
}
|
|
423
|
+
let integrity;
|
|
424
|
+
try {
|
|
425
|
+
integrity = await sha256(envelope);
|
|
426
|
+
}
|
|
427
|
+
catch (cause) {
|
|
428
|
+
return refuse(options, key, "digest-unavailable", describe("Cache integrity requires Web Crypto SHA-256.", cause));
|
|
429
|
+
}
|
|
430
|
+
if (signal?.aborted)
|
|
431
|
+
return refuse(options, key, "aborted", "The write was aborted.");
|
|
432
|
+
const summaries = await settle(storage.summaries(), signal);
|
|
433
|
+
if (summaries.status === "aborted")
|
|
434
|
+
return refuse(options, key, "aborted", "The write was aborted.");
|
|
435
|
+
if (summaries.status === "failed") {
|
|
436
|
+
return refuse(options, key, "storage-failed", describe("Unable to enumerate the cache.", summaries.error));
|
|
437
|
+
}
|
|
438
|
+
const existing = summaries.value.filter((value) => isCacheRecord(value));
|
|
439
|
+
const admission = planColumnarBatchCacheAdmission(existing, { key, byteLength: envelope.byteLength }, { quotaBytes: options.quotaBytes, maxRecords: options.maxRecords });
|
|
440
|
+
if (!admission.admitted) {
|
|
441
|
+
return refuse(options, key, "quota-exceeded", `The batch needs ${envelope.byteLength} bytes; the cache quota is ${options.quotaBytes}.`);
|
|
442
|
+
}
|
|
443
|
+
const record = Object.freeze({
|
|
444
|
+
key,
|
|
445
|
+
format: HONUA_COLUMNAR_BATCH_CACHE_FORMAT,
|
|
446
|
+
envelopeVersion: HONUA_GEOARROW_ENVELOPE_VERSION,
|
|
447
|
+
sourceId: identity.sourceId,
|
|
448
|
+
sourceVersion: identity.sourceVersion,
|
|
449
|
+
schemaVersion: identity.schemaVersion,
|
|
450
|
+
planId: identity.planId,
|
|
451
|
+
authorizationScopeDigest: scopeDigest,
|
|
452
|
+
orderingDigest: await columnarBatchCacheOrderingDigest(identity),
|
|
453
|
+
freshness: Object.freeze(cacheFreshness(identity.freshness)),
|
|
454
|
+
rowCount: batch.rowCount,
|
|
455
|
+
byteLength: envelope.byteLength,
|
|
456
|
+
integrity,
|
|
457
|
+
observedAt: new Date(options.now()).toISOString(),
|
|
458
|
+
});
|
|
459
|
+
const written = await settle(storage.write({ record, envelope }, admission.evictKeys), signal);
|
|
460
|
+
if (written.status === "aborted")
|
|
461
|
+
return refuse(options, key, "aborted", "The write was aborted.");
|
|
462
|
+
if (written.status === "failed") {
|
|
463
|
+
const reason = isStorageQuotaPressureError(written.error)
|
|
464
|
+
? "quota-pressure"
|
|
465
|
+
: "storage-failed";
|
|
466
|
+
return refuse(options, key, reason, describe("Unable to persist the batch.", written.error));
|
|
467
|
+
}
|
|
468
|
+
return { outcome: "stored", key, record, admission };
|
|
469
|
+
}
|
|
470
|
+
/** Delete an unusable entry and report the miss it produced. */
|
|
471
|
+
async function discard(storage, options, key, reason, detail) {
|
|
472
|
+
try {
|
|
473
|
+
await storage.remove(key);
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
// The entry is already unusable; failing to delete it must not change the
|
|
477
|
+
// outcome, which is a miss either way.
|
|
478
|
+
}
|
|
479
|
+
return miss(options, key, reason, detail);
|
|
480
|
+
}
|
|
481
|
+
function miss(options, key, reason, detail) {
|
|
482
|
+
report(options, "read", reason, key, detail);
|
|
483
|
+
return { outcome: "miss", key, reason };
|
|
484
|
+
}
|
|
485
|
+
function refuse(options, key, reason, detail) {
|
|
486
|
+
report(options, "write", reason, key, detail);
|
|
487
|
+
return { outcome: "refused", key, reason, detail };
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Cache spans report the discriminants the operation already decided —
|
|
491
|
+
* `hit`/`stale`/`miss` and their reason, the key digest, and for a hit the
|
|
492
|
+
* quantities the read returned. The key is a digest, so it carries no identity
|
|
493
|
+
* value, and the raw authorization scope never appears in either.
|
|
494
|
+
*/
|
|
495
|
+
function cacheReadDetail(outcome) {
|
|
496
|
+
if (outcome.outcome === "hit") {
|
|
497
|
+
return {
|
|
498
|
+
key: outcome.key,
|
|
499
|
+
outcome: "hit",
|
|
500
|
+
rowCount: outcome.record.rowCount,
|
|
501
|
+
byteLength: outcome.record.byteLength,
|
|
502
|
+
envelopeVersion: outcome.record.envelopeVersion,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
if (outcome.outcome === "stale") {
|
|
506
|
+
return { key: outcome.key, outcome: "stale", reason: outcome.reason, rowCount: outcome.record.rowCount };
|
|
507
|
+
}
|
|
508
|
+
return { key: outcome.key, outcome: "miss", reason: outcome.reason };
|
|
509
|
+
}
|
|
510
|
+
/** Write spans additionally report the eviction the admission plan performed. */
|
|
511
|
+
function cacheWriteDetail(outcome) {
|
|
512
|
+
if (outcome.outcome === "refused") {
|
|
513
|
+
return { key: outcome.key, outcome: "refused", reason: outcome.reason };
|
|
514
|
+
}
|
|
515
|
+
return {
|
|
516
|
+
key: outcome.key,
|
|
517
|
+
outcome: "stored",
|
|
518
|
+
byteLength: outcome.record.byteLength,
|
|
519
|
+
rowCount: outcome.record.rowCount,
|
|
520
|
+
evictedRecords: outcome.admission.evictKeys.length,
|
|
521
|
+
evictedBytes: outcome.admission.evictedBytes,
|
|
522
|
+
bytesAfter: outcome.admission.bytesAfter,
|
|
523
|
+
recordsAfter: outcome.admission.recordsAfter,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
/** Read a write candidate's identity and id without letting a malformed batch throw. */
|
|
527
|
+
function batchIdentity(batch) {
|
|
528
|
+
try {
|
|
529
|
+
return batch?.identity;
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
return undefined;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
function readableBatchId(batch) {
|
|
536
|
+
try {
|
|
537
|
+
const id = batch?.id;
|
|
538
|
+
return typeof id === "string" ? id : undefined;
|
|
539
|
+
}
|
|
540
|
+
catch {
|
|
541
|
+
return undefined;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
function report(options, operation, reason, key, detail) {
|
|
545
|
+
if (!options.onDiagnostic)
|
|
546
|
+
return;
|
|
547
|
+
options.onDiagnostic(Object.freeze({
|
|
548
|
+
kind: "honua.columnar-batch-cache-diagnostic",
|
|
549
|
+
version: 1,
|
|
550
|
+
operation,
|
|
551
|
+
reason,
|
|
552
|
+
key,
|
|
553
|
+
detail,
|
|
554
|
+
}));
|
|
555
|
+
}
|
|
556
|
+
function staleness(record, options) {
|
|
557
|
+
const now = options.now();
|
|
558
|
+
const staleAfter = record.freshness.staleAfter;
|
|
559
|
+
if (staleAfter !== undefined) {
|
|
560
|
+
const horizon = Date.parse(staleAfter);
|
|
561
|
+
if (!Number.isFinite(horizon) || now > horizon)
|
|
562
|
+
return "freshness-expired";
|
|
563
|
+
}
|
|
564
|
+
const observed = Date.parse(record.freshness.observedAt);
|
|
565
|
+
if (!Number.isFinite(observed) || now - observed > options.maxAgeMs)
|
|
566
|
+
return "max-age-exceeded";
|
|
567
|
+
return undefined;
|
|
568
|
+
}
|
|
569
|
+
function identityMismatch(record, identity) {
|
|
570
|
+
// The key already binds each of these; re-checking them makes a rewritten or
|
|
571
|
+
// colliding record a miss rather than a silent substitution.
|
|
572
|
+
if (record.sourceId !== identity.sourceId)
|
|
573
|
+
return "The stored batch belongs to another source.";
|
|
574
|
+
if (record.sourceVersion !== identity.sourceVersion)
|
|
575
|
+
return "The stored batch belongs to another source version.";
|
|
576
|
+
if (record.schemaVersion !== identity.schemaVersion)
|
|
577
|
+
return "The stored batch belongs to another schema version.";
|
|
578
|
+
if (record.planId !== identity.planId)
|
|
579
|
+
return "The stored batch belongs to another plan.";
|
|
580
|
+
const validators = freshnessValidators(identity.freshness);
|
|
581
|
+
if ((record.freshness.validator ?? null) !== (validators.validator ?? null)) {
|
|
582
|
+
return "The stored batch carries another source validator.";
|
|
583
|
+
}
|
|
584
|
+
if ((record.freshness.generation ?? null) !== (validators.generation ?? null)) {
|
|
585
|
+
return "The stored batch carries another source generation.";
|
|
586
|
+
}
|
|
587
|
+
return undefined;
|
|
588
|
+
}
|
|
589
|
+
function findIdentityCredentialLeak(batch, identity) {
|
|
590
|
+
const screened = [
|
|
591
|
+
["batch.id", batch.id],
|
|
592
|
+
["batch.schema.id", batch.schema?.id],
|
|
593
|
+
["identity.sourceId", identity.sourceId],
|
|
594
|
+
["identity.sourceVersion", identity.sourceVersion],
|
|
595
|
+
["identity.schemaVersion", identity.schemaVersion],
|
|
596
|
+
["identity.planId", identity.planId],
|
|
597
|
+
["identity.freshness.validator", identity.freshness.validator],
|
|
598
|
+
["identity.freshness.generation", identity.freshness.generation],
|
|
599
|
+
];
|
|
600
|
+
for (const [index, key] of (identity.ordering?.keys ?? []).entries()) {
|
|
601
|
+
screened.push([`identity.ordering.keys[${index}].field`, key?.field]);
|
|
602
|
+
}
|
|
603
|
+
for (const [path, value] of screened) {
|
|
604
|
+
if (typeof value !== "string")
|
|
605
|
+
continue;
|
|
606
|
+
const reason = screenPersistedString(value, "identity");
|
|
607
|
+
if (reason)
|
|
608
|
+
return { path, reason };
|
|
609
|
+
}
|
|
610
|
+
return undefined;
|
|
611
|
+
}
|
|
612
|
+
function cacheFreshness(freshness) {
|
|
613
|
+
return {
|
|
614
|
+
observedAt: freshness.observedAt,
|
|
615
|
+
...(freshness.staleAfter === undefined ? {} : { staleAfter: freshness.staleAfter }),
|
|
616
|
+
...freshnessValidators(freshness),
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
function freshnessValidators(freshness) {
|
|
620
|
+
return {
|
|
621
|
+
...(freshness.validator === undefined ? {} : { validator: freshness.validator }),
|
|
622
|
+
...(freshness.generation === undefined ? {} : { generation: freshness.generation }),
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
function orderingIdentity(identity) {
|
|
626
|
+
return {
|
|
627
|
+
stable: identity.ordering.stable === true,
|
|
628
|
+
keys: identity.ordering.keys.map((key) => ({
|
|
629
|
+
field: String(key.field),
|
|
630
|
+
direction: String(key.direction),
|
|
631
|
+
nulls: String(key.nulls),
|
|
632
|
+
})),
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Plain-object gate for every value a caller or backend hands in.
|
|
637
|
+
*
|
|
638
|
+
* The null test comes first on purpose. `typeof null === "object"` in
|
|
639
|
+
* JavaScript, so a `typeof` test alone admits `null`; writing the null test
|
|
640
|
+
* *after* the `typeof` refinement instead reads — to a reader and to static
|
|
641
|
+
* analysis alike — as a comparison against a case the refinement already
|
|
642
|
+
* excluded. Ordering it first keeps every shape gate below fail-closed and
|
|
643
|
+
* says exactly what it rejects.
|
|
644
|
+
*/
|
|
645
|
+
function isPlainRecord(value) {
|
|
646
|
+
return value !== null && typeof value === "object";
|
|
647
|
+
}
|
|
648
|
+
function assertIdentity(identity) {
|
|
649
|
+
const strings = ["sourceId", "sourceVersion", "schemaVersion", "planId", "authorizationScope"];
|
|
650
|
+
if (!isPlainRecord(identity)) {
|
|
651
|
+
throw new HonuaGeoArrowError("invalid-input", "A cacheable batch requires a ColumnarBatchIdentityV1.");
|
|
652
|
+
}
|
|
653
|
+
for (const name of strings) {
|
|
654
|
+
if (typeof identity[name] !== "string" || identity[name].length === 0) {
|
|
655
|
+
throw new HonuaGeoArrowError("invalid-input", `Batch identity ${name} must be a non-empty string.`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
if (!isPlainRecord(identity.ordering) || !Array.isArray(identity.ordering.keys)) {
|
|
659
|
+
throw new HonuaGeoArrowError("invalid-input", "Batch identity ordering must declare its keys.");
|
|
660
|
+
}
|
|
661
|
+
if (!isPlainRecord(identity.freshness)) {
|
|
662
|
+
throw new HonuaGeoArrowError("invalid-input", "Batch identity freshness must be an object.");
|
|
663
|
+
}
|
|
664
|
+
if (typeof identity.freshness.observedAt !== "string" ||
|
|
665
|
+
!Number.isFinite(Date.parse(identity.freshness.observedAt))) {
|
|
666
|
+
throw new HonuaGeoArrowError("invalid-input", "Batch identity freshness.observedAt must be an RFC 3339 instant.");
|
|
667
|
+
}
|
|
668
|
+
return identity;
|
|
669
|
+
}
|
|
670
|
+
function isStoredEntry(value) {
|
|
671
|
+
if (!isPlainRecord(value))
|
|
672
|
+
return false;
|
|
673
|
+
return isPlainRecord(value.record) && ArrayBuffer.isView(value.envelope);
|
|
674
|
+
}
|
|
675
|
+
/** Shape gate for anything a backend hands back as a record. */
|
|
676
|
+
export function isCacheRecord(value) {
|
|
677
|
+
if (!isPlainRecord(value))
|
|
678
|
+
return false;
|
|
679
|
+
const record = value;
|
|
680
|
+
if (record.format !== HONUA_COLUMNAR_BATCH_CACHE_FORMAT)
|
|
681
|
+
return false;
|
|
682
|
+
for (const name of ["key", "envelopeVersion", "sourceId", "sourceVersion", "schemaVersion", "planId"]) {
|
|
683
|
+
if (typeof record[name] !== "string" || record[name].length === 0)
|
|
684
|
+
return false;
|
|
685
|
+
}
|
|
686
|
+
for (const name of ["authorizationScopeDigest", "orderingDigest", "integrity", "key"]) {
|
|
687
|
+
const digest = record[name];
|
|
688
|
+
if (typeof digest !== "string" || !digest.startsWith("sha256:"))
|
|
689
|
+
return false;
|
|
690
|
+
}
|
|
691
|
+
if (!Number.isSafeInteger(record.rowCount) || record.rowCount < 0)
|
|
692
|
+
return false;
|
|
693
|
+
if (!Number.isSafeInteger(record.byteLength) || record.byteLength < 0)
|
|
694
|
+
return false;
|
|
695
|
+
if (typeof record.observedAt !== "string" || !Number.isFinite(Date.parse(record.observedAt)))
|
|
696
|
+
return false;
|
|
697
|
+
const freshness = record.freshness;
|
|
698
|
+
if (!isPlainRecord(freshness))
|
|
699
|
+
return false;
|
|
700
|
+
if (typeof freshness.observedAt !== "string")
|
|
701
|
+
return false;
|
|
702
|
+
for (const name of ["staleAfter", "validator", "generation"]) {
|
|
703
|
+
if (freshness[name] !== undefined && typeof freshness[name] !== "string")
|
|
704
|
+
return false;
|
|
705
|
+
}
|
|
706
|
+
return true;
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Settle as soon as `promise` resolves or `signal` aborts, whichever comes
|
|
710
|
+
* first. An aborted operation never waits for a backend that may be blocked
|
|
711
|
+
* behind another transaction.
|
|
712
|
+
*/
|
|
713
|
+
function settle(promise, signal) {
|
|
714
|
+
const resolved = promise.then((value) => ({ status: "settled", value }), (error) => ({ status: "failed", error }));
|
|
715
|
+
if (!signal)
|
|
716
|
+
return resolved;
|
|
717
|
+
if (signal.aborted)
|
|
718
|
+
return Promise.resolve({ status: "aborted" });
|
|
719
|
+
return new Promise((resolve) => {
|
|
720
|
+
const onAbort = () => resolve({ status: "aborted" });
|
|
721
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
722
|
+
void resolved.then((outcome) => {
|
|
723
|
+
signal.removeEventListener("abort", onAbort);
|
|
724
|
+
resolve(outcome);
|
|
725
|
+
});
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
function normalizeOptions(options) {
|
|
729
|
+
return {
|
|
730
|
+
quotaBytes: tightened(options.quotaBytes, DEFAULT_COLUMNAR_BATCH_CACHE_QUOTA_BYTES, MAX_COLUMNAR_BATCH_CACHE_QUOTA_BYTES, "quotaBytes"),
|
|
731
|
+
maxRecords: tightened(options.maxRecords, DEFAULT_COLUMNAR_BATCH_CACHE_MAX_RECORDS, MAX_COLUMNAR_BATCH_CACHE_MAX_RECORDS, "maxRecords"),
|
|
732
|
+
maxAgeMs: tightened(options.maxAgeMs, DEFAULT_COLUMNAR_BATCH_CACHE_MAX_AGE_MS, MAX_COLUMNAR_BATCH_CACHE_MAX_AGE_MS, "maxAgeMs"),
|
|
733
|
+
now: options.now ?? Date.now,
|
|
734
|
+
serialization: options.serialization ?? {},
|
|
735
|
+
...(options.onDiagnostic ? { onDiagnostic: options.onDiagnostic } : {}),
|
|
736
|
+
...(options.telemetry ? { telemetry: options.telemetry } : {}),
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
function tightened(value, fallback, ceiling, name) {
|
|
740
|
+
if (value === undefined)
|
|
741
|
+
return fallback;
|
|
742
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
743
|
+
throw new HonuaGeoArrowError("invalid-input", `${name} must be a safe integer greater than zero.`);
|
|
744
|
+
}
|
|
745
|
+
if (value > ceiling) {
|
|
746
|
+
throw new HonuaGeoArrowError("invalid-input", `${name} cannot exceed the ${ceiling} safety ceiling.`);
|
|
747
|
+
}
|
|
748
|
+
return value;
|
|
749
|
+
}
|
|
750
|
+
function compareCodeUnits(left, right) {
|
|
751
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
752
|
+
}
|
|
753
|
+
function describe(prefix, cause) {
|
|
754
|
+
if (cause instanceof HonuaGeoArrowError)
|
|
755
|
+
return `${prefix} ${cause.code}: ${cause.message}`;
|
|
756
|
+
return cause instanceof Error ? `${prefix} ${cause.message}` : prefix;
|
|
757
|
+
}
|