<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>SharePoint Provisioning on Jeppe Spanggaard - Software Developer | .NET, Azure &amp; Microsoft 365</title><link>https://jeppe-spanggaard.dk/tags/provisioning/</link><description>Recent content in SharePoint Provisioning on Jeppe Spanggaard - Software Developer | .NET, Azure &amp; Microsoft 365</description><generator>Hugo</generator><language>en-US</language><lastBuildDate>Sun, 30 Aug 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jeppe-spanggaard.dk/tags/provisioning/index.xml" rel="self" type="application/rss+xml"/><item><title>Failed Durable Functions Runs: One KQL Alert, No Code</title><link>https://jeppe-spanggaard.dk/blogs/durable-functions-failed-orchestration-alert/</link><pubDate>Sun, 30 Aug 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/durable-functions-failed-orchestration-alert/</guid><description>Learn how to get notified about failed Durable Functions orchestrations with a KQL log search alert in Application Insights, instead of writing watchdog code.</description><content:encoded><![CDATA[<p>I&rsquo;m in the middle of a bulk provisioning run: a queue message per new customer, one Durable Functions orchestration per site, more than 10,000 sites to create. Three of them have failed.</p>
<p>I know that because I&rsquo;ve been opening the portal several times a day and looking. There&rsquo;s a customer deadline on this run, and if the failures turn out to be a pattern rather than three unlucky sites, I want to stop the whole thing before it creates 9,000 more. So I check. Manually. Like a night watchman without a bell.</p>
<p>Three failures out of ten thousand is a rounding error until it&rsquo;s the three sites belonging to the customer who calls.</p>
<h2 id="a-failed-orchestration-tells-nobody">A Failed Orchestration Tells Nobody</h2>
<p>Here&rsquo;s the thing that makes this harder than it sounds: nothing surfaces a failed orchestration on its own.</p>
<p>My orchestrator wraps the whole run so a failure gets written to the status list before it propagates:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">async</span> Task&lt;ProvisioningContext&gt; ExecuteProvisioning(Func&lt;Task&lt;ProvisioningContext&gt;&gt; action)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">try</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">await</span> action();
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">catch</span> (Exception ex)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        _logger.LogError(ex, <span style="color:#e6db74">&#34;Provisioning failed.&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Try to update status, but never let a status-update failure mask the original error.</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">try</span>
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">await</span> TryUpdateStatus(errorMessage: ex.Message);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">catch</span> (Exception statusEx)
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            _logger.LogWarning(statusEx, <span style="color:#e6db74">&#34;Failed to update status after provisioning error.&#34;</span>);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">throw</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>That bare <code>throw;</code> is the load-bearing line. Because the exception isn&rsquo;t swallowed, the instance ends in runtime status <code>Failed</code> instead of quietly finishing as <code>Completed</code> with half a site behind it. Good. But <code>Failed</code> is a row in a storage table and a red badge in a tool nobody has open at 21:00.</p>
<p>And getting there takes a while. Every activity call runs under a retry policy of 10 attempts with exponential backoff from 5 seconds up to 5 minutes, so an activity that can&rsquo;t succeed spends roughly 25 minutes failing before the orchestration is allowed to give up. The hub association step is the reigning champion: <code>JoinHubSite</code> can return 200 and silently do nothing on a freshly created site, so the activity reads the association back and throws a plain <code>InvalidOperationException</code> when it didn&rsquo;t stick. Plain, on purpose, because that one usually <em>is</em> transient. It gets all ten attempts before the orchestration dies. If you want the other half of that story, <a href="https://jeppe-spanggaard.dk/blogs/durable-functions-permanent-vs-transient-failures/">permanent versus transient failures</a> is about which exceptions deserve the full ten.</p>
<p>So the failure is real, it&rsquo;s slow, and it&rsquo;s silent. I needed the bell.</p>
<h2 id="three-ways-to-get-told-and-why-i-picked-the-boring-one">Three Ways to Get Told, and Why I Picked the Boring One</h2>
<p>I had three options on the table.</p>
<p><strong>Publish an Event Grid event when the orchestration fails.</strong> I already run Event Grid in this solution, so the plumbing exists. But it means code in the failure path, a new event type, a subscriber, and a deployment. And every time I want the notification to say something slightly different, that&rsquo;s another deployment.</p>
<p><strong>A timer-triggered watchdog.</strong> A function that wakes up every N minutes, queries the instance table for anything in <code>Failed</code>, and shouts. Also code, also a deployment, plus its own state to remember what it already shouted about. Now I have two things to monitor: the pipeline and the thing watching the pipeline.</p>
<p><strong>A log search alert in Application Insights.</strong> No code at all. The Durable Functions extension already writes lifecycle events for every orchestration, and I already have an action group wired up for this customer&rsquo;s other alerts. Write a KQL query, point an alert rule at it, done.</p>
<p>I picked the third one, and the deciding argument wasn&rsquo;t elegance. It was that I bill by the hour. The alert costs about $1.50 a month; writing and deploying either of the other two costs more than that in the first fifteen minutes, and then keeps costing every time it needs a tweak. The KQL, by contrast, I can widen tomorrow, from the portal, without touching the function app.</p>
<p>The two code options are better if you need the failure to <em>do</em> something automatically. Mine only needs to reach a human.</p>
<h2 id="durable-functions-already-logs-every-failure">Durable Functions Already Logs Every Failure</h2>
<p>The reason no code is needed: the Durable extension emits tracking events to Application Insights for every lifecycle transition, under the log category <code>Host.Triggers.DurableTask</code>. They land in the <code>traces</code> table, and everything useful is hiding in <code>customDimensions</code> behind a <code>prop__</code> prefix.</p>
<p>This is the query behind the alert:</p>
<pre tabindex="0"><code class="language-kusto" data-lang="kusto">traces
| where customDimensions.Category == &#34;Host.Triggers.DurableTask&#34;
| extend functionName = tostring(customDimensions[&#34;prop__functionName&#34;])
| extend functionType = tostring(customDimensions[&#34;prop__functionType&#34;])
| extend instanceId   = tostring(customDimensions[&#34;prop__instanceId&#34;])
| extend state        = tostring(customDimensions[&#34;prop__state&#34;])
| extend reason       = tostring(customDimensions[&#34;prop__reason&#34;])
| extend isReplay     = tobool(tolower(customDimensions[&#34;prop__isReplay&#34;]))
| where isReplay != true
| where functionType == &#34;Orchestrator&#34; and state == &#34;Failed&#34;
| project timestamp, functionName, instanceId, reason, appName = cloud_RoleName
</code></pre><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li>The category filter is the whole trick. <code>Host.Triggers.DurableTask</code> is the extension&rsquo;s own channel, separate from anything my code logs, so the query keeps working no matter how I change my own logging.</li>
<li>The <code>extend</code> block lifts the tracking fields out of the dynamic <code>customDimensions</code> bag and casts them to strings. Without <code>tostring</code>, comparisons against dynamic values get weird in ways that cost you an afternoon.</li>
<li><code>isReplay != true</code> drops replayed history. Orchestrators replay their execution constantly, so without this filter a single failure can produce the same tracking event more than once, and your alert email count stops meaning anything. I wrote it in defensively from the start rather than learning it the fun way.</li>
<li><code>functionType == &quot;Orchestrator&quot;</code> is the difference between &ldquo;a run failed&rdquo; and &ldquo;an attempt failed&rdquo;. Activities emit <code>Failed</code> events too, and with a 10-attempt retry policy a perfectly healthy orchestration produces a pile of them on the way to succeeding. Filter to the orchestrator and you get one row per genuinely dead run.</li>
<li><code>reason</code> carries the error details, so the alert email arrives with the instance ID and the actual message. That&rsquo;s the difference between &ldquo;go look&rdquo; and &ldquo;here&rsquo;s what broke&rdquo;.</li>
</ol>
<h2 id="the-alert-rule-setting-by-setting">The Alert Rule, Setting by Setting</h2>
<p>Nothing exotic, and that&rsquo;s the point:</p>
<ul>
<li><strong>Signal name:</strong> Custom log search</li>
<li><strong>Query type:</strong> Aggregated logs</li>
<li><strong>Measure:</strong> Table rows, <strong>Aggregation type:</strong> Count, <strong>Aggregation granularity:</strong> 5 minutes</li>
<li><strong>Threshold type:</strong> Static, <strong>Operator:</strong> Greater than, <strong>Threshold value:</strong> 0</li>
<li><strong>Frequency of evaluation:</strong> 5 minutes</li>
</ul>
<p>Static threshold, not dynamic. Dynamic thresholds learn a baseline and alert on deviation, which is the right tool for something with a natural rhythm. The natural rhythm of failed provisioning runs is zero. Any row is an incident, not an anomaly.</p>
<p>Threshold <code>0</code> on row count with a 5-minute window means &ldquo;tell me about the first one&rdquo;, which is exactly what I want during a bulk run. Three failures out of ten thousand is fine. Three failures in ten minutes means the hub is down and I should stop the queue.</p>
<p>The action group sends mail to me and to the customer&rsquo;s IT department. That second recipient changed how I write the alert description: it has to make sense to someone who has never seen a task hub. The description says what failed, where the instance ID can be looked up, and who does what next.</p>
<h2 id="why-would-the-alert-miss-a-failure-sampling">Why Would the Alert Miss a Failure? Sampling.</h2>
<p>Application Insights samples telemetry under load, and <code>traces</code> is sampled by default. A high-volume run producing thousands of lifecycle events per minute is exactly the situation where sampling starts dropping rows - including, possibly, the one you&rsquo;re alerting on.</p>
<p>This is also the answer to the more general complaint that Azure Functions invocation logs go missing sometimes, or that App Insights alerts miss exceptions that definitely happened. The telemetry isn&rsquo;t lost because something broke; it was sampled out, and sampling gets more aggressive precisely when things are busiest. Which is when you need it.</p>
<p>If your run is that noisy, look at <code>samplingSettings</code> in <code>host.json</code> before you trust the alert. Raising <code>maxTelemetryItemsPerSecond</code> or excluding types from sampling is a one-line change, and the Azure Functions monitoring docs spell out the trade-off. The uncomfortable part is that an alert built on sampled data fails silently: you don&rsquo;t get a wrong answer, you get no answer, which looks identical to nothing having gone wrong.</p>
<h2 id="gotchas">Gotchas</h2>
<ul>
<li><strong>Failed is not the only bad ending.</strong> An orchestration stuck in <code>Running</code> never emits a <code>Failed</code> event, so this alert says nothing about it. With retries nested inside HTTP-level retries, a single activity can hold a run open for a long time and look perfectly healthy. If that&rsquo;s a risk for you, a second alert on orchestration duration is the companion piece.</li>
<li><strong>Alert on the orchestrator, not the activity.</strong> I cannot stress this one enough. Drop the <code>functionType</code> filter and your retry policy becomes a mailing list. Every attempt under <code>CallActivityWithRetryAsync</code> (or <code>CallActivityAsync</code> with retry options) logs its own <code>Failed</code> event, so one healthy orchestration that recovers on attempt four has already emitted three of them.</li>
<li><strong><code>isReplay</code> is not optional.</strong> Replay is normal behaviour, not a fault, and the same lifecycle event can show up more than once without that filter.</li>
<li><strong>A threshold of zero only works if failures are rare.</strong> Mine are. If yours are routine, raise the threshold and treat the alert as a rate detector instead, or you&rsquo;ll train yourself to archive the mail unread.</li>
<li><strong>The alert is detection, not remediation.</strong> Getting the mail is step one. Knowing what to do with a <code>Failed</code> instance, and whether a rerun is safe, is a separate problem that <a href="https://jeppe-spanggaard.dk/blogs/durable-functions-idempotent-activities/">idempotent activities</a> exist to make answerable.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>A failed Durable Functions orchestration is loud in the storage table and silent everywhere a human looks. The Durable extension already logs every lifecycle transition to Application Insights, so the cheapest possible monitor is a KQL query over <code>traces</code> filtered to non-replay orchestrator events in state <code>Failed</code>, wired to an action group you already own. Rule of thumb: before you write a watchdog, check whether the thing you want to watch is already logging it. Mine was, and the difference between knowing and not knowing cost me $1.50 a month.</p>
<p>New to this corner of Azure? <a href="https://jeppe-spanggaard.dk/blogs/what-are-durable-functions/">Durable Functions: A Function That Sleeps for a Week</a> is where the rest of this series starts.</p>
]]></content:encoded></item><item><title>PnP Core vs PnP.Framework: Why I Haven't Switched Yet</title><link>https://jeppe-spanggaard.dk/blogs/pnp-core-vs-pnp-framework-migration-blockers/</link><pubDate>Mon, 27 Jul 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/pnp-core-vs-pnp-framework-migration-blockers/</guid><description>Learn why I'm still on PnP.Framework instead of PnP Core, the two blockers holding the migration back, and what has to land before I make the jump.</description><content:encoded><![CDATA[<p>I&rsquo;ve been building an internal shared library. The idea is boring and useful: collect the code I write over and over into one place, keep it as up to date as I can, and make it battle tested rather than &ldquo;worked on my tenant last Tuesday&rdquo;. Testing with <a href="https://learn.microsoft.com/en-us/microsoft-cloud/dev/dev-proxy/overview">Dev Proxy</a> instead of hope - I&rsquo;ve <a href="https://jeppe-spanggaard.dk/blogs/devproxy-throttling-testing/">simulated throttling with it</a> enough now to trust what it tells me.</p>
<p>That kind of library is exactly the moment to pick your foundation on purpose. So I sat down and looked hard at PnP Core.</p>
<p>And then I stayed on PnP.Framework.</p>
<h2 id="what-makes-pnp-core-tempting">What Makes PnP Core Tempting</h2>
<p>The thing I actually want from PnP Core isn&rsquo;t a feature. It&rsquo;s that somebody already made a decision I keep having to make myself.</p>
<p>With CSOM and raw calls, every time I read something out of SharePoint I have to think about <em>how</em>: SharePoint REST, or Microsoft Graph? Which one has this property? Which one is faster here? PnP Core removes that question. Per the docs, the SDK &ldquo;by default is configured to favor the Microsoft Graph API when you&rsquo;re reading SharePoint data assuming the requested properties are available via Graph&rdquo;, and it falls back to SharePoint REST when they aren&rsquo;t. If you disagree, you flip <code>GraphFirst</code> to false on the <code>PnPContext</code> and it prefers REST instead.</p>
<p>Someone with more context than me picked the better call per operation, and I get to write the intent instead of the plumbing. Add async-first, a real domain model, and modern .NET hosting, and it&rsquo;s not a close comparison on paper.</p>
<p>The problem is that my code doesn&rsquo;t run on paper.</p>
<h2 id="blocker-1-the-provisioning-engine-isnt-there">Blocker 1: The Provisioning Engine Isn&rsquo;t There</h2>
<p>Almost everything I build for SharePoint eventually provisions something. Lists, content types, pages, navigation. That&rsquo;s <code>ApplyProvisioningTemplate</code>, and it lives in PnP.Framework. PnP Core has no provisioning engine.</p>
<p>So a migration wasn&rsquo;t &ldquo;rewrite my queries&rdquo;. It was &ldquo;rewrite my queries and keep PnP.Framework around anyway for the part that does the heavy lifting&rdquo;. Two SDKs, two mental models, no win.</p>
<p>That&rsquo;s the part I&rsquo;d already made peace with, until <a href="https://github.com/pnp/pnpframework/issues/1237">pnpframework issue #1237</a> showed up in June: a roadmap for moving both the Provisioning Engine and the Modernization Engine into PnP Core as dedicated projects, with the PnP.Framework versions deprecated afterwards. Target is Q4 2026.</p>
<p>That single issue turns my biggest objection into a waiting game. I don&rsquo;t need PnP Core to grow a provisioning engine as a favor to me. It&rsquo;s on the roadmap, in the open, with a phase 2 already referenced for the wider PnP.Framework deprecation. Everything I&rsquo;ve written about <a href="https://jeppe-spanggaard.dk/blogs/pnp-template-sizing-resilience/">splitting templates into modular files for retry resilience</a> survives that move, because it&rsquo;s a property of how you structure templates, not of which SDK applies them.</p>
<h2 id="blocker-2-caml-joins-come-back-empty">Blocker 2: CAML Joins Come Back Empty</h2>
<p>The second one I couldn&rsquo;t wait out, because it&rsquo;s the query pattern I lean on hardest.</p>
<p>SharePoint&rsquo;s CAML supports joining lists and pulling fields across the join with <code>&lt;Joins&gt;</code> and <code>&lt;ProjectedFields&gt;</code>. It&rsquo;s the difference between one query and four, and I&rsquo;ve written a <a href="https://jeppe-spanggaard.dk/blogs/joining-multiple-lists-csom-caml/">whole post on doing it with CAMLEX in CSOM</a> because it saves resource units and spares you from merging rows in C#.</p>
<p>Run that same view XML through PnP Core&rsquo;s <code>LoadItemsByCamlQueryAsync</code> and nothing breaks. No exception, no error, no warning. You get items back, the join is honored for filtering, and the projected fields simply aren&rsquo;t in the result. The REST <code>GetItems</code> endpoint doesn&rsquo;t serialize them, so they never make it into the response for the SDK to map.</p>
<p>A silent hole in the data is worse than a thrown exception, and it&rsquo;s a hole in the one query shape I most wanted to keep.</p>
<p>So I filed <a href="https://github.com/pnp/pnpcore/issues/1799">issue #1799</a> and then <a href="https://github.com/pnp/pnpcore/pull/1802">PR #1802</a> with an implementation: two new methods on <code>IList</code> that run the query through CSOM&rsquo;s <code>List.GetItems(CamlQuery)</code> instead of REST, because CSOM does return projected fields.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">/// &lt;summary&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">/// Loads list items based up on a CAML query executed via CSOM, which also returns</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">/// fields projected from a joined list (CAML Joins/ProjectedFields)</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">/// &lt;/summary&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> Task&lt;ICamlQueryCsomResult&gt; LoadItemsByCamlQueryViaCsomAsync(CamlQueryOptions queryOptions);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> ICamlQueryCsomResult LoadItemsByCamlQueryViaCsom(CamlQueryOptions queryOptions);
</span></span></code></pre></div><p>Using it looks like any other PnP Core call. This is the shape from the integration test in the PR:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> page1 = <span style="color:#66d9ef">await</span> list.LoadItemsByCamlQueryViaCsomAsync(<span style="color:#66d9ef">new</span> CamlQueryOptions()
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    ViewXml = viewXml,
</span></span><span style="display:flex;"><span>    DatesInUtc = <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> firstItem = page1.Items[<span style="color:#ae81ff">0</span>];
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> projected = firstItem[<span style="color:#e6db74">&#34;ProjectedText&#34;</span>] <span style="color:#66d9ef">as</span> IFieldLookupValue;
</span></span><span style="display:flex;"><span>Console.WriteLine(projected.LookupValue);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Next page, without hand-building a paging string</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> page2 = <span style="color:#66d9ef">await</span> list.LoadItemsByCamlQueryViaCsomAsync(<span style="color:#66d9ef">new</span> CamlQueryOptions()
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    ViewXml = viewXml,
</span></span><span style="display:flex;"><span>    DatesInUtc = <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>    PagingInfo = page1.PagingInfo
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li><code>LoadItemsByCamlQueryViaCsomAsync</code> takes the exact same <code>CamlQueryOptions</code> as the REST-based method, so the view XML with <code>&lt;Joins&gt;</code> and <code>&lt;ProjectedFields&gt;</code> is unchanged. Only the transport differs.</li>
<li>The projected field comes back typed as an <code>IFieldLookupValue</code>, not as a raw string. That&rsquo;s how CSOM represents it and it&rsquo;s what you&rsquo;d expect from a lookup coming across a join, so <code>LookupId</code> and <code>LookupValue</code> are both there.</li>
<li>The returned <code>ICamlQueryCsomResult</code> carries <code>Items</code> plus <code>PagingInfo</code>, taken from CSOM&rsquo;s <code>ListItemCollectionPosition</code>. Feed <code>PagingInfo</code> back into the next call and you get the next page. It&rsquo;s null when there are no more pages.</li>
<li>The loaded items are also merged into the list&rsquo;s <code>Items</code> collection, the same as the existing CAML methods, so nothing about the surrounding model changes.</li>
</ol>
<h2 id="gotchas">Gotchas</h2>
<ul>
<li><strong>The failure mode is silence, not an error.</strong> No exception on a join query with projected fields means your code happily maps a null and moves on. If you&rsquo;re evaluating PnP Core for join-heavy work, assert on the projected value in a test, don&rsquo;t eyeball the item count.</li>
<li><strong>A roadmap is a plan, not a shipped release.</strong> Issue #1237 has a Q4 2026 target and it&rsquo;s still open. Same for my PR: open, no reviewer assigned yet. I&rsquo;m building on the current state of both SDKs, not on the version I hope exists in six months.</li>
<li><strong>No batch variant for the CSOM path, on purpose.</strong> The items are materialized immediately, so it doesn&rsquo;t fit PnP Core&rsquo;s batching model. If you&rsquo;re used to queueing everything into a batch, this one call stands apart.</li>
<li><strong>Deprecation warnings are part of the plan.</strong> Once the engines land in PnP Core, the PnP.Framework equivalents get marked deprecated and stop taking new features. So if you&rsquo;re searching for whether PnP.Framework is deprecated and whether the provisioning engine is moving to PnP Core: yes and yes, on a published roadmap, and the two blockers above are what stand between &ldquo;announced&rdquo; and &ldquo;usable&rdquo;. Staying put is a decision with an expiry date, which is exactly why I&rsquo;d rather have the blockers resolved than keep postponing.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>I&rsquo;m not avoiding PnP Core because I prefer PnP.Framework. I&rsquo;m on PnP.Framework because two specific things weren&rsquo;t there: the provisioning engine, and CAML joins that actually return the joined data. Both now have an issue number attached, and one of them has my name on the pull request.</p>
<p>Rule of thumb: pick the SDK by what your workload actually needs today, then go make the gap smaller instead of waiting for someone else to. Filing #1799 took an evening. It moved my migration date more than another month of reading release notes would have.</p>
<p>When the provisioning engine lands in PnP Core and projected fields come back from a join, I&rsquo;m switching and I&rsquo;m not looking back.</p>
]]></content:encoded></item><item><title>Provision Forward, Never Backward: Checkpointing Durable Functions</title><link>https://jeppe-spanggaard.dk/blogs/durable-functions-provisioning-checkpoints/</link><pubDate>Thu, 16 Jul 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/durable-functions-provisioning-checkpoints/</guid><description>Learn how to checkpoint a Durable Functions orchestration so a failed SharePoint provisioning run resumes where it stopped instead of starting over.</description><content:encoded><![CDATA[<p>I built a provisioning engine that creates a SharePoint team site for every new customer. Create the site, activate features, apply a template, seed a folder structure, add groups, register the site in an inventory list. Roughly ten steps, several minutes end to end, talking to SharePoint and Microsoft Graph the whole way.</p>
<p>The first time step seven failed, I got to watch my engine try to create a site that already existed.</p>
<p>That run taught me the rule this post is about: in a long provisioning flow, you don&rsquo;t roll back when something fails. You checkpoint, and you resume forward.</p>
<p>If Durable Functions are new to you, start with my intro to <a href="https://jeppe-spanggaard.dk/blogs/what-are-durable-functions/">what they are and what they can do</a> - this post builds on it.</p>
<h2 id="why-rollback-is-the-wrong-instinct">Why Rollback Is the Wrong Instinct</h2>
<p>When provisioning fails at step seven, you have a half-built site. The textbook answer is a compensation saga: undo steps six through one in reverse order. Delete the folders, detach the template, deactivate the features, delete the site, then recreate everything from scratch on the next attempt.</p>
<p>I started sketching that and stopped halfway through the list, because every line was either dangerous or absurd. Deleting a site collection to work around a failed navigation tweak is using a crane to hang a picture. And some steps don&rsquo;t even have an undo - you can&rsquo;t meaningfully &ldquo;unapply&rdquo; a provisioning template that merged fields into existing lists.</p>
<p>Here&rsquo;s the thing rollback ignores: the six completed steps aren&rsquo;t damage. They&rsquo;re progress. The site is fine; what&rsquo;s missing is the steps that haven&rsquo;t run yet. So the only recovery that makes sense is forward: figure out where the run stopped, and continue from there.</p>
<p>That reframes the problem completely. I don&rsquo;t need compensation logic. I need to know, reliably, which steps finished.</p>
<h2 id="carry-the-progress-in-the-state">Carry the Progress in the State</h2>
<p>Durable Functions makes this natural, because an orchestrator already passes state to each activity and gets state back. The trick is to make &ldquo;what&rsquo;s done&rdquo; part of that state. My orchestration state looks something like this:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">sealed</span> <span style="color:#66d9ef">record</span> <span style="color:#a6e22e">SiteSetupState</span>(
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> CustomerId,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> SiteUrl,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span>[] CompletedSteps
</span></span><span style="display:flex;"><span>);
</span></span></code></pre></div><p>And the orchestrator walks its steps like this:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> state = context.GetInput&lt;SiteSetupState&gt;() ?? <span style="color:#66d9ef">await</span> CreateSite(context, customerId);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">string</span>[] pipeline =
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">[
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">    nameof(ActivateFeatures),
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">    nameof(ApplyTemplate),
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">    nameof(SeedFolders),
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">    nameof(AddMemberGroups),
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">    nameof(RegisterSite),
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">]</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> step <span style="color:#66d9ef">in</span> pipeline)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (state.CompletedSteps.Contains(step))
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        logger.LogInformation(<span style="color:#e6db74">&#34;{Step} already completed, skipping.&#34;</span>, step);
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">continue</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> context.CallActivityAsync(step, state, retryOptions);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    state = state with { CompletedSteps = [.. state.CompletedSteps, step] };
</span></span><span style="display:flex;"><span>    context.SetCustomStatus(state);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li>The pipeline is just an ordered list of activity names. Nothing clever, and that&rsquo;s the point - the interesting machinery is around the calls, not in them.</li>
<li>Before each step, the orchestrator checks whether this step already ran. On a fresh run the array is empty and nothing is skipped. On a resumed run, this check is what fast-forwards past the finished work.</li>
<li>After each successful step, the state is copied with the step name appended. An immutable <code>with</code> copy, so nothing mutates in place, which keeps replays honest.</li>
<li><code>SetCustomStatus</code> publishes the updated state on the orchestration instance. This is the checkpoint. It costs one line.</li>
</ol>
<p>That last line is doing more work than it looks like. Custom status is readable from <em>outside</em> the orchestration - through the management API, without touching the orchestration history. So the same call gives you two things: anyone polling the instance sees live progress (&ldquo;three of six steps done&rdquo;), and if the run fails, the last published state is sitting right there, telling you exactly where it stopped.</p>
<h2 id="resume-is-just-input">Resume Is Just Input</h2>
<p>Because the checkpoint is a plain serializable record, resuming a failed run doesn&rsquo;t need any special framework support. You read the failed instance&rsquo;s custom status, and you start a <em>new</em> orchestration with that state as input:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> failed = <span style="color:#66d9ef">await</span> client.GetInstanceAsync(failedInstanceId, getInputsAndOutputs: <span style="color:#66d9ef">true</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> checkpoint = failed.ReadCustomStatusAs&lt;SiteSetupState&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">await</span> client.ScheduleNewOrchestrationInstanceAsync(
</span></span><span style="display:flex;"><span>    nameof(SiteSetupOrchestrator),
</span></span><span style="display:flex;"><span>    checkpoint);
</span></span></code></pre></div><p>The new run enters the same loop, finds five steps in <code>CompletedSteps</code>, skips them in about a millisecond, and picks up at step six. No site deletion, no re-creation, no duplicate template apply. The half-built site becomes a five-sixths-built site, then a finished one.</p>
<p>I like how little there is to this. The &ldquo;resume feature&rdquo; is the skip-check in the loop plus the fact that the input type and the checkpoint type are the same type. That&rsquo;s it.</p>
<h2 id="the-gap-that-idempotency-covers">The Gap That Idempotency Covers</h2>
<p>One honest caveat. The checkpoint is written <em>after</em> the activity succeeds, so there&rsquo;s a window: the activity finishes, the process dies before the checkpoint lands. On resume, that step&rsquo;s name isn&rsquo;t in <code>CompletedSteps</code>, and it runs again.</p>
<p>You can&rsquo;t close that window - it&rsquo;s inherent to doing the work and recording the work as two operations. What you do instead is make every activity safe to run twice: check before create, treat &ldquo;already exists&rdquo; as success, write with upserts. That&rsquo;s a full topic on its own, but the division of labor is worth stating plainly: <strong>the checkpoint decides how often steps rerun, idempotency decides whether reruns hurt.</strong> You need both. The checkpoint alone has a crash window; idempotency alone means re-executing ten minutes of finished work on every hiccup.</p>
<p>And never checkpoint <em>before</em> the call to close the window from the other side - then a crashed step gets skipped on resume, which is far worse. A step that runs twice is a wasted minute; a step that runs zero times is a broken site that says &ldquo;Completed&rdquo;. Ask me how I know.</p>
<h2 id="gotchas">Gotchas</h2>
<ul>
<li><strong>Use the replay-safe logger.</strong> Orchestrator code replays from history every time the instance wakes up. <code>context.CreateReplaySafeLogger(...)</code> keeps your logs from repeating every completed step on each replay. A regular <code>ILogger</code> in an orchestrator will gaslight you.</li>
<li><strong>No clocks, no GUIDs in the orchestrator.</strong> <code>DateTime.UtcNow</code>, <code>Guid.NewGuid()</code>, and <code>Random</code> produce different values on replay and corrupt the history. Anything nondeterministic belongs inside an activity, including generated names and timestamps you want in the state.</li>
<li><strong>Custom status has a size limit</strong> (16 KB of JSON). A record with a customer ID, a URL, and an array of step names fits hundreds of times over, but don&rsquo;t stuff a whole template or file manifest in there. Checkpoint the <em>position</em>, not the <em>payload</em>.</li>
<li><strong>Step names are a contract.</strong> The moment <code>CompletedSteps</code> is persisted anywhere - a failed instance you might resume next week - renaming an activity breaks the match and the step silently reruns (fine, if idempotent) or the resume misbehaves (not fine). Rename with the same care you&rsquo;d give a database column.</li>
<li><strong>Report progress somewhere humans look.</strong> Custom status is great for machines; my engine also writes the current step name to a status column in the site inventory list, inside a try/catch that logs and swallows. A cosmetic status write must never kill a provisioning run.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>Long provisioning flows fail in the middle, so design for resuming instead of undoing: carry a list of completed steps in the orchestration state, publish it as custom status after every step, skip completed steps on rerun, and feed the saved state back in to resume. Rollback is for databases. Provisioning goes forward.</p>
]]></content:encoded></item><item><title>Create Microsoft 365 Groups Without the Welcome Email Flood</title><link>https://jeppe-spanggaard.dk/blogs/graph-sdk-create-group-without-welcome-email/</link><pubDate>Sat, 14 Feb 2026 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/graph-sdk-create-group-without-welcome-email/</guid><description>Learn how to create Microsoft 365 groups programmatically with the Graph SDK in C# without sending a welcome email flood to every member.</description><content:encoded><![CDATA[<h2 id="the-problem">The Problem</h2>
<p>When building automated site provisioning, every Microsoft 365 group creation triggers a welcome email to each member by default. In an automated scenario where multiple groups are created at once, that quickly turns into a flood of emails your users didn&rsquo;t ask for — and they&rsquo;re going to call it spam.</p>
<p>Microsoft <a href="https://learn.microsoft.com/en-us/graph/group-set-options">documents</a> <code>WelcomeEmailDisabled</code> as a supported option, but doesn&rsquo;t show you how to actually set it from the Graph SDK in C#. That&rsquo;s what this post is about.</p>
<h2 id="the-weird-part-additionaldata">The Weird Part: AdditionalData</h2>
<p>If you look at the typed <code>Group</code> object in the SDK, you won&rsquo;t find a <code>ResourceBehaviorOptions</code> property anywhere. It has to be set through <code>AdditionalData</code> — a catch-all dictionary for properties that exist in the Graph API but aren&rsquo;t modeled as first-class typed properties in the SDK.</p>
<p>It works fine, but it does mean you lose IntelliSense and compile-time safety. The same pattern applies to <code>owners@odata.bind</code> and <code>members@odata.bind</code>, which let you assign owners and members at creation time without separate follow-up calls.</p>
<h2 id="the-solution">The Solution</h2>
<p>Here&rsquo;s the complete group creation request with welcome emails disabled, owners set, and members added — all in a single API call:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> ownerId = <span style="color:#e6db74">$&#34;https://graph.microsoft.com/v1.0/users/{ownerObjectId}&#34;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> groupMembers = <span style="color:#66d9ef">new</span> List&lt;<span style="color:#66d9ef">string</span>&gt;
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">$&#34;https://graph.microsoft.com/v1.0/users/{memberObjectId1}&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">$&#34;https://graph.microsoft.com/v1.0/users/{memberObjectId2}&#34;</span>
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Group requestBody = <span style="color:#66d9ef">new</span>()
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    Description = description,
</span></span><span style="display:flex;"><span>    DisplayName = displayName,
</span></span><span style="display:flex;"><span>    GroupTypes = [<span style="color:#e6db74">&#34;Unified&#34;</span>],
</span></span><span style="display:flex;"><span>    MailEnabled = <span style="color:#66d9ef">false</span>,
</span></span><span style="display:flex;"><span>    MailNickname = siteName,
</span></span><span style="display:flex;"><span>    SecurityEnabled = <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>    AdditionalData = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">object</span>&gt;
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        { <span style="color:#e6db74">&#34;resourceBehaviorOptions&#34;</span>, <span style="color:#66d9ef">new</span> List&lt;<span style="color:#66d9ef">string</span>&gt; { <span style="color:#e6db74">&#34;WelcomeEmailDisabled&#34;</span> } },
</span></span><span style="display:flex;"><span>        { <span style="color:#e6db74">&#34;owners@odata.bind&#34;</span>, <span style="color:#66d9ef">new</span> List&lt;<span style="color:#66d9ef">string</span>&gt; { ownerId } },
</span></span><span style="display:flex;"><span>        { <span style="color:#e6db74">&#34;members@odata.bind&#34;</span>, groupMembers }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Group? <span style="color:#66d9ef">group</span> = <span style="color:#66d9ef">await</span> _graphClient.Groups.PostAsync(requestBody);
</span></span></code></pre></div><h3 id="resourcebehavioroptions">resourceBehaviorOptions</h3>
<p><code>resourceBehaviorOptions</code> controls specific group behaviors at creation. <code>WelcomeEmailDisabled</code> simply stops Microsoft 365 from sending the welcome email to members.</p>
<p>You can also combine multiple options in the same list if needed:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span>{ <span style="color:#e6db74">&#34;resourceBehaviorOptions&#34;</span>, <span style="color:#66d9ef">new</span> List&lt;<span style="color:#66d9ef">string</span>&gt; { <span style="color:#e6db74">&#34;WelcomeEmailDisabled&#34;</span>, <span style="color:#e6db74">&#34;HideGroupInOutlook&#34;</span> } }
</span></span></code></pre></div><p>Other supported values are documented <a href="https://learn.microsoft.com/en-us/graph/group-set-options">here</a>.</p>
<h3 id="and"><a href="mailto:owners@odata.bind">owners@odata.bind</a> and <a href="mailto:members@odata.bind">members@odata.bind</a></h3>
<p>The <code>@odata.bind</code> syntax binds users to the group by their full resource URL at creation time, so you don&rsquo;t need separate <code>POST /groups/{id}/members</code> calls afterward. The URL format must be the full Graph v1.0 path:</p>
<pre tabindex="0"><code>https://graph.microsoft.com/v1.0/users/{objectId}
</code></pre>]]></content:encoded></item><item><title>Update list to use content type</title><link>https://jeppe-spanggaard.dk/blogs/update-list-to-use-content-type/</link><pubDate>Sun, 10 Nov 2024 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/update-list-to-use-content-type/</guid><description>We've all tried creating a SharePoint list without using Content Types from the start. It seems like the quick solution, but it can lead to challenges later when restructuring the list. This post explains why using Content Types from the beginning is a smart move that can save time and hassle in the long run.</description><content:encoded><![CDATA[<h2 id="how-to-add-a-content-type-after-the-fact-in-sharepoint-">How To Add a Content Type After the Fact in SharePoint 💡</h2>
<p>We&rsquo;ve all tried creating a SharePoint list without using Content Types from the start. It seems like the quick solution, but it can lead to challenges later when restructuring the list. This post explains why using Content Types from the beginning is a smart move that can save time and hassle in the long run.</p>
<p>This has been a recurring issue for me 😅 – countless times I’ve started a SharePoint list without Content Types, only to realize later that I needed the list for tasks like search 🔍 or advanced filtering. And, of course, it usually happens when the list is already full of data 📊 without any associated Content Type, making it even more challenging 🛠️ to get everything to work smoothly.</p>
<p><strong>⚠️ Disclaimer: This will only work if the columns in the Content Type match those already present in the list.</strong></p>
<h3 id="how-do-i-fix-it-">How Do I Fix It? 🤔</h3>
<p>The first step is, of course, to create a content type. Once it’s created, I then export it as a template using the PnP PowerShell command <strong>Get-PnPSiteTemplate</strong> 💻. I make sure that only the content type is included in the XML file 📂.</p>
<p>Once the PnP template is ready, a PowerShell script I created can be used to fix this issue – though it can’t handle all scenarios. The script includes the following variables, which need to be filled out:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-powershell" data-lang="powershell"><span style="display:flex;"><span>$siteUrl = <span style="color:#e6db74">&#34;https://xxx.sharepoint.com/sites/xxx&#34;</span>
</span></span><span style="display:flex;"><span>$listName = <span style="color:#e6db74">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>$contentTypeName = <span style="color:#e6db74">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>$columnsToMap = @(<span style="color:#e6db74">&#34;...&#34;</span>)  <span style="color:#75715e"># Replace with the actual column names</span>
</span></span><span style="display:flex;"><span>$userFields = @(<span style="color:#e6db74">&#34;...&#34;</span>)  <span style="color:#75715e"># Replace with actual user field names if needed</span>
</span></span><span style="display:flex;"><span>$templatePath = <span style="color:#e6db74">&#34;..\ContentTypeTemplate.xml&#34;</span>;
</span></span></code></pre></div><p>What the script does is fairly simple 🛠️ – I load all rows with their values into memory 🧠, remove the columns (<em>$columnsToMap</em>) that need to be deleted 🗑️, then invoke the PnP template containing the content type. Finally, I reload all values into the new columns. Since they have the same names as the old columns, everything loads smoothly ✅.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-powershell" data-lang="powershell"><span style="display:flex;"><span><span style="color:#75715e"># Connect to SharePoint Online</span>
</span></span><span style="display:flex;"><span>Connect-PnPOnline -Url $siteUrl -Interactive
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Enable content type management on the list</span>
</span></span><span style="display:flex;"><span>Set-PnPList -Identity $listName -EnableContentTypes $true
</span></span><span style="display:flex;"><span>Write-Host <span style="color:#e6db74">&#34;Content types enabled on the list.&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Backup data from old columns</span>
</span></span><span style="display:flex;"><span>$items = Get-PnPListItem -List $listName -PageSize <span style="color:#ae81ff">2000</span>
</span></span><span style="display:flex;"><span>$backupData = @{}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> ($item <span style="color:#66d9ef">in</span> $items) {
</span></span><span style="display:flex;"><span>    $itemData = @{}
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Backup old column data</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> ($column <span style="color:#66d9ef">in</span> $columnsToMap) {
</span></span><span style="display:flex;"><span>        $itemData[$column] = $item[$column]
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    $backupData[$item.Id] = $itemData
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Remove old columns</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> ($column <span style="color:#66d9ef">in</span> $columnsToMap) {
</span></span><span style="display:flex;"><span>    Remove-PnPField -List $listName -Identity $column -Force
</span></span><span style="display:flex;"><span>    Write-Host <span style="color:#e6db74">&#34;Removed old column:&#34;</span> $column
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Apply PnP Provisioning Template</span>
</span></span><span style="display:flex;"><span>Invoke-PnPSiteTemplate -Path $templatePath
</span></span><span style="display:flex;"><span>Write-Host <span style="color:#e6db74">&#34;PnP template applied to the site.&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Get the content type</span>
</span></span><span style="display:flex;"><span>$contentType = Get-PnPContentType -Identity $contentTypeName
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Add the content type to the list</span>
</span></span><span style="display:flex;"><span>Add-PnPContentTypeToList -List $listName -ContentType $contentType
</span></span><span style="display:flex;"><span>Write-Host <span style="color:#e6db74">&#34;Content type added to the list.&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Update items to the new content type and restore data to new columns</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> ($item <span style="color:#66d9ef">in</span> $items) {
</span></span><span style="display:flex;"><span>    $itemId = $item.Id
</span></span><span style="display:flex;"><span>    $itemData = $backupData[$itemId]
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Handle user fields</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> ($userField <span style="color:#66d9ef">in</span> $userFields) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> ($itemData[$userField]) {
</span></span><span style="display:flex;"><span>            $UserDto = $itemData[$userField];
</span></span><span style="display:flex;"><span>            write-host <span style="color:#e6db74">&#34;User: &#34;</span> $UserDto.Email
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            $itemData[$userField] = $UserDto.Email
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Update item content type</span>
</span></span><span style="display:flex;"><span>    Set-PnPListItem -List $listName -Identity $itemId -Values @{<span style="color:#e6db74">&#34;ContentTypeId&#34;</span> = $contentType.Id }
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Restore data to new columns</span>
</span></span><span style="display:flex;"><span>    Set-PnPListItem -List $listName -Identity $itemId -Values $itemData
</span></span><span style="display:flex;"><span>    Write-Host <span style="color:#e6db74">&#34;Updated item ID:&#34;</span> $itemId
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Write-Host <span style="color:#e6db74">&#34;All items updated to the new content type and old columns removed.&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Disconnect from SharePoint Online</span>
</span></span><span style="display:flex;"><span>Disconnect-PnPOnline
</span></span></code></pre></div><p>As mentioned earlier, this script doesn’t solve everything. For example, any views created with the old columns will no longer work, even if the new columns have the same name. So, there’s definitely room for improvement in my script.</p>
<p>That said, the script has saved me quite a bit of manual work multiple times 🙌.</p>
]]></content:encoded></item></channel></rss>