Uh oh!
There was an error while loading. Please reload this page.
feat: add SeaRates ocean tracking compatibility gateway - #331
Conversation
The latest updates on your projects. Learn more about Vercel for GitHub.
|
| isoCode: typeCode[type] ? `${first}${second}${typeCode[type]}` : null, | ||
| sizeType: `${length}'${heightLabel} ${label[type] || type}`, | ||
| }; | ||
| } | ||
| function seaRatesStatus(value: unknown): string { | ||
| if (typeof value !== 'string') return 'UNKNOWN'; | ||
| if (['delivered', 'empty_returned', 'picked_up'].includes(value)) { | ||
| return 'DELIVERED'; | ||
| } | ||
| if ( | ||
| [ | ||
| 'available', | ||
| 'awaiting_inland_transfer', | ||
| 'in_transit', | ||
| 'not_available', | ||
| 'on_ship', | ||
| ].includes(value) |
There was a problem hiding this comment.
Active statuses become unknown
When a container has a valid active Terminal49 status such as grounded, on_rail, off_dock, dropped, or loaded, this function falls through to UNKNOWN, causing both the container and potentially the shipment metadata to report an indeterminate status despite active tracking.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/searates-compat/src/mapping.ts
Line: 201-218
Comment:
**Active statuses become unknown**
When a container has a valid active Terminal49 status such as `grounded`, `on_rail`, `off_dock`, `dropped`, or `loaded`, this function falls through to `UNKNOWN`, causing both the container and potentially the shipment metadata to report an indeterminate status despite active tracking.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| number: | ||
| stringValue(shipmentAttributes.bill_of_lading_number) || | ||
| payload.requestedNumber, |
There was a problem hiding this comment.
Requested tracking number is replaced
When a CT or BK request resolves to a shipment with a bill-of-lading number, this expression reports that BOL instead of the requested identifier, causing clients to correlate, display, or cache the response under the wrong tracking number.
| number: | |
| stringValue(shipmentAttributes.bill_of_lading_number)|| | |
| payload.requestedNumber, | |
| number: payload.requestedNumber, |
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/searates-compat/src/mapping.ts
Line: 412-414
Comment:
**Requested tracking number is replaced**
When a CT or BK request resolves to a shipment with a bill-of-lading number, this expression reports that BOL instead of the requested identifier, causing clients to correlate, display, or cache the response under the wrong tracking number.
```suggestion number: payload.requestedNumber,```---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| status: mapping.status, | ||
| facility: facilityId ? (ids.facilities.get(facilityId) ?? null) : null, | ||
| is_additional_event: false, | ||
| is_date_from_sealine: attributes.data_source === 'shipping_line', |
There was a problem hiding this comment.
Event provenance uses absent field
Public transport-event responses do not provide the data_source attribute checked here, so real events are always emitted with is_date_from_sealine: false, producing incorrect provenance despite the fixture supplying the undocumented field.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/searates-compat/src/mapping.ts
Line: 274
Comment:
**Event provenance uses absent field**
Public transport-event responses do not provide the `data_source` attribute checked here, so real events are always emitted with `is_date_from_sealine: false`, producing incorrect provenance despite the fixture supplying the undocumented field.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit:ac228f8c01
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| ...init, | ||
| headers: { | ||
| Accept: 'application/vnd.api+json', | ||
| Authorization: `Bearer ${this.token}`, |
There was a problem hiding this comment.
Send API keys with the Token scheme
In both pass-through and service-token modes, the credential is a Terminal49 API key, but every upstream request is sent as Authorization: Bearer .... Terminal49 API keys require the Token scheme (the existing SDK only preserves Bearer for explicitly supplied OAuth tokens), so valid gateway credentials receive 401 responses and tracking is returned as API_KEY_WRONG. Use Token for these API-key-backed requests.
AGENTS.md reference: AGENTS.md:L27-L27
Useful? React with 👍 / 👎.
| [ | ||
| 'available', | ||
| 'awaiting_inland_transfer', | ||
| 'in_transit', | ||
| 'not_available', | ||
| 'on_ship', | ||
| ].includes(value) |
There was a problem hiding this comment.
Map active container statuses to IN_TRANSIT
When Terminal49 returns common active statuses such as grounded, on_rail, off_dock, or loaded, this predicate falls through to UNKNOWN; in_transit, meanwhile, is not one of the documented Terminal49 current_status values. Consequently both containers[].status and the aggregate metadata status become UNKNOWN during substantial portions of a shipment's journey instead of IN_TRANSIT.
AGENTS.md reference: AGENTS.md:L27-L27
Useful? React with 👍 / 👎.
| dry: 'G1', | ||
| flat_rack: 'P1', | ||
| hard_top: 'U1', |
There was a problem hiding this comment.
Match the API's equipment type values
For open-top and flat-rack containers, the public API returns equipment_type as "open top" and "flat rack", but this lookup only recognizes underscore variants. Those supported containers therefore get iso_code: null and an unnormalized size_type, despite the gateway's stated ISO reconstruction support; normalize the incoming value or key the maps by the schema-defined strings.
AGENTS.md reference: AGENTS.md:L27-L27
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Summary
Reviewed — found 9 issues. This PR adds an isolated Vercel migration app that exposes SeaRates-compatible tracking and shipping-line endpoints backed by Terminal49’s JSON:API. I reviewed workspace and deployment configuration, request authentication, tracking-request resolution and refresh behavior, response mappings, error handling, and unit coverage.
Findings
packages/searates-compat/src/client.ts
- API-key requests use the wrong authorization scheme.
- Tracking-request reuse can select an incompatible or failed historical request.
- Upstream requests have no timeout or abort signal.
packages/searates-compat/src/service.ts
force_updateexceeds the refresh rate limit for shipments with more than ten containers.- Post-refresh responses retain stale shipment attributes.
packages/searates-compat/src/mapping.ts
- Valid journey states map to incorrect SeaRates statuses.
CTandBKresponses can report the bill of lading as the requested number.- Offset timestamps violate the promised SeaRates timestamp shape.
- Public equipment values produce missing or invalid ISO codes.
Verdict
| ...init, | ||
| headers: { | ||
| Accept: 'application/vnd.api+json', | ||
| Authorization: `Bearer ${this.token}`, |
There was a problem hiding this comment.
Issue — use the API-key authorization scheme. This client receives Terminal49 API keys, but sends them as Authorization: Bearer .... Terminal49 API keys require the Token scheme; Bearer is reserved for user-scoped JWTs and requires account context. As written, valid pass-through keys and T49_SEARATES_API_TOKEN credentials will receive 401 responses. Send API keys as Authorization: Token ... and update the header assertion in the tests.
There was a problem hiding this comment.
Addressed. Terminal49 API-key requests now send Authorization: Token ...; request-shaping tests assert the scheme.
There was a problem hiding this comment.
Verified at the current head: requests now use Authorization: Token ..., and the request-shaping coverage asserts it. This finding is resolved.
| const params = new URLSearchParams({ | ||
| 'filter[request_number]': number, | ||
| include: 'tracked_object', | ||
| 'page[size]': '1', | ||
| }); | ||
| if (scac) params.set('filter[scac]', scac); | ||
| return this.request(`/tracking_requests?${params.toString()}`); |
There was a problem hiding this comment.
Issue — do not reuse an arbitrary historical request. This query returns only one record for the number and optional SCAC, without matching request_type or selecting by status and recency. Multiple requests can exist for the same number, including different request types and failed attempts followed by successful ones, so a BL lookup can reuse a BK/CT request or an old failure and then never create a valid replacement. Retrieve enough candidates to enforce the requested type, prefer a tracked or active request, and create a new request when only failed candidates exist.
There was a problem hiding this comment.
Addressed. Reuse now fetches up to 30 candidates, matches request_type, excludes failed requests, and prefers a tracked object before recency. Tests cover ignoring failed and mismatched-type history.
There was a problem hiding this comment.
Verified at the current head: reuse now filters by request_type, excludes failed requests, fetches up to 30 candidates, and prioritizes a tracked object before recency. This finding is resolved.
| private async request( | ||
| path: string, | ||
| init: RequestInit = {}, | ||
| ): Promise<JsonApiDocument> { | ||
| const response = await this.fetchImpl(`${this.baseUrl}${path}`, { | ||
| ...init, | ||
| headers: { | ||
| Accept: 'application/vnd.api+json', | ||
| Authorization: `Bearer ${this.token}`, | ||
| ...init.headers, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
Issue — bound upstream requests with an abort timeout. The polling loop has a nominal deadline, but each fetch can stall beyond that deadline and eventually be terminated by Vercel. That bypasses the gateway's SeaRates error envelope and can affect shipment, event, shipping-line, and polling calls. Add an AbortSignal timeout bounded by the remaining polling/function budget and translate timeout failures to SEALINE_NO_RESPONSE.
There was a problem hiding this comment.
Addressed. Every upstream fetch now has an AbortSignal timeout, and polling requests are additionally bounded by the remaining poll deadline. Timeout failures map through the SeaRates error envelope.
There was a problem hiding this comment.
The per-call abort handling is an improvement, but this remains partially unresolved. The timeout restarts for each upstream call, so a force_update request can sequentially spend up to 10 seconds on each of ten refreshes before polling and event retrieval, exceeding the Vercel function’s 30-second duration. Please carry one operation-level deadline or abort signal through the full gateway request and cap every call and wait by its remaining budget.
| if (query.forceUpdate && containers.length > 0) { | ||
| await Promise.all( | ||
| containers.map((container) => client.refreshContainer(container.id)), | ||
| ); |
There was a problem hiding this comment.
Issue — throttle force_update refreshes. Container refreshes are limited to 10 requests per minute, but Promise.all submits every refresh concurrently. Any shipment with more than ten containers can therefore trigger 429 responses, fail the entire otherwise-valid lookup after partially scheduling refreshes, and immediately consume the shared quota in service-token mode. Queue or throttle these calls to the documented limit and define partial-failure behavior.
There was a problem hiding this comment.
Addressed with the intentional compatibility bound: requests with more than 10 containers return API_KEY_RATE_LIMIT before scheduling any refresh. Up to 10 refreshes run sequentially, avoiding the previous parallel burst.
There was a problem hiding this comment.
The per-invocation guard prevents one request from bursting above ten refreshes, but this remains partially unresolved in service-token mode. The documented quota applies to the shared credential, so concurrent invocations can each pass this guard, partially schedule refreshes, and then receive 429. Please enforce the budget per credential across invocations, or implement explicit best-effort behavior that does not fail the whole tracking response after partial side effects.
| shipmentDocument = await client.shipment(shipment.id); | ||
| containers = containerResources(shipmentDocument); | ||
| } | ||
| const eventsByContainerId = new Map<string, JsonApiDocument>(); | ||
| await Promise.all( | ||
| containers.map(async (container) => { | ||
| eventsByContainerId.set( | ||
| container.id, | ||
| await client.transportEvents(container.id), | ||
| ); | ||
| }), | ||
| ); | ||
| const payload: TrackingPayload = { | ||
| eventsByContainerId, | ||
| included: shipmentDocument.included || [], | ||
| requestedNumber: query.number, | ||
| requestedType: type, | ||
| shipment, |
There was a problem hiding this comment.
Issue — replace the shipment resource after refresh. After force_update, this fetches a new shipmentDocument and replaces containers, but the payload still references the pre-refresh shipment. Updated route dates, vessel or carrier metadata, and tracking timestamps from the second response are discarded while newer included containers are used. Re-extract and validate the shipment resource from the refreshed document before constructing TrackingPayload.
There was a problem hiding this comment.
Verified at the current head: the refreshed document now replaces shipmentDocument, and shipment is re-extracted and validated before payload construction. This finding is resolved.
| function seaRatesStatus(value: unknown): string { | ||
| if (typeof value !== 'string') return 'UNKNOWN'; | ||
| if (['delivered', 'empty_returned', 'picked_up'].includes(value)) { | ||
| return 'DELIVERED'; | ||
| } | ||
| if ( | ||
| [ | ||
| 'available', | ||
| 'awaiting_inland_transfer', | ||
| 'in_transit', | ||
| 'not_available', | ||
| 'on_ship', | ||
| ].includes(value) | ||
| ) { | ||
| return 'IN_TRANSIT'; | ||
| } | ||
| return 'UNKNOWN'; |
There was a problem hiding this comment.
Issue — map all public journey states correctly.picked_up is classified as DELIVERED even though it represents transport from the terminal to the warehouse, while active states such as grounded, on_rail, off_dock, loaded, and dropped fall through to UNKNOWN; new also lacks a planned-state mapping. This makes container and aggregate shipment status incorrect for common journeys. Keep pickup and active terminal/movement states in transit, reserve delivery for actual delivery or empty return, and explicitly map the pre-departure state.
There was a problem hiding this comment.
Addressed. picked_up, grounded, on_rail, off_dock, loaded, dropped, and the other active states map to IN_TRANSIT; new maps to PLANNED; only delivery or empty return maps to DELIVERED.
There was a problem hiding this comment.
Verified at the current head: active states, including picked_up, map to IN_TRANSIT; new maps to PLANNED; and only delivery/empty return map to DELIVERED. This finding is resolved.
| type: payload.requestedType, | ||
| number: | ||
| stringValue(shipmentAttributes.bill_of_lading_number) || | ||
| payload.requestedNumber, |
There was a problem hiding this comment.
Issue — preserve the identifier associated with metadata.type. For type=CT or type=BK, this replaces the submitted identifier with bill_of_lading_number, producing metadata whose type and number refer to different identifiers. Clients can then mis-correlate or mis-cache the response. Preserve payload.requestedNumber, or choose the identifier corresponding to requestedType.
There was a problem hiding this comment.
Addressed. metadata.number now always echoes payload.requestedNumber, preserving CT/BK correlation.
There was a problem hiding this comment.
Verified at the current head: metadata.number now uses payload.requestedNumber, preserving correlation with metadata.type. This finding is resolved.
| function formatDate(value: unknown): string | null { | ||
| if (typeof value !== 'string' || !value) return null; | ||
| return value.replace('T', ' ').replace(/(?:\.\d+)?Z$/, ''); |
There was a problem hiding this comment.
Issue — normalize offset timestamps to UTC. This only strips a trailing Z; a valid value such as 2020-04-04T16:13:35-07:00 becomes 2020-04-04 16:13:35-07:00, which does not match the documented YYYY-MM-DD HH:MM:SS response shape and is not UTC. Parse valid ISO-8601 values, normalize to UTC, format consistently, and add coverage for both Z and non-zero offsets.
There was a problem hiding this comment.
The output shape is fixed, but this remains partially unresolved against the documented UTC contract. For example, 2026-08-01T10:00:00-07:00 is emitted as 2026-08-01 10:00:00 rather than the equivalent UTC value 2026-08-01 17:00:00; supplying an IANA timezone similarly localizes the instant. Please normalize parsed values through UTC, or explicitly change the documented compatibility contract and tests if local SeaRates wall-clock time is intentional.
| const first = length === 20 ? '2' : length === 40 ? '4' : 'L'; | ||
| const second = height === 'high_cube' ? '5' : '2'; | ||
| const typeCode: Record<string, string> = { | ||
| dry: 'G1', | ||
| flat_rack: 'P1', | ||
| hard_top: 'U1', | ||
| open_top: 'U1', | ||
| reefer: 'R1', | ||
| tank: 'T1', | ||
| }; | ||
| const label: Record<string, string> = { | ||
| dry: 'Dry', | ||
| flat_rack: 'Flat Rack', | ||
| hard_top: 'Hard Top', | ||
| open_top: 'Open Top', | ||
| reefer: 'Reefer', | ||
| tank: 'Tank', | ||
| }; |
There was a problem hiding this comment.
Issue — handle the public equipment values and lengths. The Terminal49 schema emits equipment types such as open top and flat rack, while this table only recognizes underscore variants, yielding iso_code: null and an unnormalized size_type. The fallback length prefix L is also only appropriate for 45-foot equipment, not supported 10-foot containers, whose ISO length character is 1. Map the actual enum values and handle each supported length explicitly.
There was a problem hiding this comment.
Addressed. Equipment types normalize the public space-separated values (open top, flat rack), and length codes explicitly cover 10/20/40/45 feet.
There was a problem hiding this comment.
Verified at the current head: equipment names normalize the public space-separated variants, and length codes explicitly cover 10, 20, 40, and 45 feet. This finding is resolved.
Co-authored-by: Akshay Dodeja <dodeja@gmail.com>
Co-authored-by: Akshay Dodeja <dodeja@gmail.com>
Co-authored-by: Akshay Dodeja <dodeja@gmail.com>
Co-authored-by: Akshay Dodeja <dodeja@gmail.com>
Co-authored-by: Akshay Dodeja <dodeja@gmail.com>
Co-authored-by: Akshay Dodeja <dodeja@gmail.com>
Co-authored-by: Akshay Dodeja <dodeja@gmail.com>
Co-authored-by: Akshay Dodeja <dodeja@gmail.com>
Co-authored-by: Akshay Dodeja <dodeja@gmail.com>
Co-authored-by: Akshay Dodeja <dodeja@gmail.com>
Co-authored-by: Akshay Dodeja <dodeja@gmail.com>
Co-authored-by: Akshay Dodeja <dodeja@gmail.com>
fb200c8 to
5d06966CompareThere was a problem hiding this comment.
Summary
Reviewed — found 6 issues. I re-reviewed the complete current SeaRates compatibility gateway diff at head 5d06966507f7766e8a4df79256f06f21c13a4fe7, including the nine prior fixes, gateway lifecycle, refresh handling, response mapping, deployment limits, documentation, and tests. Six prior findings are resolved; timeout, refresh-quota, and timestamp handling remain partially unresolved, and the updated implementation has three additional data/route correctness issues.
Findings
packages/searates-compat/src/client.ts
- Per-call timeouts do not bound the complete gateway operation.
- Terminal and rail refreshes are not recognized by refresh polling.
packages/searates-compat/src/service.ts
- Concurrent requests can still exceed the shared refresh quota.
packages/searates-compat/src/mapping.ts
- All event dates are labeled as shipping-line data.
postpodignores the shipment’s inland destination.- Offset timestamps are not normalized to the documented UTC contract.
Verdict
| private async request( | ||
| path: string, | ||
| init: RequestInit = {}, | ||
| timeoutMs = this.requestTimeoutMs, | ||
| ): Promise<JsonApiDocument> { | ||
| const timeoutSignal = AbortSignal.timeout(Math.max(1, timeoutMs)); | ||
| const response = await this.fetchImpl(`${this.baseUrl}${path}`, { | ||
| ...init, | ||
| signal: init.signal | ||
| ? AbortSignal.any([init.signal, timeoutSignal]) | ||
| : timeoutSignal, |
There was a problem hiding this comment.
Issue — enforce one deadline for the complete gateway operation. This timeout restarts for every upstream request. A force_update lookup can sequentially await up to ten refresh calls at 10 seconds each, then poll and fetch events, while the Vercel handler has a 30-second maximum duration. Three slow refreshes can therefore exhaust the function before the gateway emits SEALINE_NO_RESPONSE. Carry a shared operation deadline or abort signal through the full request and cap every upstream call and polling wait by its remaining budget.
| function freshnessSignature(document: JsonApiDocument): string { | ||
| const shipment = Array.isArray(document.data) | ||
| ? document.data.find((resource) => resource.type === 'shipment') | ||
| : document.data?.type === 'shipment' | ||
| ? document.data | ||
| : undefined; | ||
| return String(shipment?.attributes?.line_tracking_last_succeeded_at || ''); | ||
| } |
There was a problem hiding this comment.
Issue — recognize non-carrier refresh results.PATCH /containers/{id}/refresh refreshes shipping-line, terminal, and rail sources, but this signature only watches line_tracking_last_succeeded_at. If terminal or rail data updates while the carrier timestamp is unchanged, the refreshed attributes and events are available but polling times out and the gateway returns NO_TRACKING_INFO. Include relevant container/source freshness markers or compare the refreshed fields used by the response.
| if (query.forceUpdate && containers.length > 0) { | ||
| if (containers.length > 10) { | ||
| return errorEnvelope('API_KEY_RATE_LIMIT'); | ||
| } | ||
| for (const container of containers) { | ||
| await client.refreshContainer(container.id); | ||
| } |
There was a problem hiding this comment.
Issue — enforce the refresh quota per credential. The ten-container guard is scoped to one invocation, but the 10-per-minute quota applies to the API credential. In service-token mode, concurrent requests share that credential and can each pass this guard, partially schedule refreshes, and then receive 429. Enforce the budget per credential across invocations, or implement explicit best-effort behavior that does not fail the complete tracking response after partial side effects.
| function publicEvent(event: EventDraft, order: number): SeaRatesEvent { | ||
| return { | ||
| actual: event.actual, | ||
| date: event.date, | ||
| description: event.description, | ||
| event_code: event.code, | ||
| event_type: event.eventType, | ||
| facility: event.facility, | ||
| is_additional_event: false, | ||
| is_date_from_sealine: true, | ||
| location: event.location, |
There was a problem hiding this comment.
Issue — preserve event provenance. Every event is emitted with is_date_from_sealine: true, but Terminal49 transport events can have attributes.data_source values such as shipping_line, terminal, or ais. Terminal and AIS milestones are therefore falsely represented as carrier-provided. Preserve data_source in EventDraft and set this field from attributes.data_source === 'shipping_line'.
| const polLocationId = relatedId(payload.shipment, 'port_of_lading'); | ||
| const podLocationId = relatedId(payload.shipment, 'port_of_discharge'); | ||
| const fallbackPol = { | ||
| actual: Boolean(shipmentAttributes.pol_atd_at), | ||
| date: formatDate( | ||
| shipmentAttributes.pol_atd_at || shipmentAttributes.pol_etd_at, | ||
| stringValue(shipmentAttributes.pol_timezone), | ||
| ), | ||
| location: polLocationId ? (locations.get(polLocationId) ?? null) : null, | ||
| }; | ||
| const fallbackPod = { | ||
| actual: Boolean(shipmentAttributes.pod_ata_at), | ||
| date: formatDate( | ||
| shipmentAttributes.pod_ata_at || shipmentAttributes.pod_eta_at, | ||
| stringValue(shipmentAttributes.pod_timezone), | ||
| ), | ||
| location: podLocationId ? (locations.get(podLocationId) ?? null) : null, | ||
| }; | ||
| const pol = polEvent ? routePoint(polEvent) : fallbackPol; | ||
| const pod = podEvent ? routePoint(podEvent) : fallbackPod; | ||
| const prepol = prepolEvent | ||
| ? routePoint(prepolEvent) | ||
| : { actual: null, date: null, location: pol.location }; | ||
| const postpod = postpodEvent ? routePoint(postpodEvent) : { ...pod }; |
There was a problem hiding this comment.
Issue — use the inland destination for the postpod fallback. When no post-POD event exists, this clones the discharge port even though the gateway includes shipment.relationships.destination and the migration contract maps postpod to that inland destination. Inland shipments without arrived inland events therefore report POD as their final destination. Build the fallback from the destination relationship and destination_ata_at/destination_eta_at/destination_timezone, falling back to POD only when no destination is present.
| function formatDate(value: unknown, timeZone?: string | null): string | null { | ||
| if (typeof value !== 'string' || !value) return null; | ||
| const parsed = new Date(value); | ||
| if (Number.isNaN(parsed.getTime())) { | ||
| const match = value.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})/); | ||
| return match ? `${match[1]} ${match[2]}` : null; | ||
| } | ||
| if (timeZone) { | ||
| const local = formatParts(parsed, timeZone); | ||
| if (local) return local; | ||
| } | ||
| const offsetMatch = value.match( | ||
| /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(?:\.\d+)?[+-]\d{2}:\d{2}$/, | ||
| ); | ||
| if (offsetMatch) return `${offsetMatch[1]} ${offsetMatch[2]}`; | ||
| return parsed.toISOString().slice(0, 19).replace('T', ' '); |
There was a problem hiding this comment.
Issue — normalize offsets to the documented UTC contract. The output shape is now correct, but this localizes timestamps or preserves offset wall-clock components. For example, 2026-08-01T10:00:00-07:00 represents 17:00:00Z but is returned as 2026-08-01 10:00:00, contradicting the package’s UTC claim and shifting the instant for consumers. Normalize parsed timestamps through UTC, or explicitly change the documented compatibility contract and tests if local SeaRates wall-clock time is intended.
Summary
apps/migrateVercel application for vendor compatibility APIs, separate from the MCP deploymentGET /searates-api/tracking,/container,/reference, and/info/sealinesroutesAuthorization: TokenNO_TRACKING_INFOwhile Terminal49 is unresolved or not foundMapping behavior
null; unmapped and availability rows are retainedLTS, ancillaryUNKN, and delayTSDare derived from the ordered timelineLTS/UNKNmovements or distinct calls at the same placeprepol,pol,pod, andpostpodare derived from timeline anchors with documented fallbacksIN_TRANSIT; only delivery or empty return becomesDELIVEREDforce_updateintentionally returnsAPI_KEY_RATE_LIMITfor more than 10 containers and never fires a parallel refresh burstDeployment architecture
The root
vercel.jsonremains MCP-only. Create a second Vercel project in the Terminal49 team from this repository with:apps/migratecd ../.. && npm cicd ../.. && npm run build --workspace @terminal49/searates-compat && npm run build --workspace @terminal49/migrate-appConfigure migrate authentication variables in that project, not the MCP project. After a successful production deployment and once DNS is ready, add
migrate.terminal49.comas its production domain. The custom domain is not currently live; previews use the Vercel preview hostname with the same/searates-api/...paths. CI does not create or configure the Vercel project.Scope
Ocean tracking and the shipping-lines dictionary only. No rates, schedules, air, parcel, road, route geometry, AIS pins, history, or terminal dictionary.
/info/terminalsremains omitted because the public API has no terminal-list endpoint.Verification
mainat22259f4; PR is mergeable and conflict-freeCI / migrateran after the rebase and passedNO_TRACKING_INFO, and/container//referencecontractsdocs/migrate/searates.mdxnow documents the compatibility gateway and native migration pathsNeed help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.Greptile Summary
The PR adds a dedicated Vercel application and package that expose SeaRates-compatible ocean-tracking and shipping-line endpoints over Terminal49's public JSON:API.
Confidence Score: 2/5
The PR should not merge until tracking responses preserve the requested identifier and correctly represent valid container status and event provenance.
The new mapping layer returns observably incorrect compatibility data for current public API responses: active container states can become unknown, CT/BK metadata can contain a bill-of-lading number, and transport-event provenance is derived from an unavailable attribute.
Files Needing Attention: packages/searates-compat/src/mapping.ts, packages/searates-compat/src/fixtures/t49.ts
Important Files Changed
Sequence Diagram
Prompt To Fix All With AI
Reviews (1): Last reviewed commit: "refactor: isolate migration APIs in dedi..." | Re-trigger Greptile
Context used: