Skip to content

Name migrations for when they were written, and prove nobody can collide again - #131

Merged
ralyodio merged 4 commits into
mainfrom
migration-naming
Aug 19, 2026
Merged

Name migrations for when they were written, and prove nobody can collide again#131
ralyodio merged 4 commits into
mainfrom
migration-naming

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Yes — Supabase-style timestamps are the right call, and the investigation turned up more than expected.

It has happened three times, not once

Yesterday two branches both took 0032 (#127 and #128). Git raised no conflict, because they are different filenames. Writing the test that now guards this found it had happened twice before and nobody ever noticed:

0006  feed_items_created_idx, feeds_sitemap_index
0018  crawl_log, queue, scraped_feeds        <- three files
0032  feed_refetch_signals, queue_hourly

It has cost nothing so far, because migrate() keys _migrations on the filename rather than the number, so each file still ran exactly once. That is luck rather than design: it holds only while the colliding files commute, and nothing was checking that they did. The first order-dependent pair would apply in whichever order lexical sort happened to pick.

The change

New migrations are named YYYYMMDDHHMMSS_what_it_does.sql:

printf '%s_add_thing.sql\n' "$(date -u +%Y%m%d%H%M%S)"

Two people would have to write one in the same second to collide.

The honest trade, written down rather than discovered later

Timestamps order by when a migration was written, not when it was merged. Write one Monday, merge it Friday, and a colleague's Wednesday migration ran first in production while a fresh database replays yours first.

This is not a regression — sequential numbering had the same divergence and the silent collisions on top — but it means:

  • keep migrations independent of each other (additive add column / create index if not exists commute; anything order-dependent belongs in one file)
  • _migrations.applied_at is the authority when a fresh replay and production disagree

Nothing is renamed

migrate() keys on the filename, so renaming an applied migration makes the runner treat it as new and run it again. Survivable for a purely additive file — ALREADY_APPLIED swallows duplicate column name / already exists — but 0001_init.sql, 0013_feed_category.sql and 0017_crawl_hourly.sql also carry insert and update statements, which are not idempotent.

The old files sort before every timestamp anyway ('0' precedes '2'), so history keeps its order for good. The test asserts that rather than assuming it.

Why a test and not just a README

A convention in a README would not have stopped any of the three collisions above, because whoever merges second is never prompted to renumber — the tool that would prompt them sees two unrelated new files. This is the prompt.

packages/db/test/migration-names.test.js enforces: valid filename shape, no two files claiming a slot (the seven already-shipped duplicates grandfathered by name, with the reason), no new sequential numbers past 0032, timestamps that name a real moment that has passed, and that history still sorts first.

Verified it actually fires, both ways:

0033_next_thing.sql: sequential numbering stopped at 32. Name a new migration for the
moment it was written — `date -u +%Y%m%d%H%M%S` — because the next number is not
knowable from a branch, and 0006, 0018 and 0032 were each claimed twice or more
before anybody noticed.

20260819080000_thing_b.sql and 20260819080000_thing_a.sql share the prefix
20260819080000. Git will not have flagged this — they are different filenames — so
rename the one that has NOT been applied to production yet. Never rename one that has.

One caveat on how much this protects you: the repo has no .github/workflows, so nothing runs the suite automatically. This guard fires on pnpm -r test, not on push. Worth a CI workflow separately if you want it enforced at merge.

Testing

All 11 workspace suites green.

🤖 Generated with Claude Code

ralyodio and others added 4 commits August 19, 2026 07:48
…ide again

Yesterday two branches both took `0032` — `0032_feed_refetch_signals.sql` (#127)
and `0032_queue_hourly.sql` (#128). Git raised no conflict, because they are
different filenames, so nothing failed and nothing warned.

Writing the test that now guards this found it had happened twice before and
nobody had ever noticed:

    0006  feed_items_created_idx, feeds_sitemap_index
    0018  crawl_log, queue, scraped_feeds        <- three files
    0032  feed_refetch_signals, queue_hourly

It has cost nothing so far because `migrate()` keys `_migrations` on the
filename rather than the number, so each file still ran exactly once. That is
luck, not design: it holds only while the colliding files commute, and nothing
was checking that they did. The first order-dependent pair would apply in
whichever order lexical sort happened to pick.

So new migrations are named `YYYYMMDDHHMMSS_what_it_does.sql`, as Supabase does
it. Two people would have to write one in the same second to collide.

The honest trade, written down in the README rather than discovered later:
timestamps order by when a migration was *written*, not when it was *merged*, so
a fresh database can replay in a different order from the one production used.
That is not a regression — sequential numbering had the same divergence and the
silent collisions on top — but it means migrations must stay independent of each
other, and `_migrations.applied_at` is the authority when the two disagree.

Nothing is renamed. `migrate()` keys on the filename, so renaming an applied
migration makes the runner treat it as new and run it again — survivable for a
purely additive file, since ALREADY_APPLIED swallows "duplicate column name",
but 0001, 0013 and 0017 also carry inserts and updates. The old files sort
before every timestamp anyway ('0' precedes '2'), so history keeps its order
untouched, and the test asserts that rather than assuming it.

The test is the point. A convention in a README would not have stopped any of
the three collisions above, because whoever merges second is never prompted to
renumber — the tool that would prompt them sees two unrelated new files. This is
the prompt. Verified both ways: it rejects a new `0033_*` and it rejects two
files sharing a timestamp.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The migration guard in this branch is a test, and nothing in this repository
ran tests. The only checks on a pull request were Socket's dependency scans,
which say nothing about whether the code works — so eleven workspaces of tests
existed and were run by whoever remembered to.

That is not a side issue here, it is the same issue. A guard nobody runs is a
comment, and the three migrations that quietly claimed a taken number went
unnoticed for exactly that reason.

Node 22 to match apps/*/Dockerfile rather than the newest version `engines`
would allow, and pnpm from `packageManager` so the version is not written down
in two places that can drift.

The build runs too. The tests cover packages and route handlers; a JSX or
import error in a page only surfaces when Next compiles it, which is how the
about page rewrite could have shipped broken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first run of the new workflow cancelled all four tests in
request-timeout.test.js with "Promise resolution is still pending but the event
loop has already resolved". They pass every time on a quiet laptop, and the
suite had never run anywhere else, so this had been latent since #111.

The stub is at fault, not `withTimeout`. It answered only by listening:

    init.signal.addEventListener('abort', () => reject(init.signal.reason))

A real fetch handed a signal that has *already* fired rejects immediately. An
abort listener added afterwards hears nothing — the event has been and gone —
so the promise never settles, the file is torn down, and the three tests behind
it are cancelled by their parent. On a quiet machine the 30ms deadline never
beats the stub to its first line. On a loaded two-core runner it does.

Forced both ways to be sure, with a deadline already expired on arrival:

    old (listener only)    -> HUNG
    new (checks aborted)   -> rejected TimeoutError after 40ms

So the stub now checks `signal.aborted` first, which is what it was always
claiming to imitate. Shared between the two tests that need it rather than
fixed twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ndoned

The previous commit fixed a real defect in this stub and not the one that was
breaking CI. The tests were still cancelled, identically, and the reason it
looked like a loaded-runner race was that it reproduces on Node 22 *in
isolation on an idle machine* — CI runs Node 22 to match apps/*/Dockerfile,
while local development is on Node 24.

`AbortSignal.timeout()` schedules an **unref'd** timer. That is deliberate: a
pending deadline must not hold a program open. Verified identical on both
versions — a script whose only pending work is such a deadline exits in 1ms
under 22 and 24 alike.

In production nothing notices, because the real in-flight fetch holds a socket
open and the loop stays alive until the deadline fires. A stub that returns a
promise and does nothing else gives the loop no reason to stay awake at all, so
it drains and the runner reports "Promise resolution is still pending but the
event loop has already resolved" and cancels the file. Node 24's test runner
happens to keep the loop alive and Node 22's does not, which is the whole of
the difference between green locally and red in CI.

So the stub now holds a ref'd timer for the life of the request it is standing
in for, cleared on every exit. Verified on both: 4 passed, 0 cancelled under
Node 22 and Node 24, and the full workspace suite is clean under Node 22.

The `aborted` check from the previous commit stays. It was a genuine fault —
a listener added after the event has fired never hears it — and would have bitten
as soon as a deadline beat the stub to its first line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ralyodio
ralyodio merged commit d926f64 into main Aug 19, 2026
3 checks passed
@ralyodio
ralyodio deleted the migration-naming branch August 19, 2026 08:47
ralyodio added a commit that referenced this pull request Aug 19, 2026
`0033_author_searches.sql` took the next sequential number, and there is
no next sequential number any more: #131 froze the scheme at 0032 and
added a test that says so, because 0006, 0018 and 0032 were each claimed
by two or more branches before anybody noticed. Git never flags it — the
files have different names — so the guard is the only thing that would.

Renamed to the moment the file was actually written, 2026-08-19 08:18:24
UTC, per `packages/db/migrations/README.md`. Nothing referenced it by
name, and it has never been applied anywhere, so this is a rename rather
than a second migration.

The failure was also hiding the rest of the suite: `pnpm -r` stops at
the first package that fails, so packages/db going red meant ingest,
auth, notify, translate, discover, web and poller never ran in CI at
all. On Node 22, which is what CI uses: 1,067 pass, 0 fail, 0 cancelled,
and `pnpm build` clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ralyodio added a commit that referenced this pull request Aug 19, 2026
#133)

* Find the author when the blog names nobody, by reading the address

/authors/pierre-felgines is a name and nothing else: no email, no site, no
accounts. The pass that produced it was not broken. It fetched felginep.github.io,
found no rel="me", no h-card, no JSON-LD and exactly one outbound link — to the
Jekyll theme its author happened to use — correctly concluded there was nobody
to credit, and stamped the feed as checked.

Meanwhile the author's GitHub account was named in the hostname the feed is
served from, and one request to it returns "Pierre Felgines".

That is the gap this closes, and it is not a corner. Of 369,056 feeds only 3,275
have ever been enriched, and 77% of the identity we hold came free out of feed
documents rather than from visiting anything. Reading what publishers marked up
works beautifully on the part of the small web that marks itself up; this reads
the two things nobody had to publish.

**The host.** A blog on <user>.github.io, <user>.substack.com, medium.com/@user
and seven more names its owner's account in the address. Deriving it is string
arithmetic on URLs already in hand, so a feed on an unrecognised platform costs
nothing extra at all.

**The profile behind it.** GitHub returns a real name, an avatar, a homepage and
sometimes an email its owner chose to publish; GitLab a name and public email;
Codeberg the same through Gitea; and a fediverse account returns its profile
fields with the instance's own rel="me" verification already performed — the
same handshake enrichFeedAuthors spends up to three fetches proving, arriving
done.

Three rules keep this from inventing people, which is the expensive direction:

- An organisation is not a person. GitHub and Gitea serve both from one
  endpoint, so jekyll.github.io resolves to an account whose name is a product.
  The account is still stored as a link — a link is not a claim about who
  somebody is — but no author row is created.
- A derived account is evidence, not proof, and sits below the 0.6 publishing
  floor until a profile answers. A 404 leaves no trace rather than half a person.
- `verified` keeps meaning what the schema says. It is set when the profile
  links *back* at the site being enriched, which is the IndieWeb handshake in
  the other direction, and never merely because an account exists.

safeFetch grew a headers option rather than being bypassed: a profile URL is
built from a hostname read out of somebody else's feed, so it is exactly as
untrusted as a page URL and must keep the private-address guard and the timeout.

GITHUB_TOKEN is wired through the poller and is not optional in practice — the
anonymous GitHub API allows 60 requests an hour per IP, which one batch spends,
against 5,000 with a token.

Measured against the live APIs while building, not assumed: GitLab's
unauthenticated user search answers a reduced object (name and public_email
only, the contact fields needing a token), and Mastodon bios are rendered HTML,
where stripping every tag to a space turns "<b>InfoSec</b>." into "InfoSec ." —
the test caught that before it reached anybody's page.

* Read the file whose only job is to say who made this

Every source the enrichment had was markup carrying identity as a side effect:
a link with a rel attribute, a microformat class, a byline in a feed document.
humans.txt is the one convention written to answer the question directly, and
the pass was walking past it.

Parsed rather than scraped, because the format inverts everything else: the key
is the role and the value is the person ("Chef: Jane Doe"), and the contact
lines that follow belong to the name above them. A parser that collected links
globally would hand Jane's Mastodon to Bob, which is worse than finding nothing
— it is a wrong contact address published as a right one.

Two sections of the file are not this blog's authors. /* THANKS */ credits other
people's work, so reading it as authorship attributes a blog to whoever its
author admires; /* SITE */ describes the build. Both are skipped, and the role
filters still apply, so "Developer: the web team" names nobody.

Fetched only when the ordinary pages named nobody. Most sites do not publish
one, so asking every site costs a request per feed across the directory to help
a minority; asking after the pages come back empty spends it exactly where it
decides between an author and no author. It is also read as text, not HTML —
plenty of servers answer every path with their 404 page, and parsing that would
turn a stylesheet reference into somebody's website.

/now and /uses join the page list for the same reason humans.txt earns a
request: they are conventions of exactly the population this directory indexes,
written in the first person, and a blog that has one often has no /about.

One fix found while testing: a "Site:" line is the most useful in the file and
classifyLink returns null for it, because it rightly refuses to file arbitrary
links as somebody's website. Here the key has already said that is what it is,
so the fallback is scoped to the keys that said so — Standards: and Language:
still produce nothing.

* Buy a search for the people who left no trail, on a budget that cannot be reset

The free sources read what a publisher put where we could find it. Some people
put nothing anywhere, and for them a search engine is the only remaining route.
It is also the one that can spend money and the one that can invent a person,
so almost all of this is what it refuses to do.

**It cannot run over the directory.** The credits come from CrawlProof's 25,000
a month, already shared with CrawlProof's own outreach runner, against 369,056
feeds — one query each would be fifteen times the monthly allowance. So it takes
a key, a non-zero budget and an explicit switch, and is off without all three.

**The budget survives a restart, because it is written down.** A counter in the
process would be reset by every deploy, which is not a budget but a rate limit
with a hole in it. `author_searches` is the ledger and the authority, and it is
counted from the provider's own cycle — this account resets on the 13th, not the
1st, so a calendar month would let the allowance be spent twice across a reset.
The ledger is also the audit trail: which people we spent money looking for, and
whether it found anything, is how the gate gets tuned rather than guessed at.

**It cannot invent a person.** Every query is scoped to the author's own domain
as well as to one network, because the name is the ambiguous part — "Jane Doe"
site:linkedin.com/in returns every Jane Doe, and the blog she writes is what
distinguishes her from them. Results are filtered through classifyLink, so a
company page, a job posting and an article about somebody are all discarded
where a profile is kept. Nothing found this way is ever marked `verified`: that
column means the IndieWeb handshake, and a search engine's opinion that two
strings co-occur is not it. Every link is stamped `web-search` so a consumer can
exclude the class outright.

The gate is mean on purpose — confident it is a person, more than one word of
name, publishes here, and currently unreachable — and it is stated twice, in the
SQL that selects and in `worthSearching` that re-checks, so a drift between them
costs nothing rather than money.

On LinkedIn, since it is the thing that was actually asked for: a profile URL
found this way is stored, because it is a public address the search engine has
already indexed. The profile behind it is not fetched — auth-walled, 999 to
anything automated, and its terms forbid scraping. We can say where somebody's
LinkedIn is; we cannot say what is on it.

Two things the tests caught rather than the reader: `authors` has no `site`
column (it is `site_url`), and `addAuthorLinks` already existed and batches its
inserts into one round trip, so the second one I wrote was deleted rather than
kept.

* Stop losing a publisher for ninety days because their site was down once

Two things in the same area, both found while looking for a queue that turned
out not to be needed.

**A failure was recorded as a miss.** Every fetch in the enrichment fails
softly — a dead host, a timeout and a 503 all come back as "no page" rather than
as an exception — so a site that was simply down looked identical to a site that
names nobody, and both were stamped as checked. That cost the publisher their
enrichment for the whole ninety-day recheck cycle on the strength of one bad
afternoon, and on a pass that has so far reached 3,275 of 369,056 feeds it
quietly loses everyone on a flaky host.

The pass now tracks whether it got an answer out of the publisher at all.
Reached and found nobody: stamped, left alone until the recheck. Never reached:
stamped to come back in a few days, because nothing was learned and the next
attempt may well work. Still stamped either way, which is what keeps a
permanently broken feed off the head of the queue.

Done by back-dating the stamp rather than adding an attempts column, and that is
a trade rather than a shortcut: writes here serialize and the crawl is already
write-bound, so the fix that costs one UPDATE beats the tidier one that costs a
migration and a second column on every read.

**The pass had no row on /crawlstats.** It is the only job on the board that was
invisible, which is a large part of why it looked like it had never started. It
now shows its backlog, how many publishers it looked at in the last hour, and
how far through the directory it is — read off the partial index 0024 already
built, and counted as the stamped set rather than its complement, because 3,275
index entries is a cheap question and 369,056 rows is not.

The queue rebuild this started as is deliberately not here. dueForAuthors
already scopes to active feeds, so the pass has never been walking the 280,360
pending ones, and at the rate now observed the 84,398 active feeds are done in
under a fortnight. A claim/lease table would have been machinery for a problem
the numbers say does not exist.

An existing test caught the new row before it shipped: every job on the board
must report when it last ran, and one that emits an event nobody records is a
row that reads as permanently stalled.

* Name the author-search migration for when it was written

`0033_author_searches.sql` took the next sequential number, and there is
no next sequential number any more: #131 froze the scheme at 0032 and
added a test that says so, because 0006, 0018 and 0032 were each claimed
by two or more branches before anybody noticed. Git never flags it — the
files have different names — so the guard is the only thing that would.

Renamed to the moment the file was actually written, 2026-08-19 08:18:24
UTC, per `packages/db/migrations/README.md`. Nothing referenced it by
name, and it has never been applied anywhere, so this is a rename rather
than a second migration.

The failure was also hiding the rest of the suite: `pnpm -r` stops at
the first package that fails, so packages/db going red meant ingest,
auth, notify, translate, discover, web and poller never ran in CI at
all. On Node 22, which is what CI uses: 1,067 pass, 0 fail, 0 cancelled,
and `pnpm build` clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant