cold_storage 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :cold_storage do
4
+ desc 'List the archivable models, their rule, pending/archived counts and last run'
5
+ task status: :environment do
6
+ puts ColdStorage.report
7
+ end
8
+
9
+ namespace :db do
10
+ # ActiveRecord's database tasks leave ActiveRecord::Base connected to the
11
+ # database they just touched, which would send the rest of the process to
12
+ # the archive. Put the primary connection back.
13
+ def with_primary_connection_restored
14
+ yield
15
+ ensure
16
+ ActiveRecord::Base.establish_connection(Rails.env.to_sym)
17
+ end
18
+
19
+ desc 'Create the archive database'
20
+ task create: :environment do
21
+ config = ColdStorage::ArchiveRecord.database_config
22
+ abort('No archive database configured (see config/database.yml)') if config.nil?
23
+
24
+ with_primary_connection_restored { ActiveRecord::Tasks::DatabaseTasks.create(config) }
25
+ puts "Created #{config.database}"
26
+ end
27
+
28
+ desc 'Drop the archive database (asks for DISPOSABLE=1)'
29
+ task drop: :environment do
30
+ abort('Refusing to drop the archive database without DISPOSABLE=1') unless ENV['DISPOSABLE'] == '1'
31
+
32
+ config = ColdStorage::ArchiveRecord.database_config
33
+ abort('No archive database configured (see config/database.yml)') if config.nil?
34
+
35
+ with_primary_connection_restored { ActiveRecord::Tasks::DatabaseTasks.drop(config) }
36
+ puts "Dropped #{config.database}"
37
+ end
38
+ end
39
+
40
+ namespace :schema do
41
+ desc 'Copy/update the archive database schema for the archivable models'
42
+ task sync: :environment do
43
+ changes = ColdStorage.sync_schema!
44
+ puts changes.empty? ? 'Archive schema already up to date.' : "Applied #{changes.size} change(s)."
45
+ end
46
+
47
+ desc 'Show what cold_storage:schema:sync would change'
48
+ task plan: :environment do
49
+ changes = ColdStorage.schema_drift
50
+ if changes.empty?
51
+ puts 'Archive schema is up to date.'
52
+ else
53
+ changes.each { |change| puts " #{change}" }
54
+ end
55
+ end
56
+
57
+ desc 'Exit non-zero when the archive schema drifted from the primary one (for CI)'
58
+ task check: :environment do
59
+ changes = ColdStorage.schema_drift.reject { |change| change.type == :extra_column }
60
+ next puts('Archive schema is up to date.') if changes.empty?
61
+
62
+ changes.each { |change| warn " #{change}" }
63
+ abort("Archive schema is #{changes.size} change(s) behind. Run rails cold_storage:schema:sync")
64
+ end
65
+ end
66
+
67
+ desc 'Archive one model: rake cold_storage:archive[Payroll]'
68
+ task :archive, %i[model] => :environment do |_task, args|
69
+ abort('Usage: rake cold_storage:archive[ModelName]') if args[:model].blank?
70
+
71
+ result = ColdStorage.archive(
72
+ args[:model].constantize,
73
+ force: ENV['FORCE'] != 'false',
74
+ dry_run: ENV['DRY_RUN'] == 'true',
75
+ limit: ENV['LIMIT']&.to_i
76
+ )
77
+ puts result
78
+ end
79
+
80
+ desc 'Archive every archivable model whose schedule is due'
81
+ task archive_all: :environment do
82
+ results = ColdStorage.archive_all(
83
+ force: ENV['FORCE'] == 'true',
84
+ dry_run: ENV['DRY_RUN'] == 'true'
85
+ )
86
+ results.each { |result| puts result }
87
+ end
88
+
89
+ desc 'Restore archived rows: rake cold_storage:restore[Payroll,1 2 3] (WITH=all|assoc,assoc)'
90
+ task :restore, %i[model ids] => :environment do |_task, args|
91
+ abort('Usage: rake cold_storage:restore[ModelName,"1 2 3"]') if args[:model].blank? || args[:ids].blank?
92
+
93
+ with = case ENV['WITH']
94
+ when nil, '' then nil
95
+ when 'all' then :all
96
+ else ENV['WITH'].split(',').map { |name| name.strip.to_sym }
97
+ end
98
+
99
+ count = ColdStorage.restore(args[:model].constantize, args[:ids].split.map(&:strip), with: with)
100
+ puts "Restored #{count} row(s)."
101
+ end
102
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ColdStorage
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+ require 'active_support'
5
+ require 'active_support/core_ext/numeric/time'
6
+ require 'active_support/core_ext/string/inflections'
7
+ require 'active_support/core_ext/object/blank'
8
+ require 'active_support/core_ext/time'
9
+ require 'active_support/concern'
10
+
11
+ require 'cold_storage/version'
12
+ require 'cold_storage/configuration'
13
+ require 'cold_storage/logging'
14
+ require 'cold_storage/row_reader'
15
+ require 'cold_storage/association_tree'
16
+ require 'cold_storage/registry'
17
+ require 'cold_storage/policy'
18
+ require 'cold_storage/archive_record'
19
+ require 'cold_storage/archive_model'
20
+ require 'cold_storage/internal_schema'
21
+ require 'cold_storage/run'
22
+ require 'cold_storage/metadata'
23
+ require 'cold_storage/archivable'
24
+ require 'cold_storage/model'
25
+ require 'cold_storage/schema_mirror'
26
+ require 'cold_storage/archiver'
27
+ require 'cold_storage/restorer'
28
+ require 'cold_storage/report'
29
+ require 'cold_storage/jobs'
30
+ require 'cold_storage/railtie' if defined?(Rails::Railtie)
31
+
32
+ # Moves rows that are past their retention window (or soft-deleted) out of the
33
+ # primary database and into a mirrored archive database.
34
+ #
35
+ # class Payroll < ApplicationRecord
36
+ # archivable after: 18.months, every: 1.month
37
+ # end
38
+ #
39
+ # See README.md for the full option list.
40
+ module ColdStorage
41
+ # Base class for every error raised by the gem.
42
+ Error = Class.new(StandardError)
43
+ # Raised when the archive database is missing or misconfigured.
44
+ ConfigurationError = Class.new(Error)
45
+ # Raised when a model was never declared `archivable`.
46
+ NotArchivableError = Class.new(Error)
47
+ # Raised when the archive database has no table for a model yet.
48
+ SchemaMissingError = Class.new(Error)
49
+ # Raised when an `archivable` declaration is not usable.
50
+ InvalidPolicyError = Class.new(ArgumentError)
51
+
52
+ class << self
53
+ # @return [ColdStorage::Configuration]
54
+ def config
55
+ @config ||= Configuration.new
56
+ end
57
+
58
+ # @yieldparam config [ColdStorage::Configuration]
59
+ def configure
60
+ yield config
61
+ config
62
+ end
63
+
64
+ # Every model that called `archivable`, in declaration-independent order.
65
+ #
66
+ # @param eager_load [Boolean] load the app's classes first so models that
67
+ # were never referenced in this process are still discovered.
68
+ # @return [Array<Class>]
69
+ def models(eager_load: true)
70
+ Registry.models(eager_load: eager_load)
71
+ end
72
+
73
+ # @return [Logger]
74
+ def logger
75
+ config.logger ||= Logger.new($stdout)
76
+ end
77
+
78
+ # Archives one model according to its policy.
79
+ #
80
+ # @return [ColdStorage::Archiver::Result]
81
+ def archive(model, **options)
82
+ Archiver.new(model, **options).call
83
+ end
84
+
85
+ # Archives every registered model. Models whose `every:` window has not
86
+ # elapsed are skipped unless `force: true`.
87
+ #
88
+ # @return [Array<ColdStorage::Archiver::Result>]
89
+ def archive_all(**options)
90
+ models.map { |model| archive(model, **options) }
91
+ end
92
+
93
+ # Moves rows back from the archive database into the primary one.
94
+ #
95
+ # ColdStorage.restore(Invoice, [1, 2])
96
+ # ColdStorage.restore(Invoice, [1, 2], with: :all)
97
+ # ColdStorage.restore(Invoice, Invoice.archived.where(year: 2019),
98
+ # with: [:invoice_lines])
99
+ #
100
+ # @param ids [Array, ActiveRecord::Relation]
101
+ # @return [Integer] number of restored rows, children included
102
+ def restore(model, ids, **options)
103
+ Restorer.new(model, **options).call(ids)
104
+ end
105
+
106
+ # The archive-database counterpart of a model, for models that are not
107
+ # archivable themselves (a cascade child, say).
108
+ #
109
+ # @return [Class]
110
+ def archived_model(model)
111
+ ArchiveModel.for(model)
112
+ end
113
+
114
+ # Creates/updates the archive database tables for the archivable models.
115
+ #
116
+ # @return [Array<ColdStorage::SchemaMirror::Change>]
117
+ def sync_schema!(**options)
118
+ SchemaMirror.new(**options).sync!
119
+ end
120
+
121
+ # The changes `sync_schema!` would apply. Empty means the archive database
122
+ # is up to date.
123
+ #
124
+ # @return [Array<ColdStorage::SchemaMirror::Change>]
125
+ def schema_drift(**options)
126
+ SchemaMirror.new(**options).plan
127
+ end
128
+
129
+ # @return [String] human readable status of every archivable model
130
+ def report(**options)
131
+ Report.new(**options).to_s
132
+ end
133
+
134
+ # @api private
135
+ def reset_config!
136
+ @config = Configuration.new
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators/base'
4
+
5
+ module ColdStorage
6
+ module Generators
7
+ # rails generate cold_storage:install
8
+ class InstallGenerator < Rails::Generators::Base
9
+ source_root File.expand_path('templates', __dir__)
10
+
11
+ desc 'Creates the ColdStorage initializer and prints the database.yml snippet.'
12
+
13
+ def create_initializer
14
+ template 'initializer.rb.tt', 'config/initializers/cold_storage.rb'
15
+ end
16
+
17
+ def show_database_instructions
18
+ say <<~MESSAGE
19
+
20
+ Add the archive database to config/database.yml, in every environment
21
+ that should archive (note `database_tasks: false`, it keeps `rails
22
+ db:migrate` from applying your migrations to the archive database):
23
+
24
+ #{ColdStorage.config.archive_database}:
25
+ <<: *default
26
+ database: <%= ENV.fetch('ARCHIVE_POSTGRES_DB') %>
27
+ database_tasks: false
28
+
29
+ Then:
30
+
31
+ rails cold_storage:db:create
32
+ rails cold_storage:schema:sync
33
+ rails cold_storage:status
34
+
35
+ MESSAGE
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ ColdStorage.configure do |config|
4
+ # Master switch. The test environment has no archive database, and specs
5
+ # destroy records all the time.
6
+ config.enabled = !Rails.env.test?
7
+
8
+ # database.yml entry holding the archive database.
9
+ config.archive_database = :archive
10
+
11
+ # Rows moved per round trip.
12
+ config.batch_size = 1_000
13
+
14
+ # Defaults used by `archivable` when a model does not say otherwise.
15
+ config.timestamp_column = :created_at
16
+ config.deleted_column = :deleted_at
17
+
18
+ # Extra column stamped on every archived row (set to nil to disable).
19
+ config.archived_at_column = :archived_at
20
+
21
+ # Delete the rows from the primary database once they are safely copied.
22
+ config.delete_after_archive = true
23
+
24
+ # Mirror the schema right after db:migrate / db:schema:load.
25
+ config.sync_schema_after_migrate = Rails.env.local?
26
+
27
+ # :warn, :raise, :change or :ignore when a column type differs.
28
+ config.on_type_mismatch = :warn
29
+
30
+ # `on_destroy: true` models: :raise blocks the delete when the archive cannot
31
+ # be reached, :log lets it through.
32
+ config.on_destroy_error = :raise
33
+
34
+ # Queue used by ColdStorage::ArchiveAllJob / ArchiveModelJob.
35
+ config.job_queue = :default
36
+ end
metadata ADDED
@@ -0,0 +1,105 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cold_storage
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Afshin Amini
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-12 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '7.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '7.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '7.1'
41
+ description: |
42
+ Add `archivable` to any model to move old or soft-deleted rows into a separate
43
+ archive database. The archive database schema is mirrored from the primary one
44
+ and kept in sync on every migration, for the archivable models only.
45
+ email:
46
+ - afshmini@gmail.com
47
+ executables: []
48
+ extensions: []
49
+ extra_rdoc_files: []
50
+ files:
51
+ - CHANGELOG.md
52
+ - LICENSE.txt
53
+ - README.md
54
+ - lib/cold_storage.rb
55
+ - lib/cold_storage/archivable.rb
56
+ - lib/cold_storage/archive_model.rb
57
+ - lib/cold_storage/archive_record.rb
58
+ - lib/cold_storage/archiver.rb
59
+ - lib/cold_storage/association_tree.rb
60
+ - lib/cold_storage/configuration.rb
61
+ - lib/cold_storage/internal_schema.rb
62
+ - lib/cold_storage/jobs.rb
63
+ - lib/cold_storage/logging.rb
64
+ - lib/cold_storage/metadata.rb
65
+ - lib/cold_storage/model.rb
66
+ - lib/cold_storage/policy.rb
67
+ - lib/cold_storage/railtie.rb
68
+ - lib/cold_storage/registry.rb
69
+ - lib/cold_storage/report.rb
70
+ - lib/cold_storage/restorer.rb
71
+ - lib/cold_storage/row_reader.rb
72
+ - lib/cold_storage/run.rb
73
+ - lib/cold_storage/schema_mirror.rb
74
+ - lib/cold_storage/tasks/cold_storage.rake
75
+ - lib/cold_storage/version.rb
76
+ - lib/generators/cold_storage/install/install_generator.rb
77
+ - lib/generators/cold_storage/install/templates/initializer.rb.tt
78
+ homepage: https://github.com/afshmini/cold_storage
79
+ licenses:
80
+ - MIT
81
+ metadata:
82
+ source_code_uri: https://github.com/afshmini/cold_storage
83
+ changelog_uri: https://github.com/afshmini/cold_storage/blob/main/CHANGELOG.md
84
+ bug_tracker_uri: https://github.com/afshmini/cold_storage/issues
85
+ rubygems_mfa_required: 'true'
86
+ post_install_message:
87
+ rdoc_options: []
88
+ require_paths:
89
+ - lib
90
+ required_ruby_version: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: '3.1'
95
+ required_rubygems_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '0'
100
+ requirements: []
101
+ rubygems_version: 3.0.3.1
102
+ signing_key:
103
+ specification_version: 4
104
+ summary: Declarative auto-archiving of ActiveRecord rows into a mirrored archive database.
105
+ test_files: []