ndb 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d05135b99146700c912046914e228098dc99c4bd67181074f39ea399c1d2e375
4
+ data.tar.gz: 76be9b065c8505b1a7c59ce69bf66bce0aa179bb6e4368f73facaea10e30c848
5
+ SHA512:
6
+ metadata.gz: b3e6fa3bded8d9ee8824f7c0038e2fe74c462cdd95561b20f0a1548c59b40ff825bb1cd41348c9a2b919166fba3ba2ded2c5b58f61133cd729adf2f915e7c0b9
7
+ data.tar.gz: 5ace970046bad037e2977795771133017dc594ef83f64ee914799d732fb4b71baca7446e2788464644d9e56e75320d0c7987f87305801730a36a1f043d4a6ceb
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 thadeu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,296 @@
1
+ # ndb
2
+
3
+ <img src="https://raw.githubusercontent.com/thadeu/ndb/main/brand/png/lockup-on-light.png" width="220">
4
+
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
+
7
+ Building a Rails app with models? Use [`ndb-rails`](https://github.com/thadeu/ndb/tree/main/sdk/rails) on top of this gem.
8
+
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).
10
+
11
+ ## Install
12
+
13
+ Needs Ruby 3.2 or newer and a running ndb daemon.
14
+
15
+ ```ruby
16
+ # Gemfile
17
+ gem "ndb"
18
+ ```
19
+
20
+ ```sh
21
+ bundle add ndb
22
+ # or: gem install ndb
23
+ ```
24
+
25
+ ```ruby
26
+ require "ndb"
27
+ ```
28
+
29
+ ## Connect
30
+
31
+ ```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
36
+ ```
37
+
38
+ All options:
39
+
40
+ ```ruby
41
+ Ndb::Client.new(
42
+ url: nil, # "unix:///path", "tcp://host:port", or a bare path that starts with /
43
+ socket: nil, # a unix socket path
44
+ token: nil, # bearer token
45
+ timeout: 30, # seconds, per request
46
+ pool_size: 16, # idle connections kept
47
+ ready_timeout: 10 # seconds to retry while the daemon starts; 0 turns it off
48
+ )
49
+ ```
50
+
51
+ - The address is, in order: `url:`, `socket:`, env `NDB_URL`, then the default socket `/var/run/ndb/ndb.sock`.
52
+ - The token is `token:`, or env `NDB_TOKEN`. It goes only in the `Authorization` header. A unix socket does not need it.
53
+ - A bad address raises `ArgumentError` at once.
54
+ - `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.
55
+ - A reused keep-alive connection that the server closed is sent again only for a read. A write is never sent twice.
56
+ - The pool is thread-safe and drops its connections after a `fork`.
57
+
58
+ ## Collections
59
+
60
+ ```ruby
61
+ ndb.ensure_collection("calls",
62
+ id: "call_id",
63
+ indexes: ["status"],
64
+ unique: ["caller"],
65
+ time: "at") # also: expires_field:, ttl:
66
+
67
+ ndb.collections # => [{"name" => "calls", ...}]
68
+ ```
69
+
70
+ `ensure_collection` is idempotent. A different definition for an existing collection raises `Ndb::CatalogConflict`.
71
+
72
+ ## Insert, update, delete
73
+
74
+ Every document has a version. A new document has version 1, and each change adds 1.
75
+
76
+ ```ruby
77
+ results = ndb.insert("calls", [
78
+ { call_id: "c1", status: "answered", caller: "a", duration: 42 },
79
+ { call_id: "c2", status: "failed", caller: "b", duration: 5 }
80
+ ])
81
+ results # => [{"id" => "c1", "v" => 1}, {"id" => "c2", "v" => 1}]
82
+
83
+ ndb.update("calls", "c1", set: { status: "done" }, unset: ["caller"]) # => {"id" => "c1", "v" => 2}
84
+ ndb.update("calls", "c1", set: { duration: 50 }, if_version: 2) # only if the version is still 2
85
+
86
+ ndb.delete("calls", "c2")
87
+ ndb.delete("calls", "c1", if_version: 3)
88
+ ```
89
+
90
+ - `if_version:` is optimistic locking. If another writer changed the document first, you get `Ndb::VersionConflict` (code `version_conflict`). Reload and try again.
91
+ - A duplicate id raises `Ndb::UniqueViolation` (code `duplicate_id`). A broken unique field raises it too (code `unique_violation`).
92
+ - A missing document raises `Ndb::NotFound`.
93
+
94
+ ```ruby
95
+ begin
96
+ ndb.update("calls", "c1", set: { status: "done" }, if_version: version)
97
+ rescue Ndb::VersionConflict
98
+ version = ndb.find("calls", where: { call_id: "c1" }, versions: true).rows.first["_v"]
99
+ retry
100
+ end
101
+ ```
102
+
103
+ ## Find
104
+
105
+ `find` returns a `Ndb::Page` with `rows` (an array of Hash), `next_cursor`, `plan`, `examined` and `total`.
106
+
107
+ ```ruby
108
+ page = ndb.find("calls",
109
+ where: { status: "answered", duration: { "$gte" => 30, "$lte" => 60 } },
110
+ sort: { at: -1 },
111
+ fields: %w[call_id status],
112
+ limit: 50,
113
+ skip: 0)
114
+
115
+ page.rows # => [{"call_id" => "c1", "status" => "answered"}, ...]
116
+ page.plan # "id", "index:status", "scan" or "snapshot"
117
+ page.examined # rows the daemon looked at
118
+ ```
119
+
120
+ 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.
121
+
122
+ 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.)
123
+
124
+ ```ruby
125
+ ndb.find("calls", where: { status: { "$in" => %w[answered failed] } })
126
+ ndb.find("calls", where: { duration: { "$gte" => 30, "$lte" => 60 } })
127
+ ndb.find("calls", where: { "$or" => [{ status: "failed" }, { duration: { "$gt" => 50 } }] })
128
+ ndb.find("calls", where: { "agent.id" => 7 }) # dot path
129
+ ```
130
+
131
+ - `sort: { at: -1, call_id: 1 }`: `1` ascending, `-1` descending. The key order is the sort order. The id is always the last key.
132
+ - `versions: true` adds the field `_v` (the document version) to each row.
133
+
134
+ ### Regular expressions
135
+
136
+ 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`.
137
+
138
+ ```ruby
139
+ ndb.find("calls", where: { name: /^alpha/i }) # i: ignore case
140
+ ndb.find("calls", where: { name: { "$regex" => /bet$/ } })
141
+ ndb.find("calls", where: { "$or" => [{ name: /^a/ }, { name: /^b/ }] })
142
+ ```
143
+
144
+ The daemon uses RE2. The mapping to `$options` is:
145
+
146
+ | Ruby | `$options` |
147
+ |---|---|
148
+ | always | `m` (Ruby `^` and `$` match at each line, so the daemon must do the same) |
149
+ | `/i` (IGNORECASE) | `i` |
150
+ | `/m` (MULTILINE: `.` matches a newline) | `s` |
151
+ | `/x` (EXTENDED) | `x` |
152
+
153
+ 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`.
154
+
155
+ 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.
156
+
157
+ ### Count
158
+
159
+ ```ruby
160
+ page = ndb.find("calls", where: { status: "answered" }, count: true, limit: 1)
161
+ page.total # how many documents match, whatever the limit
162
+ ```
163
+
164
+ ### Cursor pagination
165
+
166
+ ```ruby
167
+ cursor = nil
168
+
169
+ loop do
170
+ page = ndb.find("calls", where: { status: "answered" }, sort: { call_id: 1 }, limit: 100, after: cursor)
171
+
172
+ page.rows.each { |row| puts row["call_id"] }
173
+
174
+ cursor = page.next_cursor
175
+
176
+ break if cursor.nil? # nil on the last page
177
+ end
178
+ ```
179
+
180
+ A cursor works only with the same `where` and `sort`. Another query shape raises `Ndb::InvalidCursor`.
181
+
182
+ ## Aggregate
183
+
184
+ The pipeline needs exactly one `$group`. Accumulators: `$count`, `$sum`, `$avg`, `$min`, `$max`, `$first`, `$last`.
185
+
186
+ ```ruby
187
+ page = ndb.aggregate("calls",
188
+ pipeline: [
189
+ { "$match" => { "duration" => { "$gte" => 1 } } },
190
+ { "$group" => {
191
+ "_id" => "$status",
192
+ "n" => { "$count" => {} },
193
+ "avg" => { "$avg" => "$duration" }
194
+ } },
195
+ { "$sort" => { "n" => -1 } }
196
+ ],
197
+ limit: 100,
198
+ count: true)
199
+
200
+ page.rows # => [{"_id" => "answered", "n" => 2, "avg" => 35.5}, ...]
201
+ page.total # with count: true
202
+ ```
203
+
204
+ `$dateTrunc` units are `minute`, `hour`, `day`, `week` and `month`.
205
+
206
+ ## Watch
207
+
208
+ A watch streams the changes of a collection to a named consumer: first the events after its last ack, then new events as they happen. Use it for outbox-style work.
209
+
210
+ ```ruby
211
+ begin
212
+ ndb.watch("calls", "relay", where: { status: "failed" }) do |event|
213
+ # event has "pos", "op" ("put" or "del"), "id", "v" and "doc"
214
+ handle(event)
215
+
216
+ ndb.ack("calls", "relay", event["pos"])
217
+ end
218
+ rescue Ndb::WatchLagged
219
+ retry # the consumer was too slow. watch continues after the last ack.
220
+ rescue Ndb::HistoryTruncated
221
+ raise # the saved position is too old. Start over.
222
+ end
223
+ ```
224
+
225
+ - `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.
226
+ - `from: "now"` starts a new consumer at the end. `limit: n` ends the stream after `n` events.
227
+ - Without a block, `watch` returns an Enumerator: `ndb.watch("calls", "relay", limit: 10).to_a`.
228
+ - 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.
229
+ - `HistoryTruncated` means the WAL files for the position were deleted (`wal_retention`).
230
+ - A watch holds one connection until it ends. It is retried only for the initial open, never in the middle of a stream.
231
+
232
+ ## Blobs
233
+
234
+ Large values live in the blob store. A document keeps only a reference.
235
+
236
+ ```ruby
237
+ stored = ndb.put_blob(File.open("call.wav"), "audio/wav") # an IO, streamed in chunks
238
+ stored # => {"$blob" => "sha256:...", "size" => 123456, "type" => "audio/wav"}
239
+
240
+ ndb.insert("calls", [{ call_id: "c9", recording: stored }])
241
+
242
+ ndb.get_blob(stored["$blob"]) # all the bytes
243
+ ndb.get_blob(stored["$blob"], from: 0, to: 1023) # bytes 0..1023 (inclusive)
244
+ ```
245
+
246
+ The same content always gives the same reference. An IO upload is never retried.
247
+
248
+ ## Errors
249
+
250
+ All errors inherit from `Ndb::Error`, which has `code` (the daemon code) and `status` (the HTTP status).
251
+
252
+ | Class | Code |
253
+ |---|---|
254
+ | `Unauthorized` | `unauthorized` |
255
+ | `NotReady` | `not_ready` |
256
+ | `NotFound` | `not_found` |
257
+ | `UnknownCollection` | `unknown_collection` |
258
+ | `VersionConflict` | `version_conflict` |
259
+ | `UniqueViolation` | `unique_violation`, `duplicate_id` |
260
+ | `CatalogConflict` | `catalog_conflict` |
261
+ | `CatalogInvalid` | `catalog_invalid` |
262
+ | `ReadOnlyCollection` | `direct_collection` |
263
+ | `InvalidQuery` | `invalid_query`, `invalid_request` |
264
+ | `InvalidCursor` | `cursor_invalid`, `cursor_mismatch`, `cursor_expired` |
265
+ | `WatchLagged` | `watch_lagged` |
266
+ | `HistoryTruncated` | `history_truncated` |
267
+ | `ConnectionError` | cannot reach the daemon. Subclasses: `ConnectError` (nothing was sent), `TimeoutError` |
268
+
269
+ Any other code is a plain `Ndb::Error`. A response that is not a daemon error has the code `http_status`.
270
+
271
+ ```ruby
272
+ begin
273
+ ndb.insert("calls", [{ call_id: "c1" }])
274
+ rescue Ndb::UniqueViolation => e
275
+ e.code # "duplicate_id"
276
+ e.status # 409
277
+ end
278
+ ```
279
+
280
+ ## Testing
281
+
282
+ 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:
283
+
284
+ ```sh
285
+ ndb -config /nonexistent.yml -data-dir "$(mktemp -d)" -unix /tmp/ndb-test.sock
286
+ ```
287
+
288
+ ```ruby
289
+ ndb = Ndb::Client.new(socket: "/tmp/ndb-test.sock")
290
+ ```
291
+
292
+ Use a new collection name per test. Run the suite with `bundle exec rake test` in `sdk/ruby`.
293
+
294
+ ## License
295
+
296
+ MIT.
data/lib/ndb/client.rb ADDED
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module Ndb
7
+ Page = Struct.new(:rows, :next_cursor, :plan, :examined, :total)
8
+
9
+ # The HTTP API of the daemon (plan §12.1). It builds JSON and reads
10
+ # NDJSON; it has no query semantics.
11
+ class Client
12
+ DEFAULT_SOCKET = "/var/run/ndb/ndb.sock"
13
+
14
+ attr_reader :pool, :endpoint
15
+
16
+ RETRY_BASE = 0.05
17
+ RETRY_CAP = 1.0
18
+
19
+ # The address is, in order: url:, socket:, env NDB_URL, the default
20
+ # socket. The token is token:, or env NDB_TOKEN. It goes only in the
21
+ # Authorization header, and the unix socket does not need it.
22
+ #
23
+ # ready_timeout is how many seconds a call retries while the daemon is
24
+ # starting or recovering (not_ready, socket missing or refusing), with
25
+ # exponential backoff and jitter. 0 turns it off.
26
+ def initialize(url: nil, socket: nil, token: nil, timeout: 30, pool_size: 16, ready_timeout: 10)
27
+ @endpoint = Endpoint.parse(url || socket || presence(ENV["NDB_URL"]) || DEFAULT_SOCKET)
28
+ @token = presence(token) || presence(ENV["NDB_TOKEN"])
29
+ @pool = Pool.new(@endpoint, timeout: timeout, size: pool_size)
30
+ @ready_timeout = ready_timeout.to_f
31
+ end
32
+
33
+ def socket = @endpoint.path
34
+
35
+ def find(coll, body)
36
+ page(post(coll, "find", Conditions.regexps(body)))
37
+ end
38
+
39
+ def aggregate(coll, body)
40
+ page(post(coll, "aggregate", Conditions.regexps(body)))
41
+ end
42
+
43
+ def insert(coll, docs)
44
+ json(post(coll, "insert", insert: docs)).fetch("results")
45
+ end
46
+
47
+ def update(coll, id, set: {}, unset: [], if_version: nil)
48
+ change = { id: id, set: set, unset: unset }
49
+ change[:if_version] = if_version if if_version
50
+
51
+ json(post(coll, "update", update: change))
52
+ end
53
+
54
+ def delete(coll, id, if_version: nil)
55
+ change = { id: id }
56
+ change[:if_version] = if_version if if_version
57
+
58
+ json(post(coll, "delete", delete: change))
59
+ end
60
+
61
+ # Streams the changes of a collection for a named consumer: first the ones
62
+ # after its last ack, then the new ones. Yields one Hash per event (pos,
63
+ # op, id, v, doc). where is the same Hash/Range conditions as find, and
64
+ # only the matching events come; limit ends the stream after that many
65
+ # events. Without a block it returns an Enumerator. Delivery is at least
66
+ # once: ack the pos of an event when its work is done.
67
+ #
68
+ # The stream raises WatchLagged when the consumer is too slow, and
69
+ # HistoryTruncated when its position is older than the kept WAL; call
70
+ # watch again for the first, start over for the second.
71
+ def watch(coll, consumer, where: nil, from: nil, limit: nil, &block)
72
+ return enum_for(:watch, coll, consumer, where: where, from: from, limit: limit) unless block
73
+
74
+ body = { consumer: consumer, from: from, limit: limit }.compact
75
+ body[:where] = Conditions.equality(where) if where
76
+
77
+ with_ready_retry { stream_events("#{path(coll)}/watch", body, &block) }
78
+ end
79
+
80
+ # Stores the position of an event for its consumer: the next watch with
81
+ # that consumer name starts after it.
82
+ def ack(coll, consumer, pos)
83
+ json(post(coll, "ack", consumer: consumer, pos: pos))
84
+ end
85
+
86
+ def ensure_collection(coll, definition)
87
+ json(request("PUT", path(coll), definition))
88
+ end
89
+
90
+ def collections
91
+ json(request("GET", "/v1/c", nil)).fetch("collections")
92
+ end
93
+
94
+ # Uploads an IO (streamed) and returns the reference hash.
95
+ def put_blob(io, content_type = nil)
96
+ json(request("PUT", "/v1/blobs", io, headers: { "Content-Type" => content_type || "application/octet-stream" }))
97
+ end
98
+
99
+ # The bytes of a blob, or of its range from..to (inclusive).
100
+ def get_blob(ref, from: nil, to: nil)
101
+ headers = from ? { "Range" => "bytes=#{from}-#{to}" } : {}
102
+
103
+ request("GET", "/v1/blobs/#{ref}", nil, headers: headers).body
104
+ end
105
+
106
+ private
107
+
108
+ def presence(value)
109
+ value.to_s.strip.empty? ? nil : value
110
+ end
111
+
112
+ def post(coll, verb, body)
113
+ request("POST", "#{path(coll)}/#{verb}", body)
114
+ end
115
+
116
+ def path(coll)
117
+ "/v1/c/#{URI.encode_www_form_component(coll).gsub('+', '%20')}"
118
+ end
119
+
120
+ def request(method, target, body, headers: {})
121
+ payload = encode(body)
122
+ headers = authorize(headers)
123
+
124
+ safe = idempotent?(method, target) && !payload.respond_to?(:read)
125
+
126
+ with_ready_retry(idempotent: safe) do
127
+ response = @pool.with { |conn| conn.request(method, target, payload, headers: headers, idempotent: safe) }
128
+
129
+ raise_api_error(response) if response.status >= 300
130
+
131
+ response
132
+ end
133
+ end
134
+
135
+ def idempotent?(method, target)
136
+ method == "GET" || target.match?(%r{/(find|count|aggregate|get|stats)\z})
137
+ end
138
+
139
+ # Runs the block again while the daemon is not ready. It repeats only
140
+ # when the request certainly was not processed (not_ready, a failed
141
+ # connect), or for a read when the connection dropped. Never a timeout.
142
+ def with_ready_retry(idempotent: false)
143
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @ready_timeout
144
+ delay = RETRY_BASE
145
+
146
+ begin
147
+ yield
148
+ rescue NotReady, ConnectError, ConnectionError => e
149
+ raise if @ready_timeout <= 0 || e.is_a?(TimeoutError)
150
+ raise unless e.is_a?(NotReady) || e.is_a?(ConnectError) || idempotent
151
+
152
+ wait = (delay / 2) + (rand * delay / 2)
153
+
154
+ raise if Process.clock_gettime(Process::CLOCK_MONOTONIC) + wait > deadline
155
+
156
+ sleep wait
157
+
158
+ delay = [delay * 2, RETRY_CAP].min
159
+
160
+ retry
161
+ end
162
+ end
163
+
164
+ def authorize(headers)
165
+ @token ? headers.merge("Authorization" => "Bearer #{@token}") : headers
166
+ end
167
+
168
+ # JSON for a Hash, "" for nothing, an IO as it is (blob upload).
169
+ def encode(body)
170
+ return "" if body.nil?
171
+ return body if body.respond_to?(:read)
172
+
173
+ JSON.generate(body)
174
+ end
175
+
176
+ def json(response)
177
+ JSON.parse(response.body)
178
+ end
179
+
180
+ def page(response)
181
+ headers = response.headers
182
+
183
+ Page.new(
184
+ ndjson(response.body),
185
+ headers["x-ndb-next"],
186
+ headers["x-ndb-plan"],
187
+ headers["x-ndb-examined"]&.to_i,
188
+ headers["x-ndb-total"]&.to_i
189
+ )
190
+ end
191
+
192
+ def ndjson(body)
193
+ body.each_line.filter_map do |line|
194
+ line = line.strip
195
+
196
+ JSON.parse(line) unless line.empty?
197
+ end
198
+ end
199
+
200
+ def stream_events(target, body, &block)
201
+ pending = +""
202
+
203
+ response = @pool.with do |conn|
204
+ conn.stream("POST", target, JSON.generate(body), headers: authorize({})) do |chunk|
205
+ pending << chunk
206
+
207
+ while (newline = pending.index("\n"))
208
+ emit_event(pending.slice!(0, newline + 1), &block)
209
+ end
210
+ end
211
+ end
212
+
213
+ raise_api_error(response) if response.status >= 300
214
+ end
215
+
216
+ # A heartbeat is skipped; an error line raises.
217
+ def emit_event(line)
218
+ line = line.strip
219
+
220
+ return if line.empty?
221
+
222
+ event = JSON.parse(line)
223
+
224
+ return if event["heartbeat"]
225
+
226
+ if (error = event["error"])
227
+ raise Ndb.error_for(error["code"], error["message"], 200)
228
+ end
229
+
230
+ yield event
231
+ end
232
+
233
+ def raise_api_error(response)
234
+ parsed = JSON.parse(response.body)
235
+
236
+ raise Ndb.error_for("not_ready", "ndb is recovering", 503) if response.status == 503 && parsed["status"] == "recovering"
237
+
238
+ error = parsed.fetch("error")
239
+
240
+ raise Ndb.error_for(error["code"], error["message"], response.status)
241
+ rescue JSON::ParserError, KeyError
242
+ raise Error.new("HTTP #{response.status}: #{response.body.strip}", code: "http_status", status: response.status)
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Ndb
6
+ # Ruby values -> the where JSON of the daemon (plan §12.2). No semantics:
7
+ # every operator is evaluated by the daemon.
8
+ module Conditions
9
+ module_function
10
+
11
+ # {status: "x", n: 1..3} -> {"status" => "x", "n" => {"$gte" => 1, "$lte" => 3}}
12
+ # A key that starts with "$" ("$or", "$and", "$not") is passed as is.
13
+ def equality(attrs)
14
+ attrs.to_h do |field, value|
15
+ key = field.to_s
16
+
17
+ [key, key.start_with?("$") ? json_value(value) : condition(value)]
18
+ end
19
+ end
20
+
21
+ # where.not: every field is negated on its own.
22
+ def negation(attrs)
23
+ attrs.each_with_object({}) do |(field, value), out|
24
+ key = field.to_s
25
+
26
+ case value
27
+ when nil then out[key] = { "$exists" => true }
28
+ when Array then out[key] = { "$nin" => json_value(value) }
29
+ when Regexp then out[key] = { "$not" => json_value(value) }
30
+ when Range then out["$not"] = { key => condition(value) }
31
+ when Hash then out["$not"] = { key => condition(value) }
32
+ else out[key] = { "$ne" => json_value(value) }
33
+ end
34
+ end
35
+ end
36
+
37
+ # AND of clauses: flat when the keys are apart, "$and" when they collide.
38
+ def combine(clauses)
39
+ clauses.reduce({}) do |acc, clause|
40
+ next acc.merge(clause) if (acc.keys & clause.keys).empty?
41
+
42
+ { "$and" => [acc, clause] }
43
+ end
44
+ end
45
+
46
+ def condition(value)
47
+ case value
48
+ when nil then { "$exists" => false }
49
+ when Array then { "$in" => json_value(value) }
50
+ when Range then range(value)
51
+ when Hash then json_value(value)
52
+ else json_value(value)
53
+ end
54
+ end
55
+
56
+ def range(range)
57
+ out = {}
58
+
59
+ out["$gte"] = json_value(range.begin) unless range.begin.nil?
60
+
61
+ unless range.end.nil?
62
+ out[range.exclude_end? ? "$lt" : "$lte"] = json_value(range.end)
63
+ end
64
+
65
+ out
66
+ end
67
+
68
+ # Times become RFC 3339 strings with milliseconds; the rest is JSON as is.
69
+ def json_value(value)
70
+ case value
71
+ when Time, DateTime then value.utc.iso8601(3)
72
+ when Date then value.iso8601
73
+ when Symbol then value.to_s
74
+ when Array then value.map { |v| json_value(v) }
75
+ when Regexp then regex(value)
76
+ when Hash then hash_value(value)
77
+ else time_like?(value) ? value.utc.iso8601(3) : value
78
+ end
79
+ end
80
+
81
+ def hash_value(hash)
82
+ hash.each_with_object({}) do |(key, value), out|
83
+ if key.to_s == "$regex" && value.is_a?(Regexp)
84
+ out.merge!(regex(value))
85
+ else
86
+ out[key.to_s] = json_value(value)
87
+ end
88
+ end
89
+ end
90
+
91
+ # Only the Regexp objects of a request body become $regex; every other
92
+ # value is left as it is (find and aggregate send the body as it is).
93
+ def regexps(value)
94
+ case value
95
+ when Regexp then regex(value)
96
+ when Array then value.map { |v| regexps(v) }
97
+ when Hash
98
+ value.each_with_object({}) do |(key, v), out|
99
+ if key.to_s == "$regex" && v.is_a?(Regexp)
100
+ out.merge!(regex(v))
101
+ else
102
+ out[key] = regexps(v)
103
+ end
104
+ end
105
+ else value
106
+ end
107
+ end
108
+
109
+ # A Ruby Regexp -> {"$regex" => source, "$options" => flags}. The flag m
110
+ # is always set: in Ruby ^ and $ match at each line. IGNORECASE is i,
111
+ # MULTILINE (dot matches a newline) is s, EXTENDED is x.
112
+ def regex(regexp)
113
+ reject_unsupported!(regexp.source)
114
+
115
+ options = +"m"
116
+
117
+ options << "i" if regexp.options.anybits?(Regexp::IGNORECASE)
118
+ options << "s" if regexp.options.anybits?(Regexp::MULTILINE)
119
+ options << "x" if regexp.options.anybits?(Regexp::EXTENDED)
120
+
121
+ { "$regex" => regexp.source, "$options" => options.chars.sort_by { |c| "imsx".index(c) }.join }
122
+ end
123
+
124
+ # The daemon uses RE2. These Ruby features do not exist there, so they
125
+ # fail here, before any request.
126
+ def reject_unsupported!(source)
127
+ feature = unsupported_feature(source)
128
+
129
+ return unless feature
130
+
131
+ raise InvalidQuery.new("regexp /#{source}/ uses #{feature}, which RE2 does not support",
132
+ code: "invalid_query")
133
+ end
134
+
135
+ LOOKAROUND = { "?=" => "look-ahead (?=", "?!" => "negative look-ahead (?!",
136
+ "?<=" => "look-behind (?<=", "?<!" => "negative look-behind (?<!",
137
+ "?>" => "an atomic group (?>" }.freeze
138
+ ESCAPES = { "h" => "\\h", "G" => "\\G", "R" => "\\R", "X" => "\\X", "k" => "\\k<name>" }.freeze
139
+
140
+ def unsupported_feature(source)
141
+ in_class = false
142
+ quantifier = false
143
+ i = 0
144
+
145
+ while i < source.length
146
+ char = source[i]
147
+
148
+ if char == "\\"
149
+ nxt = source[i + 1].to_s
150
+
151
+ return "a backreference \\#{nxt}" if nxt.match?(/[1-9]/)
152
+ return ESCAPES[nxt] if ESCAPES.key?(nxt)
153
+
154
+ quantifier = false
155
+ i += 2
156
+
157
+ next
158
+ end
159
+
160
+ if in_class
161
+ in_class = false if char == "]"
162
+ elsif char == "["
163
+ in_class = true
164
+ i += 1 if source[i + 1] == "^"
165
+ i += 1 if source[i + 1] == "]"
166
+ elsif char == "("
167
+ LOOKAROUND.each { |prefix, name| return name if source[i + 1, prefix.length] == prefix }
168
+ elsif char == "+" && quantifier
169
+ return "a possessive quantifier"
170
+ end
171
+
172
+ quantifier = !in_class && %w[* + ?].include?(char) && !(char == "?" && source[i - 1] == "(")
173
+ i += 1
174
+ end
175
+
176
+ nil
177
+ end
178
+
179
+ # ActiveSupport::TimeWithZone and the like, without a dependency on them.
180
+ def time_like?(value)
181
+ value.respond_to?(:utc) && value.respond_to?(:iso8601) && !value.is_a?(Date)
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "uri"
5
+
6
+ module Ndb
7
+ Response = Struct.new(:status, :headers, :body)
8
+
9
+ # Where the daemon listens: a unix socket path, or a TCP host and port.
10
+ # The host name is resolved each time a connection opens, so a changed
11
+ # DNS record is followed after a reconnect.
12
+ class Endpoint
13
+ attr_reader :path, :host, :port
14
+
15
+ # "unix:///path", "tcp://host:port", or a bare socket path.
16
+ def self.parse(address)
17
+ address = address.to_s
18
+
19
+ return new(path: address) if address.start_with?("/")
20
+
21
+ uri = URI.parse(address)
22
+
23
+ case uri.scheme
24
+ when "unix" then new(path: uri.path)
25
+ when "tcp" then new(host: uri.host, port: uri.port)
26
+ else raise ArgumentError, "ndb address #{address.inspect} must be unix:///path or tcp://host:port"
27
+ end
28
+ rescue URI::InvalidURIError
29
+ raise ArgumentError, "ndb address #{address.inspect} must be unix:///path or tcp://host:port"
30
+ end
31
+
32
+ def initialize(path: nil, host: nil, port: nil)
33
+ if path.to_s.empty? && (host.to_s.empty? || port.nil?)
34
+ raise ArgumentError, "ndb address needs a socket path or a host and port"
35
+ end
36
+
37
+ @path = path
38
+ @host = host
39
+ @port = port
40
+ end
41
+
42
+ def unix? = !@path.to_s.empty?
43
+
44
+ def to_s = unix? ? "unix://#{@path}" : "tcp://#{@host}:#{@port}"
45
+
46
+ def host_header = unix? ? "ndb" : "#{@host}:#{@port}"
47
+
48
+ def connect(timeout)
49
+ return UNIXSocket.new(@path) if unix?
50
+
51
+ TCPSocket.new(@host, @port, connect_timeout: timeout).tap { |sock| sock.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) }
52
+ end
53
+ end
54
+
55
+ # One HTTP/1.1 keep-alive connection on a unix socket or TCP. Not
56
+ # thread-safe: the Pool hands one connection to one thread at a time.
57
+ class Connection
58
+ STREAM_CHUNK = 256 * 1024
59
+
60
+ attr_reader :socket
61
+
62
+ def initialize(endpoint, timeout)
63
+ @endpoint = endpoint
64
+ @timeout = timeout
65
+ @socket = nil
66
+ end
67
+
68
+ # body is a String, or an IO that is sent chunked and never retried. A
69
+ # dropped reused connection is sent again only when idempotent is true
70
+ # (a read): a write may already have been processed.
71
+ def request(method, target, body, headers: {}, idempotent: false)
72
+ reused = !@socket.nil?
73
+
74
+ begin
75
+ send_request(method, target, body, headers)
76
+ read_response
77
+ rescue EOFError, Errno::EPIPE, Errno::ECONNRESET, IOError
78
+ close
79
+
80
+ raise unless reused && idempotent && !body.respond_to?(:read)
81
+
82
+ reused = false
83
+
84
+ retry
85
+ end
86
+ rescue SystemCallError, EOFError, IOError => e
87
+ close
88
+
89
+ raise ConnectionError, "cannot reach ndb at #{@endpoint}: #{e.message}"
90
+ rescue IO::TimeoutError
91
+ close
92
+
93
+ raise TimeoutError, "ndb did not answer within #{@timeout}s"
94
+ end
95
+
96
+ # Sends the request and yields the body of a 2xx response chunk by chunk,
97
+ # as it arrives (a watch stream). The connection is closed at the end: a
98
+ # stream is never reused. An error response is read whole and returned.
99
+ def stream(method, target, body, headers: {}, &)
100
+ close
101
+
102
+ send_request(method, target, body, headers)
103
+
104
+ status = @socket.gets("\r\n") or raise EOFError
105
+ response_headers = read_headers
106
+ code = status.split(" ", 3)[1].to_i
107
+
108
+ return Response.new(code, response_headers, read_body(response_headers)) if code >= 300
109
+
110
+ each_body_chunk(response_headers, &)
111
+
112
+ Response.new(code, response_headers, nil)
113
+ rescue SystemCallError, EOFError, IOError => e
114
+ raise ConnectionError, "cannot reach ndb at #{@endpoint}: #{e.message}"
115
+ rescue IO::TimeoutError
116
+ raise TimeoutError, "ndb did not answer within #{@timeout}s"
117
+ ensure
118
+ close
119
+ end
120
+
121
+ def close
122
+ @socket&.close
123
+ rescue IOError
124
+ nil
125
+ ensure
126
+ @socket = nil
127
+ end
128
+
129
+ private
130
+
131
+ def open_socket
132
+ @socket = @endpoint.connect(@timeout)
133
+ @socket.timeout = @timeout
134
+ rescue SystemCallError, SocketError => e
135
+ raise ConnectError, "cannot reach ndb at #{@endpoint}: #{e.message}"
136
+ end
137
+
138
+ def send_request(method, target, body, headers)
139
+ open_socket unless @socket
140
+
141
+ head = +"#{method} #{target} HTTP/1.1\r\nHost: #{@endpoint.host_header}\r\nAccept: application/json\r\n"
142
+
143
+ { "Content-Type" => "application/json" }.merge(headers).each { |name, value| head << "#{name}: #{value}\r\n" }
144
+
145
+ return send_streamed(head, body) if body.respond_to?(:read)
146
+
147
+ head << "Content-Length: #{body.bytesize}\r\n\r\n"
148
+
149
+ @socket.write(head, body)
150
+ end
151
+
152
+ def send_streamed(head, io)
153
+ @socket.write(head, "Transfer-Encoding: chunked\r\n\r\n")
154
+
155
+ while (chunk = io.read(STREAM_CHUNK))
156
+ @socket.write(chunk.bytesize.to_s(16), "\r\n", chunk, "\r\n")
157
+ end
158
+
159
+ @socket.write("0\r\n\r\n")
160
+ end
161
+
162
+ def read_response
163
+ status = @socket.gets("\r\n") or raise EOFError
164
+ headers = read_headers
165
+ body = read_body(headers)
166
+
167
+ close if headers["connection"].to_s.casecmp?("close")
168
+
169
+ Response.new(status.split(" ", 3)[1].to_i, headers, body)
170
+ end
171
+
172
+ def read_headers
173
+ headers = {}
174
+
175
+ while (line = @socket.gets("\r\n")) && line != "\r\n"
176
+ name, value = line.split(":", 2)
177
+ headers[name.downcase] = value.to_s.strip
178
+ end
179
+
180
+ headers
181
+ end
182
+
183
+ def read_body(headers)
184
+ return read_chunked if headers["transfer-encoding"].to_s.casecmp?("chunked")
185
+ return (@socket.read(headers["content-length"].to_i) || +"") if headers["content-length"]
186
+
187
+ body = @socket.read.to_s
188
+ close
189
+
190
+ body
191
+ end
192
+
193
+ def each_body_chunk(headers)
194
+ return yield(read_body(headers)) unless headers["transfer-encoding"].to_s.casecmp?("chunked")
195
+
196
+ loop do
197
+ size = @socket.gets("\r\n").to_s.to_i(16)
198
+
199
+ break if size.zero?
200
+
201
+ yield @socket.read(size)
202
+ @socket.read(2)
203
+ end
204
+ end
205
+
206
+ def read_chunked
207
+ body = +""
208
+
209
+ loop do
210
+ size = @socket.gets("\r\n").to_s.to_i(16)
211
+
212
+ break if size.zero?
213
+
214
+ body << @socket.read(size)
215
+ @socket.read(2)
216
+ end
217
+
218
+ @socket.gets("\r\n")
219
+
220
+ body
221
+ end
222
+ end
223
+
224
+ # A small thread-safe pool: one connection per in-flight request, idle
225
+ # ones are kept for keep-alive. It drops its connections after a fork.
226
+ class Pool
227
+ attr_reader :created
228
+
229
+ def initialize(endpoint, timeout:, size:)
230
+ @endpoint = endpoint
231
+ @timeout = timeout
232
+ @size = size
233
+ @mutex = Mutex.new
234
+ @idle = []
235
+ @created = 0
236
+ @pid = Process.pid
237
+ end
238
+
239
+ def with
240
+ conn = checkout
241
+
242
+ begin
243
+ yield conn
244
+ rescue StandardError
245
+ conn.close
246
+
247
+ raise
248
+ ensure
249
+ checkin(conn)
250
+ end
251
+ end
252
+
253
+ def idle_connections
254
+ @mutex.synchronize { @idle.dup }
255
+ end
256
+
257
+ private
258
+
259
+ def checkout
260
+ @mutex.synchronize do
261
+ reset_after_fork
262
+
263
+ return @idle.pop if @idle.any?
264
+
265
+ @created += 1
266
+
267
+ Connection.new(@endpoint, @timeout)
268
+ end
269
+ end
270
+
271
+ def checkin(conn)
272
+ @mutex.synchronize do
273
+ @idle << conn if @idle.size < @size
274
+ end
275
+ end
276
+
277
+ def reset_after_fork
278
+ return if @pid == Process.pid
279
+
280
+ @idle.clear
281
+ @pid = Process.pid
282
+ end
283
+ end
284
+ end
data/lib/ndb/errors.rb ADDED
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ndb
4
+ # An error the daemon answered (code + HTTP status), or a transport failure.
5
+ class Error < StandardError
6
+ attr_reader :code, :status
7
+
8
+ def initialize(message = nil, code: nil, status: nil)
9
+ super(message)
10
+
11
+ @code = code
12
+ @status = status
13
+ end
14
+ end
15
+
16
+ class ConnectionError < Error; end
17
+ class TimeoutError < ConnectionError; end
18
+
19
+ # The connect failed: the socket is missing or refuses. Nothing was sent.
20
+ class ConnectError < ConnectionError; end
21
+
22
+ # The daemon is up but still recovering (HTTP 503). Nothing was done.
23
+ class NotReady < Error; end
24
+
25
+ class Unauthorized < Error; end
26
+ class NotFound < Error; end
27
+ class UnknownCollection < Error; end
28
+ class VersionConflict < Error; end
29
+ class UniqueViolation < Error; end
30
+ class CatalogConflict < Error; end
31
+ class CatalogInvalid < Error; end
32
+ class ReadOnlyCollection < Error; end
33
+ class InvalidQuery < Error; end
34
+ class InvalidCursor < Error; end
35
+
36
+ # The consumer was too slow: call watch again, it resumes from the last ack.
37
+ class WatchLagged < Error; end
38
+
39
+ # The WAL files that hold the history were deleted (wal_retention): the
40
+ # consumer cannot resume from its position and must start over.
41
+ class HistoryTruncated < Error; end
42
+
43
+ # The daemon error code -> the Ruby class.
44
+ CODE_TO_ERROR = {
45
+ "unauthorized" => Unauthorized,
46
+ "not_ready" => NotReady,
47
+ "not_found" => NotFound,
48
+ "unknown_collection" => UnknownCollection,
49
+ "version_conflict" => VersionConflict,
50
+ "unique_violation" => UniqueViolation,
51
+ "duplicate_id" => UniqueViolation,
52
+ "catalog_conflict" => CatalogConflict,
53
+ "catalog_invalid" => CatalogInvalid,
54
+ "direct_collection" => ReadOnlyCollection,
55
+ "invalid_query" => InvalidQuery,
56
+ "invalid_request" => InvalidQuery,
57
+ "cursor_invalid" => InvalidCursor,
58
+ "cursor_mismatch" => InvalidCursor,
59
+ "cursor_expired" => InvalidCursor,
60
+ "watch_lagged" => WatchLagged,
61
+ "history_truncated" => HistoryTruncated
62
+ }.freeze
63
+
64
+ def self.error_for(code, message, status)
65
+ CODE_TO_ERROR.fetch(code, Error).new("#{code}: #{message}", code: code, status: status)
66
+ end
67
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ndb
4
+ VERSION = "0.1.0"
5
+ end
data/lib/ndb.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ndb/version"
4
+ require "ndb/errors"
5
+ require "ndb/conditions"
6
+ require "ndb/connection"
7
+ require "ndb/client"
metadata ADDED
@@ -0,0 +1,53 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ndb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - thadeu
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: 'Ndb::Client talks HTTP to ndb over a unix socket or TCP: collections,
13
+ documents with versions, queries, aggregates, watch streams and blobs. Stdlib only,
14
+ no runtime dependencies. The daemon decides every query semantic.'
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - LICENSE
20
+ - README.md
21
+ - lib/ndb.rb
22
+ - lib/ndb/client.rb
23
+ - lib/ndb/conditions.rb
24
+ - lib/ndb/connection.rb
25
+ - lib/ndb/errors.rb
26
+ - lib/ndb/version.rb
27
+ homepage: https://github.com/thadeu/ndb
28
+ licenses:
29
+ - MIT
30
+ metadata:
31
+ homepage_uri: https://github.com/thadeu/ndb
32
+ source_code_uri: https://github.com/thadeu/ndb/tree/main/sdk/ruby
33
+ changelog_uri: https://github.com/thadeu/ndb/blob/main/CHANGELOG.md
34
+ bug_tracker_uri: https://github.com/thadeu/ndb/issues
35
+ rubygems_mfa_required: 'true'
36
+ rdoc_options: []
37
+ require_paths:
38
+ - lib
39
+ required_ruby_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '3.2'
44
+ required_rubygems_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ requirements: []
50
+ rubygems_version: 3.6.9
51
+ specification_version: 4
52
+ summary: Plain Ruby client for the ndb NDJSON query daemon
53
+ test_files: []