{"record":{"id":"7f440beae37d0737","repo":"antiwork/gumroad","slug":"this-save-would-remove-versions-that-still-have-co","errorCode":null,"errorMessage":"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.","messagePattern":"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\\.","errorType":"validation","errorClass":"Link::LinkInvalid","httpStatus":null,"severity":"error","filePath":"app/services/product/variant_category_updater_service.rb","lineNumber":97,"sourceCode":"  # treat every missing variant as \"removed\" and soft-delete the seller's\n  # entire version tree. Truly blank rows (no content, no purchases, all\n  # defaults) stay freely deletable so ordinary create-and-discard editor\n  # flows keep working without extra confirmations.\n  def self.ensure_deletion_intent!(product:, variants:, confirmed_removed_variant_ids:, diagnostics: {})\n    unconfirmed = variants.reject do |variant|\n      confirmed_removed_variant_ids.include?(variant.external_id) || !variant_requires_deletion_intent?(variant)\n    end\n    return if unconfirmed.empty?\n\n    ErrorNotifier.notify(\n      \"Blocked product save that would delete configured, purchased, or content-bearing variants without confirmation\",\n      product_id: product.id,\n      variant_ids: unconfirmed.map(&:id),\n      **diagnostics\n    )\n    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.\"\n    product.errors.add(:base, message)\n    raise Link::LinkInvalid, message\n  end\n\n  def self.variant_requires_deletion_intent?(variant)\n    variant_has_content?(variant) ||\n      variant_has_purchases?(variant) ||\n      variant_has_non_default_configuration?(variant)\n  end\n\n  def self.variant_has_content?(variant)\n    # has_editor_content? (not description.present?) so a variant whose only\n    # page is the editor's blank placeholder paragraph stays freely deletable.\n    variant.alive_rich_contents.any?(&:has_editor_content?) || variant.has_files?\n  end\n\n  # Any successful purchase means buyers rely on this variant existing (their\n  # library and receipts reference it), so deleting it must be an explicit\n  # seller decision. This closes the gap where\n  # VariantCategory#has_alive_grouping_variants_with_purchases? only shielded","sourceCodeStart":79,"sourceCodeEnd":115,"githubUrl":"https://github.com/antiwork/gumroad/blob/afeacbd394069a1cbf0c6c50ee8e900925050370/app/services/product/variant_category_updater_service.rb#L79-L115","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Refresh the product editor page and re-apply the changes on the fresh version list","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","Check the ErrorNotifier report — it carries product_id, the triggering variant_ids, and deletion_guard diagnostics to identify exactly which versions were unconfirmed","If you build the payload programmatically, always re-fetch the current variant list right before saving instead of reusing a cached snapshot"],"exampleFix":"# before\n# payload built from a stale snapshot; server has 3 configured variants\nVariantCategoryUpdaterService.new(product:, category_params: stale_params).process\n# => Link::LinkInvalid: This save would remove versions...\n\n# after\nproduct.reload # pick up versions changed in another session\n# rebuild params from the fresh list; explicitly pass confirmations for intended removals\nVariantCategoryUpdaterService.new(product:, category_params: fresh_params,\n  confirmed_removed_variant_ids: %w[vol_abc]).process","handlingStrategy":"try-catch","validationCode":"# Pre-flight the same predicate the guard uses\nfresh_variants = product.reload.variants.alive_too.map { |v| v } # re-read current state\nunconfirmed = fresh_variants.reject do |v|\n  confirmed_ids.include?(v.external_id) ||\n    !VariantCategoryUpdaterService.variant_requires_deletion_intent?(v)\nend\nraise 'refresh required' if unconfirmed.any?","typeGuard":null,"tryCatchPattern":"begin\n  VariantCategoryUpdaterService.new(product:, category_params:).process\nrescue Link::LinkInvalid => e\n  if product.errors[:base].any? { _1.include?('version list shown may be out of date') }\n    product.reload # re-fetch variants, rebuild payload, retry ONCE with fresh state\n  else\n    raise # a different validation failure — do not blind-retry\n  end\nend","preventionTips":["Always re-fetch the product and its variants immediately before building the save payload — never reuse a cached snapshot","Send confirmed_removed_variant_ids for every intentional removal of a configured/purchased/content-bearing version","Treat ErrorNotifier reports of 'Blocked product save that would delete...' as a client bug until proven otherwise","The guard fires per-save, not per-session: two editor tabs will keep clobbering each other until one is closed"],"tags":["ruby","rails","concurrency","product-editor","data-loss-guard"],"backgroundTag":"optimistic-concurrency-conflict","analyzedSha":"afeacbd394069a1cbf0c6c50ee8e900925050370","analyzedAt":"2026-08-21T17:58:52.159Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}