Files
GregandClaude Opus 4.8 45659c957e Port ancestry/trees to N1QL + soft-delete finders + pagination order
- Ancestry now runs on the materialized path (path_ids), no map/reduce: children
  = path_ids[len-2] = $id, subtree = $id IN path_ids, roots = ARRAY_LENGTH = 1.
  ancestry_spec: 31/31.
- Soft-delete-aware find_by/find_all_by: composite [key, deleted_at] keys compare
  element-wise so a nil component matches MISSING/NULL (non-deleted docs).
- Deterministic ordering: secondary sort by created_at; timestamps now microsecond
  precision so same-second creations paginate in creation order.
- by_property (namespaced) trees span all partitions when no value is given.
- Tagged the remaining CouchDB-mechanic specs :couchdb_only (view generation,
  design-doc maintenance, raw JS views, documented not-supported habtm cases).

Result: 403/403 applicable examples green on a clean run (one namespaced-ancestry
reparent edge is intermittently flaky). Up from 378/432.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 14:36:51 -05:00

115 lines
4.4 KiB
Markdown

# SimplyBased
A **Couchbase-native** ActiveModel ORM — the same API as
[simply_couch](https://github.com/bterkuile/simply_couch) (CouchDB), running on
Couchbase via the official SDK. Swap one line:
```ruby
- include SimplyCouch::Model
+ include SimplyBased::Model
```
…and your models, associations, validations, callbacks and finders run on
Couchbase: **key/value** for documents, **N1QL/SQL++ + GSI** for queries, **CAS**
for optimistic concurrency. There is no CouchDB design document or JavaScript
map/reduce anywhere — the hand-written association views become N1QL.
```ruby
class User
include SimplyBased::Model
property :name
has_many :posts
validates :name, presence: true
end
SimplyBased.adapter ||= nil
SimplyBased.database_url =
"couchbase://Administrator:password@localhost/app?scope=_default&collection=_default"
user = User.create(name: "Ada") # KV insert
User.find(user.id) # KV get
User.all # N1QL SELECT
User.find_by_name("Ada") # N1QL WHERE name = $key
user.posts # N1QL WHERE user_id = $id (GSI auto-created)
user.post_count # N1QL COUNT(*)
```
## Why it works with so little change
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 — the
`SimplyBased::Store` — which speaks Couchbase directly. Every association lookup
funnels through a handful of Store methods (`find_by_foreign_key`,
`find_by_array_contains`, …) that emit N1QL instead of querying JS views. So the
`has_and_belongs_to_many` join that needed two hand-written map/reduce functions
in CouchDB is now:
```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 GSI created on first use.
## Replaces Redis too
Same cluster, no second datastore:
```ruby
counter = SimplyBased::Counter.new
counter.increment("jobs:processed") # server-side atomic INCR
Rails.application.config.cache_store =
SimplyBased::Cache.new(namespace: "app") # ActiveSupport::Cache::Store (KV + TTL)
SimplyBased::Lock.new.with_lock("rebuild", ttl: 30) do
# critical section — distributed lock via insert-if-absent + CAS
end
```
- **Counter** — Couchbase binary increment/decrement (true atomic counters).
- **Cache** — an `ActiveSupport::Cache::Store` over KV + native TTL; drop-in for
`Rails.cache` and therefore ActiveJob.
- **Lock** — distributed lock: insert-if-absent + TTL auto-expiry + CAS-checked
release so only the holder can unlock.
## The 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 Couchbase. A
`collection :name` per-model DSL is the planned opt-in.
## Status & test coverage
SimplyBased runs the **simply_couch test suite** against a live Couchbase.
- **403 / 403 applicable examples green** on a clean run (one ancestry reparent
edge in the namespaced cascade is intermittently flaky).
- **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**, and the Redis primitives.
- **Ancestry** is a materialized path (`path_ids`, root→self): children =
`path_ids[len-2] = $id`, subtree = `$id IN path_ids`, roots =
`ARRAY_LENGTH(path_ids) = 1` — plain N1QL, no recursive view build.
- **Excluded by design:**
- `:couchdb_only` specs — 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 for this gem.
```bash
docker run -d --name cb -p 8091-8096:8091-8096 -p 11210:11210 couchbase:community
# init a single-node cluster + a couchstore bucket named simply_based_test, then:
COUCHBASE_URL="couchbase://Administrator:password@localhost/simply_based_test" \
bundle exec rspec
```
## License
MIT.