From c1ef860c4cad76093d3662e9fbaa5d3a5ffd4202 Mon Sep 17 00:00:00 2001 From: bwees Date: Sat, 14 Nov 2026 13:59:38 -0600 Subject: [PATCH] feat(route): stop wrapping the last stop on gameday routes --- src/lib/api/openapi.d.ts | 34 ++ src/lib/data/app.ts | 1 + src/lib/data/types.ts | 1 + src/lib/db/migrations/0001_route_is_loop.sql | 2 + src/lib/db/migrations/meta/0001_snapshot.json | 382 ++++++++++++++++++ src/lib/db/migrations/meta/_journal.json | 7 + src/lib/db/schema.ts | 1 + src/lib/managers/sync.manager.svelte.ts | 3 + src/lib/managers/sync.manager.test.ts | 37 +- src/lib/sync/sync-stream.test.ts | 46 ++- src/lib/sync/sync-stream.ts | 4 +- src/routes/route/[id]/+page.svelte | 6 +- 12 files changed, 508 insertions(+), 16 deletions(-) create mode 100644 src/lib/db/migrations/0001_route_is_loop.sql create mode 100644 src/lib/db/migrations/meta/0001_snapshot.json diff --git a/src/lib/api/openapi.d.ts b/src/lib/api/openapi.d.ts index fe8c9ae1..ab80b131 100644 --- a/src/lib/api/openapi.d.ts +++ b/src/lib/api/openapi.d.ts @@ -219,6 +219,15 @@ export interface components { */ type: 'RouteDeleteV1'; }; + RouteDeleteV2Line: { + ack: string; + data: components['schemas']['SyncRouteDeleteV1']; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: 'RouteDeleteV2'; + }; RouteV1Line: { ack: string; data: components['schemas']['SyncRouteV1']; @@ -228,6 +237,15 @@ export interface components { */ type: 'RouteV1'; }; + RouteV2Line: { + ack: string; + data: components['schemas']['SyncRouteV2']; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: 'RouteV2'; + }; StopDeleteV1Line: { ack: string; data: components['schemas']['SyncStopDeleteV1']; @@ -316,7 +334,9 @@ export interface components { */ SyncEntityType: | 'RouteV1' + | 'RouteV2' | 'RouteDeleteV1' + | 'RouteDeleteV2' | 'DirectionV1' | 'DirectionDeleteV1' | 'StopV1' @@ -383,6 +403,7 @@ export interface components { /** @enum {string} */ SyncRequestType: | 'RoutesV1' + | 'RoutesV2' | 'DirectionsV1' | 'StopsV1' | 'AlertsV1' @@ -414,6 +435,17 @@ export interface components { longName: string; shortName: string; }; + /** @description SyncRouteV2 schema */ + SyncRouteV2: { + active: boolean; + darkColor: string; + /** Format: uuid */ + id: string; + isLoop: boolean; + lightColor: string; + longName: string; + shortName: string; + }; /** @description SyncStopDeleteV1 schema */ SyncStopDeleteV1: { /** Format: uuid */ @@ -436,7 +468,9 @@ export interface components { }; SyncStreamLine: | components['schemas']['RouteV1Line'] + | components['schemas']['RouteV2Line'] | components['schemas']['RouteDeleteV1Line'] + | components['schemas']['RouteDeleteV2Line'] | components['schemas']['DirectionV1Line'] | components['schemas']['DirectionDeleteV1Line'] | components['schemas']['StopV1Line'] diff --git a/src/lib/data/app.ts b/src/lib/data/app.ts index 3405935d..4b972e65 100644 --- a/src/lib/data/app.ts +++ b/src/lib/data/app.ts @@ -81,6 +81,7 @@ function toRoute(row: RouteRow): Route { darkColor: row.darkColor, directions, bounds: findBoundingBox(directions.flatMap((d) => d.pathPoints)), + isLoop: row.isLoop, }; } diff --git a/src/lib/data/types.ts b/src/lib/data/types.ts index a473a681..13f663f0 100644 --- a/src/lib/data/types.ts +++ b/src/lib/data/types.ts @@ -63,6 +63,7 @@ export interface Route { darkColor: string; directions: Direction[]; bounds: Location[]; + isLoop: boolean; } export interface Direction { diff --git a/src/lib/db/migrations/0001_route_is_loop.sql b/src/lib/db/migrations/0001_route_is_loop.sql new file mode 100644 index 00000000..2ab23d27 --- /dev/null +++ b/src/lib/db/migrations/0001_route_is_loop.sql @@ -0,0 +1,2 @@ +ALTER TABLE `route` ADD `is_loop` integer DEFAULT true NOT NULL;--> statement-breakpoint +DELETE FROM `sync_ack`; diff --git a/src/lib/db/migrations/meta/0001_snapshot.json b/src/lib/db/migrations/meta/0001_snapshot.json new file mode 100644 index 00000000..882b37ac --- /dev/null +++ b/src/lib/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,382 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f225e962-55d7-478f-ab27-470aa2832a8a", + "prevId": "866d4740-22cf-47f1-b6df-4174a0c9def6", + "tables": { + "alert": { + "name": "alert", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_range_text": { + "name": "time_range_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "daily_start_time": { + "name": "daily_start_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "daily_end_time": { + "name": "daily_end_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "starts_at": { + "name": "starts_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ends_at": { + "name": "ends_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "alert_direction": { + "name": "alert_direction", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "alert_id": { + "name": "alert_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "direction_id": { + "name": "direction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "alert_direction_direction_id_idx": { + "name": "alert_direction_direction_id_idx", + "columns": [ + "direction_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "direction": { + "name": "direction", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "route_id": { + "name": "route_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "direction_route_id_idx": { + "name": "direction_route_id_idx", + "columns": [ + "route_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "route": { + "name": "route", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "short_name": { + "name": "short_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "long_name": { + "name": "long_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "light_color": { + "name": "light_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dark_color": { + "name": "dark_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_loop": { + "name": "is_loop", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stop": { + "name": "stop", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "direction_id": { + "name": "direction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lat": { + "name": "lat", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lon": { + "name": "lon", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amenities": { + "name": "amenities", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_timepoint": { + "name": "is_timepoint", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "stop_direction_id_idx": { + "name": "stop_direction_id_idx", + "columns": [ + "direction_id", + "sequence" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_ack": { + "name": "sync_ack", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ack": { + "name": "ack", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "timetable": { + "name": "timetable", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "stop_id": { + "name": "stop_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_date": { + "name": "service_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "departures": { + "name": "departures", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "timetable_slot_idx": { + "name": "timetable_slot_idx", + "columns": [ + "stop_id", + "service_date" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/lib/db/migrations/meta/_journal.json b/src/lib/db/migrations/meta/_journal.json index ce34a295..263a566f 100644 --- a/src/lib/db/migrations/meta/_journal.json +++ b/src/lib/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1790915145562, "tag": "0000_initial_tables", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1794686140380, + "tag": "0001_route_is_loop", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 748fe22a..35329da1 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -8,6 +8,7 @@ export const route = sqliteTable('route', { lightColor: text('light_color').notNull(), darkColor: text('dark_color').notNull(), active: integer('active', { mode: 'boolean' }).notNull(), + isLoop: integer('is_loop', { mode: 'boolean' }).notNull().default(true), }); export const direction = sqliteTable( diff --git a/src/lib/managers/sync.manager.svelte.ts b/src/lib/managers/sync.manager.svelte.ts index 6881cd9a..00ab2138 100644 --- a/src/lib/managers/sync.manager.svelte.ts +++ b/src/lib/managers/sync.manager.svelte.ts @@ -145,6 +145,9 @@ class SyncManager { async #runSync() { const acks = (await db.select({ ack: syncAck.ack }).from(syncAck)).map(({ ack }) => ack); + // Without acks the server sends no tombstones, so any rows already stored can't be trusted. + if (acks.length === 0) return this.#fullSync(); + const lines = await this.#reporter.watch(acks, fetchLines(acks)); if (isResetStream(lines)) return this.#fullSync(); diff --git a/src/lib/managers/sync.manager.test.ts b/src/lib/managers/sync.manager.test.ts index f383dc3c..4997251f 100644 --- a/src/lib/managers/sync.manager.test.ts +++ b/src/lib/managers/sync.manager.test.ts @@ -3,6 +3,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ listeners: new Map void>(), syncStream: vi.fn(), + acks: [] as string[], + planSync: vi.fn(() => []), + planFullSync: vi.fn(() => []), })); vi.mock('@capacitor/app', () => ({ @@ -21,11 +24,14 @@ vi.mock('$lib/sync/sync-stream', () => ({ SYNC_PROTOCOL_V1: 1, SYNC_REQUEST_TYPES: [], isResetStream: () => false, - planSync: () => [], - planFullSync: () => [], + planSync: mocks.planSync, + planFullSync: mocks.planFullSync, })); vi.mock('$lib/db/database', () => { - const query = { where: () => Promise.resolve(), from: () => Promise.resolve([]) }; + const query = { + where: () => Promise.resolve(), + from: () => Promise.resolve(mocks.acks.map((ack) => ({ ack }))), + }; return { db: { delete: () => query, @@ -61,6 +67,9 @@ beforeEach(() => { vi.spyOn(Math, 'random').mockReturnValue(1); mocks.listeners.clear(); mocks.syncStream.mockReset().mockResolvedValue([]); + mocks.acks = []; + mocks.planSync.mockClear(); + mocks.planFullSync.mockClear(); }); afterEach(() => { @@ -69,6 +78,28 @@ afterEach(() => { }); describe('SyncManager', () => { + it('replaces local data when it holds no acks', async () => { + const syncManager = await startedSyncManager(); + mocks.planFullSync.mockClear(); + await syncManager.sync(); + + expect(mocks.syncStream).toHaveBeenLastCalledWith(expect.objectContaining({ acks: [] })); + expect(mocks.planFullSync).toHaveBeenCalledOnce(); + expect(mocks.planSync).not.toHaveBeenCalled(); + }); + + it('applies changes on top of local data when it holds acks', async () => { + mocks.acks = ['RouteV2|01990000-0000-7000-8000-0000000000aa']; + const syncManager = await startedSyncManager(); + await syncManager.sync(); + + expect(mocks.syncStream).toHaveBeenLastCalledWith( + expect.objectContaining({ acks: mocks.acks }), + ); + expect(mocks.planSync).toHaveBeenCalled(); + expect(mocks.planFullSync).not.toHaveBeenCalled(); + }); + it('retries a failed sync in the foreground', async () => { const syncManager = await startedSyncManager(); mocks.syncStream.mockRejectedValueOnce(new Error('offline')); diff --git a/src/lib/sync/sync-stream.test.ts b/src/lib/sync/sync-stream.test.ts index ff941c37..1bd9c13e 100644 --- a/src/lib/sync/sync-stream.test.ts +++ b/src/lib/sync/sync-stream.test.ts @@ -28,9 +28,9 @@ function rows(sql: string) { return sqlite.exec(sql)[0]?.values ?? []; } -const routeLine = (longName: string, ack: string): SyncStreamLine => ({ - type: 'RouteV1', - ack: `RouteV1|${ack}`, +const routeLine = (longName: string, ack: string, isLoop = true): SyncStreamLine => ({ + type: 'RouteV2', + ack: `RouteV2|${ack}`, data: { id: ROUTE_ID, shortName: '01', @@ -38,18 +38,46 @@ const routeLine = (longName: string, ack: string): SyncStreamLine => ({ lightColor: '#500000', darkColor: '#ff5555', active: true, + isLoop, }, }); +function migrate(files: string[]) { + for (const file of files) { + migrationFiles[file].split(MIGRATION_BREAKPOINT).forEach((statement) => sqlite.run(statement)); + } +} + beforeEach(async () => { const SQL = await initSqlJs(); sqlite = new SQL.Database(); - for (const file of Object.keys(migrationFiles).sort()) { - migrationFiles[file].split(MIGRATION_BREAKPOINT).forEach((statement) => sqlite.run(statement)); - } +}); + +describe('route loop migration', () => { + it('keeps stored routes as loops and drops every ack so the next sync replaces them', () => { + const [initial, routeIsLoop] = Object.keys(migrationFiles).sort(); + migrate([initial]); + sqlite.run( + `INSERT INTO route VALUES ('${ROUTE_ID}', '01', 'Bonfire', '#500000', '#ff5555', 1)`, + ); + sqlite.run(`INSERT INTO sync_ack VALUES ('RouteV1', 'RouteV1|${ACK_ID}')`); + + migrate([routeIsLoop]); + + expect(rows('SELECT id, is_loop FROM route')).toEqual([[ROUTE_ID, 1]]); + expect(rows('SELECT key FROM sync_ack')).toEqual([]); + }); }); describe('planSync', () => { + beforeEach(() => migrate(Object.keys(migrationFiles).sort())); + + it('stores whether a route loops', () => { + apply(planSync([routeLine('Gameday Bonfire', ACK_ID, false)])); + + expect(rows('SELECT is_loop FROM route')).toEqual([[0]]); + }); + it('stores rows as the server sends them, with null lists as empty', () => { apply( planSync([ @@ -83,7 +111,7 @@ describe('planSync', () => { apply(planSync([routeLine('Bonfire', ACK_ID), routeLine('Bonfire Express', newerAck)])); expect(rows('SELECT long_name FROM route')).toEqual([['Bonfire Express']]); - expect(rows('SELECT key, ack FROM sync_ack')).toEqual([['RouteV1', `RouteV1|${newerAck}`]]); + expect(rows('SELECT key, ack FROM sync_ack')).toEqual([['RouteV2', `RouteV2|${newerAck}`]]); }); it('applies each run of lines in the order it arrived', () => { @@ -95,7 +123,7 @@ describe('planSync', () => { ack: `DirectionV1|${ACK_ID}`, data: { id: DIRECTION_ID, routeId: ROUTE_ID, destination: 'MSC', sequence: 0, path: '' }, }, - { type: 'RouteDeleteV1', ack: `RouteDeleteV1|${ACK_ID}`, data: { routeId: ROUTE_ID } }, + { type: 'RouteDeleteV2', ack: `RouteDeleteV2|${ACK_ID}`, data: { routeId: ROUTE_ID } }, ]), ); @@ -119,7 +147,7 @@ describe('planSync', () => { expect(rows('SELECT long_name FROM route')).toEqual([['Bonfire Express']]); expect(rows('SELECT id FROM direction')).toEqual([]); - expect(rows('SELECT ack FROM sync_ack')).toEqual([[`RouteV1|${fullAck}`]]); + expect(rows('SELECT ack FROM sync_ack')).toEqual([[`RouteV2|${fullAck}`]]); }); }); diff --git a/src/lib/sync/sync-stream.ts b/src/lib/sync/sync-stream.ts index ec17860b..6457b6ae 100644 --- a/src/lib/sync/sync-stream.ts +++ b/src/lib/sync/sync-stream.ts @@ -17,7 +17,7 @@ import { chunk } from 'lodash-es'; export const SYNC_PROTOCOL_V1: SyncRequest['protocol'] = 1; export const SYNC_REQUEST_TYPES: SyncRequestType[] = [ - 'RoutesV1', + 'RoutesV2', 'DirectionsV1', 'StopsV1', 'AlertsV1', @@ -73,6 +73,8 @@ const controlLine: EntityHandler = () => []; const HANDLERS: { [T in SyncEntityType]: EntityHandler> } = { RouteV1: upsert(route, (data: LineData<'RouteV1'>) => data), RouteDeleteV1: remove(route, (data: LineData<'RouteDeleteV1'>) => data.routeId), + RouteV2: upsert(route, (data: LineData<'RouteV2'>) => data), + RouteDeleteV2: remove(route, (data: LineData<'RouteDeleteV2'>) => data.routeId), DirectionV1: upsert(direction, (data: LineData<'DirectionV1'>) => data), DirectionDeleteV1: remove(direction, (data: LineData<'DirectionDeleteV1'>) => data.directionId), StopV1: upsert(stop, (data: LineData<'StopV1'>) => ({ diff --git a/src/routes/route/[id]/+page.svelte b/src/routes/route/[id]/+page.svelte index 24c6a30b..2e2bb559 100644 --- a/src/routes/route/[id]/+page.svelte +++ b/src/routes/route/[id]/+page.svelte @@ -102,8 +102,8 @@
{/if} {#each selectedDirection?.stops ?? [] as stop, i (`${stop.id}-${selectedDirection?.id}-${i}`)} - {@const isLast = i === (selectedDirection?.stops.length ?? 0) - 1} - {@const altDirection = isLast + {@const wrapsAround = route.isLoop && i === (selectedDirection?.stops.length ?? 0) - 1} + {@const altDirection = wrapsAround ? route.directions.length > 1 ? (route.directions.find((d) => d.id !== selectedDirection.id) ?? selectedDirection) : selectedDirection @@ -112,7 +112,7 @@ {stop} direction={selectedDirection} {route} - estimateStop={isLast ? altDirection?.stops[0] : undefined} + estimateStop={wrapsAround ? altDirection?.stops[0] : undefined} /> {#if i < (selectedDirection?.stops.length ?? 0) - 1}