@freighttech/terminal-tracking 0.7.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 (236) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/dist/generated/entities/terminal_config/index.js +53 -0
  4. package/dist/generated/entities/terminal_config/index.js.map +7 -0
  5. package/dist/generated/entities/terminal_event/index.js +59 -0
  6. package/dist/generated/entities/terminal_event/index.js.map +7 -0
  7. package/dist/generated/entities/terminal_tracking_job/index.js +37 -0
  8. package/dist/generated/entities/terminal_tracking_job/index.js.map +7 -0
  9. package/dist/generated/entities/terminal_vessel_visit/index.js +41 -0
  10. package/dist/generated/entities/terminal_vessel_visit/index.js.map +7 -0
  11. package/dist/generated/entities.ids.generated.js +26 -0
  12. package/dist/generated/entities.ids.generated.js.map +7 -0
  13. package/dist/index.js +20 -0
  14. package/dist/index.js.map +7 -0
  15. package/dist/modules/terminal_tracking/acl.js +14 -0
  16. package/dist/modules/terminal_tracking/acl.js.map +7 -0
  17. package/dist/modules/terminal_tracking/api/openapi.js +27 -0
  18. package/dist/modules/terminal_tracking/api/openapi.js.map +7 -0
  19. package/dist/modules/terminal_tracking/api/terminal-configs/route.js +140 -0
  20. package/dist/modules/terminal_tracking/api/terminal-configs/route.js.map +7 -0
  21. package/dist/modules/terminal_tracking/api/terminal-configs/test/route.js +58 -0
  22. package/dist/modules/terminal_tracking/api/terminal-configs/test/route.js.map +7 -0
  23. package/dist/modules/terminal_tracking/api/tracking-jobs/details/route.js +109 -0
  24. package/dist/modules/terminal_tracking/api/tracking-jobs/details/route.js.map +7 -0
  25. package/dist/modules/terminal_tracking/api/tracking-jobs/poll/route.js +58 -0
  26. package/dist/modules/terminal_tracking/api/tracking-jobs/poll/route.js.map +7 -0
  27. package/dist/modules/terminal_tracking/api/tracking-jobs/route.js +189 -0
  28. package/dist/modules/terminal_tracking/api/tracking-jobs/route.js.map +7 -0
  29. package/dist/modules/terminal_tracking/api/utils.js +12 -0
  30. package/dist/modules/terminal_tracking/api/utils.js.map +7 -0
  31. package/dist/modules/terminal_tracking/api/vessel-visits/route.js +83 -0
  32. package/dist/modules/terminal_tracking/api/vessel-visits/route.js.map +7 -0
  33. package/dist/modules/terminal_tracking/backend/terminal-configs/page.js +669 -0
  34. package/dist/modules/terminal_tracking/backend/terminal-configs/page.js.map +7 -0
  35. package/dist/modules/terminal_tracking/backend/terminal-configs/page.meta.js +19 -0
  36. package/dist/modules/terminal_tracking/backend/terminal-configs/page.meta.js.map +7 -0
  37. package/dist/modules/terminal_tracking/backend/terminal-containers/page.js +275 -0
  38. package/dist/modules/terminal_tracking/backend/terminal-containers/page.js.map +7 -0
  39. package/dist/modules/terminal_tracking/backend/terminal-containers/page.meta.js +19 -0
  40. package/dist/modules/terminal_tracking/backend/terminal-containers/page.meta.js.map +7 -0
  41. package/dist/modules/terminal_tracking/ce.js +35 -0
  42. package/dist/modules/terminal_tracking/ce.js.map +7 -0
  43. package/dist/modules/terminal_tracking/commands/index.js +4 -0
  44. package/dist/modules/terminal_tracking/commands/index.js.map +7 -0
  45. package/dist/modules/terminal_tracking/commands/terminal-configs.js +107 -0
  46. package/dist/modules/terminal_tracking/commands/terminal-configs.js.map +7 -0
  47. package/dist/modules/terminal_tracking/commands/terminal-tracking.js +27 -0
  48. package/dist/modules/terminal_tracking/commands/terminal-tracking.js.map +7 -0
  49. package/dist/modules/terminal_tracking/commands/tracking-jobs.js +56 -0
  50. package/dist/modules/terminal_tracking/commands/tracking-jobs.js.map +7 -0
  51. package/dist/modules/terminal_tracking/components/ContainerDetailsDrawer.js +371 -0
  52. package/dist/modules/terminal_tracking/components/ContainerDetailsDrawer.js.map +7 -0
  53. package/dist/modules/terminal_tracking/data/entities.js +337 -0
  54. package/dist/modules/terminal_tracking/data/entities.js.map +7 -0
  55. package/dist/modules/terminal_tracking/data/validators.js +98 -0
  56. package/dist/modules/terminal_tracking/data/validators.js.map +7 -0
  57. package/dist/modules/terminal_tracking/di.js +39 -0
  58. package/dist/modules/terminal_tracking/di.js.map +7 -0
  59. package/dist/modules/terminal_tracking/encryption.js +14 -0
  60. package/dist/modules/terminal_tracking/encryption.js.map +7 -0
  61. package/dist/modules/terminal_tracking/events.js +45 -0
  62. package/dist/modules/terminal_tracking/events.js.map +7 -0
  63. package/dist/modules/terminal_tracking/i18n/de.json +175 -0
  64. package/dist/modules/terminal_tracking/i18n/en.json +178 -0
  65. package/dist/modules/terminal_tracking/i18n/es.json +175 -0
  66. package/dist/modules/terminal_tracking/i18n/pl.json +206 -0
  67. package/dist/modules/terminal_tracking/index.js +15 -0
  68. package/dist/modules/terminal_tracking/index.js.map +7 -0
  69. package/dist/modules/terminal_tracking/lib/adapters/bct/adapter.js +164 -0
  70. package/dist/modules/terminal_tracking/lib/adapters/bct/adapter.js.map +7 -0
  71. package/dist/modules/terminal_tracking/lib/adapters/bct/auth/basic.js +23 -0
  72. package/dist/modules/terminal_tracking/lib/adapters/bct/auth/basic.js.map +7 -0
  73. package/dist/modules/terminal_tracking/lib/adapters/bct/incos-semantics.js +146 -0
  74. package/dist/modules/terminal_tracking/lib/adapters/bct/incos-semantics.js.map +7 -0
  75. package/dist/modules/terminal_tracking/lib/adapters/bct/types.js +1 -0
  76. package/dist/modules/terminal_tracking/lib/adapters/bct/types.js.map +7 -0
  77. package/dist/modules/terminal_tracking/lib/adapters/gct/adapter.js +106 -0
  78. package/dist/modules/terminal_tracking/lib/adapters/gct/adapter.js.map +7 -0
  79. package/dist/modules/terminal_tracking/lib/adapters/gct/auth/token.js +120 -0
  80. package/dist/modules/terminal_tracking/lib/adapters/gct/auth/token.js.map +7 -0
  81. package/dist/modules/terminal_tracking/lib/adapters/gct/gct-semantics.js +77 -0
  82. package/dist/modules/terminal_tracking/lib/adapters/gct/gct-semantics.js.map +7 -0
  83. package/dist/modules/terminal_tracking/lib/adapters/gct/types.js +1 -0
  84. package/dist/modules/terminal_tracking/lib/adapters/gct/types.js.map +7 -0
  85. package/dist/modules/terminal_tracking/lib/adapters/index.js +12 -0
  86. package/dist/modules/terminal_tracking/lib/adapters/index.js.map +7 -0
  87. package/dist/modules/terminal_tracking/lib/adapters/n4/adapter.js +121 -0
  88. package/dist/modules/terminal_tracking/lib/adapters/n4/adapter.js.map +7 -0
  89. package/dist/modules/terminal_tracking/lib/adapters/n4/auth/ropc.js +81 -0
  90. package/dist/modules/terminal_tracking/lib/adapters/n4/auth/ropc.js.map +7 -0
  91. package/dist/modules/terminal_tracking/lib/adapters/n4/http.js +66 -0
  92. package/dist/modules/terminal_tracking/lib/adapters/n4/http.js.map +7 -0
  93. package/dist/modules/terminal_tracking/lib/adapters/n4/n4-semantics.js +95 -0
  94. package/dist/modules/terminal_tracking/lib/adapters/n4/n4-semantics.js.map +7 -0
  95. package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.js +70 -0
  96. package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.js.map +7 -0
  97. package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.js +44 -0
  98. package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.js.map +7 -0
  99. package/dist/modules/terminal_tracking/lib/adapters/n4/proxy.js +15 -0
  100. package/dist/modules/terminal_tracking/lib/adapters/n4/proxy.js.map +7 -0
  101. package/dist/modules/terminal_tracking/lib/adapters/n4/types.js +1 -0
  102. package/dist/modules/terminal_tracking/lib/adapters/n4/types.js.map +7 -0
  103. package/dist/modules/terminal_tracking/lib/adapters/zoned-time.js +27 -0
  104. package/dist/modules/terminal_tracking/lib/adapters/zoned-time.js.map +7 -0
  105. package/dist/modules/terminal_tracking/lib/availability.js +40 -0
  106. package/dist/modules/terminal_tracking/lib/availability.js.map +7 -0
  107. package/dist/modules/terminal_tracking/lib/ensure-schedule.js +51 -0
  108. package/dist/modules/terminal_tracking/lib/ensure-schedule.js.map +7 -0
  109. package/dist/modules/terminal_tracking/lib/holds.js +240 -0
  110. package/dist/modules/terminal_tracking/lib/holds.js.map +7 -0
  111. package/dist/modules/terminal_tracking/lib/logger.js +19 -0
  112. package/dist/modules/terminal_tracking/lib/logger.js.map +7 -0
  113. package/dist/modules/terminal_tracking/lib/rate-limiter.js +30 -0
  114. package/dist/modules/terminal_tracking/lib/rate-limiter.js.map +7 -0
  115. package/dist/modules/terminal_tracking/lib/stops.js +48 -0
  116. package/dist/modules/terminal_tracking/lib/stops.js.map +7 -0
  117. package/dist/modules/terminal_tracking/lib/terminal-adapter.js +1 -0
  118. package/dist/modules/terminal_tracking/lib/terminal-adapter.js.map +7 -0
  119. package/dist/modules/terminal_tracking/lib/totp.js +41 -0
  120. package/dist/modules/terminal_tracking/lib/totp.js.map +7 -0
  121. package/dist/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.js +101 -0
  122. package/dist/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.js.map +7 -0
  123. package/dist/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.js +39 -0
  124. package/dist/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.js.map +7 -0
  125. package/dist/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.js +13 -0
  126. package/dist/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.js.map +7 -0
  127. package/dist/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.js +15 -0
  128. package/dist/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.js.map +7 -0
  129. package/dist/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.js +13 -0
  130. package/dist/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.js.map +7 -0
  131. package/dist/modules/terminal_tracking/services/terminalMatcherService.js +61 -0
  132. package/dist/modules/terminal_tracking/services/terminalMatcherService.js.map +7 -0
  133. package/dist/modules/terminal_tracking/services/terminalRegistry.js +24 -0
  134. package/dist/modules/terminal_tracking/services/terminalRegistry.js.map +7 -0
  135. package/dist/modules/terminal_tracking/services/terminalTrackingService.js +687 -0
  136. package/dist/modules/terminal_tracking/services/terminalTrackingService.js.map +7 -0
  137. package/dist/modules/terminal_tracking/setup.js +23 -0
  138. package/dist/modules/terminal_tracking/setup.js.map +7 -0
  139. package/dist/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.js +22 -0
  140. package/dist/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.js.map +7 -0
  141. package/generated/entities/terminal_config/index.ts +36 -0
  142. package/generated/entities/terminal_event/index.ts +39 -0
  143. package/generated/entities/terminal_tracking_job/index.ts +28 -0
  144. package/generated/entities/terminal_vessel_visit/index.ts +30 -0
  145. package/generated/entities.ids.generated.ts +52 -0
  146. package/package.json +80 -0
  147. package/src/index.ts +44 -0
  148. package/src/modules/terminal_tracking/__integration__/TC-TRACK-301-gct-config-test-connection.spec.ts +66 -0
  149. package/src/modules/terminal_tracking/__integration__/TC-TRACK-317-bct-config-test-connection.spec.ts +68 -0
  150. package/src/modules/terminal_tracking/__integration__/helpers.ts +148 -0
  151. package/src/modules/terminal_tracking/__integration__/meta.ts +4 -0
  152. package/src/modules/terminal_tracking/acl.ts +10 -0
  153. package/src/modules/terminal_tracking/api/openapi.ts +28 -0
  154. package/src/modules/terminal_tracking/api/terminal-configs/route.ts +140 -0
  155. package/src/modules/terminal_tracking/api/terminal-configs/test/route.ts +64 -0
  156. package/src/modules/terminal_tracking/api/tracking-jobs/details/route.ts +116 -0
  157. package/src/modules/terminal_tracking/api/tracking-jobs/poll/route.ts +64 -0
  158. package/src/modules/terminal_tracking/api/tracking-jobs/route.ts +243 -0
  159. package/src/modules/terminal_tracking/api/utils.ts +10 -0
  160. package/src/modules/terminal_tracking/api/vessel-visits/route.ts +95 -0
  161. package/src/modules/terminal_tracking/backend/terminal-configs/page.meta.ts +16 -0
  162. package/src/modules/terminal_tracking/backend/terminal-configs/page.tsx +728 -0
  163. package/src/modules/terminal_tracking/backend/terminal-containers/page.meta.ts +16 -0
  164. package/src/modules/terminal_tracking/backend/terminal-containers/page.tsx +328 -0
  165. package/src/modules/terminal_tracking/ce.ts +31 -0
  166. package/src/modules/terminal_tracking/commands/index.ts +3 -0
  167. package/src/modules/terminal_tracking/commands/terminal-configs.ts +132 -0
  168. package/src/modules/terminal_tracking/commands/terminal-tracking.ts +34 -0
  169. package/src/modules/terminal_tracking/commands/tracking-jobs.ts +69 -0
  170. package/src/modules/terminal_tracking/components/ContainerDetailsDrawer.tsx +540 -0
  171. package/src/modules/terminal_tracking/data/entities.ts +386 -0
  172. package/src/modules/terminal_tracking/data/validators.ts +113 -0
  173. package/src/modules/terminal_tracking/di.ts +46 -0
  174. package/src/modules/terminal_tracking/encryption.ts +12 -0
  175. package/src/modules/terminal_tracking/events.ts +48 -0
  176. package/src/modules/terminal_tracking/i18n/de.json +175 -0
  177. package/src/modules/terminal_tracking/i18n/en.json +178 -0
  178. package/src/modules/terminal_tracking/i18n/es.json +175 -0
  179. package/src/modules/terminal_tracking/i18n/pl.json +206 -0
  180. package/src/modules/terminal_tracking/index.ts +13 -0
  181. package/src/modules/terminal_tracking/lib/__tests__/availability.test.ts +136 -0
  182. package/src/modules/terminal_tracking/lib/__tests__/ensure-schedule.test.ts +67 -0
  183. package/src/modules/terminal_tracking/lib/__tests__/holds.test.ts +73 -0
  184. package/src/modules/terminal_tracking/lib/__tests__/stops.test.ts +80 -0
  185. package/src/modules/terminal_tracking/lib/__tests__/totp.test.ts +38 -0
  186. package/src/modules/terminal_tracking/lib/adapters/bct/__tests__/adapter.test.ts +196 -0
  187. package/src/modules/terminal_tracking/lib/adapters/bct/__tests__/incos-semantics.test.ts +303 -0
  188. package/src/modules/terminal_tracking/lib/adapters/bct/adapter.ts +219 -0
  189. package/src/modules/terminal_tracking/lib/adapters/bct/auth/basic.ts +34 -0
  190. package/src/modules/terminal_tracking/lib/adapters/bct/incos-semantics.ts +291 -0
  191. package/src/modules/terminal_tracking/lib/adapters/bct/types.ts +113 -0
  192. package/src/modules/terminal_tracking/lib/adapters/gct/__tests__/adapter.test.ts +238 -0
  193. package/src/modules/terminal_tracking/lib/adapters/gct/__tests__/gct-semantics.test.ts +133 -0
  194. package/src/modules/terminal_tracking/lib/adapters/gct/adapter.ts +152 -0
  195. package/src/modules/terminal_tracking/lib/adapters/gct/auth/token.ts +219 -0
  196. package/src/modules/terminal_tracking/lib/adapters/gct/gct-semantics.ts +136 -0
  197. package/src/modules/terminal_tracking/lib/adapters/gct/types.ts +58 -0
  198. package/src/modules/terminal_tracking/lib/adapters/index.ts +16 -0
  199. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/adapter-proxy.test.ts +69 -0
  200. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/data-table.test.ts +179 -0
  201. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/http.test.ts +98 -0
  202. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/n4-semantics.test.ts +140 -0
  203. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/proxy.test.ts +19 -0
  204. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/vessel-data-table.test.ts +67 -0
  205. package/src/modules/terminal_tracking/lib/adapters/n4/adapter.ts +163 -0
  206. package/src/modules/terminal_tracking/lib/adapters/n4/auth/ropc.ts +104 -0
  207. package/src/modules/terminal_tracking/lib/adapters/n4/http.ts +127 -0
  208. package/src/modules/terminal_tracking/lib/adapters/n4/n4-semantics.ts +156 -0
  209. package/src/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.ts +102 -0
  210. package/src/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.ts +60 -0
  211. package/src/modules/terminal_tracking/lib/adapters/n4/proxy.ts +24 -0
  212. package/src/modules/terminal_tracking/lib/adapters/n4/types.ts +56 -0
  213. package/src/modules/terminal_tracking/lib/adapters/zoned-time.ts +65 -0
  214. package/src/modules/terminal_tracking/lib/availability.ts +119 -0
  215. package/src/modules/terminal_tracking/lib/ensure-schedule.ts +83 -0
  216. package/src/modules/terminal_tracking/lib/holds.ts +344 -0
  217. package/src/modules/terminal_tracking/lib/logger.ts +21 -0
  218. package/src/modules/terminal_tracking/lib/rate-limiter.ts +52 -0
  219. package/src/modules/terminal_tracking/lib/stops.ts +124 -0
  220. package/src/modules/terminal_tracking/lib/terminal-adapter.ts +136 -0
  221. package/src/modules/terminal_tracking/lib/totp.ts +63 -0
  222. package/src/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.ts +104 -0
  223. package/src/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.ts +40 -0
  224. package/src/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.ts +11 -0
  225. package/src/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.ts +13 -0
  226. package/src/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.ts +16 -0
  227. package/src/modules/terminal_tracking/services/__tests__/availabilityEmit.test.ts +277 -0
  228. package/src/modules/terminal_tracking/services/__tests__/createJob.test.ts +59 -0
  229. package/src/modules/terminal_tracking/services/__tests__/pollAllBatch.test.ts +147 -0
  230. package/src/modules/terminal_tracking/services/__tests__/terminalMatcher.test.ts +70 -0
  231. package/src/modules/terminal_tracking/services/__tests__/vesselResolution.test.ts +453 -0
  232. package/src/modules/terminal_tracking/services/terminalMatcherService.ts +97 -0
  233. package/src/modules/terminal_tracking/services/terminalRegistry.ts +30 -0
  234. package/src/modules/terminal_tracking/services/terminalTrackingService.ts +912 -0
  235. package/src/modules/terminal_tracking/setup.ts +24 -0
  236. package/src/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.ts +32 -0
