@honua/react 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 (295) hide show
  1. package/contract/discovery.d.ts +34 -0
  2. package/contract/discovery.d.ts.map +1 -1
  3. package/contract/index.d.ts +7 -5
  4. package/contract/index.d.ts.map +1 -1
  5. package/contract/index.js +3 -2
  6. package/contract/pmtiles.d.ts +10 -1
  7. package/contract/pmtiles.d.ts.map +1 -1
  8. package/contract/pmtiles.js +10 -2
  9. package/contract/protocol-module.d.ts +84 -9
  10. package/contract/protocol-module.d.ts.map +1 -1
  11. package/contract/query-filter-fes.d.ts +18 -0
  12. package/contract/query-filter-fes.d.ts.map +1 -0
  13. package/contract/query-filter-fes.js +122 -0
  14. package/contract/query-filter.d.ts +300 -0
  15. package/contract/query-filter.d.ts.map +1 -0
  16. package/contract/query-filter.js +883 -0
  17. package/contract/schema.d.ts +12 -0
  18. package/contract/schema.d.ts.map +1 -1
  19. package/contract/schema.js +34 -0
  20. package/contract/source-capability-support.d.ts.map +1 -1
  21. package/contract/source-capability-support.js +15 -3
  22. package/contract/source.d.ts +45 -25
  23. package/contract/source.d.ts.map +1 -1
  24. package/contract/source.js +719 -479
  25. package/contract/types.d.ts +88 -19
  26. package/contract/types.d.ts.map +1 -1
  27. package/contract/types.js +4 -0
  28. package/contract/widget-source.d.ts.map +1 -1
  29. package/core/client.d.ts +51 -21
  30. package/core/client.d.ts.map +1 -1
  31. package/core/client.js +234 -54
  32. package/core/error-classifications.d.ts +23 -0
  33. package/core/error-classifications.d.ts.map +1 -1
  34. package/core/error-classifications.js +23 -0
  35. package/core/error-code-registry.d.ts +24 -1
  36. package/core/error-code-registry.d.ts.map +1 -1
  37. package/core/error-code-registry.js +23 -0
  38. package/core/error-envelope.js +2 -0
  39. package/core/errors.d.ts +2 -2
  40. package/core/errors.d.ts.map +1 -1
  41. package/core/errors.js +1 -1
  42. package/core/geoparquet-sql.d.ts +69 -5
  43. package/core/geoparquet-sql.d.ts.map +1 -1
  44. package/core/geoparquet-sql.js +327 -10
  45. package/core/odata-write-codec.d.ts.map +1 -1
  46. package/core/odata-write-codec.js +0 -1
  47. package/core/odata.d.ts +6 -0
  48. package/core/odata.d.ts.map +1 -1
  49. package/core/odata.js +6 -1
  50. package/core/ogc-processes.d.ts +182 -25
  51. package/core/ogc-processes.d.ts.map +1 -1
  52. package/core/ogc-processes.js +602 -30
  53. package/core/process-runner.d.ts +8 -1
  54. package/core/process-runner.d.ts.map +1 -1
  55. package/core/process-runner.js +1 -0
  56. package/core/protocol-transport.d.ts +2 -0
  57. package/core/protocol-transport.d.ts.map +1 -1
  58. package/core/stac.d.ts +32 -0
  59. package/core/stac.d.ts.map +1 -1
  60. package/core/stac.js +158 -23
  61. package/core/surfaces.d.ts.map +1 -1
  62. package/core/surfaces.js +273 -68
  63. package/core/types.d.ts +138 -11
  64. package/core/types.d.ts.map +1 -1
  65. package/core/wfs-axis-order.d.ts +18 -0
  66. package/core/wfs-axis-order.d.ts.map +1 -0
  67. package/core/wfs-axis-order.js +108 -0
  68. package/core/wfs-capabilities.d.ts +65 -24
  69. package/core/wfs-capabilities.d.ts.map +1 -1
  70. package/core/wfs-capabilities.js +237 -27
  71. package/core/wfs-filter.d.ts +26 -3
  72. package/core/wfs-filter.d.ts.map +1 -1
  73. package/core/wfs-filter.js +48 -53
  74. package/core/wfs-protocol-error.d.ts +12 -0
  75. package/core/wfs-protocol-error.d.ts.map +1 -0
  76. package/core/wfs-protocol-error.js +13 -0
  77. package/core/wfs.d.ts +53 -1
  78. package/core/wfs.d.ts.map +1 -1
  79. package/core/wfs.js +251 -29
  80. package/core/wms-feature-info.d.ts +92 -0
  81. package/core/wms-feature-info.d.ts.map +1 -0
  82. package/core/wms-feature-info.js +285 -0
  83. package/core/wms.d.ts +9 -0
  84. package/core/wms.d.ts.map +1 -1
  85. package/core/wms.js +17 -5
  86. package/esri-compat/event-bus.d.ts +20 -0
  87. package/esri-compat/event-bus.d.ts.map +1 -1
  88. package/esri-compat/feature-filter.d.ts +46 -1
  89. package/esri-compat/feature-filter.d.ts.map +1 -1
  90. package/esri-compat/feature-filter.js +49 -0
  91. package/esri-compat/geojson-layer.d.ts +4 -2
  92. package/esri-compat/geojson-layer.d.ts.map +1 -1
  93. package/esri-compat/geojson-layer.js +4 -2
  94. package/esri-compat/identity-manager.js +2 -2
  95. package/esri-compat/imagery-layer.d.ts +2 -1
  96. package/esri-compat/imagery-layer.d.ts.map +1 -1
  97. package/esri-compat/imagery-layer.js +2 -1
  98. package/esri-compat/layer-list.d.ts +8 -1
  99. package/esri-compat/layer-list.d.ts.map +1 -1
  100. package/esri-compat/layer-list.js +11 -4
  101. package/esri-compat/legend.d.ts.map +1 -1
  102. package/esri-compat/legend.js +1 -1
  103. package/esri-compat/locator.d.ts +297 -0
  104. package/esri-compat/locator.d.ts.map +1 -0
  105. package/esri-compat/locator.js +579 -0
  106. package/esri-compat/map-image-layer.d.ts.map +1 -1
  107. package/esri-compat/map-view.d.ts.map +1 -1
  108. package/esri-compat/map-view.js +1 -1
  109. package/esri-compat/scene-view.d.ts.map +1 -1
  110. package/esri-compat/time-slider.d.ts +29 -0
  111. package/esri-compat/time-slider.d.ts.map +1 -1
  112. package/esri-compat/time-slider.js +117 -0
  113. package/esri-compat/wfs-layer.d.ts +2 -1
  114. package/esri-compat/wfs-layer.d.ts.map +1 -1
  115. package/esri-compat/wfs-layer.js +2 -1
  116. package/esri-compat/widget-host.d.ts +42 -6
  117. package/esri-compat/widget-host.d.ts.map +1 -1
  118. package/esri-compat/widget-host.js +99 -10
  119. package/generated-app/projection.d.ts.map +1 -1
  120. package/generated-app/runtime.d.ts.map +1 -1
  121. package/interactions/declarative-contract.d.ts +149 -0
  122. package/interactions/declarative-contract.d.ts.map +1 -0
  123. package/interactions/declarative-contract.js +293 -0
  124. package/interactions/declarative.d.ts +198 -0
  125. package/interactions/declarative.d.ts.map +1 -0
  126. package/interactions/declarative.js +470 -0
  127. package/interactions/feature-state.d.ts +7 -0
  128. package/interactions/feature-state.d.ts.map +1 -1
  129. package/interactions/feature-state.js +10 -2
  130. package/map/data-to-map-bridge.d.ts +1 -1
  131. package/map/data-to-map-bridge.d.ts.map +1 -1
  132. package/map/data-to-map-bridge.js +1 -2
  133. package/map/honua-map.d.ts.map +1 -1
  134. package/map/honua-map.js +1 -1
  135. package/map/index.d.ts +2 -0
  136. package/map/index.d.ts.map +1 -1
  137. package/map/index.js +2 -0
  138. package/map/maplibre-target.js +1 -1
  139. package/map/realtime-reconciliation-adapter.d.ts +98 -0
  140. package/map/realtime-reconciliation-adapter.d.ts.map +1 -0
  141. package/map/realtime-reconciliation-adapter.js +84 -0
  142. package/map/temporal-playback.d.ts +25 -0
  143. package/map/temporal-playback.d.ts.map +1 -1
  144. package/map/temporal-playback.js +29 -1
  145. package/package.json +4 -3
  146. package/query-planner/canonical-filter.d.ts +30 -0
  147. package/query-planner/canonical-filter.d.ts.map +1 -0
  148. package/query-planner/canonical-filter.js +30 -0
  149. package/query-planner/columnar-plan.d.ts +63 -0
  150. package/query-planner/columnar-plan.d.ts.map +1 -0
  151. package/query-planner/columnar-plan.js +94 -0
  152. package/query-planner/diagnostics.d.ts +3 -3
  153. package/query-planner/diagnostics.d.ts.map +1 -1
  154. package/query-planner/diagnostics.js +17 -4
  155. package/query-planner/duckdb.d.ts.map +1 -1
  156. package/query-planner/duckdb.js +16 -2
  157. package/query-planner/execution-context.d.ts.map +1 -1
  158. package/query-planner/execution-context.js +3 -1
  159. package/query-planner/executor.d.ts.map +1 -1
  160. package/query-planner/executor.js +7 -1
  161. package/query-planner/geoservices.d.ts.map +1 -1
  162. package/query-planner/geoservices.js +24 -5
  163. package/query-planner/grpc.d.ts.map +1 -1
  164. package/query-planner/grpc.js +29 -5
  165. package/query-planner/index.d.ts +20 -5
  166. package/query-planner/index.d.ts.map +1 -1
  167. package/query-planner/index.js +16 -3
  168. package/query-planner/ir.d.ts +6 -1
  169. package/query-planner/ir.d.ts.map +1 -1
  170. package/query-planner/ir.js +63 -1
  171. package/query-planner/odata-protocol-module.d.ts +54 -0
  172. package/query-planner/odata-protocol-module.d.ts.map +1 -0
  173. package/query-planner/odata-protocol-module.js +199 -0
  174. package/query-planner/odata-v1.d.ts +23 -0
  175. package/query-planner/odata-v1.d.ts.map +1 -0
  176. package/query-planner/odata-v1.js +202 -0
  177. package/query-planner/odata.d.ts +0 -3
  178. package/query-planner/odata.d.ts.map +1 -1
  179. package/query-planner/odata.js +0 -143
  180. package/query-planner/ogc-features.d.ts.map +1 -1
  181. package/query-planner/ogc-features.js +23 -1
  182. package/query-planner/planner.d.ts +2 -2
  183. package/query-planner/planner.d.ts.map +1 -1
  184. package/query-planner/planner.js +146 -27
  185. package/query-planner/representation.d.ts +59 -0
  186. package/query-planner/representation.d.ts.map +1 -0
  187. package/query-planner/representation.js +140 -0
  188. package/query-planner/types.d.ts +153 -3
  189. package/query-planner/types.d.ts.map +1 -1
  190. package/query-planner/wfs-protocol-module.d.ts +57 -0
  191. package/query-planner/wfs-protocol-module.d.ts.map +1 -0
  192. package/query-planner/wfs-protocol-module.js +731 -0
  193. package/query-planner/wfs-v1.d.ts +41 -0
  194. package/query-planner/wfs-v1.d.ts.map +1 -0
  195. package/query-planner/wfs-v1.js +366 -0
  196. package/query-planner/wfs.d.ts +1 -3
  197. package/query-planner/wfs.d.ts.map +1 -1
  198. package/query-planner/wfs.js +1 -70
  199. package/react/honua-map.d.ts +3 -1
  200. package/react/honua-map.d.ts.map +1 -1
  201. package/react/honua-map.js +11 -1
  202. package/react/hooks.d.ts +1 -1
  203. package/react/hooks.js +1 -1
  204. package/react/selection.d.ts.map +1 -1
  205. package/react/selection.js +0 -0
  206. package/react/use-mounted-source.d.ts +1 -1
  207. package/react/use-mounted-source.js +1 -1
  208. package/realtime/checkpoint-store.d.ts +206 -0
  209. package/realtime/checkpoint-store.d.ts.map +1 -0
  210. package/realtime/checkpoint-store.js +504 -0
  211. package/realtime/contract.d.ts +142 -0
  212. package/realtime/contract.d.ts.map +1 -0
  213. package/realtime/contract.js +154 -0
  214. package/realtime/exploration.d.ts +16 -0
  215. package/realtime/exploration.d.ts.map +1 -0
  216. package/realtime/exploration.js +32 -0
  217. package/realtime/honua-server.d.ts +131 -0
  218. package/realtime/honua-server.d.ts.map +1 -0
  219. package/realtime/honua-server.js +302 -0
  220. package/realtime/index.d.ts +39 -0
  221. package/realtime/index.d.ts.map +1 -0
  222. package/realtime/index.js +26 -0
  223. package/realtime/odata-delta.d.ts +114 -0
  224. package/realtime/odata-delta.d.ts.map +1 -0
  225. package/realtime/odata-delta.js +483 -0
  226. package/realtime/projections.d.ts +30 -0
  227. package/realtime/projections.d.ts.map +1 -0
  228. package/realtime/projections.js +51 -0
  229. package/realtime/reconciliation.d.ts +245 -0
  230. package/realtime/reconciliation.d.ts.map +1 -0
  231. package/realtime/reconciliation.js +377 -0
  232. package/realtime/reducer.d.ts +14 -0
  233. package/realtime/reducer.d.ts.map +1 -0
  234. package/realtime/reducer.js +351 -0
  235. package/realtime/resumable-transport.d.ts +111 -0
  236. package/realtime/resumable-transport.d.ts.map +1 -0
  237. package/realtime/resumable-transport.js +406 -0
  238. package/realtime/resumable.d.ts +104 -0
  239. package/realtime/resumable.d.ts.map +1 -0
  240. package/realtime/resumable.js +632 -0
  241. package/realtime/sse.d.ts +23 -0
  242. package/realtime/sse.d.ts.map +1 -0
  243. package/realtime/sse.js +153 -0
  244. package/realtime/store.d.ts +18 -0
  245. package/realtime/store.d.ts.map +1 -0
  246. package/realtime/store.js +63 -0
  247. package/realtime/types.d.ts +190 -0
  248. package/realtime/types.d.ts.map +1 -0
  249. package/realtime/types.js +10 -0
  250. package/realtime/websocket.d.ts +60 -0
  251. package/realtime/websocket.d.ts.map +1 -0
  252. package/realtime/websocket.js +157 -0
  253. package/realtime/wire.d.ts +28 -0
  254. package/realtime/wire.d.ts.map +1 -0
  255. package/realtime/wire.js +73 -0
  256. package/runtime/index.d.ts +6 -2
  257. package/runtime/index.d.ts.map +1 -1
  258. package/runtime/index.js +3 -1
  259. package/runtime/load-package.d.ts +24 -0
  260. package/runtime/load-package.d.ts.map +1 -1
  261. package/runtime/load-package.js +45 -1
  262. package/runtime/maplibre-renderer.d.ts +11 -0
  263. package/runtime/maplibre-renderer.d.ts.map +1 -1
  264. package/runtime/maplibre-renderer.js +32 -7
  265. package/runtime/offline-region-protocol.d.ts +73 -0
  266. package/runtime/offline-region-protocol.d.ts.map +1 -0
  267. package/runtime/offline-region-protocol.js +63 -0
  268. package/runtime/offline-region-style.d.ts +78 -0
  269. package/runtime/offline-region-style.d.ts.map +1 -0
  270. package/runtime/offline-region-style.js +147 -0
  271. package/runtime/pmtiles-protocol.d.ts +7 -8
  272. package/runtime/pmtiles-protocol.d.ts.map +1 -1
  273. package/runtime/pmtiles-protocol.js +19 -66
  274. package/runtime/protocol-registry.d.ts +55 -0
  275. package/runtime/protocol-registry.d.ts.map +1 -0
  276. package/runtime/protocol-registry.js +103 -0
  277. package/runtime/runtime.d.ts +2 -0
  278. package/runtime/runtime.d.ts.map +1 -1
  279. package/runtime/runtime.js +6 -1
  280. package/runtime/style-compose.d.ts.map +1 -1
  281. package/runtime/style-compose.js +78 -9
  282. package/studio/index.d.ts +24 -7
  283. package/studio/index.d.ts.map +1 -1
  284. package/studio/index.js +21 -7
  285. package/studio/lifecycle-client.d.ts +164 -0
  286. package/studio/lifecycle-client.d.ts.map +1 -0
  287. package/studio/lifecycle-client.js +299 -0
  288. package/studio/lifecycle-errors.d.ts +91 -0
  289. package/studio/lifecycle-errors.d.ts.map +1 -0
  290. package/studio/lifecycle-errors.js +77 -0
  291. package/studio/lifecycle-types.d.ts +321 -0
  292. package/studio/lifecycle-types.d.ts.map +1 -0
  293. package/studio/lifecycle-types.js +22 -0
  294. package/webmap/convert-label.js +0 -1
  295. package/webmap/convert-symbol.d.ts.map +1 -1
