ndb 0.4.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 440cb991f003e2ce9f918da9d9f40fe3ac07e77ca117ba1b3d7dbd801d6b5866
4
- data.tar.gz: 8d48e93a33a65dc8df214ff57ac8ef77d0c6edbb5c9bfa12cb364400ab2492d5
3
+ metadata.gz: 3cee02332a613d6dcb575f559504c2efde35cece0259791103d63113fa485ac1
4
+ data.tar.gz: 7a81d66e1e69c9208d24556c59e5249e413a9e71d45e774b581a05bca237df22
5
5
  SHA512:
6
- metadata.gz: 2375c7fdd4cca6e943e6a5c27e192d4e6ed3dda62722f935bd4ffaa0c606fa2d973101e11bc1c19907dce0c0068366c2cb5cefba0022086e0dd85232cb78eaf8
7
- data.tar.gz: d382affec84704deee77750b458da1b524f6805d08ebf3f38b3411c5a7b64b002eb4256ebe94f99243253e2ecb13f438207317cfc419aeecce12115b7ba1a350
6
+ metadata.gz: 29f3541621bd7f13917db6d2ccff09d5b400728ac14d9758df26dea0df529c43db95e0420a7d3977cfe2f3872d54be246ca18926b8cc3cbafd18ca3c2ef17945
7
+ data.tar.gz: e6904af991afc68bf5a131d299fe789c3b07225ba87ae1175d43e9ee468104b2f4a74e6e8e499d064d97ce56f907f253dc1ea641291d52691ec2021db09f6eae
data/README.md CHANGED
@@ -4,9 +4,14 @@
4
4
 
