<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: robzepdev</title>
    <description>The latest articles on DEV Community by robzepdev (@robzepdev).</description>
    <link>https://dev.to/robzepdev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4137777%2F874c6eed-962a-4f8e-8d70-5e2e732ca7f8.png</url>
      <title>DEV Community: robzepdev</title>
      <link>https://dev.to/robzepdev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/robzepdev"/>
    <language>en</language>
    <item>
      <title>Five languages, and a page that admits it's mixed</title>
      <dc:creator>robzepdev</dc:creator>
      <pubDate>Thu, 22 Oct 2026 10:24:08 +0000</pubDate>
      <link>https://dev.to/robzepdev/five-languages-and-a-page-that-admits-its-mixed-3lee</link>
      <guid>https://dev.to/robzepdev/five-languages-and-a-page-that-admits-its-mixed-3lee</guid>
      <description>&lt;p&gt;I ship a release tracker called Verdict. It watches 37 technologies, and for every release that matters it writes a summary with an explicit verdict: &lt;em&gt;action required&lt;/em&gt; or &lt;em&gt;safe to upgrade&lt;/em&gt;. Last week I translated its interface into five languages: English, Italian, French, Spanish, German.&lt;/p&gt;

&lt;p&gt;The content it shows cannot be translated. That single constraint decided almost every other thing in this work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the content stays in English
&lt;/h2&gt;

&lt;p&gt;Two kinds of text live on a release page, and neither one can be translated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The release notes&lt;/strong&gt; are the project's own words, kept character for character, because every claim in a summary points at the line it was read from. Translate the notes and the pointer points at nothing: the one check a reader can run on an LLM's output stops working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The summaries&lt;/strong&gt; are generated. They are already the expensive part of the product, and every extra language would be one more model call per release. They run on Claude Haiku 5.5 through the Batch API, which is cheap, but cheap times five languages times every release of 37 technologies is not a rounding error.&lt;/p&gt;

&lt;p&gt;So a release page in Italian carries an Italian interface around an English summary of English notes. It is a mixed page. The honest options were to hide that or to say it, and the page says it: a one-line notice above the verdict, printed only when there is actually a second language on screen: never on an English page, and never on a release that has no notes and no summary to be in another language.&lt;/p&gt;

&lt;h2&gt;
  
  
  The locale goes in the URL because a cookie would cost the whole site
&lt;/h2&gt;

&lt;p&gt;The tempting design is a cookie: no URL changes, no redirects, nothing indexed moves.&lt;/p&gt;

&lt;p&gt;It would also have made every page dynamic. Reading a cookie at the root turns the whole tree into per-request rendering, and the site prerenders its public pages with &lt;code&gt;revalidate = 3600&lt;/code&gt;. I had already refused that price once, for the theme toggle. Paying it for the language would have been the same mistake with a better excuse.&lt;/p&gt;

&lt;p&gt;So: the locale is a path prefix, English has no prefix, and the build prerenders 70 static pages, the same pages as before, times five. The locale arrives as a root param, which any server component can read, and a provider carries it to the client half where the links live.&lt;/p&gt;

&lt;p&gt;One consequence that is easy to miss until it bites: &lt;strong&gt;&lt;code&gt;next/link&lt;/code&gt; is imported in exactly one file&lt;/strong&gt;. A link that forgets the prefix drops the reader back into English halfway through a path, and that is precisely the kind of bug that does not show up in a typecheck. Everywhere else the import reads &lt;code&gt;LocaleLink as Link&lt;/code&gt;, so each component's diff was a single line. Canonicals, &lt;code&gt;hreflang&lt;/code&gt;, the sitemap and &lt;code&gt;robots.txt&lt;/code&gt; are all derived from the same path helper, so they cannot disagree with each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dictionary is 200 lines, not a dependency
&lt;/h2&gt;

&lt;p&gt;I wrote the translation layer instead of installing one: a message module with Zod schemas and &lt;code&gt;{named}&lt;/code&gt; placeholders, a pure &lt;code&gt;createTranslator(locale, dictionary)&lt;/code&gt;, and one file per namespace (seventeen of them by the end).&lt;/p&gt;

&lt;p&gt;Three decisions in there earned their keep.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plural rules come from &lt;code&gt;Intl.PluralRules&lt;/code&gt;.&lt;/strong&gt; The platform already knows the rules for five languages. What was left to write was choosing the form, not the rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fallback lives in the registry, not in the translator.&lt;/strong&gt; Each locale's dictionary is merged over English once, at startup. &lt;code&gt;t&lt;/code&gt; stays a lookup, and the browser receives one object instead of two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An unfilled placeholder stays on screen with its braces.&lt;/strong&gt; &lt;code&gt;{count}&lt;/code&gt; showing up in the UI is ugly, and that is the point: it is the loudest possible way to say the caller forgot an argument.&lt;/p&gt;

&lt;p&gt;And the dictionary is passed down as a prop from the layout. A client component that imported the dictionaries directly would have shipped all five languages in the bundle.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that keeps English out of production
&lt;/h2&gt;

&lt;p&gt;A missing key is the worst kind of i18n bug, because nothing breaks. The string renders in English, on an Italian page, and says nothing about itself. Users read it as a typo and move on.&lt;/p&gt;

&lt;p&gt;So there is a parity test across dictionaries: same keys, same placeholders, and the plural forms that &lt;em&gt;that&lt;/em&gt; language actually selects. Italian needs a &lt;code&gt;many&lt;/code&gt; form for numbers like 1000000; its text is identical to &lt;code&gt;other&lt;/code&gt;, but the test demands it be written, because a missing &lt;code&gt;many&lt;/code&gt; is a half-translation that only shows up on a big number.&lt;/p&gt;

