About

A GitHub repo instead of a database: the real condition is an append-only event log, not "single user"

Why a bookmark app with zero migrations (app421) works across devices

This helps you decide whether a Rails app can use a GitHub repo instead of a database. By the end you'll know which data fits and which doesn't, and the minimum you need so writes from several devices don't break each other.

This app has no migrations

app421 (YT Mark) is a YouTube bookmark app. You save videos, sort them, and pull them out as playlists for the moment. Plainly a CRUD app, yet it has no tables.

All data sits in one JSONL file (one JSON object per line) in the user's own GitHub repo. Saving a bookmark appends an {"op":"add",...} line, deleting appends {"op":"del",...}. A Chrome extension writes, Rails reads, and the server holds no state.

Attempts to use git as a database usually fail. This one works, and the reason why is what this post is about.

Extensions write. Rails only reads and folds.Extension on PCpending queueExtension on laptoppending queueeach flush: GET file, append lines, PUT whole file with shaGitHub repo{op:add, id:1, at:...}{op:played, id:1, ...}{op:del, id:1, at:...}GET, 60s cacheRails (read only)sort_by atfold into stateno tablesWhen two devices write at the same timePUT with old sha409 Conflictre-read, append, retryBoth sides only add lines, so merge = remote lines + my lines. Nothing to resolve.

Writes: PUT the whole file, retry on conflict

The GitHub Contents API has no partial update. It overwrites the whole file. But if you send the sha you last saw along with the PUT, you get optimistic locking (the write is refused if someone changed the file first). If another device wrote first, you get a 409.

The extension handles it in three layers. Inside one browser, a Promise chain puts writes in a line. Each flush reads the remote file, appends its events, and PUTs the whole thing. On 409 it reads again and appends again. When offline, events wait in a pending array and go out later.

Optimistic locking would have been real work with a version column and transactions. Here it was already in the API spec.

Reads: Rails only folds

Rails fetches the file (cached for 60 seconds) and folds the events into current state. The key is one line: sort events by their at timestamp, then fold. So even if lines from several devices end up interleaved in the file, the result comes out the same.

Deleting doesn't remove a line either. It writes one more del event. With the extension as the only writer and Rails locked to read-only, conflict handling lives in exactly one place.

The condition isn't "single user"

The usual summary is "it's personal, so git is fine." Remember it that way and your next app breaks. There are three real conditions.

An append-only event log. On a 409, re-merge as "remote lines + my lines" and you're done. Both sides survive. Merging is where git-as-database falls apart, and an event log never creates that problem. Flip it around: even with one user, a structure that edits lines in place gets painful the moment it's in git.

Line-oriented text. With JSONL an append is one line and conflicts happen per line. People can open it in the repo, too. Committing a SQLite file doesn't qualify. It's binary, diffs mean nothing, and the repo just grows.

Small enough to read whole. There are no queries. Filtering and counting all happen in memory. Up to a few thousand items this is actually easier. No index tuning, no N+1.

What bit us: 0 versus missing

Saving via right-click on a link fetches info through oEmbed (a way to get a title, thumbnail and so on from a URL), which has no video length. So an add event comes in with d: 0. At first we took that at face value and overwrote a length we already knew with 0. That video quietly disappeared from the "under 10 minutes" filter.

Now 0 means "couldn't get it" and the previous value is kept. If you merge events, failing to tell "missing" from "0" corrupts data without a sound.

Where it breaks

None of this is a problem today. It's about growth. Each write PUTs the whole file, so past ten thousand items, saving one bookmark uploads a few MB. Splitting into monthly files handles that without changing the structure. The same fold rule lives in both Ruby and JS, so changing it means editing two places. Deletes stay forever as tombstones (a marker saying "deleted"), so don't use this for sensitive data. The remaining limits and a comparison with a regular database are in the table below.

If you're stuck at the design stage, on whether you need a database at all or where your data should live, feel free to ask negabaro. We've run small setups like this ourselves.

Where to keep it

StorageFitsConcurrent writesOps burden
GitHub repo + JSONL eventsMostly appends, thousands of items, users want to own their datasha optimistic lock + retryNo migrations, no backups
GitHub repo + SQLite fileAlmost neverWhole-file conflicts, cannot mergeRepo keeps growing
Regular DB (SQLite, PostgreSQL)In-place edits, search, many usersTransactionsMigrations, backups, a server

If most of your data is edited in place or you need search, just use a database.

Where it breaks as it grows

PointNowBeyond that
File sizeOver 1MB the Contents API sends empty content, so we fetch download_urlEvery write PUTs it all. Split into monthly files
RetriesUp to 2A third conflict leaves events in pending for the next flush. Nothing is lost
Rate limit5,000 authenticated requests per hour, guarded by a 60s cacheNoticeable with many devices and frequent reloads
Fold ruleSame rule in Ruby and JSChanging it means editing two places

fold: sort by time, then fold

def self.fold(events)
  events.sort_by { |e| e["at"].to_s }.each_with_object({}) do |e, map|
    next_state = apply(map[e["id"]], e)
    map[e["id"]] = next_state if next_state
  end
end

# length 0 means "couldn't get it". Keep the value we already had
d: e["d"].to_i.positive? ? e["d"].to_i : prev&.dig(:d).to_i

Key Points

1

Store data as events (add, del, played), one per line, not as state

2

Keep one writer (the extension): GET, append lines, PUT the whole file with sha

3

On 409, re-read and re-append. When offline, queue in pending

4

Rails is read-only. Sort by time, then fold logs from all devices into one state

5

Tell "missing" from "0" when merging. Split into monthly files when it grows

Use Cases

Personal tools and browser extensions where users own their data Small apps where the server should stay stateless
← Owner mode without login: secret-URL auth is one key to N browsers, not 1:1 Puma Slot Exhaustion: Why CPU Sat at 21% During 60,000 502s →