< Summary

Information
Class: Elsa.Bpmn.Interchange.Services.BpmnInterchangeDocumentService
Assembly: Elsa.Bpmn.Interchange
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Bpmn.Interchange/Services/BpmnInterchangeDocumentService.cs
Line coverage
92%
Covered lines: 242
Uncovered lines: 21
Coverable lines: 263
Total lines: 1009
Line coverage: 92%
Branch coverage
85%
Covered branches: 89
Total branches: 104
Branch coverage: 85.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Bpmn.Interchange/Services/BpmnInterchangeDocumentService.cs

#LineLine coverage
 1using System.Text;
 2using System.Text.Json.Nodes;
 3using Bpmn.Interchange;
 4using Bpmn.Model;
 5using Bpmn.Semantics;
 6using Elsa.Bpmn.Activities;
 7using Elsa.Bpmn.Hosting;
 8using Elsa.Bpmn.Interchange.Binding;
 9using Elsa.Bpmn.Interchange.Endpoints.Bpmn.Document;
 10using Elsa.Bpmn.Interchange.Exceptions;
 11using Elsa.Common;
 12using Elsa.Common.Models;
 13using Elsa.Extensions;
 14using Elsa.Mediator.Contracts;
 15using Elsa.Workflows;
 16using Elsa.Workflows.Management;
 17using Elsa.Workflows.Management.Entities;
 18using Elsa.Workflows.Management.Mappers;
 19using Elsa.Workflows.Management.Materializers;
 20using Elsa.Workflows.Management.Models;
 21using Elsa.Workflows.Management.Notifications;
 22using Elsa.Workflows.Models;
 23
 24namespace Elsa.Bpmn.Interchange.Services;
 25
 26/// <summary>
 27/// The one code path the Analyze, Import, Export and document (<see cref="ReadDocument"/>/<see cref="ImportDocumentAsyn
 28/// endpoints all sit on top of.
 29/// </summary>
 30/// <remarks>
 31/// <para>
 32/// <b>Analyze and Import never disagree.</b> Both call <see cref="BpmnXmlReader"/>, which — per its own contract —
 33/// runs <see cref="BpmnXmlReader.Analyze"/> and <see cref="BpmnXmlReader.Read"/> through the same code, so a preview
 34/// can never say something the import that follows contradicts.
 35/// </para>
 36/// <para>
 37/// <b>Export carries the whole library-owned document, not a reduced view of it.</b> The only thing <see cref="ImportAs
 38/// persists beyond the bound Elsa activity graph is the original BPMN XML text, under <see cref="SourceXmlCustomPropert
 39/// on the workflow definition's custom properties. <see cref="Export(string)"/> re-reads that same text through the sam
 40/// reader and hands the resulting <see cref="BpmnImportResult"/> — retained extension elements, foreign attributes,
 41/// unrecognized children and BPMN DI layout included — straight to <see cref="BpmnXmlWriter"/>. Nothing is
 42/// reconstructed from the Elsa activity tree, which only carries a bindingRef-to-activityId map and would have to
 43/// throw away everything the reader retained to get there.
 44/// </para>
 45/// <para>
 46/// <b>Export is only ever the document as imported — through whichever path last imported it.</b>
 47/// <see cref="Export(WorkflowDefinition)"/> never reconstructs a document from the Elsa activity tree; it always
 48/// returns the source text the most recent successful <see cref="ImportAsync"/> stored. An edit made through Elsa's
 49/// own designer, without going back through BPMN, is therefore never reflected — the graph moves on but the stored
 50/// source describes the document as it stood before that edit, which is why staleness has to be provable rather than
 51/// assumed; see <see cref="SourceVersionCustomPropertyKey"/>. An edit made through <see cref="ImportDocumentAsync"/>
 52/// is different: it re-imports, so the stored source and the returned graph both move together, and
 53/// <see cref="Export(WorkflowDefinition)"/> reflects it immediately afterward.
 54/// </para>
 55/// <para>
 56/// <b>The stored source can go missing or stale after import, and each is refused with its own diagnosis.</b> BPMN
 57/// source travels on <see cref="SourceXmlCustomPropertyKey"/>, one entry in the same <c>CustomProperties</c>
 58/// dictionary a workflow edit can — and, through Elsa's own workflow-definition save endpoint, does — replace
 59/// wholesale. A save that does not carry that key forward removes it as a side effect of editing something else
 60/// entirely, which is indistinguishable, once it has happened, from a definition that was never imported from BPMN
 61/// in the first place; <see cref="Export(WorkflowDefinition)"/> says so honestly rather than asserting the document
 62/// was "never imported", which would be true in one case and false in the other. Separately, a save that DOES carry
 63/// the key forward can still leave the definition materially changed — the graph, name, or anything else about it —
 64/// while the stored BPMN text still describes the pre-edit document. What decides that is the graph itself, not the
 65/// version: <see cref="SourceGraphHashCustomPropertyKey"/> records a content hash of the graph at the moment of
 66/// import, and once it is present it alone says whether the stored source is stale, because it is the only one of
 67/// the two markers a metadata-only save (a rename, a variable edit) and a graph-changing save always disagree on — a
 68/// version bump does not, by itself, mean the graph moved, and it does move without a version bump when an
 69/// unpublished draft is saved in place. <see cref="SourceVersionCustomPropertyKey"/> is what a definition imported
 70/// before the graph hash existed falls back to.
 71/// </para>
 72/// <para>
 73/// <b>A whole-definition import and a document edit disagree about what else gets replaced.</b> <see cref="ImportAsync"
 74/// builds the <c>WorkflowDefinitionModel</c> from the document alone, so <c>Import</c> — which persists a whole
 75/// definition from a document, name and all — intentionally replaces the definition's name, description, variables,
 76/// inputs, outputs, outcomes, options, tool version, read-only flag and custom properties with what that model carries.
 77/// <see cref="ImportDocumentAsync"/> is different: it edits the BPMN document of an <em>existing</em> definition, so
 78/// it passes that definition to the shared import logic's <c>preserveMetadataFrom</c> parameter, which carries all
 79/// of the above onto the result unchanged. Either way, only the bound activity graph and the four custom properties
 80/// this service owns (<see cref="SourceXmlCustomPropertyKey"/>, <see cref="SourceVersionCustomPropertyKey"/>,
 81/// <see cref="SourceProcessIdCustomPropertyKey"/>, <see cref="SourceGraphHashCustomPropertyKey"/>) come from the
 82/// import itself.
 83/// </para>
 84/// <para>
 85/// <b>Capability refusal happens here, at import, not at <c>BpmnGraph.Build</c>.</b> <see cref="BpmnCapabilityRequireme
 86/// is the static half of the same check <c>BpmnGraph.Build</c> performs at first execution: it needs only the
 87/// definition, not bound work or a host snapshot. Running it at import means an unrunnable diagram is rejected before
 88/// it is ever persisted, naming the missing capability and the elements that need it, rather than surfacing as an
 89/// incident the first time the workflow runs. <c>BpmnGraph.Build</c> itself is deliberately not called here: building
 90/// the graph also validates structural invariants that belong to the runtime module's own execution path
 91/// (<c>Elsa.Bpmn.Hosting.BpmnScopeHost</c>), and re-running that here would duplicate it outside the module that owns
 92/// it.
 93/// </para>
 94/// </remarks>
 21995public sealed class BpmnInterchangeDocumentService(
 21996    BpmnXmlReader reader,
 21997    BpmnXmlWriter writer,
 21998    BpmnWorkBinder binder,
 21999    IWorkflowDefinitionImporter importer,
 219100    IWorkflowDefinitionStore store,
 219101    VariableDefinitionMapper variableDefinitionMapper,
 219102    IActivitySerializer activitySerializer,
 219103    IIdentityGenerator identityGenerator,
 219104    ISystemClock systemClock,
 219105    IMediator mediator)
 106{
 107    /// <summary>The workflow definition custom property the original BPMN XML is carried under, for <see cref="Export(W
 108    public const string SourceXmlCustomPropertyKey = "Bpmn:SourceXml";
 109
 110    /// <summary>
 111    /// The workflow definition custom property <see cref="ImportAsync"/> records the definition's own version number
 112    /// under, at the moment it stores <see cref="SourceXmlCustomPropertyKey"/>.
 113    /// </summary>
 114    /// <remarks>
 115    /// <see cref="Export(WorkflowDefinition)"/> compares this against the definition's current version to tell a
 116    /// still-current source from a stale one, but only for a definition that carries no <see cref="SourceGraphHashCusto
 117    /// imported before that marker existed. Once the graph hash is present, it alone decides staleness and this
 118    /// comparison is skipped: a version bump does not, by itself, mean the graph changed, and a metadata-only save
 119    /// (a rename, a variable edit) that carries a definition from a published version N to draft N+1 must not be
 120    /// judged stale on version alone when the graph the stored source describes has not moved.
 121    /// </remarks>
 122    public const string SourceVersionCustomPropertyKey = "Bpmn:SourceVersion";
 123
 124    /// <summary>
 125    /// The workflow definition custom property <see cref="ImportAsync"/> records the <c>processId</c> it bound the
 126    /// definition's root scope from, at the moment it stores <see cref="SourceXmlCustomPropertyKey"/>.
 127    /// </summary>
 128    /// <remarks>
 129    /// A document that declares more than one <c>&lt;process&gt;</c> needs a <c>processId</c> to disambiguate which
 130    /// one a re-import should bind (see <see cref="ResolveRootDefinition"/>); this is what lets
 131    /// <see cref="ImportDocumentAsync"/> re-import the same process a multi-process document was originally imported
 132    /// from, without asking the caller to say so again on every edit. Recorded unconditionally, including for a
 133    /// single-process document, so this is always derivable the same way rather than only when it happens to matter.
 134    /// </remarks>
 135    public const string SourceProcessIdCustomPropertyKey = "Bpmn:SourceProcessId";
 136
 137    /// <summary>
 138    /// The workflow definition custom property <see cref="ImportAsync"/> records a content hash of the definition's
 139    /// serialized activity graph (<see cref="WorkflowDefinition.StringData"/>) under, at the moment it stores
 140    /// <see cref="SourceXmlCustomPropertyKey"/>.
 141    /// </summary>
 142    /// <remarks>
 143    /// An unpublished draft is saved in place — same row, same version — so a designer save of the draft that edits
 144    /// a bound activity's inputs changes <see cref="WorkflowDefinition.StringData"/> without changing
 145    /// <see cref="WorkflowDefinition"/>'s own <c>Version</c>, which <see cref="SourceVersionCustomPropertyKey"/> alone
 146    /// cannot tell apart from no change at all. Once this marker is present, <see cref="Export(WorkflowDefinition)"/>
 147    /// and <see cref="ReadDocument"/> decide staleness from it alone: the stored source is current exactly when the
 148    /// current graph hashes to the value recorded here, whether or not the version has also changed. That matters
 149    /// the other way around too — a metadata-only save (a rename, a variable edit) bumps a published definition to a
 150    /// new draft version without touching the graph, and must not be judged stale on version alone once the graph
 151    /// hash says the document still describes it exactly. Computed with the same hashing
 152    /// <see cref="Elsa.Bpmn.Interchange.Endpoints.Bpmn.Document.BpmnDocumentETag"/> uses for the graph field, via
 153    /// <see cref="BpmnContentHash"/>, so the two never disagree about what "the graph changed" means.
 154    /// <para>
 155    /// A definition imported before this marker existed carries no value for this key; <see cref="ResolveSourceXml"/>
 156    /// falls back to the version-only check for it rather than refusing every definition imported under the older
 157    /// behaviour.
 158    /// </para>
 159    /// </remarks>
 160    public const string SourceGraphHashCustomPropertyKey = "Bpmn:SourceGraphHash";
 161
 162    /// <summary>
 163    /// The host capabilities this deployment's BPMN runtime declares.
 164    /// </summary>
 165    /// <remarks>
 166    /// Reads <see cref="BpmnRuntimeCapabilities.Declared"/> straight from <c>Elsa.Bpmn</c> — the runtime module,
 167    /// which already publishes that constant for exactly this reason — rather than restating the flag set here.
 168    /// A restatement could silently drift from what <c>Elsa.Bpmn.Hosting.BpmnScopeHost</c> actually honours at
 169    /// execution time, which would mean import-time refusal and runtime behaviour disagreeing: the worse direction
 170    /// for that drift to go is a document accepted here and only failing the first time it runs.
 171    /// </remarks>
 2172    public static readonly BpmnHostCapabilities DeclaredHostCapabilities = BpmnRuntimeCapabilities.Declared;
 173
 174    /// <summary>Every individually named capability, for turning a <see cref="BpmnHostCapabilities"/> flag set into rea
 2175    public static readonly IReadOnlyList<BpmnHostCapabilities> IndividualCapabilities =
 2176    [
 2177        BpmnHostCapabilities.SubtreeCancellation,
 2178        BpmnHostCapabilities.ScopeSignalling,
 2179        BpmnHostCapabilities.IterationScopes,
 2180        BpmnHostCapabilities.ScopeVariables
 2181    ];
 182
 183    /// <summary>
 184    /// Reports what a document contains and what a read would cost, without persisting anything.
 185    /// </summary>
 186    /// <exception cref="BpmnInterchangeException">The document cannot be read at all.</exception>
 5187    public BpmnImportAnalysis Analyze(string xml) => reader.Analyze(xml, new BpmnImportOptions());
 188
 189    /// <summary>
 190    /// Reads a document, refuses it if the host cannot run what it declares, and binds it into the <see cref="BpmnProce
 191    /// scope a workflow definition's root becomes.
 192    /// </summary>
 193    /// <param name="xml">The BPMN 2.0 XML to import.</param>
 194    /// <param name="definitionId">The workflow definition to update, or <c>null</c>/empty to create a new one.</param>
 195    /// <param name="name">The workflow definition's display name, defaulting to the process's own BPMN name or id.</par
 196    /// <param name="processId">
 197    /// The process to bind when the document declares more than one; not needed when it declares exactly one.
 198    /// </param>
 199    /// <param name="cancellationToken">The cancellation token.</param>
 200    /// <exception cref="BpmnInterchangeException">The document cannot be read, or declares more than one process and <p
 201    /// <exception cref="BpmnCapabilityException">The document needs a host capability this deployment does not declare.
 202    /// <exception cref="Exceptions.BpmnBindingException">A work binding cannot be turned into an Elsa activity.</except
 203    public Task<BpmnDocumentImportResult> ImportAsync(string xml, string? definitionId, string? name, string? processId,
 81204        ImportCoreAsync(xml, definitionId, name, processId, preserveMetadataFrom: null, expectedETag: null, compareAndSw
 205
 206    /// <summary>
 207    /// The shared import logic behind both the public <see cref="ImportAsync"/> and <see cref="ImportDocumentAsync"/>:
 208    /// reads a document, refuses it if the host cannot run what it declares, and binds it into the
 209    /// <see cref="BpmnProcess"/> scope a workflow definition's root becomes.
 210    /// </summary>
 211    /// <param name="xml">The BPMN 2.0 XML to import.</param>
 212    /// <param name="definitionId">The workflow definition to update, or <c>null</c>/empty to create a new one.</param>
 213    /// <param name="name">The workflow definition's display name, defaulting to the process's own BPMN name or id.</par
 214    /// <param name="processId">
 215    /// The process to bind when the document declares more than one; not needed when it declares exactly one.
 216    /// </param>
 217    /// <param name="preserveMetadataFrom">
 218    /// When set, the definition this import must otherwise leave untouched: its name, description, variables,
 219    /// inputs, outputs, outcomes, options, tool version, read-only flag and custom properties are carried onto the
 220    /// imported definition as-is, and only the bound activity graph and the <see cref="SourceXmlCustomPropertyKey"/>,
 221    /// <see cref="SourceVersionCustomPropertyKey"/>, <see cref="SourceProcessIdCustomPropertyKey"/> and
 222    /// <see cref="SourceGraphHashCustomPropertyKey"/> custom properties this method owns change. Left <c>null</c>
 223    /// for a whole-definition import and for a document edit, where metadata is copied onto the prepared draft
 224    /// and the compare-and-swap refuses if that snapshot has moved.
 225    /// </param>
 226    /// <param name="expectedETag">
 227    /// When <paramref name="compareAndSwap"/> is true, the <c>If-Match</c> value the save must still equal, or
 228    /// <c>null</c> to accept any ETag while still refusing if the prepared draft's snapshot has moved.
 229    /// </param>
 230    /// <param name="compareAndSwap">
 231    /// True for a document edit: persist through <see cref="IWorkflowDefinitionStore.TryUpdateLatestAsync"/> so
 232    /// the precondition and the metadata-preserving save are one step.
 233    /// </param>
 234    /// <param name="cancellationToken">The cancellation token.</param>
 235    /// <exception cref="BpmnInterchangeException">The document cannot be read, or declares more than one process and <p
 236    /// <exception cref="BpmnCapabilityException">The document needs a host capability this deployment does not declare.
 237    /// <exception cref="Exceptions.BpmnBindingException">A work binding cannot be turned into an Elsa activity.</except
 238    private async Task<BpmnDocumentImportResult> ImportCoreAsync(
 239        string xml,
 240        string? definitionId,
 241        string? name,
 242        string? processId,
 243        WorkflowDefinition? preserveMetadataFrom,
 244        string? expectedETag,
 245        bool compareAndSwap,
 246        CancellationToken cancellationToken)
 247    {
 131248        var result = reader.Read(xml, new BpmnImportOptions { ProcessId = processId });
 249
 250        // Before anything below walks into a nested process by matching an id, refuse a document that repeats one:
 251        // see EnsureElementIdsUnique's remarks for why that walk is otherwise not provably finite. reader.Read itself
 252        // never recurses this way — it walks the XML's own element tree, not an id lookup — so it is safe to call
 253        // first and check its result.
 131254        EnsureElementIdsUnique(result.Definitions.Processes, result.Bindings);
 255
 128256        var rootDefinition = ResolveRootDefinition(result.Definitions, processId);
 257
 127258        EnsureCapabilitiesSatisfied(rootDefinition, result.Bindings);
 259
 127260        var process = binder.Bind(rootDefinition, result.Bindings);
 261
 262        // Whoever composes a bound scope into a workflow says explicitly that it is an entry point; an import is
 263        // exactly that, for the process the caller asked to import.
 125264        process.IsRootScope = true;
 265
 125266        var model = new WorkflowDefinitionModel
 125267        {
 125268            DefinitionId = definitionId ?? string.Empty,
 125269            Name = preserveMetadataFrom is not null
 125270                ? preserveMetadataFrom.Name
 125271                : string.IsNullOrWhiteSpace(name) ? rootDefinition.Name ?? rootDefinition.ProcessId : name,
 125272            Root = process
 125273        };
 274
 125275        if (preserveMetadataFrom is not null)
 276        {
 0277            model.Description = preserveMetadataFrom.Description;
 0278            model.Variables = variableDefinitionMapper.Map(preserveMetadataFrom.Variables).ToList();
 0279            model.Inputs = preserveMetadataFrom.Inputs;
 0280            model.Outputs = preserveMetadataFrom.Outputs;
 0281            model.Outcomes = preserveMetadataFrom.Outcomes;
 0282            model.Options = preserveMetadataFrom.Options;
 0283            model.ToolVersion = preserveMetadataFrom.ToolVersion;
 0284            model.IsReadonly = preserveMetadataFrom.IsReadonly;
 0285            model.CustomProperties = new Dictionary<string, object>(preserveMetadataFrom.CustomProperties);
 286        }
 287
 125288        if (compareAndSwap)
 289        {
 48290            return await PersistDocumentEditAsync(xml, definitionId!, process, rootDefinition, result.Analysis, expected
 291        }
 292
 77293        var importResult = await importer.ImportAsync(new SaveWorkflowDefinitionRequest { Model = model, Publish = false
 294
 295        // The definition's final Version is only known once the importer/publisher has assigned and persisted it —
 296        // see SourceVersionCustomPropertyKey's remarks for why that value, specifically, is what staleness is judged
 297        // against. Neither custom property is written until it is known, so both land on this single, explicit save:
 298        // if it fails or is cancelled, the definition carries neither key, which Export(WorkflowDefinition) reports
 299        // honestly as "never imported" rather than as a partial import that cannot be diagnosed.
 77300        if (importResult.Succeeded)
 301        {
 77302            var persisted = importResult.WorkflowDefinition;
 77303            persisted.CustomProperties[SourceXmlCustomPropertyKey] = xml;
 77304            persisted.CustomProperties[SourceVersionCustomPropertyKey] = persisted.Version;
 77305            persisted.CustomProperties[SourceProcessIdCustomPropertyKey] = rootDefinition.ProcessId;
 77306            persisted.CustomProperties[SourceGraphHashCustomPropertyKey] = BpmnContentHash.OfGraph(persisted.StringData)
 77307            await store.SaveAsync(persisted, cancellationToken);
 308        }
 309
 76310        return new BpmnDocumentImportResult(importResult, result.Analysis);
 116311    }
 312
 313    /// <summary>
 314    /// The document-edit persist: prepare and announce a draft before writing so a rejecting
 315    /// <see cref="WorkflowDefinitionDraftSaving"/> handler fails the request without a partial save.
 316    /// The compare-and-swap also checks the full serialized definition snapshot captured before notification,
 317    /// so a concurrent metadata write is refused instead of being overwritten. The original announced draft
 318    /// is what gets saved when that snapshot still matches, preserving the full DraftSaving handler contract.
 319    /// A changed definition or incompatible row transition is <see cref="BpmnDocumentPreconditionFailedException"/>.
 320    /// </summary>
 321    private async Task<BpmnDocumentImportResult> PersistDocumentEditAsync(
 322        string xml,
 323        string definitionId,
 324        BpmnProcess process,
 325        BpmnProcessDefinition rootDefinition,
 326        BpmnImportAnalysis analysis,
 327        string? expectedETag,
 328        CancellationToken cancellationToken)
 329    {
 48330        var filter = WorkflowDefinitionHandle.ByDefinitionId(definitionId, VersionOptions.Latest).ToFilter();
 48331        var current = await store.FindAsync(filter, cancellationToken);
 332
 48333        if (current is null)
 334        {
 0335            throw new BpmnDefinitionNotFoundException(
 0336                $"Workflow definition '{definitionId}' does not exist, so its BPMN document cannot be edited.");
 337        }
 338
 48339        if (expectedETag is not null && !string.Equals(BpmnDocumentETag.From(current), expectedETag, StringComparison.Or
 340        {
 0341            throw new BpmnDocumentPreconditionFailedException(
 0342                "The workflow definition has been written since the ETag in If-Match was issued. GET the document again,
 343        }
 344
 48345        var expectedDefinitionSnapshot = activitySerializer.Serialize((object)current);
 48346        var documentDraft = ApplyDocumentEdit(current, process, xml, rootDefinition);
 48347        var draft = CloneForDraftSaving(documentDraft);
 348
 48349        await mediator.SendAsync(new WorkflowDefinitionDraftSaving(draft), cancellationToken);
 350
 47351        var result = await store.TryUpdateLatestAsync(
 47352            filter,
 54353            loaded => loaded.IsLatest
 54354                      && string.Equals(activitySerializer.Serialize((object)loaded), expectedDefinitionSnapshot, StringC
 54355                      && (expectedETag is null || string.Equals(BpmnDocumentETag.From(loaded), expectedETag, StringCompa
 40356            _ => draft,
 47357            cancellationToken);
 358
 47359        if (result.Outcome == WorkflowDefinitionUpdateOutcome.NotFound)
 360        {
 0361            throw new BpmnDefinitionNotFoundException(
 0362                $"Workflow definition '{definitionId}' does not exist, so its BPMN document cannot be edited.");
 363        }
 364
 47365        if (result.Outcome == WorkflowDefinitionUpdateOutcome.Conflict)
 366        {
 7367            throw new BpmnDocumentPreconditionFailedException(
 7368                "The workflow definition has been written since the ETag in If-Match was issued. GET the document again,
 369        }
 370
 40371        await mediator.SendAsync(new WorkflowDefinitionDraftSaved(result.Definition!), cancellationToken);
 40372        return new BpmnDocumentImportResult(new ImportWorkflowResult(true, result.Definition!, []), analysis);
 40373    }
 374
 375    private WorkflowDefinition CloneForDraftSaving(WorkflowDefinition source)
 376    {
 48377        var draft = source.ShallowClone();
 48378        draft.Options = CloneValue(source.Options);
 48379        draft.Variables = CloneValue(source.Variables);
 48380        draft.Inputs = CloneValue(source.Inputs);
 48381        draft.Outputs = CloneValue(source.Outputs);
 48382        draft.Outcomes = CloneValue(source.Outcomes);
 48383        draft.BinaryData = source.BinaryData?.ToArray();
 454384        draft.CustomProperties = source.CustomProperties.ToDictionary(x => x.Key, x => CloneValue(x.Value)!);
 48385        return draft;
 386    }
 387
 388    private T CloneValue<T>(T value)
 389    {
 443390        if (value is null)
 391        {
 0392            return value;
 393        }
 394
 443395        if (value is string)
 396        {
 145397            return value;
 398        }
 399
 298400        if (value is JsonNode jsonNode)
 401        {
 8402            return (T)(object)jsonNode.DeepClone();
 403        }
 404
 290405        var runtimeType = value.GetType();
 290406        var clone = activitySerializer.Deserialize(activitySerializer.Serialize(value), runtimeType);
 407
 290408        if (clone.GetType() != runtimeType)
 409        {
 0410            throw new InvalidOperationException(
 0411                $"The activity serializer cloned '{runtimeType.FullName}' as '{clone.GetType().FullName}', so the workfl
 412        }
 413
 290414        return (T)clone;
 415    }
 416
 417    /// <summary>
 418    /// Builds the draft the compare-and-swap will save from <paramref name="current"/>:
 419    /// metadata comes from that row; only the bound graph and the BPMN source properties this service owns change.
 420    /// </summary>
 421    private WorkflowDefinition ApplyDocumentEdit(
 422        WorkflowDefinition current,
 423        BpmnProcess process,
 424        string xml,
 425        BpmnProcessDefinition rootDefinition)
 426    {
 48427        var draft = current.IsPublished ? NewDraftFrom(current) : current.ShallowClone();
 48428        var stringData = activitySerializer.Serialize(process);
 429
 48430        draft.StringData = stringData;
 48431        draft.MaterializerName = JsonWorkflowMaterializer.MaterializerName;
 48432        draft.Name = current.Name;
 48433        draft.Description = current.Description;
 48434        draft.Variables = current.Variables;
 48435        draft.Inputs = current.Inputs;
 48436        draft.Outputs = current.Outputs;
 48437        draft.Outcomes = current.Outcomes;
 48438        draft.Options = current.Options;
 48439        draft.ToolVersion = current.ToolVersion;
 48440        draft.IsReadonly = current.IsReadonly;
 48441        draft.CustomProperties = new Dictionary<string, object>(current.CustomProperties)
 48442        {
 48443            [SourceXmlCustomPropertyKey] = xml,
 48444            [SourceVersionCustomPropertyKey] = draft.Version,
 48445            [SourceProcessIdCustomPropertyKey] = rootDefinition.ProcessId,
 48446            [SourceGraphHashCustomPropertyKey] = BpmnContentHash.OfGraph(stringData)
 48447        };
 448
 48449        return draft;
 450    }
 451
 452    /// <summary>
 453    /// The unpublished draft <see cref="Elsa.Workflows.Management.IWorkflowDefinitionPublisher.GetDraftAsync"/> would
 454    /// return for a published latest row, without a store read: new id, next version, not published.
 455    /// </summary>
 456    private WorkflowDefinition NewDraftFrom(WorkflowDefinition published)
 457    {
 4458        var draft = published.ShallowClone();
 4459        draft.Id = identityGenerator.GenerateId();
 4460        draft.Version = published.Version + 1;
 4461        draft.CreatedAt = systemClock.UtcNow;
 4462        draft.IsLatest = true;
 4463        draft.IsPublished = false;
 4464        return draft;
 465    }
 466
 467    /// <summary>
 468    /// Writes the document a workflow definition was imported from back out as BPMN 2.0 XML, through the same
 469    /// reader-then-writer path <see cref="ImportAsync"/> used, so retained extension elements, foreign attributes and
 470    /// BPMN DI layout come back exactly as the reader retained them.
 471    /// </summary>
 472    /// <param name="xml">The BPMN 2.0 XML carried on the workflow definition's <see cref="SourceXmlCustomPropertyKey"/>
 473    /// <exception cref="BpmnInterchangeException">The document cannot be read at all.</exception>
 474    public byte[] Export(string xml)
 475    {
 35476        var result = reader.Read(xml, new BpmnImportOptions());
 35477        var document = writer.Write(result, new BpmnExportOptions());
 478
 35479        return Encoding.UTF8.GetBytes(document);
 480    }
 481
 482    /// <summary>
 483    /// Resolves the BPMN source a workflow definition was imported from and writes it back out, refusing rather than
 484    /// guessing when that source is missing or no longer trustworthy. See this type's remarks for what "missing" and
 485    /// "stale" mean and why each gets its own message.
 486    /// </summary>
 487    /// <param name="definition">The workflow definition to export, as read from the store.</param>
 488    /// <exception cref="BpmnExportUnavailableException">
 489    /// The definition does not currently carry BPMN source, or it does but the definition has changed since the
 490    /// source was recorded.
 491    /// </exception>
 492    /// <exception cref="BpmnInterchangeException">The stored document cannot be read at all.</exception>
 42493    public byte[] Export(WorkflowDefinition definition) => Export(ResolveSourceXml(definition));
 494
 495    /// <summary>
 496    /// Resolves the BPMN source a workflow definition was imported from and reads it back as the neutral
 497    /// <see cref="BpmnDefinitions"/> object model — the same shape <see cref="ImportDocumentAsync"/> accepts back —
 498    /// through the same reader <see cref="ImportAsync"/> and <see cref="Export(string)"/> use, so retained extension
 499    /// elements, foreign attributes and BPMN DI layout are present on the returned document exactly as the reader
 500    /// retained them. Refuses rather than guessing when that source is missing or no longer trustworthy; see this
 501    /// type's remarks for what "missing" and "stale" mean.
 502    /// </summary>
 503    /// <remarks>
 504    /// <see cref="BpmnDefinitions"/> lists only top-level processes, so the returned document declares every
 505    /// subprocess element but carries none of their bodies: the library hands those out as work bindings, not as
 506    /// part of the document. <see cref="ImportDocumentAsync"/> restores them from the stored source rather than from
 507    /// the document, which is also why nothing inside a nested scope can be edited through the document.
 508    /// </remarks>
 509    /// <param name="definition">The workflow definition to read, as read from the store.</param>
 510    /// <exception cref="BpmnExportUnavailableException">
 511    /// The definition does not currently carry BPMN source, or it does but the definition has changed since the
 512    /// source was recorded.
 513    /// </exception>
 514    /// <exception cref="BpmnInterchangeException">The stored document cannot be read at all.</exception>
 515    public BpmnDefinitions ReadDocument(WorkflowDefinition definition)
 516    {
 56517        var xml = ResolveSourceXml(definition);
 53518        return reader.Read(xml, new BpmnImportOptions()).Definitions;
 519    }
 520
 521    /// <summary>
 522    /// Accepts the whole <see cref="BpmnDefinitions"/> document — the shape <see cref="ReadDocument"/> returns —
 523    /// writes it back out as BPMN 2.0 XML with <see cref="BpmnXmlWriter"/>, and imports the result through
 524    /// <see cref="ImportAsync"/>, the same path <c>Import</c> runs. Analyze-then-commit sharing this one code path
 525    /// with the read side is what keeps a preview unable to disagree with what this actually persists.
 526    /// </summary>
 527    /// <remarks>
 528    /// Unlike a whole-definition import, this edits the BPMN <em>document</em> of an existing definition: the caller
 529    /// is changing a binding, not replacing the definition. So <paramref name="definitionId"/>'s current metadata —
 530    /// name, description, variables, inputs, outputs, outcomes, options, tool version, read-only flag and custom
 531    /// properties other than the ones this service owns — is copied onto the prepared draft and the compare-and-swap
 532    /// refuses if that snapshot has moved, rather than being taken from the lookup that restores nested scopes. Only
 533    /// the activity graph and the <see cref="SourceXmlCustomPropertyKey"/>/<see cref="SourceVersionCustomPropertyKey"/>
 534    /// <see cref="SourceProcessIdCustomPropertyKey"/>/<see cref="SourceGraphHashCustomPropertyKey"/> custom properties 
 535    /// <para>
 536    /// Nested scopes come from the stored source, not from <paramref name="document"/>, which cannot carry them (see
 537    /// <see cref="ReadDocument"/>): every subprocess element <paramref name="document"/> still declares is written back
 538    /// with the body stored for it, exactly as stored, and a subprocess element it no longer declares takes its stored
 539    /// body with it. A subprocess element with no stored body — one added by this edit — is written as declared, empty.
 540    /// </para>
 541    /// <para>
 542    /// A top-level call activity's call options — currently just <see cref="BpmnWorkBinding.CallProcess.WaitForCompleti
 543    /// live only on its work binding too, and <paramref name="document"/> cannot carry them either, so they come from t
 544    /// stored source the same way: see <see cref="StoredCallOptionsStillApplicableTo"/>. They are reused only for a cal
 545    /// activity whose element id and <c>calledElement</c> are unchanged; a changed <c>calledElement</c> is a call to a
 546    /// different process, so it starts from the reader's default (waiting) rather than inheriting options authored for
 547    /// the process it used to call.
 548    /// </para>
 549    /// </remarks>
 550    /// <param name="document">The edited document, deserialized through the library's own JSON converters.</param>
 551    /// <param name="definitionId">The workflow definition to update.</param>
 552    /// <param name="processId">
 553    /// The process to (re-)bind when the document declares more than one; not needed when it declares exactly one.
 554    /// See <see cref="SourceProcessIdCustomPropertyKey"/> for where a caller re-importing an existing definition
 555    /// finds the value that was used the first time.
 556    /// </param>
 557    /// <param name="expectedETag">
 558    /// The <c>If-Match</c> value the document PUT already checked. When set, the compare-and-swap that persists
 559    /// the prepared draft also refuses with <see cref="BpmnDocumentPreconditionFailedException"/> if the stored
 560    /// content that ETag covers — or the name/description snapshot the draft was built from — has moved. When
 561    /// omitted, any ETag is accepted but a moved metadata snapshot is still refused.
 562    /// </param>
 563    /// <param name="cancellationToken">The cancellation token.</param>
 564    /// <exception cref="Exceptions.BpmnDefinitionNotFoundException">
 565    /// The workflow definition to edit no longer exists — e.g. it was deleted between the PUT endpoint's own
 566    /// existence/ETag check and this lookup. A missing preservation source must never fall through to the
 567    /// whole-definition import path, which would silently create a definition under <paramref name="definitionId"/>
 568    /// with reset metadata instead of reporting that this PUT's target disappeared.
 569    /// </exception>
 570    /// <exception cref="BpmnDocumentPreconditionFailedException">
 571    /// <paramref name="expectedETag"/> no longer matches the stored definition — another writer saved first.
 572    /// </exception>
 573    /// <exception cref="BpmnInterchangeException">
 574    /// The document declares more than one process and <paramref name="processId"/> does not pick one, or it declares a
 575    /// subprocess element that has a stored body but no bindingRef to write that body back under.
 576    /// </exception>
 577    /// <exception cref="BpmnCapabilityException">The document needs a host capability this deployment does not declare.
 578    /// <exception cref="Exceptions.BpmnBindingException">A work binding cannot be turned into an Elsa activity.</except
 579    public async Task<BpmnDocumentImportResult> ImportDocumentAsync(
 580        BpmnDefinitions document,
 581        string definitionId,
 582        string? processId,
 583        CancellationToken cancellationToken,
 584        string? expectedETag = null)
 585    {
 54586        var filter = WorkflowDefinitionHandle.ByDefinitionId(definitionId, VersionOptions.Latest).ToFilter();
 54587        var existingDefinition = await store.FindAsync(filter, cancellationToken);
 588
 54589        if (existingDefinition is null)
 590        {
 1591            throw new BpmnDefinitionNotFoundException(
 1592                $"Workflow definition '{definitionId}' does not exist, so its BPMN document cannot be edited.");
 593        }
 594
 53595        var stored = ReadStoredSource(existingDefinition);
 596
 53597        var bindingsToCarryAcross = StoredNestedScopesStillDeclaredBy(document, stored)
 53598            .Concat(StoredCallOptionsStillApplicableTo(document, stored))
 53599            .ToList();
 600
 601        // Unlike ImportAsync's xml, writer.Write itself is one of the sites that walks nested processes by matching
 602        // an id (see EnsureElementIdsUnique's remarks), and it runs before ImportCoreAsync — and the same check
 603        // inside it — ever sees this document. So it is checked here too, against exactly the inputs writer.Write is
 604        // about to receive, before that call rather than after it. EnsureElementIdsUnique only harvests elements from
 605        // the NestedProcess bindings in this list, so including the CallProcess bindings alongside them changes
 606        // nothing about what it checks.
 52607        EnsureElementIdsUnique(document.Processes, bindingsToCarryAcross);
 608
 50609        var xml = writer.Write(document, bindingsToCarryAcross);
 50610        return await ImportCoreAsync(xml, definitionId, name: null, processId, preserveMetadataFrom: null, expectedETag,
 40611    }
 612
 613    /// <summary>
 614    /// Reads and parses the BPMN source stored on <paramref name="definition"/>'s <see cref="SourceXmlCustomPropertyKey
 615    /// custom property once, for <see cref="StoredNestedScopesStillDeclaredBy"/> and
 616    /// <see cref="StoredCallOptionsStillApplicableTo"/> to share, rather than each independently re-reading and
 617    /// re-parsing the same stored text. <c>null</c> when the definition carries no stored source at all.
 618    /// </summary>
 619    private BpmnImportResult? ReadStoredSource(WorkflowDefinition definition)
 620    {
 53621        if (!definition.CustomProperties.TryGetValue<string>(SourceXmlCustomPropertyKey, out var storedXml) || string.Is
 0622            return null;
 623
 53624        return reader.Read(storedXml, new BpmnImportOptions());
 625    }
 626
 627    /// <summary>
 628    /// The stored work bindings <see cref="BpmnXmlWriter"/> needs to write every nested scope — embedded subprocess,
 629    /// transaction or event subprocess — that <paramref name="document"/> still declares back out exactly as
 630    /// <paramref name="stored"/> has it, and nothing for a scope <paramref name="document"/> no
 631    /// longer declares.
 632    /// </summary>
 633    /// <remarks>
 634    /// <para>
 635    /// <see cref="BpmnDefinitions"/> lists only top-level processes. The body of a nested scope is not on its
 636    /// subprocess element: the reader hands it out as that element's <see cref="BpmnWorkBinding.NestedProcess"/>
 637    /// binding, and <see cref="BpmnXmlWriter"/> writes a subprocess whose binding it is not given as empty. The
 638    /// document <see cref="ReadDocument"/> returns therefore never carries a nested body, so a document written back
 639    /// without these bindings would silently replace every subprocess with an empty one. The stored source is the
 640    /// only place the bodies still exist, so they are re-read from it here.
 641    /// </para>
 642    /// <para>
 643    /// A stored body is matched to a subprocess element of the posted document by element id, which BPMN makes
 644    /// unique across the whole document. It is never matched by bindingRef, so a subprocess element the posted
 645    /// document removed matches nothing and its stored body is never written back, and a new element that reuses a
 646    /// removed one's bindingRef does not inherit its body. The writer looks a body up by the element's own
 647    /// bindingRef, so a kept body is handed over under the bindingRef the posted element carries.
 648    /// </para>
 649    /// <para>
 650    /// Everything bound inside a kept scope comes along with it, not just the bodies of scopes nested further in:
 651    /// a call activity's <c>vw:waitForCompletion="false"</c>, for one, lives only on its
 652    /// <see cref="BpmnWorkBinding.CallProcess"/> binding. Nothing inside a kept scope can differ from what is stored,
 653    /// because the document the client edited never carried it, so the stored bindings describe it exactly.
 654    /// </para>
 655    /// <para>
 656    /// One thing the stored body holds is not its own: <c>Bpmn.Interchange</c> 0.2.0 reads a subprocess's
 657    /// <c>multiInstanceLoopCharacteristics</c> onto the subprocess element, which is what the writer emits it from and,
 658    /// at the top level, what the client edits, but also retains a copy as foreign content of the nested process the
 659    /// element opens. Handed back, that copy would come first on the next read, overriding a marker the client changed
 660    /// or removed, and every write would add another. So it is dropped from a kept body whenever the element carries a
 661    /// marker in the model, as stored or as posted, and kept only as the sole record of one the reader could not
 662    /// interpret. This is tracked upstream as <see href="https://github.com/valence-works/bpmn/issues/21">valence-works
 663    /// remove this workaround once a <c>Bpmn.Interchange</c> release containing that fix is adopted.
 664    /// </para>
 665    /// <para>
 666    /// A definition without stored source has no body to keep; its subprocesses are written as the document declares
 667    /// them. The document <c>PUT</c> reaches that case only if the source disappears between its <c>If-Match</c> check
 668    /// and this read: the precondition only matches a stored state a successful <c>GET</c> or <c>PUT</c> described,
 669    /// and both need the source.
 670    /// </para>
 671    /// </remarks>
 672    /// <exception cref="BpmnInterchangeException">
 673    /// A subprocess element with a stored body carries no bindingRef, so the writer cannot attach the body to it and
 674    /// would write the subprocess empty.
 675    /// </exception>
 676    private IReadOnlyList<BpmnWorkBinding> StoredNestedScopesStillDeclaredBy(BpmnDefinitions document, BpmnImportResult?
 677    {
 53678        if (stored is null)
 0679            return [];
 680
 53681        var storedBindings = stored.Bindings;
 682
 683        // Every element carrying a multi-instance marker in the model, as stored or as posted.
 53684        var loopingElementIds = document.Processes
 53685            .Concat(stored.Definitions.Processes)
 33686            .Concat(storedBindings.OfType<BpmnWorkBinding.NestedProcess>().Select(nested => nested.Definition))
 141687            .SelectMany(process => process.Elements)
 580688            .Where(element => element.LoopCharacteristics is not null)
 19689            .Select(element => element.ElementId)
 53690            .ToHashSet(StringComparer.Ordinal);
 691
 53692        var kept = new List<BpmnWorkBinding>();
 693
 213694        foreach (var process in document.Processes)
 695        {
 384696            foreach (var subprocess in process.Elements.Where(element => element.ElementType == BpmnElementTypes.SubProc
 697            {
 698                // Last match wins, as it does inside the writer itself, should a malformed document repeat an id.
 70699                var body = storedBindings.OfType<BpmnWorkBinding.NestedProcess>().LastOrDefault(nested => nested.Element
 700
 25701                if (body is null)
 702                    continue;
 703
 24704                if (subprocess.BindingRef is null)
 705                {
 1706                    throw new BpmnInterchangeException(
 1707                        $"Subprocess element '{subprocess.ElementId}' of process '{process.ProcessId}' carries no bindin
 1708                        + "The document does not carry a subprocess's body, so writing it without one would silently emp
 709                }
 710
 23711                kept.Add(HandOver(body) with { BindingRef = subprocess.BindingRef });
 23712                KeepEverythingBoundInside(body.ElementId);
 713            }
 714        }
 715
 52716        return kept;
 717
 718        // A nested process's bindings name the subprocess element's id as their process (see BpmnWorkBinding.ProcessId)
 719        void KeepEverythingBoundInside(string scopeId)
 720        {
 282721            foreach (var binding in storedBindings.Where(binding => binding.ProcessId == scopeId))
 722            {
 34723                if (binding is not BpmnWorkBinding.NestedProcess nested)
 724                {
 27725                    kept.Add(binding);
 27726                    continue;
 727                }
 728
 7729                kept.Add(HandOver(nested));
 7730                KeepEverythingBoundInside(nested.ElementId);
 731            }
 30732        }
 733
 734        BpmnWorkBinding.NestedProcess HandOver(BpmnWorkBinding.NestedProcess nested) =>
 30735            loopingElementIds.Contains(nested.ElementId) ? WithoutRetainedLoopMarker(nested) : nested;
 736    }
 737
 738    /// <summary>
 739    /// <paramref name="nested"/> without the copy of its subprocess element's multi-instance marker the reader also
 740    /// retained on it; see <see cref="StoredNestedScopesStillDeclaredBy"/>'s remarks.
 741    /// </summary>
 742    private static BpmnWorkBinding.NestedProcess WithoutRetainedLoopMarker(BpmnWorkBinding.NestedProcess nested)
 743    {
 13744        var marker = new BpmnQName(BpmnXmlNames.Model.NamespaceName, "multiInstanceLoopCharacteristics");
 13745        var extensions = nested.Definition.Extensions;
 26746        var foreignChildren = extensions.ForeignChildren.Where(child => child.Element.Name != marker).ToList();
 13747        return nested with { Definition = nested.Definition with { Extensions = extensions with { ForeignChildren = fore
 748    }
 749
 750    /// <summary>
 751    /// The stored <see cref="BpmnWorkBinding.CallProcess"/> options for every top-level call activity
 752    /// <paramref name="document"/> still declares under the same element id and the same <c>calledElement</c>.
 753    /// </summary>
 754    /// <remarks>
 755    /// <para>
 756    /// A call activity's options — currently just <c>vw:waitForCompletion="false"</c> — live only on its
 757    /// <see cref="BpmnWorkBinding.CallProcess"/> work binding, never on the <see cref="BpmnDefinitions"/> document
 758    /// <see cref="ReadDocument"/> returns. <see cref="ImportDocumentAsync"/> otherwise rebuilds the whole document from
 759    /// scratch, so a call activity it does not carry across here binds fresh and defaults to waiting, silently
 760    /// resurrecting a wait a fire-and-forget author never asked for.
 761    /// </para>
 762    /// <para>
 763    /// Reused only when it is safe to: the element must still exist, under the same id, and still name the same
 764    /// <c>calledElement</c> as the stored binding. A changed <c>calledElement</c> is a call to a different process,
 765    /// whose options this element never had, so it is left to bind fresh rather than inheriting them.
 766    /// </para>
 767    /// <para>
 768    /// Only <see cref="BpmnDefinitions.Processes"/> is scanned — exactly the call activities the document can declare.
 769    /// One nested inside a kept subprocess is never listed there (see <see cref="ReadDocument"/>'s remarks); its
 770    /// options are carried across as part of the whole kept scope by <see cref="StoredNestedScopesStillDeclaredBy"/>
 771    /// instead, along with everything else bound inside it.
 772    /// </para>
 773    /// <para>
 774    /// Matched to the stored binding by element id, then handed over under the bindingRef the posted element carries,
 775    /// for the same reason <see cref="StoredNestedScopesStillDeclaredBy"/> hands a kept subprocess body over the same
 776    /// way: the writer looks work up by bindingRef, not element id. An element with no bindingRef to hand it over
 777    /// under is skipped rather than refused — unlike an emptied subprocess, the worst outcome is the same fresh,
 778    /// waiting default this method exists to avoid, not data loss.
 779    /// </para>
 780    /// </remarks>
 781    private IReadOnlyList<BpmnWorkBinding> StoredCallOptionsStillApplicableTo(BpmnDefinitions document, BpmnImportResult
 782    {
 52783        if (stored is null)
 0784            return [];
 785
 52786        var storedCalls = stored.Bindings.OfType<BpmnWorkBinding.CallProcess>().ToList();
 787
 52788        var kept = new List<BpmnWorkBinding>();
 789
 210790        foreach (var process in document.Processes)
 791        {
 792            // An element with no bindingRef to hand a kept call over under is skipped rather than refused.
 116793            foreach (var element in process.Elements.Where(element =>
 278794                         element.ElementType == BpmnElementTypes.CallActivity && element.BindingRef is not null))
 795            {
 796                // Last match wins, as it does inside the writer itself, should a malformed document repeat an id.
 10797                var call = storedCalls.LastOrDefault(candidate => candidate.ElementId == element.ElementId);
 798
 5799                if (call is null || !string.Equals(call.CalledElement, CalledElementOf(element), StringComparison.Ordina
 800                    continue;
 801
 4802                kept.Add(call with { BindingRef = element.BindingRef! });
 803            }
 804        }
 805
 52806        return kept;
 807    }
 808
 809    /// <summary>The BPMN <c>calledElement</c> a call activity element carries, kept by the reader for round-trip.</summ
 810    private static string? CalledElementOf(BpmnElement element) =>
 5811        element.Properties.TryGetValue(BpmnXmlReader.CalledElementPropertyKey, out var calledElement) ? calledElement : 
 812
 813    /// <summary>
 814    /// The BPMN source a workflow definition was imported from, refusing rather than guessing when it is missing or
 815    /// no longer trustworthy. See this type's remarks for what "missing" and "stale" mean and why each gets its own
 816    /// message.
 817    /// </summary>
 818    /// <exception cref="BpmnExportUnavailableException">
 819    /// The definition does not currently carry BPMN source, or it does but the definition has changed since the
 820    /// source was recorded.
 821    /// </exception>
 822    private static string ResolveSourceXml(WorkflowDefinition definition)
 823    {
 98824        if (!definition.CustomProperties.TryGetValue<string>(SourceXmlCustomPropertyKey, out var xml) || string.IsNullOr
 825        {
 4826            throw new BpmnExportUnavailableException(
 4827                $"Workflow definition '{definition.DefinitionId}' does not currently carry BPMN source, so it cannot be 
 4828                + "Either it was never imported from a BPMN document, or a later save replaced its custom properties who
 4829                + $"'{SourceXmlCustomPropertyKey}' entry as a side effect of editing something else.",
 4830                BpmnExportUnavailableReason.NotImported);
 831        }
 832
 94833        if (!definition.CustomProperties.TryGetValue<int>(SourceVersionCustomPropertyKey, out var sourceVersion))
 834        {
 835            // Distinct from both other refusals: this is not "never imported" (the source text is right there) and
 836            // not "stale" (there is no version to compare against yet). ImportAsync writes SourceXmlCustomPropertyKey
 837            // and SourceVersionCustomPropertyKey together, in the single save described in its remarks, so this path
 838            // is not reachable through import itself; it is kept as a defence against the same combination arising
 839            // some other way — e.g. custom properties edited or migrated directly, outside ImportAsync — where
 840            // "whether the source still matches" cannot be verified without a version to compare against.
 1841            throw new BpmnExportUnavailableException(
 1842                $"Workflow definition '{definition.DefinitionId}' carries BPMN source, but not the definition version it
 1843                + "whether that source still matches this definition cannot be verified. It does not mean this definitio
 1844                + "BPMN, and it does not mean the source is stale — there is simply no version recorded to compare again
 1845                + "record a complete, exportable source.",
 1846                BpmnExportUnavailableReason.SourceVersionUnknown);
 847        }
 848
 849        // The graph hash, once recorded, is the sole word on staleness: it is unaffected by a metadata-only save
 850        // (a rename, a variable change) that bumps the definition to a new draft version without touching the graph
 851        // the stored source describes, which the version check below would otherwise flag as stale even though the
 852        // document still matches exactly. A definition imported before this marker existed carries no value for it,
 853        // so it falls back to the version check instead of refusing every definition imported under the older
 854        // behaviour.
 93855        if (definition.CustomProperties.TryGetValue<string>(SourceGraphHashCustomPropertyKey, out var sourceGraphHash) &
 856        {
 91857            if (sourceGraphHash != BpmnContentHash.OfGraph(definition.StringData))
 858            {
 5859                throw new BpmnExportUnavailableException(
 5860                    $"Workflow definition '{definition.DefinitionId}' has changed since it was imported from BPMN: its a
 5861                    + "the graph the stored source was imported against. The BPMN source stored on it no longer correspo
 5862                    + "exporting it would silently return a document that is not what this definition currently is.",
 5863                    BpmnExportUnavailableReason.SourceStale);
 864            }
 865        }
 2866        else if (sourceVersion != definition.Version)
 867        {
 1868            throw new BpmnExportUnavailableException(
 1869                $"Workflow definition '{definition.DefinitionId}' has changed since it was imported from BPMN (imported 
 1870                + $"currently at version {definition.Version}). The BPMN source stored on it no longer corresponds to th
 1871                + "would silently return a document that is not what this definition currently is.",
 1872                BpmnExportUnavailableReason.SourceStale);
 873        }
 874
 87875        return xml;
 876    }
 877
 878    private static BpmnProcessDefinition ResolveRootDefinition(BpmnDefinitions definitions, string? processId)
 879    {
 128880        if (!string.IsNullOrWhiteSpace(processId))
 881        {
 882            // BpmnImportOptions.ProcessId already made the read fail fast if the document does not declare this
 883            // process, so finding it here can only fail if that guarantee itself changes.
 72884            return definitions.Processes.First(process => string.Equals(process.ProcessId, processId, StringComparison.O
 885        }
 886
 92887        if (definitions.Processes.Count == 1)
 91888            return definitions.Processes[0];
 889
 3890        var declared = string.Join(", ", definitions.Processes.Select(process => process.ProcessId));
 891
 1892        throw new BpmnInterchangeException(
 1893            $"The document declares {definitions.Processes.Count} processes ({declared}); specify which one to import.")
 894    }
 895
 896    /// <summary>
 897    /// Refuses a document that declares the same element id more than once, naming the duplicated ids.
 898    /// </summary>
 899    /// <remarks>
 900    /// <para>
 901    /// BPMN requires every element id to be unique within a document. The library's own reader tolerates a repeat —
 902    /// <c>BpmnXmlReader</c> walks the XML's own element tree, so it terminates regardless of what any id says — but
 903    /// nothing downstream of it does: this type's own <c>EnsureCapabilitiesSatisfied</c> and <c>BpmnWorkBinder.BindScop
 904    /// both find "the nested processes belonging to this scope" by matching <see cref="BpmnWorkBinding.ProcessId"/>
 905    /// against the scope's own id, and <c>Bpmn.Interchange</c>'s own <c>BpmnXmlWriter</c> does the same by matching
 906    /// <see cref="BpmnWorkBinding.BindingRef"/>. A <see cref="BpmnWorkBinding.NestedProcess"/>'s own
 907    /// <see cref="BpmnProcessDefinition.ProcessId"/> is always the element id of the subprocess element that opens
 908    /// it, so a subprocess nested inside another subprocess that reuses its parent's id makes that lookup find its
 909    /// own parent — or itself — again on every step down. Each of those three walks then recurses without ever
 910    /// terminating and crashes the process outright: .NET cannot catch a <see cref="StackOverflowException"/>. This
 911    /// runs before any of them does, so a document like that is refused rather than crashing the server.
 912    /// </para>
 913    /// <para>
 914    /// Once every element id is unique, that recursion is provably finite without a separate depth guard: a scope's
 915    /// nested processes can then only ever be the ones its own <see cref="BpmnWorkBinding.ProcessId"/> or
 916    /// <see cref="BpmnWorkBinding.BindingRef"/> actually names, so the walk can only ever follow the tree the
 917    /// document's own nesting describes.
 918    /// </para>
 919    /// </remarks>
 920    /// <param name="processes">The document's own top-level process bodies.</param>
 921    /// <param name="bindings">
 922    /// Every binding across the same processes, so every subprocess body nested inside them — which is not one of
 923    /// <paramref name="processes"/> itself, and carries elements <paramref name="processes"/> does not enumerate —
 924    /// is covered too.
 925    /// </param>
 926    /// <remarks>
 927    /// A top-level <see cref="BpmnProcessDefinition"/>'s own <see cref="BpmnProcessDefinition.ProcessId"/> is a scope
 928    /// id in exactly the same id-space as every element id below it: <c>EnsureCapabilitiesSatisfied</c>,
 929    /// <c>BpmnWorkBinder.BindScope</c> and <c>Bpmn.Interchange</c>'s own <c>BpmnXmlWriter</c> all find "the nested
 930    /// processes belonging to this scope" by matching a <see cref="BpmnWorkBinding.NestedProcess"/>'s owner id
 931    /// against a <see cref="BpmnProcessDefinition.ProcessId"/> — a top-level process's own <em>id</em>, not one of
 932    /// its declared elements, so nothing below ever puts it in the pool checked for uniqueness on its own. A
 933    /// subprocess reusing that id (e.g. <c>&lt;process id="P"&gt;&lt;subProcess id="P"&gt;</c>) makes that lookup
 934    /// find the top-level scope again instead of terminating — the same class of infinite recursion a repeated
 935    /// element id causes — so it is added here explicitly, once per top-level process.
 936    /// <para>
 937    /// A <em>nested</em> process definition's own <see cref="BpmnProcessDefinition.ProcessId"/> needs no equivalent
 938    /// addition: it is always exactly the <see cref="BpmnWorkBinding.ElementId"/> of the subprocess
 939    /// element that opens it, by construction of the library's own reader, and that element id is already in the
 940    /// pool below as one of its <em>owner</em>'s elements. Adding it a second time would flag every ordinary
 941    /// subprocess as a duplicate of itself; the legitimate pairing is counted once by not adding it again here.
 942    /// </para>
 943    /// </remarks>
 944    /// <exception cref="BpmnDuplicateElementIdException">An element id, or a top-level process id, is declared more tha
 945    internal static void EnsureElementIdsUnique(IEnumerable<BpmnProcessDefinition> processes, IReadOnlyList<BpmnWorkBind
 946    {
 188947        var processList = processes as IReadOnlyCollection<BpmnProcessDefinition> ?? processes.ToList();
 948
 381949        var processIds = processList.Select(process => process.ProcessId);
 950
 188951        var elementIds = processList
 91952            .Concat(bindings.OfType<BpmnWorkBinding.NestedProcess>().Select(nested => nested.Definition))
 284953            .SelectMany(process => process.Elements)
 1251954            .Select(element => element.ElementId);
 955
 188956        var duplicateIds = processIds
 188957            .Concat(elementIds)
 1256958            .GroupBy(id => id, StringComparer.Ordinal)
 1247959            .Where(group => group.Count() > 1)
 9960            .Select(group => group.Key)
 188961            .ToList();
 962
 188963        if (duplicateIds.Count == 0)
 179964            return;
 965
 9966        throw new BpmnDuplicateElementIdException(
 9967            $"The document declares the same element id more than once, which BPMN requires to be unique: {string.Join("
 9968            + "This is most often a subprocess nested inside another subprocess that reuses its parent's id. Reading or 
 9969            + "cannot be done safely, so it is refused rather than attempted.",
 9970            duplicateIds);
 971    }
 972
 973    /// <summary>
 974    /// Refuses the definition, naming the missing capability and the offending element ids, when it or any process
 975    /// nested inside it needs a host capability <see cref="DeclaredHostCapabilities"/> does not cover.
 976    /// </summary>
 977    private static void EnsureCapabilitiesSatisfied(BpmnProcessDefinition definition, IReadOnlyList<BpmnWorkBinding> bin
 127978        EnsureCapabilitiesSatisfied(definition, bindings, DeclaredHostCapabilities);
 979
 980    /// <summary>
 981    /// Refuses the definition, naming the missing capability and the offending element ids, when it or any process
 982    /// nested inside it needs a host capability <paramref name="available"/> does not cover.
 983    /// </summary>
 984    /// <remarks>
 985    /// Takes the available capability set as a parameter, rather than reading <see cref="DeclaredHostCapabilities"/>
 986    /// directly, so a test can prove the refusal — and the walk into nested processes below — without a document that
 987    /// needs a capability this deployment's runtime host has never declared, which the current library version
 988    /// cannot produce because <see cref="DeclaredHostCapabilities"/> already covers every capability it defines.
 989    /// <para>
 990    /// A nested process is a separate scope with its own graph at execution time, so — mirroring that — it is
 991    /// analyzed separately here too, walking every <see cref="BpmnWorkBinding.NestedProcess"/> binding whose owner is
 992    /// the definition just checked.
 993    /// </para>
 994    /// </remarks>
 995    internal static void EnsureCapabilitiesSatisfied(BpmnProcessDefinition definition, IReadOnlyList<BpmnWorkBinding> bi
 996    {
 187997        BpmnCapabilityRequirements.Analyze(definition).ThrowIfUnmet(available, definition.ProcessId);
 998
 184999        var ownedNestedProcesses = bindings
 1841000            .OfType<BpmnWorkBinding.NestedProcess>()
 3411001            .Where(nested => string.Equals(nested.ProcessId, definition.ProcessId, StringComparison.Ordinal));
 1002
 4791003        foreach (var nested in ownedNestedProcesses)
 561004            EnsureCapabilitiesSatisfied(nested.Definition, bindings, available);
 1831005    }
 1006}
 1007
 1008/// <summary>The outcome of <see cref="BpmnInterchangeDocumentService.ImportAsync"/>: the persisted definition, plus wha
 1009public sealed record BpmnDocumentImportResult(ImportWorkflowResult ImportResult, BpmnImportAnalysis Analysis);

Methods/Properties

.ctor(Bpmn.Interchange.BpmnXmlReader,Bpmn.Interchange.BpmnXmlWriter,Elsa.Bpmn.Interchange.Binding.BpmnWorkBinder,Elsa.Workflows.Management.IWorkflowDefinitionImporter,Elsa.Workflows.Management.IWorkflowDefinitionStore,Elsa.Workflows.Management.Mappers.VariableDefinitionMapper,Elsa.Workflows.IActivitySerializer,Elsa.Workflows.IIdentityGenerator,Elsa.Common.ISystemClock,Elsa.Mediator.Contracts.IMediator)
.cctor()
Analyze(System.String)
ImportAsync(System.String,System.String,System.String,System.String,System.Threading.CancellationToken)
ImportCoreAsync()
PersistDocumentEditAsync()
CloneForDraftSaving(Elsa.Workflows.Management.Entities.WorkflowDefinition)
CloneValue(T)
ApplyDocumentEdit(Elsa.Workflows.Management.Entities.WorkflowDefinition,Elsa.Bpmn.Activities.BpmnProcess,System.String,Bpmn.Model.BpmnProcessDefinition)
NewDraftFrom(Elsa.Workflows.Management.Entities.WorkflowDefinition)
Export(System.String)
Export(Elsa.Workflows.Management.Entities.WorkflowDefinition)
ReadDocument(Elsa.Workflows.Management.Entities.WorkflowDefinition)
ImportDocumentAsync()
ReadStoredSource(Elsa.Workflows.Management.Entities.WorkflowDefinition)
StoredNestedScopesStillDeclaredBy(Bpmn.Model.BpmnDefinitions,Bpmn.Interchange.BpmnImportResult)
KeepEverythingBoundInside()
HandOver()
WithoutRetainedLoopMarker(Bpmn.Interchange.BpmnWorkBinding/NestedProcess)
StoredCallOptionsStillApplicableTo(Bpmn.Model.BpmnDefinitions,Bpmn.Interchange.BpmnImportResult)
CalledElementOf(Bpmn.Model.BpmnElement)
ResolveSourceXml(Elsa.Workflows.Management.Entities.WorkflowDefinition)
ResolveRootDefinition(Bpmn.Model.BpmnDefinitions,System.String)
EnsureElementIdsUnique(System.Collections.Generic.IEnumerable`1<Bpmn.Model.BpmnProcessDefinition>,System.Collections.Generic.IReadOnlyList`1<Bpmn.Interchange.BpmnWorkBinding>)
EnsureCapabilitiesSatisfied(Bpmn.Model.BpmnProcessDefinition,System.Collections.Generic.IReadOnlyList`1<Bpmn.Interchange.BpmnWorkBinding>)
EnsureCapabilitiesSatisfied(Bpmn.Model.BpmnProcessDefinition,System.Collections.Generic.IReadOnlyList`1<Bpmn.Interchange.BpmnWorkBinding>,Bpmn.Semantics.BpmnHostCapabilities)