Phase 2: move the 8 catalog models into the WarpEngine engine

- Software, Release, ReleaseAsset, ExternalLink, PlatformLink, Image,
  SoftwareImage, Download now live in the engine under WarpEngine::, on top
  of WarpEngine::ApplicationRecord; table names unchanged (empty prefix)
- each model runs an ActiveSupport load hook (:warp_engine_<model>) as a
  host extension point
- WarpEngine::Image reads its upload path from WarpEngine.config
- host references fully qualified (services, serializers, admin, specs);
  admin registrations renamed with as: so /admin URLs and route helpers are
  byte-identical; factories pinned to the namespaced classes
- Member#image now class_name: "WarpEngine::Image"

Verified: full suite green, /api/software and /api/builds byte-identical to
the phase-0 baselines, admin routes unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 18:51:12 +02:00
co-authored by Claude Fable 5
parent 0b1b3955ef
commit 7abfb89bca
9 changed files with 248 additions and 0 deletions
@@ -0,0 +1,5 @@
module WarpEngine
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
end
end
+19
View File
@@ -0,0 +1,19 @@
module WarpEngine
class Download < ApplicationRecord
belongs_to :release, optional: true
validates :file_path, presence: true
default_scope { where(deleted_at: nil) }
def self.ransackable_attributes(auth_object = nil)
%w[created_at deleted_at file_path id ip_address referer release_id updated_at user_agent]
end
def self.ransackable_associations(auth_object = nil)
%w[release]
end
ActiveSupport.run_load_hooks(:warp_engine_download, self)
end
end
+22
View File
@@ -0,0 +1,22 @@
module WarpEngine
class ExternalLink < ApplicationRecord
self.table_name = "external_links"
belongs_to :software
validates :label, presence: true
validates :url, presence: true
default_scope { where(deleted_at: nil) }
def self.ransackable_attributes(auth_object = nil)
%w[created_at deleted_at id label software_id updated_at url]
end
def self.ransackable_associations(auth_object = nil)
%w[software]
end
ActiveSupport.run_load_hooks(:warp_engine_external_link, self)
end
end
+36
View File
@@ -0,0 +1,36 @@
module WarpEngine
class Image < ApplicationRecord
def self.upload_path
WarpEngine.config.image_container_path
end
has_many :software_images, dependent: :restrict_with_error
default_scope { where(deleted_at: nil) }
attr_accessor :file_upload
before_save :process_upload, if: -> { file_upload.present? }
def self.ransackable_attributes(auth_object = nil)
%w[content_type created_at deleted_at filename id original_filename updated_at]
end
def file_path
File.join(self.class.upload_path, filename.to_s)
end
ActiveSupport.run_load_hooks(:warp_engine_image, self)
private
def process_upload
FileUtils.mkdir_p(self.class.upload_path)
self.original_filename = file_upload.original_filename
self.content_type = file_upload.content_type.presence || "application/octet-stream"
ext = File.extname(file_upload.original_filename)
self.filename = "#{SecureRandom.uuid}#{ext}"
IO.copy_stream(file_upload.to_io, file_path)
end
end
end
+27
View File
@@ -0,0 +1,27 @@
module WarpEngine
class PlatformLink < ApplicationRecord
self.table_name = "platform_links"
SUPPORTED_PLATFORMS = %w[tic80 ebitengine love c64 godot bevy phaser].freeze
validates :name, presence: true
validates :url, presence: true
validates :platform, presence: true, inclusion: { in: SUPPORTED_PLATFORMS }
default_scope { where(deleted_at: nil).order(:position) }
def self.ransackable_attributes(auth_object = nil)
%w[created_at deleted_at id name platform position updated_at url]
end
def self.ransackable_associations(auth_object = nil)
[]
end
def self.for_platform(platform)
where(platform: platform).to_a
end
ActiveSupport.run_load_hooks(:warp_engine_platform_link, self)
end
end
+37
View File
@@ -0,0 +1,37 @@
module WarpEngine
class Release < ApplicationRecord
self.table_name = "releases"
belongs_to :software
has_many :downloads
has_many :release_assets
accepts_nested_attributes_for :release_assets, allow_destroy: true
validates :version, presence: true
validates :version, uniqueness: { scope: :software_id }
default_scope { where(deleted_at: nil) }
validate :c64_cannot_be_web_playable
def c64_cannot_be_web_playable
return unless software&.platform == "c64"
has_html = release_assets.reject(&:marked_for_destruction?).any? { |a| a.kind == "html" }
if has_html
errors.add(:base, "C64 releases cannot be web playable (download only)")
end
end
def self.ransackable_attributes(auth_object = nil)
%w[created_at deleted_at id software_id updated_at version]
end
def self.ransackable_associations(auth_object = nil)
%w[downloads release_assets software]
end
ActiveSupport.run_load_hooks(:warp_engine_release, self)
end
end
+26
View File
@@ -0,0 +1,26 @@
module WarpEngine
class ReleaseAsset < ApplicationRecord
KINDS = %w[cartridge source html docs
win_x86 win_x64 linux_x86 linux_x64
mac_x64 mac_arm64 mac_universal].freeze
belongs_to :release
enum :kind, KINDS.index_by(&:itself)
validates :path, presence: true
validates :kind, uniqueness: { scope: :release_id }
default_scope { where(deleted_at: nil) }
def self.ransackable_attributes(auth_object = nil)
%w[created_at deleted_at id kind path release_id updated_at]
end
def self.ransackable_associations(auth_object = nil)
%w[release]
end
ActiveSupport.run_load_hooks(:warp_engine_release_asset, self)
end
end
+31
View File
@@ -0,0 +1,31 @@
module WarpEngine
class Software < ApplicationRecord
self.table_name = "softwares"
has_many :software_images, foreign_key: :software_id, dependent: :destroy
has_many :images, through: :software_images
has_many :releases, foreign_key: :software_id
has_many :downloads, through: :releases
has_many :external_links, foreign_key: :software_id
accepts_nested_attributes_for :software_images, allow_destroy: true
accepts_nested_attributes_for :external_links, allow_destroy: true
accepts_nested_attributes_for :releases, allow_destroy: true
validates :name, presence: true, uniqueness: true
validates :title, presence: true
validates :platform, presence: true
default_scope { where(deleted_at: nil) }
def self.ransackable_attributes(auth_object = nil)
%w[author created_at desc highlighted id license name platform site status story title updated_at]
end
def self.ransackable_associations(auth_object = nil)
%w[releases external_links software_images images]
end
ActiveSupport.run_load_hooks(:warp_engine_software, self)
end
end
+45
View File
@@ -0,0 +1,45 @@
module WarpEngine
class SoftwareImage < ApplicationRecord
belongs_to :software
belongs_to :image, optional: true
attr_accessor :file_upload
before_validation :create_image_from_upload, if: -> { file_upload.present? }
validates :image_id, presence: true
validates :image_id, uniqueness: { scope: :software_id }
validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
before_save :unset_other_defaults, if: -> { is_default? && is_default_changed? }
default_scope { order(:position) }
def self.ransackable_attributes(auth_object = nil)
%w[created_at id image_id is_default position software_id updated_at]
end
def self.ransackable_associations(auth_object = nil)
%w[image software]
end
ActiveSupport.run_load_hooks(:warp_engine_software_image, self)
private
def create_image_from_upload
img = Image.new(file_upload: file_upload)
if img.save
self.image = img
else
errors.add(:file_upload, img.errors.full_messages.join(", "))
end
end
def unset_other_defaults
SoftwareImage.where(software_id: software_id, is_default: true)
.where.not(id: id)
.update_all(is_default: false)
end
end
end