{"id":49193571,"url":"https://github.com/threez/couchdb.cr","last_synced_at":"2026-04-23T08:30:49.644Z","repository":{"id":345360427,"uuid":"1185600357","full_name":"threez/couchdb.cr","owner":"threez","description":"A Crystal shard for CouchDB — local-first storage backed by SQLite3 that replicates to and from a remote CouchDB server. Inspired by PouchDB.","archived":false,"fork":false,"pushed_at":"2026-03-26T16:06:59.000Z","size":177,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-03-27T06:41:32.334Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"https://threez.github.io/couchdb.cr/","language":"Crystal","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/threez.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-18T18:51:41.000Z","updated_at":"2026-03-26T16:02:06.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/threez/couchdb.cr","commit_stats":null,"previous_names":["threez/couchdb.cr"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/threez/couchdb.cr","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/threez%2Fcouchdb.cr","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/threez%2Fcouchdb.cr/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/threez%2Fcouchdb.cr/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/threez%2Fcouchdb.cr/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/threez","download_url":"https://codeload.github.com/threez/couchdb.cr/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/threez%2Fcouchdb.cr/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32173033,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-23T02:19:40.750Z","status":"ssl_error","status_checked_at":"2026-04-23T02:17:55.737Z","response_time":53,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.5:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-04-23T08:30:47.963Z","updated_at":"2026-04-23T08:30:49.632Z","avatar_url":"https://github.com/threez.png","language":"Crystal","funding_links":[],"categories":[],"sub_categories":[],"readme":"# couchdb.cr\n\nA Crystal shard for CouchDB — local-first storage backed by SQLite3 that replicates to and from a remote CouchDB server. Inspired by PouchDB.\n\nQueries are answered instantly from local SQLite storage. Replication syncs with a remote CouchDB instance in the background, making it well-suited for offline-capable applications.\n\n## Features\n\n- **Local-first**: all reads and writes go to a local SQLite3 database — no network required\n- **CouchDB replication**: 7-step protocol with resumable checkpoints; sync to/from any CouchDB 3.x server\n- **Continuous sync**: `Database.local_replica` sets up live bidirectional replication in the background; choose local or upstream write semantics\n- **Typed documents**: subclass `CouchDB::Document` to add strongly-typed fields to your models\n- **Open schema**: unknown fields are preserved transparently through `json_unmapped`\n- **Auto-routing**: pass a file path for SQLite, pass an `http://` URL for a remote CouchDB\n\n## Installation\n\nAdd to your `shard.yml`:\n\n```yaml\ndependencies:\n  couchdb:\n    github: threez/couchdb.cr\n    version: ~\u003e 0.2\n```\n\nThen run:\n\n```\nshards install\n```\n\n## Quick Start\n\n```crystal\nrequire \"couchdb\"\n\ndb = CouchDB::Database.new(\"notes.db\")\n\n# Create a document\ndoc = CouchDB::Document.new\ndoc.id = \"hello\"\ndoc[\"message\"] = JSON::Any.new(\"world\")\nresult = db.put(doc)\nputs result[:rev]   # =\u003e \"1-5d41402abc4b2a76b9719d911017c592\"\n\n# Read it back\nfetched = db.get(\"hello\")\nputs fetched[\"message\"].as_s   # =\u003e \"world\"\nputs fetched.rev               # =\u003e \"1-5d41402abc4b2a76b9719d911017c592\"\n\n# Update — provide the current rev\nfetched[\"message\"] = JSON::Any.new(\"updated\")\nfetched.rev = result[:rev]\ndb.put(fetched)\n\n# Delete\ndb.remove(\"hello\", fetched.rev!)\n```\n\n## Typed Documents\n\nSubclass `CouchDB::Document` to define strongly-typed models. Subclass fields are serialized as top-level JSON keys — they live alongside `_id`, `_rev`, and any other dynamic fields.\n\n```crystal\nclass Note \u003c CouchDB::Document\n  property title : String = \"\"\n  property body  : String = \"\"\n  property tags  : Array(String) = [] of String\nend\n\ndb = CouchDB::Database.new(\"notes.db\")\n\nnote = Note.new\nnote.id    = \"note-1\"\nnote.title = \"Shopping list\"\nnote.body  = \"Milk, eggs, bread\"\ndb.put(note)\n\n# Retrieve as the typed subclass\nfetched = db.get(\"note-1\", as: Note)\nputs fetched.title   # =\u003e \"Shopping list\"\nputs fetched.id      # =\u003e \"note-1\"\nputs fetched.rev     # =\u003e \"1-...\"\n\n# List all notes as typed objects\nresult = db.all_docs(as: Note)\nresult[:rows].each { |note| puts note.title }\n```\n\nExtra fields not declared on the subclass are still preserved in `json_unmapped` and round-trip through replication without loss.\n\n## Document API\n\n`CouchDB::Document` provides:\n\n| Member | Type | Description |\n|--------|------|-------------|\n| `id` | `String` | Maps to `_id` in JSON |\n| `rev` | `String?` | Maps to `_rev`; `nil` for new documents |\n| `deleted` | `Bool?` | Maps to `_deleted`; `nil` for normal documents |\n| `deleted?` | `Bool` | Predicate — returns `true` when `deleted == true` |\n| `next_rev` | `String` | Computes what the next revision string would be |\n| `json_unmapped` | `Hash(String, JSON::Any)` | All fields not covered by declared properties |\n| `doc[\"key\"]` | `JSON::Any` | Hash-style read (routes `_id`/`_rev`/`_deleted` to typed fields) |\n| `doc[\"key\"] = v` | — | Hash-style write |\n| `doc[\"key\"]?` | `JSON::Any?` | Hash-style read, returns `nil` if absent |\n\n## Database API\n\n```crystal\ndb = CouchDB::Database.new(location)\n```\n\n`location` is auto-detected:\n- `\"http://...\"` or `\"https://...\"` → remote CouchDB via HTTP\n- anything else → local SQLite (`.db` extension appended if not present; `\":memory:\"` for in-memory)\n\n### CRUD\n\n```crystal\ndb.get(id)                      # =\u003e Document   (raises NotFound)\ndb.get(id, as: MyDoc)           # =\u003e MyDoc       (typed subclass)\ndb.put(doc)                     # =\u003e {ok:, id:, rev:}  (raises Conflict)\ndb.remove(id, rev)              # =\u003e {ok:}\ndb.bulk_docs(docs)              # =\u003e [{id:, rev:, ok:}, ...]\ndb.bulk_docs(docs, new_edits: false)  # replication write path\n```\n\n### Query\n\n```crystal\ndb.all_docs                                            # all non-deleted docs\ndb.all_docs(include_docs: true, limit: 50, skip: 0)\ndb.all_docs(startkey: \"a\", endkey: \"m\")               # range [a, m] inclusive\ndb.all_docs(startkey: \"note-\", endkey: \"note-\\uffff\") # prefix scan\ndb.all_docs(as: Note)                                  # typed rows — implies include_docs: true\ndb.all_docs(as: Note, limit: 50, startkey: \"note-\")   # typed + range/pagination\ndb.changes(since: \"0\")                                 # changes feed (snapshot)\ndb.changes(since: seq, limit: 100, include_docs: true)\ndb.info                                                # =\u003e {db_name:, doc_count:, update_seq:}\n```\n\n### Changes Feed\n\n`changes_feed` opens a persistent streaming connection, yielding each change to a block. Call `break` to stop.\n\n```crystal\n# Stream all changes from the beginning\ndb.changes_feed(since: \"0\") do |change|\n  puts \"#{change[\"id\"]} changed (seq #{change[\"seq\"]})\"\n  break if done?\nend\n\n# Pick up only changes after a known sequence, embedding full doc bodies\ndb.changes_feed(since: last_seq, include_docs: true) do |change|\n  process(change[\"doc\"])\n  save_checkpoint(change[\"seq\"].as_s)\n  break if shutting_down?\nend\n```\n\n| Parameter | Default | Description |\n|-----------|---------|-------------|\n| `since` | `\"0\"` | Starting sequence (exclusive). `\"0\"` yields all changes. |\n| `heartbeat` | `1000` | Polling interval in ms (SQLite) or CouchDB heartbeat in ms (HTTP). |\n| `include_docs` | `false` | Embed full document bodies in each change entry. |\n\n**SQLite**: polls `update_seq` in a loop, sleeping `heartbeat` ms between polls.\n**HTTP**: opens a `feed=continuous` connection to CouchDB and reads the response body line by line.\n\n### Query (map/reduce)\n\n`query` runs an in-memory map/reduce over all documents, PouchDB-style. Pass a block that calls `emit` for each key/value pair you want in the result; rows are sorted by key using CouchDB collation order (null \u003c false \u003c true \u003c numbers \u003c strings \u003c arrays \u003c objects).\n\n**Basic emit:**\n\n```crystal\nresult = db.query do |doc, emit|\n  emit.call(JSON::Any.new(doc[\"type\"].as_s), JSON::Any.new(1_i64))\nend\nresult[:rows].each { |r| puts \"#{r[\"key\"]} → #{r[\"value\"]}\" }\n# result[:total_rows]  — total after filtering, before skip/limit\n# result[:offset]      — the skip value used\n```\n\n**Key filtering:**\n\n```crystal\n# Exact key\ndb.query(key: JSON::Any.new(\"note\")) { |doc, emit| ... }\n\n# Multiple exact keys\ndb.query(keys: [JSON::Any.new(\"note\"), JSON::Any.new(\"task\")]) { |doc, emit| ... }\n\n# Inclusive range\ndb.query(startkey: JSON::Any.new(\"b\"), endkey: JSON::Any.new(\"d\")) { |doc, emit| ... }\n```\n\n**Pagination and ordering:**\n\n```crystal\ndb.query(limit: 10, skip: 20)             { |doc, emit| ... }\ndb.query(descending: true)                { |doc, emit| ... }\n\n# descending with bounds — pass the higher key as startkey:\ndb.query(descending: true, startkey: JSON::Any.new(\"z\"), endkey: JSON::Any.new(\"a\")) { |doc, emit| ... }\n```\n\n**Embedding full documents:**\n\n```crystal\nresult = db.query(include_docs: true) do |doc, emit|\n  emit.call(JSON::Any.new(doc[\"type\"].as_s), JSON::Any.new(nil))\nend\nresult[:rows].each { |r| puts r[\"doc\"][\"title\"] }\n```\n\n**Reduce functions:**\n\n```crystal\n# _count — total number of emitted rows\ndb.query(reduce: \"_count\") { |doc, emit| emit.call(..., ...) }\n# =\u003e [{key: null, value: 42}]\n\n# _count with grouping — one row per key\ndb.query(reduce: \"_count\", group: true) { |doc, emit| emit.call(JSON::Any.new(doc[\"type\"].as_s), ...) }\n# =\u003e [{key: \"note\", value: 10}, {key: \"task\", value: 5}]\n\n# _sum — sums numeric values (raises ArgumentError on non-numeric)\ndb.query(reduce: \"_sum\") { |doc, emit| emit.call(JSON::Any.new(nil), JSON::Any.new(doc[\"score\"].as_i64)) }\n\n# _stats — returns sum/count/min/max/sumsq (raises ArgumentError on non-numeric)\ndb.query(reduce: \"_stats\") { |doc, emit| emit.call(JSON::Any.new(nil), JSON::Any.new(doc[\"score\"].as_i64)) }\n# =\u003e [{key: null, value: {sum: 60.0, count: 3, min: 2.0, max: 30.0, sumsq: 1400.0}}]\n```\n\n**group_level with array keys** — truncates composite keys to the first N elements before grouping:\n\n```crystal\n# Emit [year, month] keys, then group by year only\ndb.query(reduce: \"_count\", group_level: 1) do |doc, emit|\n  key = JSON::Any.new([JSON::Any.new(doc[\"year\"].as_i64), JSON::Any.new(doc[\"month\"].as_i64)])\n  emit.call(key, JSON::Any.new(nil))\nend\n# =\u003e [{key: [2024], value: 12}, {key: [2025], value: 3}]\n```\n\n`query` performs a full in-memory scan — it is suited for small-to-medium datasets and ad-hoc indexing. For very large databases, use `all_docs` range queries instead.\n\n### Find (Mango selectors)\n\n`find` runs an in-memory Mango-style selector query over all documents, PouchDB/CouchDB-style. Instead of a Crystal block, pass a JSON hash describing the conditions; `find` handles filtering, sorting, projection, and pagination.\n\n**Basic usage:**\n\n```crystal\nresult = db.find(JSON.parse(%({\"type\": \"note\"})))\nresult[:docs].each { |doc| puts doc[\"title\"] }\n# result[:docs]    — Array(JSON::Any) of matching documents\n# result[:warning] — always present; full scan, no index used\n```\n\n**Field projection** — restrict keys returned per document:\n\n```crystal\nresult = db.find(JSON.parse(%({\"type\": \"note\"})), fields: [\"_id\", \"title\", \"author\"])\nresult[:docs].first.as_h.keys  # =\u003e [\"_id\", \"title\", \"author\"]\n# Dot-notation paths are stored flat: \"address.city\" becomes a top-level key in the result\n```\n\n**Sorting** — pass an array of field names (ascending) or single-key hashes with `\"asc\"`/`\"desc\"`:\n\n```crystal\n# Ascending (bare string)\ndb.find(sel, sort: [JSON::Any.new(\"name\")])\n\n# Descending (single-key hash)\ndb.find(sel, sort: [JSON.parse(%({\"score\": \"desc\"}))])\n\n# Multi-key: primary sort by group, secondary by rank\ndb.find(sel, sort: [JSON::Any.new(\"group\"), JSON::Any.new(\"rank\")])\n```\n\n**Pagination:**\n\n```crystal\ndb.find(sel, limit: 10, skip: 20)\n```\n\n**Operator reference:**\n\n| Operator | Description | Example condition |\n|---|---|---|\n| `$eq` | Equal (default for bare values) | `{\"$eq\": \"note\"}` |\n| `$ne` | Not equal | `{\"$ne\": \"deleted\"}` |\n| `$lt` | Less than | `{\"$lt\": 100}` |\n| `$lte` | Less than or equal | `{\"$lte\": 100}` |\n| `$gt` | Greater than | `{\"$gt\": 0}` |\n| `$gte` | Greater than or equal | `{\"$gte\": 0}` |\n| `$exists` | Field presence | `{\"$exists\": true}` |\n| `$type` | JSON type check | `{\"$type\": \"string\"}` |\n| `$in` | Value in set | `{\"$in\": [\"a\", \"b\"]}` |\n| `$nin` | Value not in set | `{\"$nin\": [\"x\"]}` |\n| `$all` | Array contains all | `{\"$all\": [\"a\", \"b\"]}` |\n| `$size` | Array length | `{\"$size\": 3}` |\n| `$mod` | Integer modulo | `{\"$mod\": [2, 0]}` (even) |\n| `$regex` | String matches regex | `{\"$regex\": \"^Al\"}` |\n| `$elemMatch` | Array element matches sub-selector | `{\"$elemMatch\": {\"score\": {\"$gt\": 5}}}` |\n| `$not` | Negate field condition | `{\"$not\": {\"$gt\": 10}}` |\n| `$and` | All sub-selectors match | `{\"$and\": [{\"a\": 1}, {\"b\": 2}]}` |\n| `$or` | Any sub-selector matches | `{\"$or\": [{\"type\": \"a\"}, {\"type\": \"b\"}]}` |\n| `$nor` | No sub-selector matches | `{\"$nor\": [{\"deleted\": true}]}` |\n\nValid `$type` values: `\"null\"`, `\"boolean\"`, `\"number\"`, `\"string\"`, `\"array\"`, `\"object\"`.\n\nComparisons (`$lt`, `$gt`, etc.) use the same CouchDB collation order as `query` (null \u003c false \u003c true \u003c numbers \u003c strings \u003c arrays \u003c objects), so mixed-type fields sort predictably.\n\n\u003e **Note:** `warning` is always present in the result because `find` always does a full scan — there is no index. The message prompts you to create an index if performance matters.\n\n### Conflict Resolution\n\nRegister a hook on a `Database` instance to handle `put` or `remove` conflicts automatically instead of rescuing `Conflict` manually.\n\n**`on_conflict`** — invoked when `put` raises `Conflict` (stale `_rev`):\n\n```crystal\ndb.on_conflict do |existing, attempted|\n  # existing  — the current document in the database (fresh rev)\n  # attempted — the document you tried to write\n  # Return a Document to retry with (rev is set automatically), or nil to re-raise.\n  attempted   # last-write-wins\nend\n```\n\n**`on_remove_conflict`** — invoked when `remove` raises `Conflict`:\n\n```crystal\ndb.on_remove_conflict do |existing, attempted_rev|\n  # existing      — the current document in the database\n  # attempted_rev — the stale rev you passed to remove\n  # Return true to retry the delete with the current rev, or nil to re-raise.\n  true\nend\n```\n\nField-merge example:\n\n```crystal\ndb.on_conflict do |existing, attempted|\n  merged = CouchDB::Document.new\n  merged.id = existing.id\n  merged[\"count\"] = JSON::Any.new(existing[\"count\"].as_i + attempted[\"count\"].as_i)\n  merged\nend\n```\n\n- A second conflict on retry propagates without re-invoking the hook (no infinite loop).\n- Raise inside the hook to propagate a custom exception.\n- Hooks apply to `put` and `remove` only; `bulk_docs` and attachment methods are unaffected.\n\n### Attachments\n\n```crystal\n# Store a binary attachment (creates a new document revision)\nrev = db.put(doc)[:rev]\ndb.put_attachment(\"doc-id\", \"photo.jpg\", rev, bytes, \"image/jpeg\")  # =\u003e {ok:, id:, rev:}\n\n# Retrieve raw bytes\natt = db.get_attachment(\"doc-id\", \"photo.jpg\")  # =\u003e {data: Bytes, content_type:}\nFile.write(\"photo.jpg\", att[:data])\n\n# Delete an attachment (creates a new document revision)\ndb.delete_attachment(\"doc-id\", \"photo.jpg\", att_rev)  # =\u003e {ok:, id:, rev:}\n```\n\nAttachment metadata (content type, length) is stored as a stub in the document's `_attachments` field. Binary data is stored separately (in an `attachments` SQLite table for the local adapter, or via native CouchDB attachment endpoints for the HTTP adapter).\n\n### Replication\n\n```crystal\nlocal  = CouchDB::Database.new(\"myapp.db\")\nremote = CouchDB::Database.new(\"https://admin:secret@db.example.com/myapp\")\n\nlocal.sync(remote)              # pull then push — full bidirectional sync\nlocal.replicate_from(remote)    # pull only\nlocal.replicate_to(remote)      # push only\n```\n\n`sync` and `replicate_*` return a `CouchDB::Replication::Session`:\n\n```crystal\nsession = local.replicate_to(remote)\nputs session.ok?             # true / false\nputs session.docs_written    # number of documents transferred\nputs session.docs_read       # number of documents fetched from source\nputs session.last_seq        # last sequence number processed\nputs session.error           # error message if ok == false\n```\n\nReplication is **resumable** — a checkpoint is written to both source and target after every batch of 100 documents, so interrupted replications restart from where they left off.\n\n### Continuous Sync\n\n`Database.local_replica` creates a local SQLite database that continuously syncs bidirectionally with a remote CouchDB. Two background fibers (push and pull) run for the lifetime of the object. Checkpoints are stored on the remote only, so deleting and recreating the local file resumes from where replication left off.\n\n```crystal\ndb = CouchDB::Database.local_replica(\"notes.db\",\n       \"https://admin:secret@db.example.com/notes\")\n\n# Use db exactly like a regular Database — reads/writes go to local SQLite.\ndb.put(note)\ndb.get(\"note-1\")\n\ndb.close   # stops sync fibers and closes both adapters\n```\n\n#### Write modes\n\n**Local writes** (default) — `put`/`remove` are instant (local SQLite only). The background push fiber syncs them to the remote asynchronously.\n\n```crystal\ndb = CouchDB::Database.local_replica(\"notes.db\", remote_url)\n\nresult = db.put(note)   # returns immediately — written to local SQLite\ndb.get(result[:id])     # available locally right away\n# … push fiber syncs to remote in the background\n```\n\n**Upstream writes** (`write_upstream: true`) — `put`/`remove` write to the remote first, then block until the change has been replicated locally. Reads always come from local.\n\n```crystal\ndb = CouchDB::Database.local_replica(\"notes.db\", remote_url,\n       write_upstream: true)\n\nresult = db.put(note)   # writes to remote, then waits for local copy\ndb.get(result[:id])     # guaranteed to be present immediately\n```\n\nUse upstream mode when you need read-your-own-writes consistency across devices, or when the local store is treated as a cache rather than the source of truth.\n\n\u003e **Timeout**: upstream `put`/`remove` raise `CouchDB::Error` if the change does not replicate locally within 5 seconds.\n\n#### Heartbeat\n\n`heartbeat` controls how often (in ms) the sync fibers poll for new changes. Lower values mean lower latency but more frequent network/DB activity.\n\n```crystal\ndb = CouchDB::Database.local_replica(\"notes.db\", remote_url,\n       heartbeat: 500)   # poll every 500 ms (default: 2000)\n```\n\n#### Error handling\n\nWhen a background sync run fails (network drop, DNS error, auth error, etc.) the library automatically applies **exponential backoff** before retrying — starting at 1 second and doubling up to 60 seconds. This prevents error floods when the remote is temporarily unreachable.\n\nRegister `on_sync_error` to be notified of failures. The callback receives the direction (`\"push\"` or `\"pull\"`) and the exception. Without a registered callback, errors are silently swallowed while the fibers keep running with backoff.\n\n```crystal\ndb = CouchDB::Database.local_replica(\"notes.db\", remote_url)\n\ndb.on_sync_error do |direction, ex|\n  case ex\n  when CouchDB::Unauthorized\n    puts \"#{direction}: credentials rejected\"\n    db.bearer_token = refresh_token()\n  else\n    puts \"#{direction} sync error: #{ex.message}\"\n  end\nend\n```\n\nThe backoff limits are configurable:\n\n```crystal\ndb = CouchDB::Database.local_replica(\"notes.db\", remote_url,\n       sync_initial_backoff: 500.milliseconds,\n       sync_max_backoff: 30.seconds)\n```\n\n#### Logging\n\nThe shard emits warnings through Crystal's standard `Log` module under the `\"couchdb\"` source. Logging is **off by default** — no output is produced unless your application configures a backend:\n\n```crystal\nrequire \"log\"\nLog.setup(\"couchdb\", :warn, Log::IOBackend.new)\n```\n\nThis logs the same errors that `on_sync_error` receives. Both mechanisms can be used together.\n\n#### Conflict resolution\n\nConcurrent edits on both sides can produce `Conflict` errors during the push sync. Register `on_conflict` on the replica to resolve them automatically:\n\n```crystal\ndb = CouchDB::Database.local_replica(\"notes.db\", remote_url)\n\n# Last-write-wins: the local (attempted) version always wins\ndb.on_conflict do |existing, attempted|\n  attempted\nend\n\n# Field-merge: combine numeric fields from both sides\ndb.on_conflict do |existing, attempted|\n  merged = attempted.dup\n  merged[\"count\"] = JSON::Any.new(\n    existing[\"count\"].as_i64 + attempted[\"count\"].as_i64\n  )\n  merged\nend\n```\n\nIn **upstream write mode**, conflicts happen on the remote during `put`. The same `on_conflict` hook applies — returning a `Document` retries the remote write and then waits for the resolved document to replicate locally.\n\n```crystal\ndb = CouchDB::Database.local_replica(\"notes.db\", remote_url,\n       write_upstream: true)\n\ndb.on_conflict { |existing, _attempted| existing }   # remote always wins\n```\n\n## Error Handling\n\n```crystal\nbegin\n  db.get(\"missing\")\nrescue CouchDB::NotFound =\u003e e\n  puts e.message   # \"Document not found: missing\"\nend\n\nbegin\n  db.put(doc_with_wrong_rev)\nrescue CouchDB::Conflict =\u003e e\n  # Fetch the latest rev and retry\nend\n```\n\n| Exception | When |\n|-----------|------|\n| `CouchDB::NotFound` | `get` on a non-existent or deleted document |\n| `CouchDB::Conflict` | `put`/`remove` with a stale or missing revision |\n| `CouchDB::Unauthorized` | HTTP 401 from remote CouchDB |\n| `CouchDB::BadRequest` | Missing `_id`, missing `_rev` on replication write, etc. |\n| `CouchDB::ReplicationError` | Unrecoverable failure during replication |\n\nAll exceptions inherit from `CouchDB::Error \u003c Exception`.\n\n## Architecture\n\n```\nCouchDB::Database           public facade — auto-detects adapter\n  └── LocalReplica          continuous bidirectional sync (local_replica)\n        |\nCouchDB::Adapter            abstract interface\n  ├── Adapter::SQLite        local storage via crystal-db + crystal-sqlite3\n  └── Adapter::HTTP          remote CouchDB via HTTP::Client\n        |\nCouchDB::Replication::Replicator   7-step CouchDB protocol\n  ├── Replication::Checkpoint       _local/ checkpoint read/write (remote only for LocalReplica)\n  └── Replication::Session          result object for one replication run\n```\n\n### SQLite schema\n\nFour tables underpin the local adapter:\n\n| Table | Purpose |\n|-------|---------|\n| `docs` | Every revision of every document (enables `revs_diff`) |\n| `revs` | Parent-revision linkage tree |\n| `local_docs` | `_local/` documents — checkpoints, never replicated |\n| `update_seq` | Append-only sequence log; ROWID is the `update_seq` |\n| `attachments` | Current binary attachment data keyed by `(doc_id, name)` |\n\nThe \"winning\" revision is the one with the highest `seq` for a given `id`. Deleted documents are soft-deleted (a `deleted=1` row is stored) so their revisions remain queryable for replication.\n\n## Development\n\n```bash\nshards install\ncrystal spec          # all specs run without a CouchDB instance\n```\n\nTo run e2e tests against a live server (optional):\n\n```bash\n# Option A: locally installed goydb\nmake goydb            # starts goydb on :7070 (foreground)\nmake e2e              # in another terminal\n\n# Option B: Docker\ndocker run -d -p 7070:7070 ghcr.io/goydb/goydb:latest\nmake e2e\n```\n\n`COUCHDB_URL` defaults to `http://admin:secret@localhost:7070`. Override to point at any CouchDB-compatible server.\n\n## Contributing\n\n1. Fork it (\u003chttps://github.com/threez/couchdb.cr/fork\u003e)\n2. Create your feature branch (`git checkout -b my-new-feature`)\n3. Add specs for your change and make sure `crystal spec` passes\n4. Commit your changes (`git commit -am 'Add some feature'`)\n5. Push to the branch (`git push origin my-new-feature`)\n6. Open a Pull Request\n\n## Contributors\n\n- [Vincent Landgraf](https://github.com/threez) — creator and maintainer\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fthreez%2Fcouchdb.cr","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fthreez%2Fcouchdb.cr","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fthreez%2Fcouchdb.cr/lists"}