<?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: xiaoxu</title>
    <description>The latest articles on DEV Community by xiaoxu (@_3d0d77143d405f723e74f6).</description>
    <link>https://dev.to/_3d0d77143d405f723e74f6</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%2F4015912%2Ff08508ef-f72e-433c-a42d-8cf4714c60fa.png</url>
      <title>DEV Community: xiaoxu</title>
      <link>https://dev.to/_3d0d77143d405f723e74f6</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/_3d0d77143d405f723e74f6"/>
    <language>en</language>
    <item>
      <title>Testing Binary Image Responses Beyond HTTP 200</title>
      <dc:creator>xiaoxu</dc:creator>
      <pubDate>Sun, 09 Aug 2026 14:22:23 +0000</pubDate>
      <link>https://dev.to/_3d0d77143d405f723e74f6/testing-binary-image-responses-beyond-http-200-22o7</link>
      <guid>https://dev.to/_3d0d77143d405f723e74f6/testing-binary-image-responses-beyond-http-200-22o7</guid>
      <description>&lt;h1&gt;
  
  
  Testing Binary Image Responses Beyond HTTP 200
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;HTTP 200 proves that a handler returned successfully. It does not prove that a downloaded image is decodable, uses the requested format, has the declared dimensions, carries the right filename, or can be represented correctly by the client.&lt;/p&gt;

&lt;p&gt;Binary transformation APIs often split their result across two channels: bytes in the body and metadata in headers. If those channels drift, the image can still open while the UI reports the wrong size or filename.&lt;/p&gt;

&lt;p&gt;I tested a production Next.js image route as one body-plus-headers contract instead of treating status as the outcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built or tested
&lt;/h2&gt;

&lt;p&gt;The route accepts multipart input, validates the file, resolves output format and quality, and passes bytes to a Sharp-based processor. On success it returns the encoded body plus:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Content-Type&lt;/code&gt; and &lt;code&gt;Content-Disposition&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Cache-Control: no-store&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;original and processed byte counts;&lt;/li&gt;
&lt;li&gt;original and output formats;&lt;/li&gt;
&lt;li&gt;a sanitized output filename;&lt;/li&gt;
&lt;li&gt;decoded output width and height.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The browser adapter consumes those headers to construct its result object. They are not decorative diagnostics; they drive the download and result UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;The private experiment generated a synthetic 40-by-24 PNG in memory, wrapped it in a &lt;code&gt;File&lt;/code&gt;, and sent it to the real route handler through &lt;code&gt;NextRequest&lt;/code&gt;:&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;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FormData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;file&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;File&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fixture.png&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;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image/png&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;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;outputFormat&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;webp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;quality&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;80&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nc"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;form&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;Direct handler invocation avoids starting a server while still exercising multipart parsing, validation, format resolution, processing, and response construction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step-by-step walkthrough
&lt;/h2&gt;

&lt;p&gt;Validate declarations against independently decoded bytes:&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%2Ftuz0rolwd7qxaci2oi6w.webp" 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%2Ftuz0rolwd7qxaci2oi6w.webp" alt="Mermaid diagram 1" width="539" height="1021"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The test accepts success only when encoded bytes and declared metadata agree.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Decode the body
&lt;/h3&gt;

&lt;p&gt;Do not infer format from &lt;code&gt;Content-Type&lt;/code&gt;. Pass the returned bytes to an independent decoder:&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;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sharp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toMatchObject&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;webp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;24&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;This catches a handler that sends PNG bytes with a WebP header or reports stale dimensions after resizing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reconcile byte counts
&lt;/h3&gt;

&lt;p&gt;The original-size header should equal the uploaded file size. The processed-size header should equal the response body's actual byte length:&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;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-original-size&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;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byteLength&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-processed-size&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;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byteLength&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Avoid asserting that processed bytes must be smaller. Conversion and compression settings can legitimately increase a tiny or already optimized image.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verify filename and policy
&lt;/h3&gt;

&lt;p&gt;The response should declare both an attachment filename and the custom filename consumed by the client. Those values should agree with the requested output format.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Cache-Control: no-store&lt;/code&gt; is a separate contract. Correct image bytes do not prove that an intermediary or browser receives the intended retention instruction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Exercise a non-binary error
&lt;/h3&gt;

&lt;p&gt;A missing-file request returns status 400 with a JSON error. Test content type and message before attempting binary decoding. Clients need distinct parsing paths for structured errors and successful Blobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What went wrong
&lt;/h2&gt;

&lt;p&gt;A status-only assertion would have passed even if every custom header were empty. The browser adapter would then use local fallbacks for filename, size, format, and dimensions, potentially hiding a server regression.&lt;/p&gt;

&lt;p&gt;The reverse is also possible: headers can look correct while the body is corrupt or encoded in another format. Neither channel can validate the other unless the test decodes and reconciles them.&lt;/p&gt;

&lt;p&gt;The experiment verified direct handler behavior, not XMLHttpRequest upload progress or a deployed proxy. An intermediary could alter &lt;code&gt;Content-Disposition&lt;/code&gt;, expose fewer headers cross-origin, or change caching behavior. Keep one transport-level test for the deployed shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix or mitigation
&lt;/h2&gt;

&lt;p&gt;Use a contract matrix:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Assertion source&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;status&lt;/td&gt;
&lt;td&gt;response&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;encoded format&lt;/td&gt;
&lt;td&gt;independent body decoder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dimensions&lt;/td&gt;
&lt;td&gt;decoder compared with headers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;original bytes&lt;/td&gt;
&lt;td&gt;uploaded fixture compared with header&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;processed bytes&lt;/td&gt;
&lt;td&gt;response buffer compared with header&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;filename&lt;/td&gt;
&lt;td&gt;attachment and custom filename headers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;retention&lt;/td&gt;
&lt;td&gt;cache-control header&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;failure&lt;/td&gt;
&lt;td&gt;non-2xx content type and structured payload&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Keep fixture dimensions small and deterministic. Assert relationships rather than hardcoded compression ratios. For resize routes, calculate expected dimensions from a known input and requested options.&lt;/p&gt;

&lt;p&gt;On the client, retain sensible fallbacks for resilience, but test the server contract strictly so fallbacks do not mask regressions in CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;p&gt;Direct route tests are fast and focused, but they bypass a real HTTP server, reverse proxy, CORS configuration, and browser download behavior. Pair them with a smaller end-to-end suite.&lt;/p&gt;

&lt;p&gt;Custom headers make the client simple, yet every added field becomes versioned API surface. Prefer a minimal set that the UI actually consumes.&lt;/p&gt;

&lt;p&gt;Independent image decoding adds native-library cost to tests. It is worthwhile at the binary boundary because string or snapshot assertions cannot validate encoded artifacts.&lt;/p&gt;

&lt;p&gt;Exact encoded byte lengths can vary across library versions. Compare each header with the body generated in the same run rather than pinning an absolute size.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I verified it
&lt;/h2&gt;

&lt;p&gt;The production route returned a 124-byte WebP from a 150-byte generated PNG. Independent decoding reported WebP, 40-by-24 pixels.&lt;/p&gt;

&lt;p&gt;Every metadata field matched the same artifact: content type &lt;code&gt;image/webp&lt;/code&gt;, filename &lt;code&gt;fixture.webp&lt;/code&gt;, original format PNG, output format WebP, width 40, height 24, and byte-count headers equal to their buffers. The cache policy was &lt;code&gt;no-store&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The negative request returned 400, JSON content type, and &lt;code&gt;No image file received.&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Existing browser tests provide the outer evidence: they save actual download URLs and decode output format and dimensions. The direct experiment adds exact route-response reconciliation without claiming deployed proxy behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;A binary API response is not just its body, and success is not just its status.&lt;/p&gt;

&lt;p&gt;Decode the returned artifact, compare it with every client-visible metadata field, verify filename and cache policy, and test a structured error branch. Keep browser coverage for transport-specific behavior.&lt;/p&gt;

&lt;p&gt;That gives one answer to the only question users care about: does the downloaded file—and everything the UI says about it—describe the same real result?&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Preventing Silent File Loss from Duplicate ZIP Entry Names</title>
      <dc:creator>xiaoxu</dc:creator>
      <pubDate>Thu, 06 Aug 2026 04:55:30 +0000</pubDate>
      <link>https://dev.to/_3d0d77143d405f723e74f6/preventing-silent-file-loss-from-duplicate-zip-entry-names-4pf5</link>
      <guid>https://dev.to/_3d0d77143d405f723e74f6/preventing-silent-file-loss-from-duplicate-zip-entry-names-4pf5</guid>
      <description>&lt;h1&gt;
  
  
  Preventing Silent File Loss from Duplicate ZIP Entry Names
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;A batch converter can report that two files finished, generate a valid ZIP, and still deliver only one file.&lt;/p&gt;

&lt;p&gt;The failure appears when distinct inputs converge on the same output name. Convert &lt;code&gt;photo.jpg&lt;/code&gt; and &lt;code&gt;photo.png&lt;/code&gt; to WebP and both become &lt;code&gt;photo.webp&lt;/code&gt;. If the archive builder uses that name as a key twice, the second payload replaces the first. There is no malformed archive and no required exception—just silent data loss at the final download boundary.&lt;/p&gt;

&lt;p&gt;I reproduced that behavior with the same filename rule and JSZip insertion pattern used by a browser image converter, then tested a deterministic naming strategy that preserves both payloads.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built or tested
&lt;/h2&gt;

