Every create_table I have written for twenty years ends the same way, and I typed it into a Hanami migration without a thought:

create_table :challenges do
  primary_key :id
  column :name, String, null: false
  timestamps
end

That last line does not exist here.

undefined local variable or method 'timestamps' for an instance of
Sequel::Schema::CreateTableGenerator (NameError)

The stack trace points at sequel-5.107.0/lib/sequel/database/schema_generator.rb, and that is the part that tells you where you actually are. Migrations in Hanami are Sequel, and timestamps was never Sequel’s. It is an ActiveRecord shorthand, and I had been carrying it around as though it were a fact about databases.

I am building a CTF challenge tracker in Hanami 3 with my Rails habits deliberately left switched on, and this is the smallest friction the app has handed me so far. It is also the one that told me the most about how the framework thinks.

The fix that fixed nothing

Two lines, and the migration runs:

column :created_at, Time, null: false
column :updated_at, Time, null: false

Then I created a challenge through ChallengeRepo, which is the first thing anybody does after a migration, and got this:

SQLite3::ConstraintException: NOT NULL constraint failed:
challenges.created_at (ROM::SQL::NotNullConstraintError)

The missing shorthand was not the surprise. The surprise was that replacing it did not finish the job.

In Rails, t.timestamps sells you two columns and a behaviour, in one word, and I had never separated the two in my head. In Sequel I had bought the columns. Nothing on earth was going to fill them.

Who fills them, then

I read the gems I already had installed rather than guess, which is becoming the habit this app teaches most reliably. rom-core ships two plugins named timestamps, one for schemas and one for commands, and the changeset path uses neither. rom-changeset keeps its own registry of pipe steps, and it answers the question in two methods:

def self.add_timestamps(data)
  now = Time.now
  Hash(created_at: now, updated_at: now).merge(data)
end

def self.touch(data)
  Hash(updated_at: Time.now).merge(data)
end

That is the entire mechanism, and it is worth noticing the merge(data): the step is a default rather than a mandate, so a value you pass in explicitly wins over the one it just computed.

Which is what the challenge repo ended up saying:

def create(attributes)
  challenges.changeset(:create, attributes).map(:add_timestamps).commit
end

Create and update are not the same verb

The other half of the story is one method down:

def mark_solved(id, solved_at)
  challenges
    .by_pk(id)
    .changeset(:update, solved: true, solved_at: solved_at)
    .map(:touch)
    .commit
end

add_timestamps on the way in, touch on the way through. Rails draws that same distinction, correctly, and in total silence. I have never once had to know it was there, and if you had asked me last month whether a Rails update rewrites created_at, I would have had to go and check rather than answer.

Here they are two different words, in a file I can read from top to bottom in one sitting.

Every repo has to remember

The cost shows up as soon as there is a second table. EventRepo, which lands a few commits further on, carries the same .map(:add_timestamps) line in its own create, because nothing was going to infer it for me. Every repo that writes a row has to remember.

And when you forget, there is no convention check, no warning at boot, nothing at the point where you made the mistake. You get a NOT NULL violation at runtime from the database, which is the very last layer that could have caught it.

I have not landed on whether that is a good trade. The database is arguably the right place to be told, loudly, that a column declared mandatory has no value, and the call sits at the exact spot where the write happens, which is where I would want to read it. Against that: a rule you can forget is a rule somebody eventually forgets, and I have paid this cost twice, in an app with two tables. Twice is not thirty times.

Ten lines I had never read

The pattern is much bigger than two columns. add_reference does not exist either, and add_foreign_key gives you the column and the constraint while leaving the index as a decision you have to make out loud. The shorthand and the behaviour are sold separately here, everywhere, and Hanami’s position is that the second one belongs in view at the call site.

What stays with me is smaller and less comfortable than a verdict on ceremony. For twenty years I have shipped tables whose timestamps were filled by machinery I had never looked at, and I would have told you with a straight face that I understood how my application worked. It took two errors and about ten lines of somebody else’s Ruby for that to stop being true.

The state described here is tagged 02-four-objects, the same checkpoint as the post on the four objects. That is deliberate: the add_timestamps line was sitting in a listing in that post, and I walked past it without a word.

Comments, questions, or just a reaction?

Send an email to ~bounga/bounga.org-discuss@lists.sr.ht. It is public and archived, so other readers can follow along and answer too.