Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/gateway/dtos/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ type SyncRequestType string

var SyncRequestTypes = struct {
RoutesV1 SyncRequestType
RoutesV2 SyncRequestType
DirectionsV1 SyncRequestType
StopsV1 SyncRequestType
AlertsV1 SyncRequestType
AlertDirectionsV1 SyncRequestType
TimetablesV1 SyncRequestType
}{
RoutesV1: "RoutesV1",
RoutesV2: "RoutesV2",
DirectionsV1: "DirectionsV1",
StopsV1: "StopsV1",
AlertsV1: "AlertsV1",
Expand All @@ -26,7 +28,9 @@ type SyncEntityType string

var SyncEntityTypes = struct {
RouteV1 SyncEntityType
RouteV2 SyncEntityType
RouteDeleteV1 SyncEntityType
RouteDeleteV2 SyncEntityType
DirectionV1 SyncEntityType
DirectionDeleteV1 SyncEntityType
StopV1 SyncEntityType
Expand All @@ -42,7 +46,9 @@ var SyncEntityTypes = struct {
SyncCompleteV1 SyncEntityType
}{
RouteV1: "RouteV1",
RouteV2: "RouteV2",
RouteDeleteV1: "RouteDeleteV1",
RouteDeleteV2: "RouteDeleteV2",
DirectionV1: "DirectionV1",
DirectionDeleteV1: "DirectionDeleteV1",
StopV1: "StopV1",
Expand Down
24 changes: 24 additions & 0 deletions apps/gateway/dtos/sync_payloads.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import (
// A payload never changes once shipped. A new field means a new version of the entity.
var SyncPayloads = map[SyncEntityType]any{
SyncEntityTypes.RouteV1: SyncRouteV1{},
SyncEntityTypes.RouteV2: SyncRouteV2{},
SyncEntityTypes.RouteDeleteV1: SyncRouteDeleteV1{},
SyncEntityTypes.RouteDeleteV2: SyncRouteDeleteV1{},
SyncEntityTypes.DirectionV1: SyncDirectionV1{},
SyncEntityTypes.DirectionDeleteV1: SyncDirectionDeleteV1{},
SyncEntityTypes.StopV1: SyncStopV1{},
Expand Down Expand Up @@ -52,6 +54,28 @@ func NewSyncRouteV1(r model.Route) SyncRouteV1 {
}
}

type SyncRouteV2 struct {
ID uuid.UUID `json:"id"`
ShortName string `json:"shortName"`
LongName string `json:"longName"`
LightColor string `json:"lightColor"`
DarkColor string `json:"darkColor"`
Active bool `json:"active"`
IsLoop bool `json:"isLoop"`
}

func NewSyncRouteV2(r model.Route) SyncRouteV2 {
return SyncRouteV2{
ID: r.ID,
ShortName: r.ShortName,
LongName: r.LongName,
LightColor: r.LightColor,
DarkColor: r.DarkColor,
Active: r.Active,
IsLoop: r.IsLoop,
}
}

type SyncRouteDeleteV1 struct {
RouteID uuid.UUID `json:"routeId"`
}
Expand Down
1 change: 1 addition & 0 deletions apps/gateway/services/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ type SyncStream struct {
// Parents come before children so a client can apply lines in the order they arrive.
var SyncStreams = []SyncStream{
NewSyncStream(dtos.SyncRequestTypes.RoutesV1, dtos.SyncEntityTypes.RouteV1, dtos.SyncEntityTypes.RouteDeleteV1, dtos.NewSyncRouteV1, dtos.NewSyncRouteDeleteV1),
NewSyncStream(dtos.SyncRequestTypes.RoutesV2, dtos.SyncEntityTypes.RouteV2, dtos.SyncEntityTypes.RouteDeleteV2, dtos.NewSyncRouteV2, dtos.NewSyncRouteDeleteV1),
NewSyncStream(dtos.SyncRequestTypes.DirectionsV1, dtos.SyncEntityTypes.DirectionV1, dtos.SyncEntityTypes.DirectionDeleteV1, dtos.NewSyncDirectionV1, dtos.NewSyncDirectionDeleteV1),
NewSyncStream(dtos.SyncRequestTypes.StopsV1, dtos.SyncEntityTypes.StopV1, dtos.SyncEntityTypes.StopDeleteV1, dtos.NewSyncStopV1, dtos.NewSyncStopDeleteV1),
NewSyncStream(dtos.SyncRequestTypes.AlertsV1, dtos.SyncEntityTypes.AlertV1, dtos.SyncEntityTypes.AlertDeleteV1, dtos.NewSyncAlertV1, dtos.NewSyncAlertDeleteV1),
Expand Down
21 changes: 9 additions & 12 deletions apps/gateway/services/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,22 +131,19 @@ var _ = Describe("SyncService.Plan", Label("unit"), func() {
})

When("a second version of a resource exists", func() {
BeforeEach(func() {
original := services.SyncStreams
routesV2 := services.NewSyncStream("RoutesV2", "RouteV2", "RouteDeleteV2", dtos.NewSyncRouteV1, dtos.NewSyncRouteDeleteV1)
services.SyncStreams = append(slices.Clone(original), routesV2)
DeferCleanup(func() { services.SyncStreams = original })
})

It("rejects both versions in one request", func() {
_, err := svc.Plan(dtos.SyncRequest{Protocol: dtos.SyncProtocolVersions.V1, Types: []dtos.SyncRequestType{requests.RoutesV1, "RoutesV2"}})
_, err := svc.Plan(dtos.SyncRequest{Protocol: dtos.SyncProtocolVersions.V1, Types: []dtos.SyncRequestType{requests.RoutesV1, requests.RoutesV2}})
Expect(err).To(MatchError(services.ErrInvalidSyncRequest))
})

It("accepts either version alone", func() {
_, err := svc.Plan(dtos.SyncRequest{Protocol: dtos.SyncProtocolVersions.V1, Types: []dtos.SyncRequestType{"RoutesV2"}})
Expect(err).NotTo(HaveOccurred())
})
DescribeTable("accepts either version alone",
func(request dtos.SyncRequestType) {
_, err := svc.Plan(dtos.SyncRequest{Protocol: dtos.SyncProtocolVersions.V1, Types: []dtos.SyncRequestType{request}})
Expect(err).NotTo(HaveOccurred())
},
Entry("RoutesV1", requests.RoutesV1),
Entry("RoutesV2", requests.RoutesV2),
)
})

DescribeTable("rejects bad requests",
Expand Down
4 changes: 2 additions & 2 deletions apps/requester/repositories/route_data.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,9 @@ func (r *RouteDataRepository) UpsertRoutes(ctx context.Context, routes []model.R
return upsertAll(ctx, r.db, routes,
func(route model.Route) string { return route.SourceID },
upsertSpec{
columns: []string{"sourceId", "shortName", "longName", "lightColor", "darkColor"},
columns: []string{"sourceId", "shortName", "longName", "lightColor", "darkColor", "isLoop"},
conflict: `CONFLICT ("shortName") DO UPDATE`,
set: `"sourceId" = EXCLUDED."sourceId", "longName" = EXCLUDED."longName", "lightColor" = EXCLUDED."lightColor", "darkColor" = EXCLUDED."darkColor", "active" = TRUE, "deactivatedAt" = NULL`,
set: `"sourceId" = EXCLUDED."sourceId", "longName" = EXCLUDED."longName", "lightColor" = EXCLUDED."lightColor", "darkColor" = EXCLUDED."darkColor", "isLoop" = EXCLUDED."isLoop", "active" = TRUE, "deactivatedAt" = NULL`,
})
}

Expand Down
22 changes: 20 additions & 2 deletions apps/requester/services/route_data.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package services

import (
"cmp"
"context"
"fmt"
"log/slog"
"slices"
"strconv"
"strings"

Expand All @@ -17,6 +19,11 @@ import (
"github.com/MaroonRides/api/internal/db/model"
)

// Game day routes run out to the stadium and back once, so their last stop does not lead into the first.
const gamedayRoutePrefix = "Gameday"

var gamePhases = []string{"pregame", "postgame"}

type RouteDataService struct {
api *busapi.Client
repo *repositories.RouteDataRepository
Expand Down Expand Up @@ -137,13 +144,14 @@ func routeRows(routes []busapi.MapRoute) []model.Route {
LongName: route.Name,
LightColor: strings.ToLower(lightColor),
DarkColor: strings.ToLower(darkColor),
IsLoop: !strings.HasPrefix(route.Name, gamedayRoutePrefix),
})
}

return out
}

// destination is the headsign and the order a direction is listed in, which
// destination is the headsign and the order a direction is shown in, which
// only the base data carries.
type destination struct {
text string
Expand All @@ -154,7 +162,12 @@ func destinations(routes []busapi.MapRoute) map[string]destination {
out := map[string]destination{}

for _, route := range routes {
for sequence, direction := range route.DirectionList {
listed := slices.Clone(route.DirectionList)
slices.SortStableFunc(listed, func(a, b busapi.MapDirectionList) int {
return cmp.Compare(gamePhase(a.Destination), gamePhase(b.Destination))
})

for sequence, direction := range listed {
out[direction.Direction.Key] = destination{
text: direction.Destination,
sequence: sequence,
Expand All @@ -165,6 +178,11 @@ func destinations(routes []busapi.MapRoute) map[string]destination {
return out
}

func gamePhase(destination string) int {
lower := strings.ToLower(destination)
return slices.IndexFunc(gamePhases, func(phase string) bool { return strings.Contains(lower, phase) })
}

func directionRows(paths []busapi.PatternPathsResponse, routes map[string]model.Route, headsigns map[string]destination) []model.Direction {
var out []model.Direction

Expand Down
41 changes: 41 additions & 0 deletions apps/requester/services/route_data_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,47 @@ var _ = Describe("stopRows", Label("unit"), func() {
})
})

var _ = Describe("routeRows", Label("unit"), func() {
It("marks game day routes as not looping", func() {
listed := []busapi.MapDirectionList{{LineColor: "#500000"}}

rows := routeRows([]busapi.MapRoute{
{Key: "bonfire", Name: "Bonfire", ShortName: "01", DirectionList: listed},
{Key: "gameday", Name: "Gameday Bonfire", ShortName: "BF", DirectionList: listed},
})

Expect(rows).To(HaveLen(2))
Expect(rows[0].IsLoop).To(BeTrue())
Expect(rows[1].IsLoop).To(BeFalse())
})
})

var _ = Describe("destinations", Label("unit"), func() {
It("puts pregame before postgame", func() {
got := destinations([]busapi.MapRoute{{DirectionList: []busapi.MapDirectionList{
{Direction: busapi.MapDirection{Key: "post"}, Destination: "1 Hour Postgame"},
{Direction: busapi.MapDirection{Key: "pre"}, Destination: "3 Hours Pregame"},
}}})

Expect(got).To(Equal(map[string]destination{
"pre": {text: "3 Hours Pregame", sequence: 0},
"post": {text: "1 Hour Postgame", sequence: 1},
}))
})

It("keeps the upstream order of other directions", func() {
got := destinations([]busapi.MapRoute{{DirectionList: []busapi.MapDirectionList{
{Direction: busapi.MapDirection{Key: "msc"}, Destination: "MSC"},
{Direction: busapi.MapDirection{Key: "commons"}, Destination: "Commons"},
}}})

Expect(got).To(Equal(map[string]destination{
"msc": {text: "MSC", sequence: 0},
"commons": {text: "Commons", sequence: 1},
}))
})
})

var _ = Describe("directionRows", Label("unit"), func() {
It("skips pattern paths for directions missing from base data", func() {
route := model.Route{ID: uuid.New(), SourceID: "03"}
Expand Down
1 change: 1 addition & 0 deletions docs/design/sync-stream.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ There are two enums, same as Immich.
| Request type | Upsert entity | Delete entity | Table |
| ------------------- | ------------------ | ------------------------ | ----------------- |
| `RoutesV1` | `RouteV1` | `RouteDeleteV1` | `route` |
| `RoutesV2` | `RouteV2` | `RouteDeleteV2` | `route` |
| `DirectionsV1` | `DirectionV1` | `DirectionDeleteV1` | `direction` |
| `StopsV1` | `StopV1` | `StopDeleteV1` | `stop` |
| `AlertsV1` | `AlertV1` | `AlertDeleteV1` | `alert` |
Expand Down
11 changes: 11 additions & 0 deletions internal/db/migrations/20261114195046_route_is_loop.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- +goose Up
-- modify "route" table
-- existing rows start as loops; the next route data sync marks the game day routes
ALTER TABLE "route" ADD COLUMN "isLoop" boolean NOT NULL DEFAULT true;
ALTER TABLE "route" ALTER COLUMN "isLoop" DROP DEFAULT;

-- +goose Down
-- the bump trigger compares isLoop; reconcile recreates it after migrating
DROP TRIGGER IF EXISTS route_sync_bump ON "route";
-- reverse: modify "route" table
ALTER TABLE "route" DROP COLUMN "isLoop";
3 changes: 2 additions & 1 deletion internal/db/migrations/atlas.sum
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
h1:EJLq2XfT9ptUxtMmZHdrvlC/S1h5oUGiCYiv946O4bE=
h1:oI+ueGWwNfo+sZERLbNmteLtcZOujaRFPPEPKW1PE90=
20260927192657_initial_tables.sql h1:MvX5nd5hspGOKgoPp5crmIjsXj++Bik6/7p5g2deFqo=
20260927202630_passenger_count.sql h1:wrAWgyra+WZe0q0DxXT8c8K87Jgsbgtp3SnrFX+/hcQ=
20260927222835_stop_schedule.sql h1:9CXXfeiycy5z/XG2sjzhHGuhBfvADJy2oovAiWuE1aQ=
Expand All @@ -11,3 +11,4 @@ h1:EJLq2XfT9ptUxtMmZHdrvlC/S1h5oUGiCYiv946O4bE=
20261001001300_drop_timetable_scope.sql h1:+EKbylBAop6KE4L/gjwq7erjwWtkqOixMjJbZdITbSw=
20261001164832_direction_stop_timepoint_default.sql h1:DLlPM/Qb60yBstp74J+TPnZGVJRfnNGXAyHiLhItMzg=
20261002042012_stops_per_direction.sql h1:uF7e1xHYwpgkecuaMxuoFVLj+u/1NhHejwlzLddM+AA=
20261114195046_route_is_loop.sql h1:Ahi44xWzIVdbm8d5PbT5Wp8JB7WuDOuTDzHJKSRzlkk=
1 change: 1 addition & 0 deletions internal/db/model/route.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type Route struct {
LongName string `bun:"longName,notnull"`
LightColor string `bun:"lightColor,notnull"`
DarkColor string `bun:"darkColor,notnull"`
IsLoop bool `bun:"isLoop,notnull"`
Active bool `bun:"active,notnull,default:true"`
DeactivatedAt *time.Time `bun:"deactivatedAt" sync:"nosync"`

Expand Down
Loading
Loading