&lt;p&gt;The converter constructs an output filename by removing the source extension, sanitizing the basename, and appending the selected output extension:&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;function&lt;/span&gt; &lt;span class="nf"&gt;buildFileName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;originalName&lt;/span&gt;&lt;span class="p"&gt;:&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;outputFormat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;OutputFormat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;originalName&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.[^&lt;/span&gt;&lt;span class="sr"&gt;.&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;,&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;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;a-zA-Z0-9-_&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;"&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;image&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;extension&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;outputFormat&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jpeg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jpg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;outputFormat&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;extension&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;That is reasonable for individual downloads. It is not sufficient as a unique archive key.&lt;/p&gt;

&lt;p&gt;The batch path fetches each result Blob and calls &lt;code&gt;archive.file(result.filename, blob)&lt;/code&gt;. The same pattern appears in both the general image workspace and the dedicated batch converter. Neither path allocates a collision-safe entry name before insertion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;The minimal failure needs only two synthetic source names and two distinguishable payloads:&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;inputs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;photo.jpg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;photo.png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;outputNames&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;buildFileName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;webp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// ["photo.webp", "photo.webp"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I used the source repository's installed JSZip version and text payloads named &lt;code&gt;from-jpg&lt;/code&gt; and &lt;code&gt;from-png&lt;/code&gt;. Images are unnecessary for this experiment because the bug is in archive identity, after processing has already produced valid Blobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step-by-step walkthrough
&lt;/h2&gt;

&lt;p&gt;The important distinction is between a display filename and a unique archive entry name:&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%2F2c0db4m7sp8tdau4mtb3.webp" 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%2F2c0db4m7sp8tdau4mtb3.webp" alt="Mermaid diagram 1" width="523" height="862"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Name allocation happens before insertion so every successful result owns one archive entry.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Reproduce the collision
&lt;/h3&gt;

&lt;p&gt;Adding the same path twice is syntactically valid:&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;archive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JSZip&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;archive&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;photo.webp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;from-jpg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;archive&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;photo.webp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;from-png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After generating and reopening the ZIP, the experiment found one entry named &lt;code&gt;photo.webp&lt;/code&gt;. Its content was &lt;code&gt;from-png&lt;/code&gt;, proving that the later write replaced the earlier payload.&lt;/p&gt;

&lt;p&gt;This is why checking only that ZIP generation succeeds is weak. The archive is readable and its surviving file is valid.&lt;/p&gt;

&lt;h3&gt;
  
  
  Allocate a unique name
&lt;/h3&gt;

&lt;p&gt;Track normalized entry names and add a suffix before the extension:&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;function&lt;/span&gt; &lt;span class="nf"&gt;allocateUniqueName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;:&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;used&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Set&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="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lastIndexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dot&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="nx"&gt;dot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;extension&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dot&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;suffix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;suffix&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;suffix&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;requested&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;suffix&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;extension&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;used&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;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;used&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&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;With that allocator, the inputs become &lt;code&gt;photo.webp&lt;/code&gt; and &lt;code&gt;photo (2).webp&lt;/code&gt;. Reopening the generated ZIP showed two entries, with each original payload attached to the expected name.&lt;/p&gt;

&lt;p&gt;Case normalization matters because archive consumers can differ in case sensitivity. Treating &lt;code&gt;Photo.webp&lt;/code&gt; and &lt;code&gt;photo.webp&lt;/code&gt; as a collision avoids creating an archive that behaves differently after extraction on another filesystem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What went wrong
&lt;/h2&gt;

&lt;p&gt;The existing integration coverage tests a mixed compression batch with one JPEG and one PNG. Compression preserves their formats, so the resulting names still have different extensions. The test correctly expects two entries, but it does not exercise name convergence.&lt;/p&gt;

&lt;p&gt;A separate batch conversion test uses mixed inputs and confirms that two conversions complete, yet it does not download and inspect the ZIP. Even an entry-count assertion would need collision-producing fixtures: two arbitrary input files can accidentally keep unique basenames.&lt;/p&gt;

&lt;p&gt;The mistaken assumption is that a successful processing result already has a filename safe for every container. A filename can be individually valid while remaining non-unique inside a batch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix or mitigation
&lt;/h2&gt;

&lt;p&gt;Make archive naming an explicit stage with these properties:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Allocate names in stable result order.&lt;/li&gt;
&lt;li&gt;Compare names using a documented normalization rule.&lt;/li&gt;
&lt;li&gt;Put the suffix before the extension.&lt;/li&gt;
&lt;li&gt;Reserve each name before asynchronous Blob fetching or ZIP insertion.&lt;/li&gt;
&lt;li&gt;Keep the original source-to-entry mapping for tests and diagnostics.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Reserve names synchronously before &lt;code&gt;Promise.all&lt;/code&gt;. Otherwise, concurrent tasks can observe the same unused name before either records it.&lt;/p&gt;

&lt;p&gt;Then strengthen the integration test with &lt;code&gt;photo.jpg&lt;/code&gt; and &lt;code&gt;photo.png&lt;/code&gt;, convert both to the same format, download the archive, reopen it, and assert:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the archive has two files;&lt;/li&gt;
&lt;li&gt;the names are deterministic and unique;&lt;/li&gt;
&lt;li&gt;each entry decodes to the expected image dimensions or known payload;&lt;/li&gt;
&lt;li&gt;the UI's successful-result count equals the archive entry count.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;p&gt;Numeric suffixes are predictable and preserve familiar filenames, but they do not reveal the original format. A policy such as &lt;code&gt;photo-from-jpg.webp&lt;/code&gt; is more descriptive but can expose source-format details and produce longer names.&lt;/p&gt;

&lt;p&gt;Stable ordering matters. Parallel processing completion order can vary, so assigning the unsuffixed name to whichever task finishes first would make archives nondeterministic. Allocate from the selected-file or result order instead.&lt;/p&gt;

&lt;p&gt;Unicode normalization and platform-specific reserved names add complexity if arbitrary international filenames must round-trip exactly. A conservative sanitizer plus case-insensitive uniqueness is simpler, but document that it changes names.&lt;/p&gt;

&lt;p&gt;Finally, a correct entry count does not prove correct association. Tests should inspect content or decoded metadata, not only keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I verified it
&lt;/h2&gt;

&lt;p&gt;The private experiment ran the production-equivalent basename rule against &lt;code&gt;photo.jpg&lt;/code&gt; and &lt;code&gt;photo.png&lt;/code&gt;, producing &lt;code&gt;photo.webp&lt;/code&gt; twice. JSZip generated a readable archive containing one entry; reopening that entry returned only the second payload.&lt;/p&gt;

&lt;p&gt;The mitigation reserved collision-safe names before insertion. The generated archive contained exactly &lt;code&gt;photo.webp&lt;/code&gt; and &lt;code&gt;photo (2).webp&lt;/code&gt;, and both payload assertions passed.&lt;/p&gt;

&lt;p&gt;Source inspection confirmed that both browser batch workspaces currently pass &lt;code&gt;result.filename&lt;/code&gt; directly to JSZip. The existing end-to-end ZIP test proves the ordinary two-entry path works, while the controlled experiment isolates the missing collision case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Batch success is not complete until every successful result has a distinct place in the downloaded archive.&lt;/p&gt;

&lt;p&gt;Treat output filenames as requests, not unique identifiers. Allocate deterministic archive names before concurrent work, generate the ZIP, reopen it in a test, and verify both entry identity and content association.&lt;/p&gt;

&lt;p&gt;That turns a valid-looking archive with silent replacement into a boundary the test suite can actually defend.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Verifying Image Metadata Removal by Decoding the Download</title>
      <dc:creator>xiaoxu</dc:creator>
      <pubDate>Tue, 04 Aug 2026 09:19:54 +0000</pubDate>
      <link>https://dev.to/_3d0d77143d405f723e74f6/verifying-image-metadata-removal-by-decoding-the-download-gih</link>
      <guid>https://dev.to/_3d0d77143d405f723e74f6/verifying-image-metadata-removal-by-decoding-the-download-gih</guid>
      <description>&lt;h1&gt;
  
  
  Verifying Image Metadata Removal by Decoding the Download
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;A button labeled "Remove metadata" is not evidence that metadata was removed.&lt;/p&gt;

&lt;p&gt;Neither is a successful HTTP response, a new filename, or a smaller file. The output must be decoded and inspected. Otherwise a refactor can accidentally preserve EXIF while every visible UI assertion remains green.&lt;/p&gt;

&lt;p&gt;I tested an image cleanup flow with a synthetic JPEG that definitely contains EXIF data. The production path re-encoded it, and an independent decoder inspected the returned bytes. A control path that explicitly preserved metadata made the failure condition observable.&lt;/p&gt;

&lt;p&gt;The reusable idea is simple: prove the precondition, run the real path, and assert the postcondition on the downloadable artifact.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built or tested
&lt;/h2&gt;

&lt;p&gt;The fixture generator creates a small image and embeds synthetic metadata:&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;jpeg&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;82&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withMetadata&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;exif&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;IFD0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Copyright&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Synthetic fixture&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;IFD3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;GPSLatitudeRef&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;N&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;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBuffer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The strings are intentionally artificial. Tests should never need a real user's photograph or location to exercise the privacy boundary.&lt;/p&gt;

&lt;p&gt;There are two production implementations. The common browser path decodes the source and draws it onto Canvas before exporting a new Blob. The server compatibility path decodes, rotates, and encodes through Sharp without requesting metadata preservation.&lt;/p&gt;

&lt;p&gt;Both are re-encoding paths, but they still need artifact-level tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;Separate the test into four checkpoints:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Decode the input and prove EXIF exists.&lt;/li&gt;
&lt;li&gt;Run the product operation.&lt;/li&gt;
&lt;li&gt;Decode the exact output offered for download.&lt;/li&gt;
&lt;li&gt;Assert metadata absence plus basic image validity.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The last step should include format and dimensions. An empty or corrupt Blob has no EXIF too, but it is not a successful cleanup result.&lt;/p&gt;

&lt;p&gt;Use an independent inspection call rather than reading a flag returned by the processor. If the processor reports &lt;code&gt;metadataRemoved: true&lt;/code&gt;, that value only describes intent. Decoding the result describes the artifact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step-by-step walkthrough
&lt;/h2&gt;

&lt;p&gt;The verification flow crosses several boundaries:&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%2Fypy6usrqu8es8u0intxb.webp" 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%2Fypy6usrqu8es8u0intxb.webp" alt="Mermaid diagram 1" width="784" height="116"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The assertion targets the bytes a user receives, not the UI action that produced them.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Prove the fixture is meaningful
&lt;/h3&gt;

&lt;p&gt;Start by inspecting the fixture:&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;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sharp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;before&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exif&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeDefined&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without that assertion, a broken fixture can make the test pass vacuously. "EXIF is absent after processing" means little if it was absent before processing too.&lt;/p&gt;

&lt;h3&gt;
  
  
  Execute the actual browser workflow
&lt;/h3&gt;

&lt;p&gt;The integration test opens the metadata-removal route, uploads the EXIF-bearing JPEG, and waits for a browser-engine result. It then saves the actual download to the test output directory.&lt;/p&gt;

&lt;p&gt;Canvas constructs a fresh raster surface, draws decoded pixels, and exports a new image. The code does not copy the source metadata container into the export.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cover the server compatibility path
&lt;/h3&gt;

&lt;p&gt;The server adapter creates a Sharp pipeline, reads dimensions, optionally resizes, and invokes the selected encoder. It does not call &lt;code&gt;withMetadata&lt;/code&gt; before writing the buffer.&lt;/p&gt;

&lt;p&gt;The private experiment passed the same EXIF-bearing input through this production function. This confirms that compatibility processing also satisfies the cleanup contract.&lt;/p&gt;

&lt;h3&gt;
  
  
  Decode the result again
&lt;/h3&gt;

&lt;p&gt;The assertion does not trust the output filename or MIME header:&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;after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sharp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;downloadedBytes&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;after&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;format&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jpeg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;after&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;after&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;48&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;after&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exif&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeUndefined&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those checks establish that the result is still the expected image shape while the tested metadata block is absent.&lt;/p&gt;

&lt;h2&gt;
  
  
  What went wrong
&lt;/h2&gt;

&lt;p&gt;"Re-encoding strips metadata" was too broad as an explanation. I added a control that decoded the same input, called Sharp's explicit metadata-preservation method, and encoded it again.&lt;/p&gt;

&lt;p&gt;The control output still contained EXIF.&lt;/p&gt;

&lt;p&gt;That failure case matters because it proves the fixture and decoder can detect retention. It also shows that a pipeline can re-encode pixels and still carry metadata when configured to do so.&lt;/p&gt;

&lt;p&gt;Another limitation is scope. The assertion covers the EXIF field exposed by the decoder. It does not automatically prove that every possible metadata family, sidecar, container extension, or application-specific payload is absent. The public claim should match the fields actually inspected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix or mitigation
&lt;/h2&gt;

&lt;p&gt;Build the test around a positive control and a negative postcondition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;input: metadata must be present
preserving control: metadata must remain present
cleanup output: metadata must be absent
cleanup output: format and dimensions must remain valid
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run artifact checks for every processing engine that can serve the route. A browser-only test will not catch a server fallback that later starts preserving metadata. A server unit test will not catch a Canvas path that returns the original Blob by mistake.&lt;/p&gt;

&lt;p&gt;Keep fixture metadata synthetic and recognizable. If a failure prints the value, it should never disclose real user information.&lt;/p&gt;

&lt;p&gt;Finally, document the exact guarantee. "EXIF absent from the re-encoded output" is testable. "All private information removed" is a much larger claim that needs a broader threat model and additional scanners.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;p&gt;Re-encoding changes more than metadata. Encoders can alter file size, compression artifacts, color profiles, orientation representation, and other characteristics. Removing metadata by re-encoding is not a byte-preserving operation.&lt;/p&gt;

&lt;p&gt;Orientation deserves special attention. The server path autorotates before encoding, which can turn an orientation tag into transformed pixels. A test should verify displayed orientation if that matters to the product.&lt;/p&gt;

&lt;p&gt;Canvas and Sharp may emit different bytes even with similar quality settings. The cleanup contract should focus on properties users need: decodability, dimensions, expected format, visual orientation, and absence of specified metadata.&lt;/p&gt;

&lt;p&gt;Independent decoding adds test cost, but it is much stronger than asserting internal flags. Small synthetic fixtures keep that cost low.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I verified it
&lt;/h2&gt;

&lt;p&gt;The experiment created a 64-by-48 JPEG with EXIF copyright and GPS-related fields. Sharp metadata inspection confirmed the input contained an EXIF buffer.&lt;/p&gt;

&lt;p&gt;A preservation control called &lt;code&gt;withMetadata()&lt;/code&gt; and confirmed EXIF remained after encoding. The production server processor then encoded the same input to JPEG at quality 78. Decoding its output found no EXIF while retaining the 64-by-48 dimensions and JPEG format.&lt;/p&gt;

&lt;p&gt;The repository's browser integration test provides the complementary path. It proves its input has EXIF, processes through the browser-first cleanup route, downloads the result, and asserts that an independent Sharp decode has no EXIF field.&lt;/p&gt;

&lt;p&gt;Together, those checks cover both a real browser download and the server compatibility implementation without using private photos or network services.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Test privacy transformations at the artifact boundary.&lt;/p&gt;

&lt;p&gt;Create data that definitely contains the property being removed, prove the test can detect a preserving failure, run every production path, and inspect the exact returned bytes with an independent decoder. Add validity assertions so corrupt output cannot pass by merely lacking metadata.&lt;/p&gt;

&lt;p&gt;That turns "the cleanup button worked" into a narrow, reproducible statement: this valid downloaded image no longer contains the metadata field we inserted.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Routing Image Work Between Canvas and Sharp by Capability</title>
      <dc:creator>xiaoxu</dc:creator>
      <pubDate>Mon, 03 Aug 2026 06:09:52 +0000</pubDate>
      <link>https://dev.to/_3d0d77143d405f723e74f6/routing-image-work-between-canvas-and-sharp-by-capability-hc6</link>
      <guid>https://dev.to/_3d0d77143d405f723e74f6/routing-image-work-between-canvas-and-sharp-by-capability-hc6</guid>
      <description>&lt;h1&gt;
  
  
  Routing Image Work Between Canvas and Sharp by Capability
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;"Browser first" sounds like one Boolean. A real image pipeline has at least four questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Can the browser decode this input format?&lt;/li&gt;
&lt;li&gt;Can Canvas encode the requested output format?&lt;/li&gt;
&lt;li&gt;Does the product accept the browser encoder for this operation?&lt;/li&gt;
&lt;li&gt;What happens when an apparently supported operation fails at runtime?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If those decisions leak into UI components, batch loops, and API callers, the two processing paths drift. The interface may say an image stayed local while it actually uploaded, or a supported browser silently returns a different output format.&lt;/p&gt;

&lt;p&gt;I tested a small capability router that keeps those questions in one place. Common work goes through Canvas. Sharp remains the compatibility path. The result records which engine actually produced each file.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built or tested
&lt;/h2&gt;

&lt;p&gt;The browser adapter starts with two explicit sets:&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;browserInputs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image/png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image/webp&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browserOutputs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jpeg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;webp&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;That excludes AVIF, HEIC, and HEIF inputs from the browser path. It also excludes AVIF output. These are capability decisions for this implementation, not claims about every browser.&lt;/p&gt;

&lt;p&gt;The router then adds runtime and product constraints. It returns false during server-side rendering, probes WebP Canvas export, and keeps selected PNG compression operations on Sharp even though Canvas can emit PNG.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;Define the routing input independently of either processor:&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;type&lt;/span&gt; &lt;span class="nx"&gt;ProcessingRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;File&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;outputFormat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jpeg&lt;/span&gt;&lt;span class="dl"&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;png&lt;/span&gt;&lt;span class="dl"&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;webp&lt;/span&gt;&lt;span class="dl"&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;avif&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;toolSlug&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&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 predicate needs no pixels. It only decides whether the browser path is worth attempting.&lt;/p&gt;

&lt;p&gt;JPEG and PNG output are treated as supported once the runtime is a browser. WebP is probed with a one-pixel Canvas because some implementations can accept an output MIME argument yet return a different data URL. The probe verifies that the returned URL begins with the requested MIME type, then caches the Boolean.&lt;/p&gt;

&lt;p&gt;This is capability detection rather than user-agent detection. The code asks the current runtime what it can produce instead of maintaining a browser-version table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step-by-step walkthrough
&lt;/h2&gt;

&lt;p&gt;The decision order is intentionally cheap:&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%2Fphiu87l1372xjwq10lsi.webp" 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%2Fphiu87l1372xjwq10lsi.webp" alt="Mermaid diagram 1" width="625" height="1507"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The predicate chooses an attempt; successful processing determines the recorded engine.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Reject non-browser runtimes
&lt;/h3&gt;

&lt;p&gt;The same module can be imported during server rendering. Checking &lt;code&gt;typeof window&lt;/code&gt; first prevents DOM capability code from running there. The server path is the safe default.&lt;/p&gt;

&lt;h3&gt;
  
  
  Check the input before the output
&lt;/h3&gt;

&lt;p&gt;Canvas output support does not imply that the browser adapter can decode every accepted upload. An AVIF input requesting PNG therefore goes to Sharp even though PNG output is available.&lt;/p&gt;

&lt;h3&gt;
  
  
  Preserve product exceptions
&lt;/h3&gt;

&lt;p&gt;The router keeps PNG compression on the server for three tool routes: the dedicated PNG compressor, the universal compressor producing PNG, and batch compression producing PNG.&lt;/p&gt;

&lt;p&gt;This is the subtle part. A generic &lt;code&gt;canCanvasEncode("png")&lt;/code&gt; check would say yes. The product has chosen a different implementation for compression behavior, so the exception belongs beside capability routing and needs a test of its own.&lt;/p&gt;

&lt;h3&gt;
  
  
  Probe uncertain exports once
&lt;/h3&gt;

&lt;p&gt;For WebP, the adapter creates a tiny Canvas and calls &lt;code&gt;toDataURL("image/webp")&lt;/code&gt;. It accepts the path only when the returned data URL declares WebP. The result is cached by output format, avoiding a probe for every file.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fall back after a real browser failure
&lt;/h3&gt;

&lt;p&gt;Passing the predicate does not guarantee that decoding, allocation, drawing, or encoding will succeed. The client orchestration therefore wraps the complete browser operation in &lt;code&gt;try/catch&lt;/code&gt;. On failure it logs a warning and calls the server adapter with the original request.&lt;/p&gt;

&lt;p&gt;This keeps the predicate simple. It does not try to predict available memory or every decoder edge case.&lt;/p&gt;

&lt;h2&gt;
  
  
  What went wrong
&lt;/h2&gt;

&lt;p&gt;The first tempting model was "JPEG, PNG, or WebP means browser." The experiment disproved that shortcut in several ways.&lt;/p&gt;

&lt;p&gt;A PNG input on the dedicated PNG compression route went to the server even though both its input and output appeared in the browser sets. An AVIF input requesting PNG also went to the server, but for a different reason: unsupported input decoding. JPEG requesting AVIF failed on output capability. Removing the browser runtime failed before any format test.&lt;/p&gt;

&lt;p&gt;The cases look identical from the final Boolean, but they represent different constraints. That is why the routing predicate should remain readable rather than collapse into one opaque capability flag.&lt;/p&gt;

&lt;p&gt;There is another limitation: the runtime fallback catches every browser-processing exception. A decode failure and a transient Canvas allocation failure both attempt the server. That is useful for compatibility, but it can add an upload after local work has already begun. The UI and telemetry must report the engine that completed, not merely the engine attempted first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix or mitigation
&lt;/h2&gt;

&lt;p&gt;Return an engine on the result:&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;type&lt;/span&gt; &lt;span class="nx"&gt;ProcessedResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;processor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;browser&lt;/span&gt;&lt;span class="dl"&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;server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&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;For a batch, reduce the recorded engines into &lt;code&gt;browser&lt;/code&gt;, &lt;code&gt;server&lt;/code&gt;, or &lt;code&gt;mixed&lt;/code&gt;. A mixed label is more honest than assigning the whole batch to whichever path processed the first item.&lt;/p&gt;

&lt;p&gt;Keep the server API behavior compatible with the browser request. It should accept the same quality, output-format, tool, and resize intent. Otherwise fallback changes semantics instead of only changing execution location.&lt;/p&gt;

&lt;p&gt;Finally, make product exceptions named functions. A helper such as &lt;code&gt;shouldKeepPngOnServer&lt;/code&gt; explains intent in review and gives future changes one obvious location.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;p&gt;Browser processing avoids an upload for eligible files, but it consumes client memory and CPU. Large inputs can still fail after passing the format predicate. The server fallback improves completion rate at the cost of network transfer and server resources.&lt;/p&gt;

&lt;p&gt;Canvas and Sharp also do not promise byte-identical output. The router aligns product intent and engine selection; it does not make two encoders equivalent. Verify format, dimensions, alpha handling, and other required properties rather than comparing hashes.&lt;/p&gt;

&lt;p&gt;The WebP probe detects output support in the current runtime, but it does not prove acceptable quality for every image. A successful one-pixel export is a capability signal, not a visual benchmark.&lt;/p&gt;

&lt;p&gt;Catch-all fallback can hide browser regressions if warnings are ignored. Count fallback reasons in observability, and fail tests when a route expected to stay local begins uploading.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I verified it
&lt;/h2&gt;

&lt;p&gt;I imported the production router into a Node 22 experiment with minimal &lt;code&gt;window&lt;/code&gt;, &lt;code&gt;document&lt;/code&gt;, and Canvas probe stubs. The assertions observed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JPEG to JPEG selected the browser;&lt;/li&gt;
&lt;li&gt;JPEG to a successfully probed WebP selected the browser;&lt;/li&gt;
&lt;li&gt;AVIF input selected the server;&lt;/li&gt;
&lt;li&gt;AVIF output selected the server;&lt;/li&gt;
&lt;li&gt;PNG compression selected the server; and&lt;/li&gt;
&lt;li&gt;removing &lt;code&gt;window&lt;/code&gt; selected the server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The production batch summarizer also returned &lt;code&gt;mixed&lt;/code&gt; for one browser and one server result.&lt;/p&gt;

&lt;p&gt;Repository end-to-end tests provide the next evidence layer. They download and decode a browser-produced JPEG-to-PNG result, a server-produced AVIF-to-PNG result, and both outputs from a mixed batch. That verifies the engines beyond the pure routing predicate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;A browser-first pipeline is safest when "first" means "attempted under an explicit contract," not "always used for familiar extensions."&lt;/p&gt;

&lt;p&gt;List supported inputs and outputs, probe uncertain runtime behavior, preserve named product exceptions, fall back around the complete local operation, and record the engine that actually completed. The resulting capability table is small, testable, and far easier to evolve than format checks scattered across the interface.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Keeping Before-and-After Image Examples Verifiable</title>
      <dc:creator>xiaoxu</dc:creator>
      <pubDate>Mon, 03 Aug 2026 06:09:36 +0000</pubDate>
      <link>https://dev.to/_3d0d77143d405f723e74f6/keeping-before-and-after-image-examples-verifiable-7og</link>
      <guid>https://dev.to/_3d0d77143d405f723e74f6/keeping-before-and-after-image-examples-verifiable-7og</guid>
      <description>&lt;h1&gt;
  
  
  Keeping Before-and-After Image Examples Verifiable
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;Before-and-after images look like content, but they behave like compiled artifacts. A page can keep rendering while its caption reports an old byte count, an "after" file was generated with different settings, or one asset quietly changed dimensions.&lt;/p&gt;

&lt;p&gt;I encountered that boundary in an image-tool project with six example pages. Each page showed two cases, and every case had a before and an after asset. That is 24 files whose bytes, dimensions, and descriptive metadata need to agree.&lt;/p&gt;

&lt;p&gt;The useful fix was not another screenshot review. It was to make the examples executable: generate them from controlled inputs, describe their expected properties in code, and fail a verifier when disk and metadata diverge.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built or tested
&lt;/h2&gt;

&lt;p&gt;The page data uses a typed record for each case. The important fields are not presentation-only:&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;type&lt;/span&gt; &lt;span class="nx"&gt;ExampleAsset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ExampleCase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;before&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ExampleAsset&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;after&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ExampleAsset&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;outputNote&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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 byte count is deliberately exact. It connects the label shown to a reader with the file committed to the site. The source path connects the same record to the asset the browser requests. The format and output note make the transformation understandable, while decoded dimensions are enforced by the verifier.&lt;/p&gt;

&lt;p&gt;This contract is small enough to review. It is also strict enough to answer a practical question: does the page still describe the files that ship?&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;The project keeps source images outside the public example directory. An input-generation script prepares ten named inputs, copying PNG sources when no conversion is needed and using explicit JPEG or WebP settings for the others.&lt;/p&gt;

&lt;p&gt;A second script finds every &lt;code&gt;-before&lt;/code&gt; asset for six tool slugs and creates the matching &lt;code&gt;-after&lt;/code&gt; file. The transformations are explicit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JPEG uses quality 78, MozJPEG, and progressive output.&lt;/li&gt;
&lt;li&gt;WebP uses quality 78 and smart subsampling.&lt;/li&gt;
&lt;li&gt;PNG uses quality 78, compression level 9, and progressive output.&lt;/li&gt;
&lt;li&gt;Every input is autorotated before encoding.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The generator then reports the observed input and output byte counts. Those observations are what should be reviewed and transferred into the typed records. Do not compute a reduction percentage once and leave it disconnected from the files.&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%2Fjf594ggc5bcpkhe07uba.webp" 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%2Fjf594ggc5bcpkhe07uba.webp" alt="Mermaid diagram 1" width="784" height="76"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The generator produces artifacts; the contract and verifier make them reviewable.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step-by-step walkthrough
&lt;/h2&gt;

&lt;p&gt;The verifier iterates through every before and after record. For each asset, it performs three checks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The referenced file is accessible.&lt;/li&gt;
&lt;li&gt;Its actual byte count equals the declared count.&lt;/li&gt;
&lt;li&gt;Sharp can decode it and reports the expected 1536 by 1024 dimensions.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A compact implementation looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;stat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sharp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;metadata&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;file&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;!==&lt;/span&gt; &lt;span class="nx"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: byte count changed`&lt;/span&gt;&lt;span class="p"&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;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;1536&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: dimensions changed`&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;Collecting failures is better than throwing on the first mismatch. A regeneration can affect several records, and one verifier run should give the author the complete repair list.&lt;/p&gt;

&lt;p&gt;The command exits nonzero when any failure exists. That detail turns a useful local report into a CI gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What went wrong
&lt;/h2&gt;

&lt;p&gt;A clean run proved that all 24 assets across the six pages matched their records. That only tested the success path, so I built an isolated failure experiment.&lt;/p&gt;

&lt;p&gt;The experiment copied the public example directory to a temporary location and ran the real repository verifier against it. It then appended one byte to a copied JPEG and ran the verifier again.&lt;/p&gt;

&lt;p&gt;Why append a byte? The image remained decodable, and its width and height stayed correct. A verifier that only opened the image or checked dimensions would accept it. The byte contract should reject it.&lt;/p&gt;

&lt;p&gt;The second run exited with status 1 and reported that the file contained 145155 bytes instead of the expected 145154. The temporary fixture was deleted afterward, and the source repository was never modified.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I verified it
&lt;/h2&gt;

&lt;p&gt;This is a useful pattern for repository checks in general: create a valid baseline, introduce one controlled violation, and assert both the failure status and the diagnostic. A green check is more credible after its red path has been observed.&lt;/p&gt;

&lt;p&gt;The experiment asserted the clean verifier's asset and page counts, then asserted both the nonzero failure status and the precise byte-mismatch diagnostic. This distinguished "the script ran" from "the script enforced the intended contract."&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix or mitigation
&lt;/h2&gt;

&lt;p&gt;The repeatable sequence is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Change source images or transformation settings.
2. Regenerate before and after assets.
3. Review the generated byte summary and visual output.
4. Update the typed records in the same change.
5. Run the asset verifier.
6. Let CI reject any unreviewed drift.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the generator and verifier separate. Generation is an intentional write; verification is a read-only check. If a CI check silently regenerated files, it could hide the fact that committed artifacts were stale.&lt;/p&gt;

&lt;p&gt;Also pin the runtime and image library versions used for generation. Exact encoded bytes can change across encoder versions even when quality settings are unchanged. That is not a reason to weaken the check; it is a reason to make toolchain upgrades explicit and review their output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;p&gt;Exact bytes are not a perceptual-quality metric. Two outputs can be visually equivalent with different bytes, and identical dimensions say nothing about whether important texture survived compression. Visual review or perceptual comparisons are a separate gate.&lt;/p&gt;

&lt;p&gt;The record also does not prove that a marketing claim is fair. It proves that the declared size and dimensions match the shipped sample under the documented settings.&lt;/p&gt;

&lt;p&gt;Finally, strict byte checks are a poor fit when files are intentionally recompressed after the build by an uncontrolled pipeline. In that system, verify the pre-upload artifact and separately test delivery invariants such as dimensions, format, or a content digest exposed by the asset service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Treat demo assets the way you treat generated code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;preserve their inputs,&lt;/li&gt;
&lt;li&gt;make transformations explicit,&lt;/li&gt;
&lt;li&gt;store reviewable expected metadata,&lt;/li&gt;
&lt;li&gt;verify the committed outputs without rewriting them, and&lt;/li&gt;
&lt;li&gt;exercise a real failure case.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That changes before-and-after examples from manually trusted decoration into a small, enforceable build contract. The page can still tell a visual story, but CI now checks that the story matches the files.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Updating Sitemap Dates Only for Routes That Actually Changed</title>
      <dc:creator>xiaoxu</dc:creator>
      <pubDate>Thu, 30 Jul 2026 05:37:57 +0000</pubDate>
      <link>https://dev.to/_3d0d77143d405f723e74f6/updating-sitemap-dates-only-for-routes-that-actually-changed-1dp2</link>
      <guid>https://dev.to/_3d0d77143d405f723e74f6/updating-sitemap-dates-only-for-routes-that-actually-changed-1dp2</guid>
      <description>&lt;h1&gt;
  
  
  Updating Sitemap Dates Only for Routes That Actually Changed
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;A dynamic sitemap makes it tempting to write:&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="nx"&gt;lastModified&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&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 convenient and usually wrong. It tells crawlers that every page changed&lt;br&gt;
whenever the sitemap was generated, even when the only event was a deployment.&lt;/p&gt;

&lt;p&gt;The&lt;br&gt;
&lt;a href="https://www.sitemaps.org/protocol.html" rel="noopener noreferrer"&gt;Sitemaps protocol&lt;/a&gt;&lt;br&gt;
defines &lt;code&gt;lastmod&lt;/code&gt; as the linked page's modification date, not the sitemap&lt;br&gt;
generation date. Google goes further in its current&lt;br&gt;
&lt;a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap" rel="noopener noreferrer"&gt;sitemap guidance&lt;/a&gt;:&lt;br&gt;
it says the value is used when it is consistently and verifiably accurate and&lt;br&gt;
should reflect a significant page update, such as main content, structured&lt;br&gt;
data, or links.&lt;/p&gt;

&lt;p&gt;The difficult part is not formatting an ISO timestamp. It is answering:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which public routes did this source-code change significantly affect?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I traced and tested a Git-aware workflow for a Next.js site that answers that&lt;br&gt;
question before touching any dates. It combines semantic metadata comparison,&lt;br&gt;
an explicit route dependency map, an AST-based stamper, and a CI check that&lt;br&gt;
fails when an affected route still has its previous timestamp.&lt;/p&gt;

&lt;p&gt;It does not claim an SEO ranking improvement. Its goal is narrower: make the&lt;br&gt;
sitemap metadata truthful and reviewable.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I built or tested
&lt;/h2&gt;

&lt;p&gt;The site persists &lt;code&gt;lastModified&lt;/code&gt; beside each route definition. Its Next.js&lt;br&gt;
sitemap handler simply emits those values:&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="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sitemap&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;MetadataRoute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Sitemap&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;sitemapEntries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;entry&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="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;siteConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;siteUrl&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;lastModified&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastModified&lt;/span&gt;&lt;span class="p"&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;A separate Node.js command has three modes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run seo:lastmod:list
npm run seo:lastmod:stamp
npm run seo:lastmod:check
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;list&lt;/code&gt; shows routes affected by the current diff.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;stamp&lt;/code&gt; replaces only those routes' stored dates.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;check&lt;/code&gt; fails when an affected existing route still has its previous date.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I ran the repository's integration test. It created a temporary Git repository,&lt;br&gt;
committed a small route matrix, and exercised this sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;change one tool definition;&lt;/li&gt;
&lt;li&gt;observe the tool route and homepage as affected;&lt;/li&gt;
&lt;li&gt;observe the pre-stamp check fail;&lt;/li&gt;
&lt;li&gt;stamp a fixed ISO date;&lt;/li&gt;
&lt;li&gt;observe the check pass;&lt;/li&gt;
&lt;li&gt;change one route-specific SEO record;&lt;/li&gt;
&lt;li&gt;observe only that route;&lt;/li&gt;
&lt;li&gt;change one static page;&lt;/li&gt;
&lt;li&gt;observe only its matching route.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The test passed one of one. It created and removed its own temporary repository;&lt;br&gt;
the evidence repository's existing worktree state was identical before and&lt;br&gt;
after.&lt;/p&gt;
&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;The implementation uses Node.js, Git, TypeScript's compiler API, and Next.js&lt;br&gt;
route metadata. It models four route families:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source definition&lt;/th&gt;
&lt;th&gt;Route shape&lt;/th&gt;
&lt;th&gt;Family&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tool definitions&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/${slug}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tool&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Converter navigation&lt;/td&gt;
&lt;td&gt;configured &lt;code&gt;href&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;convert&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Guide definitions&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/guides/${slug}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;guide&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Static sitemap entries&lt;/td&gt;
&lt;td&gt;configured &lt;code&gt;path&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;static&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each descriptor states:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the source file;&lt;/li&gt;
&lt;li&gt;the variable containing the records;&lt;/li&gt;
&lt;li&gt;the record's stable key;&lt;/li&gt;
&lt;li&gt;a function that converts the record into a public route; and&lt;/li&gt;
&lt;li&gt;the dependency family.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A reduced descriptor looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lib/tool-config.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;variable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;toolDefinitions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;keyProperty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;slug&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;routeFromValues&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tool&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;The workflow also needs a comparison base. With tracked working-tree changes,&lt;br&gt;
it compares against &lt;code&gt;HEAD&lt;/code&gt;. In a clean checkout, it uses &lt;code&gt;HEAD^&lt;/code&gt; when&lt;br&gt;
available. CI can pass an explicit base branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run seo:lastmod:check &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;--base&lt;/span&gt; origin/main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That distinction keeps local pre-commit checks and clean CI checkouts useful&lt;br&gt;
without silently choosing the same reference in every environment.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step-by-step walkthrough
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. Parse route metadata without executing the application
&lt;/h3&gt;

&lt;p&gt;The script reads designated TypeScript source files and parses them with the&lt;br&gt;
TypeScript compiler API. It locates a named variable initializer and expects an&lt;br&gt;
array literal for route definitions.&lt;/p&gt;

&lt;p&gt;Each object literal is serialized into a semantic value. A reduced&lt;br&gt;
property-assignment-only version is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;serializeSemanticNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sourceFile&lt;/span&gt;&lt;span class="p"&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;ts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isStringLiteral&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&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;ts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isObjectLiteralExpression&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&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="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;properties&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;property&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;getPropertyName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;property&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sourceFile&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;lastModified&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;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;property&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="nf"&gt;getPropertyName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;property&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sourceFile&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
          &lt;span class="nf"&gt;serializeSemanticNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;property&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;initializer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sourceFile&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;]),&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Arrays, numbers, booleans, identifiers, and fallback source text...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important line excludes &lt;code&gt;lastModified&lt;/code&gt; from semantic comparison. Otherwise&lt;br&gt;
