A while ago I wrote Our entire production schema is one table and 33 lines, and it has not changed in 71 days. It accounted for ten columns and four unique constraints, argued that one of them is identity and three are idempotency, and explained a timestamp that nothing reads.
It never mentioned the primary key.
export const licenses = pgTable("licenses", {
id: text("id").primaryKey(),
email: text("email").notNull().unique(),
token: text("token").notNull().unique(),
stripeSessionId: text("stripe_session_id").notNull().unique(),
...
});
That omission was not a slip in the writing. It is an accurate reflection of the code, and I only noticed when I went looking for a reason to mention id and could not find one. This post is the census.
Every reference to the primary key, in a product that takes money
There are six, in the whole of server/.
One writes it. The Stripe webhook, inserting a new licence:
await db.insert(licenses).values({
id: createId(),
email,
token,
stripeSessionId: session.id,
active: true,
});
One selects it and throws it away. The duplicate-purchase check at checkout:
const existing = await db
.select({ id: licenses.id })
.from(licenses)
.where(eq(licenses.email, email))
.limit(1);
if (existing.length > 0) {
return NextResponse.json(
{ error: "A license already exists for this email address." },
{ status: 409 }
);
}
That projection is a good habit pointed at nothing. Selecting one narrow column instead of select() keeps a wide row off the wire, which is right, and then the only thing the result is asked is whether it has a length. The projection could select a literal 1 instead of a column and the behaviour would be identical.
One logs it.
console.log(
`[stripe-webhook] Licence ${license.id} already upgraded; session ${session.id} ignored`
);
One puts it in Stripe's metadata. The auto-reply upgrade, bought from inside the desktop app:
// `kind` is how the webhook tells this apart from a new-licence purchase.
// `licenseId` says which licence to upgrade.
metadata: { kind: UPGRADE_METADATA_KIND, licenseId: license.id, email },
And two read it back, both on the far side of that round trip. The webhook resolving the upgrade:
const licenseId = session.metadata?.["licenseId"];
if (licenseId) {
[license] = await db.select().from(licenses).where(eq(licenses.id, licenseId)).limit(1);
}
and the grant itself, which takes the id the caller already resolved:
export async function markAutoReplyUpgraded(licenseId: string, stripeSessionId: string) {
await db.update(licenses).set({ autoReply: true, ... }).where(eq(licenses.id, licenseId));
}
That is the lot. One insert, one discarded projection, one log line, and one journey out to Stripe and back.
What every other query starts from
The read paths that actually matter all begin somewhere else, and each one begins at a column with a unique constraint on it:
token /api/validate, /api/notify, resolveLicense()
email duplicate check at checkout, webhook fallback paths
stripe_session_id webhook retry detection
id only after Stripe hands it back
token is the desktop app's whole identity: it is the string a user types into the app once, and the one it sends with every alert. email is the customer's identity, enforced as one licence per address. stripe_session_id is the identity of a payment, which is what makes a redelivered webhook detectable. Those three are the keys the system is genuinely organised around.
So what is id for?
It is for being handed to somebody else
The honest answer in this codebase is that the primary key exists so that there is a value to put in somebody else's key-value bag.
Stripe's metadata is exactly that. The limits are 50 keys, key names up to 40 characters and values up to 500, stored as strings, and Stripe does nothing with them. It is a slot where you may leave a note for your future self, and it comes back attached to the event.
A 24-character hex string fits that slot perfectly. Which is what the generator produces:
/**
* Generates a URL-safe random ID (similar to cuid2 / nanoid) without any
* external dependency. 24 characters, lowercase hex.
*/
export function createId(): string {
return crypto.randomBytes(12).toString("hex");
}
That comment has one clause I would now delete. "Similar to cuid2 / nanoid" is true of the length and false of everything the two libraries are actually about.
The 24 characters are a real coincidence: cuid2's default length is also 24. The resemblance stops there.
- cuid2's 24 characters are base36, and it guarantees the first one is a letter so the id is a valid identifier in JavaScript, HTML and CSS. These 24 are hex, so 16 symbols instead of 36, and the first character is a digit 10 times out of 16.
- cuid2 mixes several entropy sources and hashes them, specifically because it does not want to trust one source and does not want creation time recoverable from the output. This calls
crypto.randomBytes(12). - nanoid's default is 21 characters of a 64-symbol alphabet, which is 126 bits in three fewer characters than this spends on 96.
Ninety-six bits of randomness is a huge margin for a table with one row per paying customer, and a three-line function with no dependency is the right call for one call site. I would defend the code. The comment is the problem, because naming two libraries invites the next reader to assume one of their properties is present, and there is no way to know from the sentence which one I meant.
The thing that is genuinely interesting about the upgrade path
Having put the id into the metadata, the handler does not trust it:
// Fall back to the email if the id was missing or no longer resolves, rather
// than failing a payment we could still attribute.
if (!license && email) {
[license] = await db.select().from(licenses).where(eq(licenses.email, email)).limit(1);
}
Note that the metadata carries both. licenseId and email travel together through Stripe, which means the system has two identifiers for one row in flight at the same time, and something has to decide which wins.
The id wins, and that is the right way round. It is immutable, it is unambiguous, and it was chosen by our own code at the moment the user authenticated with their email and token. The email is the fallback because the id can be absent entirely: an upgrade granted by hand in the Stripe dashboard, for a support case, has no metadata on it at all. That is a real scenario with a real customer at the end of it, and the comment says what it is choosing between.
If that fails too, it does not return an error to Stripe, because Stripe cannot fix it:
if (!license) {
console.error(`[stripe-webhook] Could not resolve a licence for upgrade session ${session.id}`);
Sentry.captureMessage("Auto-reply upgrade could not be matched to a licence", {
level: "error",
tags: { area: "stripe-webhook" },
extra: { sessionId: session.id, licenseId, email },
});
return;
}
Somebody has paid and cannot be granted the feature. Retrying will produce the same answer, so the handler succeeds and raises an alarm instead of sitting in Stripe's retry queue for three days. The money is Stripe's record either way, and the thing that needs to happen is a human looking at it. That decision only works because something is watching the alarm, which is a dependency worth being explicit about.
Would I remove the column?
No, and the reasoning is the useful part.
email is NOT NULL UNIQUE, so it is a perfectly good natural key today. The argument against using it as the primary key is the standard one: a natural key is only as stable as the real-world fact behind it, and email addresses change. Nothing in this codebase changes one, so that argument is about a feature that does not exist.
Which makes the column insurance. And insurance is cheap here: 24 bytes per row on a table with one row per customer, written once, never updated, never joined on because there is nothing to join to. The day someone emails support asking to move their licence to a new address, the row survives and every other reference to it keeps working.
What I would change is the comment on the generator, and the projection that selects a column to measure its length.
What transfers
- Census your keys. Not "do we have a primary key", but "which column does each query start from". The answer told me more about this system's real identities than the schema did.
- A surrogate key earns its place at the boundary. The one place the id is load-bearing is a trip through an external system, which is precisely where you want a value with no meaning in it.
- Prefer an immutable id over a natural key in anything you hand to a third party, and keep the natural key as a fallback. A support person in a dashboard will not fill in your metadata.
- Do not compare your three-line helper to a named library in a comment. Say what it is: twelve random bytes, hex, unsorted.
The product all of this exists to sell is a desktop rental monitor you buy once, the licence arrives by email and gets typed into the app as described on the help page, and the webhook retry story behind the token is One payment can run our webhook four times, and the token has to come out the same every time.
Top comments (0)