Add session report (architecture, coverage, ORM vs Postgres+Redis)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
45659c957e
commit
d2202bda03
1 file changed
+302
@@ -0,0 +1,302 @@
|
||||
# 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:**
|
||||
```ruby
|
||||
- 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:
|
||||
```sql
|
||||
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.
|
||||
|
||||
### One recommended pattern shift
|
||||
|
||||
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
|
||||
|
||||
```ruby
|
||||
# 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")
|
||||
```
|
||||
```ruby
|
||||
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`
|
||||
Reference in new issue
Block a user