stamping a timestamp would itself look like another route-content change,&lt;br&gt;
creating a self-perpetuating diff.&lt;/p&gt;

&lt;p&gt;This is more robust than a regular expression because it works with typed&lt;br&gt;
object syntax, &lt;code&gt;as const&lt;/code&gt;, &lt;code&gt;satisfies&lt;/code&gt;, parentheses, and nested arrays or&lt;br&gt;
objects. It is deliberately less general than executing the module. The&lt;br&gt;
designated metadata variables must remain statically inspectable array or&lt;br&gt;
object literals.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Compare route records against Git
&lt;/h3&gt;

&lt;p&gt;The tool reads current sources from disk and base sources with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;git show &amp;lt;base&amp;gt;:&amp;lt;file&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also collects changed paths from the tracked diff and untracked files. A&lt;br&gt;
record is affected when it is new or when its semantic serialization differs&lt;br&gt;
from the matching base record.&lt;/p&gt;

&lt;p&gt;For a direct tool-definition change, the mapping adds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the tool route; and&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/&lt;/code&gt;, because the homepage consumes the tool list.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A converter-definition change follows the same rule. A guide record maps to&lt;br&gt;
its guide route. A static route record maps to its configured path.&lt;/p&gt;

&lt;p&gt;This is not based only on filenames. Two records can live in one configuration&lt;br&gt;
file while only one public route changes. Comparing records preserves that&lt;br&gt;
granularity.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Propagate shared dependencies explicitly
&lt;/h3&gt;

&lt;p&gt;Route records are not the whole page. A page can change because a component it&lt;br&gt;
uses changed.&lt;/p&gt;

&lt;p&gt;The script therefore maintains dependency sets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;familyDependencyFiles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;app/[tool]/page.tsx&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;components/image-tool-workspace.tsx&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;components/related-tool-link.tsx&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;span class="na"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;app/convert/[format]/page.tsx&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;components/convert-workspace.tsx&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;span class="na"&gt;guide&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;app/guides/[guide]/page.tsx&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;components/guide-page-shell.tsx&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;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When one of those files changes significantly, every route in that family is&lt;br&gt;
affected. Site-wide components such as the header, footer, or structured-data&lt;br&gt;
component can mark all routes.&lt;/p&gt;

&lt;p&gt;Route-specific SEO content gets a narrower treatment. The tool parses the SEO&lt;br&gt;
content object and compares records by slug. Changing one record affects only&lt;br&gt;
its matching tool or converter. Changing code outside that object in the same&lt;br&gt;
component can affect the full tool and converter families.&lt;/p&gt;

&lt;p&gt;Static pages map directly from a route to their &lt;code&gt;app/.../page.tsx&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;The resulting policy is:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Affected routes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One tool record&lt;/td&gt;
&lt;td&gt;Tool route and homepage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One converter record&lt;/td&gt;
&lt;td&gt;Converter route and homepage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One guide record&lt;/td&gt;
&lt;td&gt;Guide route&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One route-specific SEO record&lt;/td&gt;
&lt;td&gt;Matching tool or converter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared family component&lt;/td&gt;
&lt;td&gt;Every route in that family&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Header, footer, or shared structured data&lt;/td&gt;
&lt;td&gt;All sitemap routes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One static page&lt;/td&gt;
&lt;td&gt;Matching static route&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h3&gt;
  
  
  4. Validate before writing
&lt;/h3&gt;

&lt;p&gt;The command first rejects duplicate routes and any route with a missing or&lt;br&gt;
invalid &lt;code&gt;lastModified&lt;/code&gt;. New pages therefore cannot enter the sitemap without a&lt;br&gt;
valid initial date.&lt;/p&gt;

