Same seed, same pixels: deterministic ids for screenshot data
Every screenshot of merge.tel is taken from a company that doesn't exist. It's called Larkspur Labs. It has three repos, seven social accounts and a year of merged pull requests, and CI builds it from an empty database on every run so Screenery can compare the new pictures with main. The deal is simple. Same commit in, same pixels out. If a picture changes, the UI changed.
That deal had a hole in it, and the hole was gen_random_uuid(). Half the tables in my seed script let the database pick a random id for every row. Most of the time nobody could tell. But any list sorted on a value two rows shared could come back in a different order next run, and then a screen I never touched would show up in the PR comment as changed.
This is how I closed it, and what I'd tell anyone seeding data for screenshots.
The seed was only half deterministic
I already knew random data was the enemy. The seed takes a --now flag, so every date is placed relative to one frozen moment, and a test fails the build if the script ever calls Date.now(), Math.random() or randomUUID. There was even a helper, seededId(), that turned the demo email, that moment and a counter into a uuid. The docs said the seed's ids were derived, not random.
The docs were half right. The account, the pull requests, the drafts and the posts used seededId(). Installations, repos, products, ICPs, voices, social sets, connections, repo settings, posting slots, usage records, batches, the connection activity feed, digest sends and jobs all took the column default. That's fourteen tables quietly rolling dice, and the test never noticed, because the dice were in Postgres and not in my script.
The fix was boring. Every insert into a table with an id column now passes id: seededId(). The code that looks rows up by name after inserting them didn't change, and neither did anything else. It's a diff you could review on a phone.
A random id moves pixels through ties
A random id never shows up on screen by itself. Nobody prints a uuid in a dashboard. The damage happens one step earlier, in the sort.
The seed inserts Larkspur's three repos in one statement, with the same created_at on all three. Any screen that lists repos by created_at now has a three-way tie. Postgres doesn't promise an order for ties, and plenty of queries break them with order by created_at, id. So the order on screen comes down to whichever uuid happened to sort first.
Two runs could look like this. The ids are made up, the mechanism isn't.
| Run | Ids rolled | Order on screen |
|---|---|---|
| 1 | relay 3f…, statusbird a1…, formkeeper 0c… | formkeeper, relay, statusbird |
| 2 | relay 9b…, statusbird 27…, formkeeper e4… | statusbird, relay, formkeeper |
Same commit, same data, same moment, different picture. Multiply that by every insert that shares a timestamp. A year of seeded activity has plenty of them, and Postgres gives now() the same value for every row in a transaction, so a multi-row insert that leaves created_at to its default ties with itself.
The helper is ten lines
let seededIdCount = 0;
function seededId() {
const h = createHash('sha256')
.update(`${DEMO_EMAIL}|${NOW.toISOString()}|${seededIdCount++}`)
.digest('hex');
const variant = ((parseInt(h[16], 16) & 0x3) | 0x8).toString(16);
return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${variant}${h.slice(17, 20)}-${h.slice(20, 32)}`;
}
Three things go into the hash. The demo account's email says whose data this is, so two different seeds into one database never collide. The frozen --now moment ties the ids to the same clock the dates use. The counter makes each call different from the last.
The output is shaped like a real version 4 uuid, version digit and variant bits included, so a uuid column takes it and nothing downstream can tell it was made up. And it uses node:crypto, which the test that bans randomUUID is perfectly happy with. It hashes, it doesn't roll.
What I'd tell anyone seeding screenshot data
This part really is a list, so here it is as one.
- Choose every id yourself. Every table with an id column, not only the ones you think a screen reads. I thought I knew which tables mattered and I was wrong by fourteen. Join tables with composite keys need nothing extra, because they key on ids you already chose.
- Hash stable inputs only. Who the data belongs to, the frozen moment, a counter. Never the wall clock, never a random source, never a value the database hands back.
- Build rows in a fixed order, from your own constants. A counter only works if it lands on the same row every time. Map over arrays in the script. Don't generate ids while looping over a query result, because the API doesn't promise to return rows in the order you inserted them. Look rows up by name afterwards instead.
- Expect one round of changed shots when an insert moves. Adding a call early in the script shifts every id after it. That's fine once. If it bothers you, hash a stable name like repo:relay instead of a counter, and inserts stop shifting each other.
- Pin timestamps too. An id is only the tiebreaker. Set created_at yourself on anything a screen sorts by, relative to the frozen moment, or the database writes the real time for you.
- Break ties in the query as well. order by created_at alone leaves the order of ties up to Postgres. Add , id and the order is yours, decided by ids you control.
- Make the rule a test. Mine reads the seed script as text and fails on Date.now(), Math.random() or randomUUID. It wouldn't have caught this bug, because the randomness was a column default. It does stop the next one from sneaking in through the front door.
Prove it twice, then look at pixels
I didn't take the fix on faith. Two checks, both cheap.
First the ids. Seed the same moment twice into a local Supabase and dump every id, sorted, after each run:
node scripts/seed-demo.mjs --now 2026-06-17T18:00:00Z && ./dump-ids.sh ids-1.txt node scripts/seed-demo.mjs --now 2026-06-17T18:00:00Z && ./dump-ids.sh ids-2.txt cmp ids-1.txt ids-2.txt
Both dumps came out byte-identical: 1,538 ids across 18 tables, plus the same keys on all 580 join rows. Then I counted rows in every table with an account_id, to make sure no trigger was quietly writing rows of its own with random ids. Nothing outside the seed's own list.
Then the pixels. I ran the whole shoot three times on one build and hashed every PNG. 28 of 29 matched every time. app/changelog flipped in one run, so I diffed it pixel by pixel: 7 pixels in a 2 by 4 box on a rounded corner, no channel off by more than 2. That's Chromium anti-aliasing having a day, not data. If you get a diff like that, measure it before you chase it. Data moves rows. Anti-aliasing moves a handful of pixels by a shade.
The PR's own Screenery run then reported 25 unchanged and byte-identical to main, and 4 changed. That's expected once, since the fix reassigned ids. Two of the four carried Screenery's flaky hint, because they've flipped on main before when nothing real changed. One is a homepage section that reads my live Bluesky and Mastodon feeds, so of course it changed. The fourth, the stats screen, is the one worth opening. I'll take a PR comment that sorts the noise for me before I've even looked.
What this doesn't fix
Seeded ids make the data repeatable. They don't make the browser repeatable, so that changelog corner can still flip a shade now and then. They don't freeze anything the page pulls from the network either, which is why my homepage shot still changes when I post.
They also don't pin timestamps. Products, ICPs, voices, sets, repo settings and posting slots still take the database's real created_at. No screen I photograph sorts by those today. That's a bet, not a guarantee, and the day a screen starts sorting by one of them, it goes on the list in step 5.
And the counter means moving an insert reshuffles every id after it. I'm fine with that. It costs one PR's worth of changed screenshots, and Screenery shows me exactly which ones.