&lt;p&gt;That test is what makes the English fallback a safety net instead of a shipping strategy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Translating the words is the easy half
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The layout is the other half.&lt;/strong&gt; Four verdicts say less in Italian than in English, and not for reasons of style: at the panel's width, the line holds 26 characters including the label. The copy yields; the type scale does not.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;English&lt;/th&gt;
&lt;th&gt;Italian&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;action required&lt;/code&gt; (for n)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{count} interventi&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nothing requires action&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;nessun intervento&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;notes too thin to judge&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;note non giudicabili&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only one of those got better: &lt;em&gt;note non giudicabili&lt;/em&gt; is the name the domain glossary already gave those notes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Grammar pushes back too.&lt;/strong&gt; German needed two verbs for one. &lt;code&gt;folgen&lt;/code&gt; takes the dative, so it works in every sentence and on every button, but it forms no attributive participle: &lt;code&gt;gefolgte Technologien&lt;/code&gt; is not German. Those positions read &lt;code&gt;verfolgt&lt;/code&gt; instead, which is the verb the catalog was already using for tracking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And some words refuse to move.&lt;/strong&gt; &lt;code&gt;featured&lt;/code&gt;, &lt;code&gt;notable&lt;/code&gt;, &lt;code&gt;digest&lt;/code&gt;, &lt;code&gt;preview&lt;/code&gt;, and the prefixes &lt;code&gt;breaking[n]:&lt;/code&gt;, &lt;code&gt;note:&lt;/code&gt;, &lt;code&gt;warning:&lt;/code&gt;, &lt;code&gt;ok:&lt;/code&gt; stay English in every language, because they are the same tokens that appear inside the summaries, which stay English. Translating the label while the content keeps the original word would break the identity between the interface and what it is describing. The landing page's legend &lt;em&gt;is&lt;/em&gt; translated, because the legend is where those words get explained, and explaining them is exactly its job.&lt;/p&gt;

&lt;p&gt;I got the rule wrong on the first pass, and the fix is worth stating: what stays English is the &lt;strong&gt;name as an identifier&lt;/strong&gt;, in code, in the database, in keys and values. Inside a sentence you use the word the language actually uses. "Le Release delle Technology che segui" is not Italian, it is a glossary leaking into prose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four traps, all found by tests
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;usePathname()&lt;/code&gt; returns the rewritten path.&lt;/strong&gt; On an English page it reads &lt;code&gt;/en/faq&lt;/code&gt;, not &lt;code&gt;/faq&lt;/code&gt;. The language picker built its options from that and sent readers to &lt;code&gt;/it/en/faq&lt;/code&gt;, a 404.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The route matcher could not exclude paths containing a dot.&lt;/strong&gt; The usual &lt;code&gt;.*\..*&lt;/code&gt; pattern excludes static files, and with them &lt;code&gt;/technologies/vite/7.1.0&lt;/code&gt;, which is every release page in the product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An unknown path has to stay inside its locale.&lt;/strong&gt; Without a catch-all that calls &lt;code&gt;notFound()&lt;/code&gt;, a bad URL fell out of the locale tree and got the framework's bare 404: no shell, no theme, no footer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The header had to become a client component.&lt;/strong&gt; The error boundary renders it, and the root-params API has no client export, so a header reading the locale on the server broke the build on the error page.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these were visible to the typechecker. All four came out of end-to-end tests against a production build. Those tests were already green before any string moved, which is how I knew the English site had not shifted under me.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I am not claiming
&lt;/h2&gt;

&lt;p&gt;French, Spanish and German have not been reviewed by native speakers. The decision record says so in writing, and the review is on the list before launch, because the alternative is letting three languages pass as verified when they are one developer and a lot of care.&lt;/p&gt;

&lt;p&gt;The legal pages carry one more line that is not a translation: terms and privacy end by saying which language version prevails. Five translations are not five contracts. The three other document pages do not carry it, because there a bad translation is a mistake to fix, not a conflict to arbitrate.&lt;/p&gt;




&lt;p&gt;Verdict is still pre-launch: &lt;a href="https://release-tracker-drab.vercel.app" rel="noopener noreferrer"&gt;release-tracker-drab.vercel.app&lt;/a&gt;. If you want to see the mixed-language page, &lt;a href="https://release-tracker-drab.vercel.app/it/technologies/react/19.3.0" rel="noopener noreferrer"&gt;here is React 19.3.0 in Italian&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>nextjs</category>
      <category>i18n</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Structured output guarantees the shape, not the lengths</title>
      <dc:creator>robzepdev</dc:creator>
      <pubDate>Wed, 07 Oct 2026 07:29:50 +0000</pubDate>
      <link>https://dev.to/robzepdev/structured-output-guarantees-the-shape-not-the-lengths-3p5</link>
      <guid>https://dev.to/robzepdev/structured-output-guarantees-the-shape-not-the-lengths-3p5</guid>
      <description>&lt;p&gt;I run the first real batch of my release tracker on 2026-09-28: 126 releases, one Message Batches&lt;br&gt;
call, done in 17 minutes. The API reported 126 successes out of 126. My pipeline stored 122.&lt;/p&gt;

&lt;p&gt;Four summaries were thrown away by my own validation. Not because the model hallucinated, not&lt;br&gt;
because the JSON was malformed, not because a field was missing. Because the title was six&lt;br&gt;
characters too long.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;IMAGE 1: the Axios 1.19.0 page in production, showing "error: summary unavailable"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The four
&lt;/h2&gt;