&lt;p&gt;For existing affected routes, &lt;code&gt;check&lt;/code&gt; compares the current date with the base&lt;br&gt;
date:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;staleRoutes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;affectedRoutes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;route&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;currentMetadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;routeRecords&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;previous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;baseMetadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;routeRecords&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;previous&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastModified&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastModified&lt;/span&gt;&lt;span class="p"&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;staleRoutes&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;staleRoutes&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; affected route(s) still use the previous date`&lt;/span&gt;&lt;span class="p"&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;This is the real enforcement boundary. Route detection alone is only a report;&lt;br&gt;
a non-zero check makes stale metadata visible to local hooks or CI.&lt;/p&gt;
&lt;h3&gt;
  
  
  5. Stamp only selected AST ranges
&lt;/h3&gt;

&lt;p&gt;For every affected route, the parser retains the AST node for the existing date&lt;br&gt;
initializer. The stamper groups replacements by source file and applies them&lt;br&gt;
from the end of the file toward the beginning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;replacement&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;replacements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;right&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;right&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="nx"&gt;replacement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;replacement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;end&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;Reverse-order edits prevent an earlier replacement from shifting the offsets&lt;br&gt;
of later nodes.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--dry-run&lt;/code&gt; computes and reports the same route set without writing. &lt;code&gt;--route&lt;/code&gt;&lt;br&gt;
adds an explicit route, while &lt;code&gt;--all&lt;/code&gt; is reserved for a genuine site-wide&lt;br&gt;
significant change.&lt;/p&gt;

&lt;p&gt;The complete decision flow is:&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%2Ft4sg7yvvp9t7vzrv4lpa.webp" 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%2Ft4sg7yvvp9t7vzrv4lpa.webp" alt="Mermaid diagram 1" width="745" height="908"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Semantic record changes and explicit dependencies converge on one reviewable&lt;br&gt;
route set.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What went wrong
&lt;/h2&gt;

&lt;p&gt;The integration test intentionally started with a real failure.&lt;/p&gt;

&lt;p&gt;It changed one tool description but left all sitemap dates untouched. The route&lt;br&gt;
detector returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/
/compress-jpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The checker then exited with status 1 because both affected routes still used&lt;br&gt;
their previous dates. That failure is desirable: the tool page changed&lt;br&gt;
directly, and the homepage changed indirectly because it consumes the tool&lt;br&gt;
list.&lt;/p&gt;

&lt;p&gt;After stamping both routes with a fixed timestamp, the same check passed. The&lt;br&gt;
test then changed only the &lt;code&gt;compress-jpg&lt;/code&gt; SEO record and correctly returned one&lt;br&gt;
route rather than the whole tool family.&lt;/p&gt;

&lt;p&gt;The experiment also exposed the central limitation: the dependency map is&lt;br&gt;
policy, not omniscience. If a developer adds a significant shared component&lt;br&gt;
outside the known sets, the script cannot infer that architectural edge. A&lt;br&gt;
false negative is possible until the map is updated or the developer supplies&lt;br&gt;
an explicit route.&lt;/p&gt;

&lt;p&gt;There is a second boundary. The AST reader expects known variables to use&lt;br&gt;
literal arrays or objects. Refactoring a route definition into a database call,&lt;br&gt;
factory function, or generated import would break this inspection strategy&lt;br&gt;
even if the runtime sitemap still worked.&lt;/p&gt;
&lt;h2&gt;
  
  
  Fix or mitigation
&lt;/h2&gt;

&lt;p&gt;Treat the tool as an enforced workflow, not a magical detector.&lt;/p&gt;
&lt;h3&gt;
  
  
  Use list, stamp, check
&lt;/h3&gt;

&lt;p&gt;For a normal page change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run seo:lastmod:list
npm run seo:lastmod:stamp
npm run seo:lastmod:check
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Review the list before stamping. The affected set is a content decision, not&lt;br&gt;
just a code result.&lt;/p&gt;

&lt;p&gt;For a significant dependency outside the map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run seo:lastmod:stamp &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--route&lt;/span&gt; /compress-jpg &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--route&lt;/span&gt; /jpg-to-png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;--all&lt;/code&gt; only when every indexable page truly received a significant&lt;br&gt;
update.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep the dependency map beside architecture changes
&lt;/h3&gt;

&lt;p&gt;When adding a shared route shell or moving content between components:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;update the route dependency map;&lt;/li&gt;
&lt;li&gt;add an isolated-Git test case for the new edge;&lt;/li&gt;
&lt;li&gt;run &lt;code&gt;list&lt;/code&gt; against the intended base;&lt;/li&gt;
&lt;li&gt;inspect false positives and false negatives; and&lt;/li&gt;
&lt;li&gt;stamp only after the mapping is correct.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Formatting, dependency upgrades, analytics wiring, CSS-only changes, and&lt;br&gt;
copyright-year changes should not automatically move every timestamp.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test the failure before the success
&lt;/h3&gt;

&lt;p&gt;The integration test is reusable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;create a temporary Git repository;&lt;/li&gt;
&lt;li&gt;write and commit minimal route metadata;&lt;/li&gt;
&lt;li&gt;make one semantic content change;&lt;/li&gt;
&lt;li&gt;assert the exact affected route list;&lt;/li&gt;
&lt;li&gt;assert that &lt;code&gt;check&lt;/code&gt; fails;&lt;/li&gt;
&lt;li&gt;stamp a deterministic date;&lt;/li&gt;
&lt;li&gt;assert the edited metadata; and&lt;/li&gt;
&lt;li&gt;assert that &lt;code&gt;check&lt;/code&gt; now passes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Testing the pre-stamp failure proves the checker can reject stale metadata. A&lt;br&gt;
test that runs only after stamping could pass even if enforcement were missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Persisted dates create reviewable diffs, but developers must maintain them.&lt;/li&gt;
&lt;li&gt;Record-level AST comparison is more precise than file-level matching, but it
constrains how route metadata is authored.&lt;/li&gt;
&lt;li&gt;An explicit dependency map is understandable and testable, but incomplete
until humans add every meaningful shared edge.&lt;/li&gt;
&lt;li&gt;Family-wide propagation avoids false negatives for shared components, but
can update more routes than a deeper component analysis would.&lt;/li&gt;
&lt;li&gt;Git makes local and CI comparisons reproducible, but shallow CI clones need
enough history for the chosen base.&lt;/li&gt;
&lt;li&gt;A route date is evidence of a significant content change, not a promise that
a crawler will revisit the page or that rankings will change.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How I verified it
&lt;/h2&gt;

&lt;p&gt;I used five evidence layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Protocol check:&lt;/strong&gt; confirmed that &lt;code&gt;lastmod&lt;/code&gt; represents the linked page's
modification date, not sitemap generation time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Current Google guidance:&lt;/strong&gt; confirmed the value should be consistently
accurate and tied to significant page updates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Source trace:&lt;/strong&gt; followed descriptors, AST serialization, Git diff
collection, dependency propagation, validation, range edits, and final
sitemap emission.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Isolated integration test:&lt;/strong&gt; ran the real command against a temporary Git
repository and observed the expected pre-stamp failure, targeted writes, and
successful post-stamp check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Release gates:&lt;/strong&gt; ran article validation, Mermaid rendering, publisher
dry-run, TypeScript checking, and the publisher repository's complete test
suite before the authorized public write.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The focused sitemap test passed one test with zero failures. Running &lt;code&gt;list&lt;/code&gt;&lt;br&gt;
against the evidence repository's current &lt;code&gt;HEAD&lt;/code&gt; returned no affected routes&lt;br&gt;
for its unrelated working-tree files, and the repository status was unchanged&lt;br&gt;
after the experiment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Accurate sitemap dates require a route-impact model, not a clock in the sitemap&lt;br&gt;
generator.&lt;/p&gt;

&lt;p&gt;Persist each route's date, compare semantic route records against Git, propagate&lt;br&gt;
significant shared dependencies explicitly, and fail CI when an affected route&lt;br&gt;
still has its previous timestamp. Use AST ranges to update only the selected&lt;br&gt;
fields and isolated Git fixtures to test both the failure and success paths.&lt;/p&gt;

&lt;p&gt;The design will never infer every architectural dependency automatically. That&lt;br&gt;
is acceptable when the dependency map is visible, reviewable, and backed by&lt;br&gt;
manual route overrides.&lt;/p&gt;

&lt;p&gt;The result is a sitemap that changes when pages change—not merely when the site&lt;br&gt;
builds.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI assistance disclosure
&lt;/h2&gt;

&lt;p&gt;AI assisted with outlining and drafting. All implementation claims were&lt;br&gt;
checked against the evidence repository, the route-impact behavior was&lt;br&gt;
verified with its isolated Git integration test, and external behavior claims&lt;br&gt;
were checked against primary sitemap documentation before publication.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Why a Batch Publisher Should Isolate Article Failures</title>
      <dc:creator>xiaoxu</dc:creator>
      <pubDate>Wed, 29 Jul 2026 05:35:10 +0000</pubDate>
      <link>https://dev.to/_3d0d77143d405f723e74f6/why-a-batch-publisher-should-isolate-article-failures-1bed</link>
      <guid>https://dev.to/_3d0d77143d405f723e74f6/why-a-batch-publisher-should-isolate-article-failures-1bed</guid>
      <description>&lt;h1&gt;
  
  
  Why a Batch Publisher Should Isolate Article Failures
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;A batch job has two audiences.&lt;/p&gt;

&lt;p&gt;Each item needs an independent result: one malformed article should not prevent&lt;br&gt;
an unrelated article from being prepared. The scheduler or CI job needs an&lt;br&gt;
honest aggregate result: if any item failed, the batch must not report success.&lt;/p&gt;

&lt;p&gt;The naive implementations satisfy only one side:&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="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;article&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;articles&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="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// first rejection aborts the loop&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is fail-fast. It is simple, but one bad file blocks every later file.&lt;/p&gt;

&lt;p&gt;The opposite mistake catches everything and exits successfully:&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="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;article&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;articles&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// keep going&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;This keeps the batch moving but lies to automation. CI turns green even though&lt;br&gt;
some work was lost.&lt;/p&gt;

&lt;p&gt;The useful middle is &lt;strong&gt;collect and continue&lt;/strong&gt;: isolate each item, retain its&lt;br&gt;
failure, process the rest, and return a non-zero final result.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I built or tested
&lt;/h2&gt;

&lt;p&gt;I traced a TypeScript CLI with a &lt;code&gt;publish-all&lt;/code&gt; command and exercised it using a&lt;br&gt;
no-network fixture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;two valid Markdown articles;&lt;/li&gt;
&lt;li&gt;one Markdown article missing its required title;&lt;/li&gt;
&lt;li&gt;one non-Markdown file; and&lt;/li&gt;
&lt;li&gt;one valid article inside a nested directory.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The observed result was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exitStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"completed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"batch-valid-one"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"batch-valid-two"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"failedArticles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aggregateFailure"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1 article(s) failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ignoredNonMarkdown"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both valid articles completed even though the invalid article failed. The&lt;br&gt;
non-Markdown file was ignored. After every discovered Markdown file had been&lt;br&gt;
considered, the command exited with status 1.&lt;/p&gt;

&lt;p&gt;The experiment ran with &lt;code&gt;--dry-run --platform devto&lt;/code&gt;. It used in-memory&lt;br&gt;
publication state and preview storage, so it made no platform or asset-storage&lt;br&gt;
write.&lt;/p&gt;
&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;The project requires Node.js 22 or later and uses a TypeScript CLI. The relevant&lt;br&gt;
command shape is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;blog-publisher publish-all ./ready-articles &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dry-run&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--platform&lt;/span&gt; devto
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use a deliberately scoped input directory. The discovery function recursively&lt;br&gt;
accepts every filename ending in &lt;code&gt;.md&lt;/code&gt;; it does not know that &lt;code&gt;README.md&lt;/code&gt;,&lt;br&gt;
private experiment notes, an authoring &lt;code&gt;source.md&lt;/code&gt;, and a rendered &lt;code&gt;index.md&lt;/code&gt;&lt;br&gt;
have different roles.&lt;/p&gt;

&lt;p&gt;A safe input boundary might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ready-articles/
├── article-a.md
├── article-b.md
└── release-3/
    └── article-c.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not point the command at a repository root or an article workspace&lt;br&gt;
containing multiple Markdown artifacts unless every matching file is truly a&lt;br&gt;
publishable input.&lt;/p&gt;

&lt;p&gt;The experiment used dry-run intentionally. Testing failure isolation does not&lt;br&gt;
require three real publication attempts.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step-by-step walkthrough
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. Discover candidates recursively
&lt;/h3&gt;

&lt;p&gt;The CLI walks a directory using &lt;code&gt;readdir()&lt;/code&gt; with directory entries. A directory&lt;br&gt;
recurses; a file is included only when its name ends with &lt;code&gt;.md&lt;/code&gt;:&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;articleFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;directory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&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="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;readdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;directory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;withFileTypes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nested&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entryPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;directory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&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;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isDirectory&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;articleFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entryPath&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFile&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.md&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;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;entryPath&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[];&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="nx"&gt;nested&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flat&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;This explains two experiment results: the nested valid article was found, and&lt;br&gt;
the &lt;code&gt;.txt&lt;/code&gt; fixture was ignored.&lt;/p&gt;

&lt;p&gt;It also exposes a constraint. The implementation does not filter by a canonical&lt;br&gt;
filename such as &lt;code&gt;index.md&lt;/code&gt;, does not exclude private directories, and does not&lt;br&gt;
explicitly sort the resulting paths. Treat the directory contents as the&lt;br&gt;
manifest; do not make ordering a business dependency.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Await one article at a time
&lt;/h3&gt;

&lt;p&gt;The batch loop is sequential:&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;failures&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;article&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;publishArticle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;article completed&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;span class="k"&gt;catch &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="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;err&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="nx"&gt;article&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;article failed&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;await&lt;/code&gt; inside &lt;code&gt;for...of&lt;/code&gt; matters. The next article does not begin until&lt;br&gt;
the current promise settles.&lt;/p&gt;

&lt;p&gt;Sequential execution is not a throughput claim. It is a conservative default&lt;br&gt;
for a workflow with shared credentials, platform rate limits, image state, and&lt;br&gt;
publication records. Parallelism may be appropriate later, but only after those&lt;br&gt;
shared boundaries have explicit concurrency guarantees.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Catch at the item boundary
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;try&lt;/code&gt; block covers one call to &lt;code&gt;publishArticle&lt;/code&gt;, not the whole loop. That is&lt;br&gt;
the isolation boundary.&lt;/p&gt;

&lt;p&gt;In the experiment, the invalid fixture failed front matter validation because&lt;br&gt;
it lacked a title. The catch block recorded that file and returned control to&lt;br&gt;
the loop. The later valid article still ran.&lt;/p&gt;

&lt;p&gt;This is different from hiding the error. The logger retains the original error&lt;br&gt;
beside the article path, and the path is added to the failure collection.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. Fail the batch after processing the remainder
&lt;/h3&gt;

&lt;p&gt;After the loop:&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;failures&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;failures&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; article(s) failed`&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 command's top-level error handler sets a non-zero process exit code. CI&lt;br&gt;
therefore receives a failed job, even though successful items were not&lt;br&gt;
discarded.&lt;/p&gt;

&lt;p&gt;The full control flow is:&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%2Fo5avz6tjgireltcvwjmj.webp" 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%2Fo5avz6tjgireltcvwjmj.webp" alt="Mermaid diagram 1" width="525" height="841"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Per-item isolation preserves progress; the final branch preserves batch&lt;br&gt;
truthfulness.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What went wrong
&lt;/h2&gt;

&lt;p&gt;The intentionally malformed file demonstrated the desired item isolation, but&lt;br&gt;
source inspection revealed three broader limits.&lt;/p&gt;

&lt;p&gt;First, the final thrown error contains only a count. The detailed per-item&lt;br&gt;
errors exist in logs, while the in-memory &lt;code&gt;failures&lt;/code&gt; array stores only paths.&lt;br&gt;
A machine that receives only the process result knows that one item failed but&lt;br&gt;
does not receive a structured outcome manifest.&lt;/p&gt;

&lt;p&gt;Second, the batch has no durable checkpoint. If the process crashes halfway&lt;br&gt;
through, &lt;code&gt;publish-all&lt;/code&gt; itself cannot resume at item four. A later invocation&lt;br&gt;
rediscovers the directory. The lower publication layer retains platform&lt;br&gt;
records, which helps individual create/update safety, but that is not a batch&lt;br&gt;
run ledger.&lt;/p&gt;

&lt;p&gt;Third, recursive “all Markdown” discovery is broader than many article&lt;br&gt;
repositories expect. A generated article workspace can contain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;source.md
index.md
published.md
.work/experiment.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All four names end in &lt;code&gt;.md&lt;/code&gt;. Pointing a generic recursive batch at that&lt;br&gt;
workspace can attempt multiple representations of the same article and private&lt;br&gt;
notes that were never intended for publication. Failure isolation limits the&lt;br&gt;
blast radius of bad input; it does not make input selection correct.&lt;/p&gt;
&lt;h2&gt;
  
  
  Fix or mitigation
&lt;/h2&gt;

&lt;p&gt;Keep the simple loop for small, carefully scoped batches, but strengthen its&lt;br&gt;
contract.&lt;/p&gt;
&lt;h3&gt;
  
  
  Return item-level outcomes
&lt;/h3&gt;

&lt;p&gt;Represent every attempted item explicitly:&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;type&lt;/span&gt; &lt;span class="nx"&gt;BatchOutcome&lt;/span&gt; &lt;span class="o"&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;article&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;succeeded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="na"&gt;article&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="na"&gt;article&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;status&lt;/span&gt;&lt;span class="p"&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="nl"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;outcomes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BatchOutcome&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;article&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;publishArticle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;outcomes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;succeeded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &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="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;outcomes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nb"&gt;Error&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="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&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="p"&gt;});&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="nf"&gt;writeBatchReport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outcomes&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;outcomes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;item&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;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;succeeded&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;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exitCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&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;Persisting the report makes the partial result usable by CI, operators, and a&lt;br&gt;
future resume command.&lt;/p&gt;

&lt;p&gt;Do not flatten an ambiguous remote write into ordinary &lt;code&gt;failed&lt;/code&gt;. If an&lt;br&gt;
individual publisher cannot prove whether a create succeeded, retain&lt;br&gt;
&lt;code&gt;unknown&lt;/code&gt; and require reconciliation before another create attempt.&lt;/p&gt;

&lt;h3&gt;
  
  
  Make discovery a contract
&lt;/h3&gt;

&lt;p&gt;Choose one of these input policies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;accept an explicit manifest of article paths;&lt;/li&gt;
&lt;li&gt;include only a canonical filename such as &lt;code&gt;index.md&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;exclude private and generated directories by rule; or&lt;/li&gt;
&lt;li&gt;copy approved inputs into a clean staging directory.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An explicit manifest is the strongest option when order, review, or per-item&lt;br&gt;
metadata matters.&lt;/p&gt;

&lt;h3&gt;
  
  
  Add a focused regression test
&lt;/h3&gt;

