<?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: Roman</title>
    <description>The latest articles on DEV Community by Roman (@r0men_).</description>
    <link>https://dev.to/r0men_</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%2F3938619%2F52fc1df1-199d-459d-9573-c3a9cf9b178c.jpg</url>
      <title>DEV Community: Roman</title>
      <link>https://dev.to/r0men_</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/r0men_"/>
    <language>en</language>
    <item>
      <title>What the new Office.js CDN endpoint actually changes</title>
      <dc:creator>Roman</dc:creator>
      <pubDate>Tue, 20 Oct 2026 09:27:54 +0000</pubDate>
      <link>https://dev.to/r0men_/what-the-new-officejs-cdn-endpoint-actually-changes-dm9</link>
      <guid>https://dev.to/r0men_/what-the-new-officejs-cdn-endpoint-actually-changes-dm9</guid>
      <description>&lt;p&gt;I run silentship, an independent monitor for changes on the Office.js CDN. This is a copy of the original note.&lt;/p&gt;

&lt;p&gt;Since late September, Microsoft's docs list &lt;code&gt;appsforoffice.microsoft.com&lt;/code&gt; as a &lt;strong&gt;legacy&lt;/strong&gt; endpoint and recommend a new one:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Legacy&lt;/th&gt;
&lt;th&gt;New&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Release&lt;/td&gt;
&lt;td&gt;&lt;code&gt;appsforoffice.microsoft.com/lib/1/hosted/office.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;officeapis.public.onecdn.static.microsoft/1/office.js&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Preview&lt;/td&gt;
&lt;td&gt;&lt;code&gt;appsforoffice.microsoft.com/lib/beta/hosted/office.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;officeapis.public.onecdn.static.microsoft/beta/office.js&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The docs say the new endpoint "serves the same current Office.js release, but it enables additional secure defaults", without saying which defaults. So we compared the two directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Short answer
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Same build.&lt;/strong&gt; Both endpoints serve 16.0.20606.15070.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Host files are byte-identical.&lt;/strong&gt; &lt;code&gt;excel-win32-16.01.js&lt;/code&gt;, &lt;code&gt;word-win32-16.01.js&lt;/code&gt;, &lt;code&gt;outlook-win32-16.02.js&lt;/code&gt;, &lt;code&gt;powerpoint-web-16.00.js&lt;/code&gt; and the rest have the same SHA-256 on both. Every Office API your add-in calls comes from the same code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only &lt;code&gt;office.js&lt;/code&gt; (the loader) differs&lt;/strong&gt;, and only by two lines at the top:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;isOsfSecurityDefaultsEnabled&lt;/span&gt; &lt;span class="o"&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;osfLoadMsAjax&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything else in the loader is identical. Both endpoints also sit behind the same Akamai + Azure Front Door setup, with the same caching headers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the two flags do
&lt;/h2&gt;

&lt;p&gt;Reading the loader, the logic is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;If the &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag that loads &lt;code&gt;office.js&lt;/code&gt; has a &lt;code&gt;data-enable-trusted-types&lt;/code&gt; attribute, that attribute decides.&lt;/li&gt;
&lt;li&gt;Otherwise, &lt;code&gt;isOsfSecurityDefaultsEnabled&lt;/code&gt; decides.&lt;/li&gt;
&lt;li&gt;If the result is "on", the loader &lt;strong&gt;does not load MicrosoftAjax&lt;/strong&gt; and &lt;strong&gt;creates a Trusted Types policy named &lt;code&gt;officejs&lt;/code&gt;&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On the legacy endpoint neither flag is set, so neither happens unless you opt in. On the new endpoint both happen by default.&lt;/p&gt;