&lt;p&gt;Here they are, with their length against a limit of 120:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Release&lt;/th&gt;
&lt;th&gt;Title&lt;/th&gt;
&lt;th&gt;Characters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Axios 1.19.0&lt;/td&gt;
&lt;td&gt;Axios 1.19.0 raises the &lt;code&gt;form-data&lt;/code&gt; floor to fix a CRLF injection advisory and fixes NO_PROXY, interceptor and progress bugs&lt;/td&gt;
&lt;td&gt;124&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node.js 26.8.0&lt;/td&gt;
&lt;td&gt;Node.js 26.8.0 adds ZIP APIs in &lt;code&gt;zlib&lt;/code&gt;, AES-SIV cipher modes, REPL syntax highlighting and new &lt;code&gt;sqlite&lt;/code&gt; statement methods&lt;/td&gt;
&lt;td&gt;121&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Turborepo&lt;/td&gt;
&lt;td&gt;Workspace task orchestration replaces topological chunking, plus remote shared build artifacts and &lt;code&gt;sideEffectsCache&lt;/code&gt; rework.&lt;/td&gt;
&lt;td&gt;125&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pnpm 11.27.0&lt;/td&gt;
&lt;td&gt;pnpm 11.27.0 isolates registry metadata caches per URL path, adds global &lt;code&gt;nodeDownloadMirrors&lt;/code&gt;, and fixes a store symlink flaw&lt;/td&gt;
&lt;td&gt;126&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read them. They are good titles. Every other field was correct: the key points were within the&lt;br&gt;
allowed count, every citation resolved to a real line of the release notes, the link between the&lt;br&gt;
breaking changes and the "action required" verdict held, and &lt;code&gt;stop_reason&lt;/code&gt; was &lt;code&gt;end_turn&lt;/code&gt; on all&lt;br&gt;
four. These were paid, correct summaries, discarded over punctuation-sized overshoot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why structured output did not stop it
&lt;/h2&gt;

&lt;p&gt;My request carries the Zod schema as &lt;code&gt;output_config.format&lt;/code&gt;. Constrained decoding is doing real work&lt;br&gt;
there: I have never once received malformed JSON, a missing required field, or a string where an&lt;br&gt;
array belongs. That part is a guarantee, and it is worth having.&lt;/p&gt;

&lt;p&gt;But the guarantee is about the &lt;strong&gt;shape&lt;/strong&gt; of the JSON, not about the &lt;strong&gt;values&lt;/strong&gt; inside it. A&lt;br&gt;
&lt;code&gt;maxLength&lt;/code&gt; on a string is not a structural constraint that a decoder can enforce token by token,&lt;br&gt;
the way it can enforce that an object closes or that a key is one of five allowed names. By the time&lt;br&gt;
the model is 118 characters into a title, nothing in the machinery is counting, and nothing is going&lt;br&gt;
to stop it at 120.&lt;/p&gt;

&lt;p&gt;So counting characters stays something the model does by eye. And by eye, it misses by a little.&lt;/p&gt;

&lt;p&gt;Not for lack of asking. My prompt is explicit:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Hard limit: 120 characters, spaces included. Count them before answering.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It aimed well: 122 of 126 landed under the limit, and the four that missed were 1 to 6 characters&lt;br&gt;
over. That is not a model that ignored the instruction. That is a model doing arithmetic on a string&lt;br&gt;
it is in the middle of writing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that cannot be expressed at all
&lt;/h2&gt;

&lt;p&gt;Worse than a &lt;code&gt;maxLength&lt;/code&gt; that is ignored is a rule that never reaches the schema in the first place.&lt;/p&gt;

&lt;p&gt;My summary schema has a &lt;code&gt;.refine()&lt;/code&gt; on it: a summary that lists breaking changes cannot also report&lt;br&gt;
"safe to upgrade". That is the whole product in one line, and it is the one rule I would least like&lt;br&gt;
to be decorative.&lt;/p&gt;

&lt;p&gt;It does not survive the trip to JSON Schema. A cross-field invariant is not expressible there at&lt;br&gt;
all, so it exists only in my own parsing step, after the response comes back. On this batch it held&lt;br&gt;
on all 126, and I believe it mostly holds because the prompt makes the relationship obvious. But&lt;br&gt;
"it held 126 times" is an observation, not a guarantee, and I should not confuse the two. The&lt;br&gt;
&lt;code&gt;.refine()&lt;/code&gt; stays, and it stays as the last word: anything that fails it is not shown.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is a gap, not a number
&lt;/h2&gt;

&lt;p&gt;The obvious move is to raise the limit. I did, but the interesting part is what I did &lt;strong&gt;not&lt;/strong&gt; raise.&lt;/p&gt;

&lt;p&gt;The schema maximum went from 120 to 140. &lt;strong&gt;The prompt still asks for 120.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That distance is the entire fix. The model keeps aiming at 120, which is the length I actually want,&lt;br&gt;
and the few characters it overshoots land in a margin instead of destroying a paid summary. If I had&lt;br&gt;
raised the prompt to 140 as well, I would have moved the same problem to 141 and learned nothing.&lt;/p&gt;

&lt;p&gt;The prompt text is untouched, so &lt;code&gt;PROMPT_VERSION&lt;/code&gt; stays at &lt;code&gt;v4&lt;/code&gt; and my evaluation set stays&lt;br&gt;
comparable across the change. A schema widening is not a prompt change, and it was useful that the&lt;br&gt;
versioning made me say which one this was.&lt;/p&gt;

