@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.
Files changed (233) 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/controls/catalog.d.ts.map +1 -1
  56. package/controls/catalog.js +110 -0
  57. package/controls/index.d.ts +1 -1
  58. package/controls/index.js +1 -1
  59. package/controls/types.d.ts +1 -1
  60. package/controls/types.js +1 -1
  61. package/core/client.d.ts.map +1 -1
  62. package/core/client.js +87 -19
  63. package/core/deprecation-notice.d.ts +28 -0
  64. package/core/deprecation-notice.d.ts.map +1 -0
  65. package/core/deprecation-notice.js +22 -0
  66. package/core/esri-geojson.d.ts.map +1 -1
  67. package/core/esri-geojson.js +10 -6
  68. package/core/optional-grpc-peer.d.ts +15 -0
  69. package/core/optional-grpc-peer.d.ts.map +1 -0
  70. package/core/optional-grpc-peer.js +60 -0
  71. package/esri-compat/basemap-gallery.d.ts +2 -0
  72. package/esri-compat/basemap-gallery.d.ts.map +1 -1
  73. package/esri-compat/basemap-gallery.js +12 -0
  74. package/esri-compat/basemap.d.ts +4 -0
  75. package/esri-compat/basemap.d.ts.map +1 -1
  76. package/esri-compat/basemap.js +70 -0
  77. package/esri-compat/bookmarks.d.ts +2 -0
  78. package/esri-compat/bookmarks.d.ts.map +1 -1
  79. package/esri-compat/bookmarks.js +12 -0
  80. package/esri-compat/controls.d.ts +18 -0
  81. package/esri-compat/controls.d.ts.map +1 -1
  82. package/esri-compat/controls.js +120 -57
  83. package/esri-compat/directions.d.ts +4 -0
  84. package/esri-compat/directions.d.ts.map +1 -1
  85. package/esri-compat/directions.js +30 -20
  86. package/esri-compat/editor.d.ts +4 -0
  87. package/esri-compat/editor.d.ts.map +1 -1
  88. package/esri-compat/editor.js +16 -0
  89. package/esri-compat/esri-config.d.ts +2 -1
  90. package/esri-compat/esri-config.d.ts.map +1 -1
  91. package/esri-compat/feature-effect.d.ts +23 -0
  92. package/esri-compat/feature-effect.d.ts.map +1 -0
  93. package/esri-compat/feature-effect.js +12 -0
  94. package/esri-compat/feature-form.d.ts +6 -0
  95. package/esri-compat/feature-form.d.ts.map +1 -1
  96. package/esri-compat/feature-form.js +63 -0
  97. package/esri-compat/feature-layer.d.ts +21 -2
  98. package/esri-compat/feature-layer.d.ts.map +1 -1
  99. package/esri-compat/feature-layer.js +172 -1
  100. package/esri-compat/feature-table.d.ts +5 -0
  101. package/esri-compat/feature-table.d.ts.map +1 -1
  102. package/esri-compat/feature-table.js +23 -0
  103. package/esri-compat/identity-manager.d.ts +1 -1
  104. package/esri-compat/identity-manager.js +1 -1
  105. package/esri-compat/legend.d.ts +2 -0
  106. package/esri-compat/legend.d.ts.map +1 -1
  107. package/esri-compat/legend.js +98 -0
  108. package/esri-compat/locator.d.ts +1 -1
  109. package/esri-compat/locator.js +1 -1
  110. package/esri-compat/map-view-mount.d.ts +1 -0
  111. package/esri-compat/map-view-mount.d.ts.map +1 -1
  112. package/esri-compat/map-view-mount.js +46 -25
  113. package/esri-compat/map-view.d.ts +13 -0
  114. package/esri-compat/map-view.d.ts.map +1 -1
  115. package/esri-compat/map-view.js +94 -8
  116. package/esri-compat/map.d.ts +5 -1
  117. package/esri-compat/map.d.ts.map +1 -1
  118. package/esri-compat/map.js +10 -0
  119. package/esri-compat/measurement.d.ts +2 -0
  120. package/esri-compat/measurement.d.ts.map +1 -1
  121. package/esri-compat/measurement.js +14 -0
  122. package/esri-compat/point.d.ts +7 -0
  123. package/esri-compat/point.d.ts.map +1 -1
  124. package/esri-compat/point.js +33 -3
  125. package/esri-compat/popup.d.ts +2 -0
  126. package/esri-compat/popup.d.ts.map +1 -1
  127. package/esri-compat/popup.js +12 -0
  128. package/esri-compat/portal.d.ts +1 -1
  129. package/esri-compat/portal.js +1 -1
  130. package/esri-compat/print.d.ts +3 -0
  131. package/esri-compat/print.d.ts.map +1 -1
  132. package/esri-compat/print.js +15 -0
  133. package/esri-compat/search.d.ts +4 -0
  134. package/esri-compat/search.d.ts.map +1 -1
  135. package/esri-compat/search.js +25 -28
  136. package/esri-compat/sketch.d.ts +2 -0
  137. package/esri-compat/sketch.d.ts.map +1 -1
  138. package/esri-compat/sketch.js +12 -0
  139. package/esri-compat/widget-host.d.ts +46 -17
  140. package/esri-compat/widget-host.d.ts.map +1 -1
  141. package/esri-compat/widget-host.js +84 -20
  142. package/esri-compat/widget-shell-hosts.d.ts +61 -0
  143. package/esri-compat/widget-shell-hosts.d.ts.map +1 -0
  144. package/esri-compat/widget-shell-hosts.js +76 -0
  145. package/kernel/renderer.d.ts +82 -0
  146. package/kernel/renderer.js +1 -0
  147. package/offline/conflict-resolution.d.ts +183 -0
  148. package/offline/conflict-resolution.d.ts.map +1 -0
  149. package/offline/conflict-resolution.js +286 -0
  150. package/offline/credential-screen.d.ts +34 -0
  151. package/offline/credential-screen.d.ts.map +1 -0
  152. package/offline/credential-screen.js +78 -0
  153. package/offline/digest.d.ts +15 -0
  154. package/offline/digest.d.ts.map +1 -0
  155. package/offline/digest.js +58 -0
  156. package/offline/edit-queue.d.ts +403 -0
  157. package/offline/edit-queue.d.ts.map +1 -0
  158. package/offline/edit-queue.js +1807 -0
  159. package/offline/edit-replay.d.ts +116 -0
  160. package/offline/edit-replay.d.ts.map +1 -0
  161. package/offline/edit-replay.js +392 -0
  162. package/offline/fetch-handler.d.ts +12 -0
  163. package/offline/fetch-handler.d.ts.map +1 -0
  164. package/offline/fetch-handler.js +65 -0
  165. package/offline/index.d.ts +52 -0
  166. package/offline/index.d.ts.map +1 -0
  167. package/offline/index.js +34 -0
  168. package/offline/indexeddb.d.ts +135 -0
  169. package/offline/indexeddb.d.ts.map +1 -0
  170. package/offline/indexeddb.js +888 -0
  171. package/offline/memory-store.d.ts +17 -0
  172. package/offline/memory-store.d.ts.map +1 -0
  173. package/offline/memory-store.js +183 -0
  174. package/offline/quota.d.ts +161 -0
  175. package/offline/quota.d.ts.map +1 -0
  176. package/offline/quota.js +370 -0
  177. package/offline/read-gate.d.ts +175 -0
  178. package/offline/read-gate.d.ts.map +1 -0
  179. package/offline/read-gate.js +252 -0
  180. package/offline/read-resource.d.ts +99 -0
  181. package/offline/read-resource.d.ts.map +1 -0
  182. package/offline/read-resource.js +156 -0
  183. package/offline/read.d.ts +50 -0
  184. package/offline/read.d.ts.map +1 -0
  185. package/offline/read.js +65 -0
  186. package/offline/region.d.ts +29 -0
  187. package/offline/region.d.ts.map +1 -0
  188. package/offline/region.js +1007 -0
  189. package/offline/replay-conflict.d.ts +183 -0
  190. package/offline/replay-conflict.d.ts.map +1 -0
  191. package/offline/replay-conflict.js +482 -0
  192. package/offline/selection.d.ts +140 -0
  193. package/offline/selection.d.ts.map +1 -0
  194. package/offline/selection.js +391 -0
  195. package/offline/snapshot.d.ts +132 -0
  196. package/offline/snapshot.d.ts.map +1 -0
  197. package/offline/snapshot.js +312 -0
  198. package/offline/status.d.ts +136 -0
  199. package/offline/status.d.ts.map +1 -0
  200. package/offline/status.js +479 -0
  201. package/offline/store-conformance.d.ts +48 -0
  202. package/offline/store-conformance.d.ts.map +1 -0
  203. package/offline/store-conformance.js +233 -0
  204. package/offline/tile-protocol.d.ts +81 -0
  205. package/offline/tile-protocol.d.ts.map +1 -0
  206. package/offline/tile-protocol.js +119 -0
  207. package/offline/types.d.ts +349 -0
  208. package/offline/types.d.ts.map +1 -0
  209. package/offline/types.js +75 -0
  210. package/package.json +14 -2
  211. package/scene-workspace/index.d.ts +2 -2
  212. package/scene-workspace/index.d.ts.map +1 -1
  213. package/scene-workspace/index.js +1 -1
  214. package/scene-workspace/scene-discovery.d.ts +111 -91
  215. package/scene-workspace/scene-discovery.d.ts.map +1 -1
  216. package/scene-workspace/scene-discovery.js +340 -92
  217. package/scene-workspace/scene-view.d.ts +6 -6
  218. package/scene-workspace/scene-view.d.ts.map +1 -1
  219. package/scene-workspace/scene-view.js +32 -6
  220. package/web-components/elements.d.ts +34 -0
  221. package/web-components/elements.d.ts.map +1 -1
  222. package/web-components/elements.js +82 -8
  223. package/web-components/index.d.ts +2 -0
  224. package/web-components/index.d.ts.map +1 -1
  225. package/web-components/index.js +3 -1
  226. package/web-components/map-chrome.d.ts +103 -0
  227. package/web-components/map-chrome.d.ts.map +1 -0
  228. package/web-components/map-chrome.js +325 -0
  229. package/web-components/widget-shell.d.ts +68 -0
  230. package/web-components/widget-shell.d.ts.map +1 -0
  231. package/web-components/widget-shell.js +224 -0
  232. package/widget-capabilities.d.ts +67 -0
  233. package/widget-capabilities.js +152 -0