&lt;p&gt;Another detail: the loader decides where to fetch host files from based on its own &lt;code&gt;src&lt;/code&gt;. An add-in that loads &lt;code&gt;office.js&lt;/code&gt; from the new endpoint gets &lt;em&gt;all&lt;/em&gt; of its Office.js files from there, not a mix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who can break when switching
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Add-ins that use MicrosoftAjax without knowing it.&lt;/strong&gt; The legacy loader quietly pulls in &lt;code&gt;MicrosoftAjax.js&lt;/code&gt;, so its globals have always been there. Code (yours or a dependency's) that touches &lt;code&gt;Sys.*&lt;/code&gt;, &lt;code&gt;Type.registerNamespace&lt;/code&gt;, &lt;code&gt;Function.createDelegate&lt;/code&gt; or &lt;code&gt;$create&lt;/code&gt; works today and throws &lt;code&gt;ReferenceError&lt;/code&gt; on the new endpoint. Search your bundle, not just your source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rnE&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="s2"&gt;Sys&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;|Type&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;registerNamespace|Function&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;createDelegate|&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;create&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; dist/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pages with a strict CSP or Trusted Types.&lt;/strong&gt; With &lt;code&gt;require-trusted-types-for 'script'&lt;/code&gt;, the &lt;code&gt;officejs&lt;/code&gt; policy has to be allowed (&lt;code&gt;trusted-types officejs …&lt;/code&gt;), and &lt;code&gt;*.static.microsoft&lt;/code&gt; has to be allowed in &lt;code&gt;script-src&lt;/code&gt; and in any corporate network allow list.&lt;/p&gt;

&lt;p&gt;Microsoft's migration checklist also asks you to register every &lt;code&gt;ExecuteFunction&lt;/code&gt; command handler with &lt;code&gt;Office.actions.associate(...)&lt;/code&gt;. The loader diff contains nothing beyond the two flags, so that requirement comes from code paths that behave differently once the flags are on, not from a separate switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trying the new defaults without changing the URL
&lt;/h2&gt;

&lt;p&gt;Because the decision starts with the &lt;code&gt;data-enable-trusted-types&lt;/code&gt; attribute, you should be able to get the new endpoint's behaviour on the legacy URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://appsforoffice.microsoft.com/lib/1/hosted/office.js"&lt;/span&gt;
        &lt;span class="na"&gt;data-enable-trusted-types=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We read this from the loader and haven't tested it in every host. Treat it as a quick way to find MicrosoftAjax dependencies in a test build, not as a production setting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The risk worth watching
&lt;/h2&gt;

&lt;p&gt;Today the only difference between the endpoints is two flags in one file. Nothing stops the same two lines from appearing in the &lt;strong&gt;legacy&lt;/strong&gt; loader one day. Microsoft ships loader updates the way it ships everything else on this CDN: without a changelog, edge by edge. If that happens, add-ins that still depend on MicrosoftAjax will start failing on a growing share of loads over a day or so, without a single line of their own code changing.&lt;/p&gt;

&lt;p&gt;silentship now watches both endpoints. A change to either flag on any endpoint is treated as &lt;strong&gt;High&lt;/strong&gt;, and subscribers are alerted as soon as it's confirmed on two runs. You can see both endpoints side by side on every &lt;a href="///rollouts/prod/16.0.20606.15070"&gt;rollout page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Alerts: &lt;a href="https://silentship.dev/?utm_source=devto" rel="noopener noreferrer"&gt;silentship.dev&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Checked on 19 Oct 2026 against build 16.0.20606.15070. Microsoft's page: &lt;a href="https://learn.microsoft.com/en-us/office/dev/add-ins/develop/referencing-the-javascript-api-for-office-library-from-its-cdn" rel="noopener noreferrer"&gt;Referencing the Office JavaScript API library from its CDN&lt;/a&gt;. Thanks to the commenter on &lt;a href="https://github.com/OfficeDev/office-js/issues/6513" rel="noopener noreferrer"&gt;office-js#6513&lt;/a&gt; who pointed out the new endpoint.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>office</category>
      <category>javascript</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How finddax.com turns four spellings of one search into a single cache hit (Node + MongoDB)</title>
      <dc:creator>Roman</dc:creator>
      <pubDate>Mon, 19 Oct 2026 14:19:54 +0000</pubDate>
      <link>https://dev.to/r0men_/how-finddaxcom-turns-four-spellings-of-one-search-into-a-single-cache-hit-node-mongodb-9f0</link>
      <guid>https://dev.to/r0men_/how-finddaxcom-turns-four-spellings-of-one-search-into-a-single-cache-hit-node-mongodb-9f0</guid>
      <description>&lt;p&gt;If you cache search results, your cache key is probably the search query. That works right up until real people start typing.&lt;/p&gt;

&lt;p&gt;I'm building &lt;a href="https://finddax.com" rel="noopener noreferrer"&gt;Finddax&lt;/a&gt;. It's a tool for freelancers and small agencies who sell websites to local businesses. You type a city and a category, say "plumbers in Chicago", and &lt;a href="https://finddax.com" rel="noopener noreferrer"&gt;finddax.com&lt;/a&gt; returns businesses on Google Maps that have good ratings and reviews but &lt;strong&gt;no website&lt;/strong&gt;. One click then generates a working site for that business, plus a short pitch to send the owner. So you're not cold-calling with "do you want a website?", you're sending "here's your website, take a look."&lt;/p&gt;

&lt;p&gt;Every search on Finddax goes through a shared cache. If two users look for "barbershop New York" in the same week, only the first one triggers a Google Maps lookup. The second one is served from MongoDB in milliseconds. That cache is the reason the free tier can exist at all.&lt;/p&gt;

&lt;p&gt;The problem is obvious once you think about real input:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Junk removal&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;junk removal&lt;/code&gt; (trailing space)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;junk  removal&lt;/code&gt; (two spaces)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;junk remooooval&lt;/code&gt; (somebody held a key)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same search, same results. Use the raw string as the cache key and that's four keys and three misses you didn't need.&lt;/p&gt;

&lt;p&gt;Here's what I ended up with in the Finddax backend, step by step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: the boring normalization
&lt;/h2&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;REPEATED_CHARS&lt;/span&gt; &lt;span class="o"&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;)\1{2,}&lt;/span&gt;&lt;span class="sr"&gt;/gu&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;baseNormalize&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="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="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;\s&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="s1"&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="nx"&gt;REPEATED_CHARS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$1&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 first four steps are obvious. The last one is the interesting one: it collapses &lt;strong&gt;three or more&lt;/strong&gt; identical characters into one.&lt;/p&gt;

