10 KiB
Capability Spec Delta: ports
Package: @suaveplan/ports
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions.
ADDED Requirements
Requirement: Port lookup by UN/LOCODE
findPort(locode) MUST return the Port whose locode matches the input (case-insensitive) from the built-in allPorts registry, or undefined when no match exists. A strict variant findPortStrict(locode) MUST throw PortsPortNotFoundError (PORTS.PORT_NOT_FOUND) on miss. Both MUST emit the OTel counter ports.lookup_total{kind="findPort"} on every invocation.
Scenario: case-insensitive hit returns the curated record
- WHEN
findPort("cnsha")is invoked - THEN the result has
locode === "CNSHA",country === "CN", andtype === "sea"
Verified by: src/find-port/find-port.test.ts::"finds CNSHA via case-insensitive lookup"
Scenario: strict variant throws on miss
- WHEN
findPortStrict("XXNON")is invoked - THEN a
PortsPortNotFoundErrorwith codePORTS.PORT_NOT_FOUNDis thrown
Verified by: src/find-port/find-port.test.ts::"strict variant throws on miss"
Requirement: Country and type filters
findPortsByCountry(country, ports?) MUST return every port whose country matches the ISO 3166-1 alpha-2 input (uppercased). findPortsByType(type, ports?) MUST filter by PortType (sea | air | rail | multimodal). searchPorts(query, ports?) MUST return every port whose name contains the query as a case-insensitive substring. Each function MUST default ports to the curated allPorts registry.
Scenario: country filter excludes other countries
- WHEN
findPortsByCountry("nl")is invoked - THEN every returned port has
country === "NL"and Rotterdam (NLRTM) is present
Verified by: src/find-port/find-port.test.ts::"filters by country"
Requirement: Nearest port with great-circle distance
findNearestPort(coord, ports?) MUST return { port, distanceKm } where port is the closest port to coord by great-circle distance and distanceKm is rounded to two decimals. It MUST throw PortsNoPortsError (PORTS.NO_PORTS) when the input port array is empty. Internally it MUST consult a PortSpatialIndex keyed by 5° lat/lng buckets when the input is the default allPorts registry, and emit the OTel histogram ports.find_nearest_duration_ms.
Scenario: nearest sea port to Singapore Strait
- WHEN
findNearestPort({ latitude: 1.26, longitude: 103.82 })is invoked - THEN the returned
port.locode === "SGSIN"anddistanceKm < 50
Verified by: src/nearest-port/nearest-port.test.ts::"nearest port to Singapore Strait is SGSIN"
Scenario: empty registry rejects
- WHEN
findNearestPort(coord, [])is invoked - THEN a
PortsNoPortsErrorwith codePORTS.NO_PORTSis thrown
Verified by: src/nearest-port/nearest-port.test.ts::"throws on empty registry"
Requirement: Radius search
findPortsInRadius(coord, radiusKm, ports?) MUST return every port within radiusKm great-circle kilometres of coord, sorted ascending by distanceKm. It MUST throw PortsInvalidRadiusError (PORTS.INVALID_RADIUS) when radiusKm <= 0. Containment MUST be inclusive of the boundary (distance equal to radiusKm is included).
Scenario: zero radius rejected
- WHEN
findPortsInRadius(coord, 0)is invoked - THEN a
PortsInvalidRadiusErroris thrown with codePORTS.INVALID_RADIUS
Verified by: src/nearest-port/nearest-port.test.ts::"throws on zero radius"
Requirement: Shipping-lane registry
getAllShippingLanes() MUST return the curated shippingLanes registry. getShippingLane(id) MUST return the lane with matching id or undefined; getShippingLaneStrict(id) MUST throw PortsLaneNotFoundError (PORTS.LANE_NOT_FOUND) on miss. getShippingLanesByMode(mode, lanes?) MUST filter by LaneMode (sea | rail | air). All lookups MUST emit the OTel counter ports.lane_lookup_total{mode}.
Scenario: sea-mode filter excludes rail and air lanes
- WHEN
getShippingLanesByMode("sea")is invoked - THEN every returned lane has
mode === "sea"
Verified by: src/lane-lookup/lane-lookup.test.ts::"filters by sea mode"
Requirement: Lane distance via great-circle waypoints
estimateLaneDistance(lane) MUST sum the great-circle distance (nautical miles) between consecutive waypoints in lane.waypoints, rounded to two decimals. cumulativeDistances(lane) MUST return an array of length waypoints.length whose entry i is the cumulative nautical-mile distance from waypoints[0] to waypoints[i] (so entry 0 is 0). Both MUST throw PortsInsufficientWaypointsError (PORTS.INSUFFICIENT_WAYPOINTS) when waypoints.length < 2.
Scenario: insufficient waypoints rejected
- WHEN
estimateLaneDistance({ ...lane, waypoints: [waypoints[0]] })is invoked - THEN a
PortsInsufficientWaypointsErroris thrown
Verified by: src/lane-distance/lane-distance.test.ts::"throws on insufficient waypoints"
Scenario: cumulative distances are monotonically non-decreasing
- WHEN
cumulativeDistances(lane)is invoked - THEN for every consecutive pair
(d[i], d[i+1]),d[i+1] >= d[i]andd[0] === 0
Verified by: src/lane-distance/lane-distance.test.ts::"cumulative distances are monotonically non-decreasing"
Requirement: Project coordinate onto lane
findPositionOnLane(coord, lane) MUST locate the nearest waypoint to coord and return { progress, remainingDistanceNm, distanceTravelledNm, nearestWaypointIndex }. progress MUST be clamped to [0, 1] and rounded to four decimals. remainingDistanceNm and distanceTravelledNm MUST be rounded to two decimals and clamped to >= 0. It MUST throw PortsInsufficientWaypointsError (PORTS.INSUFFICIENT_WAYPOINTS) when waypoints.length < 2.
Scenario: position exactly on the first waypoint yields zero progress
- WHEN
findPositionOnLane(lane.waypoints[0], lane)is invoked - THEN the result has
nearestWaypointIndex === 0,distanceTravelledNm === 0, andprogress === 0
Verified by: src/lane-progress/lane-progress.test.ts::"starting position yields zero progress"
Requirement: ETA estimation with injectable clock
estimateETA(currentCoord, lane, avgSpeedKnots?, opts?) MUST resolve the effective speed from (in order) the explicit override, lane.typicalSpeedKnots, lane.typicalSpeedKmh / 1.852. It MUST throw PortsInvalidSpeedError (PORTS.INVALID_SPEED) when the resolved speed is non-positive and PortsMissingSpeedError (PORTS.MISSING_SPEED) when no speed source resolves. etaDate MUST be computed as (opts.departureTime ?? new Date(opts.clock?.now() ?? Date.now())) + etaHours hours. etaHours MUST be rounded to two decimals. Confidence MUST be high | medium | low derived from deviation between travelled+remaining and total lane distance. The function MUST emit the OTel counter ports.eta_total{confidence} and histogram ports.eta_duration_ms.
Scenario: ETA on a sea lane uses lane default speed
- WHEN
estimateETA(lane.waypoints[0], lane)is invoked with a lane that hastypicalSpeedKnots: 14 - THEN the returned
etaHours > 0andconfidence === "high"
Verified by: src/eta/eta.test.ts::"estimates ETA using lane default speed"
Scenario: injectable clock controls departure time
- WHEN
estimateETA(coord, lane, 14, { clock: { now: () => 0 } })is invoked - THEN the returned
etaDate.getTime() === etaHours * 3_600_000
Verified by: src/eta/eta.test.ts::"injectable clock controls departure time"
Scenario: missing speed sources reject
- WHEN
estimateETA(coord, { ...lane, typicalSpeedKnots: undefined, typicalSpeedKmh: undefined })is invoked without an override - THEN a
PortsMissingSpeedErrorwith codePORTS.MISSING_SPEEDis thrown
Verified by: src/eta/eta.test.ts::"throws PORTS.MISSING_SPEED when no speed source resolves"
Requirement: Stable typed error registry
The package MUST export PORTS_CODES registered via defineCodes("PORTS", [...]) covering at least: NO_PORTS, INVALID_RADIUS, INSUFFICIENT_WAYPOINTS, INVALID_SPEED, MISSING_SPEED, PORT_NOT_FOUND, LANE_NOT_FOUND, INVALID_LANE. Every error thrown by a public function MUST be a PortsError subclass whose .code matches exactly one entry of the registry.
Scenario: every code has a registered subclass
- WHEN the suite enumerates
PORTS_CODES - THEN for each code, a constructable
PortsErrorsubclass exists whose default.codeequals that code
Verified by: src/error-codes/error-codes.test.ts::"every PORTS code has a registered subclass"
Requirement: Zod schemas for public types
The package MUST export Zod schemas (PortSchema, ShippingLaneSchema, SphericalCoordSchema, LaneProgressSchema, ETAEstimateSchema, NearestPortResultSchema) at ./schemas/schemas. Each schema MUST validate a known good fixture and reject a malformed input.
Scenario: PortSchema accepts a curated record
- WHEN
PortSchema.parse(seaPortsAsia[0])is invoked - THEN parsing succeeds without throwing
Verified by: src/schemas/schemas.test.ts::"PortSchema accepts a curated record"
Requirement: OpenTelemetry instrumentation surface
The package MUST register three counters (ports.lookup_total, ports.lane_lookup_total, ports.eta_total) and three histograms (ports.find_nearest_duration_ms, ports.lane_distance_duration_ms, ports.eta_duration_ms) against the meter @suaveplan/ports. refreshPortsMeters() MUST re-resolve every instrument against the current global MeterProvider.
Scenario: refreshPortsMeters rebinds after provider change
- WHEN
refreshPortsMeters()is invoked after setting a new globalMeterProvider - THEN subsequent counter/histogram emissions are observed by the new provider
Verified by: src/otel/otel.test.ts::"refreshPortsMeters rebinds after provider change"