&lt;p&gt;The current repository has no focused &lt;code&gt;publish-all&lt;/code&gt; test. A regression test&lt;br&gt;
should inject or spawn a publisher with three items:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;success;&lt;/li&gt;
&lt;li&gt;failure;&lt;/li&gt;
&lt;li&gt;success.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Assert that all three were attempted in sequence, both successes were retained,&lt;br&gt;
and the aggregate result was non-zero. Add separate cases for an empty&lt;br&gt;
directory, nested discovery, and excluded files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Sequential processing is easy to reason about and gentler on shared systems,
but total duration grows with every article.&lt;/li&gt;
&lt;li&gt;Continuing after failure preserves independent progress, but the batch is no
longer atomic. Some items may be public while the final command fails.&lt;/li&gt;
&lt;li&gt;A structured outcome manifest improves recovery and observability, but it
introduces another durable artifact with retention and redaction concerns.&lt;/li&gt;
&lt;li&gt;A broad recursive scanner is convenient for simple staging directories but
dangerous in repositories containing multiple Markdown representations.&lt;/li&gt;
&lt;li&gt;An explicit manifest adds preparation work, but it makes the intended set
and order reviewable.&lt;/li&gt;
&lt;li&gt;Dry-run testing verifies local control flow without publication risk. It
cannot prove remote rate limits, credentials, or delivery.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How I verified it
&lt;/h2&gt;

&lt;p&gt;I used four checks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Source trace:&lt;/strong&gt; inspected recursive discovery, the awaited &lt;code&gt;for...of&lt;/code&gt;
loop, the per-item catch, and the aggregate throw.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Isolated experiment:&lt;/strong&gt; spawned the real CLI in DEV.to dry-run mode against
two valid articles, one invalid article, a nested directory, and an ignored
non-Markdown file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assertions:&lt;/strong&gt; required both valid slugs, exactly one recorded failure,
exit status 1, and the aggregate failure message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Release gates:&lt;/strong&gt; ran the article validator, Mermaid render, publisher
dry-run, TypeScript check, and complete repository test suite before the
authorized public write.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The experiment verified local discovery and error-control semantics. It did&lt;br&gt;
not contact DEV.to, upload an image, benchmark batch speed, or simulate a&lt;br&gt;
process crash.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;A resilient batch publisher should not choose between “stop at the first bad&lt;br&gt;
article” and “pretend every article succeeded.”&lt;/p&gt;

&lt;p&gt;Catch failures at the item boundary, continue independent work, and return a&lt;br&gt;
non-zero aggregate result. Then make the input set explicit and persist&lt;br&gt;
item-level outcomes when the job must survive restarts.&lt;/p&gt;

&lt;p&gt;That pattern is small enough for a CLI and strong enough to provide the two&lt;br&gt;
truths a batch needs: which items completed, and whether the batch as a whole&lt;br&gt;
was clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI assistance disclosure
&lt;/h2&gt;

&lt;p&gt;AI assisted with outlining and drafting. Every implementation claim was&lt;br&gt;
checked against the repository, and the failure-isolation behavior was&lt;br&gt;
verified with a local no-network CLI experiment before publication.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Why a GraphQL Publisher Must Inspect Errors on HTTP 200</title>
      <dc:creator>xiaoxu</dc:creator>
      <pubDate>Tue, 28 Jul 2026 05:37:01 +0000</pubDate>
      <link>https://dev.to/_3d0d77143d405f723e74f6/why-a-graphql-publisher-must-inspect-errors-on-http-200-3ha5</link>
      <guid>https://dev.to/_3d0d77143d405f723e74f6/why-a-graphql-publisher-must-inspect-errors-on-http-200-3ha5</guid>
      <description>&lt;h1&gt;
  
  
  Why a GraphQL Publisher Must Inspect Errors on HTTP 200
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;An HTTP 200 proves that a server returned a successful HTTP response. It does&lt;br&gt;
not prove that the GraphQL operation inside that response succeeded.&lt;/p&gt;

&lt;p&gt;That distinction is easy to miss in a publishing pipeline. The transport can&lt;br&gt;
work perfectly while the mutation is rejected because of permissions, invalid&lt;br&gt;
input, or a resolver failure. If the client checks only &lt;code&gt;response.ok&lt;/code&gt;, it may&lt;br&gt;
read missing fields as if they were a successful post and persist a result that&lt;br&gt;
never existed.&lt;/p&gt;

&lt;p&gt;The inverse is also possible: a GraphQL response can contain both &lt;code&gt;data&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;errors&lt;/code&gt;. The&lt;br&gt;
&lt;a href="https://spec.graphql.org/October2021/#sec-Response" rel="noopener noreferrer"&gt;GraphQL response specification&lt;/a&gt;&lt;br&gt;
allows partial data when field errors occur, and the current&lt;br&gt;
&lt;a href="https://graphql.github.io/graphql-over-http/draft/#sec-Response" rel="noopener noreferrer"&gt;GraphQL-over-HTTP working draft&lt;/a&gt;&lt;br&gt;
explains why field errors can still travel in a successful HTTP response.&lt;/p&gt;

&lt;p&gt;I traced this boundary in a TypeScript publisher and tested it with a mocked&lt;br&gt;
&lt;code&gt;fetch&lt;/code&gt;. The useful result is a three-gate rule:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check the HTTP status.&lt;/li&gt;
&lt;li&gt;Inspect the GraphQL &lt;code&gt;errors&lt;/code&gt; list.&lt;/li&gt;
&lt;li&gt;Require the expected mutation data before recording success.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  What I built or tested
&lt;/h2&gt;

&lt;p&gt;The publisher has a small Hashnode adapter. Its shared HTTP helper rejects&lt;br&gt;
non-2xx responses and attaches the status to the thrown error. After that&lt;br&gt;
transport gate passes, the adapter parses a GraphQL envelope shaped like this:&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;GraphqlResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&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;data&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I exercised the adapter through its public &lt;code&gt;createPost()&lt;/code&gt; method with five&lt;br&gt;
synthetic responses:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Response&lt;/th&gt;
&lt;th&gt;Observed result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HTTP 200 with the expected post data&lt;/td&gt;
&lt;td&gt;Returned the post ID and URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP 200 with two GraphQL errors&lt;/td&gt;
&lt;td&gt;Rejected with both messages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP 200 with &lt;code&gt;{}&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Rejected because data was missing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP 200 with post data and one error&lt;/td&gt;
&lt;td&gt;Rejected under the adapter's all-or-nothing policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP 403 with an error body&lt;/td&gt;
&lt;td&gt;Rejected with status &lt;code&gt;403&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The experiment replaced &lt;code&gt;globalThis.fetch&lt;/code&gt;, used fake configuration, and made&lt;br&gt;
no network request. It verified adapter behavior, not live Hashnode&lt;br&gt;
availability or credentials.&lt;/p&gt;
&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;The relevant environment is Node.js 22 or later with TypeScript. A minimal&lt;br&gt;
mutation client needs a target endpoint and token, but the token belongs in an&lt;br&gt;
environment variable rather than source code:&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;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://example.invalid/graphql&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;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GRAPHQL_TOKEN&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;query&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Hashnode specifically, API writes currently require an eligible Pro&lt;br&gt;
publication. Hashnode's official&lt;br&gt;
&lt;a href="https://hashnode.com/changelog/2026-05-13-graphql-api-paid-access" rel="noopener noreferrer"&gt;API access announcement&lt;/a&gt;&lt;br&gt;
and&lt;br&gt;
&lt;a href="https://hashnode.com/blog/hashnode-gql-agent-skill" rel="noopener noreferrer"&gt;GraphQL agent guidance&lt;/a&gt;&lt;br&gt;
also make an operational point that matters here: access errors should stop&lt;br&gt;
the workflow instead of entering a blind retry loop.&lt;/p&gt;

&lt;p&gt;The experiment did not require that plan or a real token because it intercepted&lt;br&gt;
the request locally.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step-by-step walkthrough
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. Separate HTTP failure from GraphQL failure
&lt;/h3&gt;

&lt;p&gt;The shared HTTP helper reads the response body, then rejects a non-success&lt;br&gt;
status before returning parsed JSON:&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;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusText&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&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="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&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="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Preserving the status is important. A definite &lt;code&gt;403&lt;/code&gt; has different recovery&lt;br&gt;
semantics from a timeout where the client cannot prove whether the operation&lt;br&gt;
ran.&lt;/p&gt;

&lt;p&gt;But this check is only the first gate. Calling &lt;code&gt;JSON.parse()&lt;/code&gt; after a 200 does&lt;br&gt;
not validate the GraphQL operation.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Treat a non-empty &lt;code&gt;errors&lt;/code&gt; list as an operation result
&lt;/h3&gt;

&lt;p&gt;The adapter checks the body immediately after the HTTP helper returns:&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errors&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;item&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;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;; &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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the isolated experiment, this HTTP 200 response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"errors"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"permission denied"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"publication unavailable"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;rejected with both messages. No code attempted to read&lt;br&gt;
&lt;code&gt;data.publishPost.post.id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Joining messages is a modest but useful diagnostic improvement over throwing a&lt;br&gt;
generic “GraphQL failed.” A production client may also retain structured&lt;br&gt;
fields such as &lt;code&gt;path&lt;/code&gt; and safe values from &lt;code&gt;extensions&lt;/code&gt;, subject to redaction.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Require the expected data boundary
&lt;/h3&gt;

&lt;p&gt;An empty error list is still not proof of usable mutation data. The adapter&lt;br&gt;
also checks:&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GraphQL mutation returned no data&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;My mocked HTTP 200 response containing &lt;code&gt;{}&lt;/code&gt; reached this branch. This guards&lt;br&gt;
against a malformed envelope and prevents the caller from treating &lt;code&gt;undefined&lt;/code&gt;&lt;br&gt;
as a publication result.&lt;/p&gt;

&lt;p&gt;For stronger runtime validation, the next step would be to validate the&lt;br&gt;
operation-specific shape too:&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;function&lt;/span&gt; &lt;span class="nf"&gt;requirePublishedPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GraphqlResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;publishPost&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;url&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;};&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="p"&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;envelope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errors&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="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;message&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;message&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;; &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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;publishPost&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;post&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Mutation returned no post ID or URL&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;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;post&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 repository's current generic check proves that top-level data exists; this&lt;br&gt;
operation-specific helper would make nested shape failures explicit rather&lt;br&gt;
than allowing a property-access exception.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. Persist success only after all three gates
&lt;/h3&gt;

&lt;p&gt;The full response path is:&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%2Frp7y6gr4u6scvccklzqn.webp" 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%2Frp7y6gr4u6scvccklzqn.webp" alt="Mermaid diagram 1" width="784" height="994"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Transport, operation, and data-shape checks form separate success gates.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Only the final branch has enough evidence to store a confirmed publication.&lt;br&gt;
This ordering also keeps a scheduler from mistaking “the HTTP request&lt;br&gt;
completed” for “the article was published.”&lt;/p&gt;
&lt;h2&gt;
  
  
  What went wrong
&lt;/h2&gt;

&lt;p&gt;The subtle failure was not in the GraphQL envelope check itself. It appeared&lt;br&gt;
one layer later, when the publication state machine classified the thrown&lt;br&gt;
error.&lt;/p&gt;

&lt;p&gt;The adapter creates a plain &lt;code&gt;Error&lt;/code&gt; for GraphQL messages. That error has no&lt;br&gt;
HTTP status. The outer publication loop currently maps a missing status to&lt;br&gt;
&lt;code&gt;unknown&lt;/code&gt;, the same conservative state used for transport uncertainty.&lt;/p&gt;

&lt;p&gt;That is safe for duplicate prevention, but imprecise for recovery. An explicit&lt;br&gt;
permission rejection is not the same as “the request may have succeeded but&lt;br&gt;
the response was lost.” Treating both as &lt;code&gt;unknown&lt;/code&gt; means a deterministic&lt;br&gt;
configuration problem can require reconciliation rather than a direct fix.&lt;/p&gt;

&lt;p&gt;The current test suite reveals a second gap. It verifies Hashnode publication&lt;br&gt;
action selection—for example, that an unknown result must be reconciled—but it&lt;br&gt;
does not directly test HTTP 200 GraphQL error envelopes. The mocked experiment&lt;br&gt;
covered the behavior for this article, yet turning those cases into permanent&lt;br&gt;
unit tests would better protect the boundary.&lt;/p&gt;

&lt;p&gt;There is one more important case: &lt;code&gt;data&lt;/code&gt; and &lt;code&gt;errors&lt;/code&gt; together. My experiment&lt;br&gt;
returned a valid post plus &lt;code&gt;secondary field failed&lt;/code&gt;. The adapter rejected it&lt;br&gt;
because it checks &lt;code&gt;errors&lt;/code&gt; first. For a mutation, that all-or-nothing policy is&lt;br&gt;
conservative: a side effect may have happened, so the caller should reconcile&lt;br&gt;
instead of assuming nothing changed.&lt;/p&gt;
&lt;h2&gt;
  
  
  Fix or mitigation
&lt;/h2&gt;

&lt;p&gt;Keep the three gates, then make the error category explicit.&lt;/p&gt;

&lt;p&gt;A reusable result type can preserve the distinction without coupling the state&lt;br&gt;
machine to message text:&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;type&lt;/span&gt; &lt;span class="nx"&gt;PublicationFailure&lt;/span&gt; &lt;span class="o"&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;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;transport&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;status&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;graphql&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;codes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt; &lt;span class="nl"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;shape&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The GraphQL adapter can collect safe &lt;code&gt;extensions.code&lt;/code&gt; values when the API&lt;br&gt;
provides them. The publication layer can then apply deliberate recovery rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a definite authorization or validation rejection becomes &lt;code&gt;failed&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;a timeout or ambiguous server failure remains &lt;code&gt;unknown&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;partial mutation data plus errors remains &lt;code&gt;unknown&lt;/code&gt; until the remote state is
reconciled;&lt;/li&gt;
&lt;li&gt;a malformed success envelope stops the run and raises an integration alert.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not classify by searching human-readable messages if structured codes are&lt;br&gt;
available. Message wording can change, may be localized, and can contain&lt;br&gt;
details that should not be persisted.&lt;/p&gt;

&lt;p&gt;A compact regression matrix is also worth keeping:&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="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data only&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dataEnvelope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;success&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;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;errors only&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;errorsEnvelope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;graphql-error&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;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data and errors&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;partialEnvelope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;graphql-error&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;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;empty envelope&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;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;shape-error&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;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;%s&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expected&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="nf"&gt;mockFetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;classifyMutation&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nx"&gt;resolves&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&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;This pattern verifies the protocol boundary without a production publication&lt;br&gt;
or credential.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Rejecting any GraphQL error is easy to reason about, but it discards usable
partial query data. That may be appropriate for publication mutations and
too strict for read-only dashboards.&lt;/li&gt;
&lt;li&gt;Preserving structured error codes adds types and mapping logic. It also makes
retries and operator messages substantially safer.&lt;/li&gt;
&lt;li&gt;Requiring operation-specific data validation adds code for every mutation.
A schema-generated client or runtime validator can reduce repetition, but
neither removes the need for recovery policy.&lt;/li&gt;
&lt;li&gt;Mocked transport tests are deterministic and safe. They do not prove the
current remote schema, account permissions, or service availability.&lt;/li&gt;
&lt;li&gt;Conservative reconciliation can delay automation after a partial mutation,
but it is preferable to publishing a duplicate.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How I verified it
&lt;/h2&gt;

&lt;p&gt;I used four layers of evidence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Source trace:&lt;/strong&gt; followed the request from the HTTP helper through the
Hashnode envelope check and into publication failure persistence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Isolated experiment:&lt;/strong&gt; mocked five response combinations and asserted the
returned ID, joined GraphQL messages, missing-data error, partial-response
policy, and preserved HTTP status.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test audit:&lt;/strong&gt; searched the repository for focused Hashnode adapter tests
and ran the existing state-invariant tests. The focused run passed two
tests, while confirming the envelope coverage gap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Release gates:&lt;/strong&gt; ran the TypeScript check, complete test suite, Mermaid
render, asset preparation, and publisher dry run before the authorized
public write.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The experiment also captured the request method and endpoint, confirmed that&lt;br&gt;
the mutation text was present, and verified that the synthetic token was not&lt;br&gt;
placed in the JSON body.&lt;/p&gt;

&lt;p&gt;These checks establish the local client's behavior. They do not claim that a&lt;br&gt;
live Hashnode mutation succeeded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;GraphQL clients need a richer definition of success than &lt;code&gt;response.ok&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Check the transport, inspect the GraphQL error envelope, and validate the&lt;br&gt;
operation-specific data before persisting a remote ID. Then carry enough error&lt;br&gt;
structure into the state machine to distinguish a definite rejection from an&lt;br&gt;
ambiguous outcome.&lt;/p&gt;

&lt;p&gt;That boundary is small, easy to test without network access, and reusable&lt;br&gt;
across publishing, billing, provisioning, and every other automation where a&lt;br&gt;
200 response can still contain a failed operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI assistance disclosure
&lt;/h2&gt;

&lt;p&gt;AI assisted with outlining and drafting. Every implementation claim was&lt;br&gt;
checked against the repository, and all response cases were exercised with a&lt;br&gt;
local mocked-fetch experiment before publication.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Keeping Asset URLs Deterministic Across ImageKit and R2</title>
      <dc:creator>xiaoxu</dc:creator>
      <pubDate>Mon, 27 Jul 2026 05:36:38 +0000</pubDate>
      <link>https://dev.to/_3d0d77143d405f723e74f6/keeping-asset-urls-deterministic-across-imagekit-and-r2-572p</link>
      <guid>https://dev.to/_3d0d77143d405f723e74f6/keeping-asset-urls-deterministic-across-imagekit-and-r2-572p</guid>
      <description>&lt;h1&gt;
  
  
  Keeping Asset URLs Deterministic Across ImageKit and R2
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;A Markdown publisher should not care whether an optimized diagram lands in&lt;br&gt;
ImageKit or Cloudflare R2. It should hand a storage adapter one object key and&lt;br&gt;
receive one durable public URL.&lt;/p&gt;