&lt;p&gt;Why three and not two? Because doubled letters are everywhere in real words: co*&lt;em&gt;ff&lt;/em&gt;&lt;em&gt;ee, pi&lt;/em&gt;&lt;em&gt;zz&lt;/em&gt;&lt;em&gt;a, gra&lt;/em&gt;&lt;em&gt;ss&lt;/em&gt;&lt;em&gt;, ba&lt;/em&gt;&lt;em&gt;ll&lt;/em&gt;*room. Stretched typos look like &lt;code&gt;remooooval&lt;/code&gt; or &lt;code&gt;coffeeee&lt;/code&gt;. No real word has the same letter three times in a row. So &lt;code&gt;{2,}&lt;/code&gt; after the capture group fixes the stretched typos and never touches a correct spelling.&lt;/p&gt;

&lt;p&gt;It's a one-character decision, and it's the line I'd most want a reviewer to look at.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Levenshtein, with a threshold that scales
&lt;/h2&gt;

&lt;p&gt;Normalization handles whitespace and stretched letters. It doesn't handle &lt;code&gt;junk remval&lt;/code&gt;, which has a letter missing. For that, Finddax compares the input against existing canonical terms by edit distance:&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;clusterThreshold&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;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.25&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's roughly one allowed edit per four characters, clamped to 1–3:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term length&lt;/th&gt;
&lt;th&gt;Allowed edits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1–7&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8–11&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12+&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The low clamp matters: without it, anything shorter than 4 characters would get a threshold of 0 and never cluster. The high clamp stops long queries from swallowing genuinely different searches just because they share most of their letters.&lt;/p&gt;

&lt;p&gt;The two steps work together. Take &lt;code&gt;coffeeee&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Normalization collapses &lt;code&gt;eeee&lt;/code&gt; into &lt;code&gt;e&lt;/code&gt; and gives &lt;code&gt;coffe&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;coffe&lt;/code&gt; is distance 1 from &lt;code&gt;coffee&lt;/code&gt;, and the threshold for 5 characters is 1.&lt;/li&gt;
&lt;li&gt;Same cluster, same cache bucket.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And &lt;code&gt;junk remooooval&lt;/code&gt;: normalization turns it into &lt;code&gt;junk removal&lt;/code&gt;, an exact match, no Levenshtein needed. All four spellings from the list above resolve to the same set of &lt;code&gt;placeId&lt;/code&gt;s.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: don't run Levenshtein against the whole table
&lt;/h2&gt;

