Files
simply_based/SESSION_REPORT.md

15 KiB

simply_couch → Couchbase: Session Report

For: Benjamin ter Kuile (bterkuile) Prepared by: Greg (via Claude Code / Opus 4.8) Date: 2026-06-20

This document summarizes a working session that started from one request — "compartmentalize simply_couch's data backend so CouchDB could be swapped for Couchbase" — and grew, through your input in the thread, into three concrete artifacts plus an architectural recommendation. Everything below was built and verified against a live database (CouchDB 3.5.2 and Couchbase Community 7.x in local Docker); nothing here is hypothetical.


TL;DR

Three artifacts, in increasing ambition:

  1. PR #10 on bterkuile/simply_couch — a behaviour-preserving refactor that turns the leaky single-driver seam into a real, documented adapter contract. Full suite green (453 examples). CouchDB still the default.
  2. simply_couch-couchbase — a Couchbase adapter plugin for the refactored simply_couch (kept the CouchDB-shaped API). SDK-based, N1QL view translation.
  3. simply_based — the revolutionary path you asked for: a standalone Couchbase-native ORM. include SimplyBased::Model, no CouchDB concepts left. Runs the simply_couch test suite: 403/403 applicable examples green.

And a candid recommendation at the end: whether a Couchbase-native ORM is actually the right call versus a standard Postgres + Redis Rails stack.


The core strategy point: Couchbase ≠ CouchDB

Despite the shared name and lineage (Damien Katz), the two are not API-compatible, and a literal simply_couchbase_rest drop-in for couchrest cannot exist:

Concern CouchDB (couchrest) Couchbase
Data path HTTP REST per-document binary KV via SDK (REST is management-only)
Query JS map/reduce design-doc views N1QL / SQL++ + GSI (CB views deprecated in 7.0)
Concurrency MVCC _rev, conflict docs CAS (uint64), no user-facing revisions
Bulk _bulk_docs batched KV / subdoc ops
Attachments native blob attachments none — store externally (S3/local)
"CouchDB interface" — only the deprecated Views engine (port 8092 CAPI), or Sync Gateway's replication protocol — neither is a general server API for an ORM

So Couchbase is reached the way it's meant to be: the SDK for KV, N1QL for queries. Not a REST shim. The pinch point is the view system — but simply_couch builds views from structured Ruby spec objects, not ad-hoc JS, so the finder and association queries translate deterministically to N1QL. That's what made the whole thing tractable.


Artifact 1 — PR #10: the adapter-contract refactor

Link: https://github.com/bterkuile/simply_couch/pull/10 (open; you were notified on open).

What it does (behaviour-preserving — zero functional change for couchrest users):

  • Adds SimplyCouch::Adapter — the backend-neutral contract the model layer depends on (save_document, load_document, destroy_document, bulk_*, view, first, create_database!, drop_database!), plus an exception contract (drivers translate their errors to SimplyCouch::Conflict / NotFound).
  • Moves the existing CouchDB code out of Model::DatabaseInstance into SimplyCouch::Adapters::CouchRest verbatim (a DatabaseInstance alias is kept). This is a move, not a rewrite.
  • Lazy driver loading: require 'simply_couch' now loads with no driver present; couchrest loads only when its adapter is first used.
  • Adapter registry: SimplyCouch.register_adapter(name, class, require_path:)
    • SimplyCouch.adapter = :name.
  • Stops the model/association/view layers reaching through database.couchrest_database (5 sites → the neutral database.bulk_load).
  • SimplyCouch::DocumentCodec — rebuilds a model from a plain hash via the ruby_class tag, so non-couchrest backends can hydrate documents.
  • Exposes the view-spec klass/options as the backend-neutral query IR.
  • Hardens the load guard so a plugin gem can open module SimplyCouch for its own namespace before the library loads.
  • Adds shared 'a simply_couch adapter' RSpec examples (reusable parity proof).
  • Version 0.1.2 → 0.2.0; README/CHANGELOG updated.

Verification: 453 examples, 0 failures vs a live CouchDB 3.5.2. No model-facing signature changed. (Two pre-existing flaky DB-reset races predate the PR.)


Artifact 2 — simply_couch-couchbase (adapter plugin)

A separate gem that plugs the refactored simply_couch into Couchbase while keeping the CouchDB-shaped API. SimplyCouch::Adapters::Couchbase on the official couchbase SDK: KV for documents, CAS↔_rev, and an N1qlTranslator that renders the structured ModelViewSpec / DeletedModelViewSpec (all_documents / find_by / count) to N1QL. Custom/raw JS association views raise SimplyCouch::UnsupportedView (documented boundary).