@@ -0,0 +1,66 @@
1
+ /**
2
+ * One conformance suite every columnar batch cache backend must pass (issue
3
+ * #940).
4
+ *
5
+ * The IndexedDB backend can only run in a browser and the in-memory backend can
6
+ * run anywhere, so the suite is written as plain async functions rather than
7
+ * against a test framework: vitest runs it over memory storage, and the browser
8
+ * evidence runs the identical cases over real IndexedDB. A backend that passes
9
+ * here is substitutable in {@link createColumnarBatchCache}.
10
+ *
11
+ * The cases are the security-relevant ones, not a smoke test: identity keying,
12
+ * scope isolation, freshness, integrity, envelope migration, quota eviction,
13
+ * credential screening, and abort.
14
+ *
15
+ * @experimental
16
+ */
17
+ import { type ColumnarBatchCacheRecordV1, type ColumnarBatchCacheStorage } from "./batch-cache.js";
18
+ import type { ColumnarBatchIdentityV1, ColumnarBatchV1 } from "./types.js";
19
+ export declare const HONUA_COLUMNAR_BATCH_CACHE_CONFORMANCE_KIND: "honua.columnar-batch-cache-conformance";
20
+ export declare const HONUA_COLUMNAR_BATCH_CACHE_CONFORMANCE_VERSION: "1.0";
21
+ export interface ColumnarBatchCacheConformanceOptions {
22
+ /** Must return storage with no entries. Called once per case. */
23
+ readonly createStorage: () => ColumnarBatchCacheStorage | Promise<ColumnarBatchCacheStorage>;
24
+ /** Optional teardown for the storage a case finished with. */
25
+ readonly disposeStorage?: (storage: ColumnarBatchCacheStorage) => void | Promise<void>;
26
+ /** Human label recorded on the report (for example `indexeddb`). */
27
+ readonly label?: string;
28
+ /** Run a subset by name. Unknown names fail the report rather than passing silently. */
29
+ readonly only?: readonly string[];
30
+ }
31
+ export interface ColumnarBatchCacheConformanceCaseResultV1 {
32
+ readonly name: string;
33
+ readonly status: "passed" | "failed";
34
+ readonly detail?: string;
35
+ }
36
+ export interface ColumnarBatchCacheConformanceReportV1 {
37
+ readonly kind: typeof HONUA_COLUMNAR_BATCH_CACHE_CONFORMANCE_KIND;
38
+ readonly version: typeof HONUA_COLUMNAR_BATCH_CACHE_CONFORMANCE_VERSION;
39
+ readonly label?: string;
40
+ readonly total: number;
41
+ readonly passed: number;
42
+ readonly failed: number;
43
+ readonly cases: readonly ColumnarBatchCacheConformanceCaseResultV1[];
44
+ }
45
+ /** Names of every case in the suite, in execution order. */
46
+ export declare const COLUMNAR_BATCH_CACHE_CONFORMANCE_CASES: readonly string[];
47
+ /**
48
+ * Run the suite and report per-case outcomes.
49
+ *
50
+ * Cases never share storage: each one is handed a fresh backend from
51
+ * `createStorage`, so a failure cannot cascade into a false failure downstream.
52
+ */
53
+ export declare function runColumnarBatchCacheConformance(options: ColumnarBatchCacheConformanceOptions): Promise<ColumnarBatchCacheConformanceReportV1>;
54
+ /** Deterministic fixture identity; overrides replace one component at a time. */
55
+ export declare function columnarBatchCacheFixtureIdentity(overrides?: Partial<ColumnarBatchIdentityV1>): ColumnarBatchIdentityV1;
56
+ /** Deterministic two-row GeoArrow batch bound to `identity`. */
57
+ export declare function columnarBatchCacheFixtureBatch(identity: ColumnarBatchIdentityV1): ColumnarBatchV1;
58
+ /**
59
+ * Build the entry an older SDK would have written: an envelope at layout
60
+ * version 1.0, with its record digest taken over those exact legacy bytes.
61
+ */
62
+ export declare function columnarBatchCacheLegacyEntry(identity: ColumnarBatchIdentityV1, now: number): Promise<{
63
+ record: ColumnarBatchCacheRecordV1;
64
+ envelope: Uint8Array;
65
+ }>;
66
+ //# sourceMappingURL=batch-cache-conformance.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"batch-cache-conformance.d.ts","sourceRoot":"","sources":["../../../src/columnar/batch-cache-conformance.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAGH,OAAO,EACL,KAAK,0BAA0B,EAC/B,KAAK,yBAAyB,EAK/B,MAAM,kBAAkB,CAAC;AAI1B,OAAO,KAAK,EAAE,uBAAuB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAE3E,eAAO,MAAM,2CAA2C,EAAG,wCAAiD,CAAC;AAC7G,eAAO,MAAM,8CAA8C,EAAG,KAAc,CAAC;AAE7E,MAAM,WAAW,oCAAoC;IACnD,iEAAiE;IACjE,QAAQ,CAAC,aAAa,EAAE,MAAM,yBAAyB,GAAG,OAAO,CAAC,yBAAyB,CAAC,CAAC;IAC7F,8DAA8D;IAC9D,QAAQ,CAAC,cAAc,CAAC,EAAE,CAAC,OAAO,EAAE,yBAAyB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvF,oEAAoE;IACpE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,wFAAwF;IACxF,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACnC;AAED,MAAM,WAAW,yCAAyC;IACxD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,QAAQ,GAAG,QAAQ,CAAC;IACrC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,qCAAqC;IACpD,QAAQ,CAAC,IAAI,EAAE,OAAO,2CAA2C,CAAC;IAClE,QAAQ,CAAC,OAAO,EAAE,OAAO,8CAA8C,CAAC;IACxE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,SAAS,yCAAyC,EAAE,CAAC;CACtE;AAaD,4DAA4D;AAC5D,eAAO,MAAM,sCAAsC,EAAE,SAAS,MAAM,EAYnE,CAAC;AAmMF;;;;;GAKG;AACH,wBAAsB,gCAAgC,CACpD,OAAO,EAAE,oCAAoC,GAC5C,OAAO,CAAC,qCAAqC,CAAC,CAoChD;AAED,iFAAiF;AACjF,wBAAgB,iCAAiC,CAC/C,SAAS,GAAE,OAAO,CAAC,uBAAuB,CAAM,GAC/C,uBAAuB,CAWzB;AAED,gEAAgE;AAChE,wBAAgB,8BAA8B,CAAC,QAAQ,EAAE,uBAAuB,GAAG,eAAe,CASjG;AAED;;;GAGG;AACH,wBAAsB,6BAA6B,CACjD,QAAQ,EAAE,uBAAuB,EACjC,GAAG,EAAE,MAAM,GACV,OAAO,CAAC;IAAE,MAAM,EAAE,0BAA0B,CAAC;IAAC,QAAQ,EAAE,UAAU,CAAA;CAAE,CAAC,CAmCvE"}
@@ -0,0 +1,317 @@
1
+ /**
2
+ * One conformance suite every columnar batch cache backend must pass (issue
3
+ * #940).
4
+ *
5
+ * The IndexedDB backend can only run in a browser and the in-memory backend can
6
+ * run anywhere, so the suite is written as plain async functions rather than
7
+ * against a test framework: vitest runs it over memory storage, and the browser
8
+ * evidence runs the identical cases over real IndexedDB. A backend that passes
9
+ * here is substitutable in {@link createColumnarBatchCache}.
10
+ *
11
+ * The cases are the security-relevant ones, not a smoke test: identity keying,
12
+ * scope isolation, freshness, integrity, envelope migration, quota eviction,
13
+ * credential screening, and abort.
14
+ *
15
+ * @experimental
16
+ */
17
+ import { sha256 } from "../offline/digest.js";
18
+ import { HONUA_COLUMNAR_BATCH_CACHE_FORMAT, columnarBatchCacheKey, columnarBatchCacheOrderingDigest, createColumnarBatchCache, } from "./batch-cache.js";
19
+ import { HONUA_GEOARROW_ENVELOPE_VERSION, serializeGeoArrowBatch } from "./geoarrow-serialization.js";
20
+ import { createGeoArrowBatch } from "./geoarrow.js";
21
+ import { columnarAuthorizationScopeDigest } from "./telemetry.js";
22
+ export const HONUA_COLUMNAR_BATCH_CACHE_CONFORMANCE_KIND = "honua.columnar-batch-cache-conformance";
23
+ export const HONUA_COLUMNAR_BATCH_CACHE_CONFORMANCE_VERSION = "1.0";
24
+ const OBSERVED_AT = "2026-07-30T00:00:00.000Z";
25
+ const NOW = Date.parse(OBSERVED_AT) + 1_000;
26
+ const QUOTA = 1024 * 1024;
27
+ // Short aliases for the exported fixtures the cases share.
28
+ const identity = columnarBatchCacheFixtureIdentity;
29
+ const fixtureBatch = columnarBatchCacheFixtureBatch;
30
+ const legacyEntry = columnarBatchCacheLegacyEntry;
31
+ /** Names of every case in the suite, in execution order. */
32
+ export const COLUMNAR_BATCH_CACHE_CONFORMANCE_CASES = [
33
+ "empty-cache-reports-no-records",
34
+ "write-then-read-returns-the-batch",
35
+ "read-returns-caller-owned-buffers",
36
+ "another-authorization-scope-never-reads-the-batch",
37
+ "expired-freshness-returns-an-explicit-stale-outcome",
38
+ "quota-overflow-evicts-oldest-first",
39
+ "tampered-payload-is-a-miss-and-is-deleted",
40
+ "foreign-record-format-is-discarded",
41
+ "legacy-envelope-is-migrated-forward",
42
+ "credential-bearing-identity-is-refused",
43
+ "aborted-read-settles-as-a-miss",
44
+ ];
45
+ const CASES = {
46
+ "empty-cache-reports-no-records": async (storage) => {
47
+ const cache = createColumnarBatchCache(storage, { now: () => NOW });
48
+ assert((await cache.records()).length === 0, "a fresh cache must report no records");
49
+ const read = await cache.read(identity());
50
+ assert(read.outcome === "miss", "a fresh cache must miss");
51
+ assert(read.outcome === "miss" && read.reason === "absent", "an empty cache must report an absent miss");
52
+ },
53
+ "write-then-read-returns-the-batch": async (storage) => {
54
+ const cache = createColumnarBatchCache(storage, { now: () => NOW });
55
+ const batch = fixtureBatch(identity());
56
+ const written = await cache.write(batch);
57
+ assert(written.outcome === "stored", `write must store the batch (${describeWrite(written)})`);
58
+ const read = await cache.read(identity());
59
+ assert(read.outcome === "hit", `a written batch must read back (${describeRead(read)})`);
60
+ if (read.outcome !== "hit")
61
+ return;
62
+ assert(read.batch.rowCount === batch.rowCount, "a hit must preserve the row count");
63
+ assert(read.metrics.envelopeVersion === HONUA_GEOARROW_ENVELOPE_VERSION, "a hit must report its envelope version");
64
+ assert(read.metrics.migrations.length === 0, "a current envelope must report no migrations");
65
+ // The raw authorization scope is never persisted; the caller's own scope is
66
+ // restored onto the returned batch.
67
+ assert(read.batch.identity?.authorizationScope === identity().authorizationScope, "a hit must restore the caller's authorization scope");
68
+ const records = await cache.records();
69
+ assert(records.length === 1, "one write must produce one record");
70
+ assert(records.every((record) => !JSON.stringify(record).includes(identity().authorizationScope)), "no record may contain the raw authorization scope");
71
+ },
72
+ "read-returns-caller-owned-buffers": async (storage) => {
73
+ const cache = createColumnarBatchCache(storage, { now: () => NOW });
74
+ await cache.write(fixtureBatch(identity()));
75
+ const first = await cache.read(identity());
76
+ assert(first.outcome === "hit", "a written batch must read back");
77
+ if (first.outcome !== "hit")
78
+ return;
79
+ for (const buffer of first.batch.buffers)
80
+ new Uint8Array(buffer.data).fill(0);
81
+ const second = await cache.read(identity());
82
+ assert(second.outcome === "hit", "mutating a returned batch must not corrupt the cache");
83
+ if (second.outcome !== "hit")
84
+ return;
85
+ assert(second.batch.buffers.some((buffer) => new Uint8Array(buffer.data).some((byte) => byte !== 0)), "a second read must return unmutated bytes");
86
+ },
87
+ "another-authorization-scope-never-reads-the-batch": async (storage) => {
88
+ const cache = createColumnarBatchCache(storage, { now: () => NOW });
89
+ await cache.write(fixtureBatch(identity()));
90
+ const foreign = await cache.read(identity({ authorizationScope: "tenant-b" }));
91
+ assert(foreign.outcome === "miss", "a foreign authorization scope must never hit");
92
+ // Cross-scope isolation also holds when a record is planted under the
93
+ // reader's own key: the recorded scope digest, not the key, decides.
94
+ const readerKey = await columnarBatchCacheKey(identity({ authorizationScope: "tenant-b" }));
95
+ const planted = await storage.read(await columnarBatchCacheKey(identity()));
96
+ if (isEntry(planted)) {
97
+ await storage.write({
98
+ record: { ...planted.record, key: readerKey },
99
+ envelope: planted.envelope,
100
+ }, []);
101
+ const rewritten = await cache.read(identity({ authorizationScope: "tenant-b" }));
102
+ assert(rewritten.outcome === "miss" && rewritten.reason === "authorization-scope-mismatch", "a record rewritten under another scope's key must be refused");
103
+ assert((await storage.read(readerKey)) === undefined, "a scope-mismatched record must be deleted, not left readable");
104
+ }
105
+ },
106
+ "expired-freshness-returns-an-explicit-stale-outcome": async (storage) => {
107
+ const staleAfter = new Date(NOW + 60_000).toISOString();
108
+ const cache = createColumnarBatchCache(storage, { now: () => NOW });
109
+ await cache.write(fixtureBatch(identity({ freshness: { observedAt: OBSERVED_AT, staleAfter } })));
110
+ const fresh = await cache.read(identity({ freshness: { observedAt: OBSERVED_AT, staleAfter } }));
111
+ assert(fresh.outcome === "hit", "a batch inside its freshness horizon must hit");
112
+ const expired = createColumnarBatchCache(storage, { now: () => NOW + 120_000 });
113
+ const read = await expired.read(identity({ freshness: { observedAt: OBSERVED_AT, staleAfter } }));
114
+ assert(read.outcome === "stale", `a batch past its horizon must be stale (${describeRead(read)})`);
115
+ assert(read.outcome === "stale" && read.reason === "freshness-expired", "stale must name the freshness horizon");
116
+ },
117
+ "quota-overflow-evicts-oldest-first": async (storage) => {
118
+ const first = identity({ planId: "plan-1" });
119
+ const second = identity({ planId: "plan-2" });
120
+ const cache = createColumnarBatchCache(storage, { now: () => NOW, maxRecords: 1, quotaBytes: QUOTA });
121
+ await cache.write(fixtureBatch(first));
122
+ const written = await createColumnarBatchCache(storage, {
123
+ now: () => NOW + 1_000,
124
+ maxRecords: 1,
125
+ quotaBytes: QUOTA,
126
+ }).write(fixtureBatch(second));
127
+ assert(written.outcome === "stored", "the second batch must be admitted");
128
+ assert(written.outcome === "stored" && written.admission.evictKeys.length === 1, "admitting past the record cap must evict exactly one entry");
129
+ const records = await cache.records();
130
+ assert(records.length === 1, "the record cap must hold after eviction");
131
+ assert((await cache.read(first)).outcome === "miss", "the evicted entry must be gone");
132
+ assert((await cache.read(second)).outcome === "hit", "the newest entry must survive eviction");
133
+ },
134
+ "tampered-payload-is-a-miss-and-is-deleted": async (storage) => {
135
+ const cache = createColumnarBatchCache(storage, { now: () => NOW });
136
+ await cache.write(fixtureBatch(identity()));
137
+ const key = await columnarBatchCacheKey(identity());
138
+ const stored = await storage.read(key);
139
+ assert(isEntry(stored), "the written entry must be readable from storage");
140
+ if (!isEntry(stored))
141
+ return;
142
+ const tampered = stored.envelope.slice();
143
+ // Flip one byte deep inside the base64 payload: the record's byte length
144
+ // still matches, so only the digest can catch it.
145
+ tampered[tampered.length - 8] = tampered[tampered.length - 8] === 65 ? 66 : 65;
146
+ await storage.write({ record: stored.record, envelope: tampered }, []);
147
+ const read = await cache.read(identity());
148
+ assert(read.outcome === "miss" && read.reason === "integrity-mismatch", `a tampered payload must miss on integrity (${describeRead(read)})`);
149
+ assert((await storage.read(key)) === undefined, "a tampered entry must be deleted, not left readable");
150
+ },
151
+ "foreign-record-format-is-discarded": async (storage) => {
152
+ const cache = createColumnarBatchCache(storage, { now: () => NOW });
153
+ await cache.write(fixtureBatch(identity()));
154
+ const key = await columnarBatchCacheKey(identity());
155
+ const stored = await storage.read(key);
156
+ assert(isEntry(stored), "the written entry must be readable from storage");
157
+ if (!isEntry(stored))
158
+ return;
159
+ await storage.write({
160
+ record: { ...stored.record, format: "honua.columnar-batch-cache/9.9" },
161
+ envelope: stored.envelope,
162
+ }, []);
163
+ const read = await cache.read(identity());
164
+ assert(read.outcome === "miss" && read.reason === "foreign-format", `a foreign record format must be refused (${describeRead(read)})`);
165
+ assert((await storage.read(key)) === undefined, "a foreign-format record must be deleted");
166
+ },
167
+ "legacy-envelope-is-migrated-forward": async (storage) => {
168
+ const cache = createColumnarBatchCache(storage, { now: () => NOW });
169
+ const entry = await legacyEntry(identity(), NOW);
170
+ await storage.write(entry, []);
171
+ const read = await cache.read(identity());
172
+ assert(read.outcome === "hit", `a 1.0 envelope must migrate forward (${describeRead(read)})`);
173
+ if (read.outcome !== "hit")
174
+ return;
175
+ assert(read.metrics.migrations.join(",") === "1.0->1.1", `the applied migration chain must be reported (${read.metrics.migrations.join(",")})`);
176
+ assert(read.batch.rowCount === 2, "a migrated batch must revalidate with its original rows");
177
+ },
178
+ "credential-bearing-identity-is-refused": async (storage) => {
179
+ const cache = createColumnarBatchCache(storage, { now: () => NOW });
180
+ const written = await cache.write(fixtureBatch(identity({ sourceId: "https://user:secret@example.test/a?token=1" })));
181
+ assert(written.outcome === "refused" && written.reason === "credential-screened", `a credential-bearing identity must be refused (${describeWrite(written)})`);
182
+ assert((await cache.records()).length === 0, "a refused write must persist nothing");
183
+ },
184
+ "aborted-read-settles-as-a-miss": async (storage) => {
185
+ const cache = createColumnarBatchCache(storage, { now: () => NOW });
186
+ await cache.write(fixtureBatch(identity()));
187
+ const controller = new AbortController();
188
+ controller.abort();
189
+ const read = await cache.read(identity(), { signal: controller.signal });
190
+ assert(read.outcome === "miss" && read.reason === "aborted", `an aborted read must settle as an aborted miss (${describeRead(read)})`);
191
+ },
192
+ };
193
+ /**
194
+ * Run the suite and report per-case outcomes.
195
+ *
196
+ * Cases never share storage: each one is handed a fresh backend from
197
+ * `createStorage`, so a failure cannot cascade into a false failure downstream.
198
+ */
199
+ export async function runColumnarBatchCacheConformance(options) {
200
+ const selected = options.only ?? COLUMNAR_BATCH_CACHE_CONFORMANCE_CASES;
201
+ const cases = [];
202
+ for (const name of selected) {
203
+ const body = CASES[name];
204
+ if (!body) {
205
+ cases.push({ name, status: "failed", detail: "unknown conformance case" });
206
+ continue;
207
+ }
208
+ let storage;
209
+ try {
210
+ storage = await options.createStorage();
211
+ await body(storage);
212
+ cases.push({ name, status: "passed" });
213
+ }
214
+ catch (error) {
215
+ cases.push({ name, status: "failed", detail: error instanceof Error ? error.message : String(error) });
216
+ }
217
+ finally {
218
+ if (storage && options.disposeStorage) {
219
+ try {
220
+ await options.disposeStorage(storage);
221
+ }
222
+ catch {
223
+ // Teardown failures must not rewrite a case's own verdict.
224
+ }
225
+ }
226
+ }
227
+ }
228
+ const failed = cases.filter((entry) => entry.status === "failed").length;
229
+ return {
230
+ kind: HONUA_COLUMNAR_BATCH_CACHE_CONFORMANCE_KIND,
231
+ version: HONUA_COLUMNAR_BATCH_CACHE_CONFORMANCE_VERSION,
232
+ ...(options.label ? { label: options.label } : {}),
233
+ total: cases.length,
234
+ passed: cases.length - failed,
235
+ failed,
236
+ cases,
237
+ };
238
+ }
239
+ /** Deterministic fixture identity; overrides replace one component at a time. */
240
+ export function columnarBatchCacheFixtureIdentity(overrides = {}) {
241
+ return {
242
+ sourceId: "parcels",
243
+ sourceVersion: "source-1",
244
+ schemaVersion: "parcels-v1",
245
+ planId: "plan-1",
246
+ authorizationScope: "tenant-a",
247
+ ordering: { stable: true, keys: [{ field: "feature_id", direction: "ascending", nulls: "last" }] },
248
+ freshness: { observedAt: OBSERVED_AT },
249
+ ...overrides,
250
+ };
251
+ }
252
+ /** Deterministic two-row GeoArrow batch bound to `identity`. */
253
+ export function columnarBatchCacheFixtureBatch(identity) {
254
+ return createGeoArrowBatch({
255
+ id: `parcels:${identity.planId}`,
256
+ sequence: 0,
257
+ schemaId: identity.schemaVersion,
258
+ identity,
259
+ geometry: { kind: "point", crs: "EPSG:4326", values: [[-157.86, 21.31], null] },
260
+ featureIds: { field: "feature_id", values: [7, 8] },
261
+ }).batch;
262
+ }
263
+ /**
264
+ * Build the entry an older SDK would have written: an envelope at layout
265
+ * version 1.0, with its record digest taken over those exact legacy bytes.
266
+ */
267
+ export async function columnarBatchCacheLegacyEntry(identity, now) {
268
+ const scopeDigest = await columnarAuthorizationScopeDigest(identity.authorizationScope);
269
+ const batch = columnarBatchCacheFixtureBatch({ ...identity, authorizationScope: scopeDigest });
270
+ const current = JSON.parse(new TextDecoder().decode(serializeGeoArrowBatch(batch)));
271
+ const legacy = {
272
+ ...current,
273
+ version: "1.0",
274
+ // Envelope 1.0 declared neither the layout stamp nor decoded lengths; the
275
+ // migration has to reconstruct both.
276
+ backings: current.backings.map(({ id, data }) => ({ id, data })),
277
+ };
278
+ delete legacy.layout;
279
+ const envelope = new TextEncoder().encode(JSON.stringify(legacy));
280
+ const key = await columnarBatchCacheKey(identity);
281
+ return {
282
+ record: {
283
+ key,
284
+ format: HONUA_COLUMNAR_BATCH_CACHE_FORMAT,
285
+ envelopeVersion: "1.0",
286
+ sourceId: identity.sourceId,
287
+ sourceVersion: identity.sourceVersion,
288
+ schemaVersion: identity.schemaVersion,
289
+ planId: identity.planId,
290
+ authorizationScopeDigest: scopeDigest,
291
+ orderingDigest: await columnarBatchCacheOrderingDigest(identity),
292
+ freshness: { observedAt: identity.freshness.observedAt },
293
+ rowCount: batch.rowCount,
294
+ byteLength: envelope.byteLength,
295
+ integrity: await sha256(envelope),
296
+ observedAt: new Date(now).toISOString(),
297
+ },
298
+ envelope,
299
+ };
300
+ }
301
+ // The null test precedes the `typeof` test because `typeof null === "object"`.
302
+ function isEntry(value) {
303
+ if (value === null || typeof value !== "object")
304
+ return false;
305
+ const entry = value;
306
+ return entry.envelope instanceof Uint8Array && entry.record !== null && typeof entry.record === "object";
307
+ }
308
+ function describeRead(read) {
309
+ return `${read.outcome}${read.reason ? `: ${read.reason}` : ""}`;
310
+ }
311
+ function describeWrite(write) {
312
+ return `${write.outcome}${write.reason ? `: ${write.reason}` : ""}`;
313
+ }
314
+ function assert(condition, message) {
315
+ if (!condition)
316
+ throw new Error(message);
317
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Browser backend for the columnar batch cache (issue #940).
3
+ *
4
+ * The store itself is backend-agnostic; this module is the only place that
5
+ * knows about IndexedDB, and it touches nothing at module scope, so importing
6
+ * `@honua/sdk-js/query-planner` on a server or in a worker without IndexedDB
7
+ * stays safe. `createIndexedDbColumnarBatchCacheStorage()` is the first line
8
+ * that can fail, and only when a host really has no IndexedDB factory.
9
+ *
10
+ * Two object stores are deliberate. `records` holds metadata only, so admission
11
+ * can enumerate every entry without deserializing megabytes of payload, and
12
+ * `payloads` holds the envelope bytes keyed identically. A write puts both and
13
+ * deletes every evicted key inside a **single** readwrite transaction, so the
14
+ * store is never observably over quota and an aborted transaction leaves the
15
+ * previous contents intact.
16
+ *
17
+ * @experimental
18
+ */
19
+ import type { ColumnarBatchCacheStorage } from "./batch-cache.js";
20
+ export interface IndexedDbColumnarBatchCacheStorageOptions {
21
+ /** Defaults to `honua-columnar-batch-cache`. Names are origin-scoped by IndexedDB. */
22
+ readonly name?: string;
23
+ /** Injectable for browser tests and hosts with an alternate IndexedDB factory. */
24
+ readonly indexedDB?: IDBFactory;
25
+ }
26
+ /**
27
+ * Persistent browser storage for serialized batches: metadata and payload
28
+ * written, and evictions applied, in one transaction.
29
+ */
30
+ export declare function createIndexedDbColumnarBatchCacheStorage(options?: IndexedDbColumnarBatchCacheStorageOptions): ColumnarBatchCacheStorage;
31
+ //# sourceMappingURL=batch-cache-indexeddb.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"batch-cache-indexeddb.d.ts","sourceRoot":"","sources":["../../../src/columnar/batch-cache-indexeddb.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAA6B,yBAAyB,EAAE,MAAM,kBAAkB,CAAC;AAO7F,MAAM,WAAW,yCAAyC;IACxD,sFAAsF;IACtF,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,kFAAkF;IAClF,QAAQ,CAAC,SAAS,CAAC,EAAE,UAAU,CAAC;CACjC;AAED;;;GAGG;AACH,wBAAgB,wCAAwC,CACtD,OAAO,GAAE,yCAA8C,GACtD,yBAAyB,CA2D3B"}
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Browser backend for the columnar batch cache (issue #940).
3
+ *
4
+ * The store itself is backend-agnostic; this module is the only place that
5
+ * knows about IndexedDB, and it touches nothing at module scope, so importing
6
+ * `@honua/sdk-js/query-planner` on a server or in a worker without IndexedDB
7
+ * stays safe. `createIndexedDbColumnarBatchCacheStorage()` is the first line
8
+ * that can fail, and only when a host really has no IndexedDB factory.
9
+ *
10
+ * Two object stores are deliberate. `records` holds metadata only, so admission
11
+ * can enumerate every entry without deserializing megabytes of payload, and
12
+ * `payloads` holds the envelope bytes keyed identically. A write puts both and
13
+ * deletes every evicted key inside a **single** readwrite transaction, so the
14
+ * store is never observably over quota and an aborted transaction leaves the
15
+ * previous contents intact.
16
+ *
17
+ * @experimental
18
+ */
19
+ const DATABASE_VERSION = 1;
20
+ const RECORD_STORE = "records";
21
+ const PAYLOAD_STORE = "payloads";
22
+ const STORES = [RECORD_STORE, PAYLOAD_STORE];
23
+ /**
24
+ * Persistent browser storage for serialized batches: metadata and payload
25
+ * written, and evictions applied, in one transaction.
26
+ */
27
+ export function createIndexedDbColumnarBatchCacheStorage(options = {}) {
28
+ const factory = options.indexedDB ?? globalThis.indexedDB;
29
+ if (!factory)
30
+ throw new Error("IndexedDB is not available in this runtime.");
31
+ const name = options.name ?? "honua-columnar-batch-cache";
32
+ if (name.trim().length === 0)
33
+ throw new Error("IndexedDB database name must be non-empty.");
34
+ let database = openDatabase(factory, name);
35
+ const connection = () => {
36
+ if (!database)
37
+ return Promise.reject(new Error("This columnar batch cache storage was disposed."));
38
+ return database;
39
+ };
40
+ return {
41
+ summaries: async () => runTransaction(await connection(), "readonly", async (transaction) => request(transaction.objectStore(RECORD_STORE).getAll())),
42
+ read: async (key) => runTransaction(await connection(), "readonly", async (transaction) => {
43
+ const record = await request(transaction.objectStore(RECORD_STORE).get(key));
44
+ if (record === undefined)
45
+ return undefined;
46
+ const payload = await request(transaction.objectStore(PAYLOAD_STORE).get(key));
47
+ if (payload === undefined)
48
+ return undefined;
49
+ const envelope = toEnvelope(payload?.envelope);
50
+ return envelope === undefined ? undefined : { record, envelope };
51
+ }),
52
+ write: async (entry, evictKeys) => runTransaction(await connection(), "readwrite", async (transaction) => {
53
+ const records = transaction.objectStore(RECORD_STORE);
54
+ const payloads = transaction.objectStore(PAYLOAD_STORE);
55
+ for (const key of evictKeys) {
56
+ records.delete(key);
57
+ payloads.delete(key);
58
+ }
59
+ records.put(entry.record);
60
+ // A fresh copy: the caller keeps ownership of its own bytes, and a
61
+ // structured clone of a subarray would otherwise carry the whole
62
+ // backing buffer into storage.
63
+ payloads.put({ key: entry.record.key, envelope: entry.envelope.slice() });
64
+ }),
65
+ remove: async (key) => runTransaction(await connection(), "readwrite", async (transaction) => {
66
+ transaction.objectStore(RECORD_STORE).delete(key);
67
+ transaction.objectStore(PAYLOAD_STORE).delete(key);
68
+ }),
69
+ clear: async () => runTransaction(await connection(), "readwrite", async (transaction) => {
70
+ transaction.objectStore(RECORD_STORE).clear();
71
+ transaction.objectStore(PAYLOAD_STORE).clear();
72
+ }),
73
+ dispose: () => {
74
+ const pending = database;
75
+ database = undefined;
76
+ // Closing releases the connection and every buffer it retained; a
77
+ // rejected open must not leave an unhandled rejection behind.
78
+ void pending?.then((open) => open.close(), () => undefined);
79
+ },
80
+ };
81
+ }
82
+ function toEnvelope(value) {
83
+ if (value instanceof Uint8Array)
84
+ return value;
85
+ if (value instanceof ArrayBuffer)
86
+ return new Uint8Array(value);
87
+ if (ArrayBuffer.isView(value))
88
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
89
+ return undefined;
90
+ }
91
+ function openDatabase(factory, name) {
92
+ return new Promise((resolve, reject) => {
93
+ const open = factory.open(name, DATABASE_VERSION);
94
+ open.onupgradeneeded = () => {
95
+ const database = open.result;
96
+ for (const store of STORES) {
97
+ if (!database.objectStoreNames.contains(store))
98
+ database.createObjectStore(store, { keyPath: "key" });
99
+ }
100
+ };
101
+ open.onsuccess = () => resolve(open.result);
102
+ open.onerror = () => reject(open.error ?? new Error("Failed to open the columnar batch cache database."));
103
+ open.onblocked = () => reject(new Error("Columnar batch cache database upgrade is blocked by another connection."));
104
+ });
105
+ }
106
+ function runTransaction(database, mode, body) {
107
+ return new Promise((resolve, reject) => {
108
+ const transaction = database.transaction([...STORES], mode);
109
+ let result;
110
+ let settled = false;
111
+ void body(transaction).then((value) => {
112
+ result = value;
113
+ }, (error) => {
114
+ settled = true;
115
+ try {
116
+ transaction.abort();
117
+ }
118
+ catch {
119
+ // The transaction may already have completed or aborted.
120
+ }
121
+ reject(error);
122
+ });
123
+ transaction.oncomplete = () => {
124
+ if (settled)
125
+ return;
126
+ settled = true;
127
+ resolve(result);
128
+ };
129
+ transaction.onerror = () => {
130
+ if (settled)
131
+ return;
132
+ settled = true;
133
+ reject(transaction.error ?? new Error("Columnar batch cache transaction failed."));
134
+ };
135
+ transaction.onabort = () => {
136
+ if (settled)
137
+ return;
138
+ settled = true;
139
+ reject(transaction.error ?? new Error("Columnar batch cache transaction aborted."));
140
+ };
141
+ });
142
+ }
143
+ function request(source) {
144
+ return new Promise((resolve, reject) => {
145
+ source.onsuccess = () => resolve(source.result);
146
+ source.onerror = () => reject(source.error ?? new Error("Columnar batch cache request failed."));
147
+ });
148
+ }