&lt;p&gt;The naive version loads every term and runs the distance function against each one, on every search. That's fine with 50 terms and silly with 50,000.&lt;/p&gt;

&lt;p&gt;The trick: &lt;strong&gt;edit distance is never smaller than the difference in lengths.&lt;/strong&gt; If the input has 12 characters and the threshold is 3, a 20-character term can't possibly match. So that filter goes into Mongo:&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;candidates&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;SearchTerm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;mergedInto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;$expr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;$lte&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="na"&gt;$abs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$subtract&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;$strLenCP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$canonical&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;normalized&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="p"&gt;},&lt;/span&gt;
      &lt;span class="nx"&gt;threshold&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="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canonical&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;lean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;$strLenCP&lt;/code&gt; (code points), not &lt;code&gt;$strLenBytes&lt;/code&gt;. Search terms can contain &lt;code&gt;café&lt;/code&gt;, &lt;code&gt;Zürich&lt;/code&gt; or &lt;code&gt;Kraków&lt;/code&gt;, and the UI also ships in Ukrainian, where every letter is 2 bytes in UTF-8. &lt;code&gt;$strLenBytes&lt;/code&gt; would make those terms look longer than they are and quietly drop valid candidates. JavaScript's &lt;code&gt;.length&lt;/code&gt; isn't strictly code points either, but for the BMP characters people type into a search box it agrees with &lt;code&gt;$strLenCP&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The result is identical to the naive version. I just load a small slice of the registry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: the synonyms Levenshtein can't see
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;junk removal&lt;/code&gt; and &lt;code&gt;trash hauling&lt;/code&gt; are the same business. &lt;code&gt;cleaning service&lt;/code&gt; and &lt;code&gt;maid service&lt;/code&gt; mostly are too. The edit distance between them is basically the whole string, so no string metric will catch that.&lt;/p&gt;

&lt;p&gt;So once a week a Finddax job sends the list of canonical terms to an LLM (DeepSeek) and asks which ones mean the same thing. It sends &lt;strong&gt;only the term strings&lt;/strong&gt;: no business data, no user data. The model returns pairs, and the job marks one term in each pair with &lt;code&gt;mergedInto: &amp;lt;other canonical&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;At lookup time the chain is followed to the end:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;followMergeChain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;term&lt;/span&gt;&lt;span class="p"&gt;)&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;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;term&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seen&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="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&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;mergedInto&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;seen&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;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mergedInto&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;seen&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;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mergedInto&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;next&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;SearchTerm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOne&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="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;:&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;mergedInto&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;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&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;next&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;current&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;seen&lt;/code&gt; set is there because an LLM will eventually return both A→B and B→A in the same week, and I'd rather not find out what an infinite loop in a request handler looks like in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that was waiting for exactly this
&lt;/h2&gt;

