Initial Commit
This commit is contained in:
151
openspec/archive/2026-05-25-enterprise-ports/design.md
Normal file
151
openspec/archive/2026-05-25-enterprise-ports/design.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Design: enterprise-ports
|
||||
|
||||
## Module layout
|
||||
|
||||
```
|
||||
packages/geospatial/logistics/ports/
|
||||
package.json
|
||||
tsconfig.lib.json
|
||||
vite.config.ts
|
||||
vitest.config.ts
|
||||
README.md
|
||||
src/
|
||||
index.ts
|
||||
constants/index.ts # EARTH_RADIUS_NM, DEFAULT_SPEED_KNOTS, VESSEL_CLASS_SPEEDS
|
||||
types/
|
||||
port.types.ts
|
||||
port.types.md
|
||||
error-codes/
|
||||
error-codes.ts
|
||||
error-codes.md
|
||||
error-codes.test.ts
|
||||
index.ts
|
||||
schemas/
|
||||
schemas.ts
|
||||
schemas.md
|
||||
schemas.test.ts
|
||||
index.ts
|
||||
otel/
|
||||
otel.ts
|
||||
otel.md
|
||||
otel.test.ts
|
||||
index.ts
|
||||
data/
|
||||
sea-ports-asia/
|
||||
sea-ports-europe/
|
||||
sea-ports-americas/
|
||||
sea-ports-africa-mena/
|
||||
sea-ports-oceania/
|
||||
sea-ports/
|
||||
airports/
|
||||
rail-terminals/
|
||||
all-ports/
|
||||
shipping-lanes/
|
||||
distance/
|
||||
distance.ts # great-circle wrapper, nm/km conversions
|
||||
distance.md
|
||||
distance.test.ts
|
||||
index.ts
|
||||
find-port/
|
||||
find-port.ts
|
||||
find-port.md
|
||||
find-port.test.ts
|
||||
index.ts
|
||||
nearest-port/
|
||||
nearest-port.ts
|
||||
nearest-port.md
|
||||
nearest-port.test.ts
|
||||
index.ts
|
||||
spatial-index/
|
||||
spatial-index.ts
|
||||
spatial-index.md
|
||||
spatial-index.test.ts
|
||||
index.ts
|
||||
lane-lookup/
|
||||
lane-lookup.ts
|
||||
lane-lookup.md
|
||||
lane-lookup.test.ts
|
||||
index.ts
|
||||
lane-distance/
|
||||
lane-distance.ts
|
||||
lane-distance.md
|
||||
lane-distance.test.ts
|
||||
index.ts
|
||||
lane-progress/
|
||||
lane-progress.ts
|
||||
lane-progress.md
|
||||
lane-progress.test.ts
|
||||
index.ts
|
||||
eta/
|
||||
eta.ts
|
||||
eta.md
|
||||
eta.test.ts
|
||||
index.ts
|
||||
property.test.ts
|
||||
```
|
||||
|
||||
## Distance helper
|
||||
|
||||
`distanceNm(a, b)` and `distanceKm(a, b)` wrap
|
||||
`@suaveplan/geo`'s `greatCircleDistance` against a WGS84-radius
|
||||
spherical earth (semi-major axis 6_371_000 m) via `defineEllipsoid`. The
|
||||
genesis `greatCircleDistance` returns metres by default; this package
|
||||
converts via the geo `convertDistance` utility. We bind a single
|
||||
`EARTH_SPHERE` ellipsoid once at module load — no allocation per call.
|
||||
|
||||
## Error mapping
|
||||
|
||||
Each main-branch `new PortError("msg", "CODE")` call maps to a typed
|
||||
subclass:
|
||||
|
||||
| main code | genesis class |
|
||||
|---|---|
|
||||
| `NO_PORTS` | `PortsNoPortsError` |
|
||||
| `INVALID_RADIUS` | `PortsInvalidRadiusError` |
|
||||
| `INSUFFICIENT_WAYPOINTS` | `PortsInsufficientWaypointsError` |
|
||||
| `INVALID_SPEED` | `PortsInvalidSpeedError` |
|
||||
| `MISSING_SPEED` | `PortsMissingSpeedError` |
|
||||
| _(new)_ | `PortsPortNotFoundError` — strict variant of `findPort` |
|
||||
| _(new)_ | `PortsLaneNotFoundError` — strict variant of `getShippingLane` |
|
||||
| _(new)_ | `PortsInvalidLaneError` — Zod schema rejected |
|
||||
|
||||
All extend `PortsError extends SuaveplanError<PortsCode>` with i18n
|
||||
message keys.
|
||||
|
||||
## Spatial index
|
||||
|
||||
`PortSpatialIndex` keys ports by `floor(latitude / 5)` and
|
||||
`floor(longitude / 5)` into a `Map<string, Port[]>`. Query for a
|
||||
coordinate fetches the 3×3 cell neighbourhood and runs exact great-circle
|
||||
distance on the candidate set. For ~120 curated ports this is overkill in
|
||||
absolute terms but keeps the public API stable as the dataset grows.
|
||||
|
||||
## OTel instruments
|
||||
|
||||
Module-scope meter `@suaveplan/ports@0.1.0`:
|
||||
|
||||
- Counter `ports.lookup_total` — labels `kind=findPort|byCountry|byType|search`.
|
||||
- Counter `ports.lane_lookup_total` — labels `mode=sea|rail|air|all`.
|
||||
- Counter `ports.eta_total` — labels `confidence=high|medium|low`.
|
||||
- Histogram `ports.find_nearest_duration_ms`.
|
||||
- Histogram `ports.lane_distance_duration_ms`.
|
||||
- Histogram `ports.eta_duration_ms`.
|
||||
|
||||
`refreshPortsMeters()` re-resolves every instrument against the current
|
||||
global `MeterProvider` after tests wire in-memory exporters.
|
||||
|
||||
## Clock injection
|
||||
|
||||
`estimateETA(coord, lane, speedKnotsOverride?, opts?)`:
|
||||
`opts = { departureTime?: Date; clock?: Clock }`. If `departureTime` is
|
||||
omitted and `clock` is supplied → `new Date(clock.now())`; else `new
|
||||
Date()`. Tests pass a `FakeClock` from `@suaveplan/testing` for
|
||||
determinism.
|
||||
|
||||
## Data shape stability
|
||||
|
||||
The migrated dataset preserves every record from the main branch
|
||||
(coordinates, throughputs, vessel limits, IATA/UIC codes) — adding any
|
||||
new fields would break consumer expectations. The data is split into
|
||||
per-region files exactly mirroring the main layout so future curated
|
||||
updates can be diffed cleanly.
|
||||
105
openspec/archive/2026-05-25-enterprise-ports/proposal.md
Normal file
105
openspec/archive/2026-05-25-enterprise-ports/proposal.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# OpenSpec Change: enterprise-ports
|
||||
|
||||
- **Change ID**: enterprise-ports
|
||||
- **Package**: `@suaveplan/ports`
|
||||
- **Category / plugin**: `geospatial` / `logistics`
|
||||
- **Target path**: `packages/geospatial/logistics/ports/`
|
||||
- **Status**: PROPOSED
|
||||
- **Date**: 2026-05-25
|
||||
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/ports.md`
|
||||
- **Main-branch source**: `/mnt/s/web/suaveplan/packages/universal/ports`
|
||||
|
||||
---
|
||||
|
||||
## Why
|
||||
|
||||
`@suaveplan/ports` is the canonical universal-tier global port database
|
||||
on the main branch. It pairs a curated dataset of sea ports, airports,
|
||||
and rail terminals (UN/LOCODE addressed) with shipping-lane waypoint
|
||||
graphs and ETA estimation. The genesis branch starts empty; landing this
|
||||
package unblocks every logistics/shipping consumer (`port-tracking`,
|
||||
`logistics`, `customs`, `procurement`, `warehouse-ops`, and the matching
|
||||
`api-plugin-*` packages) which all transitively depend on port lookups
|
||||
and lane-based routing.
|
||||
|
||||
The reference implementation on main shipped at 95%+ coverage but
|
||||
without the cross-cutting concerns mandated by the enterprise checklist
|
||||
in `enterprise/ports.md`: stable typed error codes via
|
||||
`@suaveplan/error`, Zod schemas at the public boundary, OpenTelemetry
|
||||
instrumentation, injectable `Clock`, and 100% test coverage. This change
|
||||
rebuilds the package from scratch on genesis with those additions while
|
||||
preserving the full main-branch public surface so direct consumers do
|
||||
not need migration shims.
|
||||
|
||||
## What changes
|
||||
|
||||
### Preserved (from main-branch surface)
|
||||
|
||||
- Curated port dataset split by region: `seaPortsAsia`, `seaPortsEurope`,
|
||||
`seaPortsAmericas`, `seaPortsAfricaMena`, `seaPortsOceania`, aggregated
|
||||
into `seaPorts`; plus `airports` and `railTerminals`. `allPorts`
|
||||
combines every category.
|
||||
- Curated `shippingLanes` waypoint graph spanning the major global routes
|
||||
(Asia–Europe via Suez, Asia–Europe via Cape, Transpacific, Transatlantic,
|
||||
rail and air lanes).
|
||||
- Port lookups: `findPort(locode)`, `findPortsByCountry(country, ports?)`,
|
||||
`findPortsByType(type, ports?)`, `searchPorts(query, ports?)`.
|
||||
- Spatial lookups: `findNearestPort(coord, ports?)`,
|
||||
`findPortsInRadius(coord, radiusKm, ports?)`.
|
||||
- Lane lookups: `getShippingLane(id)`, `getShippingLanesByMode(mode, lanes?)`,
|
||||
`getAllShippingLanes()`.
|
||||
- Lane distance: `estimateLaneDistance(lane)`, `cumulativeDistances(lane)`
|
||||
(nautical miles, great-circle).
|
||||
- Lane progress: `findPositionOnLane(coord, lane)` →
|
||||
`{ progress, remainingDistanceNm, distanceTravelledNm, nearestWaypointIndex }`.
|
||||
- ETA: `estimateETA(currentCoord, lane, avgSpeedKnots?, departureTime?)` →
|
||||
`{ remainingDistanceNm, etaHours, etaDate, confidence }`.
|
||||
- Types: `Port`, `PortType`, `PortFacility`, `LaneMode`, `ShippingLane`,
|
||||
`LaneProgress`, `ETAEstimate`, `NearestPortResult`.
|
||||
|
||||
### Added (genesis enterprise additions)
|
||||
|
||||
1. **Typed error registry** — `PORTS_CODES` via `defineCodes("PORTS",
|
||||
["NO_PORTS", "INVALID_RADIUS", "INSUFFICIENT_WAYPOINTS",
|
||||
"INVALID_SPEED", "MISSING_SPEED", "INVALID_LANE", "PORT_NOT_FOUND",
|
||||
"LANE_NOT_FOUND"])`. Each code surfaces a dedicated
|
||||
`PortsError` subclass extending `SuaveplanError`.
|
||||
2. **Zod schemas** for `Port`, `ShippingLane`, `SphericalCoord`,
|
||||
`LaneProgress`, `ETAEstimate`, `NearestPortResult` exposed under
|
||||
`./schemas/schemas` for boundary validation by API consumers.
|
||||
3. **Injectable `Clock`** — `estimateETA(coord, lane, speed?, opts?)`
|
||||
now accepts `{ departureTime?, clock? }` so callers (jobs,
|
||||
simulation, tests) can supply a deterministic clock. `Date.now()` is
|
||||
never read at module scope.
|
||||
4. **Vessel-class speed defaults** — `VESSEL_CLASS_SPEEDS` constants for
|
||||
`ulcv`, `panamax`, `feeder`, `coastal`, `freighter-air`, `rail-freight`
|
||||
used as fallbacks when neither the lane nor caller specifies a speed.
|
||||
5. **OTel instrumentation** — counters `ports.lookup_total{kind}`,
|
||||
`ports.lane_lookup_total{mode}`, `ports.eta_total{confidence}`; histograms
|
||||
`ports.find_nearest_duration_ms`, `ports.lane_distance_duration_ms`,
|
||||
`ports.eta_duration_ms`. `refreshPortsMeters()` for test isolation.
|
||||
6. **Spatial index** — `PortSpatialIndex` bucketed by 5-degree lat/lng
|
||||
cells, providing O(neighbours) `findNearest`/`findInRadius` instead of
|
||||
scanning all ~120 ports. Used internally by the public functions.
|
||||
7. **Property-based tests** — fast-check invariants for ETA monotonicity
|
||||
(speed↑ ⇒ ETA earlier), distance triangle inequality on great-circle
|
||||
segments, and radius-containment consistency (`findPortsInRadius`
|
||||
never returns a port whose haversine distance exceeds `radiusKm`).
|
||||
|
||||
### Removed
|
||||
|
||||
Nothing. The previous proposal's `PortDatabase` class and JSON-backed
|
||||
"6000-port" dataset are not part of the main-branch surface and were
|
||||
aspirational; this change ships the actual surface main shipped.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Affected specs**: new `ports` capability spec.
|
||||
- **Affected code**: new package at `packages/geospatial/logistics/ports/`.
|
||||
- **Downstream**: unblocks `port-tracking`, `logistics`, `customs`,
|
||||
`procurement`, `warehouse-ops`, and their `api-plugin-*` companions.
|
||||
- **Manifest**: `MANIFEST.md` regenerated; `STATUS.md` flips `–` → `✅`.
|
||||
- **Category mapping**: adds plugin `logistics` to super-category
|
||||
`geospatial` in `scripts/folder-reorg/assignment.yml` (`logistics` was
|
||||
previously a plugin without a category assignment). The 18 closed
|
||||
super-categories are unchanged.
|
||||
186
openspec/archive/2026-05-25-enterprise-ports/specs/ports/spec.md
Normal file
186
openspec/archive/2026-05-25-enterprise-ports/specs/ports/spec.md
Normal file
@@ -0,0 +1,186 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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"`, and `type === "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 `PortsPortNotFoundError` with code `PORTS.PORT_NOT_FOUND` is 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"` and `distanceKm < 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 `PortsNoPortsError` with code `PORTS.NO_PORTS` is 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 `PortsInvalidRadiusError` is thrown with code `PORTS.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 `PortsInsufficientWaypointsError` is 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]` and `d[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`, and `progress === 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 has `typicalSpeedKnots: 14`
|
||||
- **THEN** the returned `etaHours > 0` and `confidence === "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 `PortsMissingSpeedError` with code `PORTS.MISSING_SPEED` is 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 `PortsError` subclass exists whose default `.code` equals 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 global `MeterProvider`
|
||||
- **THEN** subsequent counter/histogram emissions are observed by the new provider
|
||||
|
||||
**Verified by:** `src/otel/otel.test.ts::"refreshPortsMeters rebinds after provider change"`
|
||||
62
openspec/archive/2026-05-25-enterprise-ports/tasks.md
Normal file
62
openspec/archive/2026-05-25-enterprise-ports/tasks.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Tasks: enterprise-ports
|
||||
|
||||
**Change ID**: enterprise-ports
|
||||
**Package**: `@suaveplan/ports`
|
||||
**Target path**: `packages/geospatial/logistics/ports/`
|
||||
**Prerequisite packages**: `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/geo`, `@suaveplan/testing`
|
||||
|
||||
## 0. Pre-flight
|
||||
|
||||
- [x] 0.1 Worktree audit (`git worktree list`, `git status`) clean.
|
||||
- [x] 0.2 §0a Sync — fast-forward to `genesis` HEAD (`ffe254a1`).
|
||||
- [x] 0.3 Confirm `logistics` plugin added to `geospatial` super-category in `scripts/folder-reorg/assignment.yml`.
|
||||
|
||||
## 1. Scaffold
|
||||
|
||||
- [x] 1.1 Create `packages/geospatial/logistics/ports/` with `src/` tree per `design.md`.
|
||||
- [x] 1.2 Write `package.json` at `0.1.0` with `"type":"module"`, `"sideEffects":false`, subpath exports, deps `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/geo`, `@opentelemetry/api`, `zod`, `tslib`.
|
||||
- [x] 1.3 `tsconfig.lib.json` extending `../../../../tsconfig.base.json`.
|
||||
- [x] 1.4 `vite.config.ts` with multi-entry library build.
|
||||
- [x] 1.5 `vitest.config.ts` via `createTestConfig` with 100% coverage thresholds.
|
||||
- [x] 1.6 Scripts: `build`, `lint`, `test`, `test:coverage`, `typecheck`.
|
||||
|
||||
## 2. Types + errors + schemas + OTel + constants
|
||||
|
||||
- [x] 2.1 `src/types/port.types.ts` — `Port`, `PortType`, `PortFacility`, `LaneMode`, `ShippingLane`, `LaneProgress`, `ETAEstimate`, `NearestPortResult`, `SphericalCoord` (re-exported from geo).
|
||||
- [x] 2.2 `src/error-codes/error-codes.ts` — `PORTS_CODES` (8 codes) + 8 `PortsError` subclasses.
|
||||
- [x] 2.3 `src/schemas/schemas.ts` — Zod schemas for every public type.
|
||||
- [x] 2.4 `src/otel/otel.ts` — 3 counters + 3 histograms + `refreshPortsMeters`.
|
||||
- [x] 2.5 `src/constants/index.ts` — `EARTH_RADIUS_NM`, `DEFAULT_SPEED_KNOTS`, `VESSEL_CLASS_SPEEDS`, `EARTH_SPHERE` ellipsoid.
|
||||
|
||||
## 3. Data
|
||||
|
||||
- [x] 3.1 Per-region sea-port files (`sea-ports-asia`, `-europe`, `-americas`, `-africa-mena`, `-oceania`).
|
||||
- [x] 3.2 `sea-ports/sea-ports.ts` aggregating all regions.
|
||||
- [x] 3.3 `airports/airports.ts`, `rail-terminals/rail-terminals.ts`.
|
||||
- [x] 3.4 `all-ports/all-ports.ts` combining sea + air + rail.
|
||||
- [x] 3.5 `shipping-lanes/shipping-lanes.ts` with curated lanes.
|
||||
|
||||
## 4. Modules
|
||||
|
||||
- [x] 4.1 `distance` — `distanceNm`, `distanceKm` wrapping geo great-circle.
|
||||
- [x] 4.2 `find-port` — `findPort`, `findPortStrict`, `findPortsByCountry`, `findPortsByType`, `searchPorts`.
|
||||
- [x] 4.3 `spatial-index` — `PortSpatialIndex` with 5° bucketing.
|
||||
- [x] 4.4 `nearest-port` — `findNearestPort`, `findPortsInRadius` using spatial index.
|
||||
- [x] 4.5 `lane-lookup` — `getShippingLane`, `getShippingLaneStrict`, `getShippingLanesByMode`, `getAllShippingLanes`.
|
||||
- [x] 4.6 `lane-distance` — `estimateLaneDistance`, `cumulativeDistances`.
|
||||
- [x] 4.7 `lane-progress` — `findPositionOnLane`.
|
||||
- [x] 4.8 `eta` — `estimateETA` with `{departureTime?, clock?}` opts.
|
||||
|
||||
## 5. Verify
|
||||
|
||||
- [x] 5.1 100% coverage on every module (Istanbul, branches/functions/lines/statements).
|
||||
- [x] 5.2 `bunx turbo lint typecheck test build --filter=@suaveplan/ports` green.
|
||||
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/ports` green at 100/100/100/100.
|
||||
- [x] 5.4 Co-located `.md` (≥200 words, 6 sections) for every non-barrel `.ts`.
|
||||
- [x] 5.5 Root `README.md`.
|
||||
- [x] 5.6 Refresh `MANIFEST.md`; flip `STATUS.md`.
|
||||
|
||||
## 6. Archive
|
||||
|
||||
- [x] 6.1 Fold deltas into `openspec/specs/ports/spec.md` (drop `**Verified by:**` lines).
|
||||
- [x] 6.2 Move folder to `openspec/archive/2026-05-25-enterprise-ports/`.
|
||||
Reference in New Issue
Block a user