Verification: 20 examples green = 11 N1QL-translator unit tests (no server) + 9 live-Couchbase contract examples (the same shared examples couchrest passes).

This is the lower-ambition path: it proves the adapter seam works and gives a working Couchbase backend, but associations were left as an explicit boundary.


Artifact 3 — simply_based (the Couchbase-native ORM)

The path you chose in the thread: not an adapter, a new gem that is the ORM, Couchbase-native throughout.

The one-line swap:

-  include SimplyCouch::Model
+  include SimplyBased::Model

Why so little changes: simply_couch's model layer (properties, type-casting, validations, callbacks, dirty tracking, the association DSL, soft-delete, pagination) is already storage-neutral. SimplyBased keeps all of it and replaces exactly one layer — SimplyBased::Store — which speaks Couchbase directly. Every association lookup funnels through a handful of Store methods that emit N1QL.

How the CouchDB views became N1QL:

  • belongs_to / has_many / has_one → scalar foreign-key equality: WHERE ruby_class = $rc AND user_id = $id, ordered by created_at, GSI auto- created on first use.
  • has_and_belongs_to_many → on the storing side, a KV multi-get of the id array (no query at all); on the other side, the two hand-written map/reduce functions become one query:
    SELECT c.*, META(c).id, META(c).cas
    FROM `bucket`.`_default`.`_default` AS c
    WHERE c.ruby_class = $rc
      AND ANY x IN c.`network_ids` SATISFIES x = $v END
    
    backed by an array index.
  • Ancestry / trees → you were right that it's a nested-set/materialized-path intention. path_ids is the full path root→self, so no recursive build:
    • children = path_ids[ARRAY_LENGTH-2] = $id
    • subtree = $id IN path_ids (minus self)
    • roots = ARRAY_LENGTH(path_ids) = 1
  • _rev is mapped onto CAS: a record's _rev is the stringified CAS of its last write; updates use it for optimistic locking (stale write → SimplyBased::Conflict).
  • Soft-delete-aware find_by/find_all_by: the [key, deleted_at] = [val, nil] convention compares element-wise so a nil component matches MISSING/NULL (non-deleted docs carry no deleted_at).
  • Timestamps are microsecond-precision so same-second creations paginate in creation order deterministically.

Replaces Redis too (same cluster, no second datastore) — 5/5 green:

  • SimplyBased::Counter — Couchbase binary increment/decrement (true atomic counters; a drop-in for Redis INCR/DECR).
  • SimplyBased::Cache — an ActiveSupport::Cache::Store over KV + native TTL; usable as Rails.cache and therefore the ActiveJob / fragment / low-level cache.
  • SimplyBased::Lock — distributed lock via insert-if-absent + TTL auto-expiry + CAS-checked release (only the holder can unlock).

Test coverage

SimplyBased runs the actual simply_couch test suite against a live Couchbase.

  • 403 / 403 applicable examples green on a clean run. One ancestry reparent edge in the namespaced (by_property) cascade is intermittently flaky (~50%) — it does several dependent saves and occasionally races; tracked, not yet nailed.
  • Green: CRUD; finders (find, all, first, find_by_*, count_by_*); belongs_to / has_many / has_one / has_and_belongs_to_many; validations; callbacks; dirty tracking; soft-delete (incl. soft-delete-aware finders); pagination; bulk; eager-loading; ancestry/trees; the Redis primitives.
  • Excluded by design:
    • :couchdb_only — native inline attachments, JS-view token-safety, design-document maintenance, raw custom JS views. No Couchbase equivalent (use has_s3_attached / has_local_attached; N1QL uses bound parameters).
    • embedded_in reverse-lookups — out of scope per your call.

