Ghost sections in seat layouts: the consistency check we should have written first
The mistake
A venue is two tables: one Section per area, one Seat per chair. The customer-facing seating chart queries both — list the sections for the venue, then list the seats for the selected section, render. The first version of the chart shipped with the obvious query: Section.findMany({ where: { venueSlug } }). The first venue worked. The second venue worked. The third venue, after a few rounds of admin-side editing, started showing a section that looked correct on the chart but had no seats inside it — and worse, also had seats that lived outside any visible section.
The team called these "ghost sections" (the section that displays but has no children) and "orphan seats" (the seat that has a sectionId pointing at nothing). Both are the same bug seen from opposite ends: the chart and the database disagreed about which sections existed.
The mistake was not validating the parent-child relationship at write time, and assuming foreign keys would do the work of referential integrity when the application was the one violating it.
Why it looks fine on day one
Day-one venues are built by the developer who wrote the admin panel. The developer creates a Section, then creates Seats inside it, then saves. There are no incomplete transitions. There are no double-clicks. The data goes in clean.
Day-one validation also passes. The Prisma schema has Seat.section Section @relation(fields: [sectionId], references: [id]). Postgres enforces the foreign key. A seat cannot be saved with a sectionId that does not exist. That guarantee covers fifty percent of the bug. The other fifty percent — sections without seats, and seats whose sectionId was once valid but is now stale — does not show up on day one because nothing has been deleted yet.
The bug is dormant in the schema. It needs a few weeks of admin activity to surface.
The cost — what actually breaks, who pays, when
Two failure modes, both customer-facing.
A section without seats. A venue manager creates a new section as part of laying out a venue, draws it on the canvas, gets distracted, and saves the venue. The section is in the database with positionX, positionY, sizeX, sizeY, and zero seats. The customer-facing chart renders the section as a clickable rectangle. The customer clicks. The seat-selection panel opens. The panel is empty. The customer's read of this is "the section is full" — and they bounce.
The number of customers who bounce is invisible. There is no error log. There is no exception thrown. The chart renders correctly, the section query returns one row, the seats query returns zero rows, and the platform interprets that as "no seats available in this section." The truth — "this section was a draft that should not be visible" — is nowhere in the system.
An orphan seat. A venue manager re-organises a venue. Section A is split into Section A1 and Section A2. The manager creates the new sections, drags the seats over, deletes Section A. If the delete cascades, the seats die. If the delete does not cascade (and the platform's Prisma config defaults to no cascade), the seats survive — pointing at a sectionId that is no longer there. The Postgres foreign key is configured with ON DELETE SET NULL or ON DELETE NO ACTION. The schema accepts it. The customer-facing chart, querying seats by section, does not return them. The customer cannot buy them. They are sold, technically, but only via a direct database query nobody is running.
The cost lands on the customer ("this seat is missing from the chart"), then on support ("can I sit there or not"), then on the venue manager ("why am I getting refund requests for seats nobody bought"). The bug is a slow drip. By the time the team notices, there are forty orphan seats across nine venues, and the only way to find them is a custom query against the database.
The fix — the change in approach and a real example
The fix is in two layers. The schema gets stricter, and the application validates the parent-child relationship at write time, before the bug can land.
Schema layer. Configure cascading deletes explicitly. When a Section is deleted, its Seats die with it. This is the database refusing to ship orphan rows under any circumstances.
model Section {
id String @id @default(cuid())
venueSlug String
name String
// ...layout columns
seats Seat[]
venue Venue @relation(fields: [venueSlug], references: [slug], onDelete: Cascade)
}
model Seat {
id String @id @default(cuid())
sectionId String
// ...layout columns
section Section @relation(fields: [sectionId], references: [id], onDelete: Cascade)
}
onDelete: Cascade on the Section→Seat relation is the line that retires "orphan seat." When a manager deletes a section, the seats die in the same transaction. Postgres now refuses the inverse — there is no path to leaving a seat with a stale sectionId.
Application layer. Add a consistency check at every admin write that affects layout, and a periodic integrity scan that catches anything the application missed.
// services/VenueIntegrityService.ts
async function checkVenue(venueSlug: string) {
const sections = await prisma.section.findMany({
where: { venueSlug },
include: { seats: { select: { id: true } } },
});
const ghost = sections.filter((s) => s.seats.length === 0 && !s.standing);
const invalid = sections.filter((s) =>
s.disabled === false && s.seats.some((seat) => !isInsideBounds(seat, s)),
);
return { ghost, invalid };
}
function isInsideBounds(seat, section) {
return (
seat.positionX >= 0 &&
seat.positionX + seat.sizeX <= section.sizeX &&
seat.positionY >= 0 &&
seat.positionY + seat.sizeY <= section.sizeY
);
}
checkVenue runs after every section save and on a nightly cron. The two checks — "section with no seats" and "seat outside its section's bounds" — are the two ways a layout can become incoherent. The result is surfaced in the admin panel as a yellow banner: "Venue has 1 incomplete section. Review."
The customer-facing chart query is also tightened so a section with zero seats and standing = false is treated as not-for-sale:
async function getVenueChart(venueSlug: string) {
return prisma.section.findMany({
where: {
venueSlug,
disabled: false,
OR: [
{ standing: true }, // standing-room sections have no seats
{ seats: { some: {} } }, // seated sections must have at least one seat
],
},
include: { seats: true },
});
}
The OR is the structural answer to "what is a sellable section." A standing-room section needs no seats; a seated section needs at least one. Anything else is incomplete data and should not reach the customer.
Trade-off you are accepting with the fix
The cascade delete means an accidental section deletion takes the seats with it, and there is no undo at the schema level. A nervous venue manager can lose work. The mitigation is application-level — show a confirmation dialog before delete, soft-delete via a disabled = true flag instead of hard-delete for most workflows, and only run real DELETE from a separate "permanently remove" action that is not part of the day-to-day editing flow.
The integrity scan runs at write-time and on a cron, which is overhead on every save. For most venues this is negligible (a venue has dozens of sections and hundreds of seats). For a stadium with thirty thousand seats it is real. The scan can be scoped to the section being edited rather than the full venue, at the cost of missing cross-section issues that emerge from re-organisation.
The customer-facing OR-filter means the chart query is slightly more expensive than a plain Section.findMany. The database has to evaluate the EXISTS for the seats. For typical venue chart loads (a single venue, dozens of sections), the cost is invisible. For the rare "list all venues with their chart" admin view, the cost is real and the query needs a redesign.
CTA — what to check in your own project this week
Run two queries against your own database. The first finds ghost sections:
SELECT s.id, s.name, s."venueSlug"
FROM "Section" s
LEFT JOIN "Seat" se ON se."sectionId" = s.id
WHERE s.disabled = false
AND s.standing = false
GROUP BY s.id
HAVING COUNT(se.id) = 0;
The second finds orphan seats:
SELECT se.id, se."sectionId"
FROM "Seat" se
LEFT JOIN "Section" s ON s.id = se."sectionId"
WHERE s.id IS NULL;
If the first query returns rows, you have customers bouncing on empty sections. If the second returns rows, you have seats nobody can buy. Both are quiet revenue leaks. Both are fixable in one migration and one PR.
Trade-off (recommendation)
The fix accepts a small overhead on writes in exchange for predictable reads. The customer-facing chart query gets to assume "every section I render is sellable." The team gives up the freedom to leave draft data in the schema and forces incomplete data into a separate state (a draft = true flag, or a separate SectionDraft model) if the workflow needs it.
For most platforms the trade is worth it. The cost of an admin-side workflow change is small; the cost of an empty section facing a paying customer is real and recurring.
Business impact
The revenue model of a venue is the percentage of its capacity that gets sold. A platform that silently hides seats — through ghost sections or orphan rows — depresses that percentage in a way the venue cannot see. Customers see "this row is sold out" and look elsewhere; the venue manager sees a slow-selling event and wonders why. The mismatch erodes the venue's trust in the platform.
The fix restores the contract. Every section the customer can click is a section with seats. Every seat in the database is reachable from the customer-facing chart. The venue's capacity utilisation matches reality. The reports the venue manager runs the morning after a show match the receipts they cashed in. Trust is the slow currency; the fix earns it back.
What to do next
If you have a multi-table layout model and have not configured cascading deletes, the cheapest move this week is to write the migration. ON DELETE CASCADE on the parent-child relation is one line in the Prisma schema and one migration. The behaviour change is opt-in to the next deploy — existing rows are not affected, but the next delete cascades.
If you have time for the larger fix, add the integrity scan endpoint and the admin-panel banner. The scan is forty lines of TypeScript, the banner is fifteen lines of JSX, and together they prevent the next ghost section from reaching production.
The artifact to copy: a cascade delete on the parent-child relation, a periodic integrity scan with two named checks, an OR-filtered customer-facing chart query that treats incomplete data as unsellable, and an admin banner that surfaces incomplete venues so the manager can fix them before customers do.
Related Articles
Same CategoryComments (0)
Newsletter
Stay updated! Get all the latest and greatest posts delivered straight to your inbox