antiwork/gumroad · error · Link::LinkInvalid

This save would remove versions that still have content, set

Error message

This save would remove versions that still have content, settings, or sales, which weren't explicitly removed in the editor. The version list shown may be out of date — please refresh the page and try again.

What it means

Product::VariantCategoryUpdaterService.ensure_deletion_intent! blocks any save whose payload would delete variants that 'matter' — ones with editor content or attached files, purchases, or non-default configuration (custom price, quantity cap, duration, PWYW, integrations, recurring prices, description) — unless each such variant's external_id appears in confirmed_removed_variant_ids. It exists because a payload built from stale or incomplete editor state would treat every missing variant as 'removed' and soft-delete the seller's version tree. It notifies ErrorNotifier, adds the message to product.errors, and raises Link::LinkInvalid.

Source

Thrown at app/services/product/variant_category_updater_service.rb:97

  # treat every missing variant as "removed" and soft-delete the seller's
  # entire version tree. Truly blank rows (no content, no purchases, all
  # defaults) stay freely deletable so ordinary create-and-discard editor
  # flows keep working without extra confirmations.
  def self.ensure_deletion_intent!(product:, variants:, confirmed_removed_variant_ids:, diagnostics: {})
    unconfirmed = variants.reject do |variant|
      confirmed_removed_variant_ids.include?(variant.external_id) || !variant_requires_deletion_intent?(variant)
    end
    return if unconfirmed.empty?

    ErrorNotifier.notify(
      "Blocked product save that would delete configured, purchased, or content-bearing variants without confirmation",
      product_id: product.id,
      variant_ids: unconfirmed.map(&:id),
      **diagnostics
    )
    message = "This save would remove versions that still have content, settings, or sales, which weren't explicitly removed in the editor. The version list shown may be out of date — please refresh the page and try again."
    product.errors.add(:base, message)
    raise Link::LinkInvalid, message
  end

  def self.variant_requires_deletion_intent?(variant)
    variant_has_content?(variant) ||
      variant_has_purchases?(variant) ||
      variant_has_non_default_configuration?(variant)
  end

  def self.variant_has_content?(variant)
    # has_editor_content? (not description.present?) so a variant whose only
    # page is the editor's blank placeholder paragraph stays freely deletable.
    variant.alive_rich_contents.any?(&:has_editor_content?) || variant.has_files?
  end

  # Any successful purchase means buyers rely on this variant existing (their
  # library and receipts reference it), so deleting it must be an explicit
  # seller decision. This closes the gap where
  # VariantCategory#has_alive_grouping_variants_with_purchases? only shielded

View on GitHub (pinned to afeacbd394)

Solutions

  1. Refresh the product editor page and re-apply the changes on the fresh version list
  2. If the deletions are intended, remove those versions explicitly in the editor so each removal's confirmation id (confirmed_removed_variant_ids) rides along with the save
  3. Check the ErrorNotifier report — it carries product_id, the triggering variant_ids, and deletion_guard diagnostics to identify exactly which versions were unconfirmed
  4. If you build the payload programmatically, always re-fetch the current variant list right before saving instead of reusing a cached snapshot

Example fix

# before
# payload built from a stale snapshot; server has 3 configured variants
VariantCategoryUpdaterService.new(product:, category_params: stale_params).process
# => Link::LinkInvalid: This save would remove versions...

# after
product.reload # pick up versions changed in another session
# rebuild params from the fresh list; explicitly pass confirmations for intended removals
VariantCategoryUpdaterService.new(product:, category_params: fresh_params,
  confirmed_removed_variant_ids: %w[vol_abc]).process
Defensive patterns

Strategy: try-catch

Validate before calling

# Pre-flight the same predicate the guard uses
fresh_variants = product.reload.variants.alive_too.map { |v| v } # re-read current state
unconfirmed = fresh_variants.reject do |v|
  confirmed_ids.include?(v.external_id) ||
    !VariantCategoryUpdaterService.variant_requires_deletion_intent?(v)
end
raise 'refresh required' if unconfirmed.any?

Try / catch

begin
  VariantCategoryUpdaterService.new(product:, category_params:).process
rescue Link::LinkInvalid => e
  if product.errors[:base].any? { _1.include?('version list shown may be out of date') }
    product.reload # re-fetch variants, rebuild payload, retry ONCE with fresh state
  else
    raise # a different validation failure — do not blind-retry
  end
end

Prevention

When it happens

Trigger: The product editor tab was loaded hours ago and another tab/session changed the version list, so the save payload omits variants that still exist server-side; a client bug drops variants from the payload; a seller removes a purchased version but the confirmation ids are not sent along.

Common situations: Two editor tabs open at once; long-lived SPA state; scripts or third-party tools POSTing product saves built from a stale snapshot — precisely the RichContentDeletionGuard incident history the comment references.

Related errors


AI-assisted analysis of antiwork/gumroad@afeacbd394 (2026-08-21). Data as JSON: /api/errors/7f440beae37d0737. Report an issue: GitHub.