@honua/app-platform 0.1.12 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/columnar/apache-arrow.d.ts +17 -0
  2. package/columnar/apache-arrow.d.ts.map +1 -0
  3. package/columnar/apache-arrow.js +1036 -0
  4. package/columnar/batch-cache-conformance.d.ts +66 -0
  5. package/columnar/batch-cache-conformance.d.ts.map +1 -0
  6. package/columnar/batch-cache-conformance.js +317 -0
  7. package/columnar/batch-cache-indexeddb.d.ts +31 -0
  8. package/columnar/batch-cache-indexeddb.d.ts.map +1 -0
  9. package/columnar/batch-cache-indexeddb.js +148 -0
  10. package/columnar/batch-cache.d.ts +298 -0
  11. package/columnar/batch-cache.d.ts.map +1 -0
  12. package/columnar/batch-cache.js +757 -0
  13. package/columnar/geoarrow-aggregate.d.ts +178 -0
  14. package/columnar/geoarrow-aggregate.d.ts.map +1 -0
  15. package/columnar/geoarrow-aggregate.js +976 -0
  16. package/columnar/geoarrow-patch.d.ts +382 -0
  17. package/columnar/geoarrow-patch.d.ts.map +1 -0
  18. package/columnar/geoarrow-patch.js +1656 -0
  19. package/columnar/geoarrow-reproject.d.ts +22 -0
  20. package/columnar/geoarrow-reproject.d.ts.map +1 -0
  21. package/columnar/geoarrow-reproject.js +119 -0
  22. package/columnar/geoarrow-serialization.d.ts +68 -0
  23. package/columnar/geoarrow-serialization.d.ts.map +1 -0
  24. package/columnar/geoarrow-serialization.js +331 -0
  25. package/columnar/geoarrow-types.d.ts +271 -0
  26. package/columnar/geoarrow-types.d.ts.map +1 -0
  27. package/columnar/geoarrow-types.js +15 -0
  28. package/columnar/geoarrow.d.ts +52 -0
  29. package/columnar/geoarrow.d.ts.map +1 -0
  30. package/columnar/geoarrow.js +1469 -0
  31. package/columnar/index.d.ts +43 -0
  32. package/columnar/index.d.ts.map +1 -0
  33. package/columnar/index.js +29 -0
  34. package/columnar/operators.d.ts +70 -0
  35. package/columnar/operators.d.ts.map +1 -0
  36. package/columnar/operators.js +307 -0
  37. package/columnar/result.d.ts +369 -0
  38. package/columnar/result.d.ts.map +1 -0
  39. package/columnar/result.js +815 -0
  40. package/columnar/telemetry.d.ts +152 -0
  41. package/columnar/telemetry.d.ts.map +1 -0
  42. package/columnar/telemetry.js +265 -0
  43. package/columnar/transfer.d.ts +28 -0
  44. package/columnar/transfer.d.ts.map +1 -0
  45. package/columnar/transfer.js +669 -0
  46. package/columnar/types.d.ts +175 -0
  47. package/columnar/types.d.ts.map +1 -0
  48. package/columnar/types.js +22 -0
  49. package/columnar/worker.d.ts +157 -0
  50. package/columnar/worker.d.ts.map +1 -0
  51. package/columnar/worker.js +1059 -0
  52. package/contract/pmtiles.d.ts +6 -5
  53. package/contract/pmtiles.d.ts.map +1 -1
  54. package/contract/pmtiles.js +30 -5
  55. package/contract/source.d.ts.map +1 -1
  56. package/contract/source.js +11 -8
  57. package/controls/catalog.d.ts.map +1 -1
  58. package/controls/catalog.js +110 -0
  59. package/controls/index.d.ts +1 -1
  60. package/controls/index.js +1 -1
  61. package/controls/types.d.ts +1 -1
  62. package/controls/types.js +1 -1
  63. package/core/client.d.ts.map +1 -1
  64. package/core/client.js +87 -19
  65. package/core/deprecation-notice.d.ts +28 -0
  66. package/core/deprecation-notice.d.ts.map +1 -0
  67. package/core/deprecation-notice.js +22 -0
  68. package/core/esri-geojson.d.ts.map +1 -1
  69. package/core/esri-geojson.js +14 -8
  70. package/core/optional-grpc-peer.d.ts +15 -0
  71. package/core/optional-grpc-peer.d.ts.map +1 -0
  72. package/core/optional-grpc-peer.js +60 -0
  73. package/esri-compat/basemap-gallery.d.ts +2 -0
  74. package/esri-compat/basemap-gallery.d.ts.map +1 -1
  75. package/esri-compat/basemap-gallery.js +12 -0
  76. package/esri-compat/basemap.d.ts +4 -0
  77. package/esri-compat/basemap.d.ts.map +1 -1
  78. package/esri-compat/basemap.js +70 -0
  79. package/esri-compat/bookmarks.d.ts +2 -0
  80. package/esri-compat/bookmarks.d.ts.map +1 -1
  81. package/esri-compat/bookmarks.js +12 -0
  82. package/esri-compat/controls.d.ts +18 -0
  83. package/esri-compat/controls.d.ts.map +1 -1
  84. package/esri-compat/controls.js +120 -57
  85. package/esri-compat/directions.d.ts +4 -0
  86. package/esri-compat/directions.d.ts.map +1 -1
  87. package/esri-compat/directions.js +30 -20
  88. package/esri-compat/editor.d.ts +4 -0
  89. package/esri-compat/editor.d.ts.map +1 -1
  90. package/esri-compat/editor.js +16 -0
  91. package/esri-compat/esri-config.d.ts +2 -1
  92. package/esri-compat/esri-config.d.ts.map +1 -1
  93. package/esri-compat/feature-effect.d.ts +23 -0
  94. package/esri-compat/feature-effect.d.ts.map +1 -0
  95. package/esri-compat/feature-effect.js +12 -0
  96. package/esri-compat/feature-form.d.ts +6 -0
  97. package/esri-compat/feature-form.d.ts.map +1 -1
  98. package/esri-compat/feature-form.js +63 -0
  99. package/esri-compat/feature-layer.d.ts +21 -2
  100. package/esri-compat/feature-layer.d.ts.map +1 -1
  101. package/esri-compat/feature-layer.js +172 -1
  102. package/esri-compat/feature-table.d.ts +5 -0
  103. package/esri-compat/feature-table.d.ts.map +1 -1
  104. package/esri-compat/feature-table.js +23 -0
  105. package/esri-compat/identity-manager.d.ts +1 -1
  106. package/esri-compat/identity-manager.js +1 -1
  107. package/esri-compat/legend.d.ts +2 -0
  108. package/esri-compat/legend.d.ts.map +1 -1
  109. package/esri-compat/legend.js +98 -0
  110. package/esri-compat/locator.d.ts +1 -1
  111. package/esri-compat/locator.js +1 -1
  112. package/esri-compat/map-view-mount.d.ts +1 -0
  113. package/esri-compat/map-view-mount.d.ts.map +1 -1
  114. package/esri-compat/map-view-mount.js +46 -25
  115. package/esri-compat/map-view.d.ts +13 -0
  116. package/esri-compat/map-view.d.ts.map +1 -1
  117. package/esri-compat/map-view.js +94 -8
  118. package/esri-compat/map.d.ts +5 -1
  119. package/esri-compat/map.d.ts.map +1 -1
  120. package/esri-compat/map.js +10 -0
  121. package/esri-compat/measurement.d.ts +2 -0
  122. package/esri-compat/measurement.d.ts.map +1 -1
  123. package/esri-compat/measurement.js +14 -0
  124. package/esri-compat/point.d.ts +7 -0
  125. package/esri-compat/point.d.ts.map +1 -1
  126. package/esri-compat/point.js +33 -3
  127. package/esri-compat/popup.d.ts +2 -0
  128. package/esri-compat/popup.d.ts.map +1 -1
  129. package/esri-compat/popup.js +12 -0
  130. package/esri-compat/portal.d.ts +1 -1
  131. package/esri-compat/portal.js +1 -1
  132. package/esri-compat/print.d.ts +3 -0
  133. package/esri-compat/print.d.ts.map +1 -1
  134. package/esri-compat/print.js +15 -0
  135. package/esri-compat/search.d.ts +4 -0
  136. package/esri-compat/search.d.ts.map +1 -1
  137. package/esri-compat/search.js +25 -28
  138. package/esri-compat/sketch.d.ts +2 -0
  139. package/esri-compat/sketch.d.ts.map +1 -1
  140. package/esri-compat/sketch.js +12 -0
  141. package/esri-compat/widget-host.d.ts +46 -17
  142. package/esri-compat/widget-host.d.ts.map +1 -1
  143. package/esri-compat/widget-host.js +84 -20
  144. package/esri-compat/widget-shell-hosts.d.ts +61 -0
  145. package/esri-compat/widget-shell-hosts.d.ts.map +1 -0
  146. package/esri-compat/widget-shell-hosts.js +76 -0
  147. package/kernel/renderer.d.ts +82 -0
  148. package/kernel/renderer.js +1 -0
  149. package/offline/conflict-resolution.d.ts +183 -0
  150. package/offline/conflict-resolution.d.ts.map +1 -0
  151. package/offline/conflict-resolution.js +286 -0
  152. package/offline/credential-screen.d.ts +34 -0
  153. package/offline/credential-screen.d.ts.map +1 -0
  154. package/offline/credential-screen.js +78 -0
  155. package/offline/digest.d.ts +15 -0
  156. package/offline/digest.d.ts.map +1 -0
  157. package/offline/digest.js +58 -0
  158. package/offline/edit-queue.d.ts +403 -0
  159. package/offline/edit-queue.d.ts.map +1 -0
  160. package/offline/edit-queue.js +1807 -0
  161. package/offline/edit-replay.d.ts +116 -0
  162. package/offline/edit-replay.d.ts.map +1 -0
  163. package/offline/edit-replay.js +392 -0
  164. package/offline/fetch-handler.d.ts +12 -0
  165. package/offline/fetch-handler.d.ts.map +1 -0
  166. package/offline/fetch-handler.js +65 -0
  167. package/offline/index.d.ts +52 -0
  168. package/offline/index.d.ts.map +1 -0
  169. package/offline/index.js +34 -0
  170. package/offline/indexeddb.d.ts +135 -0
  171. package/offline/indexeddb.d.ts.map +1 -0
  172. package/offline/indexeddb.js +888 -0
  173. package/offline/memory-store.d.ts +17 -0
  174. package/offline/memory-store.d.ts.map +1 -0
  175. package/offline/memory-store.js +183 -0
  176. package/offline/quota.d.ts +161 -0
  177. package/offline/quota.d.ts.map +1 -0
  178. package/offline/quota.js +370 -0
  179. package/offline/read-gate.d.ts +175 -0
  180. package/offline/read-gate.d.ts.map +1 -0
  181. package/offline/read-gate.js +252 -0
  182. package/offline/read-resource.d.ts +99 -0
  183. package/offline/read-resource.d.ts.map +1 -0
  184. package/offline/read-resource.js +156 -0
  185. package/offline/read.d.ts +50 -0
  186. package/offline/read.d.ts.map +1 -0
  187. package/offline/read.js +65 -0
  188. package/offline/region.d.ts +29 -0
  189. package/offline/region.d.ts.map +1 -0
  190. package/offline/region.js +1007 -0
  191. package/offline/replay-conflict.d.ts +183 -0
  192. package/offline/replay-conflict.d.ts.map +1 -0
  193. package/offline/replay-conflict.js +482 -0
  194. package/offline/selection.d.ts +140 -0
  195. package/offline/selection.d.ts.map +1 -0
  196. package/offline/selection.js +391 -0
  197. package/offline/snapshot.d.ts +132 -0
  198. package/offline/snapshot.d.ts.map +1 -0
  199. package/offline/snapshot.js +312 -0
  200. package/offline/status.d.ts +136 -0
  201. package/offline/status.d.ts.map +1 -0
  202. package/offline/status.js +479 -0
  203. package/offline/store-conformance.d.ts +48 -0
  204. package/offline/store-conformance.d.ts.map +1 -0
  205. package/offline/store-conformance.js +233 -0
  206. package/offline/tile-protocol.d.ts +81 -0
  207. package/offline/tile-protocol.d.ts.map +1 -0
  208. package/offline/tile-protocol.js +119 -0
  209. package/offline/types.d.ts +349 -0
  210. package/offline/types.d.ts.map +1 -0
  211. package/offline/types.js +75 -0
  212. package/package.json +14 -2
  213. package/realtime/odata-delta.d.ts +1 -1
  214. package/realtime/odata-delta.d.ts.map +1 -1
  215. package/realtime/odata-delta.js +38 -10
  216. package/scene-workspace/index.d.ts +2 -2
  217. package/scene-workspace/index.d.ts.map +1 -1
  218. package/scene-workspace/index.js +1 -1
  219. package/scene-workspace/scene-discovery.d.ts +111 -91
  220. package/scene-workspace/scene-discovery.d.ts.map +1 -1
  221. package/scene-workspace/scene-discovery.js +340 -92
  222. package/scene-workspace/scene-view.d.ts +6 -6
  223. package/scene-workspace/scene-view.d.ts.map +1 -1
  224. package/scene-workspace/scene-view.js +32 -6
  225. package/web-components/elements.d.ts +34 -0
  226. package/web-components/elements.d.ts.map +1 -1
  227. package/web-components/elements.js +82 -8
  228. package/web-components/index.d.ts +2 -0
  229. package/web-components/index.d.ts.map +1 -1
  230. package/web-components/index.js +3 -1
  231. package/web-components/map-chrome.d.ts +103 -0
  232. package/web-components/map-chrome.d.ts.map +1 -0
  233. package/web-components/map-chrome.js +325 -0
  234. package/web-components/widget-shell.d.ts +68 -0
  235. package/web-components/widget-shell.d.ts.map +1 -0
  236. package/web-components/widget-shell.js +224 -0
  237. package/widget-capabilities.d.ts +67 -0
  238. 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
+ }