blog
Two Presets, Not One
Papermark is an open-source document-sharing platform — think a self-hostable DocSend. Issue #1951 asked for something simple on its face: let users save a watermark configuration and reuse it, instead of rebuilding it for every new link. Here's what actually happened between reading that sentence and opening the PR.
The model that already had the right name
Before writing anything, I searched the codebase for how watermarks currently worked. One file in, I hit an import that should have made the issue trivial to close: import { LinkPreset } from "@prisma/client". A preset model already existed. Case closed — extend it, ship it, move on.
Except reading further, LinkPreset turned out to be something else entirely: a single, team-wide default template auto-applied to every new link — metadata, notifications, passwords, watermark config, all of it, one row per team. The issue was asking for a small library of named watermark configs a person picks from per link — "Confidential," "Draft," "NDA" — nothing to do with a team-wide default.
Why this mattered: bolting "multiple named presets" onto a model built for "one default per team" would have meant either a breaking schema change to something already used everywhere, or a confusing dual-purpose field nobody could reason about later. A second, purpose-built model — WatermarkPreset, scoped by team, unique on [teamId, name] — was the smaller, correcter change. Naming a thing well is a design instinct. I'd just never had to defend it against a live schema before.
Following the grain of code I didn't write
The rest of the feature followed the same discipline: find the closest existing pattern and mirror it exactly, rather than invent a new one. The tags API endpoints already implemented the identical shape — session auth, team-membership check, Zod-validated body — so the new watermark-presets routes copied that structure line for line. The UI reused the app's existing Toggle, Select, and Popover primitives instead of introducing new ones. Nothing about the feature needed a new library.
Refusing to ship a migration I hadn't watched run
I didn't have a database available in the environment I was working from. The tempting shortcut was obvious: hand-write the Prisma migration SQL by copying the shape of an existing one and hope it matched what Prisma would actually generate.
I didn't take it. Instead I stood up a disposable, throwaway PostgreSQL instance locally, applied all ~150 of Papermark's existing migrations to it as a clean baseline, then let prisma migrate dev generate the real migration for the new model against that live schema — not a guess at one.
$ node verify-watermark-preset.mjs
Created preset: { name: 'Confidential', teamId: '...', config: {...} }
List count: 1
OK: duplicate name correctly rejected: P2002
Count after delete: 0
Preset after team cascade delete (should be null): null
That last line was the one I actually cared about. It's easy to write a migration where the happy path works and quietly forget the unhappy ones: what happens to a team's presets when the team itself is deleted? Does the unique-name constraint really reject a duplicate, or does the API's own pre-check just get lucky in testing? Watching Postgres answer those questions directly — not asserting they'd probably be fine — is the difference between a migration I was confident in and one I was hoping about.
What the reviewer caught
I opened the PR expecting review feedback, and it arrived fast — an automated reviewer (CodeRabbit) flagged two real issues in the new API routes, both worth taking seriously rather than dismissing as noise.
1. A failed auth check didn't actually stop the request. The team-membership check's error handler was called but never returned — a pattern copied verbatim from the endpoint I'd mirrored, where the same bug already existed unnoticed. If that lookup threw, execution fell through into the GET/POST logic anyway. Fixed — every catch block now returns.
2. A race between checking and creating. The create route checked for a duplicate name, then created — with a gap between the two where a second concurrent request could slip through and hit Postgres's own unique-constraint error as an unhandled 500 instead of a clean 400. Fixed — now catches Zod errors and Prisma's P2002 explicitly.
Fixing the first bug had a side effect worth noting: the project's overall TypeScript error count — pre-existing, unrelated to this PR, baseline-checked before I ever touched the file — actually dropped by two once the early return was in place. Better control flow gave the type checker better information for free.
} catch (error) {
- errorhandler(error, res);
+ return errorhandler(error, res);
}
A second review pass, after that push, found one more thing: the DELETE route was doing a separate existence check before deleting — two round trips with a gap between them, where the row's state could theoretically change in between. The fix was a single atomic deleteMany scoped to id and teamId, checking the returned count instead. I didn't take that on faith either — I went back to the same disposable Postgres instance and specifically proved that a delete attempt with the right ID but the wrong team returns a count of zero and leaves the row untouched, before calling it done.
On being reviewed: neither finding was something I'd have caught alone in that pass — that's what review is for. What mattered was not treating an automated reviewer's output as noise to argue past: both were real, both got fixed properly, and both got re-verified against a live database rather than just re-read for plausibility.
Where it stands
The PR is open, both review rounds addressed, awaiting a maintainer's look. I don't know yet if it merges as-is, gets asked to change further, or gets rejected outright for reasons I haven't anticipated — that's the maintainer's call, not mine to predict. What I do know is that every claim in this PR's description — the migration behavior, the cascade delete, the constraint rejection, the team-isolation on delete — is backed by something that actually ran, not something that sounded right while I was writing it.
Coming from six years of shipping interfaces, that instinct wasn't new. What's new is having a database to prove it against instead of a design review to defend it in.
Part of an ongoing run of contributions across Next.js/Prisma/Postgres codebases — same process each time: read before touching, verify against something real, ship the smallest correct change. More at github.com/pruthvi-builds.