&lt;p&gt;That small interface hides meaningful differences.&lt;/p&gt;

&lt;p&gt;ImageKit has an upload API that can overwrite a known path when unique&lt;br&gt;
filenames are disabled. R2 exposes an S3-compatible API, so a publisher can&lt;br&gt;
check an object with &lt;code&gt;HeadObject&lt;/code&gt; before deciding whether to upload it. Their&lt;br&gt;
authentication, folder semantics, public delivery configuration, and failure&lt;br&gt;
modes are not interchangeable.&lt;/p&gt;

&lt;p&gt;The goal is therefore not to pretend the providers are identical. It is to&lt;br&gt;
keep the provider-neutral identity stable while making provider-specific&lt;br&gt;
recovery behavior explicit.&lt;/p&gt;

&lt;p&gt;I traced both adapters in a real TypeScript publisher and ran an isolated&lt;br&gt;
experiment that captured the ImageKit form and R2 S3 commands without sending&lt;br&gt;
anything over the network.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I built or tested
&lt;/h2&gt;

&lt;p&gt;The common contract is deliberately small:&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;StorageProvider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;objectKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;localFile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;objectKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}):&lt;/span&gt; &lt;span class="nb"&gt;Promise&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="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;publicUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;objectKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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;I tested:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;default and explicit provider selection;&lt;/li&gt;
&lt;li&gt;required configuration for each provider;&lt;/li&gt;
&lt;li&gt;public URL construction with spaces in an object key;&lt;/li&gt;
&lt;li&gt;the multipart fields sent to ImageKit;&lt;/li&gt;
&lt;li&gt;R2 &lt;code&gt;HeadObject&lt;/code&gt; and &lt;code&gt;PutObject&lt;/code&gt; command construction;&lt;/li&gt;
&lt;li&gt;R2 behavior for found, 404, and non-404 outcomes; and&lt;/li&gt;
&lt;li&gt;the repository's focused ImageKit URL test.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The experiment used fake configuration, an in-memory &lt;code&gt;fetch&lt;/code&gt; replacement, and&lt;br&gt;
a local S3 command recorder. It did not contact ImageKit or R2, so it does not&lt;br&gt;
claim that credentials, bucket policy, caching, or remote delivery were&lt;br&gt;
verified.&lt;/p&gt;
&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;Provider choice is configuration-driven. ImageKit is the default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;STORAGE_PROVIDER=imagekit
IMAGEKIT_URL_ENDPOINT=https://ik.imagekit.io/example
IMAGEKIT_PRIVATE_KEY=...
IMAGEKIT_FOLDER=/blog_img
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;R2 must be selected explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;STORAGE_PROVIDER=r2
R2_ACCOUNT_ID=...
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET=assets
R2_PUBLIC_BASE_URL=https://assets.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The factory validates provider-specific requirements before constructing an&lt;br&gt;
adapter. That keeps a missing public base URL or credential from surfacing&lt;br&gt;
halfway through image processing.&lt;/p&gt;

&lt;p&gt;Notice that R2 needs two different endpoint concepts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the account-specific S3 API endpoint for authenticated object operations;&lt;/li&gt;
&lt;li&gt;a configured public base URL for readers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Successful &lt;code&gt;PutObject&lt;/code&gt; does not, by itself, prove that the resulting public&lt;br&gt;
URL is reachable.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step-by-step walkthrough
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. Keep one provider-neutral object key
&lt;/h3&gt;

&lt;p&gt;The upstream image pipeline supplies keys shaped like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;blog/article-slug/diagram-contenthash.webp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both adapters consume that same key. Their hostnames and API calls differ, but&lt;br&gt;
the path identity does not. This is the most useful portability boundary:&lt;br&gt;
switching providers changes where the bytes live, not how the article refers&lt;br&gt;
to the logical asset.&lt;/p&gt;

&lt;p&gt;Both &lt;code&gt;publicUrl()&lt;/code&gt; methods encode each path segment independently. In the&lt;br&gt;
experiment, a key containing &lt;code&gt;diagram 1.webp&lt;/code&gt; became&lt;br&gt;
&lt;code&gt;diagram%201.webp&lt;/code&gt; for both providers. Encoding segments instead of an entire&lt;br&gt;
URL preserves &lt;code&gt;/&lt;/code&gt; as hierarchy while protecting spaces and other path&lt;br&gt;
characters.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Route through a strict factory
&lt;/h3&gt;

&lt;p&gt;The storage factory first checks configuration, then chooses an adapter:&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STORAGE_PROVIDER&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;r2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;R2Storage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ImageKitStorage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My experiment observed ImageKit for default configuration and R2 only when&lt;br&gt;
&lt;code&gt;STORAGE_PROVIDER=r2&lt;/code&gt; was explicit. It also observed the complete missing-field&lt;br&gt;
list for each provider.&lt;/p&gt;

&lt;p&gt;This is more useful than allowing a half-configured fallback. If CI intends to&lt;br&gt;
write to R2, silently selecting ImageKit would create valid URLs in the wrong&lt;br&gt;
account.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Make ImageKit overwrite semantics deliberate
&lt;/h3&gt;

&lt;p&gt;The ImageKit adapter constructs a multipart upload with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fileName=diagram.webp
folder=/blog_img/blog/demo
useUniqueFileName=false
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The current&lt;br&gt;
&lt;a href="https://imagekit.io/docs/api-reference/upload-file/upload-file" rel="noopener noreferrer"&gt;ImageKit upload documentation&lt;/a&gt;&lt;br&gt;
states that &lt;code&gt;useUniqueFileName=false&lt;/code&gt; keeps the supplied filename and replaces&lt;br&gt;
an existing file at the same name. That behavior is important because the&lt;br&gt;
adapter deliberately returns &lt;code&gt;false&lt;/code&gt; from &lt;code&gt;exists()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Its recovery model is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;trust the local image table as the durable deduplication index;&lt;/li&gt;
&lt;li&gt;when that record is unavailable, upload the deterministic path again;&lt;/li&gt;
&lt;li&gt;overwrite that path instead of creating a suffixed duplicate.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The successful upload response can supply the delivery URL. If it omits one,&lt;br&gt;
the adapter can derive the URL from its configured endpoint, folder, and&lt;br&gt;
object key.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. Let R2 check the object remotely
&lt;/h3&gt;

&lt;p&gt;R2 takes a different path:&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%2F1ph32xfegcqvgbds8l1t.webp" 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%2F1ph32xfegcqvgbds8l1t.webp" alt="Mermaid diagram 1" width="784" height="654"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One object identity, two explicit recovery strategies.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The R2 adapter sends &lt;code&gt;HeadObject&lt;/code&gt; to its configured bucket. A 404 means the&lt;br&gt;
object is absent. Other errors are rethrown rather than treated as a cache&lt;br&gt;
miss.&lt;/p&gt;

&lt;p&gt;That distinction prevents an authorization failure or service problem from&lt;br&gt;
silently becoming a write. “I could not check” is not the same as “the object&lt;br&gt;
does not exist.”&lt;/p&gt;

&lt;p&gt;When upload is needed, the adapter sends &lt;code&gt;PutObject&lt;/code&gt; with the key, content&lt;br&gt;
type, and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cache-Control: public, max-age=31536000, immutable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cloudflare documents R2's&lt;br&gt;
&lt;a href="https://developers.cloudflare.com/r2/api/s3/api/" rel="noopener noreferrer"&gt;S3-compatible endpoint and operations&lt;/a&gt;,&lt;br&gt;
including &lt;code&gt;HeadObject&lt;/code&gt;, and provides&lt;br&gt;
&lt;a href="https://developers.cloudflare.com/r2/get-started/s3/" rel="noopener noreferrer"&gt;JavaScript &lt;code&gt;PutObject&lt;/code&gt; examples&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  What went wrong
&lt;/h2&gt;

&lt;p&gt;The provider interface initially suggests symmetric behavior, but &lt;code&gt;exists()&lt;/code&gt;&lt;br&gt;
does not mean the same thing in both adapters.&lt;/p&gt;

&lt;p&gt;For R2, it is a real remote existence check. For ImageKit, it always returns&lt;br&gt;
&lt;code&gt;false&lt;/code&gt;; the comment explains that the local database is the durable index and&lt;br&gt;
deterministic overwrite is the fallback.&lt;/p&gt;

&lt;p&gt;That asymmetry is intentional, but it is easy to miss in a code review. A&lt;br&gt;
generic caller that assumes every &lt;code&gt;exists()&lt;/code&gt; asks the remote provider could&lt;br&gt;
draw the wrong conclusions about traffic, cost, or failure recovery.&lt;/p&gt;

&lt;p&gt;The test gap makes the risk larger. The repository has one direct storage test&lt;br&gt;
for ImageKit folder placement and encoded URLs. It has no direct R2 adapter&lt;br&gt;
test for 404 classification, non-404 rethrow, upload metadata, or URL encoding.&lt;/p&gt;

&lt;p&gt;There is also a deployment boundary that local command construction cannot&lt;br&gt;
verify. R2 buckets are private by default. Cloudflare's&lt;br&gt;
&lt;a href="https://developers.cloudflare.com/r2/buckets/public-buckets/" rel="noopener noreferrer"&gt;public bucket documentation&lt;/a&gt;&lt;br&gt;
describes custom domains and the &lt;code&gt;r2.dev&lt;/code&gt; development endpoint, and recommends&lt;br&gt;
custom domains for production features. A correct &lt;code&gt;R2_PUBLIC_BASE_URL&lt;/code&gt; still&lt;br&gt;
requires the matching bucket exposure to be configured.&lt;/p&gt;
&lt;h2&gt;
  
  
  Fix or mitigation
&lt;/h2&gt;

&lt;p&gt;I would keep the small interface, but add an adapter contract suite with&lt;br&gt;
provider-specific cases.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Contract case&lt;/th&gt;
&lt;th&gt;ImageKit expectation&lt;/th&gt;
&lt;th&gt;R2 expectation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;encoded public path&lt;/td&gt;
&lt;td&gt;endpoint + folder + encoded key&lt;/td&gt;
&lt;td&gt;public base + encoded key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;known local record&lt;/td&gt;
&lt;td&gt;caller skips provider work&lt;/td&gt;
&lt;td&gt;caller skips provider work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;storage existence check&lt;/td&gt;
&lt;td&gt;returns false by design&lt;/td&gt;
&lt;td&gt;sends &lt;code&gt;HeadObject&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;missing remote object&lt;/td&gt;
&lt;td&gt;deterministic overwrite upload&lt;/td&gt;
&lt;td&gt;404 then &lt;code&gt;PutObject&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;authorization/service error&lt;/td&gt;
&lt;td&gt;upload response must fail&lt;/td&gt;
&lt;td&gt;non-404 HEAD error rethrows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;upload result&lt;/td&gt;
&lt;td&gt;response URL or derived fallback&lt;/td&gt;
&lt;td&gt;derived public base URL&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The suite should use fake transports, as my experiment did, so it can inspect&lt;br&gt;
requests without credentials. A separate integration check can then verify one&lt;br&gt;
real upload in a disposable prefix for each configured provider.&lt;/p&gt;

&lt;p&gt;I would also rename or document the existence capability more explicitly if&lt;br&gt;
more adapters are added. A method such as &lt;code&gt;remoteExists()&lt;/code&gt; communicates more&lt;br&gt;
than a generic &lt;code&gt;exists()&lt;/code&gt;, while an adapter capability flag could make&lt;br&gt;
database-led recovery visible to the caller.&lt;/p&gt;
&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;p&gt;ImageKit's deterministic overwrite path is simple and works even when the&lt;br&gt;
local deduplication record is lost. The cost is that recovery performs an&lt;br&gt;
upload rather than a remote existence query.&lt;/p&gt;

&lt;p&gt;R2's HEAD-before-PUT path can recover an existing object without uploading it.&lt;br&gt;
It adds another remote operation and requires careful error classification. It&lt;br&gt;
also separates S3 write access from public delivery configuration.&lt;/p&gt;

&lt;p&gt;Immutable cache headers fit content-addressed object keys, because changed&lt;br&gt;
bytes should produce a changed key. They are dangerous if mutable content is&lt;br&gt;
reused at the same key. The cache policy and naming policy must therefore be&lt;br&gt;
reviewed together.&lt;/p&gt;

&lt;p&gt;Finally, deterministic does not mean identical across providers. The hostname&lt;br&gt;
and provider folder prefix differ. The invariant is the encoded logical object&lt;br&gt;
path and repeatable mapping within each configured provider.&lt;/p&gt;
&lt;h2&gt;
  
  
  How I verified it
&lt;/h2&gt;

&lt;p&gt;The isolated experiment produced these key observations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"factoryDefaultsTo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"imagekit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"factorySelectsR2"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"r2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"imageKitExists"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"imageKitUseUniqueFileName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"false"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"r2Exists"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"r2NotFound"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"r2ServerErrorRethrown"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"r2Commands"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"HeadObjectCommand"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"PutObjectCommand"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also verified that both adapters encoded a space as &lt;code&gt;%20&lt;/code&gt;, that the&lt;br&gt;
ImageKit folder contained the configured prefix exactly once, and that the R2&lt;br&gt;
put command carried the expected bucket, key, content type, and immutable&lt;br&gt;
cache header.&lt;/p&gt;

&lt;p&gt;The focused ImageKit test passed. Before release, I additionally ran the&lt;br&gt;
article validator, Mermaid rendering and visual inspection, publisher dry-run,&lt;br&gt;
TypeScript typecheck, and the full repository test suite.&lt;/p&gt;

&lt;p&gt;No remote provider was contacted during the experiment. A real R2 public&lt;br&gt;
domain and real ImageKit authorization remain integration concerns, not&lt;br&gt;
claims made by this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;A useful storage abstraction does not erase provider differences. It gives&lt;br&gt;
those differences a narrow place to live.&lt;/p&gt;

&lt;p&gt;Keep one object key across adapters. Validate configuration before processing.&lt;br&gt;
Encode public paths consistently. Then test each provider's actual recovery&lt;br&gt;
model: deterministic overwrite for ImageKit, HEAD-before-PUT for R2, and hard&lt;br&gt;
failure when an existence result is ambiguous.&lt;/p&gt;

&lt;p&gt;That is enough to let the Markdown publisher stay provider-neutral without&lt;br&gt;
pretending its storage systems behave the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI assistance disclosure
&lt;/h2&gt;

&lt;p&gt;I used an AI coding assistant to trace the storage adapters, prepare the&lt;br&gt;
isolated transport experiment, compare provider behavior with official&lt;br&gt;
ImageKit and Cloudflare documentation, and edit the draft. I reviewed the&lt;br&gt;
cited source, executed the reported checks, inspected the rendered diagram,&lt;br&gt;
and kept unexecuted remote behavior explicitly labeled as a limitation.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Using Front Matter as a Multi-Platform Publishing Contract</title>
      <dc:creator>xiaoxu</dc:creator>
      <pubDate>Fri, 24 Jul 2026 05:38:12 +0000</pubDate>
      <link>https://dev.to/_3d0d77143d405f723e74f6/using-front-matter-as-a-multi-platform-publishing-contract-1nhj</link>
      <guid>https://dev.to/_3d0d77143d405f723e74f6/using-front-matter-as-a-multi-platform-publishing-contract-1nhj</guid>
      <description>&lt;h1&gt;
  
  
  Using Front Matter as a Multi-Platform Publishing Contract
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;A Markdown article often looks self-contained: front matter at the top, prose&lt;br&gt;
below it, and perhaps a few local images. But once a publisher targets more&lt;br&gt;
than one platform, those first lines stop being editorial decoration. They&lt;br&gt;
decide where a network write goes, whether it creates a draft or a public&lt;br&gt;
article, which tags cross the API boundary, and whether a canonical URL or&lt;br&gt;
cover image is attached.&lt;/p&gt;

&lt;p&gt;That makes front matter deployment configuration.&lt;/p&gt;

&lt;p&gt;The dangerous failure mode is not always invalid YAML. A syntactically valid&lt;br&gt;
file can still express a surprising operation. In the publisher I examined,&lt;br&gt;
omitting &lt;code&gt;platforms&lt;/code&gt; enables both supported platforms by default. Omitting&lt;br&gt;
&lt;code&gt;published&lt;/code&gt; is safer—it defaults to &lt;code&gt;false&lt;/code&gt;—but setting it to &lt;code&gt;true&lt;/code&gt; changes the&lt;br&gt;
default operation to public unless the command explicitly overrides the mode.&lt;/p&gt;

&lt;p&gt;I traced the contract from parser to adapter, exercised its defaults and&lt;br&gt;
rejections with temporary fixtures, and captured the outgoing DEV.to payload&lt;br&gt;
without making a network request.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I built or tested
&lt;/h2&gt;

