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.
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
| Storage | Fits | Concurrent writes | Ops burden |
|---|---|---|---|
| GitHub repo + JSONL events | Mostly appends, thousands of items, users want to own their data | sha optimistic lock + retry | No migrations, no backups |
| GitHub repo + SQLite file | Almost never | Whole-file conflicts, cannot merge | Repo keeps growing |
| Regular DB (SQLite, PostgreSQL) | In-place edits, search, many users | Transactions | Migrations, 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
| Point | Now | Beyond that |
|---|---|---|
| File size | Over 1MB the Contents API sends empty content, so we fetch download_url | Every write PUTs it all. Split into monthly files |
| Retries | Up to 2 | A third conflict leaves events in pending for the next flush. Nothing is lost |
| Rate limit | 5,000 authenticated requests per hour, guarded by a 60s cache | Noticeable with many devices and frequent reloads |
| Fold rule | Same rule in Ruby and JS | Changing 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
Store data as events (add, del, played), one per line, not as state
Keep one writer (the extension): GET, append lines, PUT the whole file with sha
On 409, re-read and re-append. When offline, queue in pending
Rails is read-only. Sort by time, then fold logs from all devices into one state
Tell "missing" from "0" when merging. Split into monthly files when it grows