&lt;p&gt;I also considered truncating at the limit. I decided against it: a title cut mid-word is worse than&lt;br&gt;
a title that runs slightly long, and it would hide the problem instead of measuring it. I checked&lt;br&gt;
the layout with a real 140-character title instead: two lines at 1280px, four lines at 390px, no&lt;br&gt;
overflow. The title lives in exactly one place, the &lt;code&gt;&amp;lt;h2&amp;gt;&lt;/code&gt; of the release page, which made that&lt;br&gt;
cheap to verify.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;IMAGE 2: React 19.3.0 in production, a title that fit, with its five key points and line citations&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Do not pay twice for the same answer
&lt;/h2&gt;

&lt;p&gt;The four summaries existed. They were generated, they were billed, and they were correct. Resending&lt;br&gt;
them would have cost about 12 cents, which is nothing, and would also have been the wrong habit.&lt;/p&gt;

&lt;p&gt;So the repair reads the saved batch results from disk, revalidates them through exactly the same&lt;br&gt;
parser the pages use, and writes &lt;code&gt;UPDATE&lt;/code&gt; statements to a &lt;code&gt;.sql&lt;/code&gt; file. All four pass at 140. The&lt;br&gt;
script touches no database: applying the file stays a deliberate gesture in the SQL editor of the&lt;br&gt;
environment I mean.&lt;/p&gt;

&lt;p&gt;There is a trap hiding in that sentence, and it nearly cost me the option. The &lt;code&gt;expires_at&lt;/code&gt; of a&lt;br&gt;
batch is &lt;strong&gt;24 hours from creation&lt;/strong&gt;, not the result retention period of 29 days that the&lt;br&gt;
documentation makes easy to assume. I had saved the 126 results to a local file the same day, out of&lt;br&gt;
habit rather than foresight, and that habit is the only reason this repair was free. Save the&lt;br&gt;
results file. It costs nothing and it buys you every repair you have not thought of yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cost to learn
&lt;/h2&gt;

&lt;p&gt;The batch billed $4.16: a mean of $0.033 per summary, a median of $0.0245. I had estimated $2.94,&lt;br&gt;
measured on 14 releases I had picked by hand. The median was nearly right. The tail was not: real&lt;br&gt;
release notes are longer than the ones I chose to test with, and the ten most expensive summaries&lt;br&gt;
were 21% of the spend. That is a separate lesson and it deserves its own post.&lt;/p&gt;

&lt;p&gt;The 3% discard is what I actually came away with. It is not a bug I fixed, it is a number I did not&lt;br&gt;
have before: the measured distance between what structured output promises and what I had assumed it&lt;br&gt;
promised.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honest status at the time of writing:&lt;/strong&gt; that SQL has not been applied yet, so those four releases&lt;br&gt;
still read "summary unavailable" in production. The screenshot above is the live page, not a staged&lt;br&gt;
one. The repair is written and verified; running it is a manual step still sitting in my queue.&lt;/p&gt;




&lt;p&gt;The tracker is a side project: it watches releases of languages, frameworks and libraries and turns&lt;br&gt;
their release notes into summaries that say whether you need to act before upgrading. Everything&lt;br&gt;
above is from the real run, not a reconstruction.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Enforce your free-plan limit in the database, not in the UI</title>
      <dc:creator>robzepdev</dc:creator>
      <pubDate>Mon, 28 Sep 2026 10:29:19 +0000</pubDate>
      <link>https://dev.to/robzepdev/enforce-your-free-plan-limit-in-the-database-not-in-the-ui-330p</link>
      <guid>https://dev.to/robzepdev/enforce-your-free-plan-limit-in-the-database-not-in-the-ui-330p</guid>
      <description>&lt;p&gt;Your free plan says "up to 10". Where does that 10 actually live?&lt;/p&gt;

&lt;p&gt;In most apps I have read, it lives in the interface: a button that disables itself at ten, a count in a store, maybe a check in the route handler that writes the row. That is a suggestion, not a limit. A second tab, a page left open since yesterday, a direct call to the API with a token from devtools, and the eleventh row is in the table.&lt;/p&gt;

&lt;p&gt;I put mine in Postgres. This is what that took, and the three things I got wrong on the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;The product is a release tracker: you follow the technologies in your stack and it tells you what shipped and whether upgrading needs work. The Free plan follows up to 10.&lt;/p&gt;