&lt;p&gt;The publisher uses a typed metadata shape:&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ArticleMeta&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;description&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;cover&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;canonical_url&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;published&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;platforms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Partial&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;devto&lt;/span&gt;&lt;span class="dl"&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;hashnode&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;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;I tested four boundaries:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;parsing a fully explicit article;&lt;/li&gt;
&lt;li&gt;parsing a minimal article and observing defaults;&lt;/li&gt;
&lt;li&gt;deriving and overriding the effective release mode; and&lt;/li&gt;
&lt;li&gt;mapping metadata into a captured DEV.to request.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I also supplied three malformed values: a non-URL canonical value,&lt;br&gt;
comma-separated tags instead of an array, and a string where a platform&lt;br&gt;
boolean was required. All three failed locally with &lt;code&gt;ZodError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The experiment did not contact DEV.to or Hashnode. It verifies the local&lt;br&gt;
contract and payload construction, not remote acceptance.&lt;/p&gt;
&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;The source article uses this shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;descriptive&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;title"&lt;/span&gt;
&lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a-descriptive-title"&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;short&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;platform&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;summary."&lt;/span&gt;
&lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;typescript&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;testing&lt;/span&gt;
&lt;span class="na"&gt;published&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="na"&gt;platforms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;devto&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;hashnode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two rules are worth making explicit in a review checklist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tags&lt;/code&gt; is a YAML array, not a comma-separated string.&lt;/li&gt;
&lt;li&gt;Every production article should state its platform booleans even though the
parser provides defaults.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The parser accepts an optional local &lt;code&gt;cover&lt;/code&gt; path and an optional&lt;br&gt;
&lt;code&gt;canonical_url&lt;/code&gt;. The canonical value must be a URL. A cover takes a separate&lt;br&gt;
asset path through image processing before it becomes a platform-facing URL.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step-by-step walkthrough
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. Parse once into a narrow metadata type
&lt;/h3&gt;

&lt;p&gt;The Markdown loader separates YAML from body content, then validates the&lt;br&gt;
metadata with Zod. &lt;code&gt;title&lt;/code&gt; and &lt;code&gt;slug&lt;/code&gt; must be non-empty strings. Tags default&lt;br&gt;
to an empty array, &lt;code&gt;published&lt;/code&gt; defaults to &lt;code&gt;false&lt;/code&gt;, and &lt;code&gt;platforms&lt;/code&gt; defaults to&lt;br&gt;
this:&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="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;devto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hashnode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Defaults make small examples convenient, but the platform default is broad.&lt;br&gt;
If both credentials exist, an omitted field can expand the blast radius of a&lt;br&gt;
later publish command. My mitigation is simple: allow the parser default for&lt;br&gt;
backward compatibility, but require explicit platform values in generated and&lt;br&gt;
reviewed production content.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Turn metadata into an operation
&lt;/h3&gt;

&lt;p&gt;The publisher derives the effective mode from the article when no command&lt;br&gt;
option is present:&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;mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;published&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;public&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;draft&lt;/span&gt;&lt;span class="dl"&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 CLI can supply &lt;code&gt;--draft&lt;/code&gt; or &lt;code&gt;--public&lt;/code&gt;, and rejects using both. This creates&lt;br&gt;
a useful precedence rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;explicit command mode &amp;gt; front-matter published flag &amp;gt; safe false default
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keeping &lt;code&gt;published: false&lt;/code&gt; in the canonical source is therefore compatible&lt;br&gt;
with a reviewed public release: the automation can run all checks first, then&lt;br&gt;
make the final command's &lt;code&gt;--public&lt;/code&gt; flag the release decision.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Intersect article targets with command scope
&lt;/h3&gt;

&lt;p&gt;Front matter says which platforms an article permits. A repeated&lt;br&gt;
&lt;code&gt;--platform&lt;/code&gt; option can narrow that list further. The publisher collects&lt;br&gt;
enabled metadata targets and intersects them with the requested platforms.&lt;/p&gt;

&lt;p&gt;That distinction matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;front matter is the article's durable allowlist;&lt;/li&gt;
&lt;li&gt;the command is the current operation's requested scope.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A command should be able to narrow an allowlist, not silently widen it.&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%2Fov6kjzhd6ezhihvdh9b3.webp" 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%2Fov6kjzhd6ezhihvdh9b3.webp" alt="Mermaid diagram 1" width="784" height="105"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Durable article policy and one-run command scope meet before any adapter is&lt;br&gt;
called.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  4. Map fields at the adapter boundary
&lt;/h3&gt;

&lt;p&gt;After local images are processed, the publisher constructs a platform-neutral&lt;br&gt;
&lt;code&gt;PublishInput&lt;/code&gt; containing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;title and slug;&lt;/li&gt;
&lt;li&gt;transformed Markdown;&lt;/li&gt;
&lt;li&gt;description and tags;&lt;/li&gt;
&lt;li&gt;canonical and cover URLs; and&lt;/li&gt;
&lt;li&gt;the effective public/draft boolean.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The DEV.to adapter translates that input into its API object. It converts the&lt;br&gt;
tag array into a comma-separated value and takes at most four tags:&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="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="dl"&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 current&lt;br&gt;
&lt;a href="https://developers.forem.com/api/v1" rel="noopener noreferrer"&gt;Forem API documentation&lt;/a&gt;&lt;br&gt;
documents the same create fields—title, Markdown body, published state, tags,&lt;br&gt;
main image, canonical URL, and description—and limits an article to four&lt;br&gt;
tags.&lt;/p&gt;

&lt;p&gt;My intercepted request started with five tags and contained only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;typescript,testing,automation,devops
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That proves the local truncation behavior. It does not prove that the fifth&lt;br&gt;
tag is unimportant. Silent truncation can hide a content mistake, so the&lt;br&gt;
better generator-side rule is to reject more than four tags before the&lt;br&gt;
adapter.&lt;/p&gt;
&lt;h2&gt;
  
  
  What went wrong
&lt;/h2&gt;

&lt;p&gt;The most surprising result came from the minimal valid fixture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Minimal&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;article"&lt;/span&gt;
&lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;minimal-article"&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It parsed successfully with no tags, draft mode, and &lt;strong&gt;both&lt;/strong&gt; platforms&lt;br&gt;
enabled. That is valid according to the repository schema, but it is a risky&lt;br&gt;
default for unattended publishing. The meaning of “field absent” is not “do&lt;br&gt;
nothing”; it is “allow both targets.”&lt;/p&gt;

&lt;p&gt;There is a second asymmetry. The parser accepts any number of tags, while the&lt;br&gt;
DEV.to adapter sends only the first four. Validation and transport therefore&lt;br&gt;
do not express the same limit. The payload is legal, but an author can believe&lt;br&gt;
five tags were selected when one was silently discarded.&lt;/p&gt;

&lt;p&gt;Finally, the test suite has focused DEV.to adapter coverage but no direct&lt;br&gt;
table-driven test for front-matter defaults and malformed values. TypeScript&lt;br&gt;
interfaces cannot close that gap because YAML arrives as runtime data.&lt;/p&gt;
&lt;h2&gt;
  
  
  Fix or mitigation
&lt;/h2&gt;

&lt;p&gt;I would enforce the contract at three levels.&lt;/p&gt;

&lt;p&gt;First, keep runtime schema validation. It correctly rejected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;canonical_url: "not-a-url"&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tags: "typescript,testing"&lt;/code&gt;; and&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;devto: "yes"&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Second, make production policy stricter than parser compatibility:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;published&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="na"&gt;platforms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;devto&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;hashnode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Require one to four lowercase tags for DEV.to and omit &lt;code&gt;canonical_url&lt;/code&gt; unless&lt;br&gt;
the article actually has an original publication URL. Require a local relative&lt;br&gt;
cover path only when that file exists.&lt;/p&gt;

&lt;p&gt;Third, add table-driven contract tests. A small matrix should assert:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Case&lt;/th&gt;
&lt;th&gt;Expected result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;explicit single target&lt;/td&gt;
&lt;td&gt;only that target is enabled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;omitted platforms&lt;/td&gt;
&lt;td&gt;documented default is returned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;public flag with draft override&lt;/td&gt;
&lt;td&gt;effective mode is draft&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;five DEV.to tags&lt;/td&gt;
&lt;td&gt;validation fails before truncation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;malformed canonical URL&lt;/td&gt;
&lt;td&gt;parser rejects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;string platform flag&lt;/td&gt;
&lt;td&gt;parser rejects&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The key is to test both valid defaults and invalid inputs. Testing only the&lt;br&gt;
happy-path YAML leaves the most operationally important semantics implicit.&lt;/p&gt;
&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;p&gt;Front matter keeps article policy beside the content. It is reviewable in Git,&lt;br&gt;
portable across environments, and easy for a generator to produce. A&lt;br&gt;
platform-neutral &lt;code&gt;PublishInput&lt;/code&gt; also prevents every adapter from reparsing&lt;br&gt;
Markdown metadata independently.&lt;/p&gt;

&lt;p&gt;The same convenience creates coupling:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;changing a parser default changes old articles that omitted the field;&lt;/li&gt;
&lt;li&gt;platform-specific limits can be hidden behind a generic type;&lt;/li&gt;
&lt;li&gt;a boolean &lt;code&gt;published&lt;/code&gt; flag mixes authoring intent with release behavior;&lt;/li&gt;
&lt;li&gt;canonical URLs and covers require validation beyond TypeScript's compile
time; and&lt;/li&gt;
&lt;li&gt;multi-platform metadata tends toward the least common denominator.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a larger system, I would separate compatible parsing defaults from a&lt;br&gt;
stricter release policy validator. The parser answers “can I understand this&lt;br&gt;
file?” The release gate answers “is this exact operation allowed now?”&lt;/p&gt;
&lt;h2&gt;
  
  
  How I verified it
&lt;/h2&gt;

&lt;p&gt;The isolated experiment observed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"minimalDefaults"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"published"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"platforms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"devto"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"hashnode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"modeFromPublishedFlag"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"public"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"explicitModeOverride"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"draft"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"capturedDevtoTags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"typescript,testing,automation,devops"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"invalidCanonicalUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ZodError"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"commaSeparatedTags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ZodError"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"nonBooleanPlatform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ZodError"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The adapter call used an in-memory replacement for &lt;code&gt;fetch&lt;/code&gt;, so no API received&lt;br&gt;
the fixture or fake credential. The repository's focused DEV.to test file also&lt;br&gt;
passed all three tests.&lt;/p&gt;

&lt;p&gt;Before public release, I additionally ran the article validator, Mermaid&lt;br&gt;
rendering and visual inspection, the publisher dry-run, the TypeScript&lt;br&gt;
typecheck, and the complete repository test suite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Front matter is a compact publishing contract only when its semantics are&lt;br&gt;
explicit and tested. The most important fields are not the visible title and&lt;br&gt;
description; they are the ones that control side effects: targets, mode,&lt;br&gt;
canonical identity, cover processing, and platform limits.&lt;/p&gt;

&lt;p&gt;Parse runtime data into a narrow type, intersect durable article permissions&lt;br&gt;
with one-run command scope, and validate platform constraints before the&lt;br&gt;
adapter silently normalizes them. Keep the source in draft mode and make the&lt;br&gt;
final public flag a reviewed release decision.&lt;/p&gt;

&lt;p&gt;That turns a few lines of YAML from hopeful metadata into an auditable safety&lt;br&gt;
boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI assistance disclosure
&lt;/h2&gt;

&lt;p&gt;I used an AI coding assistant to trace metadata from parser to platform&lt;br&gt;
adapter, create the isolated fixture experiment, compare the payload with&lt;br&gt;
official Forem documentation, and edit the draft. I reviewed the cited source,&lt;br&gt;
executed the reported checks, inspected the rendered diagram, and made the&lt;br&gt;
final publication decision only after the automated gates passed.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Using SQLite Locally and MySQL in CI for Publication State</title>
      <dc:creator>xiaoxu</dc:creator>
      <pubDate>Thu, 23 Jul 2026 05:37:02 +0000</pubDate>
      <link>https://dev.to/_3d0d77143d405f723e74f6/using-sqlite-locally-and-mysql-in-ci-for-publication-state-hje</link>
      <guid>https://dev.to/_3d0d77143d405f723e74f6/using-sqlite-locally-and-mysql-in-ci-for-publication-state-hje</guid>
      <description>&lt;h1&gt;
  
  
  Using SQLite Locally and MySQL in CI for Publication State
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;Publishing is not a fire-and-forget HTTP request. A practical publisher needs&lt;br&gt;
to remember which source hash it sent, which remote ID and URL came back, and&lt;br&gt;
which rendered images were already uploaded. That state prevents duplicate&lt;br&gt;
posts and unnecessary asset uploads.&lt;/p&gt;

&lt;p&gt;For a command-line tool, SQLite is a great local default: it requires no&lt;br&gt;
service and keeps state beside the project. CI or a shared runner may need&lt;br&gt;
MySQL instead. The tempting design is to put both behind one TypeScript&lt;br&gt;
interface and assume the backends are interchangeable.&lt;/p&gt;

&lt;p&gt;The interface is necessary, but it is not proof of parity. SQL dialects,&lt;br&gt;
timestamp types, JSON decoding, and concurrency semantics still cross the&lt;br&gt;
boundary. I tested the local path, compared both implementations line by line,&lt;br&gt;
and found one MySQL-specific risk that the existing unit test cannot see.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I built or tested
&lt;/h2&gt;

&lt;p&gt;The repository already defines a small &lt;code&gt;DatabaseProvider&lt;/code&gt; contract:&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;DatabaseProvider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;findPublishRecord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;PublishRecord&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;savePublishRecord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PublishRecord&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;findImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&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;objectKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ImageRecord&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;saveImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ImageRecord&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;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;Both implementations store two kinds of idempotency state:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a publication record keyed by article slug; and&lt;/li&gt;
&lt;li&gt;an image record keyed by storage provider plus object key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I exercised SQLite with repeated writes and a reopen, checked provider&lt;br&gt;
selection and configuration failure, ran the focused test, and inspected the&lt;br&gt;
MySQL SQL and installed driver. I did &lt;strong&gt;not&lt;/strong&gt; run a MySQL server, so this is not&lt;br&gt;
an end-to-end MySQL compatibility claim.&lt;/p&gt;
&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;The project runs on Node.js 22 and TypeScript. Its configuration defaults to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DATABASE_DRIVER=sqlite
DATABASE_SQLITE_PATH=.publish/blog-publisher.db
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Selecting MySQL is explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DATABASE_DRIVER=mysql
DATABASE_HOST=mysql
DATABASE_PORT=3306
DATABASE_NAME=publisher
DATABASE_USERNAME=publisher
DATABASE_PASSWORD=...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The factory rejects the MySQL choice when host, database name, or username is&lt;br&gt;
missing. That early failure matters: silently falling back to a local file in&lt;br&gt;
CI would split state between runs.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step-by-step walkthrough
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. Keep the logical keys identical
&lt;/h3&gt;

&lt;p&gt;SQLite creates a &lt;code&gt;publish_records&lt;/code&gt; table with &lt;code&gt;slug&lt;/code&gt; as its primary key and an&lt;br&gt;
&lt;code&gt;images&lt;/code&gt; table with a composite &lt;code&gt;(provider, object_key)&lt;/code&gt; primary key. MySQL&lt;br&gt;
uses the same logical keys with bounded &lt;code&gt;VARCHAR&lt;/code&gt; columns.&lt;/p&gt;

&lt;p&gt;That gives both implementations the same answer to “is this the record I&lt;br&gt;
already know?” even though their physical types differ.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Express an upsert in each dialect
&lt;/h3&gt;

&lt;p&gt;SQLite writes a publication like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;publish_records&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;platforms_json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&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="o"&gt;?&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="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt;
  &lt;span class="n"&gt;source_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;excluded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;platforms_json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;excluded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;platforms_json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;excluded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updated_at&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MySQL uses its corresponding form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;publish_records&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;platforms_json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&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="o"&gt;?&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="n"&gt;UTC_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;DUPLICATE&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;
  &lt;span class="n"&gt;source_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source_hash&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;platforms_json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;platforms_json&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The syntax differs, but the intended invariant is the same: a second write for&lt;br&gt;
the same logical key updates one row instead of creating another. The&lt;br&gt;
&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/insert-on-duplicate.html" rel="noopener noreferrer"&gt;MySQL upsert documentation&lt;/a&gt;&lt;br&gt;
is the source of truth for that server-side behavior.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Normalize records at the provider boundary
&lt;/h3&gt;

&lt;p&gt;The rest of the publisher should not know whether SQLite stores platform state&lt;br&gt;
as text or MySQL stores it in a native JSON column. Both providers serialize a&lt;br&gt;
&lt;code&gt;platforms&lt;/code&gt; object on write and should return the same JavaScript shape on&lt;br&gt;
read.&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%2Ftefdpgg7e624dawl6j8r.webp" 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%2Ftefdpgg7e624dawl6j8r.webp" alt="Mermaid diagram 1" width="784" height="161"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One application contract, two storage adapters, one logical state model.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  4. Test replacement and persistence, not just insertion
&lt;/h3&gt;

&lt;p&gt;My experiment wrote one slug first as a draft with remote ID &lt;code&gt;42&lt;/code&gt;, then as&lt;br&gt;
published with ID &lt;code&gt;99&lt;/code&gt;. It also wrote the same image key twice with different&lt;br&gt;
hashes and URLs. After closing and reopening SQLite, I queried the records and&lt;br&gt;
counted the rows.&lt;/p&gt;

&lt;p&gt;The result contained one publication and one image—the updated values survived&lt;br&gt;
the reopen. The database also reported &lt;code&gt;wal&lt;/code&gt; journal mode. The official&lt;br&gt;
&lt;a href="https://www.sqlite.org/wal.html" rel="noopener noreferrer"&gt;SQLite WAL documentation&lt;/a&gt; explains why WAL&lt;br&gt;
can improve reader/writer concurrency, while still allowing only one writer at&lt;br&gt;
a time. It also documents a crucial boundary: WAL relies on shared memory and&lt;br&gt;
is not a network-filesystem design.&lt;/p&gt;
&lt;h2&gt;
  
  
  What went wrong
&lt;/h2&gt;

&lt;p&gt;The schemas look parallel, but the MySQL JSON read boundary is not.&lt;/p&gt;