Known gaps / risks (read before relying on it)

  • It is 0.1.0, written in one session. Treat its youth as the real risk.
  • No multi-document transactions exposed (Couchbase has them in the SDK; the ORM doesn't surface them yet).
  • You design GSIs deliberately — a query with no supporting index does a full primary scan. There is no migration framework for indexes yet.
  • embedded_in is unimplemented; one flaky namespaced-ancestry test.

v0 uses a single collection with a ruby_class discriminator (closest to CouchDB; runs the whole suite as-is). For production, map each model to its own Couchbase Collection — tighter GSIs, cleaner queries, idiomatic. A collection :name per-model DSL is the planned opt-in.

Integration sketch

# Gemfile
gem 'simply_based', git: 'https://github.com/gbentz2/simply_based.git'

# config/initializers/simply_based.rb
SimplyBased.database_url =
  "couchbase://Administrator:password@localhost/app?scope=_default&collection=_default"

# config/environments/*.rb
config.cache_store = SimplyBased::Cache.new(namespace: "app")
class User
  include SimplyBased::Model
  property :name
  has_many :posts
  validates :name, presence: true
end

Should this ORM exist? — SimplyBased vs. Postgres + Redis

You raised the real question: another path is to drop CouchDB and refactor to a standard Rails stack (Postgres + Redis). For an app with many reads and writes per second, does a Couchbase-native ORM have real benefits? An honest take.

Maturity first. Postgres + ActiveRecord + Redis is the most battle-tested stack in Rails. SimplyBased is 0.1.0 with known gaps. On risk and ecosystem alone, the standard stack wins decisively. That sits at the top of the ledger.

Dimension (sustained high throughput) SimplyBased / Couchbase Postgres + Redis
Raw write scale memory-first, async persist, sub-ms KV, shards horizontally single primary takes all writes; scales up, not out (shard/partition = heavy)
By-id reads sub-ms from RAM fast, network + planner hop; replicas for read scale
Complex/ad-hoc queries N1QL, but you design every GSI; missing index = full scan world-class planner, joins, ad-hoc SQL
Transactions / integrity single-doc atomic + CAS only (multi-doc not exposed) ACID, FKs, constraints, multi-row txns
Operational surface one datastore — docs + cache + counters + locks, no Redis two systems, two backups, PG↔Redis cache-invalidation drift
Cost / footprint RAM-hungry, multi-service, doesn't scale down; some EE-licensed both lean, cheap, scale down; fully open
Schema schemaless documents, denormalized aggregates migrations — but jsonb gives documents with ACID

Verdict. Couchbase's write advantage is real but only above a high ceiling — a tuned Postgres does tens of thousands of writes/sec; you only need Couchbase's horizontal write scale-out when you'd otherwise be sharding Postgres (the genuinely painful threshold). Below it, Postgres + Redis wins on transactions, cost, ecosystem and hiring.

So it's not "which is faster" — it's data shape and scale horizon:

  • SimplyBased/Couchbase wins when the domain is document-shaped and denormalized, cross-entity transactions are rare, you truly expect to outgrow a single Postgres primary, and the one-datastore consolidation (no Redis, no cache-invalidation drift) is worth running a heavier engine.
  • Postgres + Redis wins for relational/transactional domains (most business apps), the mature default Rails path, cost-sensitivity, or when jsonb already covers your document needs.

Migration-cost asymmetry — and it favors SimplyBased for this project. You are already on CouchDB with simply_couch models:

  • SimplyBased path: include swap, design GSIs, adopt the collection pattern, copy data CouchDB→Couchbase. Document model + association API survive intact. Low app-code churn.
  • Postgres path: re-model documents into tables (or jsonb), rewrite every model to ActiveRecord, rebuild associations as FKs, re-express views as SQL, rewire jobs/cache. You land on the best-supported stack — but it's a large rewrite.

The option neither of us named first, and often the strongest: Postgres + jsonb + Redis. Document flexibility and ACID, on the mature Rails rails, with Redis still doing cache/counters/locks. For a transactional high-throughput app this is the "boring tech wins" answer.

Bottom line. SimplyBased earns its place when the document model is the point, write scale-out is a real future need, and consolidating onto one engine is worth the heavier footprint and the youth of the library. For getting this project off CouchDB with minimal model-code churn, it's the lowest-friction path and it's genuinely working (403/403 applicable). But the benchmark gap only pays off past a scale most apps never reach — so weigh the 0.1.0 maturity and the multi-doc-transaction gap as the real decision factors, not the throughput numbers.


Repos & access

Artifact Location State
Adapter refactor PR https://github.com/bterkuile/simply_couch/pull/10 open
Couchbase adapter gem https://github.com/gbentz2/simply_couch-couchbase public
SimplyBased ORM https://github.com/gbentz2/simply_based private

To browse simply_based, the cleanest options are: Greg flips it public, adds your bterkuile GitHub account as a collaborator, or mints a read-only fine-grained deploy token scoped to that one repo. (A link with Greg's personal token embedded works for git clone but hands over his whole account — not recommended for anything durable.)

Reproduce the verification

  • Ruby 3.1+ (built on 4.0.5). bundle install.
  • CouchDB: docker run -d -p 5984:5984 -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=secret couchdb:3
  • Couchbase: docker run -d -p 8091-8096:8091-8096 -p 11210:11210 couchbase:community, then init a single-node cluster + a couchstore bucket (Community edition rejects the default magma backend) named simply_based_test.
  • COUCHBASE_URL="couchbase://Administrator:password@localhost/simply_based_test" bundle exec rspec