&lt;p&gt;Follows are the one thing a signed in developer writes, and row level security already limits every row to their own user id, so the browser talks to the database directly. There is no route of mine in the middle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;follow&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;follows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;developerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;technology_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;technologyId&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;follows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;technology_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;technologyId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a deliberate choice, and it is what makes the rest of this possible: if the database refuses, the refusal arrives in the mutation that asked, not in a log I will read next week.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule, in the database
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enforce_follow_rules&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="k"&gt;trigger&lt;/span&gt;
&lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;
&lt;span class="k"&gt;security&lt;/span&gt; &lt;span class="k"&gt;definer&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;search_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;
&lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;declare&lt;/span&gt;
  &lt;span class="n"&gt;followed&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;begin&lt;/span&gt;
  &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;retired&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;technologies&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;technology_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="n"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;exception&lt;/span&gt; &lt;span class="s1"&gt;'A retired Technology cannot be followed'&lt;/span&gt;
      &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="n"&gt;errcode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'FRTRD'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="n"&gt;perform&lt;/span&gt; &lt;span class="n"&gt;pg_advisory_xact_lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hashtextextended&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plans&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'free'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'free'&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="n"&gt;followed&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;follows&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="n"&gt;followed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
      &lt;span class="n"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;exception&lt;/span&gt; &lt;span class="s1"&gt;'The Free Plan follows at most 10 Technologies'&lt;/span&gt;
        &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="n"&gt;errcode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'FLIMT'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;trigger&lt;/span&gt; &lt;span class="n"&gt;enforce_follow_rules&lt;/span&gt;
  &lt;span class="k"&gt;before&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;follows&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;each&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;execute&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enforce_follow_rules&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details in there are worth more than the rest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The advisory lock.&lt;/strong&gt; Without it the whole thing is theatre. Two tabs inserting at the same moment both run the count, both read nine, both pass. &lt;code&gt;pg_advisory_xact_lock&lt;/code&gt; on a hash of the user id serialises the decision per developer and releases at the end of the transaction. It is exactly the "added from another session" case the feature is supposed to survive, and it is the one I would have shipped broken if I had not written a test that inserts a follow from outside the browser first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;security definer&lt;/code&gt;.&lt;/strong&gt; The trigger applies to the service role too. That is what I want, and it also means my own test fixtures cannot set up a followed retired technology directly: they have to follow first and retire after, which is the only way the case appears in real life anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The plan check.&lt;/strong&gt; The limit is for &lt;code&gt;free&lt;/code&gt; only. What a paid plan allows is not decided yet, and the trigger is the place where that decision will land.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refusals should be codes, not sentences
&lt;/h2&gt;

&lt;p&gt;The interesting part is not raising the error, it is getting it to the row that has to explain itself.&lt;/p&gt;

&lt;p&gt;I gave each rule an SQLSTATE of my own: &lt;code&gt;FLIMT&lt;/code&gt; for the limit, &lt;code&gt;FRTRD&lt;/code&gt; for a retired technology. PostgREST carries the code to the browser in the error object, so the page never parses a message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BY_CODE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;FollowRejection&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;FLIMT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;limit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;FRTRD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;retired&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;followRejectionFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;FollowRejection&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;BY_CODE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything I did not plan for, a dropped connection or an expired session, becomes &lt;code&gt;unknown&lt;/code&gt;, which still has a remedy to offer ("try again") instead of failing silently. Two rules, three outcomes, and the wording of the database error stays where it belongs: in the console.&lt;/p&gt;

&lt;p&gt;Pick codes in the &lt;code&gt;XX000&lt;/code&gt; custom space and write them down somewhere the next person will look. Mine are in the migration, in a comment right above the function.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug the first real refusal found
&lt;/h2&gt;

&lt;p&gt;The UI follows optimistically: the row flips, the count moves, and if the database says no the previous state goes back. I had built that with TanStack Query, with one &lt;code&gt;useMutation&lt;/code&gt; inside the follow toggle component.&lt;/p&gt;

&lt;p&gt;It worked perfectly until the first refusal arrived, and then the refusal was invisible.&lt;/p&gt;

&lt;p&gt;One mutation per row means the error lives in the component that was pressed. The row that has to show "this technology is retired" is a sibling; it never sees it. Worse, the count in the top bar is computed somewhere else again, so three parts of the same page can each hold a different idea of what just happened.&lt;/p&gt;

&lt;p&gt;The fix is dull and it is the whole lesson: one mutation for the page, in the provider, exposed through context.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Follows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;following&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;atLimit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;following&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;FREE_FOLLOW_LIMIT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;isFollowing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;technologyId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;following&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;technologyId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;toggle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;technologyId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mutate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;technologyId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;follow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;following&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;technologyId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;technologyId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;technologyId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;technologyId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;rejectionOn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;technologyId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;technologyId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;rejected&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;rejection&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The count, the block at ten and the refusal now come from one place, so they cannot drift apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the interface still owes you
&lt;/h2&gt;

&lt;p&gt;Enforcing the rule in the database does not excuse the UI from anticipating it. At ten of ten the unpressed buttons get &lt;code&gt;aria-disabled="true"&lt;/code&gt; and point at a note that says what to do, and they stay in the tab order. A real &lt;code&gt;disabled&lt;/code&gt; would take them out of it, and a keyboard user would meet a control that has silently vanished and no explanation of why.&lt;/p&gt;

&lt;p&gt;There is a nice side effect. Playwright refuses to click a control marked &lt;code&gt;aria-disabled&lt;/code&gt;, so the test has to say &lt;code&gt;click({ force: true })&lt;/code&gt;. That line is the proof that the block is there: if the attribute went missing, the forced click would go through and the assertion after it would fail.&lt;/p&gt;

&lt;p&gt;The number 10 is now written in two places, &lt;code&gt;FREE_FOLLOW_LIMIT&lt;/code&gt; in the code and the trigger in the migration. That duplication bothers me a little, and it is the correct trade: the one in the migration is the one that decides, the one in the code is how the page explains itself before asking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth it
&lt;/h2&gt;

&lt;p&gt;The trigger is 25 lines. It ships with the schema, it applies to every client that will ever exist, including the ones I have not written yet, and it turned a plan limit from a claim into a fact.&lt;/p&gt;

