@honua/app-platform 0.1.2-beta.0 → 0.1.7-beta.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.
Files changed (400) hide show
  1. package/app/index.d.ts.map +1 -1
  2. package/app/index.js +4 -0
  3. package/collaboration/client.d.ts.map +1 -1
  4. package/connect-url-safety.d.ts +53 -0
  5. package/connect-url-safety.js +189 -0
  6. package/contract/discovery.d.ts +34 -0
  7. package/contract/discovery.d.ts.map +1 -1
  8. package/contract/index.d.ts +7 -5
  9. package/contract/index.d.ts.map +1 -1
  10. package/contract/index.js +3 -2
  11. package/contract/pmtiles.d.ts +10 -1
  12. package/contract/pmtiles.d.ts.map +1 -1
  13. package/contract/pmtiles.js +10 -2
  14. package/contract/protocol-module.d.ts +84 -9
  15. package/contract/protocol-module.d.ts.map +1 -1
  16. package/contract/query-filter-fes.d.ts +18 -0
  17. package/contract/query-filter-fes.d.ts.map +1 -0
  18. package/contract/query-filter-fes.js +122 -0
  19. package/contract/query-filter.d.ts +300 -0
  20. package/contract/query-filter.d.ts.map +1 -0
  21. package/contract/query-filter.js +883 -0
  22. package/contract/schema.d.ts +12 -0
  23. package/contract/schema.d.ts.map +1 -1
  24. package/contract/schema.js +34 -0
  25. package/contract/source-capability-support.d.ts.map +1 -1
  26. package/contract/source-capability-support.js +15 -3
  27. package/contract/source.d.ts +45 -25
  28. package/contract/source.d.ts.map +1 -1
  29. package/contract/source.js +719 -479
  30. package/contract/types.d.ts +88 -19
  31. package/contract/types.d.ts.map +1 -1
  32. package/contract/types.js +4 -0
  33. package/contract/widget-source.d.ts.map +1 -1
  34. package/controls/basemap-switcher.d.ts +19 -11
  35. package/controls/basemap-switcher.d.ts.map +1 -1
  36. package/controls/basemap-switcher.js +58 -25
  37. package/controls/catalog.d.ts +105 -0
  38. package/controls/catalog.d.ts.map +1 -0
  39. package/controls/catalog.js +341 -0
  40. package/controls/element-utils.d.ts +9 -0
  41. package/controls/element-utils.d.ts.map +1 -1
  42. package/controls/element-utils.js +17 -0
  43. package/controls/index.d.ts +29 -8
  44. package/controls/index.d.ts.map +1 -1
  45. package/controls/index.js +26 -8
  46. package/controls/layer-list.d.ts +13 -5
  47. package/controls/layer-list.d.ts.map +1 -1
  48. package/controls/layer-list.js +72 -15
  49. package/controls/legend.d.ts +32 -5
  50. package/controls/legend.d.ts.map +1 -1
  51. package/controls/legend.js +95 -25
  52. package/controls/qualification.d.ts +152 -0
  53. package/controls/qualification.d.ts.map +1 -0
  54. package/controls/qualification.js +1265 -0
  55. package/controls/registry.d.ts +104 -0
  56. package/controls/registry.d.ts.map +1 -0
  57. package/controls/registry.js +150 -0
  58. package/controls/swipe-control.d.ts +6 -1
  59. package/controls/swipe-control.d.ts.map +1 -1
  60. package/controls/swipe-control.js +82 -20
  61. package/core/client.d.ts +51 -21
  62. package/core/client.d.ts.map +1 -1
  63. package/core/client.js +234 -54
  64. package/core/error-classifications.d.ts +23 -0
  65. package/core/error-classifications.d.ts.map +1 -1
  66. package/core/error-classifications.js +23 -0
  67. package/core/error-code-registry.d.ts +24 -1
  68. package/core/error-code-registry.d.ts.map +1 -1
  69. package/core/error-code-registry.js +23 -0
  70. package/core/error-envelope.js +2 -0
  71. package/core/errors.d.ts +2 -2
  72. package/core/errors.d.ts.map +1 -1
  73. package/core/errors.js +1 -1
  74. package/core/geoparquet-sql.d.ts +69 -5
  75. package/core/geoparquet-sql.d.ts.map +1 -1
  76. package/core/geoparquet-sql.js +327 -10
  77. package/core/odata-write-codec.d.ts.map +1 -1
  78. package/core/odata-write-codec.js +0 -1
  79. package/core/odata.d.ts +6 -0
  80. package/core/odata.d.ts.map +1 -1
  81. package/core/odata.js +6 -1
  82. package/core/ogc-processes.d.ts +182 -25
  83. package/core/ogc-processes.d.ts.map +1 -1
  84. package/core/ogc-processes.js +602 -30
  85. package/core/process-runner.d.ts +8 -1
  86. package/core/process-runner.d.ts.map +1 -1
  87. package/core/process-runner.js +1 -0
  88. package/core/protocol-transport.d.ts +2 -0
  89. package/core/protocol-transport.d.ts.map +1 -1
  90. package/core/stac.d.ts +32 -0
  91. package/core/stac.d.ts.map +1 -1
  92. package/core/stac.js +158 -23
  93. package/core/surfaces.d.ts.map +1 -1
  94. package/core/surfaces.js +273 -68
  95. package/core/types.d.ts +138 -11
  96. package/core/types.d.ts.map +1 -1
  97. package/core/wfs-axis-order.d.ts +18 -0
  98. package/core/wfs-axis-order.d.ts.map +1 -0
  99. package/core/wfs-axis-order.js +108 -0
  100. package/core/wfs-capabilities.d.ts +65 -24
  101. package/core/wfs-capabilities.d.ts.map +1 -1
  102. package/core/wfs-capabilities.js +237 -27
  103. package/core/wfs-filter.d.ts +26 -3
  104. package/core/wfs-filter.d.ts.map +1 -1
  105. package/core/wfs-filter.js +48 -53
  106. package/core/wfs-protocol-error.d.ts +12 -0
  107. package/core/wfs-protocol-error.d.ts.map +1 -0
  108. package/core/wfs-protocol-error.js +13 -0
  109. package/core/wfs.d.ts +53 -1
  110. package/core/wfs.d.ts.map +1 -1
  111. package/core/wfs.js +251 -29
  112. package/core/wms-feature-info.d.ts +92 -0
  113. package/core/wms-feature-info.d.ts.map +1 -0
  114. package/core/wms-feature-info.js +285 -0
  115. package/core/wms.d.ts +9 -0
  116. package/core/wms.d.ts.map +1 -1
  117. package/core/wms.js +17 -5
  118. package/esri-compat/event-bus.d.ts +20 -0
  119. package/esri-compat/event-bus.d.ts.map +1 -1
  120. package/esri-compat/feature-filter.d.ts +46 -1
  121. package/esri-compat/feature-filter.d.ts.map +1 -1
  122. package/esri-compat/feature-filter.js +49 -0
  123. package/esri-compat/geojson-layer.d.ts +4 -2
  124. package/esri-compat/geojson-layer.d.ts.map +1 -1
  125. package/esri-compat/geojson-layer.js +4 -2
  126. package/esri-compat/identity-manager.js +2 -2
  127. package/esri-compat/imagery-layer.d.ts +2 -1
  128. package/esri-compat/imagery-layer.d.ts.map +1 -1
  129. package/esri-compat/imagery-layer.js +2 -1
  130. package/esri-compat/layer-list.d.ts +8 -1
  131. package/esri-compat/layer-list.d.ts.map +1 -1
  132. package/esri-compat/layer-list.js +11 -4
  133. package/esri-compat/legend.d.ts.map +1 -1
  134. package/esri-compat/legend.js +1 -1
  135. package/esri-compat/locator.d.ts +297 -0
  136. package/esri-compat/locator.d.ts.map +1 -0
  137. package/esri-compat/locator.js +579 -0
  138. package/esri-compat/map-image-layer.d.ts.map +1 -1
  139. package/esri-compat/map-view.d.ts.map +1 -1
  140. package/esri-compat/map-view.js +1 -1
  141. package/esri-compat/scene-view.d.ts.map +1 -1
  142. package/esri-compat/time-slider.d.ts +29 -0
  143. package/esri-compat/time-slider.d.ts.map +1 -1
  144. package/esri-compat/time-slider.js +117 -0
  145. package/esri-compat/wfs-layer.d.ts +2 -1
  146. package/esri-compat/wfs-layer.d.ts.map +1 -1
  147. package/esri-compat/wfs-layer.js +2 -1
  148. package/esri-compat/widget-host.d.ts +42 -6
  149. package/esri-compat/widget-host.d.ts.map +1 -1
  150. package/esri-compat/widget-host.js +99 -10
  151. package/generated-app/projection.d.ts.map +1 -1
  152. package/generated-app/runtime.d.ts.map +1 -1
  153. package/interactions/declarative-contract.d.ts +149 -0
  154. package/interactions/declarative-contract.d.ts.map +1 -0
  155. package/interactions/declarative-contract.js +293 -0
  156. package/interactions/declarative.d.ts +198 -0
  157. package/interactions/declarative.d.ts.map +1 -0
  158. package/interactions/declarative.js +470 -0
  159. package/interactions/feature-state.d.ts +7 -0
  160. package/interactions/feature-state.d.ts.map +1 -1
  161. package/interactions/feature-state.js +10 -2
  162. package/map/data-to-map-bridge.d.ts +1 -1
  163. package/map/data-to-map-bridge.d.ts.map +1 -1
  164. package/map/data-to-map-bridge.js +1 -2
  165. package/map/honua-map.d.ts.map +1 -1
  166. package/map/honua-map.js +1 -1
  167. package/map/index.d.ts +2 -0
  168. package/map/index.d.ts.map +1 -1
  169. package/map/index.js +2 -0
  170. package/map/maplibre-target.js +1 -1
  171. package/map/realtime-reconciliation-adapter.d.ts +98 -0
  172. package/map/realtime-reconciliation-adapter.d.ts.map +1 -0
  173. package/map/realtime-reconciliation-adapter.js +84 -0
  174. package/map/temporal-playback.d.ts +25 -0
  175. package/map/temporal-playback.d.ts.map +1 -1
  176. package/map/temporal-playback.js +29 -1
  177. package/operator/workspace/workspace.d.ts.map +1 -1
  178. package/operator/workspace/workspace.js +0 -2
  179. package/package.json +4 -3
  180. package/query-planner/canonical-filter.d.ts +30 -0
  181. package/query-planner/canonical-filter.d.ts.map +1 -0
  182. package/query-planner/canonical-filter.js +30 -0
  183. package/query-planner/columnar-plan.d.ts +63 -0
  184. package/query-planner/columnar-plan.d.ts.map +1 -0
  185. package/query-planner/columnar-plan.js +94 -0
  186. package/query-planner/diagnostics.d.ts +3 -3
  187. package/query-planner/diagnostics.d.ts.map +1 -1
  188. package/query-planner/diagnostics.js +17 -4
  189. package/query-planner/duckdb.d.ts.map +1 -1
  190. package/query-planner/duckdb.js +16 -2
  191. package/query-planner/execution-context.d.ts.map +1 -1
  192. package/query-planner/execution-context.js +3 -1
  193. package/query-planner/executor.d.ts.map +1 -1
  194. package/query-planner/executor.js +7 -1
  195. package/query-planner/geoservices.d.ts.map +1 -1
  196. package/query-planner/geoservices.js +24 -5
  197. package/query-planner/grpc.d.ts.map +1 -1
  198. package/query-planner/grpc.js +29 -5
  199. package/query-planner/index.d.ts +20 -5
  200. package/query-planner/index.d.ts.map +1 -1
  201. package/query-planner/index.js +16 -3
  202. package/query-planner/ir.d.ts +6 -1
  203. package/query-planner/ir.d.ts.map +1 -1
  204. package/query-planner/ir.js +63 -1
  205. package/query-planner/odata-protocol-module.d.ts +54 -0
  206. package/query-planner/odata-protocol-module.d.ts.map +1 -0
  207. package/query-planner/odata-protocol-module.js +199 -0
  208. package/query-planner/odata-v1.d.ts +23 -0
  209. package/query-planner/odata-v1.d.ts.map +1 -0
  210. package/query-planner/odata-v1.js +202 -0
  211. package/query-planner/odata.d.ts +0 -3
  212. package/query-planner/odata.d.ts.map +1 -1
  213. package/query-planner/odata.js +0 -143
  214. package/query-planner/ogc-features.d.ts.map +1 -1
  215. package/query-planner/ogc-features.js +23 -1
  216. package/query-planner/planner.d.ts +2 -2
  217. package/query-planner/planner.d.ts.map +1 -1
  218. package/query-planner/planner.js +146 -27
  219. package/query-planner/representation.d.ts +59 -0
  220. package/query-planner/representation.d.ts.map +1 -0
  221. package/query-planner/representation.js +140 -0
  222. package/query-planner/types.d.ts +153 -3
  223. package/query-planner/types.d.ts.map +1 -1
  224. package/query-planner/wfs-protocol-module.d.ts +57 -0
  225. package/query-planner/wfs-protocol-module.d.ts.map +1 -0
  226. package/query-planner/wfs-protocol-module.js +731 -0
  227. package/query-planner/wfs-v1.d.ts +41 -0
  228. package/query-planner/wfs-v1.d.ts.map +1 -0
  229. package/query-planner/wfs-v1.js +366 -0
  230. package/query-planner/wfs.d.ts +1 -3
  231. package/query-planner/wfs.d.ts.map +1 -1
  232. package/query-planner/wfs.js +1 -70
  233. package/realtime/checkpoint-store.d.ts +206 -0
  234. package/realtime/checkpoint-store.d.ts.map +1 -0
  235. package/realtime/checkpoint-store.js +504 -0
  236. package/realtime/honua-server.d.ts +8 -0
  237. package/realtime/honua-server.d.ts.map +1 -1
  238. package/realtime/honua-server.js +123 -14
  239. package/realtime/index.d.ts +4 -0
  240. package/realtime/index.d.ts.map +1 -1
  241. package/realtime/index.js +2 -0
  242. package/realtime/reconciliation.d.ts +245 -0
  243. package/realtime/reconciliation.d.ts.map +1 -0
  244. package/realtime/reconciliation.js +377 -0
  245. package/realtime/resumable-transport.d.ts.map +1 -1
  246. package/realtime/sse.d.ts.map +1 -1
  247. package/realtime/sse.js +7 -1
  248. package/realtime/websocket.d.ts.map +1 -1
  249. package/realtime/wire.d.ts +2 -1
  250. package/realtime/wire.d.ts.map +1 -1
  251. package/realtime/wire.js +24 -8
  252. package/replica-sync/errors.d.ts +19 -1
  253. package/replica-sync/errors.d.ts.map +1 -1
  254. package/replica-sync/errors.js +16 -0
  255. package/replica-sync/geoservices.d.ts +260 -0
  256. package/replica-sync/geoservices.d.ts.map +1 -0
  257. package/replica-sync/geoservices.js +1093 -0
  258. package/replica-sync/index.d.ts +5 -1
  259. package/replica-sync/index.d.ts.map +1 -1
  260. package/replica-sync/index.js +3 -1
  261. package/replica-sync/transport-conformance.d.ts +58 -0
  262. package/replica-sync/transport-conformance.d.ts.map +1 -0
  263. package/replica-sync/transport-conformance.js +265 -0
  264. package/replica-sync/types.d.ts +22 -0
  265. package/replica-sync/types.d.ts.map +1 -1
  266. package/runtime/index.d.ts +6 -2
  267. package/runtime/index.d.ts.map +1 -1
  268. package/runtime/index.js +3 -1
  269. package/runtime/load-package.d.ts +24 -0
  270. package/runtime/load-package.d.ts.map +1 -1
  271. package/runtime/load-package.js +45 -1
  272. package/runtime/maplibre-renderer.d.ts +11 -0
  273. package/runtime/maplibre-renderer.d.ts.map +1 -1
  274. package/runtime/maplibre-renderer.js +32 -7
  275. package/runtime/offline-region-protocol.d.ts +73 -0
  276. package/runtime/offline-region-protocol.d.ts.map +1 -0
  277. package/runtime/offline-region-protocol.js +63 -0
  278. package/runtime/offline-region-style.d.ts +78 -0
  279. package/runtime/offline-region-style.d.ts.map +1 -0
  280. package/runtime/offline-region-style.js +147 -0
  281. package/runtime/pmtiles-protocol.d.ts +7 -8
  282. package/runtime/pmtiles-protocol.d.ts.map +1 -1
  283. package/runtime/pmtiles-protocol.js +19 -66
  284. package/runtime/protocol-registry.d.ts +55 -0
  285. package/runtime/protocol-registry.d.ts.map +1 -0
  286. package/runtime/protocol-registry.js +103 -0
  287. package/runtime/runtime.d.ts +2 -0
  288. package/runtime/runtime.d.ts.map +1 -1
  289. package/runtime/runtime.js +6 -1
  290. package/runtime/style-compose.d.ts.map +1 -1
  291. package/runtime/style-compose.js +78 -9
  292. package/scene-workspace/analysis-widgets.d.ts +4 -2
  293. package/scene-workspace/analysis-widgets.d.ts.map +1 -1
  294. package/scene-workspace/analysis-widgets.js +4 -2
  295. package/scene-workspace/camera-correspondence.d.ts +151 -0
  296. package/scene-workspace/camera-correspondence.d.ts.map +1 -0
  297. package/scene-workspace/camera-correspondence.js +220 -0
  298. package/scene-workspace/cesium-adapter.d.ts +230 -29
  299. package/scene-workspace/cesium-adapter.d.ts.map +1 -1
  300. package/scene-workspace/cesium-adapter.js +741 -56
  301. package/scene-workspace/cesium-mount.d.ts +205 -0
  302. package/scene-workspace/cesium-mount.d.ts.map +1 -0
  303. package/scene-workspace/cesium-mount.js +392 -0
  304. package/scene-workspace/cesium-scene-owner.d.ts +160 -0
  305. package/scene-workspace/cesium-scene-owner.d.ts.map +1 -0
  306. package/scene-workspace/cesium-scene-owner.js +205 -0
  307. package/scene-workspace/cesium-state-sync.d.ts +101 -0
  308. package/scene-workspace/cesium-state-sync.d.ts.map +1 -0
  309. package/scene-workspace/cesium-state-sync.js +432 -0
  310. package/scene-workspace/cesium-time.d.ts +346 -0
  311. package/scene-workspace/cesium-time.d.ts.map +1 -0
  312. package/scene-workspace/cesium-time.js +403 -0
  313. package/scene-workspace/index.d.ts +42 -10
  314. package/scene-workspace/index.d.ts.map +1 -1
  315. package/scene-workspace/index.js +32 -7
  316. package/scene-workspace/maplibre-state-sync.d.ts +112 -0
  317. package/scene-workspace/maplibre-state-sync.d.ts.map +1 -0
  318. package/scene-workspace/maplibre-state-sync.js +400 -0
  319. package/scene-workspace/primitives.d.ts +237 -3
  320. package/scene-workspace/primitives.d.ts.map +1 -1
  321. package/scene-workspace/primitives.js +1087 -17
  322. package/scene-workspace/scene-discovery.d.ts +5 -2
  323. package/scene-workspace/scene-discovery.d.ts.map +1 -1
  324. package/scene-workspace/scene-discovery.js +5 -2
  325. package/scene-workspace/scene-view.d.ts +5 -2
  326. package/scene-workspace/scene-view.d.ts.map +1 -1
  327. package/scene-workspace/scene-view.js +5 -2
  328. package/scene-workspace/source-to-cesium.d.ts +141 -8
  329. package/scene-workspace/source-to-cesium.d.ts.map +1 -1
  330. package/scene-workspace/source-to-cesium.js +497 -72
  331. package/scene-workspace/state-sync-port-kit.d.ts +156 -0
  332. package/scene-workspace/state-sync-port-kit.d.ts.map +1 -0
  333. package/scene-workspace/state-sync-port-kit.js +319 -0
  334. package/scene-workspace/state-sync.d.ts +34 -1
  335. package/scene-workspace/state-sync.d.ts.map +1 -1
  336. package/scene-workspace/state-sync.js +48 -0
  337. package/scene-workspace/types.d.ts +8 -0
  338. package/scene-workspace/types.d.ts.map +1 -1
  339. package/scene-workspace/types.js +2 -0
  340. package/scene-workspace/workspace.d.ts +12 -1
  341. package/scene-workspace/workspace.d.ts.map +1 -1
  342. package/scene-workspace/workspace.js +13 -0
  343. package/studio/index.d.ts +24 -7
  344. package/studio/index.d.ts.map +1 -1
  345. package/studio/index.js +21 -7
  346. package/studio/lifecycle-client.d.ts +164 -0
  347. package/studio/lifecycle-client.d.ts.map +1 -0
  348. package/studio/lifecycle-client.js +299 -0
  349. package/studio/lifecycle-errors.d.ts +91 -0
  350. package/studio/lifecycle-errors.d.ts.map +1 -0
  351. package/studio/lifecycle-errors.js +77 -0
  352. package/studio/lifecycle-types.d.ts +321 -0
  353. package/studio/lifecycle-types.d.ts.map +1 -0
  354. package/studio/lifecycle-types.js +22 -0
  355. package/web-components/controller.d.ts.map +1 -1
  356. package/web-components/csp-styles.d.ts +6 -0
  357. package/web-components/csp-styles.d.ts.map +1 -0
  358. package/web-components/csp-styles.js +53 -0
  359. package/web-components/elements.d.ts +158 -2
  360. package/web-components/elements.d.ts.map +1 -1
  361. package/web-components/elements.js +1302 -215
  362. package/web-components/export-redaction.d.ts +252 -0
  363. package/web-components/export-redaction.d.ts.map +1 -0
  364. package/web-components/export-redaction.js +588 -0
  365. package/web-components/export.d.ts +417 -0
  366. package/web-components/export.d.ts.map +1 -0
  367. package/web-components/export.js +719 -0
  368. package/web-components/feature-editor-model.d.ts +265 -0
  369. package/web-components/feature-editor-model.d.ts.map +1 -0
  370. package/web-components/feature-editor-model.js +438 -0
  371. package/web-components/feature-editor-sketch.d.ts +125 -0
  372. package/web-components/feature-editor-sketch.d.ts.map +1 -0
  373. package/web-components/feature-editor-sketch.js +194 -0
  374. package/web-components/feature-editor-workflow.d.ts +286 -0
  375. package/web-components/feature-editor-workflow.d.ts.map +1 -0
  376. package/web-components/feature-editor-workflow.js +889 -0
  377. package/web-components/feature-editor.d.ts +83 -0
  378. package/web-components/feature-editor.d.ts.map +1 -0
  379. package/web-components/feature-editor.js +829 -0
  380. package/web-components/feature-table-engine.d.ts +465 -0
  381. package/web-components/feature-table-engine.d.ts.map +1 -0
  382. package/web-components/feature-table-engine.js +1360 -0
  383. package/web-components/feature-table-view.d.ts +95 -0
  384. package/web-components/feature-table-view.d.ts.map +1 -0
  385. package/web-components/feature-table-view.js +343 -0
  386. package/web-components/index.d.ts +34 -3
  387. package/web-components/index.d.ts.map +1 -1
  388. package/web-components/index.js +35 -2
  389. package/web-components/maplibre-renderer.d.ts.map +1 -1
  390. package/web-components/maplibre-renderer.js +13 -4
  391. package/web-components/measurement.d.ts +3 -1
  392. package/web-components/measurement.d.ts.map +1 -1
  393. package/web-components/measurement.js +62 -18
  394. package/web-components/time-slider.d.ts +122 -0
  395. package/web-components/time-slider.d.ts.map +1 -0
  396. package/web-components/time-slider.js +740 -0
  397. package/web-components/types.d.ts +310 -0
  398. package/web-components/types.d.ts.map +1 -1
  399. package/webmap/convert-label.js +0 -1
  400. package/webmap/convert-symbol.d.ts.map +1 -1
