My last release was v1.2.2 in May 2019. After that, Geremia Taglialatela took over and pushed it to v5.0.0 with Rails 8.1 and Ruby 4.0 support. 34 releases spanning 14 years, 201 stars, and still actively maintained. The API documentation and the repo are both alive.
Seven years ago I released ChronoModel v0.1.0 โ a Ruby gem that gives ActiveRecord models temporal capabilities on PostgreSQL. Five days of hacking, thirty-six commits, no tests, and a confession about monkey-patching the PostgreSQL adapter constant.
Today I’m tagging v1.0.0. The commit message is :gem: this is v1.0.0. Not much of a speech, but the code speaks for itself: 506 commits, 31 releases, 52 files changed, 5,392 lines added. The core idea โ updatable views on public, current data on temporal, history on history with table inheritance โ never changed. Everything else did.
What changed
Three things were wrong with v0.1.0, and I said so at the time. All three got fixed on the same day โ Valentine’s Day 2014, the v0.6.0 release. A complete rewrite of the database layer while keeping the Ruby API identical. The minimum PostgreSQL version jumped from 9.0 to 9.3. If v0.1.0 was “this works,” v0.6.0 was “this works correctly.”
Rules โ INSTEAD OF triggers
The original design used PostgreSQL rules to make the public views writable. Rules work, but they have sharp edges โ they rewrite queries at parse time, they can’t handle RETURNING clauses properly, and debugging them is a nightmare.
I ripped them all out and replaced them with INSTEAD OF triggers. Same behavior, cleaner execution model. Triggers fire at statement execution time, handle RETURNING naturally, and you can actually debug them. The commit message says “BREAKING CHANGE” โ because it was. Every temporal table needed a migration to switch over.
box()/point() โ tsrange
The original exclusion constraint was my proudest hack โ abusing GiST geometric indexes to prevent overlapping history entries by encoding time ranges as 2D boxes. It worked, but it was a hack. PostgreSQL 9.2 shipped proper range types, and by 9.3 they were solid.
Replaced the geometric hack with native tsrange columns. The constraint went from this:
-- v0.1.0: encode time as geometry, hope for the best
EXCLUDE USING gist (
box(
point( date_part('epoch', valid_from), id ),
point( date_part('epoch', valid_to - INTERVAL '1 msec'), id )
) WITH &&
)
to this:
-- v0.6.0: say what you mean
EXCLUDE USING gist ( id WITH =, validity WITH && )
And the WHERE clauses for temporal queries cleaned up just as dramatically:
-- v0.1.0: "what year is it?!" as a geometry problem
WHERE box(point(date_part('epoch', valid_from), 0),
point(date_part('epoch', valid_to), 0))
&& box(point(date_part('epoch', '2014-01-01'), 0),
point(date_part('epoch', '2014-01-01'), 0))
-- v0.6.0: just ask
WHERE '2014-01-01' <@ validity
The database understands what it’s enforcing, and so does anyone reading the query log.
Monkey-patching โ proper adapter
The v0.1.0 “ugly truth”:
silence_warnings do
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter = ChronoModel::Adapter
end
Gone. ChronoModel now registers itself as a proper adapter subclass. You configure it in database.yml with adapter: chronomodel and ActiveRecord loads it through its standard adapter resolution. No constants are harmed.
Tests
The v0.1.0 post said “no tests yet โ they’re coming, I promise.” They came. v0.3.0 (June 2012, six weeks later) added comprehensive RSpec specs. By v1.0.0 there are 5,000+ lines of test code covering temporal tables, history queries, associations, time queries, STI, indexes, migrations, schema dumping, and standard ActiveRecord behavior.
The test suite runs against multiple Rails versions via Appraisal โ Rails 5.0, 5.1, and 5.2 for v1.0.0. The v0.13.1 release, tagged thirty minutes before v1.0.0, is the last version supporting Rails 4.2.
The weekend of April 6th
The final push is a weekend sprint. Rails 5.0 through 5.2 support lands in the afternoon, Rails 4.2 gets dropped, specs get added, deprecation warnings get fixed. Then three releases in under an hour:
- 20:25 โ v0.13.1: “the last version to support Rails 4.2”
- 20:54 โ v1.0.0:
:gem: this is v1.0.0 - 21:17 โ v1.0.1, because of course there’s a v1.0.1
Then the refactoring runs until 5 AM โ extracting the adapter into clean modules, rewriting on_schema to use thread-local storage, fixing CodeClimate smells, increasing coverage. Because tagging 1.0 doesn’t mean you stop. It means you finally have permission to clean up properly.
What didn’t change
The three-schema architecture. The temporal: true migration option. The include ChronoModel::TimeMachine mixin. The as_of query interface. The idea that temporal data belongs in the database, not in application callbacks.
# This worked in 2012. It still works in 2019.
Country.as_of(1.year.ago).find_by(code: 'IT')
506 commits to make the internals worthy of the interface. Seven years of production at IFAD without a single data loss incident.
The source is on GitHub, the API docs cover every public method. gem 'chrono_model', '~> 1.0' and you’re set.
Time travel shouldn’t cost an Oracle license. It still doesn’t.