Problem
dj-site's archived-show view has no way to step to the show that aired before or after the one being viewed, because GET /flowsheet/playlist?show_id= describes one show in isolation. tubafrenzy resolved the neighbouring show ids server-side in the same request, which is what let flowsheetRadioShowDisplayPublic.jsp render << Previous Show / Next Show >>. dj-site has no equivalent, so a listener browsing the archive must return to the weekly calendar between every set.
Bill Burton reported the gap by email on 2026-09-08, the day after rollout. Consumer: WXYC/dj-site#1394.
Desired end state
GET /flowsheet/playlist?show_id= carries previous_show_id / next_show_id, null at the ends of the archive.
What tubafrenzy did — this is the design authority
libs/core/src/main/java/org/wxyc/flowsheet/FlowsheetRadioShowRepositoryImpl.java:194-212:
handle.createQuery("SELECT MIN(ID) FROM " + TABLE_NAME + " WHERE ID > :currentId")
.bind("currentId", currentId).mapTo(Integer.class).findOne().orElse(0));
...
handle.createQuery("SELECT MAX(ID) FROM " + TABLE_NAME + " WHERE ID < :currentId")
- Ordered by ID, the surrogate PK — no
ORDER BY, no reference to SIGNON_TIME or STARTING_RADIO_HOUR.
- Zero scoping: not by DJ, not by week, not by having entries (
FLOWSHEET_ENTRY_PROD is never joined), not by having ended (SIGNOFF_TIME absent — contrast findOpenShows() in the same file, which does filter on it).
- The in-progress show is included. With no sign-off predicate, the live show is a valid
nextShowID target from the instant its row is inserted at signon. CurrentRadioShowDisplayServlet.java:43-48 defines "current show" as getMaxRadioShowID() and computes prev/next for it, so the legacy site navigated both to and from the live show.
- Ends handled asymmetrically, and the old end is a latent bug: both queries
.orElse(0), and the JSP guards only Next (flowsheetRadioShowDisplayPublic.jsp:18-27). On the oldest show, << Previous Show renders a link to radioShowID=0, which throws RadioShowDoesNotExistException (FlowsheetRadioShowService.java:229-230) and lands the visitor on an error page. Do not port that. Null at both ends is a deliberate divergence.
Legacy IDs were hand-assigned MAX(ID)+1 at signon (AbstractJdbiRepository.java:37-42), so ID order is creation order — which for that system was airtime order.
Answered against production (2026-09-08, 18:46 PDT)
32 inversions. shows.id does not preserve airtime order across the tubafrenzy import, so the cheap tubafrenzy-style id walk is off the table — it would present shows chronologically backwards at 32 boundaries.
shows_rows | open_shows | abandoned_90d | inverted_intervals
72893 | 2814 | 2813 | 0
inversions
32
tie_groups | largest_group | rows_in_ties
1 | 5 | 5
shows_with_zero_entries: 74
So the implementation is settled, and it is the more expensive of the two branches:
-
Order by (start_time, id), not by id. 32 out of 72,893 is 0.04%, but a prev/next walk exists precisely to be followed sequentially, and 32 places where "Next Show" goes backwards in time is exactly the kind of thing Bill notices.
-
Row-value comparison is required, not optional. There is one tie group and it holds 5 shows. Under strict < / > on a bare start_time, all five are skipped — a five-show hole in the walk. Use the search.service.ts:178-183 form:
(start_time, id) > ($1::timestamptz, $2) ORDER BY start_time ASC, id ASC LIMIT 1
-
Add shows_start_time_id_idx. Confirmed no usable index exists: prod shows only shows_pkey, shows_legacy_show_id_idx, and shows_open_start_time_idx (the partial one — 80 kB, 38 scans lifetime). The write-amplification argument that governs flowsheet does not apply here: shows is 72,893 rows growing at roughly 8/day, so a second btree is free in practice.
-
inverted_intervals is 0. The end_time < start_time hazard the service comments defend against does not exist in current data. Do not spend code on it.
-
74 shows have zero entries (0.1%). tubafrenzy linked to them anyway; filtering would deviate from the authority and cost a flowsheet join on every playlist read. Recommend still not filtering, but the walk will occasionally land on one, so the show view must render an empty show without looking broken.
-
The open-show backlog is confirmed and unchanged: 2,814 open, 2,813 of them older than 90 days. The end_time IS NULL trap below stands exactly as written.
Index and tie detail (now confirmed, kept for the implementer)
There is no usable index on start_time. Complete inventory for wxyc_schema.shows:
| Index |
Columns |
Type |
Predicate |
Migration |
shows_pkey |
id |
btree unique |
— |
0000_rare_prima.sql:115 |
shows_legacy_show_id_idx |
legacy_show_id |
btree unique |
— |
0034_legacy_id_columns.sql:14 |
shows_open_start_time_idx |
start_time |
btree |
WHERE end_time IS NULL |
0154_shows-open-start-time-idx.sql:47 |
That partial index covers under 4% of the table and excludes exactly the closed shows a visitor walks. 0154:4-6 states it: "The only two indexes on the table are shows_legacy_show_id_idx … and the primary key, so every end_time IS NULL read is a full scan." Two start_time-ordered lookups today are two sequential scans of 72,736 rows (0154:22, restated schema.ts:2594). Needed: CREATE INDEX shows_start_time_id_idx ON wxyc_schema.shows (start_time, id), non-partial, ~2-3 MB.
And strict < / > on a bare start_time would skip both rows of every tie. start_time is defaultNow().notNull() (schema.ts:2582) with no uniqueness constraint, and ties are documented production data in two shipped code sites that already tie-break on id:
flowsheet.service.ts:1458-1461 — "id is a deterministic tie-break for the second-granularity collisions the legacy ETL produces (see the 00:55:35/00:55:41 pairs in production's open-show tail)."
flowsheet.service.ts:839-841 (getShowsInTimeWindow) — "an unstable secondary order would reshuffle between identical requests."
The live tie-generator is still accruing — internal.route.ts:216-224: "start_time is NOW() — the webhook-delivery instant … these placeholder values now PERSIST until Phase 6a retires this webhook." (Not the ETL: jobs/flowsheet-etl/job.ts:177 reads tubafrenzy's per-row SIGNON_TIME and skips unparseable rows, and deliberately avoids the hour-rounded STARTING_RADIO_HOUR.)
Use the row-value form, per the in-repo precedent at search.service.ts:178-183 — same direction on both keys, ORDER BY <col> <dir>, id <dir>:
(start_time, id) > ($1::timestamptz, $2) ORDER BY start_time ASC, id ASC LIMIT 1
The trap: do not filter on end_time IS NULL
Whatever ordering you pick, resist adding a "skip open shows" predicate. flowsheet.service.ts:773-777 puts it in block capitals:
"A NULL end_time is not 'still on the air.'" It has two causes this column cannot distinguish: the show is genuinely live, or its show_end delivery was dropped and the column stayed NULL permanently.
0154:22-26: 2,814 open shows on 2026-08-21, of which 2,813 are legacy ETL imports stretching back to 2006 — all but one with NULL primary_dj_id, exactly one started in the last 90 days. Filtering on open-ness would exclude 2,813 historical shows to exclude one live one.
If you do need "is this the on-air show", it is not a bare max(shows.id). getOpenShows subtracts a terminal-marker carve-out (flowsheet.service.ts:1546-1563): is_current = (id === max(shows.id)) && !isLatestEntryShowEnd(id), because a show whose show_end webhook was lost holds max(id) forever. Note isLatestEntryShowEnd carries a self-warning that it "is not self-safe" without an end_time guard (:1668-1678); every current caller supplies one.
Recommendation: point next_show_id at the live show, matching tubafrenzy. If you cache the response, exclude the newest show from the cache exactly as RadioShowDisplayServlet.java:81-91 did — the real hazard is freezing a null next_show_id on the newest show after a later show starts, not linking to a live one.
Known-degenerate rows a walk will land on
- Empty shows are real, with a named instance:
tests/unit/services/flowsheet.getOpenShows.sql.test.ts:88-90 — "production show 74840 is open with zero entries." LIKELY_ABANDONED_ENTRY_THRESHOLD = 4 exists because sub-4-entry shows are common. tubafrenzy linked to them anyway; filtering would be a deviation from the authority and would cost a flowsheet join on every playlist read. Recommend not filtering.
- Inverted intervals are real:
flowsheet.service.ts:1607-1613 — "Legacy rows carrying an add_time earlier than their show's start_time are not hypothetical." No CHECK constraint, no repair migration.
- NULL
start_time is structurally impossible (NOT NULL DEFAULT now(), schema.ts:2582).
- No data-quality audit of
shows exists; no migration has ever touched its row data.
Contract: fix the drift in the same PR
ShowPlaylist at wxyc-shared/api.yaml:1651-1670 is referenced only by this route, and there is no exported TypeScript type for it — dj-site's hand-written ShowPlaylistWire isn't drift-avoidance, it's the only option. Six drifts, all verified:
entries is declared FlowsheetEntryResponse (v1); the route emits projectEntriesV2 — the v2 discriminated union (flowsheet.controller.ts:1388).
- Declared
specialty_show; emitted specialty_show_name (flowsheet.service.ts:2112-2116).
- No
required: list at all; the route always emits.
- No identifier field declared — neither
id nor show_id. The route spreads the shows row, so the key is id.
- Six undeclared emitted columns:
getShowMetadata does db.select().from(shows) (:2097), so primary_dj_id, specialty_id, legacy_show_id, legacy_dj_name, legacy_dj_id, dj_name_override all ship undocumented.
show_djs $refs OnAirDJ, which requires dj_name: string; the route emits it nullable (:2101-2104).
Adding two fields validates without touching the schema (no additionalProperties: false), so it is technically additive — but they would be the 7th and 8th undeclared fields on a response the spec already misdescribes six ways. Fixing it is documentation-only, no runtime behavior moves, and it is small.
Acceptance criteria
Related
Problem
dj-site's archived-show view has no way to step to the show that aired before or after the one being viewed, because
GET /flowsheet/playlist?show_id=describes one show in isolation. tubafrenzy resolved the neighbouring show ids server-side in the same request, which is what letflowsheetRadioShowDisplayPublic.jsprender<< Previous Show/Next Show >>. dj-site has no equivalent, so a listener browsing the archive must return to the weekly calendar between every set.Bill Burton reported the gap by email on 2026-09-08, the day after rollout. Consumer: WXYC/dj-site#1394.
Desired end state
GET /flowsheet/playlist?show_id=carriesprevious_show_id/next_show_id, null at the ends of the archive.What tubafrenzy did — this is the design authority
libs/core/src/main/java/org/wxyc/flowsheet/FlowsheetRadioShowRepositoryImpl.java:194-212:ORDER BY, no reference toSIGNON_TIMEorSTARTING_RADIO_HOUR.FLOWSHEET_ENTRY_PRODis never joined), not by having ended (SIGNOFF_TIMEabsent — contrastfindOpenShows()in the same file, which does filter on it).nextShowIDtarget from the instant its row is inserted at signon.CurrentRadioShowDisplayServlet.java:43-48defines "current show" asgetMaxRadioShowID()and computes prev/next for it, so the legacy site navigated both to and from the live show..orElse(0), and the JSP guards only Next (flowsheetRadioShowDisplayPublic.jsp:18-27). On the oldest show,<< Previous Showrenders a link toradioShowID=0, which throwsRadioShowDoesNotExistException(FlowsheetRadioShowService.java:229-230) and lands the visitor on an error page. Do not port that. Null at both ends is a deliberate divergence.Legacy IDs were hand-assigned
MAX(ID)+1at signon (AbstractJdbiRepository.java:37-42), so ID order is creation order — which for that system was airtime order.Answered against production (2026-09-08, 18:46 PDT)
32 inversions.
shows.iddoes not preserve airtime order across the tubafrenzy import, so the cheap tubafrenzy-style id walk is off the table — it would present shows chronologically backwards at 32 boundaries.So the implementation is settled, and it is the more expensive of the two branches:
Order by
(start_time, id), not byid. 32 out of 72,893 is 0.04%, but a prev/next walk exists precisely to be followed sequentially, and 32 places where "Next Show" goes backwards in time is exactly the kind of thing Bill notices.Row-value comparison is required, not optional. There is one tie group and it holds 5 shows. Under strict
</>on a barestart_time, all five are skipped — a five-show hole in the walk. Use thesearch.service.ts:178-183form:Add
shows_start_time_id_idx. Confirmed no usable index exists: prod shows onlyshows_pkey,shows_legacy_show_id_idx, andshows_open_start_time_idx(the partial one — 80 kB, 38 scans lifetime). The write-amplification argument that governsflowsheetdoes not apply here:showsis 72,893 rows growing at roughly 8/day, so a second btree is free in practice.inverted_intervalsis 0. Theend_time < start_timehazard the service comments defend against does not exist in current data. Do not spend code on it.74 shows have zero entries (0.1%). tubafrenzy linked to them anyway; filtering would deviate from the authority and cost a
flowsheetjoin on every playlist read. Recommend still not filtering, but the walk will occasionally land on one, so the show view must render an empty show without looking broken.The open-show backlog is confirmed and unchanged: 2,814 open, 2,813 of them older than 90 days. The
end_time IS NULLtrap below stands exactly as written.Index and tie detail (now confirmed, kept for the implementer)
There is no usable index on
start_time. Complete inventory forwxyc_schema.shows:shows_pkeyid0000_rare_prima.sql:115shows_legacy_show_id_idxlegacy_show_id0034_legacy_id_columns.sql:14shows_open_start_time_idxstart_timeWHERE end_time IS NULL0154_shows-open-start-time-idx.sql:47That partial index covers under 4% of the table and excludes exactly the closed shows a visitor walks.
0154:4-6states it: "The only two indexes on the table areshows_legacy_show_id_idx… and the primary key, so everyend_time IS NULLread is a full scan." Twostart_time-ordered lookups today are two sequential scans of 72,736 rows (0154:22, restatedschema.ts:2594). Needed:CREATE INDEX shows_start_time_id_idx ON wxyc_schema.shows (start_time, id), non-partial, ~2-3 MB.And strict
</>on a barestart_timewould skip both rows of every tie.start_timeisdefaultNow().notNull()(schema.ts:2582) with no uniqueness constraint, and ties are documented production data in two shipped code sites that already tie-break onid:The live tie-generator is still accruing —
internal.route.ts:216-224: "start_timeis NOW() — the webhook-delivery instant … these placeholder values now PERSIST until Phase 6a retires this webhook." (Not the ETL:jobs/flowsheet-etl/job.ts:177reads tubafrenzy's per-rowSIGNON_TIMEand skips unparseable rows, and deliberately avoids the hour-roundedSTARTING_RADIO_HOUR.)Use the row-value form, per the in-repo precedent at
search.service.ts:178-183— same direction on both keys,ORDER BY <col> <dir>, id <dir>:The trap: do not filter on
end_time IS NULLWhatever ordering you pick, resist adding a "skip open shows" predicate.
flowsheet.service.ts:773-777puts it in block capitals:0154:22-26: 2,814 open shows on 2026-08-21, of which 2,813 are legacy ETL imports stretching back to 2006 — all but one with NULLprimary_dj_id, exactly one started in the last 90 days. Filtering on open-ness would exclude 2,813 historical shows to exclude one live one.If you do need "is this the on-air show", it is not a bare
max(shows.id).getOpenShowssubtracts a terminal-marker carve-out (flowsheet.service.ts:1546-1563):is_current = (id === max(shows.id)) && !isLatestEntryShowEnd(id), because a show whoseshow_endwebhook was lost holdsmax(id)forever. NoteisLatestEntryShowEndcarries a self-warning that it "is not self-safe" without anend_timeguard (:1668-1678); every current caller supplies one.Recommendation: point
next_show_idat the live show, matching tubafrenzy. If you cache the response, exclude the newest show from the cache exactly asRadioShowDisplayServlet.java:81-91did — the real hazard is freezing a nullnext_show_idon the newest show after a later show starts, not linking to a live one.Known-degenerate rows a walk will land on
tests/unit/services/flowsheet.getOpenShows.sql.test.ts:88-90— "production show 74840 is open with zero entries."LIKELY_ABANDONED_ENTRY_THRESHOLD = 4exists because sub-4-entry shows are common. tubafrenzy linked to them anyway; filtering would be a deviation from the authority and would cost aflowsheetjoin on every playlist read. Recommend not filtering.flowsheet.service.ts:1607-1613— "Legacy rows carrying anadd_timeearlier than their show'sstart_timeare not hypothetical." NoCHECKconstraint, no repair migration.start_timeis structurally impossible (NOT NULL DEFAULT now(),schema.ts:2582).showsexists; no migration has ever touched its row data.Contract: fix the drift in the same PR
ShowPlaylistatwxyc-shared/api.yaml:1651-1670is referenced only by this route, and there is no exported TypeScript type for it — dj-site's hand-writtenShowPlaylistWireisn't drift-avoidance, it's the only option. Six drifts, all verified:entriesis declaredFlowsheetEntryResponse(v1); the route emitsprojectEntriesV2— the v2 discriminated union (flowsheet.controller.ts:1388).specialty_show; emittedspecialty_show_name(flowsheet.service.ts:2112-2116).required:list at all; the route always emits.idnorshow_id. The route spreads theshowsrow, so the key isid.getShowMetadatadoesdb.select().from(shows)(:2097), soprimary_dj_id,specialty_id,legacy_show_id,legacy_dj_name,legacy_dj_id,dj_name_overrideall ship undocumented.show_djs$refsOnAirDJ, which requiresdj_name: string; the route emits it nullable (:2101-2104).Adding two fields validates without touching the schema (no
additionalProperties: false), so it is technically additive — but they would be the 7th and 8th undeclared fields on a response the spec already misdescribes six ways. Fixing it is documentation-only, no runtime behavior moves, and it is small.Acceptance criteria
The inversion query is run and recorded.32 inversions — see above. Ordering key is(start_time, id).(start_time, id), not strict</>on a bare column — there is a real 5-show tie group.previous_show_id/next_show_idon the response; null at both ends, with a test for each.end_timepredicate anywhere in the implementation.shows_start_time_id_idxadded in the same migration (non-partial,(start_time, id)).EXPLAINshowing both lookups are index scans on the new index, pasted in the PR.api.yaml'sShowPlaylistdrift fixed alongside; dj-site'sShowPlaylistWireandEMPTY_SHOW_PLAYLIST(types.ts:50-59) gain the two fields.Related