| // Shared UN Comtrade annual-period helpers. Single source of truth imported by: | |
| // - scripts/seed-trade-flows.mjs (strategic commodity flows) | |
| // - scripts/seed-comtrade-bilateral-hs4.mjs (bulk per-country seeder) | |
| // - server/worldmonitor/supply-chain/v1/_bilateral-hs4-lazy.ts (on-demand fallback) | |
| // | |
| // Before PR #5641's review these three carried three byte-identical copies of | |
| // recentPeriod(). The copies mattered: seed-trade-flows.mjs already had a | |
| // year-boundary fallback the other two silently lacked, so a fix landing in | |
| // one did not reach the others. | |
| // | |
| // IMPORTANT — DO NOT MOVE THIS FILE OUT OF scripts/. Railway nixpacks services | |
| // build with `root_dir=scripts` and package only `scripts/` into `/app/`, so a | |
| // relative import that escapes `scripts/` resolves to a path that does not | |
| // exist in the container and crashes the worker on startup with | |
| // ERR_MODULE_NOT_FOUND. See scripts/_simulation-queue-constants.mjs (#3811 | |
| // incident / #3818 hotfix) and the regression test | |
| // tests/scripts-railway-nixpacks-no-escape-import.test.mts. Vercel-side TS | |
| // handlers are fine: esbuild inlines this module's contents at build time. | |
| // | |
| // Runtime constraint: Web-Platform APIs only (must run on Vercel Edge + Node). | |
| /** | |
| * Newest annual period that is reliably final across reporters. | |
| * | |
| * Comtrade annual data lags, and it lags unevenly: the fastest reporters are a | |
| * full year ahead of the slowest. `lag = 2` is the repo-wide default because | |
| * (y-2) is old enough that the major reporters have all filed. | |
| */ | |
| export function recentPeriod(now = new Date(), lag = 2) { | |
| return String(now.getUTCFullYear() - lag); | |
| } | |
| /** | |
| * Sequential fallback periods, freshest first, for endpoints that accept only | |
| * ONE period per request. | |
| * | |
| * Needed because (y-2) rolls forward the instant the UTC year turns, to a year | |
| * the slower reporters have not filed yet; without a fallback a seed goes empty | |
| * every January until they catch up. | |
| */ | |
| export function candidatePeriods(now = new Date()) { | |
| return [recentPeriod(now, 2), recentPeriod(now, 3)]; | |
| } | |
| /** | |
| * Comma-joined multi-year window for endpoints that accept a period LIST. | |
| * | |
| * Costs the same single request as one period but still lands a row for a | |
| * reporter that files late — the failure mode documented in | |
| * scripts/seed-recovery-import-hhi.mjs (UAE, Oman, Bahrain publish 1-2y behind | |
| * the G7). Consumers must resolve one row per (product, partner) afterwards, | |
| * newest year first, or a multi-year response silently mixes years. | |
| * | |
| * ONLY the authenticated `data/v1/get` route accepts a list. The public | |
| * `public/v1/preview` route returns HTTP 400 for a comma-separated period — | |
| * verified by live probe 2026-07-26: `period=2024` -> 200 (31 rows), | |
| * `period=2024,2023` -> 400. Use recentPeriod()/candidatePeriods() there. | |
| */ | |
| export function periodWindow(now = new Date(), { from = 2, to = 5 } = {}) { | |
| const years = []; | |
| for (let lag = from; lag <= to; lag++) years.push(recentPeriod(now, lag)); | |
| return years.join(','); | |
| } | |