Our quiz app is two deployed things. A Next.js app, and a long-lived WebSocket server that holds the game clock, because a serverless function cannot sit on a fifteen second timer waiting for a question to end.
They are separate packages with separate builds on separate platforms, and the WebSocket server imports nothing from the app. Which means its Drizzle schema is a second copy, with a header saying so:
// ─── INTENTIONAL DUPLICATE ────────────────────────────────────────────────────
// This file is a deliberate copy of utils/db/schema.ts in the main Next.js app.
// The ws-server is kept as a standalone process with no imports from the parent
// package, so the schema must live here too.
//
// When you change utils/db/schema.ts, mirror those changes here and vice-versa.
I went and compared the two files properly, table by table and column by column, which I had never done. Here is what came back:
answers: app=9 ws=9
game_sessions: app=11 ws=11
question_packs: app=8 ws=8
questions: app=10 ws=10
table_participants: app=6 ws=6
tables: app=6 ws=6
users_table: app=6 ws=6
venues: app=9 ws=9
leaderboard_entries: app=8 ws=8
privacy_audit_log ONLY IN APP (6 columns)
user_sessions ONLY IN APP (5 columns)
venue_members ONLY IN APP (10 columns)
Nine tables, identical column for column, and three that were never copied.
The three missing ones have a theme
venue_members is who is allowed into an account. user_sessions is which browser sessions are signed in. privacy_audit_log is who exported or deleted their data.
Accounts, access, privacy. The three tables in the database that are about people rather than about a quiz that is currently running, and the game server has no definition of any of them. What it holds is sessions, tables, participants, questions, packs, answers and leaderboard entries: the state of tonight.
That split was not planned on a whiteboard. It happened because somebody, which is to say me, copied over the tables a query needed and stopped. But it is the split I would have chosen, and it is worth naming the principle it accidentally follows: a secondary process should carry the narrowest model of the data it can do its job with, because every table in that file is a table whose changes now have to be mirrored into a second repository by hand.
The missing three are, in other words, three tables I can change freely without thinking about the other deployment. That is a real benefit and it is invisible until you go looking for it.
Nine copied, five actually queried
The game queries touch exactly five:
answersTable 20 references
questionsTable 13
gameSessionsTable 9
tableParticipantsTable 8
tablesTable 4
users_table, venues, question_packs and leaderboard_entries are declared in the server's schema and never queried by it. Four dead tables, carried along because copying a file is easier than deciding what to copy, each one a thing that can drift silently.
That is the small, boring maintenance debt in this design, and the fix is to delete them. A schema file is not documentation of the database, it is a declaration of what this process is coupled to, and four of the nine declarations are lying about the coupling.
The part that surprised me: it never writes
I grepped the entire ws-server source tree for db.insert, db.update and db.delete.
Zero hits.
The process that owns the clock, that decides when a question's time is up, that fans out every message to every phone in the pub, has full credentials to the production database and uses them exclusively to read. Every change to the game goes back over HTTP to the Next.js app:
export function requestClose(sessionId: string, questionIndex: number): Promise<void> {
if ((lastClosedIndex.get(sessionId) ?? -1) >= questionIndex) return Promise.resolve();
return postInternal(
'/api/internal/close-question',
{ sessionId, questionIndex },
`close:${sessionId}:${questionIndex}`,
);
}
const res = await fetch(`${nextjsUrl}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-internal-secret': secret },
body: JSON.stringify(body),
signal: AbortSignal.timeout(INTERNAL_TIMEOUT_MS),
})
A shared secret header, a ten second timeout, and a key that keeps one request per action, session and question in flight at a time.
The clock lives where a timer can live. The rules live where the rules live. When a question's time expires, the server does not apply the ending itself; it asks the app to, and the app runs the same path a host pressing the button runs. One writer, one implementation of what closing a question means, and a timer in the only process that can hold one. The timings themselves are shared constants rather than numbers each side guesses at: a short read before the clock starts, the answer window, a second of grace at the buzzer, which is why the host's display and the server's deadline agree to the second.
It also means a nasty class of bug cannot exist. If both halves wrote, the question of what happens when the clock fires at the same moment the host presses the button becomes a question about two processes racing on rows. With one writer it is a question about two requests hitting the same endpoint, which is a problem with known answers.
The honest cost: a close now depends on an HTTP hop that can time out, and the failure handling is a comment rather than a retry queue. There is a sweep that runs every three seconds and catches anything missed, which is the kind of answer you accept when the alternative is distributed transactions across two platforms for a pub quiz.
The comment in the file is the only thing holding the copies together
Back to the duplicated schema, and the thing I actually want to change.
What keeps the two copies in step is a comment asking politely. Change a column in the app, forget the mirror, and nothing fails at build time, because the server compiles perfectly against its own stale copy. It fails in production the next time a query selects a column that is no longer there, which will be during somebody's quiz night.
We already have the pattern for fixing this somewhere else in the repo: a test that reads the other package's source as text and asserts a shared constant matches. Three lines of the same idea would cover the schema, something like parsing both files and asserting that every table present in both declares the same columns, which is exactly the check I ran by hand to write this post. The reason it is not in the suite yet is that I only wrote the check as a one-off script, and a one-off script is a test you decided not to keep.
And one thing it is important not to claim. This is not a security boundary. Both processes connect with the same DATABASE_URL and the same role, so the server not declaring venue_members does not mean it cannot read venue_members; it means it would need a line of code to do so. The schema is a statement of intent enforced by the type checker. If you want the real boundary, that is a second database role with grants on five tables, which is a thing I have on a list and have not done.
What all of it delivers is one visible effect: the moment a question closes, the standings move on the host's screen and on every phone in the room at once. Two deployments, two schema files, nine tables, five queries and one writer, so that a table can tell between question four and question five that they have gone from third to second.
Top comments (0)