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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2d7a93fbe18555d8eafe6a0720722476a369eea45bf86d81e7c43490c8a33456
4
+ data.tar.gz: 33399d9c88a64d53b7fff48a7ab75881454b2e77248be4247ad7e488b9f5ce12
5
+ SHA512:
6
+ metadata.gz: 7147ede6d8a60b043f093f0ce1ab8000828f2eae5583d9a02d7efb7dd7588c88bdbf04d87a88e82faaedc004bf8df93f7c4ef22010cc1f31d101c258fb2d148c
7
+ data.tar.gz: 4b5954d3ad66ef1c48e2ad3d6db848633076310399df4fd49049f50860ea918f03b7782765926e6181f622a043fa29751c127b21f49807784eb121d15cd5cf26
data/CHANGELOG.md ADDED
@@ -0,0 +1,35 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ * `archivable` macro: `after:`, `on:`, `deleted:`, `deleted_column:`,
6
+ `on_destroy:`, `every:`, `scope:`, `cascade:`, `batch_size:`,
7
+ `delete_after_archive:`, `delete_method:`.
8
+ * `on_destroy: true` keeps hard-deleted rows by copying them to the archive in
9
+ a `before_destroy` hook, together with the policy's cascade and without
10
+ deleting anything itself; `config.on_destroy_error` decides whether an
11
+ unreachable archive blocks the delete.
12
+ * Association scopes that the archive class cannot evaluate degrade to the
13
+ unfiltered relation instead of raising on read.
14
+ * Unique indexes are mirrored as plain indexes; the primary key is the only
15
+ uniqueness the archive enforces.
16
+ * `config.enabled` master switch, so an app can stand the gem down per
17
+ environment (the test one, typically).
18
+ * `cascade:` accepts a nested tree (`cascade: { lines: [:taxes] }`) so a whole
19
+ object graph can be taken along, validated before anything moves.
20
+ * Separate archive database, connected lazily through `ColdStorage::ArchiveRecord`.
21
+ * Schema mirroring for the archivable models only, including PostgreSQL enum
22
+ types, arrays and indexes; automatic sync after `db:migrate`, and a
23
+ `cold_storage:schema:check` task for CI.
24
+ * Batched, idempotent archiving with cascade support, dry runs and limits.
25
+ * Reading: `Model.archived`, `archived_count`, `find_archived`,
26
+ `find_with_archived`, and archive models that mirror the source
27
+ associations (`has_many`, `has_one`, `belongs_to`, `:through`) so an archived
28
+ row can be read together with its archived children.
29
+ * Restore back into the primary database, on its own or with relations:
30
+ `with: :all`, `with: [:lines]`, `with: { lines: [:taxes] }`, from
31
+ `Model.restore_archived` or from an archived record's `restore!`.
32
+ * Run bookkeeping in the archive database, which is what makes `every:` work
33
+ across processes.
34
+ * `ColdStorage::ArchiveAllJob` / `ArchiveModelJob` and the
35
+ `cold_storage:*` rake tasks.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Afshin Amini
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,399 @@
1
+ # Cold Storage
2
+
3
+ Declarative auto-archiving for ActiveRecord.
4
+
5
+ Add one line to a model and its old (or soft-deleted) rows move, on a schedule,
6
+ into a **separate archive database** whose schema is a mirror of the primary
7
+ one — created for you, and kept up to date on every migration, for the
8
+ archivable models only.
9
+
10
+ ```ruby
11
+ class Payroll < ApplicationRecord
12
+ archivable after: 18.months, every: 1.month
13
+ end
14
+
15
+ class Document < ApplicationRecord
16
+ archivable deleted: true, after: 30.days # soft-deleted 30+ days ago
17
+ end
18
+
19
+ class Receipt < ApplicationRecord
20
+ archivable on_destroy: true # keep hard-deleted rows
21
+ end
22
+
23
+ class Invoice < ApplicationRecord
24
+ archivable after: 2.years, cascade: [:invoice_lines]
25
+ end
26
+ ```
27
+
28
+ ## Installation
29
+
30
+ ```ruby
31
+ # Gemfile
32
+ gem 'cold_storage', git: 'git@github.com:afshmini/cold_storage.git'
33
+ # or, while working on it:
34
+ gem 'cold_storage', path: '../cold_storage'
35
+ ```
36
+
37
+ ```bash
38
+ rails generate cold_storage:install
39
+ ```
40
+
41
+ ### 1. Add the archive database
42
+
43
+ `config/database.yml`, in every environment that should archive:
44
+
45
+ ```yaml
46
+ development:
47
+ primary:
48
+ <<: *default
49
+ archive:
50
+ <<: *default
51
+ database: <%= ENV.fetch('ARCHIVE_POSTGRES_DB') %>
52
+ database_tasks: false
53
+ ```
54
+
55
+ `database_tasks: false` matters: it stops `rails db:migrate` from running your
56
+ application migrations against the archive database. ColdStorage mirrors the
57
+ schema itself, and only for the tables it needs.
58
+
59
+ ### 2. Create it and mirror the schema
60
+
61
+ ```bash
62
+ rails cold_storage:db:create # CREATE DATABASE
63
+ rails cold_storage:schema:sync # copy the archivable models' tables
64
+ rails cold_storage:status
65
+ ```
66
+
67
+ ### 3. Schedule it
68
+
69
+ The bundled job walks every archivable model and lets each one decide whether
70
+ its `every:` window has elapsed, so a daily schedule is enough:
71
+
72
+ ```yaml
73
+ # config/recurring.yml (solid_queue)
74
+ cold_storage:
75
+ schedule: "0 2 * * *"
76
+ class: "ColdStorage::ArchiveAllJob"
77
+ ```
78
+
79
+ Or from cron/rake: `rails cold_storage:archive_all`.
80
+
81
+ ## The `archivable` options
82
+
83
+ | Option | Meaning | Default |
84
+ | --- | --- | --- |
85
+ | `after:` (alias `older_than:`) | archive rows older than this duration | — |
86
+ | `on:` | column the age is measured on | `:created_at`, or the deleted column when `deleted: true` |
87
+ | `deleted:` | archive soft-deleted rows only | `false` |
88
+ | `deleted_column:` | where the soft-delete timestamp lives | `config.deleted_column` (`:deleted_at`) |
89
+ | `on_destroy:` (alias `hard_delete:`) | copy a row to the archive whenever it is really destroyed | `false` |
90
+ | `every:` | minimum time between two runs of this model | run on every pass |
91
+ | `scope:` | symbol, proc or relation narrowing the selection | — |
92
+ | `cascade:` | `has_many` / `has_one` names archived with the parent, nestable | `[]` |
93
+ | `batch_size:` | rows per round trip | `config.batch_size` (1000) |
94
+ | `delete_after_archive:` | remove the source rows once copied | `true` |
95
+ | `delete_method:` | `:delete_all` or `:destroy_all` | `:delete_all` |
96
+
97
+ At least one of `after:`, `deleted:`, `scope:` or `on_destroy:` is required —
98
+ without a criterion the whole table would be archivable, which is never what
99
+ you meant.
100
+
101
+ ```ruby
102
+ archivable after: 12.months # by age
103
+ archivable after: 90.days, on: :closed_at # by another column
104
+ archivable deleted: true # soft-deleted rows
105
+ archivable deleted: true, after: 30.days # ... after a grace period
106
+ archivable on_destroy: true # hard deletes
107
+ archivable after: 5.years, on_destroy: true # both
108
+ archivable after: 5.years, every: 1.month # ... at most monthly
109
+ archivable after: 1.year, scope: -> { where(exported: true) }
110
+ archivable after: 2.years, cascade: [:items], batch_size: 5_000
111
+ archivable after: 2.years, cascade: [{ items: [:taxes, :notes] }, :comments]
112
+ ```
113
+
114
+ `cascade:` takes a name, an array, or a nested hash, as deep as the graph goes.
115
+ Children are archived and deleted before their parents, so foreign keys hold at
116
+ every step, and anything the cascade does not name is left behind — which for a
117
+ child with a `NOT NULL` foreign key means the parent's delete fails, loudly.
118
+ Association scopes are ignored on the way down: every row pointing at the
119
+ parent is taken, never a subset, so nothing is orphaned.
120
+
121
+ The selection always starts from `unscoped`, so a soft-delete `default_scope`
122
+ cannot hide the very rows you asked to archive.
123
+
124
+ ## Soft delete, hard delete, both
125
+
126
+ Three different things can make a row disappear, and each has its own switch:
127
+
128
+ * **it got old** — `after:` sweeps it on a schedule and then deletes it from
129
+ the primary database;
130
+ * **it was soft-deleted** — `deleted: true` picks up rows whose `deleted_at`
131
+ is set (optionally after a grace period with `after:`);
132
+ * **it is being hard-deleted right now** — `on_destroy: true` copies the row to
133
+ the archive in a `before_destroy` hook, so `destroy`, `destroy!` and
134
+ `destroy_all` keep a copy instead of losing one.
135
+
136
+ ```ruby
137
+ class Receipt < ApplicationRecord
138
+ archivable on_destroy: true # nothing swept, deletes kept
139
+ end
140
+
141
+ class Payroll < ApplicationRecord
142
+ archivable after: 18.months, on_destroy: true # aged out *and* deleted early
143
+ end
144
+ ```
145
+
146
+ `on_destroy:` on its own means "react to deletes", not "archive this table":
147
+ a scheduled run reports the model as skipped (`:destroy_only`) and
148
+ `archivable_records` is empty, so nothing is swept behind your back.
149
+
150
+ Worth knowing:
151
+
152
+ * `delete`, `delete_all` and database-level `ON DELETE CASCADE` do not run
153
+ callbacks, so they cannot be captured. Use `destroy`/`destroy_all`, or
154
+ `dependent: :destroy` on the parent, for rows you must keep.
155
+ * The parent's `cascade:` comes along, because `dependent: :destroy` children
156
+ are about to go too. Nothing is deleted by the hook itself: the destroy that
157
+ triggered it is what removes the rows, so `dependent:` keeps deciding what
158
+ happens to the children (and a rolled-back destroy loses nothing).
159
+ * If the archive database cannot be reached, the destroy fails
160
+ (`config.on_destroy_error = :raise`, the default): losing the row is worse
161
+ than failing the delete. Set it to `:log` to let deletes through and only
162
+ record the problem.
163
+ * The copy is written before the surrounding transaction commits, so a
164
+ rolled-back destroy can leave a copy in the archive. It is keyed by primary
165
+ key, so the row is still identifiable and a later real archive overwrites it.
166
+ * Each destroyed record is one round trip to the archive database; mass
167
+ cleanups are better served by the scheduled sweep.
168
+
169
+ ## What you get on the model
170
+
171
+ ```ruby
172
+ Payroll.archivable_records # relation of rows eligible right now
173
+ Payroll.archivable_count
174
+ Payroll.archive_now! # archive immediately, ignoring every:
175
+ payroll.archive! # a single record (with its cascade)
176
+ payroll.archived?
177
+
178
+ ColdStorage.archive(Payroll) # honours every:
179
+ ColdStorage.archive(Payroll, dry_run: true) # report, move nothing
180
+ ColdStorage.archive_all
181
+ ```
182
+
183
+ ## Reading archived data
184
+
185
+ ```ruby
186
+ Payroll.archived # relation on the archive database
187
+ Payroll.archived.where(year: 2019).order(:id).pluck(:total)
188
+ Payroll.archived_count
189
+ Payroll.find_archived(42) # RecordNotFound if it is not archived
190
+ Payroll.find_with_archived(42) # live record, or the archived one
191
+ ```
192
+
193
+ `archived` is an ordinary relation on a class connected to the archive
194
+ database, so scopes, `where`, `pluck`, `find_each` and `includes` all work.
195
+ **The model's associations are mirrored onto it**, pointing at the archive
196
+ database, so an archived row can be read together with its archived children:
197
+
198
+ ```ruby
199
+ invoice = Invoice.archived.find(42)
200
+ invoice.invoice_lines # archived lines, from the archive DB
201
+ invoice.taxes # has_many :through works too
202
+ Invoice.archived.includes(:invoice_lines).find_each { |i| ... }
203
+
204
+ line = ColdStorage.archived_model(InvoiceLine).find(7)
205
+ line.invoice # ... and back up
206
+ line.source_model # => InvoiceLine
207
+ ```
208
+
209
+ Association scopes carry over when the archive class can evaluate them
210
+ (`-> { where(active: true) }`). One that reaches for something only the source
211
+ model has (`-> { sorted('start_time', 'asc') }`, a scope on the child) falls
212
+ back to the unfiltered relation instead of raising, so a read never dies on a
213
+ scope. Turn on debug logging to see which ones were ignored.
214
+
215
+ Mirrored: `has_many`, `has_one`, `belongs_to` and `:through`. Not mirrored:
216
+ polymorphic `belongs_to`, and `:through` with a `source_type:` — both would
217
+ have to resolve a class name back into the primary database, and reading would
218
+ silently cross into it. `dependent:`, counter caches and `touch` are dropped on
219
+ purpose: the archive is a reading surface and must not cascade anything.
220
+
221
+ Joins between the two databases are not possible — they are separate
222
+ connections.
223
+
224
+ ## Recovering
225
+
226
+ ```ruby
227
+ ColdStorage.restore(Payroll, [1, 2, 3])
228
+ Payroll.restore_archived([1, 2, 3]) # same thing
229
+ Payroll.restore_archived(Payroll.archived.where(year: 2019))
230
+ Invoice.archived.find(42).restore! # from the row itself
231
+
232
+ # ... with its relations
233
+ Invoice.restore_archived([42], with: :all) # every archived child
234
+ Invoice.restore_archived([42], with: [:invoice_lines]) # named relations
235
+ Invoice.restore_archived([42], with: { invoice_lines: [:taxes] }) # nested
236
+ Invoice.archived.find(42).restore!(with: :all)
237
+
238
+ # keep the archived copy instead of moving the rows
239
+ Invoice.restore_archived([42], with: :all, delete_from_archive: false)
240
+ ```
241
+
242
+ Rows are written to the primary database parent-first, so foreign keys hold at
243
+ every step, and the archived copies are removed as they land. The return value
244
+ is the number of restored rows, children included.
245
+
246
+ `with: :all` walks every `has_many`/`has_one` that has an archive table,
247
+ recursively, cutting cycles as it goes. Named associations are checked before
248
+ anything is written, so a typo cannot leave half a graph behind. A `belongs_to`
249
+ is refused: restore the owner first, then its children — restoring a child
250
+ whose parent is still archived would fail on the foreign key.
251
+
252
+ ```
253
+ rails cold_storage:restore[Invoice,"42 43"] WITH=all
254
+ rails cold_storage:restore[Invoice,"42"] WITH=invoice_lines,taxes
255
+ ```
256
+
257
+ ## Rake tasks
258
+
259
+ ```
260
+ rails cold_storage:status # models, rules, pending/archived counts, last run
261
+ rails cold_storage:db:create # create the archive database
262
+ rails cold_storage:schema:sync # create/update the mirrored tables
263
+ rails cold_storage:schema:plan # what sync would change
264
+ rails cold_storage:schema:check # exit 1 on drift (for CI)
265
+ rails cold_storage:archive[Payroll] # one model, now
266
+ rails cold_storage:archive_all # every model whose every: elapsed
267
+ rails cold_storage:restore[Payroll,"1 2"] # move rows back (WITH=all)
268
+ ```
269
+
270
+ `DRY_RUN=true`, `LIMIT=1000` and `FORCE=true` are honoured by the archive tasks.
271
+
272
+ ## Schema mirroring
273
+
274
+ `cold_storage:schema:sync` walks the archivable models (plus the tables they
275
+ cascade into) and, in the archive database:
276
+
277
+ * creates missing tables, column by column, with the same SQL types —
278
+ including PostgreSQL enum types, arrays and `jsonb`;
279
+ * keeps the primary key, so re-archiving a row updates it instead of
280
+ duplicating it;
281
+ * mirrors indexes, **dropping their uniqueness** — an archive accumulates
282
+ history, and a natural key that is unique in the primary database at any one
283
+ moment is not unique across everything that table ever held;
284
+ * adds an `archived_at` column (configurable, `nil` disables it);
285
+ * adds columns that later migrations introduced;
286
+ * records the migration version it synced against.
287
+
288
+ and deliberately does **not**:
289
+
290
+ * copy foreign keys — an archive holds partial object graphs;
291
+ * copy `NOT NULL` or defaults — a schema that gets stricter later must not
292
+ reject rows that were archived before that (`mirror_null_constraints`,
293
+ `mirror_defaults` if you disagree);
294
+ * drop columns the primary database dropped — archived rows keep the columns
295
+ they were archived with (`drop_removed_columns` if you disagree).
296
+
297
+ It runs automatically after `db:migrate`, `db:rollback` and `db:schema:load`
298
+ (`config.sync_schema_after_migrate`). In CI, `cold_storage:schema:check`
299
+ fails the build when a migration changed an archivable table and the archive
300
+ database was not brought along.
301
+
302
+ Column type changes are reported, not applied, unless you ask:
303
+ `config.on_type_mismatch = :warn | :raise | :change | :ignore`.
304
+
305
+ ## How a row moves
306
+
307
+ Two databases cannot share a transaction, so each batch is:
308
+
309
+ 1. read from the primary database, cast by the **database** column types (model
310
+ level serializers, enums and default scopes are bypassed on both sides, so
311
+ what lands in the archive is byte-for-byte what was in the source column);
312
+ 2. `upsert`ed into the archive database, keyed on the primary key — a retry can
313
+ never duplicate a row;
314
+ 3. deleted from the primary database.
315
+
316
+ A crash between 2 and 3 leaves the row in both databases; the next run upserts
317
+ it again and deletes it. Archiving is at-least-once, and never loses a row.
318
+
319
+ With `cascade:`, children are archived and deleted **before** their parent, so
320
+ foreign keys in the primary database hold at every step.
321
+
322
+ `delete_method: :delete_all` (the default) skips callbacks by design: `after_destroy`
323
+ hooks that notify, bill or cascade should not fire because a row aged out. Use
324
+ `:destroy_all` when you do want them.
325
+
326
+ ## Scheduling and `every:`
327
+
328
+ Every successful run is recorded in `cold_storage_runs` **in the archive
329
+ database**, and `every:` is checked against it. This means the cadence survives
330
+ restarts and deploys, and two workers running `archive_all` on the same day do
331
+ not archive twice.
332
+
333
+ `ColdStorage::ArchiveAllJob` enqueues one `ArchiveModelJob` per model, so a
334
+ big table cannot starve the others.
335
+
336
+ ## Configuration
337
+
338
+ ```ruby
339
+ ColdStorage.configure do |config|
340
+ config.enabled = !Rails.env.test?
341
+ config.archive_database = :archive
342
+ config.batch_size = 1_000
343
+ config.timestamp_column = :created_at
344
+ config.deleted_column = :deleted_at
345
+ config.archived_at_column = :archived_at # nil to disable
346
+ config.delete_after_archive = true
347
+ config.delete_method = :delete_all
348
+ config.mirror_indexes = true
349
+ config.mirror_null_constraints = false
350
+ config.mirror_defaults = false
351
+ config.drop_removed_columns = false
352
+ config.on_type_mismatch = :warn
353
+ config.on_destroy_error = :raise # or :log
354
+ config.sync_schema_after_migrate = true
355
+ config.throttle = 0 # seconds between batches
356
+ config.job_queue = :default
357
+ config.job_parent_class = 'ApplicationJob'
358
+ config.dry_run = false
359
+ end
360
+ ```
361
+
362
+ `config.enabled = false` stands everything down: scheduled runs report
363
+ themselves as skipped and the `on_destroy` hook does nothing. That is usually
364
+ what you want in the test environment, where there is no archive database and
365
+ specs destroy records all the time. Schema tasks keep working either way, so
366
+ `cold_storage:schema:check` still guards CI.
367
+
368
+ ## Requirements and limits
369
+
370
+ * Rails 7.1+, Ruby 3.1+. Developed and tested against PostgreSQL; the enum
371
+ mirroring is PostgreSQL specific and simply does nothing elsewhere.
372
+ * Models need a single-column primary key.
373
+ * Custom PostgreSQL types other than enums (domains, composites) are not
374
+ mirrored.
375
+ * Views, functions and triggers are not mirrored.
376
+ * `cascade:` supports `has_many`/`has_one`, including polymorphic children;
377
+ `:through` associations are rejected.
378
+
379
+ ## Tests
380
+
381
+ The suite runs against two real PostgreSQL databases, because enums, arrays and
382
+ `jsonb` are exactly what a schema mirror gets wrong.
383
+
384
+ ```bash
385
+ bundle install
386
+ bundle exec rspec
387
+ ```
388
+
389
+ It reads `DB_HOST`, `DB_PORT`, `POSTGRES_USER` and `POSTGRES_PASSWORD`, and
390
+ creates `cold_storage_source_test` / `cold_storage_archive_test`.
391
+
392
+ To run it against the PostgreSQL of an app that already has a container, copy
393
+ the gem in and borrow that app's bundle:
394
+
395
+ ```bash
396
+ docker cp . <app-container>:/tmp/ra
397
+ docker exec -w /tmp/ra <app-container> \
398
+ bash -lc 'BUNDLE_GEMFILE=/path/to/app/Gemfile bundle exec rspec'
399
+ ```
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ColdStorage
4
+ # Mixed into a model the first time it declares `archivable`.
5
+ module Archivable
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ class_attribute :archiving_policy, instance_accessor: false, default: nil
10
+ class_attribute :archiving_destroy_hook, instance_accessor: false, default: false
11
+ end
12
+
13
+ class_methods do
14
+ def archivable?
15
+ archiving_policy.present?
16
+ end
17
+
18
+ # Copies a row to the archive just before it is really deleted.
19
+ # Installed by `archivable on_destroy: true`.
20
+ def install_archive_on_destroy!
21
+ return if archiving_destroy_hook
22
+
23
+ self.archiving_destroy_hook = true
24
+ # prepend: so the row is captured before dependent: callbacks start
25
+ # taking its children apart.
26
+ before_destroy(:archive_before_destroy, prepend: true)
27
+ end
28
+
29
+ # Rows that are eligible for archiving right now.
30
+ # @return [ActiveRecord::Relation]
31
+ def archivable_records
32
+ archiving_policy!.relation(self)
33
+ end
34
+
35
+ # How many rows are waiting to be archived.
36
+ def archivable_count
37
+ archivable_records.count
38
+ end
39
+
40
+ # Handle on the archived rows, living in the archive database. An
41
+ # ordinary relation: scopes, where, pluck, find_each all work, and the
42
+ # model's associations are mirrored, so archived children come along.
43
+ #
44
+ # Payroll.archived.where(company_id: 1).count
45
+ # Invoice.archived.find(42).invoice_lines
46
+ #
47
+ # @return [ActiveRecord::Relation]
48
+ def archived
49
+ archive_model.all
50
+ end
51
+
52
+ # How many rows of this model sit in the archive.
53
+ def archived_count
54
+ archived.count
55
+ end
56
+
57
+ # @return [ActiveRecord::Base] the archived row
58
+ # @raise [ActiveRecord::RecordNotFound]
59
+ def find_archived(*ids)
60
+ archive_model.find(*ids)
61
+ end
62
+
63
+ # Looks in the primary database first, then in the archive.
64
+ #
65
+ # @return [ActiveRecord::Base] a live record, or an archived one
66
+ # @raise [ActiveRecord::RecordNotFound] when it is in neither
67
+ def find_with_archived(id)
68
+ unscoped.find_by(primary_key => id) || find_archived(id)
69
+ end
70
+
71
+ # Moves archived rows back into this table.
72
+ #
73
+ # Payroll.restore_archived([1, 2, 3])
74
+ # Invoice.restore_archived(Invoice.archived.where(year: 2019), with: :all)
75
+ #
76
+ # @return [Integer] number of restored rows, children included
77
+ def restore_archived(ids, **options)
78
+ ColdStorage.restore(self, ids, **options)
79
+ end
80
+
81
+ # The ActiveRecord class mapped onto this table in the archive database.
82
+ # @return [Class]
83
+ def archive_model
84
+ ArchiveModel.for(self)
85
+ end
86
+
87
+ # Archives this model now, ignoring the `every:` window by default.
88
+ # @return [ColdStorage::Archiver::Result]
89
+ def archive_now!(**options)
90
+ Archiver.new(self, **{ force: true }.merge(options)).call
91
+ end
92
+
93
+ # @return [ColdStorage::Policy]
94
+ def archiving_policy!
95
+ archiving_policy || raise(NotArchivableError, "#{name} is not archivable")
96
+ end
97
+ end
98
+
99
+ # Archives this single record (and its cascaded children).
100
+ # @return [ColdStorage::Archiver::Result]
101
+ def archive!
102
+ relation = self.class.unscoped.where(self.class.primary_key => id)
103
+ Archiver.new(self.class, relation: relation, force: true).call
104
+ end
105
+
106
+ # Is there a row with this id in the archive database?
107
+ def archived?
108
+ self.class.archive_model.where(self.class.primary_key => id).exists?
109
+ end
110
+
111
+ private
112
+
113
+ # Keeps a copy of a hard-deleted row. The row is still readable here, so it
114
+ # is read (and archived) exactly like the scheduled run would.
115
+ #
116
+ # The policy's cascade comes along, because `dependent: :destroy` children
117
+ # are about to go too. Nothing is deleted here: the destroy that triggered
118
+ # this is what removes the rows, so `dependent:` keeps deciding what
119
+ # happens to the children.
120
+ #
121
+ # Raises by default: losing the row is worse than failing the delete. Set
122
+ # `config.on_destroy_error = :log` to let deletes through instead.
123
+ def archive_before_destroy
124
+ return unless ColdStorage.config.enabled
125
+ return unless self.class.archiving_policy&.on_destroy?
126
+ return if new_record? || id.nil?
127
+
128
+ Archiver.new(
129
+ self.class,
130
+ relation: self.class.unscoped.where(self.class.primary_key => id),
131
+ force: true,
132
+ track: false,
133
+ cascade: true,
134
+ delete_after_archive: false
135
+ ).call
136
+ rescue StandardError => e
137
+ raise if ColdStorage.config.on_destroy_error == :raise
138
+
139
+ ColdStorage.logger.error do
140
+ "#{Logging::PREFIX} could not archive #{self.class}##{id} before destroy: #{e.class}: #{e.message}"
141
+ end
142
+ end
143
+ end
144
+ end