@@ -0,0 +1,1360 @@
1
+ /**
2
+ * Bounded feature-table engine — the headless kernel behind
3
+ * `<honua-feature-table>` (issue #681).
4
+ *
5
+ * The engine is deliberately DOM-free so the whole production contract
6
+ * (paging, sort, typed filters, column semantics, budgets, virtualization,
7
+ * linked exploration state, realtime reconciliation, bounded export, and the
8
+ * keyboard focus model) is testable without a browser and reusable by any
9
+ * renderer.
10
+ *
11
+ * Design rules this module holds itself to:
12
+ *
13
+ * - **Public seams only.** Data arrives through the canonical `Source`-shaped
14
+ * query surface ({@link HonuaFeatureTableQuerySource}), typed filters are
15
+ * compiled by `@honua/sdk-js/filter-registry`, linked state flows through an
16
+ * `@honua/sdk-js/exploration` view controller, and realtime deltas arrive as
17
+ * the `@honua/sdk-js/realtime` reconciliation diff. No protocol adapter,
18
+ * renderer, or SDK-private path is imported.
19
+ * - **Never manufacture a count.** {@link HonuaFeatureTableCount} always names
20
+ * the evidence a number came from, and reports `"unknown"` rather than
21
+ * guessing (REQ-004).
22
+ * - **Never materialize a layer.** Every fetch is bounded by
23
+ * {@link HonuaFeatureTableBudgets}; the page cache evicts to stay under the
24
+ * row/byte ceilings and stops issuing work once the request ceiling is
25
+ * reached (REQ-002).
26
+ * - **Identity before linkage.** A table with no resolvable stable row identity
27
+ * enters the `"unsupported"` state instead of keying rows by their position
28
+ * in a page, which would corrupt selection across paging.
29
+ *
30
+ * @module
31
+ */
32
+ import { isSourceQualifiedSelectionTarget, sourceFeatureSelectionTarget } from "../exploration/selection.js";
33
+ import { projectFilterRegistryToQuery } from "../filter-registry/index.js";
34
+ import { reconcileRealtimeKeyedState, reconcileRealtimeViewport } from "../realtime/reconciliation.js";
35
+ /** Build the engine's row key. Shares the realtime key space (`sourceId:id`). */
36
+ export function featureTableRowKey(sourceId, id) {
37
+ return `${sourceId}:${String(id)}`;
38
+ }
39
+ /** Conservative defaults sized for an operational browser panel. */
40
+ export const DEFAULT_FEATURE_TABLE_BUDGETS = Object.freeze({
41
+ pageSize: 200,
42
+ maxCachedRows: 2_000,
43
+ maxCachedBytes: 4 * 1024 * 1024,
44
+ maxRequests: 64,
45
+ maxExportRows: 10_000,
46
+ windowOverscan: 20,
47
+ });
48
+ /** Select the work items attributed to one execution tier. */
49
+ export function featureTableWorkByTier(evidence, tier) {
50
+ return Object.freeze(evidence.work.filter((item) => item.tier === tier));
51
+ }
52
+ /**
53
+ * Pure window computation: which absolute row indices a renderer must paint
54
+ * for the given scroll geometry, widened by `overscan` and clamped to
55
+ * `totalRows` when a total is known.
56
+ */
57
+ export function featureTableWindow(metrics, options = {}) {
58
+ const rowHeight = metrics.rowHeight > 0 ? metrics.rowHeight : 1;
59
+ const overscan = Math.max(0, options.overscan ?? 0);
60
+ const first = Math.max(0, Math.floor(Math.max(0, metrics.scrollTop) / rowHeight) - overscan);
61
+ const visible = Math.max(1, Math.ceil(Math.max(0, metrics.viewportHeight) / rowHeight));
62
+ const last = first + visible + overscan * 2;
63
+ const end = options.totalRows === undefined ? last : Math.min(last, Math.max(0, options.totalRows));
64
+ return Object.freeze({ startIndex: Math.min(first, end), endIndex: end });
65
+ }
66
+ /** Deterministic page-cache key. Stable across property insertion order. */
67
+ export function featureTablePageCacheKey(identity, page = {}) {
68
+ return [
69
+ `source=${identity.sourceId}`,
70
+ `sourceVersion=${identity.sourceVersion ?? ""}`,
71
+ `schemaVersion=${identity.schemaVersion ?? ""}`,
72
+ `plan=${identity.planFingerprint ?? ""}`,
73
+ `filter=${identity.filterKey}`,
74
+ `sort=${identity.sortKey}`,
75
+ `projection=${identity.projectionKey}`,
76
+ `scope=${[...identity.authorizationScope].sort().join("|")}`,
77
+ `freshness=${identity.freshness}`,
78
+ `offset=${page.offset ?? ""}`,
79
+ `limit=${page.limit ?? ""}`,
80
+ `cursor=${page.cursor ?? ""}`,
81
+ ].join(" ");
82
+ }
83
+ const EMPTY_EVIDENCE = Object.freeze({
84
+ work: Object.freeze([]),
85
+ degraded: Object.freeze([]),
86
+ cacheKey: "",
87
+ });
88
+ const CANCELLED_MESSAGE = "The table refresh was cancelled.";
89
+ const NO_IDENTITY_FIELD_MESSAGE = "A stable row identity is required. Set `identityField` (or the source descriptor's `schema.primaryKey`) so selection, focus, and realtime reconciliation survive paging.";
90
+ const QUERY_CAPABILITY_MESSAGE = "The source does not advertise the canonical `query` capability, so a feature table cannot claim that bounded paging is supported.";
91
+ const IDENTITY_REQUIRED_HINT = "Every row must carry a string or number identity value, or selection and realtime reconciliation cannot survive paging.";
92
+ const PAGE_SIZE_HINT = "no page could ever be held within the memory budget. Lower `pageSize` or raise `maxCachedRows`.";
93
+ const CACHE_BUDGET_HINT = "Lower `pageSize` or raise the cache budget.";
94
+ /**
95
+ * Create a bounded feature table.
96
+ *
97
+ * The engine starts `"idle"` and performs no I/O until {@link
98
+ * HonuaFeatureTable.refresh} or {@link HonuaFeatureTable.setScroll} is called,
99
+ * so mounting a grid never triggers an unbounded drain.
100
+ */
101
+ export function createHonuaFeatureTable(options) {
102
+ const budgets = Object.freeze({ ...DEFAULT_FEATURE_TABLE_BUDGETS, ...options.budgets });
103
+ const pagingMode = options.pagingMode ?? "offset";
104
+ const residualTier = options.residualExecution === "worker" ? "worker" : "client";
105
+ const authorizationScope = Object.freeze([...(options.authorizationScope ?? [])]);
106
+ const returnGeometry = options.returnGeometry ?? false;
107
+ const identityField = options.identityField ?? options.source.descriptor?.schema?.primaryKey;
108
+ const queryCapabilityAdvertised = options.source.descriptor?.capabilities?.has("query");
109
+ const listeners = new Set();
110
+ const pages = new Map();
111
+ /**
112
+ * Exploration selection targets for the keys currently selected. Selection
113
+ * outlives cache residency, so a row scrolled out of the bounded window must
114
+ * still round-trip to a target. Pruned to the live selection on every change,
115
+ * so this never becomes a second unbounded row cache.
116
+ */
117
+ const selectionTargetIndex = new Map();
118
+ const misconfiguredPageSize = budgets.pageSize > budgets.maxCachedRows;
119
+ let columns = resolveColumns(options.columns ?? []);
120
+ let sort = Object.freeze([...(options.sort ?? [])]);
121
+ let filters = normalizeFilters(options.filters);
122
+ let selection = Object.freeze([]);
123
+ let focus;
124
+ let window = Object.freeze({ startIndex: 0, endIndex: 0 });
125
+ let state = identityField && !misconfiguredPageSize && queryCapabilityAdvertised !== false ? "idle" : "unsupported";
126
+ let stale = false;
127
+ let message = !identityField
128
+ ? NO_IDENTITY_FIELD_MESSAGE
129
+ : queryCapabilityAdvertised === false
130
+ ? QUERY_CAPABILITY_MESSAGE
131
+ : misconfiguredPageSize
132
+ ? `\`budgets.pageSize\` (${budgets.pageSize}) exceeds \`budgets.maxCachedRows\` (${budgets.maxCachedRows}), so ${PAGE_SIZE_HINT}`
133
+ : undefined;
134
+ /** Set when a bounded operation had to give something up; survives a successful run. */
135
+ let budgetNotice;
136
+ let error;
137
+ let conflicts = Object.freeze([]);
138
+ let evidence = EMPTY_EVIDENCE;
139
+ let count = Object.freeze({ kind: "unknown", loaded: 0, evidence: "none" });
140
+ let ledger = Object.freeze({
141
+ requests: 0,
142
+ lifetimeRequests: 0,
143
+ rows: 0,
144
+ bytes: 0,
145
+ evictedRows: 0,
146
+ exhausted: Object.freeze([]),
147
+ });
148
+ let totalKnown;
149
+ /** Which evidence produced `totalKnown`. Never inferred after the fact. */
150
+ let totalEvidence = "result-total-count";
151
+ let totalEstimated;
152
+ let exhausted = false;
153
+ let sequence = 0;
154
+ let revision = 0;
155
+ let disposed = false;
156
+ let generation = 0;
157
+ let cursorPages = 0;
158
+ let cursorIterator;
159
+ let inFlight;
160
+ // ── Snapshot assembly ───────────────────────────────────────
161
+ function buildSnapshot() {
162
+ return Object.freeze({
163
+ sourceId: options.sourceId,
164
+ state,
165
+ columns,
166
+ visibleColumns: Object.freeze(columns.filter((column) => column.visible)),
167
+ rows: Object.freeze(rowsInWindow()),
168
+ window,
169
+ count,
170
+ sort,
171
+ filters,
172
+ selection,
173
+ ...(focus ? { focus } : {}),
174
+ paging: Object.freeze({ mode: pagingMode, pageSize: budgets.pageSize, loadedPages: pages.size, exhausted }),
175
+ budgets,
176
+ ledger,
177
+ evidence,
178
+ conflicts,
179
+ stale,
180
+ ...(message !== undefined ? { message } : {}),
181
+ ...(error !== undefined ? { error } : {}),
182
+ revision,
183
+ });
184
+ }
185
+ let snapshot = buildSnapshot();
186
+ function publish() {
187
+ revision += 1;
188
+ snapshot = buildSnapshot();
189
+ for (const listener of [...listeners])
190
+ listener(snapshot);
191
+ return snapshot;
192
+ }
193
+ /**
194
+ * Read `state` opaquely. `loadWindow`/`nextCursorPage` can move the table to
195
+ * `"unsupported"` mid-flight, which a direct comparison would have narrowed
196
+ * away at the call site.
197
+ */
198
+ function currentState() {
199
+ return state;
200
+ }
201
+ function rowsInWindow() {
202
+ const out = [];
203
+ for (let index = window.startIndex; index < window.endIndex; index += 1)
204
+ out.push(rowAt(index));
205
+ return out;
206
+ }
207
+ function rowAt(index) {
208
+ const page = pages.get(pageOffsetFor(index));
209
+ if (!page)
210
+ return undefined;
211
+ return page.rows.find((row) => row.index === index);
212
+ }
213
+ function pageOffsetFor(index) {
214
+ return Math.floor(index / budgets.pageSize) * budgets.pageSize;
215
+ }
216
+ function loadedRows() {
217
+ const out = [];
218
+ for (const page of [...pages.values()].sort((left, right) => left.offset - right.offset))
219
+ out.push(...page.rows);
220
+ return out;
221
+ }
222
+ function findRow(key) {
223
+ for (const page of pages.values()) {
224
+ const hit = page.rows.find((row) => row.key === key);
225
+ if (hit)
226
+ return hit;
227
+ }
228
+ return undefined;
229
+ }
230
+ // ── Query composition ───────────────────────────────────────
231
+ function projection() {
232
+ const fields = columns.filter((column) => column.visible).map((column) => column.field);
233
+ if (fields.length === 0)
234
+ return undefined;
235
+ return identityField && !fields.includes(identityField) ? [...fields, identityField] : fields;
236
+ }
237
+ function orderedFields() {
238
+ const visible = columns.filter((column) => column.visible).map((column) => column.field);
239
+ return visible.length > 0 ? visible : columns.map((column) => column.field);
240
+ }
241
+ function composeQuery(page) {
242
+ const descriptor = options.source.descriptor;
243
+ const projected = projectFilterRegistryToQuery({ version: 1, clauses: filters }, {
244
+ sourceId: options.sourceId,
245
+ ...(descriptor
246
+ ? { source: { id: descriptor.id, protocol: descriptor.protocol, capabilities: descriptor.capabilities } }
247
+ : {}),
248
+ });
249
+ const outFields = projection();
250
+ const query = {
251
+ ...(projected.where !== undefined ? { where: projected.where } : {}),
252
+ ...(projected.query.spatialFilter ? { spatialFilter: projected.query.spatialFilter } : {}),
253
+ ...(outFields ? { outFields } : {}),
254
+ ...(sort.length > 0 ? { orderBy: [...sort] } : {}),
255
+ pagination: { ...(page.offset === undefined ? {} : { offset: page.offset }), limit: page.limit },
256
+ returnGeometry,
257
+ };
258
+ return { query, degraded: projected.degraded ?? [], filterKey: projected.cacheKey };
259
+ }
260
+ function identity(filterKey, planFingerprint) {
261
+ const descriptor = options.source.descriptor;
262
+ const freshness = descriptor?.analytics?.freshness;
263
+ return {
264
+ sourceId: options.sourceId,
265
+ ...(options.sourceVersion ? { sourceVersion: options.sourceVersion } : {}),
266
+ ...(descriptor?.schemaV2?.fingerprint ? { schemaVersion: descriptor.schemaV2.fingerprint } : {}),
267
+ ...(planFingerprint ? { planFingerprint } : {}),
268
+ filterKey,
269
+ sortKey: sort.map((entry) => `${entry.field}:${entry.direction ?? "asc"}`).join(","),
270
+ projectionKey: (projection() ?? ["*"]).join(","),
271
+ authorizationScope,
272
+ freshness: freshness ? `${freshness.mode}:${freshness.ttlMs ?? ""}:${generation}` : `generation:${generation}`,
273
+ };
274
+ }
275
+ function buildEvidence(plan, degraded, cacheKey) {
276
+ const work = [];
277
+ for (const step of plan?.steps ?? []) {
278
+ if (step.engine === "remote") {
279
+ work.push({
280
+ tier: "server",
281
+ concern: step.pushdown === "full" ? "filter" : "paging",
282
+ detail: `${step.operation} pushdown=${step.pushdown} fidelity=${step.fidelity}: ${step.reason}`,
283
+ planStepId: step.id,
284
+ });
285
+ }
286
+ else {
287
+ work.push({
288
+ tier: residualTier,
289
+ concern: "filter",
290
+ detail: `residual ${step.operation} bounded to ${step.maxRows} rows: ${step.reason}`,
291
+ planStepId: step.id,
292
+ });
293
+ }
294
+ }
295
+ for (const reason of degraded) {
296
+ work.push({
297
+ tier: residualTier,
298
+ concern: "filter",
299
+ detail: `residual for ${reason.capability}: ${reason.reason}`,
300
+ });
301
+ }
302
+ if (sort.length > 0) {
303
+ work.push({
304
+ tier: plan === undefined || plan.pushdown === "full" ? "server" : residualTier,
305
+ concern: "sort",
306
+ detail: `orderBy ${sort.map((entry) => `${entry.field} ${entry.direction ?? "asc"}`).join(", ")}`,
307
+ });
308
+ }
309
+ work.push({
310
+ tier: "client",
311
+ concern: "virtualization",
312
+ detail: `window ${window.startIndex}-${window.endIndex} over ${pages.size} resident page(s)`,
313
+ });
314
+ const formatted = columns.filter((column) => column.format).length;
315
+ if (formatted > 0) {
316
+ work.push({
317
+ tier: "client",
318
+ concern: "format",
319
+ detail: `${formatted} column formatter(s) applied at render`,
320
+ });
321
+ }
322
+ return Object.freeze({
323
+ ...(plan ? { planId: plan.id, planFingerprint: plan.fingerprint, pushdown: plan.pushdown } : {}),
324
+ ...(plan ? { fidelity: plan.fidelity } : {}),
325
+ work: Object.freeze(work.map((item) => Object.freeze(item))),
326
+ degraded: Object.freeze([...degraded]),
327
+ cacheKey,
328
+ });
329
+ }
330
+ // ── Budget accounting ───────────────────────────────────────
331
+ function noteBudget(patch) {
332
+ ledger = Object.freeze({
333
+ requests: ledger.requests + (patch.requests ?? 0),
334
+ lifetimeRequests: ledger.lifetimeRequests + (patch.requests ?? 0),
335
+ rows: ledger.rows + (patch.rows ?? 0),
336
+ bytes: ledger.bytes + (patch.bytes ?? 0),
337
+ evictedRows: ledger.evictedRows + (patch.evictedRows ?? 0),
338
+ exhausted: ledger.exhausted,
339
+ });
340
+ }
341
+ function markExhausted(kind) {
342
+ if (ledger.exhausted.includes(kind))
343
+ return;
344
+ ledger = Object.freeze({ ...ledger, exhausted: Object.freeze([...ledger.exhausted, kind]) });
345
+ }
346
+ function residentRows() {
347
+ let total = 0;
348
+ for (const page of pages.values())
349
+ total += page.rows.length;
350
+ return total;
351
+ }
352
+ function residentBytes() {
353
+ let total = 0;
354
+ for (const page of pages.values())
355
+ total += page.bytes;
356
+ return total;
357
+ }
358
+ function withinMemoryBudgets() {
359
+ return residentRows() <= budgets.maxCachedRows && residentBytes() <= budgets.maxCachedBytes;
360
+ }
361
+ function evictPage(victim) {
362
+ markExhausted(residentBytes() > budgets.maxCachedBytes ? "bytes" : "rows");
363
+ pages.delete(victim.offset);
364
+ noteBudget({ evictedRows: victim.rows.length });
365
+ }
366
+ /**
367
+ * Evict least-recently-used pages until both memory ceilings hold.
368
+ *
369
+ * `protectedOffsets` names the pages the current window just fetched, which
370
+ * are evicted last — but they are *not* exempt. A single page heavier than
371
+ * `maxCachedBytes` would otherwise sit above a ceiling documented as hard,
372
+ * so once every other page is gone the oversized page is dropped too and the
373
+ * caller is told why. Its window rows then render as placeholders, which is
374
+ * the honest outcome: the budget forbids holding that page.
375
+ */
376
+ function enforceMemoryBudgets(protectedOffsets) {
377
+ const byAge = (left, right) => left.sequence - right.sequence;
378
+ const notice = `A page exceeded the table's memory budget (${budgets.maxCachedRows} rows / ${budgets.maxCachedBytes} bytes) and was dropped. ${CACHE_BUDGET_HINT}`;
379
+ const evictable = [...pages.values()].filter((page) => !protectedOffsets.has(page.offset)).sort(byAge);
380
+ for (const victim of evictable) {
381
+ if (withinMemoryBudgets())
382
+ return;
383
+ evictPage(victim);
384
+ budgetNotice = notice;
385
+ }
386
+ if (withinMemoryBudgets())
387
+ return;
388
+ for (const victim of [...pages.values()].sort(byAge)) {
389
+ if (withinMemoryBudgets())
390
+ break;
391
+ evictPage(victim);
392
+ budgetNotice = notice;
393
+ }
394
+ }
395
+ // ── Fetching ────────────────────────────────────────────────
396
+ async function fetchPage(offset, signal) {
397
+ const composed = composeQuery({ offset: pagingMode === "cursor" ? undefined : offset, limit: budgets.pageSize });
398
+ const plan = options.planner?.(composed.query);
399
+ const cacheKey = featureTablePageCacheKey(identity(composed.filterKey, plan?.fingerprint), {
400
+ offset,
401
+ limit: budgets.pageSize,
402
+ ...(pagingMode === "cursor" ? { cursor: offset / budgets.pageSize } : {}),
403
+ });
404
+ const cached = pages.get(offset);
405
+ if (cached && cached.cacheKey === cacheKey) {
406
+ sequence += 1;
407
+ cached.sequence = sequence;
408
+ evidence = buildEvidence(plan, composed.degraded, cacheKey);
409
+ return cached;
410
+ }
411
+ if (ledger.requests >= budgets.maxRequests) {
412
+ markExhausted("requests");
413
+ return undefined;
414
+ }
415
+ noteBudget({ requests: 1 });
416
+ const request = { ...composed.query, ...(signal ? { signal } : {}) };
417
+ // `Source.query` is not required to reject when its signal aborts, so a
418
+ // superseded generation's response can still resolve normally and land here
419
+ // long after the filter/sort/projection moved on. Cancellation alone cannot
420
+ // prevent that; only refusing to write results whose generation no longer
421
+ // matches can.
422
+ const requestGeneration = generation;
423
+ const result = pagingMode === "cursor" ? await nextCursorPage(offset, request) : await options.source.query(request);
424
+ if (generation !== requestGeneration || disposed)
425
+ return undefined;
426
+ if (!result)
427
+ return undefined;
428
+ const rows = [];
429
+ for (const [index, feature] of result.features.entries()) {
430
+ const row = toRow(feature, offset + index);
431
+ if (!row)
432
+ return undefined; // `toRow` already moved the table to `"unsupported"`.
433
+ rows.push(row);
434
+ }
435
+ const bytes = estimateBytes(result.features);
436
+ sequence += 1;
437
+ const page = { offset, rows: Object.freeze([...rows]), bytes, cacheKey, sequence };
438
+ pages.set(offset, page);
439
+ noteBudget({ rows: rows.length, bytes });
440
+ if (rows.length < budgets.pageSize && !result.exceededTransferLimit) {
441
+ exhausted = true;
442
+ if (totalKnown === undefined) {
443
+ totalKnown = offset + rows.length;
444
+ totalEvidence = "exhausted-pages";
445
+ }
446
+ }
447
+ if (typeof result.totalCount === "number") {
448
+ totalKnown = result.totalCount;
449
+ totalEvidence = "result-total-count";
450
+ }
451
+ else if (typeof plan?.estimates?.rows === "number")
452
+ totalEstimated = plan.estimates.rows;
453
+ evidence = buildEvidence(plan, [...composed.degraded, ...(result.degraded ?? [])], cacheKey);
454
+ enforceMemoryBudgets(new Set([offset]));
455
+ return page;
456
+ }
457
+ /**
458
+ * Forward-only cursor paging over `Source.stream()` — the protocol-neutral
459
+ * cursor seam. Random access is impossible by construction, so a jump past
460
+ * the drained frontier is reported as `"unsupported"` rather than silently
461
+ * served from the wrong page.
462
+ */
463
+ async function nextCursorPage(offset, request) {
464
+ const stream = options.source.stream;
465
+ if (!stream) {
466
+ state = "unsupported";
467
+ message = 'Cursor paging requires the source\'s `stream` capability. Use `pagingMode: "offset"` instead.';
468
+ return undefined;
469
+ }
470
+ const wantedPage = offset / budgets.pageSize;
471
+ if (wantedPage > cursorPages) {
472
+ state = "unsupported";
473
+ message = `Cursor paging is forward-only: page ${wantedPage} is past the drained frontier (${cursorPages}).`;
474
+ return undefined;
475
+ }
476
+ cursorIterator ??= stream.call(options.source, request);
477
+ const next = await cursorIterator.next();
478
+ if (next.done) {
479
+ exhausted = true;
480
+ return { features: [], exceededTransferLimit: false };
481
+ }
482
+ cursorPages += 1;
483
+ return next.value;
484
+ }
485
+ /**
486
+ * Build one row, or move the table to `"unsupported"` and return `undefined`.
487
+ *
488
+ * A row whose identity attribute is missing, `null`, or not a string/number
489
+ * has no stable identity. Substituting its position in the page would key the
490
+ * row by an offset that changes with every sort, filter, and page boundary —
491
+ * exactly the selection corruption the unsupported-identity state exists to
492
+ * prevent — so this refuses instead of inventing one.
493
+ */
494
+ function toRow(feature, index) {
495
+ const attributes = feature.attributes ?? {};
496
+ const raw = identityField ? attributes[identityField] : undefined;
497
+ if (typeof raw !== "string" && typeof raw !== "number") {
498
+ state = "unsupported";
499
+ message = `Row ${index} has no stable identity: \`${identityField}\` is ${describeIdentityValue(raw)}. ${IDENTITY_REQUIRED_HINT}`;
500
+ return undefined;
501
+ }
502
+ const id = raw;
503
+ return Object.freeze({
504
+ key: featureTableRowKey(options.sourceId, id),
505
+ id,
506
+ sourceId: options.sourceId,
507
+ index,
508
+ attributes,
509
+ ...(feature.geometry !== undefined ? { geometry: feature.geometry } : {}),
510
+ target: sourceFeatureSelectionTarget(options.sourceId, id),
511
+ });
512
+ }
513
+ function recomputeCount() {
514
+ const loaded = residentRows();
515
+ if (typeof totalKnown === "number") {
516
+ count = Object.freeze({ kind: "known", value: totalKnown, loaded, evidence: totalEvidence });
517
+ return;
518
+ }
519
+ if (exhausted) {
520
+ count = Object.freeze({ kind: "known", value: loaded, loaded, evidence: "exhausted-pages" });
521
+ return;
522
+ }
523
+ if (typeof totalEstimated === "number") {
524
+ count = Object.freeze({ kind: "estimated", value: totalEstimated, loaded, evidence: "plan-estimate" });
525
+ return;
526
+ }
527
+ count =
528
+ loaded > 0
529
+ ? Object.freeze({ kind: "partial", loaded, evidence: "loaded-rows" })
530
+ : Object.freeze({ kind: "unknown", loaded, evidence: "none" });
531
+ }
532
+ async function loadWindow(signal) {
533
+ const wanted = new Set();
534
+ const end = Math.max(window.endIndex, window.startIndex + 1);
535
+ for (let index = window.startIndex; index < end; index += 1)
536
+ wanted.add(pageOffsetFor(index));
537
+ for (const offset of [...wanted].sort((left, right) => left - right)) {
538
+ if (currentState() === "unsupported")
539
+ return;
540
+ if (exhausted && typeof totalKnown === "number" && offset >= totalKnown)
541
+ continue;
542
+ await fetchPage(offset, signal);
543
+ if (signal?.aborted)
544
+ return;
545
+ }
546
+ enforceMemoryBudgets(wanted);
547
+ }
548
+ async function run(signal) {
549
+ if (disposed || state === "unsupported")
550
+ return snapshot;
551
+ inFlight?.abort();
552
+ const controller = new AbortController();
553
+ inFlight = controller;
554
+ if (signal) {
555
+ if (signal.aborted)
556
+ controller.abort();
557
+ else
558
+ signal.addEventListener("abort", () => controller.abort(), { once: true });
559
+ }
560
+ // A superseded run must not write state either: its `cancelled` verdict
561
+ // would otherwise land after the newer run already published `ready`.
562
+ const runGeneration = generation;
563
+ state = "loading";
564
+ error = undefined;
565
+ publish();
566
+ try {
567
+ await loadWindow(controller.signal);
568
+ if (generation !== runGeneration || disposed)
569
+ return snapshot;
570
+ if (controller.signal.aborted) {
571
+ state = "cancelled";
572
+ message = CANCELLED_MESSAGE;
573
+ }
574
+ else if (currentState() !== "unsupported") {
575
+ const complete = exhausted || (typeof totalKnown === "number" && residentRows() >= totalKnown);
576
+ state = complete ? "ready" : "partial";
577
+ // A budget the run had to give something up for is not an error, but it
578
+ // must not be silently cleared either.
579
+ message = budgetNotice;
580
+ stale = false;
581
+ }
582
+ }
583
+ catch (caught) {
584
+ if (generation !== runGeneration || disposed)
585
+ return snapshot;
586
+ if (isAbort(caught)) {
587
+ state = "cancelled";
588
+ message = CANCELLED_MESSAGE;
589
+ }
590
+ else {
591
+ state = "error";
592
+ error = caught;
593
+ message = caught instanceof Error ? caught.message : "The table query failed.";
594
+ }
595
+ }
596
+ finally {
597
+ if (inFlight === controller)
598
+ inFlight = undefined;
599
+ }
600
+ recomputeCount();
601
+ return publish();
602
+ }
603
+ function invalidate() {
604
+ generation += 1;
605
+ pages.clear();
606
+ exhausted = false;
607
+ totalKnown = undefined;
608
+ totalEvidence = "result-total-count";
609
+ totalEstimated = undefined;
610
+ cursorPages = 0;
611
+ void cursorIterator?.return(undefined);
612
+ cursorIterator = undefined;
613
+ // `budgets.maxRequests` bounds one filter/sort/projection identity. Carrying
614
+ // the previous identity's consumption forward would let an exhausted table
615
+ // refuse to load a brand-new question forever, so the per-identity counter
616
+ // and its exhaustion flags reset while the lifetime totals stay intact.
617
+ ledger = Object.freeze({ ...ledger, requests: 0, exhausted: Object.freeze([]) });
618
+ budgetNotice = undefined;
619
+ conflicts = Object.freeze([]);
620
+ if (state !== "unsupported")
621
+ state = "idle";
622
+ }
623
+ function ensureWindow() {
624
+ if (window.endIndex > window.startIndex)
625
+ return;
626
+ window = Object.freeze({ startIndex: 0, endIndex: budgets.pageSize });
627
+ }
628
+ // ── Selection + focus ───────────────────────────────────────
629
+ function setSelection(next) {
630
+ const deduped = [...new Set(next)];
631
+ if (deduped.length === selection.length && deduped.every((key, index) => selection[index] === key))
632
+ return;
633
+ selection = Object.freeze(deduped);
634
+ rememberSelectionTargets();
635
+ publish();
636
+ }
637
+ /**
638
+ * Keep an exploration target for every selected key, and only for those keys.
639
+ *
640
+ * Resident rows supply their own target; keys learned from
641
+ * {@link HonuaFeatureTable.keysForTargets} keep the target they were resolved
642
+ * from. Pruning to the live selection bounds the index by selection size.
643
+ */
644
+ function rememberSelectionTargets() {
645
+ for (const key of selection) {
646
+ const row = findRow(key);
647
+ if (row)
648
+ selectionTargetIndex.set(key, row.target);
649
+ }
650
+ for (const key of [...selectionTargetIndex.keys()]) {
651
+ if (!selection.includes(key))
652
+ selectionTargetIndex.delete(key);
653
+ }
654
+ }
655
+ /**
656
+ * The row key a selection target maps to, or `undefined` when the target
657
+ * belongs to another source.
658
+ *
659
+ * Resolution is arithmetic on identity, not a cache lookup: a target for this
660
+ * source resolves whether or not its page is resident. A bare `FeatureId`
661
+ * target (the single-source exploration form) is treated as this source's.
662
+ */
663
+ function keyForTarget(target) {
664
+ if (!isSourceQualifiedSelectionTarget(target))
665
+ return featureTableRowKey(options.sourceId, target);
666
+ if (target.sourceId !== options.sourceId)
667
+ return undefined;
668
+ return featureTableRowKey(options.sourceId, target.id);
669
+ }
670
+ async function applyFocusMove(move) {
671
+ const fields = orderedFields();
672
+ if (fields.length === 0)
673
+ return snapshot;
674
+ const current = focus ? findRow(focus.rowKey) : undefined;
675
+ const pageStep = Math.max(1, window.endIndex - window.startIndex);
676
+ const lastIndex = typeof totalKnown === "number" ? Math.max(0, totalKnown - 1) : maxLoadedIndex();
677
+ let absolute = current?.index ?? window.startIndex;
678
+ let field = focus ? Math.max(0, fields.indexOf(focus.field)) : 0;
679
+ switch (move) {
680
+ case "up":
681
+ absolute -= 1;
682
+ break;
683
+ case "down":
684
+ absolute += 1;
685
+ break;
686
+ case "left":
687
+ field -= 1;
688
+ break;
689
+ case "right":
690
+ field += 1;
691
+ break;
692
+ case "row-start":
693
+ field = 0;
694
+ break;
695
+ case "row-end":
696
+ field = fields.length - 1;
697
+ break;
698
+ case "page-up":
699
+ absolute -= pageStep;
700
+ break;
701
+ case "page-down":
702
+ absolute += pageStep;
703
+ break;
704
+ case "grid-start":
705
+ absolute = 0;
706
+ field = 0;
707
+ break;
708
+ case "grid-end":
709
+ absolute = lastIndex;
710
+ field = fields.length - 1;
711
+ break;
712
+ }
713
+ absolute = Math.max(0, Math.min(absolute, lastIndex));
714
+ field = Math.max(0, Math.min(field, fields.length - 1));
715
+ if (absolute < window.startIndex || absolute >= window.endIndex) {
716
+ const span = Math.max(1, window.endIndex - window.startIndex);
717
+ const start = Math.max(0, absolute - Math.floor(span / 2));
718
+ window = Object.freeze({ startIndex: start, endIndex: start + span });
719
+ await run(undefined);
720
+ }
721
+ const row = rowAt(absolute);
722
+ const nextField = fields[field];
723
+ if (!row || nextField === undefined)
724
+ return publish();
725
+ focus = Object.freeze({ rowKey: row.key, field: nextField });
726
+ return publish();
727
+ }
728
+ function maxLoadedIndex() {
729
+ let max = 0;
730
+ for (const page of pages.values()) {
731
+ for (const row of page.rows)
732
+ max = Math.max(max, row.index);
733
+ }
734
+ return max;
735
+ }
736
+ // ── Realtime reconciliation (REQ-005) ───────────────────────
737
+ function reconcile(diff) {
738
+ const keyed = {
739
+ changes: diff.changes.map((change) => ({ kind: change.kind, key: change.key, id: change.id })),
740
+ reset: diff.reset,
741
+ ...(diff.resetReason ? { resetReason: diff.resetReason } : {}),
742
+ };
743
+ const nextConflicts = [];
744
+ const patched = [];
745
+ const sortFields = new Set(sort.map((entry) => entry.field));
746
+ const selectionResult = reconcileRealtimeKeyedState(new Set(selection), keyed);
747
+ const focusResult = reconcileRealtimeKeyedState(new Set(focus ? [focus.rowKey] : []), keyed);
748
+ const viewportResult = reconcileRealtimeViewport(window, keyed);
749
+ if (diff.reset) {
750
+ // A replacement snapshot / schema change replaces identity wholesale: the
751
+ // cache is dropped and the conflict announced. Sort, filters, and column
752
+ // state are user intent, so they are deliberately preserved.
753
+ const preservedWindow = window;
754
+ invalidate();
755
+ window = preservedWindow;
756
+ selection = Object.freeze([]);
757
+ focus = undefined;
758
+ stale = true;
759
+ if (state !== "unsupported")
760
+ state = "stale";
761
+ const code = diff.resetReason === "schema-changed" ? "schema-changed" : "snapshot-reset";
762
+ message =
763
+ code === "schema-changed"
764
+ ? "The source schema changed; refresh the table to rebuild rows."
765
+ : "A replacement snapshot arrived; refresh the table to rebuild rows.";
766
+ nextConflicts.push({ code, message, rowKeys: Object.freeze([]) });
767
+ }
768
+ else {
769
+ for (const change of diff.changes) {
770
+ if (change.kind === "delete") {
771
+ removeRow(change.key);
772
+ continue;
773
+ }
774
+ const existing = findRow(change.key);
775
+ // A create landing outside the resident window is picked up by the next
776
+ // fetch; patching it in would fabricate an ordering position.
777
+ if (!existing)
778
+ continue;
779
+ const attributes = change.record?.feature.attributes;
780
+ if (!attributes)
781
+ continue;
782
+ if (changesSortKey(existing.attributes, attributes, sortFields)) {
783
+ nextConflicts.push({
784
+ code: "sort-key-changed",
785
+ message: "A live update changed a sorted column. The row was patched in place; refresh to re-order the table.",
786
+ rowKeys: Object.freeze([change.key]),
787
+ });
788
+ }
789
+ patchRow(existing, attributes, change.record?.feature.geometry);
790
+ patched.push(change.key);
791
+ }
792
+ if (selectionResult.invalidations.length > 0) {
793
+ selection = Object.freeze([...selectionResult.next]);
794
+ nextConflicts.push({
795
+ code: "selection-invalidated",
796
+ message: "Selected rows were deleted upstream and were dropped from the selection.",
797
+ rowKeys: Object.freeze(selectionResult.invalidations.map((entry) => entry.key)),
798
+ });
799
+ }
800
+ if (focus && focusResult.invalidations.length > 0) {
801
+ const deleted = focus.rowKey;
802
+ focus = nextFocusAfterDeletion(focus);
803
+ nextConflicts.push({
804
+ code: "focused-row-deleted",
805
+ message: "The focused row was deleted upstream. Focus moved to the next available row.",
806
+ rowKeys: Object.freeze([deleted]),
807
+ });
808
+ }
809
+ }
810
+ // Realtime updates can change the serialized size of a resident page.
811
+ // Re-apply the hard cache ceilings after reconciliation so a large delta
812
+ // cannot bypass the same bounded-memory contract as a fetched page.
813
+ enforceMemoryBudgets(new Set());
814
+ if (budgetNotice !== undefined)
815
+ message = budgetNotice;
816
+ recomputeCount();
817
+ conflicts = Object.freeze(nextConflicts.map((conflict) => Object.freeze(conflict)));
818
+ return Object.freeze({
819
+ preserved: nextConflicts.length === 0,
820
+ conflicts,
821
+ ...(focus ? { focus } : {}),
822
+ selection,
823
+ sort,
824
+ window: viewportResult.viewport,
825
+ invalidations: Object.freeze([...selectionResult.invalidations, ...focusResult.invalidations]),
826
+ patchedRowKeys: Object.freeze(patched),
827
+ });
828
+ }
829
+ function removeRow(key) {
830
+ for (const page of [...pages.values()]) {
831
+ if (!page.rows.some((row) => row.key === key))
832
+ continue;
833
+ const rows = Object.freeze(page.rows.filter((row) => row.key !== key));
834
+ pages.set(page.offset, {
835
+ ...page,
836
+ rows,
837
+ bytes: estimateBytes(rows.map((row) => ({ attributes: row.attributes }))),
838
+ });
839
+ if (typeof totalKnown === "number")
840
+ totalKnown = Math.max(0, totalKnown - 1);
841
+ return;
842
+ }
843
+ }
844
+ function patchRow(row, attributes, geometry) {
845
+ const page = pages.get(pageOffsetFor(row.index));
846
+ if (!page)
847
+ return;
848
+ const next = Object.freeze({
849
+ ...row,
850
+ attributes: { ...row.attributes, ...attributes },
851
+ ...(geometry !== undefined ? { geometry } : {}),
852
+ });
853
+ const rows = Object.freeze(page.rows.map((candidate) => (candidate.key === row.key ? next : candidate)));
854
+ pages.set(page.offset, {
855
+ ...page,
856
+ rows,
857
+ bytes: estimateBytes(rows.map((candidate) => ({ attributes: candidate.attributes }))),
858
+ });
859
+ }
860
+ function nextFocusAfterDeletion(current) {
861
+ const fallback = loadedRows().find((row) => row.key !== current.rowKey);
862
+ return fallback ? Object.freeze({ rowKey: fallback.key, field: current.field }) : undefined;
863
+ }
864
+ // ── Mutators ────────────────────────────────────────────────
865
+ async function refresh(refreshOptions = {}) {
866
+ invalidate();
867
+ ensureWindow();
868
+ return run(refreshOptions.signal);
869
+ }
870
+ async function setSort(nextSort) {
871
+ sort = Object.freeze([...nextSort]);
872
+ invalidate();
873
+ ensureWindow();
874
+ return run(undefined);
875
+ }
876
+ async function setColumns(nextColumns) {
877
+ const before = (projection() ?? []).join(",");
878
+ columns = resolveColumns(nextColumns);
879
+ if ((projection() ?? []).join(",") === before)
880
+ return publish();
881
+ invalidate();
882
+ ensureWindow();
883
+ return run(undefined);
884
+ }
885
+ async function runExport(request) {
886
+ const fields = orderedFields();
887
+ if (currentState() === "unsupported") {
888
+ return Object.freeze({
889
+ format: request.format,
890
+ content: request.format === "csv" ? toCsv([], fields, columns) : toJson([], fields, columns),
891
+ rowCount: 0,
892
+ truncated: false,
893
+ columns: Object.freeze([...fields]),
894
+ evidence,
895
+ });
896
+ }
897
+ const ceiling = Math.max(0, Math.min(request.maxRows ?? budgets.maxExportRows, budgets.maxExportRows));
898
+ const rows = [];
899
+ let limit = (request.maxRows ?? 0) > budgets.maxExportRows ? "export-rows" : undefined;
900
+ if (request.selectionOnly) {
901
+ const keys = new Set(selection);
902
+ for (const row of loadedRows()) {
903
+ if (keys.has(row.key) && rows.length < ceiling)
904
+ rows.push(row);
905
+ }
906
+ }
907
+ else {
908
+ let offset = 0;
909
+ while (rows.length < ceiling) {
910
+ if (ledger.requests >= budgets.maxRequests) {
911
+ markExhausted("requests");
912
+ limit = "requests";
913
+ break;
914
+ }
915
+ const page = await fetchPage(offset, request.signal);
916
+ if (!page || page.rows.length === 0)
917
+ break;
918
+ for (const row of page.rows) {
919
+ if (rows.length < ceiling)
920
+ rows.push(row);
921
+ }
922
+ if (page.rows.length < budgets.pageSize)
923
+ break;
924
+ offset += budgets.pageSize;
925
+ }
926
+ if (rows.length >= ceiling && !exhausted)
927
+ limit ??= "export-rows";
928
+ }
929
+ recomputeCount();
930
+ publish();
931
+ return Object.freeze({
932
+ format: request.format,
933
+ content: request.format === "csv" ? toCsv(rows, fields, columns) : toJson(rows, fields, columns),
934
+ rowCount: rows.length,
935
+ truncated: limit !== undefined,
936
+ ...(limit ? { limit } : {}),
937
+ columns: Object.freeze([...fields]),
938
+ evidence,
939
+ });
940
+ }
941
+ return {
942
+ get snapshot() {
943
+ return snapshot;
944
+ },
945
+ subscribe(listener) {
946
+ listeners.add(listener);
947
+ return () => {
948
+ listeners.delete(listener);
949
+ };
950
+ },
951
+ refresh,
952
+ async setScroll(metrics) {
953
+ const next = featureTableWindow(metrics, {
954
+ overscan: budgets.windowOverscan,
955
+ ...(typeof totalKnown === "number" ? { totalRows: totalKnown } : {}),
956
+ });
957
+ if (next.startIndex === window.startIndex && next.endIndex === window.endIndex && state !== "idle") {
958
+ return snapshot;
959
+ }
960
+ window = next;
961
+ return run(undefined);
962
+ },
963
+ setSort,
964
+ async toggleSort(field, toggleOptions = {}) {
965
+ const existing = sort.find((entry) => entry.field === field);
966
+ const others = toggleOptions.additive ? sort.filter((entry) => entry.field !== field) : [];
967
+ if (existing === undefined)
968
+ return setSort([...others, { field, direction: "asc" }]);
969
+ if ((existing.direction ?? "asc") === "asc")
970
+ return setSort([...others, { field, direction: "desc" }]);
971
+ return setSort(others);
972
+ },
973
+ async setFilters(nextFilters) {
974
+ filters = normalizeFilters(nextFilters);
975
+ invalidate();
976
+ ensureWindow();
977
+ return run(undefined);
978
+ },
979
+ setColumns,
980
+ async setColumnVisibility(field, visible) {
981
+ return setColumns(columns.map((column) => (column.field === field ? { ...column, visible } : { ...column })));
982
+ },
983
+ select(keys, selectOptions = {}) {
984
+ setSelection(selectOptions.replace === false ? [...selection, ...keys] : keys);
985
+ },
986
+ deselect(keys) {
987
+ if (!keys) {
988
+ setSelection([]);
989
+ return;
990
+ }
991
+ const drop = new Set(keys);
992
+ setSelection(selection.filter((key) => !drop.has(key)));
993
+ },
994
+ keysForTargets(targets) {
995
+ const out = [];
996
+ for (const target of targets) {
997
+ const key = keyForTarget(target);
998
+ if (key === undefined || out.includes(key))
999
+ continue;
1000
+ // Remember the target so a selection made from an off-window key can
1001
+ // still be published back as a target.
1002
+ selectionTargetIndex.set(key, target);
1003
+ out.push(key);
1004
+ }
1005
+ return Object.freeze(out);
1006
+ },
1007
+ selectionTargets() {
1008
+ const out = [];
1009
+ for (const key of selection) {
1010
+ const target = findRow(key)?.target ?? selectionTargetIndex.get(key);
1011
+ if (target !== undefined)
1012
+ out.push(target);
1013
+ }
1014
+ return Object.freeze(out);
1015
+ },
1016
+ setFocus(nextFocus) {
1017
+ focus = nextFocus ? Object.freeze({ ...nextFocus }) : undefined;
1018
+ publish();
1019
+ },
1020
+ moveFocus: applyFocusMove,
1021
+ applyRealtimeDiff(diff) {
1022
+ const outcome = reconcile(diff);
1023
+ publish();
1024
+ return outcome;
1025
+ },
1026
+ markStale(nextMessage) {
1027
+ stale = true;
1028
+ if (state === "ready" || state === "partial")
1029
+ state = "stale";
1030
+ message = nextMessage;
1031
+ publish();
1032
+ },
1033
+ export: runExport,
1034
+ cancel() {
1035
+ const controller = inFlight;
1036
+ inFlight = undefined;
1037
+ controller?.abort();
1038
+ if (state === "loading") {
1039
+ state = "cancelled";
1040
+ message = CANCELLED_MESSAGE;
1041
+ publish();
1042
+ }
1043
+ },
1044
+ dispose() {
1045
+ disposed = true;
1046
+ inFlight?.abort();
1047
+ inFlight = undefined;
1048
+ void cursorIterator?.return(undefined);
1049
+ cursorIterator = undefined;
1050
+ pages.clear();
1051
+ listeners.clear();
1052
+ },
1053
+ };
1054
+ }
1055
+ /**
1056
+ * Wire a bounded table to a shared exploration view controller in **both**
1057
+ * directions (REQ-003).
1058
+ *
1059
+ * Table → context: selection, sort, filters, and the virtualization window
1060
+ * (as the shared `page` slice) are published as intents from the bound view,
1061
+ * so the linked-view preset decides whether peers accept them.
1062
+ *
1063
+ * Context → table: peer changes to selection, sort, visible fields, and filters
1064
+ * are applied to the table. The view controller suppresses self-origin
1065
+ * notifications, and this binding additionally guards re-entry, so no feedback
1066
+ * loop is possible.
1067
+ *
1068
+ * Returns an unsubscribe function; call it on component teardown.
1069
+ */
1070
+ export function linkFeatureTableToExploration(table, view, options = {}) {
1071
+ const syncSort = options.sort ?? true;
1072
+ const syncFields = options.visibleFields ?? true;
1073
+ const syncFilters = options.filters ?? true;
1074
+ const syncSelection = options.selection ?? true;
1075
+ const syncPage = options.page ?? true;
1076
+ let applying = false;
1077
+ let suppressFilterPublish = 0;
1078
+ /** Filter snapshots requested by inbound assignments; these are suppressed. */
1079
+ const inboundFilterSnapshots = new Set();
1080
+ let queuedFilterPublish = false;
1081
+ const publishedFilterIds = new Set();
1082
+ function publishFilters(snapshot) {
1083
+ const currentFilterIds = new Set();
1084
+ for (const clause of snapshot.filters) {
1085
+ const explorationClause = filterClauseToExplorationClause(clause);
1086
+ if (explorationClause === undefined || clause.enabled === false)
1087
+ continue;
1088
+ currentFilterIds.add(clause.id);
1089
+ publishedFilterIds.add(clause.id);
1090
+ view.setFilter(clause.id, explorationClause);
1091
+ }
1092
+ for (const id of publishedFilterIds) {
1093
+ if (!currentFilterIds.has(id)) {
1094
+ view.clearFilter(id);
1095
+ publishedFilterIds.delete(id);
1096
+ }
1097
+ }
1098
+ }
1099
+ const unsubscribeContext = view.subscribe(["selection", "sort", "visibleFields", "filters"], (event) => {
1100
+ if (applying)
1101
+ return;
1102
+ applying = true;
1103
+ try {
1104
+ if (syncSelection && event.changedSlices.has("selection")) {
1105
+ table.select(table.keysForTargets(event.state.selection), { replace: true });
1106
+ }
1107
+ if (syncSort && event.changedSlices.has("sort"))
1108
+ void table.setSort(event.state.sort);
1109
+ if (syncFields && event.changedSlices.has("visibleFields")) {
1110
+ const fields = new Set(event.state.visibleFields);
1111
+ if (fields.size > 0) {
1112
+ void table.setColumns(table.snapshot.columns.map((column) => ({ ...column, visible: fields.has(column.field) })));
1113
+ }
1114
+ }
1115
+ if (syncFilters && event.changedSlices.has("filters")) {
1116
+ suppressFilterPublish += 1;
1117
+ const inboundRefresh = table.setFilters(Object.entries(event.state.filters).map(([id, clause]) => explorationClauseToFilterClause(id, clause)));
1118
+ // setFilters publishes its loading snapshot synchronously before it
1119
+ // returns. Remember that exact snapshot so completion of this inbound
1120
+ // assignment is not mistaken for a local table edit.
1121
+ const inboundSnapshot = table.snapshot.filters;
1122
+ inboundFilterSnapshots.add(inboundSnapshot);
1123
+ void inboundRefresh.finally(() => {
1124
+ inboundFilterSnapshots.delete(inboundSnapshot);
1125
+ suppressFilterPublish -= 1;
1126
+ if (suppressFilterPublish === 0 && queuedFilterPublish) {
1127
+ queuedFilterPublish = false;
1128
+ if (!inboundFilterSnapshots.has(table.snapshot.filters))
1129
+ publishFilters(table.snapshot);
1130
+ }
1131
+ });
1132
+ }
1133
+ }
1134
+ finally {
1135
+ applying = false;
1136
+ }
1137
+ });
1138
+ let lastSelection = table.snapshot.selection;
1139
+ let lastSort = table.snapshot.sort;
1140
+ let lastWindow = table.snapshot.window;
1141
+ let lastFilters = table.snapshot.filters;
1142
+ const unsubscribeTable = table.subscribe((snapshot) => {
1143
+ const filtersChanged = snapshot.filters !== lastFilters;
1144
+ lastFilters = snapshot.filters;
1145
+ if (applying)
1146
+ return;
1147
+ applying = true;
1148
+ try {
1149
+ if (syncSelection && snapshot.selection !== lastSelection) {
1150
+ lastSelection = snapshot.selection;
1151
+ // Replace only this table's own source. A multi-source workspace's
1152
+ // selections for peer sources are not the table's to drop.
1153
+ const sourceId = snapshot.sourceId;
1154
+ const peers = view.state.selection.filter((target) => isSourceQualifiedSelectionTarget(target) && target.sourceId !== sourceId);
1155
+ view.select([...peers, ...table.selectionTargets()], { replace: true });
1156
+ }
1157
+ if (syncSort && snapshot.sort !== lastSort) {
1158
+ lastSort = snapshot.sort;
1159
+ view.setSort(snapshot.sort);
1160
+ }
1161
+ if (syncPage && snapshot.window !== lastWindow) {
1162
+ lastWindow = snapshot.window;
1163
+ view.setPage({
1164
+ offset: snapshot.window.startIndex,
1165
+ limit: Math.max(0, snapshot.window.endIndex - snapshot.window.startIndex),
1166
+ });
1167
+ }
1168
+ if (syncFilters && filtersChanged && suppressFilterPublish === 0) {
1169
+ publishFilters(snapshot);
1170
+ }
1171
+ else if (syncFilters && filtersChanged && suppressFilterPublish > 0) {
1172
+ // A local setFilters can start while an inbound refresh is awaiting
1173
+ // I/O. Do not lose that edit when the inbound suppression settles.
1174
+ queuedFilterPublish = true;
1175
+ }
1176
+ }
1177
+ finally {
1178
+ applying = false;
1179
+ }
1180
+ });
1181
+ return () => {
1182
+ unsubscribeContext();
1183
+ unsubscribeTable();
1184
+ };
1185
+ }
1186
+ /**
1187
+ * Project one exploration filter clause onto a filter-registry clause so the
1188
+ * shared `filters` slice compiles through the same typed pushdown path as
1189
+ * table-owned filters.
1190
+ */
1191
+ export function explorationClauseToFilterClause(id, clause) {
1192
+ return {
1193
+ id,
1194
+ owner: { kind: "table", id: "honua-feature-table" },
1195
+ field: clause.field,
1196
+ operator: clause.operator,
1197
+ ...(clause.value === undefined ? {} : { value: clause.value }),
1198
+ ...(clause.appliesTo && clause.appliesTo.length > 0 ? { sourceScope: [...clause.appliesTo] } : {}),
1199
+ effect: "filter",
1200
+ };
1201
+ }
1202
+ /**
1203
+ * Project a table-owned filter back onto the shared exploration filter shape.
1204
+ * Registry-only metadata is intentionally omitted; unsupported clauses (for
1205
+ * example spatial masks without a field/operator pair) remain table-local.
1206
+ */
1207
+ export function filterClauseToExplorationClause(clause) {
1208
+ if (clause.field === undefined || clause.operator === undefined)
1209
+ return undefined;
1210
+ const policy = clause.valuePolicy;
1211
+ if (policy?.secret === true)
1212
+ return undefined;
1213
+ if (policy?.maxSerializedBytes !== undefined && clause.value !== undefined) {
1214
+ let serialized;
1215
+ try {
1216
+ serialized = JSON.stringify(clause.value);
1217
+ }
1218
+ catch {
1219
+ return undefined;
1220
+ }
1221
+ if (serialized.length > policy.maxSerializedBytes)
1222
+ return undefined;
1223
+ }
1224
+ return {
1225
+ field: clause.field,
1226
+ operator: clause.operator,
1227
+ ...(clause.value === undefined ? {} : { value: clause.value }),
1228
+ ...(Array.isArray(clause.sourceScope) && clause.sourceScope.length > 0
1229
+ ? { appliesTo: [...clause.sourceScope] }
1230
+ : {}),
1231
+ };
1232
+ }
1233
+ // ── Presentation helpers ──────────────────────────────────────
1234
+ /**
1235
+ * Default cell formatting. Pure presentation (always client work): `null` and
1236
+ * `undefined` render as an empty string rather than the words
1237
+ * `"null"`/`"undefined"`.
1238
+ */
1239
+ export function formatFeatureTableCell(value, column) {
1240
+ if (column?.format)
1241
+ return column.format(value);
1242
+ if (value === null || value === undefined)
1243
+ return "";
1244
+ if (typeof value === "number")
1245
+ return Number.isFinite(value) ? String(value) : "";
1246
+ if (typeof value === "boolean")
1247
+ return value ? "true" : "false";
1248
+ if (typeof value === "string")
1249
+ return value;
1250
+ return JSON.stringify(value) ?? "";
1251
+ }
1252
+ /** `aria-sort` token for one column, per the WAI-ARIA grid pattern. */
1253
+ export function featureTableAriaSort(field, sort) {
1254
+ const entry = sort.find((candidate) => candidate.field === field);
1255
+ if (!entry)
1256
+ return "none";
1257
+ if (sort.length > 1 && sort[0]?.field !== field)
1258
+ return "other";
1259
+ return (entry.direction ?? "asc") === "asc" ? "ascending" : "descending";
1260
+ }
1261
+ /**
1262
+ * Screen-reader announcement for the current result truth. Deliberately says
1263
+ * "at least" for partial results instead of presenting a loaded-row count as a
1264
+ * total (REQ-004).
1265
+ */
1266
+ export function describeFeatureTableCount(count) {
1267
+ switch (count.kind) {
1268
+ case "known":
1269
+ return `${count.value ?? 0} rows`;
1270
+ case "estimated":
1271
+ return `about ${count.value ?? 0} rows (estimated)`;
1272
+ case "partial":
1273
+ return `at least ${count.loaded} rows loaded; total unknown`;
1274
+ default:
1275
+ return "row count unknown";
1276
+ }
1277
+ }
1278
+ /**
1279
+ * `aria-rowcount` for the grid. Returns `-1` — the ARIA value for "total
1280
+ * unknown" — instead of substituting the loaded-row count (REQ-004).
1281
+ */
1282
+ export function featureTableAriaRowCount(count) {
1283
+ return count.kind === "known" || count.kind === "estimated" ? (count.value ?? -1) + 1 : -1;
1284
+ }
1285
+ // ── Internals ─────────────────────────────────────────────────
1286
+ function resolveColumns(columns) {
1287
+ return Object.freeze(columns.map((column) => Object.freeze({
1288
+ field: column.field,
1289
+ label: column.label ?? column.field,
1290
+ type: column.type ?? "unknown",
1291
+ visible: column.visible ?? true,
1292
+ sortable: column.sortable ?? true,
1293
+ ...(column.format ? { format: column.format } : {}),
1294
+ })));
1295
+ }
1296
+ /** Name an unusable identity value without leaking the attribute payload. */
1297
+ function describeIdentityValue(value) {
1298
+ if (value === undefined)
1299
+ return "absent";
1300
+ if (value === null)
1301
+ return "null";
1302
+ return `a ${Array.isArray(value) ? "array" : typeof value}`;
1303
+ }
1304
+ function normalizeFilters(filters) {
1305
+ if (!filters)
1306
+ return Object.freeze([]);
1307
+ if (Array.isArray(filters))
1308
+ return Object.freeze([...filters]);
1309
+ return Object.freeze([...filters.clauses]);
1310
+ }
1311
+ function estimateBytes(features) {
1312
+ let total = 0;
1313
+ for (const feature of features) {
1314
+ try {
1315
+ total += JSON.stringify(feature.attributes ?? {})?.length ?? 0;
1316
+ }
1317
+ catch {
1318
+ // A non-serializable attribute bag contributes no measurable ceiling cost.
1319
+ }
1320
+ }
1321
+ return total * 2; // UTF-16 code units
1322
+ }
1323
+ function changesSortKey(previous, next, sortFields) {
1324
+ if (sortFields.size === 0)
1325
+ return false;
1326
+ const before = previous;
1327
+ const after = next;
1328
+ for (const field of sortFields) {
1329
+ if (field in after && before[field] !== after[field])
1330
+ return true;
1331
+ }
1332
+ return false;
1333
+ }
1334
+ function isAbort(error) {
1335
+ return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
1336
+ }
1337
+ function toCsv(rows, fields, columns) {
1338
+ const byField = new Map(columns.map((column) => [column.field, column]));
1339
+ const lines = [fields.map(csvCell).join(",")];
1340
+ for (const row of rows) {
1341
+ const attributes = row.attributes;
1342
+ lines.push(fields.map((field) => csvCell(formatFeatureTableCell(attributes[field], byField.get(field)))).join(","));
1343
+ }
1344
+ return lines.join("\n");
1345
+ }
1346
+ function csvCell(value) {
1347
+ if (!value.includes(",") && !value.includes('"') && !value.includes("\n"))
1348
+ return value;
1349
+ return `"${value.split('"').join('""')}"`;
1350
+ }
1351
+ function toJson(rows, fields, columns) {
1352
+ const byField = new Map(columns.map((column) => [column.field, column]));
1353
+ return JSON.stringify(rows.map((row) => {
1354
+ const attributes = row.attributes;
1355
+ const out = {};
1356
+ for (const field of fields)
1357
+ out[field] = formatFeatureTableCell(attributes[field], byField.get(field));
1358
+ return { id: row.id, attributes: out };
1359
+ }));
1360
+ }