@@ -0,0 +1,483 @@
1
+ /**
2
+ * OData v4 delta-link pull adapter for `@honua/sdk-js/realtime` (issue #558).
3
+ *
4
+ * OData delta links (`@odata.deltaLink`) are a *pull* change feed, not a
5
+ * socket transport: a client requests a scoped delta-link URL and the
6
+ * server returns only what changed since that link was minted, plus a fresh
7
+ * delta link for the next request. This module maps that request/response
8
+ * paging protocol onto the transport-neutral `RealtimeFeatureTransport`
9
+ * surface (`types.ts`, #556) so it composes with `createRealtimeFeatureStore`
10
+ * / `createResumableRealtimeSubscription` exactly like the SSE and WebSocket
11
+ * adapters (`sse.ts`, `websocket.ts`, #557) — with one deliberate
12
+ * difference dictated by REQ-004: this transport never claims to be live.
13
+ * `capabilities.kind` is `"polling"`, it never emits a `"heartbeat"`, and
14
+ * `onPoll` reports an explicit cadence/freshness snapshot on every request
15
+ * cycle so a consumer can render "checked N seconds ago, next check in M"
16
+ * rather than a live badge that misrepresents a poll as a stream.
17
+ *
18
+ * Ownership split from the SSE/WebSocket adapters: those open exactly one
19
+ * connection per `subscribe()` and never reconnect on their own, deferring
20
+ * reconnect entirely to `resumable-transport.ts`. This transport has no
21
+ * connection to reconnect — every cycle is an independent request/response —
22
+ * so it owns its own bounded poll loop, including the on-expiry resnapshot
23
+ * recovery a socket transport cannot perform for itself: an expired or
24
+ * rejected delta link (REQ per the User Workflow, "Expired/invalid delta
25
+ * state requires an explicit resnapshot") is recovered by re-running a full
26
+ * snapshot cycle and emitting a fresh `type: "snapshot", replace: true`
27
+ * event, bounded by `maxConsecutiveResnapshots` so a server that keeps
28
+ * rejecting the token cannot loop forever.
29
+ *
30
+ * Security posture (REQ-002): every `@odata.nextLink` / `@odata.deltaLink`
31
+ * this transport follows, and any caller-supplied `resumeFrom.deltaToken`,
32
+ * must resolve to the exact same origin and collection path as the
33
+ * configured `url`. A link that does not is rejected rather than followed —
34
+ * a delta token is an opaque, potentially attacker-influenced value once it
35
+ * has round-tripped through a checkpoint store, and this transport never
36
+ * trusts it to redirect a request elsewhere.
37
+ *
38
+ * @module
39
+ */
40
+ import { HonuaRealtimeResumeError } from "./resumable.js";
41
+ import { realtimeFailure } from "./wire.js";
42
+ const DEFAULT_MAX_PAGES_PER_CYCLE = 500;
43
+ const DEFAULT_MAX_SNAPSHOT_ROWS = 50_000;
44
+ const DEFAULT_MAX_CONSECUTIVE_RESNAPSHOTS = 3;
45
+ /**
46
+ * Build a {@link RealtimeFeatureTransport} that polls an OData v4 entity
47
+ * set's delta link. See the module doc for the full recovery/security model.
48
+ */
49
+ export function createOdataDeltaTransport(options) {
50
+ if (typeof options.entityId !== "function") {
51
+ throw new TypeError("createOdataDeltaTransport requires options.entityId.");
52
+ }
53
+ const pollIntervalMs = positiveInteger(options.pollIntervalMs, "pollIntervalMs");
54
+ const maxPagesPerCycle = positiveInteger(options.maxPagesPerCycle ?? DEFAULT_MAX_PAGES_PER_CYCLE, "maxPagesPerCycle");
55
+ const maxSnapshotRows = positiveInteger(options.maxSnapshotRows ?? DEFAULT_MAX_SNAPSHOT_ROWS, "maxSnapshotRows");
56
+ const maxConsecutiveResnapshots = positiveInteger(options.maxConsecutiveResnapshots ?? DEFAULT_MAX_CONSECUTIVE_RESNAPSHOTS, "maxConsecutiveResnapshots");
57
+ const collectionUrl = parseAbsoluteUrl(options.url, `createOdataDeltaTransport requires an absolute options.url ("${options.url}").`);
58
+ return {
59
+ capabilities: {
60
+ kind: "polling",
61
+ resumeModes: ["delta-token"],
62
+ emitsHeartbeats: false,
63
+ emitsWatermarks: false,
64
+ },
65
+ subscribe(request, observer) {
66
+ if (request.signal?.aborted) {
67
+ observer.complete();
68
+ return { close: () => { } };
69
+ }
70
+ const resumeDeltaToken = request.deltaToken ?? request.resumeFrom?.deltaToken;
71
+ let initialDeltaLink;
72
+ if (resumeDeltaToken !== undefined) {
73
+ const resumeUrl = parseRelativeOdataUrl(resumeDeltaToken, collectionUrl, `Realtime resumeFrom.deltaToken "${resumeDeltaToken}" is not a valid URL.`);
74
+ assertSameOdataCollection(collectionUrl, resumeUrl);
75
+ initialDeltaLink = resumeUrl.toString();
76
+ }
77
+ const now = () => options.now?.() ?? Date.now();
78
+ const controller = new AbortController();
79
+ let closed = false;
80
+ let sequence = request.resumeFrom?.sequence ?? 0;
81
+ let resnapshotStreak = 0;
82
+ let timer;
83
+ const abortListener = () => teardown("Subscription signal was aborted.", true);
84
+ request.signal?.addEventListener("abort", abortListener, { once: true });
85
+ function teardown(reason, complete) {
86
+ if (closed)
87
+ return;
88
+ closed = true;
89
+ if (timer !== undefined)
90
+ clearTimeout(timer);
91
+ request.signal?.removeEventListener("abort", abortListener);
92
+ if (!controller.signal.aborted)
93
+ controller.abort(reason);
94
+ if (complete)
95
+ observer.complete();
96
+ }
97
+ function fail(error) {
98
+ if (closed)
99
+ return;
100
+ closed = true;
101
+ if (timer !== undefined)
102
+ clearTimeout(timer);
103
+ request.signal?.removeEventListener("abort", abortListener);
104
+ if (!controller.signal.aborted)
105
+ controller.abort("OData delta transport failed.");
106
+ observer.error(error);
107
+ }
108
+ function emit(event) {
109
+ if (closed)
110
+ return false;
111
+ try {
112
+ observer.next(event);
113
+ return true;
114
+ }
115
+ catch (cause) {
116
+ fail(realtimeFailure("consumer-failed", "Realtime event observer rejected delivery.", cause));
117
+ return false;
118
+ }
119
+ }
120
+ function reportPoll(partial) {
121
+ if (!options.onPoll)
122
+ return;
123
+ try {
124
+ options.onPoll({
125
+ polledAt: now(),
126
+ intervalMs: pollIntervalMs,
127
+ sequence,
128
+ changed: partial.changed,
129
+ upsertCount: partial.upsertCount,
130
+ deleteCount: partial.deleteCount,
131
+ ...(partial.scheduled ? { nextPollAt: now() + pollIntervalMs } : {}),
132
+ });
133
+ }
134
+ catch {
135
+ // Telemetry is diagnostic only; a throwing onPoll callback must
136
+ // never affect delivery.
137
+ }
138
+ }
139
+ function schedulePoll(link) {
140
+ if (closed)
141
+ return;
142
+ timer = setTimeout(() => {
143
+ timer = undefined;
144
+ void runDeltaCycle(link);
145
+ }, pollIntervalMs);
146
+ }
147
+ async function fetchOdataDocument(target, trackChanges) {
148
+ const fetchImpl = resolveFetch(options.fetchImpl);
149
+ const headers = { Accept: "application/json", ...(options.headers?.() ?? {}) };
150
+ if (trackChanges)
151
+ headers.Prefer = "odata.track-changes";
152
+ let response;
153
+ try {
154
+ response = await fetchImpl(target, { method: "GET", headers, signal: controller.signal });
155
+ }
156
+ catch (cause) {
157
+ if (controller.signal.aborted)
158
+ throw cause;
159
+ throw realtimeFailure("transport-gap", "OData delta request failed.", cause);
160
+ }
161
+ if (!response.ok) {
162
+ const body = await safeReadJson(response);
163
+ const isExpired = (options.isDeltaLinkExpiredResponse ?? defaultIsDeltaLinkExpiredResponse)(response.status, body);
164
+ if (isExpired) {
165
+ throw new HonuaRealtimeResumeError("cursor-expired", `OData delta link expired or was rejected by the server (HTTP ${String(response.status)}).`);
166
+ }
167
+ throw realtimeFailure(response.status >= 500 ? "transport-gap" : "invalid-event", `OData delta request failed with HTTP ${String(response.status)}${response.statusText ? ` ${response.statusText}` : ""}.`, body);
168
+ }
169
+ const body = await safeReadJson(response);
170
+ if (!isRecord(body) || !Array.isArray(body.value)) {
171
+ throw realtimeFailure("invalid-event", "OData delta response is missing a JSON `value` array.", body);
172
+ }
173
+ return body;
174
+ }
175
+ function parseEntry(raw) {
176
+ if (!isRecord(raw)) {
177
+ throw realtimeFailure("invalid-event", "OData delta entry must be a JSON object.", raw);
178
+ }
179
+ const removed = raw["@removed"] ?? raw["@odata.removed"];
180
+ if (removed !== undefined) {
181
+ return { kind: "delete", patch: { id: removedEntryId(raw) } };
182
+ }
183
+ const isRelationshipDelta = options.isRelationshipDeltaEntry ?? defaultIsRelationshipDeltaEntry;
184
+ if (isRelationshipDelta(raw)) {
185
+ throw realtimeFailure("invalid-event", "OData relationship (link) deltas are not supported by createOdataDeltaTransport; only entity upserts and removals are normalized (REQ-003).", raw);
186
+ }
187
+ try {
188
+ const id = options.entityId(raw);
189
+ const feature = (options.toFeature ?? defaultToFeature)(raw);
190
+ return { kind: "upsert", patch: { id, feature } };
191
+ }
192
+ catch (cause) {
193
+ throw realtimeFailure("invalid-event", "OData delta entry projection (entityId/toFeature) failed.", cause);
194
+ }
195
+ }
196
+ function removedEntryId(entry) {
197
+ const withoutAnnotations = Object.fromEntries(Object.entries(entry).filter(([key]) => !key.startsWith("@")));
198
+ if (Object.keys(withoutAnnotations).length > 0) {
199
+ try {
200
+ return options.entityId(withoutAnnotations);
201
+ }
202
+ catch {
203
+ // Fall through to @id parsing below.
204
+ }
205
+ }
206
+ const idValue = entry["@id"] ?? entry["@odata.id"];
207
+ if (typeof idValue !== "string") {
208
+ throw realtimeFailure("invalid-event", "OData removed delta entry has neither key properties nor an @id/@odata.id to derive an id from.", entry);
209
+ }
210
+ const key = parseODataIdKey(idValue);
211
+ if (key === undefined) {
212
+ throw realtimeFailure("invalid-event", `OData removed delta entry's id "${idValue}" uses a composite or unparseable key; the removed entry must retain key properties instead.`, entry);
213
+ }
214
+ return key;
215
+ }
216
+ async function runCycle(startUrl, mode) {
217
+ let target = startUrl;
218
+ let pages = 0;
219
+ const upserts = [];
220
+ const deletes = [];
221
+ let deltaLink;
222
+ while (true) {
223
+ pages += 1;
224
+ if (pages > maxPagesPerCycle) {
225
+ throw realtimeFailure("delivery-failed", `OData delta paging exceeded the configured ${String(maxPagesPerCycle)}-page bound.`, undefined);
226
+ }
227
+ const envelope = await fetchOdataDocument(target, mode === "snapshot" && pages === 1);
228
+ for (const raw of envelope.value) {
229
+ const parsed = parseEntry(raw);
230
+ if (parsed.kind === "delete")
231
+ deletes.push(parsed.patch);
232
+ else
233
+ upserts.push(parsed.patch);
234
+ if (mode === "snapshot" && upserts.length > maxSnapshotRows) {
235
+ throw realtimeFailure("delivery-failed", `OData snapshot exceeded the configured ${String(maxSnapshotRows)}-row bound.`, undefined);
236
+ }
237
+ }
238
+ const pageDeltaLink = envelope["@odata.deltaLink"];
239
+ if (pageDeltaLink !== undefined) {
240
+ const resolved = parseRelativeOdataUrl(pageDeltaLink, target, `OData response @odata.deltaLink "${pageDeltaLink}" is not a valid URL.`);
241
+ assertSameOdataCollection(collectionUrl, resolved);
242
+ deltaLink = resolved.toString();
243
+ }
244
+ const nextLink = envelope["@odata.nextLink"];
245
+ if (nextLink === undefined)
246
+ break;
247
+ const resolvedNext = parseRelativeOdataUrl(nextLink, target, `OData response @odata.nextLink "${nextLink}" is not a valid URL.`);
248
+ assertSameOdataCollection(collectionUrl, resolvedNext);
249
+ target = resolvedNext.toString();
250
+ }
251
+ if (deltaLink === undefined) {
252
+ throw realtimeFailure("invalid-event", "OData response did not include a terminal @odata.deltaLink; the server may not support delta-link change tracking for this collection.", undefined);
253
+ }
254
+ return { upserts, deletes, deltaLink };
255
+ }
256
+ function buildInitialUrl() {
257
+ const url = new URL(collectionUrl.toString());
258
+ const query = options.initialQuery;
259
+ if (query?.filter)
260
+ url.searchParams.set("$filter", query.filter);
261
+ if (query?.select?.length)
262
+ url.searchParams.set("$select", query.select.join(","));
263
+ if (query?.orderBy?.length)
264
+ url.searchParams.set("$orderby", query.orderBy.join(","));
265
+ if (typeof query?.top === "number" && Number.isFinite(query.top)) {
266
+ url.searchParams.set("$top", String(query.top));
267
+ }
268
+ return url.toString();
269
+ }
270
+ /**
271
+ * Shared recovery path for a `cursor-expired` failure from *either*
272
+ * cycle: a real server should only ever reject a `$deltatoken`, but a
273
+ * caller-supplied `isDeltaLinkExpiredResponse` could in principle
274
+ * classify any response that way, so both cycles route through the
275
+ * same bounded streak counter (REQ-005) rather than the snapshot
276
+ * cycle failing closed on its very first retry.
277
+ */
278
+ async function recoverFromExpiredDeltaLink(cause) {
279
+ resnapshotStreak += 1;
280
+ if (resnapshotStreak > maxConsecutiveResnapshots) {
281
+ fail(realtimeFailure("delivery-failed", `OData delta link expired ${String(resnapshotStreak)} times consecutively; giving up after the configured ${String(maxConsecutiveResnapshots)}-resnapshot bound.`, cause));
282
+ return;
283
+ }
284
+ reportPoll({ changed: false, upsertCount: 0, deleteCount: 0, scheduled: false });
285
+ const delivered = emit({
286
+ type: "status",
287
+ status: "reconnecting",
288
+ reason: "cursor-expired",
289
+ receivedAt: now(),
290
+ });
291
+ if (!delivered)
292
+ return;
293
+ await runSnapshotCycle();
294
+ }
295
+ async function runSnapshotCycle() {
296
+ try {
297
+ const cycle = await runCycle(buildInitialUrl(), "snapshot");
298
+ if (closed)
299
+ return;
300
+ resnapshotStreak = 0;
301
+ sequence += 1;
302
+ const delivered = emit({
303
+ type: "snapshot",
304
+ sequence,
305
+ deltaToken: cycle.deltaLink,
306
+ receivedAt: now(),
307
+ replace: true,
308
+ features: cycle.upserts,
309
+ });
310
+ if (!delivered)
311
+ return;
312
+ reportPoll({
313
+ changed: true,
314
+ upsertCount: cycle.upserts.length,
315
+ deleteCount: 0,
316
+ scheduled: true,
317
+ });
318
+ schedulePoll(cycle.deltaLink);
319
+ }
320
+ catch (cause) {
321
+ if (closed)
322
+ return;
323
+ if (cause instanceof HonuaRealtimeResumeError && cause.code === "cursor-expired") {
324
+ await recoverFromExpiredDeltaLink(cause);
325
+ return;
326
+ }
327
+ fail(cause);
328
+ }
329
+ }
330
+ async function runDeltaCycle(link) {
331
+ try {
332
+ const cycle = await runCycle(link, "delta");
333
+ if (closed)
334
+ return;
335
+ resnapshotStreak = 0;
336
+ const changed = cycle.upserts.length > 0 || cycle.deletes.length > 0;
337
+ if (changed)
338
+ sequence += 1;
339
+ const delivered = emit(changed
340
+ ? {
341
+ type: "delta",
342
+ sequence,
343
+ deltaToken: cycle.deltaLink,
344
+ receivedAt: now(),
345
+ ...(cycle.upserts.length ? { upserts: cycle.upserts } : {}),
346
+ ...(cycle.deletes.length ? { deletes: cycle.deletes } : {}),
347
+ }
348
+ : {
349
+ type: "status",
350
+ status: "live",
351
+ sequence,
352
+ deltaToken: cycle.deltaLink,
353
+ receivedAt: now(),
354
+ reason: "poll-unchanged",
355
+ });
356
+ if (!delivered)
357
+ return;
358
+ reportPoll({
359
+ changed,
360
+ upsertCount: cycle.upserts.length,
361
+ deleteCount: cycle.deletes.length,
362
+ scheduled: true,
363
+ });
364
+ schedulePoll(cycle.deltaLink);
365
+ }
366
+ catch (cause) {
367
+ if (closed)
368
+ return;
369
+ if (cause instanceof HonuaRealtimeResumeError && cause.code === "cursor-expired") {
370
+ await recoverFromExpiredDeltaLink(cause);
371
+ return;
372
+ }
373
+ fail(cause);
374
+ }
375
+ }
376
+ void (initialDeltaLink !== undefined ? runDeltaCycle(initialDeltaLink) : runSnapshotCycle());
377
+ return {
378
+ close() {
379
+ teardown("Subscription closed by caller.", true);
380
+ },
381
+ };
382
+ },
383
+ };
384
+ }
385
+ function defaultIsDeltaLinkExpiredResponse(status) {
386
+ return status === 410;
387
+ }
388
+ function defaultIsRelationshipDeltaEntry(entry) {
389
+ return typeof entry.source === "string" && typeof entry.relationship === "string" && typeof entry.target === "string";
390
+ }
391
+ function defaultToFeature(entity) {
392
+ const clean = {};
393
+ for (const [key, value] of Object.entries(entity)) {
394
+ if (!key.startsWith("@"))
395
+ clean[key] = value;
396
+ }
397
+ return clean;
398
+ }
399
+ /**
400
+ * Reject a next/delta link that does not resolve to the exact origin and
401
+ * collection path this transport was configured for (REQ-002). Trailing
402
+ * slashes are normalized so `.../Incidents` and `.../Incidents/` are treated
403
+ * as the same collection.
404
+ */
405
+ function assertSameOdataCollection(expected, candidate) {
406
+ if (candidate.origin !== expected.origin || normalizedPathname(candidate) !== normalizedPathname(expected)) {
407
+ throw new HonuaRealtimeResumeError("invalid-event", `OData delta/next link "${candidate.toString()}" does not resolve to the configured collection "${expected.toString()}"; refusing to follow a foreign link.`);
408
+ }
409
+ }
410
+ function normalizedPathname(url) {
411
+ return url.pathname.length > 1 && url.pathname.endsWith("/") ? url.pathname.slice(0, -1) : url.pathname;
412
+ }
413
+ /**
414
+ * Parse the single-value key predicate out of an OData `@id`/`@odata.id`
415
+ * value like `Incidents('AB-12')` or `Incidents(42)`. Returns `undefined`
416
+ * for composite keys (`Key1=1,Key2='a'`) or an unparseable shape — callers
417
+ * treat that as unsupported rather than guessing.
418
+ */
419
+ function parseODataIdKey(idValue) {
420
+ const open = idValue.lastIndexOf("(");
421
+ if (open === -1 || !idValue.endsWith(")"))
422
+ return undefined;
423
+ const inner = idValue.slice(open + 1, -1);
424
+ if (inner.includes("=") || hasTopLevelComma(inner))
425
+ return undefined;
426
+ const unquoted = inner.startsWith("'") && inner.endsWith("'") ? inner.slice(1, -1).replace(/''/g, "'") : inner;
427
+ if (unquoted.length === 0)
428
+ return undefined;
429
+ if (/^-?\d+$/.test(unquoted) && Number.isSafeInteger(Number(unquoted)))
430
+ return Number(unquoted);
431
+ return unquoted;
432
+ }
433
+ function hasTopLevelComma(inner) {
434
+ let quoted = false;
435
+ for (const character of inner) {
436
+ if (character === "'")
437
+ quoted = !quoted;
438
+ else if (character === "," && !quoted)
439
+ return true;
440
+ }
441
+ return false;
442
+ }
443
+ function parseAbsoluteUrl(input, message) {
444
+ try {
445
+ return new URL(input);
446
+ }
447
+ catch (cause) {
448
+ throw new TypeError(message, { cause });
449
+ }
450
+ }
451
+ function parseRelativeOdataUrl(value, base, message) {
452
+ try {
453
+ return new URL(value, base);
454
+ }
455
+ catch (cause) {
456
+ throw new HonuaRealtimeResumeError("invalid-event", message, { cause });
457
+ }
458
+ }
459
+ function resolveFetch(custom) {
460
+ if (custom)
461
+ return custom;
462
+ const globalFetch = globalThis.fetch;
463
+ if (!globalFetch)
464
+ throw new Error("fetch is not available; provide fetchImpl for this runtime.");
465
+ return globalFetch.bind(globalThis);
466
+ }
467
+ async function safeReadJson(response) {
468
+ try {
469
+ return await response.json();
470
+ }
471
+ catch {
472
+ return undefined;
473
+ }
474
+ }
475
+ function isRecord(value) {
476
+ return typeof value === "object" && value !== null;
477
+ }
478
+ function positiveInteger(value, name) {
479
+ if (!Number.isSafeInteger(value) || value < 1) {
480
+ throw new TypeError(`createOdataDeltaTransport: ${name} must be a safe integer greater than zero.`);
481
+ }
482
+ return value;
483
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Projection helpers for map, table, and detail views that consume realtime
3
+ * feature state.
4
+ *
5
+ * @module
6
+ */
7
+ import type { FeatureId, SourceId } from "../contract/types.js";
8
+ import type { RealtimeFeatureRecord, RealtimeFeatureState, RealtimeFeatureTombstone } from "./types.js";
9
+ export interface RealtimeFeatureRecordSelectorOptions<TFeature = unknown> {
10
+ readonly sourceId?: SourceId;
11
+ readonly sort?: (left: RealtimeFeatureRecord<TFeature>, right: RealtimeFeatureRecord<TFeature>) => number;
12
+ }
13
+ export interface RealtimeFeatureReference {
14
+ readonly id: FeatureId;
15
+ readonly sourceId?: SourceId;
16
+ }
17
+ export interface RealtimeDetailSelectorOptions {
18
+ readonly sourceId?: SourceId;
19
+ }
20
+ export interface RealtimeDetailState<TFeature = unknown> {
21
+ readonly status: "present" | "deleted" | "missing";
22
+ readonly record?: RealtimeFeatureRecord<TFeature>;
23
+ readonly tombstone?: RealtimeFeatureTombstone;
24
+ }
25
+ export declare function selectRealtimeFeatureRecords<TFeature>(state: RealtimeFeatureState<TFeature>, options?: RealtimeFeatureRecordSelectorOptions<TFeature>): RealtimeFeatureRecord<TFeature>[];
26
+ export declare function selectRealtimeFeatures<TFeature>(state: RealtimeFeatureState<TFeature>, options?: RealtimeFeatureRecordSelectorOptions<TFeature>): TFeature[];
27
+ export declare function selectRealtimeFeatureRecordMap<TFeature>(state: RealtimeFeatureState<TFeature>, options?: RealtimeFeatureRecordSelectorOptions<TFeature>): Readonly<Record<string, RealtimeFeatureRecord<TFeature>>>;
28
+ export declare function selectRealtimeFeatureTombstones(state: RealtimeFeatureState<unknown>, options?: RealtimeDetailSelectorOptions): RealtimeFeatureTombstone[];
29
+ export declare function selectRealtimeDetail<TFeature>(state: RealtimeFeatureState<TFeature>, target: FeatureId | RealtimeFeatureReference, options?: RealtimeDetailSelectorOptions): RealtimeDetailState<TFeature>;
30
+ //# sourceMappingURL=projections.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"projections.d.ts","sourceRoot":"","sources":["../../../src/realtime/projections.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAEhE,OAAO,KAAK,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAC;AAExG,MAAM,WAAW,oCAAoC,CAAC,QAAQ,GAAG,OAAO;IACtE,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC;IAC7B,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,qBAAqB,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE,qBAAqB,CAAC,QAAQ,CAAC,KAAK,MAAM,CAAC;CAC3G;AAED,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,EAAE,EAAE,SAAS,CAAC;IACvB,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC;CAC9B;AAED,MAAM,WAAW,6BAA6B;IAC5C,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC;CAC9B;AAED,MAAM,WAAW,mBAAmB,CAAC,QAAQ,GAAG,OAAO;IACrD,QAAQ,CAAC,MAAM,EAAE,SAAS,GAAG,SAAS,GAAG,SAAS,CAAC;IACnD,QAAQ,CAAC,MAAM,CAAC,EAAE,qBAAqB,CAAC,QAAQ,CAAC,CAAC;IAClD,QAAQ,CAAC,SAAS,CAAC,EAAE,wBAAwB,CAAC;CAC/C;AAED,wBAAgB,4BAA4B,CAAC,QAAQ,EACnD,KAAK,EAAE,oBAAoB,CAAC,QAAQ,CAAC,EACrC,OAAO,GAAE,oCAAoC,CAAC,QAAQ,CAAM,GAC3D,qBAAqB,CAAC,QAAQ,CAAC,EAAE,CAGnC;AAED,wBAAgB,sBAAsB,CAAC,QAAQ,EAC7C,KAAK,EAAE,oBAAoB,CAAC,QAAQ,CAAC,EACrC,OAAO,GAAE,oCAAoC,CAAC,QAAQ,CAAM,GAC3D,QAAQ,EAAE,CAEZ;AAED,wBAAgB,8BAA8B,CAAC,QAAQ,EACrD,KAAK,EAAE,oBAAoB,CAAC,QAAQ,CAAC,EACrC,OAAO,GAAE,oCAAoC,CAAC,QAAQ,CAAM,GAC3D,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,qBAAqB,CAAC,QAAQ,CAAC,CAAC,CAAC,CAM3D;AAED,wBAAgB,+BAA+B,CAC7C,KAAK,EAAE,oBAAoB,CAAC,OAAO,CAAC,EACpC,OAAO,GAAE,6BAAkC,GAC1C,wBAAwB,EAAE,CAE5B;AAED,wBAAgB,oBAAoB,CAAC,QAAQ,EAC3C,KAAK,EAAE,oBAAoB,CAAC,QAAQ,CAAC,EACrC,MAAM,EAAE,SAAS,GAAG,wBAAwB,EAC5C,OAAO,GAAE,6BAAkC,GAC1C,mBAAmB,CAAC,QAAQ,CAAC,CAiB/B"}
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Projection helpers for map, table, and detail views that consume realtime
3
+ * feature state.
4
+ *
5
+ * @module
6
+ */
7
+ import { realtimeFeatureKey } from "./reducer.js";
8
+ export function selectRealtimeFeatureRecords(state, options = {}) {
9
+ const records = Object.values(state.records).filter((record) => matchesSource(record, options.sourceId));
10
+ return options.sort ? [...records].sort(options.sort) : records;
11
+ }
12
+ export function selectRealtimeFeatures(state, options = {}) {
13
+ return selectRealtimeFeatureRecords(state, options).map((record) => record.feature);
14
+ }
15
+ export function selectRealtimeFeatureRecordMap(state, options = {}) {
16
+ const records = {};
17
+ for (const record of selectRealtimeFeatureRecords(state, options)) {
18
+ records[record.key] = record;
19
+ }
20
+ return records;
21
+ }
22
+ export function selectRealtimeFeatureTombstones(state, options = {}) {
23
+ return Object.values(state.tombstones).filter((tombstone) => matchesSource(tombstone, options.sourceId));
24
+ }
25
+ export function selectRealtimeDetail(state, target, options = {}) {
26
+ const key = realtimeReferenceKey(target, options.sourceId);
27
+ const record = state.records[key];
28
+ if (record) {
29
+ return {
30
+ status: "present",
31
+ record,
32
+ };
33
+ }
34
+ const tombstone = state.tombstones[key];
35
+ if (tombstone) {
36
+ return {
37
+ status: "deleted",
38
+ tombstone,
39
+ };
40
+ }
41
+ return { status: "missing" };
42
+ }
43
+ function matchesSource(record, sourceId) {
44
+ return sourceId === undefined || record.sourceId === sourceId;
45
+ }
46
+ function realtimeReferenceKey(target, sourceId) {
47
+ if (typeof target === "object" && target !== null) {
48
+ return realtimeFeatureKey(target.sourceId ?? sourceId, target.id);
49
+ }
50
+ return realtimeFeatureKey(sourceId, target);
51
+ }