&lt;p&gt;What it does not do is tell your users anything. That is still the interface's job, and the trigger only makes it easier, because now the page reacts to a rule instead of pretending to be one.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>webdev</category>
      <category>typescript</category>
      <category>supabase</category>
    </item>
    <item>
      <title>How I keep an LLM from inventing breaking changes</title>
      <dc:creator>robzepdev</dc:creator>
      <pubDate>Thu, 24 Sep 2026 14:13:05 +0000</pubDate>
      <link>https://dev.to/robzepdev/how-i-keep-an-llm-from-inventing-breaking-changes-5625</link>
      <guid>https://dev.to/robzepdev/how-i-keep-an-llm-from-inventing-breaking-changes-5625</guid>
      <description>&lt;p&gt;I'm building a tool that reads every new release of React, TypeScript, Vite and friends, and tells you two things: what changed, and whether you need to touch your code before upgrading.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3ed01ig55fyna5a49m88.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3ed01ig55fyna5a49m88.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The summarising part is easy. Any decent model will write you a tidy paragraph about a release.&lt;/p&gt;

&lt;p&gt;The part that decides whether the tool is worth anything is different: &lt;strong&gt;when it says "action required", is that true?&lt;/strong&gt; A tracker that invents a breaking change is worse than no tracker, because you'll go looking for a migration that doesn't exist. And a tracker that misses one is worse still.&lt;/p&gt;

&lt;p&gt;Here is everything I do about that, in the order I had to learn it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The model only sees the release notes
&lt;/h2&gt;

&lt;p&gt;The system prompt says it in the second line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Use only the release notes in the user message. Do not use what you know about the project from elsewhere.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This matters more than it sounds. A model asked about "React 19.2.0" has opinions about React 19 from training. Those opinions are often right, which is exactly what makes them dangerous: they're right until the release where they aren't, and you have no way to tell which one you're looking at.&lt;/p&gt;

&lt;p&gt;So the notes go into the user message, wrapped in a tag, and nothing else about the project does.&lt;/p&gt;

&lt;p&gt;Two consequences follow immediately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No notes, no summary.&lt;/strong&gt; If the release has no notes, the request is never built. The release stays in the catalogue with no summary rather than getting a plausible one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notes are never truncated.&lt;/strong&gt; There's a hard ceiling of 80,000 characters. Over it, the summary fails with a reason instead of being generated from the first half. A truncated summary is a confidently wrong one: the breaking change was probably in the part you cut.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;release notes are empty&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_NOTES_LENGTH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`release notes are &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; characters, over the &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;MAX_NOTES_LENGTH&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; limit`&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are failures I'd rather see than paper over.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. "Action required" is a definition, not a judgement
&lt;/h2&gt;

&lt;p&gt;The prompt doesn't ask the model whether you should act. It asks for a list:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"breakingChanges": every change the notes state or clearly describe as breaking, removed or incompatible, so that users must change code, configuration or runtime to upgrade.&lt;/p&gt;

&lt;p&gt;"actionRequired": true exactly when "breakingChanges" is not empty.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And then, flatly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Never infer a breaking change the notes do not mention, even if the version number suggests one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That last line earns its place. Without it, a &lt;code&gt;x.0.0&lt;/code&gt; invites the model to reason "this is a major, majors break things" and produce something. Version numbers are a &lt;em&gt;classification&lt;/em&gt; input for me, never evidence about content.&lt;/p&gt;

&lt;p&gt;The output shape is constrained with a schema, so a reply that isn't that shape never reaches the database. But keep reading, schemas do less than you think.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. I never ask the model for line numbers
&lt;/h2&gt;

&lt;p&gt;Each key point and each breaking change links back into the official notes, to the line it came from. On the page it looks like &lt;code&gt;release-notes:20&lt;/code&gt;, and clicking it opens the notes, scrolls to line 20 and puts a caret at the right column.&lt;/p&gt;

&lt;p&gt;The obvious implementation is to ask the model for the line number. Don't. Models count badly, and, worse, they count badly &lt;em&gt;invisibly&lt;/em&gt;: you get &lt;code&gt;42&lt;/code&gt;, it looks like a line number, and it's off by nine.&lt;/p&gt;

&lt;p&gt;So the model returns a &lt;strong&gt;verbatim quote&lt;/strong&gt; instead:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A "quote" is the stretch of the release notes the item comes from, copied character for character [...] Never adjust, shorten with an ellipsis or rewrite a quote to fit: a quote that does not appear verbatim in the notes is dropped.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And I compute the position myself, by searching my stored copy of the notes for that string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;locateCitation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// → { fromLine, toLine, column, length } | null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxo8tgp7e2ccnuyvo5e7t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxo8tgp7e2ccnuyvo5e7t.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;null&lt;/code&gt; means the quote isn't in the notes. Which means the model made it up. Which means no pointer is rendered.&lt;/p&gt;

&lt;p&gt;This is the part I'm most pleased with, because of what it collapses. "The model must not hallucinate citations" and "references must stay inside the document" sound like two separate things to test. They're the same thing, and it's a string search. The column offset for the caret falls out for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Then I stopped guessing and measured
&lt;/h2&gt;

&lt;p&gt;Everything above is reasoning about a prompt. Reasoning about a prompt is how you end up with a prompt that feels great and is wrong.&lt;/p&gt;

&lt;p&gt;So: an evaluation set. 14 real releases, from 8 projects, 5 of them not even in my catalogue, so I couldn't accidentally tune the product to the test. The notes are &lt;strong&gt;copied into the repo&lt;/strong&gt;, not read from the database. The catalogue changes; the set must not, or two reports a month apart mean nothing.&lt;/p&gt;