&lt;p&gt;The provider declares &lt;code&gt;platforms_json&lt;/code&gt; as a string and does this:&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="nx"&gt;platforms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platforms_json&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;However, the installed &lt;code&gt;mysql2&lt;/code&gt; 3.22.6 driver defaults &lt;code&gt;jsonStrings&lt;/code&gt; to&lt;br&gt;
&lt;code&gt;false&lt;/code&gt;. Its result parser decodes a MySQL JSON column with &lt;code&gt;JSON.parse&lt;/code&gt; before&lt;br&gt;
returning the row. The pool configuration does not override that default.&lt;/p&gt;

&lt;p&gt;That means the provider may receive an object and attempt to parse it again.&lt;br&gt;
In plain JavaScript, &lt;code&gt;JSON.parse({ devto: { status: "published" } })&lt;/code&gt; throws a&lt;br&gt;
&lt;code&gt;SyntaxError&lt;/code&gt; because the object is coerced to &lt;code&gt;"[object Object]"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is a source-grounded compatibility risk, not a live-server reproduction.&lt;br&gt;
The existing focused test only instantiates &lt;code&gt;SqliteDatabaseProvider&lt;/code&gt;, so it&lt;br&gt;
cannot catch the mismatch. MySQL's&lt;br&gt;
&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/json.html" rel="noopener noreferrer"&gt;native JSON documentation&lt;/a&gt;&lt;br&gt;
confirms that the column validates and stores JSON documents; how a Node.js&lt;br&gt;
driver returns that value remains a client-boundary concern.&lt;/p&gt;
&lt;h2&gt;
  
  
  Fix or mitigation
&lt;/h2&gt;

&lt;p&gt;There are two defensible fixes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Set &lt;code&gt;jsonStrings: true&lt;/code&gt; in the MySQL pool and keep the provider's explicit
&lt;code&gt;JSON.parse&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Let &lt;code&gt;mysql2&lt;/code&gt; decode JSON, type the returned field as the platform-state
object, and normalize string-or-object input in one helper if mixed drivers
must be supported.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I prefer the second approach when the application controls its driver version:&lt;br&gt;
the provider's runtime type matches the driver's default behavior, and parsing&lt;br&gt;
is not duplicated. The first approach is smaller and preserves the existing&lt;br&gt;
provider code. Either choice is incomplete without a MySQL integration test.&lt;/p&gt;

&lt;p&gt;That test should start a disposable MySQL instance, initialize the schema,&lt;br&gt;
write each logical key twice, reconnect, and assert:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;exactly one row per key;&lt;/li&gt;
&lt;li&gt;the second hash, status, remote ID, and URL are returned;&lt;/li&gt;
&lt;li&gt;the platform state has the same JavaScript shape as SQLite; and&lt;/li&gt;
&lt;li&gt;incomplete configuration fails before a connection attempt.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;p&gt;SQLite keeps local development delightfully small, but the database file and&lt;br&gt;
its WAL-related files are persistent state and should stay on one host. It is&lt;br&gt;
not a shortcut to a shared network database.&lt;/p&gt;

&lt;p&gt;MySQL supports a service-oriented CI topology and native JSON, but adds&lt;br&gt;
credentials, lifecycle management, migrations, and a real network failure&lt;br&gt;
surface. It also makes driver behavior part of the persistence contract.&lt;/p&gt;

&lt;p&gt;There are subtler parity differences too:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SQLite writes ISO-8601 timestamp text from the Node.js process; MySQL writes
&lt;code&gt;DATETIME(3)&lt;/code&gt; using &lt;code&gt;UTC_TIMESTAMP(3)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;SQLite's text columns do not impose the same length limits as MySQL's
&lt;code&gt;VARCHAR&lt;/code&gt; keys.&lt;/li&gt;
&lt;li&gt;Matching method signatures do not guarantee matching transaction,
contention, collation, or error semantics.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The useful abstraction is therefore a promise backed by contract tests, not a&lt;br&gt;
claim that both databases are identical.&lt;/p&gt;
&lt;h2&gt;
  
  
  How I verified it
&lt;/h2&gt;

&lt;p&gt;The local experiment produced:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"journalMode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"wal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"publishCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"imageCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reopenedPublishHash"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"hash-v2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reopenedPublishStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"published"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reopenedPublishRemoteId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"99"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reopenedImageHash"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"image-v2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"defaultDatabaseDriver"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sqlite"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"defaultProviderName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SqliteDatabaseProvider"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"missingMysqlConfigRejected"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The focused SQLite Vitest test passed. I also ran the repository typecheck and&lt;br&gt;
full test suite after drafting the article and rendering its diagram.&lt;/p&gt;

&lt;p&gt;For MySQL, verification was deliberately narrower: I compared the provider&lt;br&gt;
code, official server documentation, and the installed driver's configuration&lt;br&gt;
and parser source. A live MySQL round trip remains the next required gate&lt;br&gt;
before calling the two implementations operationally equivalent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;A provider interface can keep publication logic clean, and SQLite can give&lt;br&gt;
local runs durable idempotency state with almost no setup. Moving the same&lt;br&gt;
contract to MySQL requires more than translating &lt;code&gt;ON CONFLICT&lt;/code&gt; into &lt;code&gt;ON&lt;br&gt;
DUPLICATE KEY UPDATE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Test the observable contract across both adapters: replacement, reopen,&lt;br&gt;
decoded value shapes, and failure behavior. The JSON double-parse risk here is&lt;br&gt;
exactly the kind of boundary bug that strong TypeScript types can hide when a&lt;br&gt;
driver's runtime value disagrees with a handwritten row interface.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI assistance disclosure
&lt;/h2&gt;

&lt;p&gt;I used an AI coding assistant to trace the two provider implementations,&lt;br&gt;
prepare the isolated SQLite experiment, compare the installed driver's JSON&lt;br&gt;
path, and edit the draft. I reviewed the cited source lines, executed the&lt;br&gt;
reported commands, inspected the rendered diagram, and kept the unexecuted&lt;br&gt;
MySQL path explicitly labeled as a limitation.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Designing a No-Network Dry Run for an Image-Aware Publisher</title>
      <dc:creator>xiaoxu</dc:creator>
      <pubDate>Wed, 22 Jul 2026 05:37:26 +0000</pubDate>
      <link>https://dev.to/_3d0d77143d405f723e74f6/designing-a-no-network-dry-run-for-an-image-aware-publisher-4fml</link>
      <guid>https://dev.to/_3d0d77143d405f723e74f6/designing-a-no-network-dry-run-for-an-image-aware-publisher-4fml</guid>
      <description>&lt;h1&gt;
  
  
  Designing a No-Network Dry Run for an Image-Aware Publisher
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;A useful dry run should do more than print “would publish.” It should parse the real article, process the real local images, build the real platform payload, and expose failures before credentials can create a remote artifact.&lt;/p&gt;

&lt;p&gt;That requirement gets harder when a Markdown publisher owns images as well as text. The normal path may optimize files, query persistent upload records, contact object storage, replace Markdown URLs, and finally call a publishing API. Simply skipping the last HTTP request leaves several earlier network writes intact.&lt;/p&gt;

&lt;p&gt;The focused question is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How can an image-aware publisher exercise its real transformation path while making storage and platform writes impossible?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I traced and tested a TypeScript publisher that answers with dependency substitution plus an early return: use an in-memory database, use a preview storage provider, process the article normally, then stop before persistent publication state and platform clients are touched.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built or tested
&lt;/h2&gt;

&lt;p&gt;I ran the publisher against a generated SVG fixture with all of these flags together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run publish &lt;span class="nt"&gt;--&lt;/span&gt; fixture/index.md &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--public&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--platform&lt;/span&gt; devto &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dry-run&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output-markdown&lt;/span&gt; fixture/preview.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using &lt;code&gt;--public&lt;/code&gt; was deliberate. It exercised construction of a public payload while &lt;code&gt;--dry-run&lt;/code&gt; remained the higher-level safety boundary.&lt;/p&gt;

&lt;p&gt;The observed result was:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Reported mode&lt;/td&gt;
&lt;td&gt;&lt;code&gt;public&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Platform results&lt;/td&gt;
&lt;td&gt;Empty&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image URL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://preview.invalid/...&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistent SQLite checksum&lt;/td&gt;
&lt;td&gt;Unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local processed-image cache&lt;/td&gt;
&lt;td&gt;Changed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row is important. This implementation provides a no-network dry run, not a zero-side-effect sandbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;The experiment used Node.js 22, the repository's TypeScript CLI, a small local SVG, and an existing SQLite publication database whose checksum was recorded before and after the run. It used no production images and no remote requests.&lt;/p&gt;

&lt;p&gt;The real DEV/Forem create operation is &lt;code&gt;POST /api/articles&lt;/code&gt;; the official API contract uses the &lt;code&gt;published&lt;/code&gt; field to choose draft or immediate publication. The current contract is documented in the &lt;a href="https://developers.forem.com/api/v1" rel="noopener noreferrer"&gt;Forem v1 API reference&lt;/a&gt;. A trustworthy dry run must stop before this operation regardless of whether the requested payload says &lt;code&gt;published: true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The architecture already exposes two interfaces that make this possible:&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;DatabaseProvider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;findPublishRecord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;PublishRecord&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;savePublishRecord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PublishRecord&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;findImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&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;objectKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ImageRecord&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;saveImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ImageRecord&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;StorageProvider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;objectKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UploadInput&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&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="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;publicUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;objectKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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 dry-run path can therefore replace behaviors at the provider boundary instead of sprinkling &lt;code&gt;if (dryRun)&lt;/code&gt; around every network call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step-by-step walkthrough
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Choose safe dependencies before processing
&lt;/h3&gt;

&lt;p&gt;Immediately after parsing the article, the publisher selects its database and storage implementations:&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;database&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dryRun&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MemoryDatabaseProvider&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;createDatabaseProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;storage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dryRun&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;createPreviewStorage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;createStorageProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The in-memory provider implements the same publication and image-record operations as SQLite or MySQL, but its maps disappear when the process ends. Preview storage implements the same object methods as ImageKit or R2 without importing a network client.&lt;/p&gt;

&lt;p&gt;Choosing these dependencies at the top is the critical safety property. Every later image operation receives the preview providers through the normal call chain.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Process local assets through the real pipeline
&lt;/h3&gt;

&lt;p&gt;Dry run does not bypass article parsing or image replacement. The publisher still resolves local paths, optimizes images, computes content hashes, builds object keys, and replaces Markdown image nodes.&lt;/p&gt;

&lt;p&gt;Preview storage intentionally reports that objects do not already exist. Its &lt;code&gt;upload&lt;/code&gt; method returns the same URL shape its &lt;code&gt;publicUrl&lt;/code&gt; method would produce:&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;publicUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;objectKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`https://preview.invalid/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodePath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;objectKey&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;objectKey&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;objectKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}):&lt;/span&gt; &lt;span class="nb"&gt;Promise&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="o"&gt;&amp;gt;&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publicUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;objectKey&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 reserved &lt;code&gt;.invalid&lt;/code&gt; top-level domain makes the artifact visibly non-production. The preview Markdown shows the transformed path without creating an address that could be mistaken for a successful upload.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Build the actual publication input
&lt;/h3&gt;

&lt;p&gt;After image replacement, the publisher constructs the same &lt;code&gt;PublishInput&lt;/code&gt; used by a real run: title, rendered Markdown, description, tags, canonical URL, cover URL, and the requested published state.&lt;/p&gt;

&lt;p&gt;This is why &lt;code&gt;--public --dry-run&lt;/code&gt; is a useful test. It proves that public-mode payload construction works while still preventing a platform call.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Return before persistent publication and platform work
&lt;/h3&gt;

&lt;p&gt;The decisive guard appears after payload construction and before persistent state lookup:&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dryRun&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prepareOnly&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only code after this point loads an existing publication record, calculates platform actions, constructs the DEV.to publisher, and sends a create or update request.&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%2F9bw5c3rthy286fqqnx3f.webp" 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%2F9bw5c3rthy286fqqnx3f.webp" alt="Mermaid diagram 1" width="784" height="771"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The diagram highlights both safety layers: external providers are replaced before transformation, and the platform path is cut off after the payload exists.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Write an inspectable artifact
&lt;/h3&gt;

&lt;p&gt;The CLI can write the transformed Markdown to a requested output path. That artifact includes preview image URLs and preserves the article metadata, so a reviewer can inspect exactly what would be passed downstream.&lt;/p&gt;

&lt;p&gt;This is a local write, but it is intentional and named by the caller. It is different from a remote publication or durable publication-state change.&lt;/p&gt;

&lt;h2&gt;
  
  
  What went wrong
&lt;/h2&gt;

&lt;p&gt;My initial mental model was “dry run means no writes.” The experiment disproved that wording.&lt;/p&gt;

&lt;p&gt;The persistent SQLite checksum stayed unchanged, and no platform result appeared, but the processed-image cache changed. The reason is visible in control flow: the publisher uses a fixed local cache directory, and image optimization happens before the dry-run early return. Preview storage prevents a remote upload; it does not prevent Sharp or file-copy output.&lt;/p&gt;

&lt;p&gt;This distinction matters in CI and agent workflows. A supposedly clean preview can leave an untracked cache object, affect later file counts, or make a workspace appear dirty even though no network write occurred.&lt;/p&gt;

&lt;p&gt;There are also boundaries the dry run cannot verify:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;whether the API key is accepted by DEV.to;&lt;/li&gt;
&lt;li&gt;whether ImageKit or R2 permissions are valid remotely;&lt;/li&gt;
&lt;li&gt;how the published page renders on the platform;&lt;/li&gt;
&lt;li&gt;rate limits, moderation, or remote validation behavior;&lt;/li&gt;
&lt;li&gt;whether a real create returns a stable public URL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The repository currently lacks a focused unit test that calls the publication function with &lt;code&gt;dryRun: true&lt;/code&gt;. Source tracing and the CLI experiment establish the current behavior, but a regression test would make the no-network boundary durable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix or mitigation
&lt;/h2&gt;

&lt;p&gt;The immediate fix is precise language: promise &lt;strong&gt;no storage-provider or publishing-platform network writes&lt;/strong&gt;, not “no side effects.” Then make local mutations explicit.&lt;/p&gt;

&lt;p&gt;A reusable dry-run checklist is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Select fake or in-memory external dependencies before business logic begins.&lt;/li&gt;
&lt;li&gt;Exercise the same parser, validator, image transformer, and payload builder as production.&lt;/li&gt;
&lt;li&gt;Use an unmistakably non-production URL domain for preview assets.&lt;/li&gt;
&lt;li&gt;Return before persistent publication lookup and every platform client.&lt;/li&gt;
&lt;li&gt;Write preview artifacts only to caller-selected paths.&lt;/li&gt;
&lt;li&gt;Record or isolate any local cache directory used during transformation.&lt;/li&gt;
&lt;li&gt;Test dry run with the most dangerous requested mode, such as &lt;code&gt;--public&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Assert that persistent state is unchanged and platform results are empty.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a truly clean workspace, inject the cache directory too. A dry run could use a temporary directory that is removed afterward, while real preparation retains the durable cache. That change would preserve transformation fidelity without leaving new files in the repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;p&gt;Dependency substitution adds implementations that must remain behaviorally compatible with production providers. Preview storage can model URL construction and image replacement, but it cannot reproduce authentication failures, latency, provider-specific validation, or eventual consistency.&lt;/p&gt;

&lt;p&gt;Running real image processing improves confidence but costs CPU and creates temporary or cached files. Skipping image work would be faster and cleaner, but it would miss broken paths, unsupported formats, optimizer failures, and incorrect Markdown replacement—the failures a useful preview is meant to catch.&lt;/p&gt;

&lt;p&gt;An in-memory database avoids persistent state changes, but it also starts empty on every run. It cannot reveal a conflict with an existing remote ID or an &lt;code&gt;unknown&lt;/code&gt; publication state. Reconciliation and update behavior need separate state-machine tests.&lt;/p&gt;

&lt;p&gt;The design therefore optimizes for payload fidelity and network safety, not complete production simulation.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I verified it
&lt;/h2&gt;

&lt;p&gt;I recorded the SQLite file checksum before and after a public-mode dry run; it was unchanged. The CLI reported &lt;code&gt;dryRun: true&lt;/code&gt;, returned no platform result, and wrote one &lt;code&gt;preview.invalid&lt;/code&gt; image reference into the preview artifact.&lt;/p&gt;

&lt;p&gt;I also inspected the local cache and found the generated SVG copy, confirming the local-side-effect limitation rather than hiding it. Source tracing tied each observation to the provider selection, image-processing sequence, and early return.&lt;/p&gt;

&lt;p&gt;Finally, I checked the official Forem v1 API reference for the actual article-create boundary. The repository's DEV.to client sends that create only after the dry-run return point.&lt;/p&gt;

&lt;p&gt;Before publication, the TypeScript check passed and the full Vitest suite passed all 12 tests across seven test files. The article validator also rendered and previewed the Mermaid diagram without a network write.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;A trustworthy dry run is not a parallel toy implementation. It is the real transformation pipeline with dangerous dependencies replaced and a hard stop before irreversible work.&lt;/p&gt;

&lt;p&gt;For an image-aware publisher, that means using in-memory state, producing clearly fake asset URLs, processing local files normally, constructing the requested payload, and returning before persistent publication state or platform APIs.&lt;/p&gt;

&lt;p&gt;Just name the guarantee honestly. This design is no-network, not no-filesystem. Once that boundary is explicit, it becomes straightforward to test, document, and tighten with a temporary cache when a completely clean preview is required.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI assistance disclosure
&lt;/h2&gt;

&lt;p&gt;AI assisted with outlining and drafting. Technical claims were checked against repository source, a recorded local experiment, and the official Forem API reference. No credentials, private paths, or production data are included.&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
