skaidb 1.0.3

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.
@@ -0,0 +1,127 @@
1
+ # Getting started
2
+
3
+ ## Install
4
+
5
+ The gem is published on RubyGems.org as
6
+ [`skaidb`](https://rubygems.org/gems/skaidb); whichever way it is installed
7
+ the library is loaded with `require "skaidb"`.
8
+
9
+ ```sh
10
+ gem install skaidb
11
+ ```
12
+
13
+ With Bundler:
14
+
15
+ ```ruby
16
+ # Gemfile
17
+ gem "skaidb", "~> 1.0"
18
+ ```
19
+
20
+ Or vendor the single file `lib/skaidb.rb` into your project and
21
+ `require_relative` it — it needs nothing beyond Ruby's standard library
22
+ (`socket`, `openssl`, `securerandom`, `bigdecimal`).
23
+
24
+ Ruby 2.7 or newer; CI covers 3.1 through 3.4.
25
+
26
+ ## Connect
27
+
28
+ ```ruby
29
+ require "skaidb"
30
+
31
+ conn = Skaidb.connect(host: "localhost", port: 7000,
32
+ user: "skaidb", password: "secret",
33
+ database: "app")
34
+ ```
35
+
36
+ `connect` opens the TCP connection, runs the four-frame SCRAM-SHA-256
37
+ handshake (verifying the server's signature when a password is given), sends
38
+ the driver's `Hello`, and runs `USE "app"` when `database:` is set. Pass a
39
+ block to have the connection closed when it returns:
40
+
41
+ ```ruby
42
+ Skaidb.connect(host: "localhost", user: "skaidb", password: "secret") do |conn|
43
+ # ...
44
+ end
45
+ ```
46
+
47
+ For a server with authentication disabled, omit `user:` and `password:`.
48
+
49
+ For a cluster, pass every node as `seeds:` — they are tried in random order
50
+ until one accepts the connection and the handshake:
51
+
52
+ ```ruby
53
+ conn = Skaidb.connect(seeds: ["db1", "db2:7000", "db3"], user: "app", password: "secret")
54
+ ```
55
+
56
+ ## Run statements
57
+
58
+ ```ruby
59
+ conn.exec("CREATE TABLE users (PRIMARY KEY (id))")
60
+
61
+ conn.exec_params("INSERT INTO users (id, name, age, tags) VALUES ($1, $2, $3, $4)",
62
+ [1, "Ada", 36, ["math", "eng"]])
63
+
64
+ res = conn.exec_params("SELECT id, name, tags FROM users WHERE age > $1 ORDER BY id", [30])
65
+ res.each { |row| puts "#{row['id']} #{row['name']} #{row['tags'].inspect}" }
66
+ res.ntuples # 1
67
+ res.fields # ["id", "name", "tags"]
68
+ res.rows # [[1, "Ada", ["math", "eng"]]]
69
+
70
+ conn.exec_params("UPDATE users SET age = $1 WHERE id = $2", [37, 1]).cmd_tuples # 1
71
+ ```
72
+
73
+ Placeholders are `$1`, `$2`, … and values are sent **typed** through a
74
+ server-side prepared statement: Strings need no escaping, and Arrays and
75
+ Hashes bind natively. See [types.md](types.md) for the full mapping.
76
+
77
+ ## Bulk insert
78
+
79
+ ```ruby
80
+ rows = (1..10_000).map { |i| [i, "row #{i}"] }
81
+ conn.exec_batch("INSERT INTO t (id, v) VALUES ($1, $2)", rows) # one round-trip
82
+ ```
83
+
84
+ ## Large results
85
+
86
+ ```ruby
87
+ conn.stream("SELECT id, payload FROM events ORDER BY id") do |row|
88
+ process(row)
89
+ end
90
+ ```
91
+
92
+ Rows arrive chunk by chunk; the driver holds one chunk at a time. See
93
+ [streaming.md](streaming.md), in particular what happens when you leave the
94
+ block early.
95
+
96
+ ## Errors
97
+
98
+ ```ruby
99
+ begin
100
+ conn.exec("SELECT nope FROM missing")
101
+ rescue Skaidb::QueryError => e # the server rejected the statement; connection still fine
102
+ warn e.message # table "missing" does not exist
103
+ rescue Skaidb::ConnectionError => e # transport/handshake trouble; next statement re-dials
104
+ warn e.message
105
+ end
106
+ ```
107
+
108
+ Both inherit from `Skaidb::Error`.
109
+
110
+ ## Close
111
+
112
+ ```ruby
113
+ conn.close
114
+ ```
115
+
116
+ Or use the block form of `connect`, or a [pool](pooling.md).
117
+
118
+ ## Next
119
+
120
+ - [API reference](api.md)
121
+ - [Types](types.md)
122
+ - [TLS](tls.md)
123
+ - [Streaming](streaming.md)
124
+ - [Pooling](pooling.md)
125
+ - Runnable scripts in [`examples/`](../examples/)
126
+ - Server documentation: <https://skaidb.org/docs/> — the wire protocol this
127
+ driver speaks: <https://skaidb.org/docs/PROTOCOL.html>
data/docs/pooling.md ADDED
@@ -0,0 +1,47 @@
1
+ # Connection pool
2
+
3
+ `Skaidb::Pool` keeps a bounded set of idle connections and hands one to each
4
+ caller, so a threaded server or job runner can use one connection per
5
+ thread without dialing and authenticating on every request.
6
+
7
+ ```ruby
8
+ pool = Skaidb::Pool.new(seeds: ["db1", "db2", "db3"], user: "app", password: "secret",
9
+ database: "app", maxsize: 8)
10
+
11
+ pool.with do |conn| # checked out; returned when the block ends
12
+ conn.exec_params("SELECT … WHERE id = $1", [id])
13
+ end
14
+
15
+ conn = pool.checkout # the explicit form
16
+ begin
17
+ conn.exec("…")
18
+ ensure
19
+ pool.checkin(conn)
20
+ end
21
+
22
+ pool.close # closes every idle connection
23
+ ```
24
+
25
+ ## Behaviour
26
+
27
+ - Every `Skaidb.connect` keyword passes through (`seeds`, `database`, TLS,
28
+ `consistency`, `timeout`), so pooled connections fail over across seeds
29
+ and run `USE` exactly like single ones.
30
+ - `maxsize` (default 10) bounds the connections kept **idle**, not the number
31
+ checked out. `checkout` never blocks: with no idle connection available it
32
+ dials a new one, and a connection returned while `maxsize` are already idle
33
+ is closed instead of kept.
34
+ - `checkout` and `checkin` both validate with `usable?`: a connection broken
35
+ by a transport error, or left mid-stream by an abandoned Enumerator, is
36
+ closed and dropped rather than handed on. `usable?` is local knowledge
37
+ only — a connection the server closed while it sat idle still looks fine
38
+ and will fail its first statement with `Skaidb::ConnectionError`; that
39
+ failure marks it broken, so `checkin` discards it and the next checkout
40
+ gets a fresh one. Retry the statement in that case if it is safe to.
41
+ - `close` marks the pool closed and closes the idle connections; connections
42
+ checked out at the time are closed when they are returned. `checkout` on a
43
+ closed pool raises `Skaidb::Error`. `maxsize < 1` raises `ArgumentError`.
44
+ - The pool is thread-safe. A single connection should still be used by one
45
+ thread at a time; the pool is what gives each thread its own.
46
+
47
+ See also `examples/pool.rb`.
data/docs/streaming.md ADDED
@@ -0,0 +1,69 @@
1
+ # Streaming large results
2
+
3
+ `exec` and `exec_params` buffer the whole result in a `Skaidb::Result`. For
4
+ a result that should not be held in memory at once, `stream` runs the
5
+ statement over the protocol's streaming opcode: the server answers with a
6
+ header (the column names), then row chunks, then an end marker, and the
7
+ driver yields rows as each chunk arrives, holding one chunk at a time.
8
+
9
+ ```ruby
10
+ conn.stream("SELECT id, payload FROM events ORDER BY id") do |row|
11
+ export(row) # row is a Hash keyed by column name
12
+ end
13
+ ```
14
+
15
+ - `stream(sql, consistency: nil)` takes SQL text only — no parameters.
16
+ - With a block it returns `nil` after the last row.
17
+ - Without a block it returns an Enumerator: `conn.stream(sql).first(10)`,
18
+ `.each_slice(1000)`, `.lazy.map { … }`, and the rest of Enumerable work.
19
+ - A non-row statement (an `INSERT`, DDL, `USE`) answers with its ordinary
20
+ result over the same opcode; `stream` returns `nil` without yielding.
21
+ - A server without the streaming opcode raises `Skaidb::QueryError`
22
+ (`server does not support streaming: …`).
23
+
24
+ ## Errors mid-stream
25
+
26
+ The server can fail a statement after it has started sending rows (a node
27
+ dying mid-scan, a scan budget tripping). The rows already yielded are valid;
28
+ the block stops and `stream` raises `Skaidb::QueryError` with the server's
29
+ message. The connection stays usable.
30
+
31
+ ## The abandon/drain rule
32
+
33
+ The protocol forbids any other request on the connection until the stream
34
+ ends, so the whole exchange runs under the connection's lock: a statement
35
+ from another thread **waits** for the stream to finish rather than
36
+ interleaving with it.
37
+
38
+ Leaving the block early — `break`, `return`, `raise`, or any Enumerable
39
+ method that stops before the end (`first`, `take`, `find`) — unwinds through
40
+ an `ensure` that **drains** the frames the server is still sending, so the
41
+ socket is back at a request boundary and the next statement is clean:
42
+
43
+ ```ruby
44
+ first = conn.stream("SELECT id FROM big ORDER BY id").first(3)
45
+ conn.usable? # true
46
+ conn.exec("SELECT 1")
47
+ ```
48
+
49
+ Draining is not free and there is no cancel opcode: breaking out of a
50
+ million-row scan still transfers the rest of it before the connection is
51
+ usable again. If you only want a few rows, say so in SQL (`LIMIT`).
52
+
53
+ Two things defeat the drain:
54
+
55
+ - **External iteration** — `e = conn.stream(sql); e.next` — runs the stream
56
+ inside the Enumerator's Fiber. A Fiber abandoned part-way is collected
57
+ without running any `ensure`, so nothing drains and the connection stays
58
+ marked mid-stream: `usable?` is `false` for good and a pool will not hand
59
+ it out again. Iterate with a block or `each`.
60
+ - **A frame that makes no sense mid-stream**, or a dead socket. The driver
61
+ no longer knows where the reply ends, so it marks the connection broken
62
+ instead of guessing; `usable?` turns `false`, and the next statement
63
+ re-dials.
64
+
65
+ There is no read deadline: a peer that is alive but silent blocks the drain,
66
+ holding the lock. `close` does not take the lock, so another thread can break
67
+ such a read by closing the connection.
68
+
69
+ See also `examples/stream.rb`.
data/docs/tls.md ADDED
@@ -0,0 +1,59 @@
1
+ # TLS
2
+
3
+ The binary protocol runs over plain TCP by default. A server configured with
4
+ `client_tls = required` refuses plaintext outright, so such a cluster is
5
+ only reachable with TLS switched on in the driver.
6
+
7
+ ## Enabling it
8
+
9
+ Any one of these three keywords turns TLS on:
10
+
11
+ ```ruby
12
+ Skaidb.connect(host: "db1", tls: true) # verify against the system trust store
13
+ Skaidb.connect(host: "db1", tls_ca: "/etc/skaidb/ca.crt") # verify against this CA file only
14
+ Skaidb.connect(host: "db1", tls_insecure: true) # encrypt, verify nothing
15
+ ```
16
+
17
+ - `tls: true` uses OpenSSL's default certificate paths — right when the
18
+ server's certificate chains to a public or system-installed CA.
19
+ - `tls_ca:` trusts exactly the certificates in that PEM file. This is the
20
+ usual shape for a cluster with its own CA: hand the driver the CA
21
+ certificate, nothing else.
22
+ - `tls_insecure: true` sets `VERIFY_NONE`. The connection is encrypted but
23
+ the peer is not authenticated, so a man in the middle can present any
24
+ certificate. Development and throw-away environments only.
25
+
26
+ `Skaidb::Pool` passes all of these through to every connection it opens.
27
+
28
+ ## The server name
29
+
30
+ The driver sends `tls_server_name` as SNI and, unless `tls_insecure`, checks
31
+ it against the certificate's subject alternative names with
32
+ `post_connection_check`. The default is `"skaidb"`, the DNS SAN skaidb's own
33
+ generated certificates carry — which is usually **not** the address you
34
+ dial. If your certificate names the host instead, say so:
35
+
36
+ ```ruby
37
+ Skaidb.connect(host: "db1.internal", tls_ca: "ca.crt", tls_server_name: "db1.internal")
38
+ ```
39
+
40
+ A mismatch fails the connect with `Skaidb::ConnectionError` wrapping
41
+ OpenSSL's message.
42
+
43
+ ## What runs inside the session
44
+
45
+ The TCP connection is upgraded to TLS *before* the SCRAM handshake, so the
46
+ user name, nonces and proof all travel encrypted; with `seeds:`, each
47
+ endpoint is upgraded and authenticated in turn until one succeeds. The
48
+ driver does not present a client certificate; authentication is SCRAM.
49
+
50
+ ## Failures
51
+
52
+ - Wrong CA, expired certificate, name mismatch: `Skaidb::ConnectionError`
53
+ with the OpenSSL message, from `connect` (or from the statement that
54
+ triggered a re-dial).
55
+ - Plain TCP against a `client_tls = required` server: the handshake cannot
56
+ complete, so `connect` raises `Skaidb::ConnectionError`
57
+ (`no reachable endpoint in …`) naming the underlying failure.
58
+
59
+ See also `examples/tls.rb`.
data/docs/types.md ADDED
@@ -0,0 +1,72 @@
1
+ # Types
2
+
3
+ Results decode from skaidb's typed wire values; parameters encode back to
4
+ them when a statement is prepared, which is the normal path whenever you pass
5
+ values to `exec_params` or `exec_batch`.
6
+
7
+ | skaidb type | Result value | Accepted as a parameter |
8
+ |---|---|---|
9
+ | Null | `nil` | `nil` |
10
+ | Bool | `true` / `false` | `true` / `false` |
11
+ | Int (64-bit) | `Integer` | `Integer` in the signed 64-bit range |
12
+ | Float (64-bit) | `Float` | `Float`, finite (NaN and ±Infinity are refused) |
13
+ | Decimal | `BigDecimal`, exact | `BigDecimal`, finite, mantissa within a signed 128-bit integer |
14
+ | String | `String` (UTF-8) | `String` with any text encoding (sent as UTF-8 bytes); `Symbol` |
15
+ | Bytes | `String` with `Encoding::BINARY` | `String` whose encoding is `Encoding::BINARY` (`ASCII-8BIT`) |
16
+ | Uuid | `String`, canonical lowercase `8-4-4-4-12` | `Skaidb::Uuid` |
17
+ | Timestamp | `Time` in UTC, millisecond precision (may predate 1970) | `Time` (any zone) |
18
+ | Array | `Array` of mapped values | `Array` (elements follow this table) |
19
+ | Document | `Hash` with String keys, in the order the server sends them | `Hash` (keys are converted with `to_s`) |
20
+
21
+ ## Notes
22
+
23
+ - **Bytes vs String** is decided by the Ruby String's encoding: `"…".b`,
24
+ `String.new`, `File.binread` and `[…].pack` produce binary Strings and bind
25
+ as Bytes; literals and `File.read` produce text and bind as String. A text
26
+ String is sent as its UTF-8 bytes (it is `force_encoding`'d, not
27
+ transcoded — keep text in UTF-8).
28
+ - **Uuid** results are Strings so they compare and print naturally. To bind
29
+ a value *as* a UUID wrap it: `Skaidb::Uuid.new("6ba7b810-9dad-11d1-80b4-00c04fd430c8")`
30
+ (dashes optional, any case), `Skaidb::Uuid.random`, or
31
+ `Skaidb::Uuid.from_bytes(raw)`. `uuid == "6ba7b810-…"` is true when the
32
+ canonical forms match, so a bound value compares equal to what comes back.
33
+ A plain String parameter binds as String, not Uuid.
34
+ - **Decimal** is `mantissa × 10^-scale` on the wire. `BigDecimal("123.45")`
35
+ is sent as mantissa `12345`, scale `2`; trailing zeros are dropped and a
36
+ positive exponent (`BigDecimal("1e5")`) is folded into the mantissa. A
37
+ mantissa beyond ±2^127 raises `QueryError`.
38
+ - **Document** results keep the key order the server sends; the driver adds
39
+ keys to the `Hash` as they arrive. That order is not the one the document
40
+ was bound in: the server stores a document in canonical form, with keys
41
+ sorted at every nesting level, so `{"zeta" => 1, "alpha" => 2}` comes back
42
+ as `{"alpha" => 2, "zeta" => 1}`. Do not rely on key order.
43
+ - **Timestamp**: a `Time` keeps its instant whatever its zone (local,
44
+ `+05:30`, UTC) and is truncated to the millisecond towards negative
45
+ infinity, so `decode(encode(t)) == t` for any millisecond-precision `Time`.
46
+ Results are always UTC.
47
+ - **Int**: an `Integer` outside `-2^63 … 2^63-1` raises `QueryError` before
48
+ anything is sent. There is no marker to force Float for an integral value;
49
+ bind `3.0`, not `3`, when you mean a Float.
50
+ - **Document** keys become Strings (`{ city: "London" }` is stored with key
51
+ `"city"`); nested Arrays and Hashes are encoded recursively.
52
+ - Any other class — `Date`, `DateTime`, `Rational`, `Set`, `Range`, your own
53
+ objects — raises `QueryError("cannot bind value of type …")`. Convert
54
+ first (`date.to_time`, `rational.to_f`, `set.to_a`).
55
+
56
+ ## The client-side fallback
57
+
58
+ When the server refuses to prepare a statement (DDL, `USE`, and every
59
+ statement on a server without the prepare opcode), `exec_params` renders the
60
+ parameters into the SQL text instead:
61
+
62
+ | Ruby value | Literal |
63
+ |---|---|
64
+ | `nil` | `NULL` |
65
+ | `true` / `false` | `TRUE` / `FALSE` |
66
+ | `Integer`, `Float` (finite) | as printed |
67
+ | `BigDecimal` | plain decimal notation (`123.45`) |
68
+ | `String` (text), `Symbol` | single-quoted, `'` doubled |
69
+ | `String` (binary) | the hex digits, single-quoted |
70
+ | `Skaidb::Uuid` | the canonical form, single-quoted |
71
+ | `Time` | Unix milliseconds |
72
+ | `Array`, `Hash` | refused: `QueryError`, with the server's reason for not preparing the statement appended |