&lt;p&gt;The human judgement lives in a &lt;code&gt;set.json&lt;/code&gt; next to them, and it's more nuanced than a pass/fail:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;required&lt;/strong&gt; breaking changes: miss one and the case fails;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tolerated&lt;/strong&gt; breaking changes: legitimately arguable, listing one isn't an error;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;actionRequired&lt;/code&gt;, which can be &lt;code&gt;true&lt;/code&gt;, &lt;code&gt;false&lt;/code&gt;, or &lt;strong&gt;&lt;code&gt;"either"&lt;/code&gt;&lt;/strong&gt; for releases whose notes describe a behaviour change without ever calling it breaking. Some releases genuinely are a judgement call, and pretending otherwise just bakes my mood on the day into the test.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The comparison itself is a pure function with its own unit tests. The script around it only does I/O. Two prompt versions, one batch each:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;v3&lt;/th&gt;
&lt;th&gt;v4&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;cases evaluated&lt;/td&gt;
&lt;td&gt;13/14&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14/14&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"action required" correct&lt;/td&gt;
&lt;td&gt;13/13&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14/14&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;missing breaking changes&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;invented citations&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;verbatim quotes&lt;/td&gt;
&lt;td&gt;66 of 72&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;77 of 81&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Six of the fourteen are deliberate duds: patch releases whose notes say nothing but "see the changelog". All six came back "no action needed". That was the point of including them: I wanted evidence that the model shuts up when there's nothing to say, not just that it's eloquent when there is.&lt;/p&gt;

&lt;p&gt;Two full runs cost about 50 cents through the Batch API. Roughly $0.02 a summary.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Two bugs that only a real call could find
&lt;/h2&gt;

&lt;p&gt;Both of these survived a fake adapter, unit tests and integration tests. Both died within minutes of the first real batch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Line endings.&lt;/strong&gt; Release notes fetched from GitHub contain &lt;code&gt;CRLF&lt;/code&gt;. The model quotes them back with &lt;code&gt;LF&lt;/code&gt;. The page searches the normalised text. So any quote spanning more than one line silently failed to locate. No crash, no log, just a pointer that quietly wasn't there. In production. &lt;code&gt;locateCitation&lt;/code&gt; now normalises both sides.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A sixth key point.&lt;/strong&gt; The prompt asks for 3 to 5 key points, and the schema says so too. A reply came back with six, failed validation, and threw away a summary I had already paid for.&lt;/p&gt;

&lt;p&gt;The lesson is worth stating plainly: &lt;strong&gt;structured output constrains the shape of a reply, never its counts.&lt;/strong&gt; You can guarantee "an array of objects with these fields". You cannot guarantee "at most five". So now the parser keeps the first five — they arrive in order of importance — and the prompt states the limit as a hard rule with the consequence spelled out. That change is what took the prompt to v4, and it's why the "cases evaluated" row went from 13 to 14.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Running out of credit is not a failure
&lt;/h2&gt;

&lt;p&gt;A smaller thing, but it's the same instinct. When the Anthropic account runs out of credit, the affected releases go back to &lt;code&gt;pending&lt;/code&gt;, not to &lt;code&gt;failed&lt;/code&gt;. &lt;code&gt;failed&lt;/code&gt; is final — those summaries would never have regenerated, even after topping up.&lt;/p&gt;

&lt;p&gt;And the case is &lt;strong&gt;typed&lt;/strong&gt;, not sniffed out of an error message: &lt;code&gt;submit&lt;/code&gt; throws a &lt;code&gt;CreditExhaustedError&lt;/code&gt;, &lt;code&gt;collect&lt;/code&gt; returns &lt;code&gt;credit_exhausted&lt;/code&gt;. "Other errors must not be mistaken for exhausted credit" is then something the compiler enforces, rather than something I promise.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd keep
&lt;/h2&gt;

&lt;p&gt;If I did this again from scratch, three things carry over:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ground the model in a document you also store.&lt;/strong&gt; Not because the model is untrustworthy, but because it gives you something to check the answer &lt;em&gt;against&lt;/em&gt;, mechanically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask for quotes, derive everything else.&lt;/strong&gt; Anything positional the model computes is a silent error waiting to happen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spend the fifty cents.&lt;/strong&gt; A fake adapter tells you your code works. A real call tells you your assumptions do. I had two bugs in production-bound code that no amount of local testing was ever going to surface.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The tracker is live and collecting releases while I build the rest of it. The summaries aren't switched on in production yet — that's the next thing, and it's the first part of this project that costs money to leave running.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://release-tracker-drab.vercel.app/technologies/react" rel="noopener noreferrer"&gt;https://release-tracker-drab.vercel.app/technologies/react&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>TypeScript never ships x.y.0: classifying releases the way projects actually version</title>
      <dc:creator>robzepdev</dc:creator>
      <pubDate>Tue, 22 Sep 2026 14:13:13 +0000</pubDate>
      <link>https://dev.to/robzepdev/typescript-never-ships-xy0-classifying-releases-the-way-projects-actually-version-56a</link>
      <guid>https://dev.to/robzepdev/typescript-never-ships-xy0-classifying-releases-the-way-projects-actually-version-56a</guid>
      <description>&lt;p&gt;I'm building a tracker that tells developers which releases of their stack actually matter.&lt;br&gt;
The first problem wasn't AI or scraping. It was a deceptively simple question:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Given a version string, how important is this release?&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Why "just use SemVer" doesn't work
&lt;/h2&gt;