5
5
  Plain Ruby client for [ndb](https://github.com/thadeu/ndb), an NDJSON query daemon. It talks HTTP over a unix socket or TCP, with a connection pool and keep-alive. It uses the Ruby standard library only: **no runtime dependencies**. The gem only builds JSON. The daemon decides what every query means.
6
6
 
7
+ It has two layers:
8
+
9
+ - `Ndb.connect` and `Ndb::Collection`: a collection with a query builder (`query`, `sort`, `limit`, `each`). Start here.
10
+ - `Ndb::Client`: the low-level layer, one method per HTTP route. `Ndb.connect` returns one.
11
+
7
12
  Building a Rails app with models? Use [`ndb-rails`](https://github.com/thadeu/ndb/tree/main/sdk/rails) on top of this gem.
8
13
 
9
- Docs: [client guide](https://github.com/thadeu/ndb/blob/main/docs/dev/clients.md), [query language](https://github.com/thadeu/ndb/blob/main/docs/dev/query.md), [HTTP API](https://github.com/thadeu/ndb/blob/main/docs/dev/http-api.md).
14
+ Docs: [client guide](https://github.com/thadeu/ndb/blob/main/docs/dev/clients.md), [query language](https://github.com/thadeu/ndb/blob/main/docs/dev/query.md), [HTTP API](https://github.com/thadeu/ndb/blob/main/docs/dev/http.md).
10
15
 
11
16
  ## Install
12
17
 
@@ -29,13 +34,14 @@ require "ndb"
29
34
  ## Connect
30
35
 
31
36
  ```ruby
32
- ndb = Ndb::Client.new(socket: "/run/ndb/ndb.sock") # unix socket
33
- ndb = Ndb::Client.new(url: "unix:///run/ndb/ndb.sock") # same, as a URL
34
- ndb = Ndb::Client.new(url: "tcp://10.0.0.5:7070", token: "secret") # TCP with a bearer token
35
- ndb = Ndb::Client.new # reads NDB_URL and NDB_TOKEN
37
+ ndb = Ndb.connect("unix:///run/ndb/ndb.sock") # unix socket
38
+ ndb = Ndb.connect("tcp://10.0.0.5:7070", token: "secret") # TCP with a bearer token
39
+ ndb = Ndb.connect # reads NDB_URL and NDB_TOKEN
40
+
41
+ ndb.health # => true when the daemon is ready (GET /_health)
36
42
  ```
37
43
 
38
- All options:
44
+ `Ndb.connect(addr, **opts)` returns an `Ndb::Client`. It is the same as `Ndb::Client.new(url: addr, **opts)`. All options:
39
45
 
40
46
  ```ruby
41
47
  Ndb::Client.new(
@@ -48,130 +54,166 @@ Ndb::Client.new(
48
54
  )
49
55
  ```
50
56
 
51
- - The address is, in order: `url:`, `socket:`, env `NDB_URL`, then the default socket `/var/run/ndb/ndb.sock`.
57
+ - The address is, in order: `url:` (the `addr` of `Ndb.connect`), `socket:`, env `NDB_URL`, then the default socket `/var/run/ndb/ndb.sock`.
52
58
  - The token is `token:`, or env `NDB_TOKEN`. It goes only in the `Authorization` header. A unix socket does not need it.
53
59
  - A bad address raises `ArgumentError` at once.
54
60
  - `ready_timeout` is how long a call retries while the daemon is not ready (`not_ready`, or the socket is missing or refuses). It uses exponential backoff (50 ms up to 1 s) with jitter. A write is retried only when the daemon certainly did not process it. A read is also retried after a dropped connection. A timeout is never retried. When the deadline passes, the last error is raised.
61
+ - `health` does not wait: a daemon that is recovering raises `Ndb::NotReady`.
55
62
  - A reused keep-alive connection that the server closed is sent again only for a read. A write is never sent twice.
56
63
  - The pool is thread-safe and drops its connections after a `fork`.
57
64
 
58
65
  ## Collections
59
66
 
60
67
  ```ruby
61
- ndb.ensure_collection("calls",
62
- indexes: ["status"],
63
- unique: ["caller"],
64
- time: "at") # also: expires_field:, ttl:
68
+ calls = ndb.collection("calls",
69
+ indexes: ["status"],
70
+ unique: ["caller"],
71
+ time: "at") # also: expires_field:, ttl:
65
72
 
66
- ndb.collections # => [{"name" => "calls", ...}]
73
+ ndb.collections # => [{"name" => "calls", ...}] (GET /_collections)
67
74
  ```
68
75
 
69
- Every collection has the primary key `_id`. You can send your own (any non-empty string), or leave it out: the daemon makes a UUIDv7 (`Ndb.new_id` makes the same kind of id on the client). For a natural key such as an email, keep `_id` and declare that field in `unique:`.
76
+ - `collection` sends nothing. The first call on the collection declares it (`PUT /calls`) with this definition, once per object. A different definition for an existing collection raises `Ndb::CatalogConflict` on that call.
77
+ - A collection declared in `ndb.yml` needs no definition: `ndb.collection("app_logs")`.
78
+ - A name that ends with `*` is a collection pattern: `ndb.collection("logs-2026-*")` reads every collection whose name starts with `logs-2026-`. It declares nothing, and a write raises `Ndb::Error` with code `pattern_read_only`. Each row has `"_coll"`, the name of its collection.
70
79
 
71
- `ensure_collection` is idempotent. A different definition for an existing collection raises `Ndb::CatalogConflict`.
80
+ Every collection has the primary key `_id`. You can send your own (any non-empty string), or leave it out: the daemon makes a UUIDv7 (`Ndb.new_id` makes the same kind of id on the client). For a natural key such as an email, keep `_id` and declare that field in `unique:`.
72
81
 
73
82
  ## Insert, update, delete
74
83
 
75
- Every document has a version. A new document has version 1, and each change adds 1.
84
+ Every document has a version, `_v`. A new document has version 1, and each change adds 1.
76
85
 
77
86
  ```ruby
78
- results = ndb.insert("calls", [
79
- { _id: "c1", status: "answered", caller: "a", duration: 42 },
80
- { _id: "c2", status: "failed", caller: "b", duration: 5 }
87
+ calls.insert(_id: "c1", status: "answered", caller: "a", duration: 42) # one document (POST /calls/_doc)
88
+ # => {"_id" => "c1", "_v" => 1}
89
+
90
+ calls.insert([ # many in one transaction (POST /calls/_bulk)
91
+ { _id: "c2", status: "failed", caller: "b", duration: 5 },
92
+ { _id: "c3", status: "failed", caller: "c", duration: 7 }
81
93
  ])
82
- results # => [{"_id" => "c1", "v" => 1}, {"_id" => "c2", "v" => 1}]
94
+ # => [{"_id" => "c2", "_v" => 1}, {"_id" => "c3", "_v" => 1}]
95
+
96
+ calls.upsert(_id: "c2", status: "answered") # create by _id, or merge into the stored document
97
+ calls.upsert_all([{ _id: "c2", duration: 9 }, { _id: "c4", status: "new" }]) # one transaction
83
98
 
84
- ndb.get("calls", "c1") # => {"_id" => "c1", ..., "_v" => 1}, or nil
99
+ calls.get("c1") # => {"_id" => "c1", ..., "_v" => 1}, or nil (GET /calls/_doc/c1)
85
100
 
86
- # Atomic update by _id (a String) or by filter (a Hash): the target, then the write operators.
87
- ndb.update("calls", "c1", { "$set" => { status: "done" }, "$unset" => { caller: true } })
101
+ # Atomic update by _id (POST /calls/_update/c1): the id, then the write operators.
102
+ calls.update("c1", { "$set" => { status: "done" }, "$unset" => { caller: true } })
88
103
  # => {"matched" => 1, "modified" => 1, "doc" => {"_id" => "c1", ..., "_v" => 2}}
89
104
 
90
- ndb.update("calls", { _id: "c1", _v: 2 }, { "$set" => { duration: 50 } }) # only if the version is still 2
91
- ndb.update("stock", { _id: "a", qty: { "$gte" => 1 } }, { "$inc" => { qty: -1 } }) # guarded decrement
105
+ calls.update("c1", { "$set" => { duration: 50 } }, guard: { _v: 2 }) # only if the version is still 2
106
+ stock.update("a", { "$inc" => { qty: -1 } }, guard: { qty: 1.. }) # guarded decrement
107
+ counters.update("hits", { "$inc" => { n: 1 } }, upsert: true) # created with this _id when missing
92
108
 
93
- ndb.update("calls", { status: "failed" }, { "$set" => { status: "retry" } }, many: true) # => {"matched" => 3, "modified" => 3}
94
- ndb.update("counters", { name: "hits" }, { "$inc" => { n: 1 } }, upsert: true)
109
+ # Atomic update by query (POST /calls/_update_by_query): the first match, or every match with many.
110
+ calls.update_by({ status: "failed" }, { "$set" => { status: "retry" } }, many: true) # => {"matched" => 2, "modified" => 2}
111
+ calls.update_by({ caller: "z" }, { "$inc" => { duration: 1 } }, upsert: true)
95
112
 
96
- ndb.delete("calls", "c2") # by _id => {"deleted" => 1}
97
- ndb.delete("calls", { status: "failed" }, many: true)
113
+ calls.delete("c2") # by _id => {"deleted" => 1} (DELETE /calls/_doc/c2)
114
+ calls.delete_by({ status: "retry" }, many: true) # by query (POST /calls/_delete_by_query)
98
115
  ```
99
116
 
100
117
  - Operators: `$set`, `$setOnInsert` (like `$set`, but only when an upsert creates the document), `$unset`, `$inc`, `$push` (with `{ "$each" => [...] }`), `$pull`, `$min`, `$max`. Field names can be dot paths. A key of the operators without `$` raises `ArgumentError` before anything is sent.
101
- - Nothing matched is not an error: `matched` is 0. A stale `_v` is a filter that matches nothing, so read again and retry. `modified` is 0 when the operators leave the document as it is.
102
- - `many: true` needs `_id` or an indexed field in the filter (`Ndb::UnindexedMany`), and does not apply to an `_id` String. `upsert: true` creates the document from the equality fields of the filter. The answer has `doc` only for one document.
118
+ - Nothing matched is not an error: `matched` is 0. A stale `_v` guard matches nothing, so read again and retry. `modified` is 0 when the operators leave the document as it is.
119
+ - `update` takes `guard:`, `upsert:` (creates the document with that `_id`) and `expire_in:`. `update_by` takes `many:`, `upsert:` (creates the document from the equality fields of the query) and `expire_in:`. `delete_by` takes `many:`. The answer has `doc` only for one document.
120
+ - The query and the guard take the Ruby forms of `query` (below): an Array is `$in`, a Range is `$gte`/`$lte`, `nil` is `$exists: false`.
121
+ - `many: true` needs `_id` or an indexed field in the query (`Ndb::UnindexedMany`). An empty query raises `Ndb::EmptyFilter`.
103
122
  - The contract errors are `Ndb::UnknownOperator`, `NoUpdate`, `ConflictingUpdate`, `InvalidField`, `UnindexedMany` and `EmptyFilter`, all under `Ndb::InvalidUpdate`.
104
- - A duplicate `_id` raises `Ndb::UniqueViolation` (code `duplicate_id`). A broken unique field raises it too (code `unique_violation`).
123
+ - A duplicate `_id` raises `Ndb::UniqueViolation` (code `duplicate_id`), and a many-document insert writes nothing. A broken unique field raises it too (code `unique_violation`).
105
124
 
106
125
  ### Truncate and drop
107
126
 
108
127
  ```ruby
109
- ndb.truncate("calls") # every document goes; the definition and indexes stay => {"collection" => "calls", "truncated" => true}
110
- ndb.drop("calls") # the collection, its definition and its watch consumers go
128
+ calls.truncate # every document goes; the definition and indexes stay => {"collection" => "calls", "truncated" => true}
129
+ calls.drop # the collection, its definition and its watch consumers go
111
130
  ```
112
131
 
113
132
  - Both take the same time for 10 or 10 million documents. Watch consumers get one event with `"op" => "truncate"` (no `_id`), then the new changes.
114
- - A direct-mode collection raises `Ndb::ReadOnlyCollection`. A collection declared in `ndb.yml` comes back, empty, when the daemon restarts after a drop.
133
+ - After `drop`, the next call on the collection declares it again. A collection declared in `ndb.yml` comes back, empty, when the daemon restarts.
134
+ - A direct-mode collection raises `Ndb::ReadOnlyCollection`.
115
135
 
116
136
  ## Expiry (TTL like Redis)
117
137
 
118
138
  The collection needs `expires_field`.
119
139
 
120
140
  ```ruby
121
- ndb.ensure_collection("sessions", { expires_field: "exp" })
141
+ sessions = ndb.collection("sessions", expires_field: "exp")
122
142
 
123
- ndb.insert("sessions", [{ _id: "s1" }], expire_in: 3600) # seconds, or "1h" / "30m" / "1d"
124
- ndb.update("sessions", "s1", { "$set" => { user: "u" } }, expire_in: "30m")
143
+ sessions.insert({ _id: "s1" }, expire_in: 3600) # seconds, or "1h" / "30m" / "1d"
144
+ sessions.update("s1", { "$set" => { user: "u" } }, expire_in: "30m")
125
145
 
126
- ndb.expire("sessions", "s1", in: 1800) # => {"_id", "v", "expires_at"}
127
- ndb.expire("sessions", "s1", at: Time.now + 7200)
128
- ndb.ttl("sessions", "s1") # => 7199, -1 (never expires), or nil (missing or expired)
129
- ndb.persist("sessions", "s1") # => {"_id", "v"}
146
+ sessions.expire("s1", in: 1800) # => {"_id", "_v", "expires_at"}
147
+ sessions.expire("s1", at: Time.now + 7200)
148
+ sessions.ttl("s1") # => 7199, -1 (never expires), or nil (missing or expired)
149
+ sessions.persist("s1") # => {"_id", "_v"}
130
150
  ```
131
151
 
132
152
  - `expire_in` wins over a value of `expires_field` in the same call. `ttl` rounds up to whole seconds.
133
153
  - `expire` and `persist` raise `Ndb::NotFound` for a missing or expired document. `Ndb::TtlNotEnabled` means no `expires_field`; `Ndb::TtlDerived` means the expiry comes from `ttl` + `time`.
134
154
  - Disk space of expired documents returns after compaction, and fully only with `wal_retention`; see [Freeing disk](https://github.com/thadeu/ndb/blob/main/docs/dev/configuration.md#freeing-disk).
135
155
 
136
- ## Find
156
+ ## Query
137
157
 
138
- `find` returns a `Ndb::Page` with `rows` (an array of Hash), `next_cursor`, `plan`, `examined` and `total`.
158
+ A query is immutable: every step returns a new one. The request (`POST /{coll}/_search`) goes out when you call `all`, `first`, `page`, `count` or `each`.
139
159
 
140
160
  ```ruby
141
- page = ndb.find("calls",
142
- where: { status: "answered", duration: { "$gte" => 30, "$lte" => 60 } },
143
- sort: { at: -1 },
144
- fields: %w[_id status],
145
- limit: 50,
146
- skip: 0)
147
-
148
- page.rows # => [{"_id" => "c1", "status" => "answered"}, ...]
149
- page.plan # "id", "index:status", "scan" or "snapshot"
150
- page.examined # rows the daemon looked at
161
+ calls.query(status: "answered").all
162
+ calls.query(status: %w[answered failed]) # $in
163
+ calls.query(duration: 30..60) # $gte and $lte
164
+ calls.query(duration: 30...60) # $gte and $lt
165
+ calls.query(duration: nil) # $exists: false
166
+ calls.query(at: Time.now - 86_400..)
167
+ calls.query("agent.id" => 7) # dot path
168
+ calls.query(duration: { "$gt" => 10 }) # any daemon operator, as a Hash
169
+ calls.query("$or" => [{ status: "failed" }, { duration: { "$gt" => 50 } }])
170
+
171
+ calls.query(status: "answered")
172
+ .query(caller: %w[a b]) # ANDed
173
+ .sort(at: :desc, _id: :asc)
174
+ .fields(:_id, :status)
175
+ .limit(50)
176
+ .skip(100)
177
+ .all # => [{"_id" => "c1", "status" => "answered"}, ...]
178
+
179
+ calls.query(status: "answered").first # the first row, or nil
151
180
  ```
152
181
 
153
- The body of `find` is the JSON of the [query language](https://github.com/thadeu/ndb/blob/main/docs/dev/query.md): `where`, `sort`, `fields`, `limit`, `skip`, `after`, `count`, `explain`, `versions`. Symbol keys are fine.
182
+ - `query` turns Ruby values into operators: an Array is `$in`, a Range is `$gte` and `$lte` (or `$lt`), `nil` is `$exists: false`, a `Time` is an RFC 3339 string, a `Regexp` is `$regex`. A key that starts with `$` is passed as is.
183
+ - `sort(at: -1, _id: 1)`: `1`, `:asc` ascending, `-1`, `:desc` descending. The key order is the sort order. The id is always the last key.
184
+ - `fields` returns only these fields.
185
+ - See the [query language](https://github.com/thadeu/ndb/blob/main/docs/dev/query.md) for every operator.
186
+
187
+ ### A query built elsewhere
188
+
189
+ A query from a search form or a saved search is a `raw_query`. Its Hash is sent as it is (only Times and Regexps are converted), ANDed with the other conditions:
154
190
 
155
- In `find`, `where` is sent as it is: write the daemon operators yourself. (Only `watch` turns Ruby values such as Range and Array into operators, see below.)
191
+ ```ruby
192
+ calls.raw_query("status" => "answered", "at" => { "$gte" => "now-7d" }).limit(50).all
193
+ ```
194
+
195
+ For a whole search body (`query`, `sort`, `fields`, `limit`, `skip`, `after`, `count`, `explain`), use `search`. It returns one `Ndb::Page`:
156
196
 
157
197
  ```ruby
158
- ndb.find("calls", where: { status: { "$in" => %w[answered failed] } })
159
- ndb.find("calls", where: { duration: { "$gte" => 30, "$lte" => 60 } })
160
- ndb.find("calls", where: { "$or" => [{ status: "failed" }, { duration: { "$gt" => 50 } }] })
161
- ndb.find("calls", where: { "agent.id" => 7 }) # dot path
198
+ page = calls.search(query: { status: "answered" }, sort: { at: -1 }, limit: 50, count: true)
199
+
200
+ page.rows # => [{"_id" => "c1", ...}, ...]
201
+ page.next # the cursor of the next page, nil on the last one
202
+ page.total # with count: true
203
+ page.plan # "id", "index:status", "scan" or "snapshot"
204
+ page.examined # rows the daemon looked at
162
205
  ```
163
206
 
164
- - `sort: { at: -1, _id: 1 }`: `1` ascending, `-1` descending. The key order is the sort order. The id is always the last key.
165
- - `versions: true` adds the field `_v` (the document version) to each row.
207
+ `query(...).page` returns the same `Ndb::Page` for a built query (`page(count: true)` fills `total`).
166
208
 
167
209
  ### Regular expressions
168
210
 
169
- A Ruby `Regexp` is accepted anywhere a value is: a field value, `"$regex" => /x/`, inside `$not`, `$or`, `$and`, in `find`, `aggregate` (`$match`) and in the `where:` of `watch`.
211
+ A Ruby `Regexp` is accepted anywhere a value is: a field value, `"$regex" => /x/`, inside `$not`, `$or`, `$and`, in `query`, `search`, `aggregate` (`$match`) and in the `query:` of `watch`.
170
212
 
171
213
  ```ruby
172
- ndb.find("calls", where: { name: /^alpha/i }) # i: ignore case
173
- ndb.find("calls", where: { name: { "$regex" => /bet$/ } })
174
- ndb.find("calls", where: { "$or" => [{ name: /^a/ }, { name: /^b/ }] })
214
+ calls.query(name: /^alpha/i) # i: ignore case
215
+ calls.query(name: { "$regex" => /bet$/ })
216
+ calls.query("$or" => [{ name: /^a/ }, { name: /^b/ }])
175
217
  ```
176
218
 
177
219
  The daemon uses RE2. The mapping to `$options` is:
@@ -185,56 +227,67 @@ The daemon uses RE2. The mapping to `$options` is:
185
227
 
186
228
  Ruby features that RE2 does not have raise `Ndb::InvalidQuery` (code `invalid_query`) **before** any request: look-around (`(?=`, `(?!`, `(?<=`, `(?<!`), backreferences (`\1` to `\9`, `\k<name>`), atomic groups (`(?>`), possessive quantifiers (`a++`), `\h`, `\G`, `\R` and `\X`.
187
229
 
188
- A String pattern is sent unchanged and has no `m`: `where: { name: { "$regex" => "^abc" } }`. A pattern anchored with `^` and a literal start, without `i` or `m`, uses the index on the field (`page.plan` is `index:name`). A Regexp always sends `m`, so it is a scan filter, not an index range. For a prefix search that uses the index, send the String form.
230
+ A String pattern is sent unchanged and has no `m`: `query(name: { "$regex" => "^abc" })`. A pattern anchored with `^` and a literal start, without `i` or `m`, uses the index on the field (`page.plan` is `index:name`). A Regexp always sends `m`, so it is a scan filter, not an index range. For a prefix search that uses the index, send the String form.
189
231
 
190
232
  ### Count
191
233
 
192
234
  ```ruby
193
- page = ndb.find("calls", where: { status: "answered" }, count: true, limit: 1)
194
- page.total # how many documents match, whatever the limit
235
+ calls.count(status: "answered") # POST /calls/_count
236
+ calls.query(status: "answered").count # the same
237
+ calls.count # every document
195
238
  ```
196
239
 
240
+ `limit`, `skip` and `sort` do not change the count.
241
+
197
242
  ### Cursor pagination
198
243
 
244
+ Offset pages get slower as `skip` grows. Use a cursor for deep pages.
245
+
199
246
  ```ruby
200
- cursor = nil
247
+ query = calls.query(status: "answered").sort(_id: 1).limit(100)
201
248
 
202
249
  loop do
203
- page = ndb.find("calls", where: { status: "answered" }, sort: { _id: 1 }, limit: 100, after: cursor)
250
+ page = query.page
204
251
 
205
252
  page.rows.each { |row| puts row["_id"] }
206
253
 
207
- cursor = page.next_cursor
254
+ break if page.next.nil? # nil on the last page
208
255
 
209
- break if cursor.nil? # nil on the last page
256
+ query = query.after(page.next)
210
257
  end
211
258
  ```
212
259
 
213
- A cursor works only with the same `where` and `sort`. Another query shape raises `Ndb::InvalidCursor`.
260
+ A cursor works only with the same query and `sort`. Another query shape raises `Ndb::InvalidCursor`.
261
+
262
+ To walk every row, use `each`. It follows the cursor, with `limit` as the batch size. A query is `Enumerable`:
263
+
264
+ ```ruby
265
+ calls.query(status: "answered").sort(_id: 1).limit(500).each { |row| puts row["_id"] }
266
+ ```
214
267
 
215
268
  ## Aggregate
216
269
 
217
270
  The pipeline needs exactly one `$group`. Accumulators: `$count`, `$sum`, `$avg`, `$min`, `$max`, `$first`, `$last`.
218
271
 
219
272
  ```ruby
220
- page = ndb.aggregate("calls",
221
- pipeline: [
222
- { "$match" => { "duration" => { "$gte" => 1 } } },
223
- { "$group" => {
224
- "_id" => "$status",
225
- "n" => { "$count" => {} },
226
- "avg" => { "$avg" => "$duration" }
227
- } },
228
- { "$sort" => { "n" => -1 } }
229
- ],
230
- limit: 100,
231
- count: true)
273
+ page = calls.aggregate([
274
+ { "$match" => { "duration" => { "$gte" => 1 } } },
275
+ { "$group" => {
276
+ "_id" => "$status",
277
+ "n" => { "$count" => {} },
278
+ "avg" => { "$avg" => "$duration" }
279
+ } },
280
+ { "$sort" => { "n" => -1 } }
281
+ ],
282
+ limit: 100,
283
+ count: true)
232
284
 
233
285
  page.rows # => [{"_id" => "answered", "n" => 2, "avg" => 35.5}, ...]
234
286
  page.total # with count: true
287
+ page.next # the cursor of the next page, nil on the last one
235
288
  ```
236
289
 
237
- `$dateTrunc` units are `minute`, `hour`, `day`, `week` and `month`.
290
+ Options: `limit:`, `skip:`, `after:`, `count:`. `$dateTrunc` units are `minute`, `hour`, `day`, `week` and `month`.
238
291
 
239
292
  ## Watch
240
293
 
@@ -242,11 +295,11 @@ A watch streams the changes of a collection to a named consumer: first the event
242
295
 
243
296
  ```ruby
244
297
  begin
245
- ndb.watch("calls", "relay", where: { status: "failed" }) do |event|
246
- # event has "pos", "op" ("put", "del" or "truncate"), "_id", "v" and "doc"
298
+ calls.watch("relay", query: { status: "failed" }) do |event|
299
+ # event has "pos", "op" ("put", "del" or "truncate"), "_id", "_v" and "doc"
247
300
  handle(event)
248
301
 
249
- ndb.ack("calls", "relay", event["pos"])
302
+ calls.ack(event, "relay")
250
303
  end
251
304
  rescue Ndb::WatchLagged
252
305
  retry # the consumer was too slow. watch continues after the last ack.
@@ -255,10 +308,11 @@ rescue Ndb::HistoryTruncated
255
308
  end
256
309
  ```
257
310
 
258
- - `where:` takes the same conditions as the models of `ndb-rails`: equality, an Array (`$in`), a Range, a Regexp (see Regular expressions), `nil` (`$exists: false`) and `$` operators as keys. Only matching events come. A delete event has no `"doc"`; it is sent only if this stream saw an earlier matching put of that id.
311
+ - `query:` takes the same conditions as `query`: equality, an Array (`$in`), a Range, a Regexp, `nil` (`$exists: false`) and `$` operators as keys. Only matching events come. A delete event has no `"doc"`; it is sent only if this stream saw an earlier matching put of that id.
259
312
  - `from: "now"` starts a new consumer at the end. `limit: n` ends the stream after `n` events.
260
- - Without a block, `watch` returns an Enumerator: `ndb.watch("calls", "relay", limit: 10).to_a`.
261
- - Delivery is at least once. After a restart an event can come again, so make the work safe to repeat. `ack` stores the position of an event: the next `watch` with that consumer name starts after it.
313
+ - Without a block, `watch` returns an Enumerator: `calls.watch("relay", limit: 10).to_a`.
314
+ - `ack(event, consumer)` stores the position of the event (`POST /calls/_ack`): the next `watch` with that consumer name starts after it. The event Hash has no consumer name, so pass it.
315
+ - Delivery is at least once. After a restart an event can come again, so make the work safe to repeat.
262
316
  - `HistoryTruncated` means the WAL files for the position were deleted (`wal_retention`).
263
317
  - A watch holds one connection until it ends. It is retried only for the initial open, never in the middle of a stream.
264
318
 
@@ -267,17 +321,68 @@ end
267
321
  Large values live in the blob store. A document keeps only a reference.
268
322
 
269
323
  ```ruby
270
- stored = ndb.put_blob(File.open("call.wav"), "audio/wav") # an IO, streamed in chunks
324
+ stored = ndb.put_blob(File.open("call.wav"), "audio/wav") # an IO, streamed in chunks (POST /_blobs)
271
325
  stored # => {"$blob" => "sha256:...", "size" => 123456, "type" => "audio/wav"}
272
326
 
273
- ndb.insert("calls", [{ _id: "c9", recording: stored }])
327
+ calls.insert(_id: "c9", recording: stored)
274
328
 
275
- ndb.get_blob(stored["$blob"]) # all the bytes
329
+ ndb.get_blob(stored["$blob"]) # all the bytes (GET /_blobs/{ref})
276
330
  ndb.get_blob(stored["$blob"], from: 0, to: 1023) # bytes 0..1023 (inclusive)
277
331
  ```
278
332
 
279
333
  The same content always gives the same reference. An IO upload is never retried.
280
334
 
335
+ ## Admin
336
+
337
+ ```ruby
338
+ calls.stats # rows, indexes, files, bytes (GET /calls/_stats)
339
+ calls.compact # merge every segment now (POST /calls/_compact)
340
+ calls.ensure_index("caller") # built in the background (PUT /calls/_indexes/caller) => the indexed fields
341
+ calls.drop_index("caller")
342
+
343
+ ndb.collections # one definition per collection (GET /_collections)
344
+ ndb.stats # every collection, totals, version, uptime (GET /_stats)
345
+ ndb.size # disk use per collection and part (GET /_size)
346
+ ```
347
+
348
+ ## The low-level client
349
+
350
+ `Ndb::Client` has one method per route. The collection methods above call it. Use it when the collection name is only known at run time, or to send a body built elsewhere. The first argument is the collection name.
351
+
352
+ ```ruby
353
+ ndb.ensure_collection("calls", indexes: ["status"], time: "at") # PUT /calls
354
+
355
+ ndb.insert("calls", { _id: "c1", status: "answered" }) # POST /calls/_doc => {"_id", "_v"}
356
+ ndb.bulk("calls", [{ _id: "c2" }, { _id: "c3" }], expire_in: "1h") # POST /calls/_bulk => the results
357
+ ndb.get("calls", "c1") # GET /calls/_doc/c1 => the document, or nil
358
+
359
+ ndb.update("calls", "c1", { "$set" => { status: "done" } }, guard: { _v: 1 }) # POST /calls/_update/c1
360
+ ndb.update_by("calls", { status: "failed" }, { "$set" => { status: "retry" } }, many: true) # POST /calls/_update_by_query
361
+ ndb.delete("calls", "c2") # DELETE /calls/_doc/c2
362
+ ndb.delete_by("calls", { status: "retry" }, many: true) # POST /calls/_delete_by_query
363
+
364
+ page = ndb.search("calls", query: { status: "answered" }, sort: { at: -1 }, limit: 50) # POST /calls/_search => an Ndb::Page
365
+ ndb.count("calls", { status: "answered" }) # POST /calls/_count => an Integer
366
+ ndb.aggregate("calls", pipeline: [{ "$group" => { "_id" => "$status", "n" => { "$count" => {} } } }]) # POST /calls/_aggregate
367
+
368
+ ndb.expire("calls", "c1", in: "30m") # POST /calls/_expire/c1
369
+ ndb.persist("calls", "c1") # POST /calls/_persist/c1
370
+ ndb.ttl("calls", "c1") # GET /calls/_ttl/c1
371
+
372
+ ndb.watch("calls", "relay", query: { status: "failed" }) { |event| ndb.ack("calls", "relay", event["pos"]) } # POST /calls/_watch, /calls/_ack
373
+
374
+ ndb.truncate("calls") # POST /calls/_truncate?confirm=calls
375
+ ndb.drop("calls") # DELETE /calls?confirm=calls
376
+ ndb.ensure_index("calls", "caller") # PUT /calls/_indexes/caller
377
+ ndb.drop_index("calls", "caller") # DELETE /calls/_indexes/caller
378
+ ndb.collection_stats("calls") # GET /calls/_stats
379
+ ndb.compact("calls") # POST /calls/_compact
380
+ ```
381
+
382
+ - `search`, `count` and `aggregate` send the body as it is: write the daemon operators yourself. Only a Regexp is turned into `$regex`.
383
+ - `update`, `update_by`, `delete_by` and `watch` take the Ruby forms of `query` (Array, Range, `nil`, Time, Regexp) in the query and the guard.
384
+ - `ndb.collection(name, definition)` returns the `Ndb::Collection` of the sections above.
385
+
281
386
  ## Errors
282
387
 
283
388
  All errors inherit from `Ndb::Error`, which has `code` (the daemon code) and `status` (the HTTP status).
@@ -300,11 +405,11 @@ All errors inherit from `Ndb::Error`, which has `code` (the daemon code) and `st
300
405
  | `TtlNotEnabled`, `TtlDerived` | `ttl_not_enabled`, `ttl_derived` |
301
406
  | `ConnectionError` | cannot reach the daemon. Subclasses: `ConnectError` (nothing was sent), `TimeoutError` |
302
407
 
303
- Any other code is a plain `Ndb::Error`. A response that is not a daemon error has the code `http_status`.
408
+ Any other code (for example `unknown_route`, `pattern_read_only`) is a plain `Ndb::Error`. A response that is not a daemon error has the code `http_status`.
304
409
 
305
410
  ```ruby
306
411
  begin
307
- ndb.insert("calls", [{ _id: "c1" }])
412
+ calls.insert(_id: "c1")
308
413
  rescue Ndb::UniqueViolation => e
309
414
  e.code # "duplicate_id"
310
415
  e.status # 409
@@ -313,14 +418,14 @@ end
313
418
 
314
419
  ## Testing
315
420
 
316
- The tests of this gem build the daemon from the repository (`go build ./cmd/ndb`), start it on a short temp unix socket (macOS allows 104 bytes), wait for `GET /health` to return 200, and stop it at exit. To do the same by hand:
421
+ The tests of this gem build the daemon from the repository (`go build ./cmd/ndb`), start it on a short temp unix socket (macOS allows 104 bytes), wait for `GET /_health` to return 200, and stop it at exit. To do the same by hand:
317
422
 
318
423
  ```sh
319
424
  ndb -config /nonexistent.yml -data-dir "$(mktemp -d)" -unix /tmp/ndb-test.sock
320
425
  ```
321
426
 
322
427
  ```ruby
323
- ndb = Ndb::Client.new(socket: "/tmp/ndb-test.sock")
428
+ ndb = Ndb.connect("unix:///tmp/ndb-test.sock")
324
429
  ```
325
430
 
326
431
  Use a new collection name per test. Run the suite with `bundle exec rake test` in `sdk/ruby`.
data/lib/ndb/client.rb CHANGED
@@ -5,10 +5,14 @@ require "uri"
5
5
  require "time"
6
6
 
7
7
  module Ndb
8
- Page = Struct.new(:rows, :next_cursor, :plan, :examined, :total)
9
-
10
- # The HTTP API of the daemon (plan §12.1). It builds JSON and reads
11
- # NDJSON; it has no query semantics.
8
+ # One page of a search or an aggregate: the rows, the cursor of the next
9
+ # page (nil on the last one), the plan, how many rows were examined, and
10
+ # the total (with count: true).
11
+ Page = Data.define(:rows, :next, :plan, :examined, :total)
12
+
13
+ # The HTTP API of the daemon, one method per route. It builds JSON and
14
+ # reads the answers; it has no query semantics. Ndb::Collection is the
15
+ # friendlier layer on top of it.
12
16
  class Client
13
17
  DEFAULT_SOCKET = "/var/run/ndb/ndb.sock"
14
18
 
@@ -33,123 +37,153 @@ module Ndb
33
37
 
34
38
  def socket = @endpoint.path
35
39
 
36
- def find(coll, body)
37
- page(post(coll, "find", Conditions.regexps(body)))
40
+ # A collection (or a pattern such as "logs-2026-*") with the query
41
+ # builder. definition is what the first call declares (PUT /{coll}).
42
+ def collection(name, definition = {})
43
+ Collection.new(self, name, definition)
44
+ end
45
+
46
+ # POST /{coll}/_search: body is { "query", "sort", "fields", "limit",
47
+ # "skip", "after", "count", ... }. Returns a Page.
48
+ def search(coll, body = {})
49
+ page(post(coll, "_search", Conditions.regexps(body)))
50
+ end
51
+
52
+ # POST /{coll}/_count: how many documents match query.
53
+ def count(coll, query = nil)
54
+ body = query ? { query: Conditions.regexps(query) } : {}
55
+
56
+ json(post(coll, "_count", body)).fetch("count")
38
57
  end
39
58
 
59
+ # POST /{coll}/_aggregate: body is { "pipeline", "limit", "skip",
60
+ # "after", "count" }. Returns a Page.
40
61
  def aggregate(coll, body)
41
- page(post(coll, "aggregate", Conditions.regexps(body)))
62
+ page(post(coll, "_aggregate", Conditions.regexps(body)))
42
63
  end
43
64
 
44
65
  # The document with this _id (and its "_v"), or nil.
45
66
  def get(coll, id)
46
- json(request("GET", id_path(coll, id), nil))
67
+ json(request("GET", doc_path(coll, "_doc", id), nil))
47
68
  rescue NotFound
48
69
  nil
49
70
  end
50
71
 
51
- # Each document gets a generated UUIDv7 _id unless it carries one.
72
+ # POST /{coll}/_doc: one document; it gets a generated UUIDv7 _id unless
73
+ # it carries one. Returns {"_id", "_v"}.
74
+ def insert(coll, doc)
75
+ json(post(coll, "_doc", doc))
76
+ end
77
+
78
+ # POST /{coll}/_bulk: many documents in one transaction, all or nothing.
52
79
  # expire_in (seconds, or "30m" / "1h" / "1d") sets the expires_field of
53
- # every document to now + expire_in, in the same transaction.
54
- def insert(coll, docs, expire_in: nil)
55
- body = { insert: docs }
80
+ # every document to now + expire_in. Returns the results.
81
+ #
82
+ # upsert: true merges a document whose _id exists into the stored one
83
+ # (its fields replace the fields of the same name; the other fields
84
+ # stay), like doc_as_upsert in Elasticsearch, instead of duplicate_id.
85
+ def bulk(coll, docs, expire_in: nil, upsert: false)
86
+ body = { docs: docs }
56
87
  body[:expire_in] = expire_in if expire_in
88
+ body[:upsert] = true if upsert
57
89
 
58
- json(post(coll, "insert", body)).fetch("results")
90
+ json(post(coll, "_bulk", body)).fetch("results")
59
91
  end
60
92
 
61
- # Atomic update. A String is the _id of one document (POST /{_id}/update);
62
- # a Hash is a filter (POST /update). The filter and the write operators go
63
- # in one body; the daemon matches and writes in one step on the newest state, so
64
- # concurrent updates never interleave.
93
+ # POST /{coll}/_update/{id}: applies the write operators to the document
94
+ # with this _id, in one atomic step. guard adds conditions on the same
95
+ # document ({ _v: 7 }); matched is 0 when they fail.
65
96
  #
66
- # client.update("stock", { sku: "a", qty: { "$gte" => 1 } }, { "$inc" => { qty: -1 } })
97
+ # client.update("stock", "a", { "$inc" => { qty: -1 } }, guard: { qty: { "$gte" => 1 } })
67
98
  # # => {"matched" => 1, "modified" => 1, "doc" => {...with "_v"}}
68
99
  #
69
- # Operators: $set, $setOnInsert, $unset, $inc, $push, $pull, $min, $max (dot paths work).
70
- # Nothing matched is not an error: {"matched" => 0, "modified" => 0}. A
71
- # version guard is a filter: { _id: "x", _v: 7 }. many: true updates every
72
- # match (no "doc" in the answer) and needs _id or an indexed field in the
73
- # filter; it does not apply to an _id String. upsert: true creates the document from the equality fields
74
- # of the filter when nothing matches. expire_in (seconds, or "30m") sets
75
- # the expires_field.
76
- def update(coll, id_or_filter, ops, many: false, upsert: false, expire_in: nil)
77
- if id_or_filter.is_a?(String)
78
- query = write_query(upsert: upsert, expire_in: expire_in)
79
-
80
- return json(request("POST", "#{id_path(coll, id_or_filter)}/update#{query}", merge_update({}, ops)))
81
- end
100
+ # Operators: $set, $setOnInsert, $unset, $inc, $push, $pull, $min, $max
101
+ # (dot paths work). upsert: true creates the document with this _id.
102
+ def update(coll, id, ops, guard: nil, upsert: false, expire_in: nil)
103
+ body = write_body(update: check_ops(ops), query: guard && Conditions.equality(guard), upsert: upsert, expire_in: expire_in)
104
+
105
+ json(request("POST", doc_path(coll, "_update", id), body))
106
+ end
82
107
 
83
- query = write_query(many: many, upsert: upsert, expire_in: expire_in)
108
+ # POST /{coll}/_update_by_query: the atomic update by query. The daemon
109
+ # matches and writes in one step on the newest state, so concurrent
110
+ # updates never interleave. Without many it updates the first match;
111
+ # many: true updates every match and needs _id or an indexed field in
112
+ # the query. upsert: true creates the document from the equality fields
113
+ # of the query when nothing matches.
114
+ def update_by(coll, query, ops, many: false, upsert: false, expire_in: nil)
115
+ body = write_body(query: Conditions.equality(query), update: check_ops(ops), many: many, upsert: upsert, expire_in: expire_in)
84
116
 
85
- json(post(coll, "update#{query}", merge_update(id_or_filter, ops)))
117
+ json(post(coll, "_update_by_query", body))
86
118
  end
87
119
 
88
- # Deletes by _id (a String), or the documents that the filter matches (one,
89
- # or every match with many: true, which needs _id or an indexed field).
90
- # {"deleted" => n}
91
- def delete(coll, id_or_filter, many: false)
92
- return json(request("DELETE", id_path(coll, id_or_filter), nil)) if id_or_filter.is_a?(String)
120
+ # DELETE /{coll}/_doc/{id}: {"deleted" => 0 | 1}.
121
+ def delete(coll, id)
122
+ json(request("DELETE", doc_path(coll, "_doc", id), nil))
123
+ end
93
124
 
94
- json(post(coll, "delete#{write_query(many: many)}", id_or_filter))
125
+ # POST /{coll}/_delete_by_query: the first match, or every match with
126
+ # many: true (same rule as update_by). {"deleted" => n}
127
+ def delete_by(coll, query, many: false)
128
+ json(post(coll, "_delete_by_query", write_body(query: Conditions.equality(query), many: many)))
95
129
  end
96
130
 
97
131
  # Makes a document expire, like EXPIRE in Redis: in: seconds or "30m", or
98
- # at: a Time or an RFC 3339 String. Returns {"_id", "v", "expires_at"}.
132
+ # at: a Time or an RFC 3339 String. Returns {"_id", "_v", "expires_at"}.
99
133
  # Raises NotFound for a missing or expired document, TtlNotEnabled when
100
134
  # the collection has no expires_field, TtlDerived when the expiry comes
101
135
  # from ttl + time.
102
136
  def expire(coll, id, **time)
103
- body = { _id: id }
137
+ body = {}
104
138
  body[:in] = time[:in] if time[:in]
105
139
  body[:at] = time[:at].respond_to?(:utc) ? time[:at].utc.iso8601(9) : time[:at] if time[:at]
106
140
 
107
- json(post(coll, "expire", body))
141
+ json(request("POST", doc_path(coll, "_expire", id), body))
108
142
  end
109
143
 
110
144
  # Removes the expiry of a document, like PERSIST in Redis.
111
145
  def persist(coll, id)
112
- json(post(coll, "persist", _id: id))
146
+ json(request("POST", doc_path(coll, "_persist", id), nil))
113
147
  end
114
148
 
115
149
  # The seconds left of a document (an Integer, rounded up), -1 when it
116
150
  # never expires, nil when it is missing or expired.
117
151
  def ttl(coll, id)
118
- json(request("GET", "#{id_path(coll, id)}/ttl", nil)).fetch("ttl")
152
+ json(request("GET", doc_path(coll, "_ttl", id), nil)).fetch("ttl")
119
153
  rescue NotFound
120
154
  nil
121
155
  end
122
156
 
123
157
  # Streams the changes of a collection for a named consumer: first the ones
124
158
  # after its last ack, then the new ones. Yields one Hash per event (pos,
125
- # op, id, v, doc). where is the same Hash/Range conditions as find, and
126
- # only the matching events come; limit ends the stream after that many
127
- # events. Without a block it returns an Enumerator. Delivery is at least
128
- # once: ack the pos of an event when its work is done.
159
+ # op, _id, _v, doc). query is the same conditions as a search, and only
160
+ # the matching events come; limit ends the stream after that many events.
161
+ # Without a block it returns an Enumerator. Delivery is at least once: ack
162
+ # the pos of an event when its work is done.
129
163
  #
130
164
  # The stream raises WatchLagged when the consumer is too slow, and
131
165
  # HistoryTruncated when its position is older than the kept WAL; call
132
166
  # watch again for the first, start over for the second.
133
- def watch(coll, consumer, where: nil, from: nil, limit: nil, &block)
134
- return enum_for(:watch, coll, consumer, where: where, from: from, limit: limit) unless block
167
+ def watch(coll, consumer, query: nil, from: nil, limit: nil, &block)
168
+ return enum_for(:watch, coll, consumer, query: query, from: from, limit: limit) unless block
135
169
 
136
170
  body = { consumer: consumer, from: from, limit: limit }.compact
137
- body[:where] = Conditions.equality(where) if where
171
+ body[:query] = Conditions.equality(query) if query
138
172
 
139
- with_ready_retry { stream_events("#{path(coll)}/watch", body, &block) }
173
+ with_ready_retry { stream_events("#{path(coll)}/_watch", body, &block) }
140
174
  end
141
175
 
142
176
  # Stores the position of an event for its consumer: the next watch with
143
177
  # that consumer name starts after it.
144
178
  def ack(coll, consumer, pos)
145
- json(post(coll, "ack", consumer: consumer, pos: pos))
179
+ json(post(coll, "_ack", consumer: consumer, pos: pos))
146
180
  end
147
181
 
148
182
  # Removes every document of the collection and keeps its definition and
149
183
  # indexes. The cost does not depend on the number of documents. Watch
150
184
  # consumers get one event with op "truncate". {"collection", "truncated"}
151
185
  def truncate(coll)
152
- json(request("POST", "#{path(coll)}/truncate?confirm=#{confirm(coll)}", nil))
186
+ json(request("POST", "#{path(coll)}/_truncate?confirm=#{confirm(coll)}", nil))
153
187
  end
154
188
 
155
189
  # Removes the collection: its documents, its definition and the positions
@@ -159,24 +193,64 @@ module Ndb
159
193
  json(request("DELETE", "#{path(coll)}?confirm=#{confirm(coll)}", nil))
160
194
  end
161
195
 
196
+ # PUT /{coll}: creates the collection or merges the definition into it.
162
197
  def ensure_collection(coll, definition = {})
163
198
  json(request("PUT", path(coll), definition))
164
199
  end
165
200
 
201
+ # PUT /{coll}/_indexes/{field}: adds an index, built in the background.
202
+ def ensure_index(coll, field)
203
+ json(request("PUT", doc_path(coll, "_indexes", field), nil)).fetch("indexes")
204
+ end
205
+
206
+ # DELETE /{coll}/_indexes/{field}.
207
+ def drop_index(coll, field)
208
+ json(request("DELETE", doc_path(coll, "_indexes", field), nil)).fetch("indexes")
209
+ end
210
+
211
+ # GET /{coll}/_stats: rows, indexes, files and bytes of one collection.
212
+ def collection_stats(coll)
213
+ json(request("GET", "#{path(coll)}/_stats", nil))
214
+ end
215
+
216
+ # POST /{coll}/_compact: seals and merges every segment now.
217
+ def compact(coll)
218
+ json(post(coll, "_compact", nil))
219
+ end
220
+
221
+ # GET /_health: true when the daemon is ready; raises NotReady while it
222
+ # recovers. It does not wait.
223
+ def health
224
+ request("GET", "/_health", nil, wait: false)
225
+
226
+ true
227
+ end
228
+
229
+ # GET /_collections: one definition per collection.
166
230
  def collections
167
- json(request("GET", "/v1/c", nil)).fetch("collections")
231
+ json(request("GET", "/_collections", nil)).fetch("collections")
232
+ end
233
+
234
+ # GET /_stats: every collection, totals, version and uptime.
235
+ def stats
236
+ json(request("GET", "/_stats", nil))
237
+ end
238
+
239
+ # GET /_size: disk use per collection and per part.
240
+ def size
241
+ json(request("GET", "/_size", nil))
168
242
  end
169
243
 
170
244
  # Uploads an IO (streamed) and returns the reference hash.
171
245
  def put_blob(io, content_type = nil)
172
- json(request("PUT", "/v1/blobs", io, headers: { "Content-Type" => content_type || "application/octet-stream" }))
246
+ json(request("POST", "/_blobs", io, headers: { "Content-Type" => content_type || "application/octet-stream" }))
173
247
  end
174
248
 
175
249
  # The bytes of a blob, or of its range from..to (inclusive).
176
250
  def get_blob(ref, from: nil, to: nil)
177
251
  headers = from ? { "Range" => "bytes=#{from}-#{to}" } : {}
178
252
 
179
- request("GET", "/v1/blobs/#{ref}", nil, headers: headers).body
253
+ request("GET", "/_blobs/#{ref}", nil, headers: headers).body
180
254
  end
181
255
 
182
256
  private
@@ -185,51 +259,50 @@ module Ndb
185
259
  value.to_s.strip.empty? ? nil : value
186
260
  end
187
261
 
188
- def write_query(many: false, upsert: false, expire_in: nil)
189
- params = {}
190
-
191
- params["many"] = "true" if many
192
- params["upsert"] = "true" if upsert
193
- params["expire_in"] = expire_in.to_s if expire_in
194
-
195
- params.empty? ? "" : "?#{URI.encode_www_form(params)}"
262
+ # The body of a write, without the options that are off.
263
+ def write_body(**parts)
264
+ parts.reject { |_, v| v.nil? || v == false }
196
265
  end
197
266
 
198
- # The filter and the operators in one body. An operator key without "$"
199
- # would be read as a filter key by the daemon, so it is refused here.
200
- def merge_update(filter, ops)
267
+ # An operator key without "$" is a mistake, so it is refused here.
268
+ def check_ops(ops)
201
269
  ops.each_key do |key|
202
270
  next if key.to_s.start_with?("$")
203
271
 
204
272
  raise ArgumentError, "update operator #{key.to_s.inspect} must start with $ (for example \"$set\")"
205
273
  end
206
274
 
207
- filter.to_h.transform_keys(&:to_s).merge(ops.to_h.transform_keys(&:to_s))
275
+ ops
208
276
  end
209
277
 
210
- def post(coll, verb, body)
211
- request("POST", "#{path(coll)}/#{verb}", body)
278
+ def post(coll, action, body)
279
+ request("POST", "#{path(coll)}/#{action}", body)
212
280
  end
213
281
 
214
282
  def path(coll)
215
- "/v1/c/#{URI.encode_www_form_component(coll).gsub('+', '%20')}"
283
+ "/#{escape(coll)}"
284
+ end
285
+
286
+ def escape(segment)
287
+ URI.encode_www_form_component(segment).gsub("+", "%20")
216
288
  end
217
289
 
218
290
  def confirm(coll)
219
291
  URI.encode_www_form_component(coll)
220
292
  end
221
293
 
222
- def id_path(coll, id)
223
- "#{path(coll)}/#{URI.encode_www_form_component(id).gsub('+', '%20')}"
294
+ def doc_path(coll, action, id)
295
+ "#{path(coll)}/#{action}/#{escape(id)}"
224
296
  end
225
297
 
226
- def request(method, target, body, headers: {})
298
+ def request(method, target, body, headers: {}, wait: true)
227
299
  payload = encode(body)
228
300
  headers = authorize(headers)
229
301
 
230
302
  safe = idempotent?(method, target) && !payload.respond_to?(:read)
303
+ timeout = wait ? @ready_timeout : 0
231
304
 
232
- with_ready_retry(idempotent: safe) do
305
+ with_ready_retry(idempotent: safe, timeout: timeout) do
233
306
  response = @pool.with { |conn| conn.request(method, target, payload, headers: headers, idempotent: safe) }
234
307
 
235
308
  raise_api_error(response) if response.status >= 300
@@ -239,20 +312,20 @@ module Ndb
239
312
  end
240
313
 
241
314
  def idempotent?(method, target)
242
- method == "GET" || target.match?(%r{/(find|count|aggregate|get|stats)\z})
315
+ method == "GET" || target.match?(%r{/(_search|_count|_aggregate)\z})
243
316
  end
244
317
 
245
318
  # Runs the block again while the daemon is not ready. It repeats only
246
319
  # when the request certainly was not processed (not_ready, a failed
247
320
  # connect), or for a read when the connection dropped. Never a timeout.
248
- def with_ready_retry(idempotent: false)
249
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @ready_timeout
321
+ def with_ready_retry(idempotent: false, timeout: @ready_timeout)
322
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
250
323
  delay = RETRY_BASE
251
324
 
252
325
  begin
253
326
  yield
254
327
  rescue NotReady, ConnectError, ConnectionError => e
255
- raise if @ready_timeout <= 0 || e.is_a?(TimeoutError)
328
+ raise if timeout <= 0 || e.is_a?(TimeoutError)
256
329
  raise unless e.is_a?(NotReady) || e.is_a?(ConnectError) || idempotent
257
330
 
258
331
  wait = (delay / 2) + (rand * delay / 2)
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ndb
4
+ # One collection of the daemon, with the query builder. The first call
5
+ # declares it (PUT /{coll}) with its definition. A name with a "*" at the
6
+ # end is a collection pattern ("logs-2026-*"): it reads every collection
7
+ # whose name starts with the prefix, declares nothing, and its writes raise
8
+ # Ndb::Error with code pattern_read_only.
9
+ #
10
+ # db = Ndb.connect("unix:///run/ndb/ndb.sock")
11
+ # calls = db.collection("calls", time: "ts", indexes: ["status"])
12
+ # calls.insert(status: "answered", ts: Time.now)
13
+ # calls.query(status: "answered").sort(ts: -1).limit(10).all
14
+ class Collection
15
+ attr_reader :client, :name, :definition
16
+
17
+ def initialize(client, name, definition = {})
18
+ @client = client
19
+ @name = name
20
+ @definition = definition
21
+ @ensured = false
22
+ end
23
+
24
+ def pattern? = name.include?("*")
25
+
26
+ # A Hash is one document (_doc) and returns {"_id", "_v"}; an Array is
27
+ # many in one transaction (_bulk) and returns the results. expire_in
28
+ # applies to every document.
29
+ def insert(doc_or_docs = nil, expire_in: nil, **fields)
30
+ doc_or_docs ||= fields
31
+ ready!
32
+
33
+ return client.bulk(name, doc_or_docs, expire_in: expire_in) if doc_or_docs.is_a?(Array)
34
+ return client.bulk(name, [doc_or_docs], expire_in: expire_in).first if expire_in
35
+
36
+ client.insert(name, doc_or_docs)
37
+ end
38
+
39
+ # Writes a document by its _id: creates it when the _id is free, and
40
+ # merges its fields into the stored document when it exists (the other
41
+ # fields stay), like doc_as_upsert in Elasticsearch. No _id is an insert.
42
+ # Returns {"_id", "_v"}.
43
+ def upsert(doc = nil, expire_in: nil, **fields)
44
+ upsert_all([doc || fields], expire_in: expire_in).first
45
+ end
46
+
47
+ # upsert for many documents in one transaction, all or nothing.
48
+ def upsert_all(docs, expire_in: nil)
49
+ ready!
50
+ client.bulk(name, docs, expire_in: expire_in, upsert: true)
51
+ end
52
+
53
+ # The document with this _id, or nil.
54
+ def get(id)
55
+ ready!
56
+ client.get(name, id)
57
+ end
58
+
59
+ # Applies the write operators to the document with this _id (atomic).
60
+ # guard adds conditions on the same document, such as { _v: 7 }.
61
+ def update(id, ops, guard: nil, upsert: false, expire_in: nil)
62
+ ready!
63
+ client.update(name, id, ops, guard: guard, upsert: upsert, expire_in: expire_in)
64
+ end
65
+
66
+ # The atomic update by query: the first match, or every match with many.
67
+ def update_by(query, ops, many: false, upsert: false, expire_in: nil)
68
+ ready!
69
+ client.update_by(name, query, ops, many: many, upsert: upsert, expire_in: expire_in)
70
+ end
71
+
72
+ def delete(id)
73
+ ready!
74
+ client.delete(name, id)
75
+ end
76
+
77
+ def delete_by(query = nil, many: false, **conditions)
78
+ query ||= conditions
79
+ ready!
80
+ client.delete_by(name, query, many: many)
81
+ end
82
+
83
+ # A search; the conditions use the Ruby forms of Ndb::Conditions
84
+ # (Ranges, Times, Regexps, an Array as $in).
85
+ def query(conditions = nil, **keywords)
86
+ Query.new(self).query(conditions || keywords)
87
+ end
88
+
89
+ # A query built elsewhere, such as from a search form, sent as is.
90
+ def raw_query(hash)
91
+ Query.new(self).raw_query(hash)
92
+ end
93
+
94
+ # How many documents match.
95
+ def count(conditions = nil, **keywords)
96
+ query(conditions || keywords).count
97
+ end
98
+
99
+ # A whole search body built elsewhere: { "query", "sort", "limit",
100
+ # "after", "count" }. Returns an Ndb::Page.
101
+ def search(body)
102
+ ready!
103
+ client.search(name, body)
104
+ end
105
+
106
+ def aggregate(pipeline, **opts)
107
+ ready!
108
+ client.aggregate(name, { pipeline: pipeline }.merge(opts))
109
+ end
110
+
111
+ # Each event Hash also has "consumer", so ack(event) needs nothing else.
112
+ def watch(consumer, query: nil, from: nil, limit: nil, &block)
113
+ return enum_for(:watch, consumer, query: query, from: from, limit: limit) unless block
114
+
115
+ ready!
116
+ client.watch(name, consumer, query: query, from: from, limit: limit) do |event|
117
+ block.call(event.merge("consumer" => consumer))
118
+ end
119
+ end
120
+
121
+ # Stores the position of an event from watch for its consumer: the next
122
+ # watch with that consumer name starts after it.
123
+ def ack(event, consumer = nil)
124
+ client.ack(name, consumer || event.fetch("consumer"), event.fetch("pos"))
125
+ end
126
+
127
+ def expire(id, **time)
128
+ ready!
129
+ client.expire(name, id, **time)
130
+ end
131
+
132
+ def persist(id)
133
+ ready!
134
+ client.persist(name, id)
135
+ end
136
+
137
+ def ttl(id)
138
+ ready!
139
+ client.ttl(name, id)
140
+ end
141
+
142
+ def truncate = client.truncate(name)
143
+
144
+ def drop
145
+ @ensured = false
146
+ client.drop(name)
147
+ end
148
+
149
+ def stats
150
+ ready!
151
+ client.collection_stats(name)
152
+ end
153
+
154
+ def compact
155
+ ready!
156
+ client.compact(name)
157
+ end
158
+
159
+ def ensure_index(field)
160
+ ready!
161
+ client.ensure_index(name, field)
162
+ end
163
+
164
+ def drop_index(field)
165
+ ready!
166
+ client.drop_index(name, field)
167
+ end
168
+
169
+ # Declares the collection once (not a pattern).
170
+ def ready!
171
+ return if @ensured || pattern?
172
+
173
+ client.ensure_collection(name, definition)
174
+ @ensured = true
175
+ end
176
+ end
177
+
178
+ # An immutable search on a collection: every step returns a new Query.
179
+ class Query
180
+ include Enumerable
181
+
182
+ def initialize(collection, clauses: [], options: {})
183
+ @collection = collection
184
+ @clauses = clauses
185
+ @options = options
186
+ end
187
+
188
+ # Adds conditions (Ruby forms), ANDed with the ones already there.
189
+ def query(conditions)
190
+ return self if conditions.empty?
191
+
192
+ with(clauses: @clauses + [Conditions.equality(conditions)])
193
+ end
194
+
195
+ # Adds a query built elsewhere, sent as is.
196
+ def raw_query(hash)
197
+ with(clauses: @clauses + [Conditions.json_value(hash)])
198
+ end
199
+
200
+ def sort(spec) = with(sort: spec.to_h { |k, v| [k.to_s, direction(v)] })
201
+ def limit(count) = with(limit: count)
202
+ def skip(count) = with(skip: count)
203
+ def after(token) = with(after: token)
204
+ def fields(*names) = with(fields: names.flatten.map(&:to_s))
205
+
206
+ # The rows of one request: one page with limit, every row without it.
207
+ def all = page.rows
208
+
209
+ def first = limit(1).all.first
210
+
211
+ # One page: rows, next cursor, total (with count: true via #page(count: true)).
212
+ def page(count: false)
213
+ body = body_hash
214
+ body["count"] = true if count
215
+
216
+ @collection.search(body)
217
+ end
218
+
219
+ # How many documents match (POST /{coll}/_count).
220
+ def count
221
+ @collection.ready!
222
+ @collection.client.count(@collection.name, where)
223
+ end
224
+
225
+ # Every row, following the cursor page by page (limit is the page size).
226
+ def each(&block)
227
+ return enum_for(:each) unless block
228
+
229
+ query = self
230
+
231
+ loop do
232
+ result = query.page
233
+ result.rows.each(&block)
234
+
235
+ break unless result.next
236
+
237
+ query = query.after(result.next).with(skip: nil)
238
+ end
239
+ end
240
+
241
+ def body_hash
242
+ body = { "query" => where }.merge(@options.compact.transform_keys(&:to_s))
243
+ body.delete("query") if body["query"].empty?
244
+ body
245
+ end
246
+
247
+ protected
248
+
249
+ def with(**options)
250
+ clauses = options.delete(:clauses) || @clauses
251
+
252
+ self.class.new(@collection, clauses: clauses, options: @options.merge(options))
253
+ end
254
+
255
+ private
256
+
257
+ def where = Conditions.combine(@clauses)
258
+
259
+ def direction(value)
260
+ %w[desc -1].include?(value.to_s.downcase) ? -1 : 1
261
+ end
262
+ end
263
+
264
+ # A client for addr: unix:///path/to/ndb.sock or tcp://host:port. Without
265
+ # addr it reads NDB_URL. See Ndb::Client for the options.
266
+ def self.connect(addr = nil, **opts)
267
+ Client.new(url: addr, **opts)
268
+ end
269
+ end
@@ -4,7 +4,7 @@ require "socket"
4
4
  require "uri"
5
5
 
6
6
  module Ndb
7
- Response = Struct.new(:status, :headers, :body)
7
+ Response = Data.define(:status, :headers, :body)
8
8
 
9
9
  # Where the daemon listens: a unix socket path, or a TCP host and port.
10
10
  # The host name is resolved each time a connection opens, so a changed
@@ -138,7 +138,7 @@ module Ndb
138
138
  def send_request(method, target, body, headers)
139
139
  open_socket unless @socket
140
140
 
141
- head = +"#{method} #{target} HTTP/1.1\r\nHost: #{@endpoint.host_header}\r\nAccept: application/json\r\n"
141
+ head = +"#{method} #{target} HTTP/1.1\r\nHost: #{@endpoint.host_header}\r\nAccept: application/x-ndjson\r\n"
142
142
 
143
143
  { "Content-Type" => "application/json" }.merge(headers).each { |name, value| head << "#{name}: #{value}\r\n" }
144
144
 
data/lib/ndb/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ndb
4
- VERSION = "0.4.0"
4
+ VERSION = "1.0.0"
5
5
  end
data/lib/ndb.rb CHANGED
@@ -6,3 +6,4 @@ require "ndb/errors"
6
6
  require "ndb/conditions"
7
7
  require "ndb/connection"
8
8
  require "ndb/client"
9
+ require "ndb/collection"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ndb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - thadeu
@@ -20,6 +20,7 @@ files:
20
20
  - README.md
21
21
  - lib/ndb.rb
22
22
  - lib/ndb/client.rb
23
+ - lib/ndb/collection.rb
23
24
  - lib/ndb/conditions.rb
24
25
  - lib/ndb/connection.rb
25
26
  - lib/ndb/errors.rb