&lt;p&gt;The first version of the lookup did this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Find a near match among &lt;strong&gt;live&lt;/strong&gt; terms (&lt;code&gt;mergedInto: null&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;If there's none, &lt;code&gt;create()&lt;/code&gt; a new term.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now imagine someone types the exact canonical of a term that was &lt;strong&gt;merged&lt;/strong&gt; last week. Step 1 skips it, because it isn't live. Step 2 tries to create it and hits the unique index on &lt;code&gt;{type, canonical}&lt;/code&gt;, because the merged term still exists under that canonical. &lt;strong&gt;500.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fix was to check for an exact match first, merged or not, with one indexed lookup, before any clustering. Merged terms then resolve through the chain like everything else.&lt;/p&gt;

&lt;p&gt;New terms are also created with an upsert (&lt;code&gt;$setOnInsert&lt;/code&gt;) instead of &lt;code&gt;create()&lt;/code&gt;. Two users searching for a brand-new term in the same second is rare, but "rare" in a request handler means "eventually".&lt;/p&gt;

&lt;h2&gt;
  
  
  What this can and can't do
&lt;/h2&gt;

&lt;p&gt;A few things I know are wrong, or at least questionable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Short words are risky.&lt;/strong&gt; At 3 characters the threshold is 1, so &lt;code&gt;spa&lt;/code&gt; and &lt;code&gt;spar&lt;/code&gt; would land in the same cluster. Short terms probably need a threshold of 0 plus an explicit synonym list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The first typo wins.&lt;/strong&gt; Whoever searches a term first defines its canonical form. If the very first search is &lt;code&gt;barbeershop&lt;/code&gt; (only two e's, so normalization keeps it), that becomes the canonical, and the autocomplete label is the first raw variant. Electing the most frequent variant would fix it. I haven't done that yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terms have no language field.&lt;/strong&gt; Finddax ships in English and Ukrainian, and the term registry is shared. For autocomplete I filter by script: English users see Latin-script terms, Ukrainian users see Cyrillic ones. It works only because the two languages don't share an alphabet, and it would break the day I add Spanish next to English.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The part I didn't expect
&lt;/h2&gt;

&lt;p&gt;The same &lt;code&gt;SearchTerm&lt;/code&gt; table that normalizes cache keys became the autocomplete. Every search increments a &lt;code&gt;count&lt;/code&gt; atomically (&lt;code&gt;$inc&lt;/code&gt;, so concurrent searches don't lose updates), and suggestions are a prefix regex sorted by that count. Start typing in the search box on &lt;a href="https://finddax.com" rel="noopener noreferrer"&gt;finddax.com&lt;/a&gt; and the suggestions you see are just this table. I never designed a "popular searches" feature. It fell out of keeping cache keys consistent.&lt;/p&gt;

&lt;p&gt;The cache window is 7 days now (it started at 1). Businesses on maps change slowly, and a cache miss is the slowest and most expensive request in the app.&lt;/p&gt;

&lt;p&gt;The same cache also feeds the public &lt;a href="https://finddax.com/en/businesses-without-website" rel="noopener noreferrer"&gt;businesses without a website&lt;/a&gt; pages on Finddax. Once a month the backend scans a set of cities through the exact same cache-first path users hit, and a static page per city gets rebuilt from the results. Every user search makes those pages a little fresher for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The product:&lt;/strong&gt; &lt;a href="https://finddax.com" rel="noopener noreferrer"&gt;finddax.com&lt;/a&gt;. Sign in with Google and you get 15 free credits, no card. A search costs 1 credit and a generated website costs 10.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;City lists:&lt;/strong&gt; &lt;a href="https://finddax.com/en/businesses-without-website" rel="noopener noreferrer"&gt;finddax.com/en/businesses-without-website&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The technique:&lt;/strong&gt; one service of under 200 lines plus one Mongo &lt;code&gt;$expr&lt;/code&gt;. No Elasticsearch, no trigram index, no Redis.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you sell websites to local businesses, I'd really like to know whether Finddax finds leads you'd actually contact. And if you've run fuzzy cache keys at a larger scale: at what point did you give up on Levenshtein for trigrams or embeddings? Has anyone found a clean way to pick the canonical spelling other than "most frequent wins"?&lt;/p&gt;

</description>
      <category>node</category>
      <category>mongodb</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A 33-hour Office.js rollout that changed four bytes</title>
      <dc:creator>Roman</dc:creator>
      <pubDate>Sat, 17 Oct 2026 10:41:52 +0000</pubDate>
      <link>https://dev.to/r0men_/a-33-hour-officejs-rollout-that-changed-four-bytes-3001</link>
      <guid>https://dev.to/r0men_/a-33-hour-officejs-rollout-that-changed-four-bytes-3001</guid>
      <description>&lt;p&gt;If you build Office add-ins, you load Office.js from one fixed URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://appsforoffice.microsoft.com/lib/1/hosted/office.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no version in that URL, no changelog for what it serves, and no Subresource Integrity hash you could pin. Microsoft ships new code behind it whenever it decides to, and your add-in picks it up the next time Office loads it. In February, 233 developers discussed exactly this in an &lt;a href="https://github.com/OfficeDev/office-js/issues/6513" rel="noopener noreferrer"&gt;open letter on the office-js repo&lt;/a&gt;: add-ins breaking with no warning, no record of what changed, and no way to tell whether a bug is theirs or Microsoft's.&lt;/p&gt;

&lt;p&gt;I wanted to see what one of those silent updates actually looks like from the outside. So I built a small monitor, &lt;a href="https://silentship.dev" rel="noopener noreferrer"&gt;silentship&lt;/a&gt;, and caught a full rollout from (almost) start to finish.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the monitor works
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The loader tells you which files exist.&lt;/strong&gt; &lt;code&gt;office.js&lt;/code&gt; is only a bootstrapper. It contains a map of every host and platform (&lt;code&gt;excel-win32-16.01.js&lt;/code&gt;, &lt;code&gt;outlook-web-16.01.js&lt;/code&gt;, …), so the crawler builds its file list from the loader itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every file carries its own build stamp.&lt;/strong&gt; There isn't one "Office.js version". PowerPoint and OneNote files have a &lt;code&gt;// host: 16.0\16.0.x&lt;/code&gt; header, Excel and Word embed &lt;code&gt;hostJSVersion&lt;/code&gt;, and Outlook lives on a separate build line entirely (&lt;code&gt;osfweb version: 16.0.20410.x&lt;/code&gt; when I looked).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sampling, not polling.&lt;/strong&gt; Every 10 minutes each file is requested 8 times, each on a fresh TLS connection, so the CDN spreads the requests across different edge servers. Requests are conditional on the stable version's &lt;code&gt;Last-Modified&lt;/code&gt;: edges still serving the old file answer &lt;code&gt;304&lt;/code&gt; with no body, so only a new version costs bandwidth. The share of samples that return the new build is the "rollout %".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API diff by reflection.&lt;/strong&gt; New versions are executed in a locked-down sandbox (separate process, empty environment, Node permission model) and the public API surface (&lt;code&gt;Office.*&lt;/code&gt;, &lt;code&gt;Excel.*&lt;/code&gt;, &lt;code&gt;Word.*&lt;/code&gt;, …) is walked and diffed against the previous build. API status (GA, preview, undocumented) comes from Microsoft's own &lt;code&gt;@types/office-js&lt;/code&gt; and &lt;code&gt;@types/office-js-preview&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No Microsoft code is published.&lt;/strong&gt; The site shows metadata and API-surface diffs only.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The rollout: 16.0.20528 → 16.0.20606
&lt;/h2&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%2Fb5fd84gaend31wno47ij.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb5fd84gaend31wno47ij.png" alt="Share of sampled CDN edges serving 16.0.20606" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Production&lt;/th&gt;
&lt;th&gt;Beta&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;First seen&lt;/td&gt;
&lt;td&gt;Oct 14, 20:06 UTC&lt;/td&gt;
&lt;td&gt;Oct 14, 20:07 UTC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Complete (100% for 3 runs)&lt;/td&gt;
&lt;td&gt;Oct 16, 04:50 UTC&lt;/td&gt;
&lt;td&gt;Oct 16, 04:50 UTC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duration (observed)&lt;/td&gt;
&lt;td&gt;~33 h&lt;/td&gt;
&lt;td&gt;~33 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host files&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public API changes&lt;/td&gt;
&lt;td&gt;0 (only the build stamp changed)&lt;/td&gt;
&lt;td&gt;44, in 12 types&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few things surprised me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It was already moving when I first looked.&lt;/strong&gt; On the very first run some files were well past the start line: beta &lt;code&gt;excel-web&lt;/code&gt; was on 87.5% of samples, production &lt;code&gt;excel-winrt&lt;/code&gt; on 75%. So "20:06" is an upper bound for the start, not the start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hosts don't move together.&lt;/strong&gt; In production, &lt;code&gt;visio-web&lt;/code&gt; and &lt;code&gt;excel-winrt&lt;/code&gt; were complete within the first hour. &lt;code&gt;word-win32&lt;/code&gt; was the very last file in &lt;em&gt;both&lt;/em&gt; channels, about 32 hours later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There's a long plateau.&lt;/strong&gt; For roughly 20 hours the overall share sat between 40% and 60% and went up &lt;em&gt;and down&lt;/em&gt;: the same edge would serve the new build on one run and the old one on the next. Then, between midnight and 02:00 UTC on Oct 16, it jumped from ~55% to ~95% and finished a few hours later.&lt;/p&gt;

&lt;p&gt;In practice, that plateau is the part that hurts. For most of a day, two users of the same add-in, on the same host, could load different Office.js builds, depending only on which edge answered. If you ever had a bug report that you couldn't reproduce and that "fixed itself" the next day, this is one way that happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changed: four bytes (in production)
&lt;/h2&gt;

&lt;p&gt;The API diff for production came back empty, so I compared the files themselves. For 21 of the 22 production files, 20606 is byte-for-byte identical to 20528 except for the embedded build stamp. In &lt;code&gt;excel-win32-16.01.js&lt;/code&gt;, the whole 33-hour rollout changed exactly four bytes: &lt;code&gt;hostJSVersion="16.0.20528…"&lt;/code&gt; became &lt;code&gt;"16.0.20606…"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So in production this was a version bump, not a code change. Nothing about it could break your add-in. But for a day and a half your users were still loading two "different" builds of the same code, and from the outside you can't tell which kind of rollout you're in until you diff it. The next one may not be a no-op.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in the API (beta)
&lt;/h2&gt;

&lt;p&gt;Beta is where the real changes went: 44 changes across Excel and PowerPoint files, grouping into 12 types. Almost all of it is preview API, which Microsoft serves only from the beta CDN:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Removed (preview):&lt;/strong&gt; &lt;code&gt;Excel.SensitivityLabelAbacAttribute&lt;/code&gt;, &lt;code&gt;Excel.SensitivityLabelAbacAttributeValue&lt;/code&gt;, and their PowerPoint counterparts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Renamed (preview):&lt;/strong&gt; &lt;code&gt;SensitivityLabelUpdateResult&lt;/code&gt; → &lt;code&gt;SensitivityLabelUpdateStatus&lt;/code&gt; in both Excel and PowerPoint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Added (not in Microsoft's type definitions yet):&lt;/strong&gt; &lt;code&gt;Excel.AllowedUrls&lt;/code&gt;, &lt;code&gt;Excel.Copilot.allowedUrls&lt;/code&gt;, a &lt;code&gt;pivotTableEdit&lt;/code&gt; value on &lt;code&gt;Excel.UserActivityType&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enum value added:&lt;/strong&gt; &lt;code&gt;tabs&lt;/code&gt; on &lt;code&gt;Excel.EntityCardLayoutType&lt;/code&gt;, which Microsoft's types list as GA (ExcelApi 1.19). It isn't on the production CDN yet: production doesn't even have the &lt;code&gt;array&lt;/code&gt; value that beta already shipped.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this should break a production add-in; preview APIs come with an explicit "do not use in production" warning. But if you &lt;em&gt;were&lt;/em&gt; experimenting with sensitivity labels on the beta CDN, your code started throwing &lt;code&gt;TypeError: Excel.SensitivityLabelAbacAttribute is not a constructor&lt;/code&gt; sometime on Oct 14–15, gradually, on a growing share of loads. Each changed type has its own page with the exact error messages, e.g. &lt;a href="https://silentship.dev/reference/Excel.SensitivityLabelAbacAttribute" rel="noopener noreferrer"&gt;Excel.SensitivityLabelAbacAttribute&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this can and can't see
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Only the JavaScript layer.&lt;/strong&gt; Plenty of add-in breakage comes from the native hosts (Outlook classic vs. new Outlook, Excel on Windows vs. web), and that never touches the CDN. If your add-in broke and nothing shows up here, that's useful information too: it's probably not the CDN.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One vantage point.&lt;/strong&gt; All samples come from a European server. Other regions may see the same rollout at a different pace.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;8 samples per file&lt;/strong&gt; means a single run moves in 12.5% steps; the site smooths over the last hour.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Live status and every rollout: &lt;strong&gt;&lt;a href="https://silentship.dev" rel="noopener noreferrer"&gt;silentship.dev&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://silentship.dev/rss.xml" rel="noopener noreferrer"&gt;RSS feed&lt;/a&gt; of new builds&lt;/li&gt;
&lt;li&gt;Email alerts are optional, and only for builds that change the public API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'd love to hear from people who maintain add-ins in production: did this rollout line up with anything you saw? And what would make this actually useful to you: per-API watchlists, a webhook into your CI, something else?&lt;/p&gt;

</description>
      <category>office</category>
      <category>javascript</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