@@ -0,0 +1,127 @@
1
+ import { fetch, type RequestInit as UndiciRequestInit } from 'undici'
2
+ import { proxyDispatcher, type FetchInitWithDispatcher } from './proxy'
3
+ import { terminalLogger } from '../../logger'
4
+
5
+ /** Outcome of an N4 HTTP request: status + the fully-read response body. */
6
+ export type N4Response = { status: number; ok: boolean; text: string }
7
+
8
+ export type N4RequestOptions = {
9
+ /** Terminal proxy URL — egresses through an undici ProxyAgent when set. */
10
+ proxyUrl?: string | null
11
+ /** Per-attempt timeout (whole attempt: connect + headers + body). Default 30s. */
12
+ timeoutMs?: number
13
+ /** Extra attempts after the first, on transient network failures. Default 2. */
14
+ retries?: number
15
+ /** Base backoff between attempts (doubles each retry). Default 250ms. */
16
+ retryBackoffMs?: number
17
+ /** Short label for logs (e.g. `"<terminalCode> /unit"`). */
18
+ label?: string
19
+ }
20
+
21
+ const DEFAULT_TIMEOUT_MS = 30_000
22
+ const DEFAULT_RETRIES = 2
23
+ const DEFAULT_BACKOFF_MS = 250
24
+
25
+ // undici raises these for a dropped/half-open/timed-out connection — all safe to
26
+ // retry (the request either never reached the server or got no usable response).
27
+ const TRANSIENT_CODES = new Set([
28
+ 'UND_ERR_SOCKET',
29
+ 'UND_ERR_CONNECT_TIMEOUT',
30
+ 'UND_ERR_HEADERS_TIMEOUT',
31
+ 'UND_ERR_BODY_TIMEOUT',
32
+ 'ECONNRESET',
33
+ 'ECONNREFUSED',
34
+ 'EPIPE',
35
+ 'ETIMEDOUT',
36
+ 'EAI_AGAIN',
37
+ ])
38
+
39
+ /**
40
+ * A transient network failure (connection dropped/reset/timed out) — distinct
41
+ * from an HTTP error *status*, which is a real answer and must not be retried.
42
+ */
43
+ export function isTransientNetworkError(err: unknown): boolean {
44
+ // AbortSignal.timeout() rejects with a TimeoutError DOMException.
45
+ if (err instanceof DOMException && err.name === 'TimeoutError') return true
46
+ const e = err as { code?: string; message?: string; cause?: { code?: string; message?: string } }
47
+ const code = e?.code ?? e?.cause?.code
48
+ if (code && TRANSIENT_CODES.has(code)) return true
49
+ // undici surfaces a mid-flight peer socket close as `TypeError: terminated`
50
+ // (cause: UND_ERR_SOCKET "other side closed"); match on message as a backstop.
51
+ const msg = `${e?.message ?? ''} ${e?.cause?.message ?? ''}`
52
+ return /terminated|other side closed|socket hang ?up|fetch failed/i.test(msg)
53
+ }
54
+
55
+ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
56
+
57
+ /**
58
+ * Perform one N4 HTTP request and ALWAYS fully read the response body — even on a
59
+ * non-2xx status — returning `{ status, ok, text }`. Callers inspect `status`
60
+ * and parse `text`; they never touch the live response stream.
61
+ *
62
+ * Why drain the body unconditionally: undici holds the keep-alive socket open
63
+ * while a response body is unread. The previous code threw on a non-ok status
64
+ * (e.g. Baltic Hub's Cloudflare allowlist 403) WITHOUT reading the body, leaving
65
+ * a dangling stream. When the peer later closed that socket, undici errored the
66
+ * orphaned body controller with no reader attached, surfacing an *unhandled*
67
+ * `TypeError: terminated`. Under Node's default unhandled-rejection policy that
68
+ * killed the queue-worker process and took the whole server down. Reading the
69
+ * body inside this `try` means any mid-stream close rejects an awaited call here,
70
+ * where it is caught and (when transient) retried — it can never escape.
71
+ *
72
+ * Each attempt is bounded by an AbortSignal timeout, and transient connection
73
+ * drops are retried with exponential backoff so a single Cloudflare/tunnel
74
+ * keep-alive reset doesn't fail an entire poll cycle.
75
+ */
76
+ export async function n4Request(
77
+ url: string,
78
+ init: Omit<FetchInitWithDispatcher, 'dispatcher' | 'signal'>,
79
+ options: N4RequestOptions = {},
80
+ ): Promise<N4Response> {
81
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
82
+ const retries = options.retries ?? DEFAULT_RETRIES
83
+ const backoffMs = options.retryBackoffMs ?? DEFAULT_BACKOFF_MS
84
+ const dispatcher = proxyDispatcher(options.proxyUrl)
85
+ const label = options.label ?? url
86
+
87
+ let lastErr: unknown
88
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
89
+ try {
90
+ // `fetch` is imported from undici (not the global) so the request driver
91
+ // and the `dispatcher` (a ProxyAgent from the SAME undici) belong to one
92
+ // instance. With the global fetch — Node's *built-in* undici — driving a
93
+ // dispatcher from this package's *bundled* undici, a peer socket reset
94
+ // makes the bundled dispatcher call the built-in fetch's onAborted, which
95
+ // throws `TypeError: terminated` from a detached socket-close handler that
96
+ // no try/catch here can see — it escaped as an uncaughtException and killed
97
+ // the worker. Same-instance, that reset rejects the awaited call below,
98
+ // where it is caught and (when transient) retried.
99
+ const res = await fetch(url, {
100
+ ...init,
101
+ dispatcher,
102
+ signal: AbortSignal.timeout(timeoutMs),
103
+ } as UndiciRequestInit)
104
+ // Drain the body within this try — see the doc comment above. This is the
105
+ // line that prevents the dangling-stream unhandled rejection.
106
+ const text = await res.text()
107
+ return { status: res.status, ok: res.ok, text }
108
+ } catch (err) {
109
+ lastErr = err
110
+ if (attempt < retries && isTransientNetworkError(err)) {
111
+ const wait = backoffMs * 2 ** attempt
112
+ terminalLogger.warn('N4 request transient failure — retrying', {
113
+ label,
114
+ attempt: attempt + 1,
115
+ maxAttempts: retries + 1,
116
+ waitMs: wait,
117
+ message: err instanceof Error ? err.message : String(err),
118
+ })
119
+ await sleep(wait)
120
+ continue
121
+ }
122
+ throw err
123
+ }
124
+ }
125
+ // The loop always returns or throws; this satisfies the type checker.
126
+ throw lastErr
127
+ }
@@ -0,0 +1,156 @@
1
+ import type { N4UnitRow } from './types'
2
+ import { DEFAULT_TERMINAL_TIMEZONE, zonedNaiveToUtc } from '../zoned-time'
3
+ import type { TerminalFetchedEvent, ResolvedTerminalConfig } from '../../terminal-adapter'
4
+ import type {
5
+ TerminalEventType,
6
+ TerminalEventClassifierCode,
7
+ TerminalModeOfTransport,
8
+ } from '../../../data/entities'
9
+
10
+ type StateMapping = {
11
+ code: string // canonical N4 transit-state code
12
+ eventType: TerminalEventType
13
+ eventCode: string
14
+ classifier: TerminalEventClassifierCode
15
+ timestampField: 'timeIn' | 'timeOut' | 'loaded'
16
+ }
17
+
18
+ /**
19
+ * Maps an N4 friendly T-State (as returned by `/unit`) to a normalized event.
20
+ * The `/unit` response uses friendly names ("Inbound", "Yard", "Loaded",
21
+ * "Departed"); the `S*` codes are the request-side filter values.
22
+ */
23
+ const STATE_MAP: Record<string, StateMapping> = {
24
+ inbound: { code: 'S20_INBOUND', eventType: 'EQUIPMENT', eventCode: 'GTIN', classifier: 'ACT', timestampField: 'timeIn' },
25
+ ecin: { code: 'S30_ECIN', eventType: 'EQUIPMENT', eventCode: 'GTIN', classifier: 'ACT', timestampField: 'timeIn' },
26
+ yard: { code: 'S40_YARD', eventType: 'EQUIPMENT', eventCode: 'DISC', classifier: 'ACT', timestampField: 'timeIn' },
27
+ loaded: { code: 'S60_LOADED', eventType: 'EQUIPMENT', eventCode: 'LOAD', classifier: 'ACT', timestampField: 'loaded' },
28
+ departed: { code: 'S70_DEPARTED', eventType: 'TRANSPORT', eventCode: 'DEPA', classifier: 'ACT', timestampField: 'timeOut' },
29
+ }
30
+
31
+ /** Map an eventCode to the semantic module event id (or null if none). */
32
+ export function semanticEventIdFor(eventCode: string): string | null {
33
+ switch (eventCode) {
34
+ case 'GTIN':
35
+ return 'terminal_tracking.equipment.gate_in'
36
+ case 'DISC':
37
+ return 'terminal_tracking.equipment.discharged'
38
+ case 'LOAD':
39
+ return 'terminal_tracking.equipment.loaded'
40
+ case 'DEPA':
41
+ return 'terminal_tracking.transport.departed'
42
+ default:
43
+ return null
44
+ }
45
+ }
46
+
47
+ export function buildSourceEventId(terminalCode: string, ufvGkey: string, eventCode: string): string {
48
+ return `${terminalCode}:${ufvGkey}:${eventCode}`
49
+ }
50
+
51
+ /**
52
+ * Parse an N4 timestamp ("yyyy-mm-dd hh:mm[:ss]"). N4 returns terminal-local
53
+ * time without an offset, so we interpret it in the terminal's timezone
54
+ * (Europe/Warsaw by default) and return the corresponding UTC instant.
55
+ */
56
+ export function parseN4DateTime(
57
+ value: string | null | undefined,
58
+ timeZone: string = DEFAULT_TERMINAL_TIMEZONE,
59
+ ): Date | null {
60
+ if (!value) return null
61
+ const m = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value.trim())
62
+ if (!m) return null
63
+ const [, yyyy, mm, dd, hh, mi, ss] = m
64
+ return zonedNaiveToUtc(+yyyy, +mm, +dd, +hh, +mi, ss ? +ss : 0, timeZone)
65
+ }
66
+
67
+ export function inferMode(visitRef: string | null | undefined): TerminalModeOfTransport | null {
68
+ if (!visitRef) return null
69
+ const v = visitRef.toUpperCase()
70
+ if (v.includes('TRUCK')) return 'TRUCK'
71
+ // Rail visit ids look like 'TUX3W31-25_IMP' / '..._EXP'
72
+ if (/_IMP|_EXP|\bW\d/.test(v)) return 'RAIL'
73
+ return 'VESSEL'
74
+ }
75
+
76
+ /**
77
+ * The container's direction of travel through the terminal, from the N4
78
+ * `Category` column. Anything else N4 reports (notably "Storage" for empties)
79
+ * is not a direction and normalizes to null.
80
+ */
81
+ export type TerminalCargoCategory = 'import' | 'export'
82
+
83
+ /** Normalize an N4 `Category` value to a direction (null when not directional). */
84
+ export function parseCargoCategory(rawData: Record<string, unknown> | null | undefined): TerminalCargoCategory | null {
85
+ const category = String(rawData?.['Category'] ?? '').trim().toLowerCase()
86
+ if (category === 'import') return 'import'
87
+ if (category === 'export') return 'export'
88
+ return null
89
+ }
90
+
91
+ /**
92
+ * Pick the vessel-side visit ref for a leg, by its N4 `Category`:
93
+ * an **Import** leg's vessel is the inbound visit ("I/B Actual Visit"); an
94
+ * **Export** leg's vessel is the outbound visit ("O/B Actual Visit"). The `dir`
95
+ * tells the caller which voyage (inbound vs outbound) to attach. Returns null
96
+ * when the category is unknown or the relevant side is missing.
97
+ */
98
+ export function pickVesselVisit(
99
+ event: Pick<TerminalFetchedEvent, 'visitRefIn' | 'visitRefOut' | 'rawData'>,
100
+ ): { ref: string; dir: 'in' | 'out' } | null {
101
+ const category = parseCargoCategory(event.rawData)
102
+ if (category === 'import') return event.visitRefIn ? { ref: event.visitRefIn, dir: 'in' } : null
103
+ if (category === 'export') return event.visitRefOut ? { ref: event.visitRefOut, dir: 'out' } : null
104
+ return null
105
+ }
106
+
107
+ /**
108
+ * Map a single N4 unit-facility-visit row to a normalized terminal event,
109
+ * keyed by the row's current transit state. Returns null when the state is not
110
+ * mappable (e.g. advised-only, retired).
111
+ */
112
+ export function mapRowToEvent(
113
+ row: N4UnitRow,
114
+ config: ResolvedTerminalConfig,
115
+ ): TerminalFetchedEvent | null {
116
+ const stateKey = (row.tState ?? '').trim().toLowerCase()
117
+ const mapping = STATE_MAP[stateKey]
118
+ if (!mapping) return null
119
+
120
+ const eventDateTime =
121
+ parseN4DateTime(row[mapping.timestampField]) ??
122
+ parseN4DateTime(row.timeOut) ??
123
+ parseN4DateTime(row.loaded) ??
124
+ parseN4DateTime(row.timeIn) ??
125
+ new Date()
126
+
127
+ const seals = (row.seals ?? []).map((number) => ({ number, source: config.terminalCode }))
128
+
129
+ return {
130
+ source: 'terminal',
131
+ sourceEventId: buildSourceEventId(config.terminalCode, row.ufvGkey, mapping.eventCode),
132
+ eventType: mapping.eventType,
133
+ eventCode: mapping.eventCode,
134
+ eventClassifierCode: mapping.classifier,
135
+ eventDateTime,
136
+ containerNumber: row.unitNbr,
137
+ ufvGkey: row.ufvGkey,
138
+ transitState: mapping.code,
139
+ visitState: row.vState ?? null,
140
+ facilityCode: config.facilityCode ?? null,
141
+ facilityCodeListProvider: config.facilityCodeListProvider ?? null,
142
+ unlocode: config.unlocode ?? null,
143
+ visitRefIn: row.ibActualVisit ?? null,
144
+ visitRefOut: row.obActualVisit ?? null,
145
+ vesselName: null,
146
+ voyageNumber: null,
147
+ modeOfTransport: inferMode(row.obActualVisit) ?? inferMode(row.ibActualVisit),
148
+ seals: seals.length ? seals : null,
149
+ vgmWeightKg: row.vgmWeight ?? null,
150
+ impediments: row.impediments ?? null,
151
+ // The N4 `Loaded` timestamp, parsed independently of the current state so a
152
+ // container that has since departed still carries when it was loaded.
153
+ loadedAt: parseN4DateTime(row.loaded),
154
+ rawData: row.raw,
155
+ }
156
+ }
@@ -0,0 +1,102 @@
1
+ import type { N4UnitRow } from '../types'
2
+ import { parseTerminalStops } from '../../../stops'
3
+
4
+ /** Coerce a value that may be a single item or an array into an array. */
5
+ function asArray<T>(v: T | T[] | null | undefined): T[] {
6
+ if (v == null) return []
7
+ return Array.isArray(v) ? v : [v]
8
+ }
9
+
10
+ function toNum(v: string | null | undefined): number | null {
11
+ if (v == null || v === '') return null
12
+ const n = Number(v)
13
+ return Number.isFinite(n) ? n : null
14
+ }
15
+
16
+ function splitList(v: string | null | undefined): string[] | null {
17
+ if (v == null || v === '') return null
18
+ return v
19
+ .split(',')
20
+ .map((s) => s.trim())
21
+ .filter(Boolean)
22
+ }
23
+
24
+ export type N4DataTableResponse = {
25
+ 'query-response'?: {
26
+ 'data-table'?: {
27
+ '@count'?: string
28
+ columns?: { column?: string | string[] }
29
+ rows?: { row?: N4RawRow | N4RawRow[] } | null
30
+ }
31
+ }
32
+ }
33
+
34
+ type N4RawRow = {
35
+ '@primary-key'?: string
36
+ field?: (string | null) | (string | null)[]
37
+ }
38
+
39
+ /**
40
+ * Parse the N4 `/unit` `query-response.data-table` JSON envelope into typed
41
+ * rows. Maps `field[]` onto `columns.column[]` by index, reads `@primary-key`
42
+ * as the Ufv_Gkey, and tolerates single-vs-array collapsing from XML→JSON.
43
+ */
44
+ export function parseUnitDataTable(body: N4DataTableResponse): N4UnitRow[] {
45
+ const table = body?.['query-response']?.['data-table']
46
+ if (!table) return []
47
+
48
+ const columns = asArray(table.columns?.column).map((c) => String(c))
49
+ const rows = asArray(table.rows?.row ?? undefined)
50
+
51
+ const idx = (header: string): number => columns.indexOf(header)
52
+ const out: N4UnitRow[] = []
53
+
54
+ for (const row of rows) {
55
+ const fields = asArray(row.field)
56
+ const get = (header: string): string | null => {
57
+ const i = idx(header)
58
+ if (i < 0 || i >= fields.length) return null
59
+ const v = fields[i]
60
+ return v == null || v === '' ? null : String(v)
61
+ }
62
+
63
+ const raw: Record<string, string | null> = {}
64
+ columns.forEach((col, i) => {
65
+ raw[col] = fields[i] == null || fields[i] === '' ? null : String(fields[i])
66
+ })
67
+
68
+ const ufvGkey = row['@primary-key'] ? String(row['@primary-key']) : ''
69
+ const unitNbr = get('Unit Nbr') ?? ''
70
+ if (!ufvGkey || !unitNbr) continue
71
+
72
+ out.push({
73
+ ufvGkey,
74
+ unitNbr,
75
+ tState: get('T-State'),
76
+ vState: get('V-State'),
77
+ category: get('Category'),
78
+ lineOp: get('Line Op'),
79
+ typeIso: get('Type ISO'),
80
+ frghtKind: get('Frght Kind'),
81
+ ibActualVisit: get('I/B Actual Visit'),
82
+ obActualVisit: get('O/B Actual Visit'),
83
+ timeIn: get('Time In'),
84
+ timeOut: get('Time Out'),
85
+ impediments: splitList(get('Unit Impediments')),
86
+ cenNumber: get('CEN Number'),
87
+ vgmWeight: toNum(get('VGM Weight')),
88
+ cargoWtKg: toNum(get('Cargo Wt (kg)')),
89
+ tareWt: toNum(get('Tare Wt')),
90
+ weightKg: toNum(get('Weight (kg)')),
91
+ seals: [get('Seal Nbr1'), get('Seal Nbr2'), get('Seal Nbr3'), get('Seal Nbr4')].filter(
92
+ (s): s is string => !!s,
93
+ ),
94
+ dskNumber: get('DSK Number'),
95
+ loaded: get('Loaded'),
96
+ stops: parseTerminalStops(raw),
97
+ raw,
98
+ })
99
+ }
100
+
101
+ return out
102
+ }
@@ -0,0 +1,60 @@
1
+ import type { N4DataTableResponse } from './data-table'
2
+ import type { N4VesselRow } from '../types'
3
+
4
+ /** Coerce a value that may be a single item or an array into an array. */
5
+ function asArray<T>(v: T | T[] | null | undefined): T[] {
6
+ if (v == null) return []
7
+ return Array.isArray(v) ? v : [v]
8
+ }
9
+
10
+ /**
11
+ * Parse the N4 `/VESSEL` `query-response.data-table` envelope into a single
12
+ * vessel-visit row. A `/VESSEL?VISIT_REF=` query targets exactly one visit, so
13
+ * we return the first row (or null when there are none). Columns are mapped by
14
+ * header name, mirroring parseUnitDataTable.
15
+ *
16
+ * Real columns: Visit, Vessel Name, I/B Vyg, O/B Vyg, Line, Phase, ETA, ETD,
17
+ * ATA, ATD, Begin Receive, DryCutoff. The `Visit` column equals the unit's
18
+ * `visitRefIn`/`visitRefOut` (the join key).
19
+ */
20
+ export function parseVesselDataTable(body: N4DataTableResponse): N4VesselRow | null {
21
+ const table = body?.['query-response']?.['data-table']
22
+ if (!table) return null
23
+
24
+ const columns = asArray(table.columns?.column).map((c) => String(c))
25
+ const rows = asArray(table.rows?.row ?? undefined)
26
+ if (rows.length === 0) return null
27
+
28
+ const row = rows[0]
29
+ const fields = asArray(row.field)
30
+ const get = (header: string): string | null => {
31
+ const i = columns.indexOf(header)
32
+ if (i < 0 || i >= fields.length) return null
33
+ const v = fields[i]
34
+ return v == null || v === '' ? null : String(v)
35
+ }
36
+
37
+ const raw: Record<string, string | null> = {}
38
+ columns.forEach((col, i) => {
39
+ raw[col] = fields[i] == null || fields[i] === '' ? null : String(fields[i])
40
+ })
41
+
42
+ const visitRef = get('Visit')
43
+ if (!visitRef) return null
44
+
45
+ return {
46
+ visitRef,
47
+ vesselName: get('Vessel Name'),
48
+ ibVoyage: get('I/B Vyg'),
49
+ obVoyage: get('O/B Vyg'),
50
+ line: get('Line'),
51
+ phase: get('Phase'),
52
+ eta: get('ETA'),
53
+ etd: get('ETD'),
54
+ ata: get('ATA'),
55
+ atd: get('ATD'),
56
+ beginReceive: get('Begin Receive'),
57
+ dryCutoff: get('DryCutoff'),
58
+ raw,
59
+ }
60
+ }
@@ -0,0 +1,24 @@
1
+ import { ProxyAgent, type Dispatcher } from 'undici'
2
+
3
+ /**
4
+ * Build (and memoise) an undici `ProxyAgent` for a terminal's `proxyUrl`, to be
5
+ * passed as the `dispatcher` on the N4 `fetch` calls so they egress through a
6
+ * whitelisted IP. Returns undefined when no proxy is configured (direct).
7
+ *
8
+ * Note: undici proxying is HTTP(S) CONNECT only — `proxyUrl` must be an
9
+ * http(s) forward proxy (e.g. tinyproxy/squid), not a SOCKS proxy.
10
+ */
11
+ const cache = new Map<string, ProxyAgent>()
12
+
13
+ export function proxyDispatcher(proxyUrl?: string | null): Dispatcher | undefined {
14
+ if (!proxyUrl) return undefined
15
+ let agent = cache.get(proxyUrl)
16
+ if (!agent) {
17
+ agent = new ProxyAgent(proxyUrl)
18
+ cache.set(proxyUrl, agent)
19
+ }
20
+ return agent
21
+ }
22
+
23
+ /** Init type for global fetch including undici's `dispatcher` (not in DOM types). */
24
+ export type FetchInitWithDispatcher = RequestInit & { dispatcher?: Dispatcher }
@@ -0,0 +1,56 @@
1
+ import type { TerminalStops } from '../../stops'
2
+
3
+ /**
4
+ * Normalized representation of one N4 "Unit Facility Visit" row returned by
5
+ * the terminal `/unit` endpoint (the `query-response.data-table` envelope).
6
+ * Field names mirror the N4 column headers (mapped by name, not position).
7
+ */
8
+ export type N4UnitRow = {
9
+ ufvGkey: string
10
+ unitNbr: string
11
+ tState?: string | null
12
+ vState?: string | null
13
+ category?: string | null
14
+ lineOp?: string | null
15
+ typeIso?: string | null
16
+ frghtKind?: string | null
17
+ ibActualVisit?: string | null
18
+ obActualVisit?: string | null
19
+ timeIn?: string | null
20
+ timeOut?: string | null
21
+ impediments?: string[] | null
22
+ cenNumber?: string | null
23
+ vgmWeight?: number | null
24
+ cargoWtKg?: number | null
25
+ tareWt?: number | null
26
+ weightKg?: number | null
27
+ seals?: string[] | null
28
+ dskNumber?: string | null
29
+ loaded?: string | null
30
+ /** Per-mode load/pickup STOP flags (Stop-Vsl / Stop-Road / Stop-Rail). */
31
+ stops: TerminalStops
32
+ /** The full header->value map for rawData persistence. */
33
+ raw: Record<string, string | null>
34
+ }
35
+
36
+ /**
37
+ * Normalized representation of one N4 vessel visit row returned by the `/VESSEL`
38
+ * endpoint. Date fields are raw strings ("yyyy-mm-dd hh:mm"); the adapter parses
39
+ * them via parseN4DateTime. There are two voyages — inbound and outbound.
40
+ */
41
+ export type N4VesselRow = {
42
+ visitRef: string
43
+ vesselName: string | null
44
+ ibVoyage: string | null
45
+ obVoyage: string | null
46
+ line: string | null
47
+ phase: string | null
48
+ eta: string | null
49
+ etd: string | null
50
+ ata: string | null
51
+ atd: string | null
52
+ beginReceive: string | null
53
+ dryCutoff: string | null
54
+ /** The full header->value map for rawData persistence. */
55
+ raw: Record<string, string | null>
56
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Terminal timestamp timezone handling.
3
+ *
4
+ * Every terminal we integrate (BCT Gdańsk via N4, GCT and BCT Gdynia) reports
5
+ * event times as a naive wall-clock string — `2026-08-21 11:57`, with no offset.
6
+ * Those are *terminal-local* times, not UTC. Reading them as UTC (the old
7
+ * behaviour: append `Z`) shifted every displayed time by the zone's offset — in
8
+ * summer Europe/Warsaw is CEST (+02:00), so a gate-out of 11:57 rendered as
9
+ * 13:57 in the (Warsaw) UI.
10
+ *
11
+ * All current terminals sit in `Europe/Warsaw`; that is the default. When a
12
+ * terminal outside Poland is onboarded, pass its IANA zone into the parsers
13
+ * instead of relying on this constant.
14
+ */
15
+ export const DEFAULT_TERMINAL_TIMEZONE = 'Europe/Warsaw'
16
+
17
+ /**
18
+ * How far ahead of UTC `timeZone` is at the instant `utcMs`, in milliseconds.
19
+ * Uses `Intl` so DST is honoured (CET/CEST for Europe/Warsaw) without any
20
+ * external timezone library.
21
+ */
22
+ function zoneOffsetMs(utcMs: number, timeZone: string): number {
23
+ const parts = new Intl.DateTimeFormat('en-US', {
24
+ timeZone,
25
+ hour12: false,
26
+ year: 'numeric',
27
+ month: '2-digit',
28
+ day: '2-digit',
29
+ hour: '2-digit',
30
+ minute: '2-digit',
31
+ second: '2-digit',
32
+ }).formatToParts(new Date(utcMs))
33
+ const f: Record<string, number> = {}
34
+ for (const p of parts) if (p.type !== 'literal') f[p.type] = Number(p.value)
35
+ // Some engines render midnight as hour 24 rather than 0.
36
+ const hour = f.hour === 24 ? 0 : f.hour
37
+ const asIfUtc = Date.UTC(f.year, f.month - 1, f.day, hour, f.minute, f.second)
38
+ return asIfUtc - utcMs
39
+ }
40
+
41
+ /**
42
+ * Convert a naive wall-clock datetime — its components written in `timeZone`
43
+ * with no offset — to the correct UTC instant.
44
+ *
45
+ * e.g. `(2026, 8, 21, 11, 57, 0, 'Europe/Warsaw')` during CEST (+02:00) yields
46
+ * `2026-08-21T09:57:00.000Z`.
47
+ *
48
+ * `month` is 1-based (January = 1). Within a DST transition (the ~1h gap or
49
+ * overlap twice a year) the result may be off by the DST delta — acceptable for
50
+ * terminal event timestamps, which never land inside that window in practice.
51
+ */
52
+ export function zonedNaiveToUtc(
53
+ year: number,
54
+ month: number,
55
+ day: number,
56
+ hour: number,
57
+ minute: number,
58
+ second: number,
59
+ timeZone: string,
60
+ ): Date {
61
+ // First approximation: pretend the wall-clock components are already UTC,
62
+ // then subtract the zone's offset at (approximately) that instant.
63
+ const asUtc = Date.UTC(year, month - 1, day, hour, minute, second)
64
+ return new Date(asUtc - zoneOffsetMs(asUtc, timeZone))
65
+ }