@walkthru-earth/objex-utils 1.3.0 → 1.4.0

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.
@@ -0,0 +1,145 @@
1
+ # stac-pushdown
2
+
3
+ Translate a `FacetState` into a native STAC API query plus CQL2-JSON, gated by what the endpoint advertises in `conformsTo`. Pair with [`stac-facets`](./stac-facets.md): push as much as the server supports, then apply the residual client-side.
4
+
5
+ ```ts
6
+ import {
7
+ type StacApiCapabilities,
8
+ type StacNativeQuery,
9
+ type ToNativeQueryOptions,
10
+ sniffApiCapabilities,
11
+ toNativeQuery,
12
+ toCql2Filter,
13
+ residualState,
14
+ } from '@walkthru-earth/objex-utils';
15
+ ```
16
+
17
+ Pure TypeScript, zero runtime deps.
18
+
19
+ ## Concept
20
+
21
+ A STAC API `/conformance` (or the `conformsTo` array on the landing page) advertises which extensions the endpoint implements. Different endpoints support different subsets:
22
+
23
+ - Earth Search v1: OGC API Features + STAC API Item Search + Filter (CQL2).
24
+ - Microsoft Planetary Computer: same as Earth Search plus Sortables / Queryables.
25
+ - A vanilla `pystac-server` with no Filter ext: bbox + datetime only, the rest must be done client-side.
26
+
27
+ `sniffApiCapabilities(conformsTo)` returns a flag set the rest of the module branches on. `toNativeQuery` emits whatever the endpoint can honor as native query params (`bbox`, `datetime`, `collections`); `toCql2Filter` emits a CQL2-JSON expression for the cloud-cover / GSD / platform / constellation / instruments / collection-when-no-native-cap dimensions; `residualState` returns the subset of `FacetState` neither was able to push, so the caller can apply it client-side via `applyFacets` (from `stac-facets`).
28
+
29
+ ## Types
30
+
31
+ ### `StacApiCapabilities`
32
+
33
+ ```ts
34
+ interface StacApiCapabilities {
35
+ bbox: boolean;
36
+ datetime: boolean;
37
+ collections: boolean;
38
+ cql2: boolean;
39
+ queryables: boolean;
40
+ }
41
+ ```
42
+
43
+ Every flag matches a regex against entries in the `conformsTo` array. False when the URI is absent, true when present.
44
+
45
+ ### `StacNativeQuery`
46
+
47
+ ```ts
48
+ interface StacNativeQuery {
49
+ bbox?: [number, number, number, number];
50
+ datetime?: string;
51
+ collections?: string[];
52
+ limit?: number;
53
+ filter?: unknown;
54
+ 'filter-lang'?: 'cql2-json';
55
+ }
56
+ ```
57
+
58
+ Superset of the standard `StacItemsQuery`. `filter` carries the CQL2-JSON object when the endpoint supports the Filter ext; absent otherwise.
59
+
60
+ ### `ToNativeQueryOptions`
61
+
62
+ ```ts
63
+ interface ToNativeQueryOptions {
64
+ limit?: number;
65
+ collections?: string[];
66
+ }
67
+ ```
68
+
69
+ `collections` lets the caller force a single-collection scope when classifying a Collection or Catalog (the Filter ext is not the right tool to constrain collection at the protocol level).
70
+
71
+ ## Functions
72
+
73
+ ### `sniffApiCapabilities(conformsTo)`
74
+
75
+ ```ts
76
+ function sniffApiCapabilities(conformsTo: readonly string[] | undefined): StacApiCapabilities;
77
+ ```
78
+
79
+ Regex-matches OGC API Features + STAC API Item Search + Filter extension URIs. Returns all-false when `conformsTo` is missing or empty.
80
+
81
+ ### `toNativeQuery(state, caps, opts?)`
82
+
83
+ ```ts
84
+ function toNativeQuery(
85
+ state: FacetState,
86
+ caps: StacApiCapabilities,
87
+ opts?: ToNativeQueryOptions
88
+ ): StacNativeQuery;
89
+ ```
90
+
91
+ Translates a `FacetState` into the native query shape. Drops anything the endpoint cannot honor. The caller still applies the residual client-side via `applyFacets(views, residualState(state, caps))`.
92
+
93
+ When `caps.cql2 === true` the cloud-cover / GSD / platform / constellation / instruments dimensions are baked into a CQL2-JSON `filter` plus `'filter-lang': 'cql2-json'`. When `caps.cql2 === false` they stay in the residual.
94
+
95
+ ### `toCql2Filter(state, caps)`
96
+
97
+ ```ts
98
+ function toCql2Filter(state: FacetState, caps: StacApiCapabilities): unknown | null;
99
+ ```
100
+
101
+ Emits the CQL2-JSON expression for the dimensions `caps.cql2` covers (cloud cover, GSD, platform, constellation, instruments, and collection when there is no native `collections=` cap). Returns `null` when nothing is pushable. Useful when callers want to compose the filter into a different request shape (POST `/search` with extra fields, etc.).
102
+
103
+ ### `residualState(state, caps)`
104
+
105
+ ```ts
106
+ function residualState(state: FacetState, caps: StacApiCapabilities): FacetState;
107
+ ```
108
+
109
+ Subtract everything `toNativeQuery` (or a hypothetical `toCql2Filter`) would push, return the rest. Pass to `applyFacets(views, residual)` to handle the client-side leftovers.
110
+
111
+ ## Recipe: STAC API source with push-down
112
+
113
+ ```ts
114
+ import {
115
+ sniffApiCapabilities,
116
+ toNativeQuery,
117
+ residualState,
118
+ applyFacets,
119
+ extractItemView,
120
+ type FacetState,
121
+ } from '@walkthru-earth/objex-utils';
122
+
123
+ async function loadFiltered(endpoint: string, state: FacetState) {
124
+ const landing = await fetch(endpoint).then((r) => r.json());
125
+ const caps = sniffApiCapabilities(landing.conformsTo);
126
+
127
+ const query = toNativeQuery(state, caps, { limit: 250 });
128
+ const url = new URL(`${endpoint}/search`);
129
+ for (const [k, v] of Object.entries(query)) {
130
+ url.searchParams.set(k, Array.isArray(v) ? v.join(',') : String(v));
131
+ }
132
+
133
+ const fc = await fetch(url).then((r) => r.json());
134
+ const views = fc.features.map(extractItemView);
135
+ const residual = residualState(state, caps);
136
+ return applyFacets(views, residual);
137
+ }
138
+ ```
139
+
140
+ ## Caveats
141
+
142
+ - The CQL2-JSON shape is the **JSON encoding** (RFC 8259 / OGC 21-065). Some servers only accept the **text encoding** at `/search` query strings, in which case POST to `/search` with `Content-Type: application/json` and a body of `{ "filter": <cql2-json>, "filter-lang": "cql2-json", ... }`.
143
+ - `sniffApiCapabilities` is regex-based; it does not fetch `/queryables` to verify which property keys the server actually exposes. If your caller enables push-down for a property the server does not index, the server may reject the request or silently return zero results. Test against your target catalogs.
144
+ - This module does **not** retry, paginate, or AbortSignal-handle. Wrap the request layer yourself.
145
+ - For a fully-orchestrated viewer (link-walking, abort, atomic-swap, item caps, footprint hover), see `StacMosaicViewer` in the main `@walkthru-earth/objex` package.
@@ -0,0 +1,224 @@
1
+ # stac-source
2
+
3
+ The `StacSource` contract, a unified async-iterable interface over the three STAC ingestion paths (STAC API, stac-geoparquet, self-contained static catalog) so a viewer has one orchestration loop and the UI can branch on capability flags instead of hard-coded discovery modes. Pure TypeScript, zero Svelte / maplibre / deck.gl / DuckDB on the import graph.
4
+
5
+ Source: `packages/objex-utils/src/stac-source.ts`.
6
+
7
+ ```ts
8
+ import {
9
+ type StacSource,
10
+ type StacSourceKind,
11
+ type StacSourceCapabilities,
12
+ type StacSourceRequest,
13
+ type StacSourceBatch,
14
+ emptyPushdown,
15
+ createApiSource,
16
+ createStaticSource,
17
+ } from '@walkthru-earth/objex-utils';
18
+ ```
19
+
20
+ ## Concept
21
+
22
+ A `StacSource` is constructed synchronously (no await, so the orchestrator can read `capabilities.kind` before any I/O) and exposes a single `query(req)` returning an `AsyncIterable<StacSourceBatch>`. Each batch reports the subset of the filter the engine pushed down (`pushedDown`) and the subset the caller must still apply client-side (`residual`, via `applyFacets` from [`stac-facets`](./stac-facets.md)). The split is per batch, not per source: a parquet file with a STRUCT `properties` column can push `eo:cloud_cover` while a sibling with an opaque `properties` cannot.
23
+
24
+ Two implementations ship here, `createApiSource` and `createStaticSource`. The third path, the stac-geoparquet `StacSource`, lives in the Svelte components package (`@walkthru-earth/objex`) under `query/`, because it needs DuckDB-WASM. It is deliberately NOT exported from `objex-utils` so the `utils/` side stays free of heavy deps.
25
+
26
+ ## Types
27
+
28
+ ### `StacSourceKind`
29
+
30
+ ```ts
31
+ type StacSourceKind = 'api' | 'parquet' | 'static';
32
+ ```
33
+
34
+ Which underlying engine drives the source. Used by the viewer to pick atomic-swap-vs-append, by the UI to choose copy and badges, and by tests.
35
+
36
+ ### `StacSourceCapabilities`
37
+
38
+ ```ts
39
+ interface StacSourceCapabilities {
40
+ kind: StacSourceKind;
41
+ label: string;
42
+ countAvailable: boolean;
43
+ streaming: boolean;
44
+ hivePartitioned?: boolean;
45
+ pushdown: {
46
+ bbox: boolean;
47
+ datetime: boolean;
48
+ collection: boolean;
49
+ cloudCover: boolean;
50
+ gsd: boolean;
51
+ epsg: boolean;
52
+ platform: boolean;
53
+ constellation: boolean;
54
+ instruments: boolean;
55
+ assetRoles: boolean;
56
+ };
57
+ }
58
+ ```
59
+
60
+ Read at construction (synchronous) and by the filter UI to decide which controls to disable or badge as "client-side only".
61
+
62
+ - `label` -- human-readable HUD label, e.g. `"STAC API"`, `"stac-geoparquet"`, `"Static catalog"`.
63
+ - `countAvailable` -- true when a cheap `count(filter, bbox)` exists, so the UI can surface "Y of X".
64
+ - `streaming` -- true when `query()` yields multiple batches before completing.
65
+ - `hivePartitioned` -- true when the source is a hive-partitioned parquet directory (set by the parquet source). Lets the viewer hint at the discovery model without inspecting `kind === 'parquet'` alone, since the same `kind` also covers single-file stac-geoparquet.
66
+ - `pushdown` -- the *ceiling* of what this source kind can push. Exhaustive: every `FacetState` facet has a flag, so adding a new facet is a compile-time error in every consumer until handled. The actual per-request push-down is reported in each batch's `pushedDown`.
67
+
68
+ ### `StacSourceRequest`
69
+
70
+ ```ts
71
+ interface StacSourceRequest {
72
+ bbox: [number, number, number, number];
73
+ filter: FacetState;
74
+ limit: number;
75
+ pageSize?: number;
76
+ signal: AbortSignal;
77
+ }
78
+ ```
79
+
80
+ Per-query inputs.
81
+
82
+ - `bbox` -- WGS84 viewport `[west, south, east, north]`, required. Sources that cannot push bbox still receive it, they stream the whole set and rely on the caller's residual filter.
83
+ - `filter` -- the active `FacetState` (from `stac-facets`).
84
+ - `limit` -- hard item cap for the request.
85
+ - `pageSize` -- optional per-page hint for paginating sources, the server may ignore it.
86
+ - `signal` -- required. Sources MUST throw `DOMException("Aborted", "AbortError")` on abort, never silently complete.
87
+
88
+ ### `StacSourceBatch`
89
+
90
+ ```ts
91
+ interface StacSourceBatch {
92
+ items: StacItem[];
93
+ pushedDown: FacetState;
94
+ residual: FacetState;
95
+ done: boolean;
96
+ totalHinted?: number;
97
+ }
98
+ ```
99
+
100
+ One yielded batch.
101
+
102
+ - `items` -- the items in this batch (empty on the terminal `done` batch).
103
+ - `pushedDown` -- the subset of `filter` the source / engine already applied, reported so the UI can show "pushed".
104
+ - `residual` -- the subset the caller must still apply via `applyFacets(views, residual)`.
105
+ - `done` -- true on the final batch. The iterator's own end-of-iteration also signals completion, this flag lets a caller break the loop the moment a single-yield source finishes.
106
+ - `totalHinted` -- best-effort total matching count, when the source knows it.
107
+
108
+ ### `StacSource`
109
+
110
+ ```ts
111
+ interface StacSource {
112
+ capabilities: StacSourceCapabilities;
113
+ query(req: StacSourceRequest): AsyncIterable<StacSourceBatch>;
114
+ count?(filter: FacetState, bbox: StacSourceRequest['bbox'], signal: AbortSignal): Promise<number>;
115
+ }
116
+ ```
117
+
118
+ The contract every implementation satisfies. `count` is optional, surfaced as "Y of X" only when present (and `capabilities.countAvailable` is true). Neither shipped implementation here defines `count`.
119
+
120
+ ## Functions
121
+
122
+ ### `emptyPushdown()`
123
+
124
+ ```ts
125
+ function emptyPushdown(): StacSourceCapabilities['pushdown'];
126
+ ```
127
+
128
+ Return an all-false push-down flag set, a terse base for capability declarations. Spread it and flip on only the flags a source supports, e.g. `{ ...emptyPushdown(), bbox: true, datetime: true }`.
129
+
130
+ ## Implementations
131
+
132
+ ### `createApiSource(kind, deps)` (`stac-source-api.ts`)
133
+
134
+ ```ts
135
+ function createApiSource(kind: StacRoutableKind, deps: StacApiSourceDeps): StacSource;
136
+
137
+ interface StacApiSourceDeps {
138
+ adapter: StorageAdapter;
139
+ baseHref: string;
140
+ urlToKey?: (absoluteUrl: string) => string | null;
141
+ concurrency?: number;
142
+ }
143
+ ```
144
+
145
+ Wraps `hydrateStacItems` (see [`stac-hydrate`](./stac-hydrate.md)) with `itemsQuery` push-down. `kind` is the classified payload (a Collection / Catalog with a `rel="items"` endpoint, or a STAC API `item-collection` page), the factory validates before dispatching, this function does not re-validate.
146
+
147
+ Advertised `capabilities.pushdown` is the CQL2 ceiling: `bbox`, `datetime`, `collection`, `cloudCover`, `gsd`, `platform`, `constellation`, `instruments`. `epsg` and `assetRoles` stay false. `streaming` is true, `countAvailable` is false.
148
+
149
+ Per-request behavior:
150
+
151
+ - On the first `query()`, lazily sniffs the source's `conformsTo` (via [`sniffApiCapabilities`](./stac-pushdown.md)) once and caches the resolved promise, later queries never re-fetch the root. The sniff reads `conformsTo` inline from the Collection / Catalog / item-collection payload when present, else fetches `baseHref` (routing through the adapter when `urlToKey` resolves it). Any failure degrades to a slice-1 fallback of `bbox` + `datetime` only, so the source never throws on construction or first query.
152
+ - Translates `req.filter` into a native query via `toNativeQuery` (bbox + datetime + CQL2 filter), maps it onto a `StacItemsQuery`, and threads it into `hydrateStacItems` so it rides every `rel="next"` page.
153
+ - Reports the actually-pushed `FacetState` by inverting `residualState`: `residual = residualState(req.filter, caps)` and `pushedDown = filter - residual`. Both ride every batch unchanged. `collections` cannot be stamped on `/items` (hydrate walks per-collection), so a collection constraint stays in the residual for the caller to apply client-side.
154
+ - Bridges hydrate's callback-based `onBatch` into the async iterable via an internal promise queue. Abort pushes a terminal `DOMException("Aborted", "AbortError")` that the iterator throws.
155
+
156
+ ### `createStaticSource(kind, deps)` (`stac-source-static.ts`)
157
+
158
+ ```ts
159
+ function createStaticSource(kind: StacRoutableKind, deps: StacStaticSourceDeps): StacSource;
160
+
161
+ interface StacStaticSourceDeps {
162
+ adapter: StorageAdapter;
163
+ baseHref: string;
164
+ urlToKey?: (absoluteUrl: string) => string | null;
165
+ concurrency?: number;
166
+ }
167
+ ```
168
+
169
+ Wraps `hydrateStacItems` with NO `itemsQuery`. The entire advertised tree is fetched and the caller filters everything client-side. Capabilities advertise an all-false `pushdown` (via `emptyPushdown()`), `label: 'Static catalog'`, `streaming: true`, `countAvailable: false`. Every batch reports `pushedDown: {}` and `residual: req.filter`. Same internal promise-queue bridge and the same abort contract as the API source.
170
+
171
+ ### The parquet source (not exported here)
172
+
173
+ The stac-geoparquet `StacSource` (`kind: 'parquet'`) lives in the Svelte components package `@walkthru-earth/objex` under `query/`, because it needs DuckDB-WASM to read parquet and push down predicates. It is intentionally absent from `objex-utils` so this package's import graph stays free of heavy deps. To use the row-level transforms it relies on, see [`stac-geoparquet`](./stac-geoparquet.md), which is pure and does ship here.
174
+
175
+ ## Example
176
+
177
+ ```ts
178
+ import {
179
+ classifyStac,
180
+ createApiSource,
181
+ createStaticSource,
182
+ hasStacItemsEndpoint,
183
+ applyFacets,
184
+ extractItemView,
185
+ emptyFacetState,
186
+ type StacSource,
187
+ type StacSourceBatch,
188
+ } from '@walkthru-earth/objex-utils';
189
+ import type { StorageAdapter } from '@walkthru-earth/objex-utils';
190
+
191
+ async function build(url: string, adapter: StorageAdapter): Promise<StacSource> {
192
+ const json = await fetch(url).then((r) => r.json());
193
+ const kind = classifyStac(json);
194
+ const serverBacked =
195
+ (kind.kind === 'collection' || kind.kind === 'catalog') &&
196
+ hasStacItemsEndpoint(kind.payload);
197
+ const deps = { adapter, baseHref: url };
198
+ return serverBacked ? createApiSource(kind, deps) : createStaticSource(kind, deps);
199
+ }
200
+
201
+ async function run(source: StacSource, bbox: [number, number, number, number]) {
202
+ const filter = emptyFacetState();
203
+ const controller = new AbortController();
204
+ const collected: ReturnType<typeof extractItemView>[] = [];
205
+
206
+ for await (const batch of source.query({
207
+ bbox,
208
+ filter,
209
+ limit: 2000,
210
+ signal: controller.signal,
211
+ })) {
212
+ // Only re-filter what the engine could NOT push down.
213
+ const views = batch.items.map(extractItemView);
214
+ collected.push(...applyFacets(views, batch.residual));
215
+ if (batch.done) break;
216
+ }
217
+
218
+ return collected;
219
+ }
220
+ ```
221
+
222
+ ## Peer dependencies
223
+
224
+ None bundled. The implementations depend only on the caller-supplied `StorageAdapter` interface (re-exported from this package's host-side surface) and the pure sibling modules [`stac-hydrate`](./stac-hydrate.md), [`stac-pushdown`](./stac-pushdown.md), and [`stac-facets`](./stac-facets.md). No DuckDB, Svelte, maplibre, or deck.gl.
@@ -0,0 +1,146 @@
1
+ # STAC Storage Extension
2
+
3
+ Parser for the STAC Storage Extension (v1.0.0 and v2.0.0) that extracts connection-relevant hints (region, requester-pays, custom-S3 endpoint) from a STAC Item. Pure TypeScript, no fetch, no Svelte dependency.
4
+
5
+ Source: `packages/objex-utils/src/stac-storage-extension.ts`.
6
+
7
+ Inspired by lazycogs's `_storage_ext.py`. Recognized schema URLs:
8
+
9
+ - `https://stac-extensions.github.io/storage/v1.0.0/schema.json`
10
+ - `https://stac-extensions.github.io/storage/v2.0.0/schema.json`
11
+
12
+ The two versions carry storage metadata in different places:
13
+
14
+ - **v1** stores fields directly on item `properties` and/or per-asset (`storage:platform`, `storage:region`, `storage:requester_pays`, `storage:tier`). Asset-level fields take precedence over item-level fields. `storage:tier` is ignored (no obstore equivalent).
15
+ - **v2** stores a scheme map at `properties.storage:schemes` keyed by ref name, and each asset references one or more schemes via `storage:refs`. The first matching ref wins.
16
+
17
+ ## Types
18
+
19
+ ### `StorageExtensionVersion`
20
+
21
+ ```ts
22
+ type StorageExtensionVersion = '1.0.0' | '2.0.0';
23
+ ```
24
+
25
+ The two Storage Extension schema versions this parser recognizes.
26
+
27
+ ### `StorageHints`
28
+
29
+ ```ts
30
+ interface StorageHints {
31
+ platform: string | null;
32
+ region: string | null;
33
+ requesterPays: boolean;
34
+ endpoint: string | null;
35
+ }
36
+ ```
37
+
38
+ Connection-relevant hints extracted from the Storage Extension. All fields are nullable (except `requesterPays`, which is a plain boolean) so callers can merge selectively into existing config without clobbering user-set values.
39
+
40
+ | Field | Meaning |
41
+ |-------|---------|
42
+ | `platform` | Cloud platform, e.g. `'AWS'`, `'GCP'`, `'AZURE'`, `'MINIO'`. Uppercased. Null when absent. |
43
+ | `region` | Region code, e.g. `'us-west-2'`. Null when absent. |
44
+ | `requesterPays` | `true` when requester-pays must be set. `false` when absent or false. |
45
+ | `endpoint` | Concrete S3-compatible endpoint URL. Null unless a v2 `custom-s3` scheme carries a non-templated value. |
46
+
47
+ ## Functions
48
+
49
+ ### `emptyStorageHints()`
50
+
51
+ ```ts
52
+ function emptyStorageHints(): StorageHints
53
+ ```
54
+
55
+ Return an empty hints record (`platform: null`, `region: null`, `requesterPays: false`, `endpoint: null`). This is what `extractStorageHints` returns when the extension is absent or unparseable, so callers can treat the absent case and the present-but-empty case identically.
56
+
57
+ ### `detectStorageExtensionVersion(item)`
58
+
59
+ ```ts
60
+ function detectStorageExtensionVersion(item: StacItem): StorageExtensionVersion | null
61
+ ```
62
+
63
+ Scan `item.stac_extensions[]` for the Storage Extension schema URL and return its parsed version.
64
+
65
+ **Parameters**
66
+
67
+ | Name | Type | Meaning |
68
+ |------|------|---------|
69
+ | `item` | `StacItem` | The STAC Item to inspect. `stac_extensions` is read defensively, so a non-array or missing value returns `null`. |
70
+
71
+ **Returns** `StorageExtensionVersion | null`. Null when the extension is absent or the version is not one we recognize.
72
+
73
+ **Notes**
74
+
75
+ - Both the trailing `/schema.json` suffix and a leading `v` on the version segment are stripped before matching.
76
+ - An exact `'1.0.0'` or `'2.0.0'` is returned directly. For an unknown patch or minor (a hypothetical `v1.0.1`), the parser falls back to major-version detection: major `1` maps to `'1.0.0'` and major `2` maps to `'2.0.0'`, so the appropriate parse path still runs.
77
+
78
+ ### `extractStorageHints(item, assetKey?)`
79
+
80
+ ```ts
81
+ function extractStorageHints(item: StacItem, assetKey?: string): StorageHints
82
+ ```
83
+
84
+ Extract connection hints from a STAC Item. Dispatches on the detected Storage Extension version and returns `emptyStorageHints()` when the extension is absent or fails to parse.
85
+
86
+ **Parameters**
87
+
88
+ | Name | Type | Meaning |
89
+ |------|------|---------|
90
+ | `item` | `StacItem` | The STAC Item to parse. |
91
+ | `assetKey` | `string` (optional) | Scope the lookup to a specific asset. |
92
+
93
+ **`assetKey` behavior**
94
+
95
+ - **v1**: when given, that asset's `storage:*` overrides take precedence over item-level fields. When omitted, only item-level fields are read.
96
+ - **v2**: when given, that asset's `storage:refs[0]` (first ref present on the asset) resolves the item scheme. When omitted, the first scheme reachable from any asset's refs wins.
97
+
98
+ **Returns** `StorageHints`.
99
+
100
+ **v2 endpoint resolution**
101
+
102
+ The `endpoint` field is only populated for a scheme whose `type` is `'custom-s3'`. The parser prefers the scheme's explicit `endpoint` string, then falls back to its `platform` string, but only treats either as a concrete endpoint when it contains no URI-template variable (no `{`, e.g. `{region}`). Templated values are left as `null` so the caller does not wire a non-resolvable URL.
103
+
104
+ ### `applyStorageHintsToConnection(conn, hints)`
105
+
106
+ ```ts
107
+ function applyStorageHintsToConnection<T extends { region?: string; endpoint?: string }>(
108
+ conn: T,
109
+ hints: StorageHints
110
+ ): T
111
+ ```
112
+
113
+ Merge `region` and `endpoint` hints into a connection-shaped object, filling each field only when the existing value is empty. Returns a shallow copy. The generic accepts any object with optional `region` / `endpoint` keys, so it stays decoupled from the concrete `Connection` type.
114
+
115
+ **Notes**
116
+
117
+ - Only `region` and `endpoint` are merged. `platform` and `requesterPays` are not written by this helper.
118
+ - An existing non-empty `region` or `endpoint` on `conn` is never overwritten.
119
+ - The input object is not mutated; the returned value is a new object.
120
+
121
+ ## Example
122
+
123
+ ```ts
124
+ import {
125
+ detectStorageExtensionVersion,
126
+ extractStorageHints,
127
+ applyStorageHintsToConnection,
128
+ } from '@walkthru-earth/objex-utils';
129
+
130
+ const version = detectStorageExtensionVersion(item); // '1.0.0' | '2.0.0' | null
131
+
132
+ const hints = extractStorageHints(item, 'visual');
133
+ // {
134
+ // platform: 'AWS',
135
+ // region: 'us-west-2',
136
+ // requesterPays: false,
137
+ // endpoint: null
138
+ // }
139
+
140
+ // Pre-fill a new connection without clobbering anything the user already set.
141
+ const conn = applyStorageHintsToConnection(
142
+ { region: '', endpoint: '' },
143
+ hints
144
+ );
145
+ // { region: 'us-west-2', endpoint: '' }
146
+ ```