&lt;p&gt;The naive rule: &lt;code&gt;x.0.0&lt;/code&gt; is a major, &lt;code&gt;x.y.0&lt;/code&gt; is a minor, everything else is a patch.&lt;br&gt;
It holds for many libraries, and breaks for some of the most popular ones.&lt;/p&gt;

&lt;p&gt;I ended up with one &lt;em&gt;classification strategy&lt;/em&gt; per project, mapping a version&lt;br&gt;
to one of four buckets: &lt;strong&gt;Spotlight&lt;/strong&gt; (you should look), &lt;strong&gt;Latest&lt;/strong&gt; (new stuff),&lt;br&gt;
&lt;strong&gt;Digest&lt;/strong&gt; (patches, batched weekly) and &lt;strong&gt;Preview&lt;/strong&gt; (beta and rc).&lt;/p&gt;
&lt;h3&gt;
  
  
  1. &lt;code&gt;semver&lt;/code&gt;: the default
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;x.0.0&lt;/code&gt; → Spotlight, &lt;code&gt;x.y.0&lt;/code&gt; → Latest, patches → Digest. Vite fits perfectly:&lt;br&gt;
after 8.0.0 came 16 patches, 8.0.1 through 8.0.16, in under three months.&lt;br&gt;
A good tracker shows you 8.0.0 and quietly folds the rest into a digest.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. &lt;code&gt;node&lt;/code&gt;: even majors become LTS
&lt;/h3&gt;

&lt;p&gt;Node's even majors (20, 22, 24) become Long Term Support; odd ones don't.&lt;br&gt;
So an even &lt;code&gt;x.0.0&lt;/code&gt; goes to Spotlight, while odd majors and &lt;code&gt;x.y.0&lt;/code&gt; go to Latest.&lt;br&gt;
Same version shape, different meaning.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. &lt;code&gt;typescript&lt;/code&gt;: there is no stable x.y.0
&lt;/h3&gt;

&lt;p&gt;TypeScript doesn't follow SemVer: every &lt;code&gt;x.y&lt;/code&gt; can contain breaking changes.&lt;br&gt;
And the numbering is unusual:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;5.9.0-beta → 5.9.1-rc → 5.9.2   ← first stable 5.9
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A rule that looks for &lt;code&gt;x.y.0&lt;/code&gt; would never flag TypeScript 5.9 at all.&lt;br&gt;
So the strategy is: &lt;strong&gt;the first stable version of each &lt;code&gt;x.y&lt;/code&gt; that the sources observed is Spotlight&lt;/strong&gt;;&lt;br&gt;
later ones are Digest. The strategy has to look at history, not just the string.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. &lt;code&gt;zerover&lt;/code&gt;: in 0.x the minor breaks
&lt;/h3&gt;

&lt;p&gt;For libraries still in &lt;code&gt;0.x&lt;/code&gt;, &lt;code&gt;0.y.0&lt;/code&gt; is effectively a major.&lt;br&gt;
So &lt;code&gt;0.y.0&lt;/code&gt; → Spotlight, other &lt;code&gt;0.y.z&lt;/code&gt; → Digest, and from &lt;code&gt;1.0.0&lt;/code&gt; on it falls back to &lt;code&gt;semver&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Previews and noise
&lt;/h2&gt;

&lt;p&gt;Every strategy treats pre-releases the same way. Only suffixes that are exactly&lt;br&gt;
&lt;code&gt;beta&lt;/code&gt; or &lt;code&gt;rc&lt;/code&gt; (optionally numbered, like &lt;code&gt;-rc.1&lt;/code&gt;) count as previews.&lt;br&gt;
Canary, nightly, experimental, alpha and hash- or date-stamped builds&lt;br&gt;
(&lt;code&gt;-rc.0-next-…&lt;/code&gt;) aren't tracked at all. They're the bulk of the noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep it pure, test it on real versions
&lt;/h2&gt;

&lt;p&gt;Each strategy is a pure TypeScript function, with no I/O,&lt;br&gt;
tested against real version histories from npm and GitHub.&lt;br&gt;
Real data catches what toy examples don't: TypeScript's &lt;code&gt;.2&lt;/code&gt;, React's&lt;br&gt;
&lt;code&gt;-rc.0-next-&amp;lt;hash&amp;gt;&lt;/code&gt; tags, &lt;code&gt;v&lt;/code&gt;-prefixed tags.&lt;/p&gt;

&lt;p&gt;And when a rule is still wrong for an edge case, a manual override lives&lt;br&gt;
in the database and is never overwritten by the next sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Classification decides &lt;em&gt;what deserves your attention&lt;/em&gt;. The next step decides&lt;br&gt;
&lt;em&gt;what to do about it&lt;/em&gt;: an AI summary of each important release, with breaking changes,&lt;br&gt;
migration steps and an explicit &lt;strong&gt;Action required / Safe to upgrade&lt;/strong&gt; verdict.&lt;br&gt;
It will be grounded only in the official release notes. If they don't mention&lt;br&gt;
a breaking change, neither will the summary.&lt;/p&gt;

&lt;p&gt;You can see the classification at work on an early preview:&lt;br&gt;
&lt;a href="https://release-tracker-drab.vercel.app/technologies/vite" rel="noopener noreferrer"&gt;Vite's release page&lt;/a&gt;: 8.0.0 up front,&lt;br&gt;
the 16 patches folded into a digest. Name and domain are still provisional.&lt;/p&gt;

&lt;p&gt;I'm building this in public. If your stack has a versioning scheme that would&lt;br&gt;
break these rules, I'd love to hear about it in the comments.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>versioning</category>
    </item>
  </channel>
</rss>
