PubTrivia is two deployed things. The app is Next.js on one host. A long-running Node process that holds the sockets and owns the clock is on another, deployed from the same repository but as its own package: its build sees only the ws-server/ directory, not the workspace root, not the lockfile above it, and not the app's source.
That is a reasonable deployment choice and it has one annoying consequence. The second process reads the same Postgres database, with Drizzle, which means it needs the schema, which it cannot import. So the schema is declared twice.
Both files open by telling you about the other one, in different words:
// ─── MIRRORED IN WS-SERVER ──────────────────────────────────────────────
// This file is mirrored in ws-server/src/db/schema.ts.
// The ws-server is a standalone process that cannot import from this package,
// so the schema is intentionally duplicated there.
// ─── 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.
A comment is not a mechanism. So I measured how far apart the two copies have actually drifted.
The shape of the duplication
| app copy | ws-server copy | |
|---|---|---|
| lines | 269 | 179 |
pgTable declarations |
11 | 8 |
index() calls |
14 | 10 |
| whole-line comments | 59 | 18 |
| trailing comments | 18 | 1 |
diff reports 126 differing lines between them.
That number looks alarming and is not, which is the point of this post. I parsed both files with a small script that pulls out every pgTable('name', { ... }) and every column: type( inside it, then compared the two maps.
Result: of the eight tables both files declare, zero have a column the other does not, and zero have a column declared with a different type. Not one difference in the part that would break at runtime.
The three tables that are missing on purpose
The app declares three tables the socket process does not: a privacy audit trail, login session records, and team membership. The game process never reads any of them, so copying them over would be copying code to be ignored.
That is the right kind of difference. The copy is a subset, not a variant. It is also the difference a test can encode precisely, which matters later.
So the diff is comments
With structure identical and three tables legitimately absent, the remaining diff is almost entirely the thing comments carry: why a column looks the way it does.
The app copy says things like this:
plan: text('plan'), // nullable - will use venues.subscription_status instead
stripe_id: text('stripe_id'), // nullable for backward compatibility
subscription_status: text('subscription_status').notNull().default('trial'), // 'trial' | 'active' | 'canceled' | 'expired'
// Fast lookup of all venues by owner (used on every dashboard load)
The copy keeps none of those. It is the same eight tables with the reasoning filed off: 19 comment lines against 77.
And the asymmetry runs the wrong way. Several of those columns are text holding a small fixed set of values, so the only written record of what the allowed values are is the trailing comment, in the file belonging to the process that mostly writes them. The process that reads them and has to branch on them is the one with no comment.
There is one piece of counter-evidence worth reporting, because it is the most recent change. Last week's mailing-list column went into both files in the same commit, three lines each, with its comment copied across:
// Mailing list opt-in for product update emails (admin broadcasts). Account
// emails ignore it. Turned off from Settings or an email's unsubscribe link.
email_updates: boolean('email_updates').notNull().default(true),
So the ritual works when the same person writes both sides inside the same minute. That is the only situation in which it has ever been tested.
Only one of the two can produce DDL
The reason this duplication has not caused an incident is a structural constraint rather than discipline. The Drizzle config names exactly one file:
export default defineConfig({
schema: "./utils/db/schema.ts",
out: "./utils/db/migrations",
dialect: "postgresql",
// ...
})
The copy cannot generate a migration. There is no drizzle.config.ts in ws-server/ at all, and its package.json has four runtime dependencies, none of them drizzle-kit. So a divergence in the copy can never alter the database. It can only misread it.
That bounds the failure modes to two, and they are not symmetrical:
- The copy is missing a column that exists. Drizzle selects the columns it knows about, so the process simply never sees the value. Silent, and exactly the kind of bug that shows up as "the feature works in the app and not on the big screen".
- The copy names a column that no longer exists. Postgres rejects the query, the process logs an error, and you find out in seconds.
The dangerous one is the quiet one, which is a good argument for the test below rather than for more comments.
The 25-line test that would close it
Our suite already contains tests that read source files as text rather than importing them, so this fits an existing precedent. The assertion is not "the files are identical", which would be false and useless. It is two claims:
- every table in the copy also exists in the app schema,
- for every table in both, the column name to column type map is identical.
const TABLE = /export const \w+ = pgTable\('(\w+)',\s*\{([\s\S]*?)\n\}/g
const COLUMN = /^\s*(\w+):\s*(\w+)\(/gm
function tables(source: string) {
const out = new Map<string, Map<string, string>>()
for (const [, name, body] of source.matchAll(TABLE)) {
out.set(name, new Map([...body.matchAll(COLUMN)].map((m) => [m[1], m[2]])))
}
return out
}
it('the ws-server schema copy is a faithful subset', async () => {
const app = tables(await readFile('utils/db/schema.ts', 'utf8'))
const ws = tables(await readFile('ws-server/src/db/schema.ts', 'utf8'))
for (const [name, columns] of ws) {
expect(app.has(name), `${name} is not in the app schema`).toBe(true)
expect(Object.fromEntries(columns)).toEqual(Object.fromEntries(app.get(name)!))
}
})
No database, no imports, a couple of milliseconds. It would have caught a missing column in the copy, which is the failure the comments cannot catch, and it deliberately permits the three absent tables and all 126 lines of comment difference.
Why not one of the proper answers
- Publish the schema as a package. Now there is a version number between two things that are always deployed together, and a release step in the middle of a one-line column change.
- Monorepo imports. The whole reason the second process is separate is that its host builds it from its own directory with its own lockfile. Reaching up out of that directory is what the separation is for.
- Generate both from SQL. Plausible, and it makes the migration the source of truth, which it arguably already is. It also means neither file is hand-editable, and the comments above are the thing I would lose.
Two files and a test that compares them is the cheapest arrangement that fails loudly. I would not defend it as elegant.
Check it yourself
The two processes are both visible from outside, which is the fastest way to see why one file would have been easier. The app answers from its own host at pub-trivia.app, and the socket process publishes its own health:
$ curl -s https://ws-server-production-f275.up.railway.app/health
{"status":"ok","sessions":0,"clients":0,"redis":true}
Different host, different process, different build, same database. Everything the feature pages describe as one product is those two things agreeing about eleven tables, eight of which are written down twice.
Top comments (0)