axonbase-sdk 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: f2af53f84385eb0aa349e48950a5ab6e91d8368a918028127f9cd254ec030b14
4
+ data.tar.gz: db4a5d54e6cedf4a7af3cac044dc40c4e352ada002186d2e69b51f561fb8593d
5
+ SHA512:
6
+ metadata.gz: 37c471b91d5be964be66b5267cc747209a68aad3ca61568a42422633f3c6cd9eb5ea03e5d81d00646a8cd24eade0dc190dcef74100b4146766d2fe4e7ef9a780
7
+ data.tar.gz: bf67a5d343902ed310a1485a43b36b9c710a1e057aa2595d18ce9ba1b3a6741e04dc8d04063f88a7ffdb9ad6c0b9c4a1d6867814fa3536c9a4701bfe5309915d
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AxonBase
4
+ class Client
5
+ attr_reader :namespace, :database, :hello
6
+
7
+ def self.connect(url, ssl_context: nil)
8
+ new(WebSocketTransport.new(url, ssl_context: ssl_context))
9
+ end
10
+
11
+ def initialize(transport)
12
+ @transport = transport
13
+ @next_id = 1
14
+ @live_handlers = {}
15
+ frame = decode(@transport.receive)
16
+ raise Error, "expected hello as first WebSocket frame" unless frame["hello"].is_a?(Hash)
17
+ @hello = frame["hello"]
18
+ end
19
+
20
+ def use(namespace, database)
21
+ result = call("use", [namespace, database])
22
+ @namespace = result.fetch("namespace", namespace)
23
+ @database = result.fetch("database", database)
24
+ result
25
+ end
26
+
27
+ def signin(user, pass, access = nil)
28
+ credentials = { "user" => user, "pass" => pass }
29
+ credentials["access"] = access unless access.nil?
30
+ @token = call("signin", [credentials])
31
+ end
32
+
33
+ def authenticate(token)
34
+ call("authenticate", [token])
35
+ @token = token
36
+ nil
37
+ end
38
+
39
+ def query(sql, vars = nil)
40
+ call("query", vars.nil? ? [sql] : [sql, vars])
41
+ end
42
+
43
+ def select(target, where = nil); call("select", where.nil? ? [target] : [target, where]); end
44
+ def create(target, data); call("create", [target, data]); end
45
+ def insert(table, data); call("insert", [table, data]); end
46
+ def update(target, data); call("update", [target, data]); end
47
+ def upsert(target, data); call("upsert", [target, data]); end
48
+ def delete(target, where = nil); call("delete", where.nil? ? [target] : [target, where]); end
49
+ def ping; call("ping", []); end
50
+ def version; call("version", []); end
51
+ def close; @transport.close; end
52
+
53
+ def relate(from, kind, to, data = {})
54
+ call("relate", [from, kind, to, data])
55
+ end
56
+
57
+ def let_var(name, value)
58
+ clean = name.start_with?("$") ? name[1..] : name
59
+ call("let", [clean, value])
60
+ end
61
+
62
+ def unset(name)
63
+ clean = name.start_with?("$") ? name[1..] : name
64
+ call("unset", [clean])
65
+ end
66
+
67
+ def begin; call("begin", []); end
68
+ def commit; call("commit", []); end
69
+ def cancel; call("cancel", []); end
70
+
71
+ def kv_get(ns, db, key); call("kv_get", [ns, db, key]); end
72
+ def kv_set(ns, db, key, value, ttl = nil); call("kv_set", [ns, db, key, value, ttl]); end
73
+ def kv_del(ns, db, key); call("kv_del", [ns, db, key]); end
74
+ def kv_scan(ns, db, prefix); call("kv_scan", [ns, db, prefix]); end
75
+
76
+ def live(table, handler, diff = false)
77
+ id = call("live", [table, diff])
78
+ @live_handlers[id] = handler
79
+ id
80
+ end
81
+
82
+ def kill(id)
83
+ ok = call("kill", [id])
84
+ @live_handlers.delete(id) if ok
85
+ ok
86
+ end
87
+
88
+ def certificate_begin(store)
89
+ result = call("certificate.begin", [{ "store" => store }])
90
+ {
91
+ "id" => result["id"],
92
+ "challenge" => result["challenge"],
93
+ "expires_at" => result["expires_at"],
94
+ }
95
+ end
96
+
97
+ def certificate_complete(completion_hash)
98
+ call("certificate.complete", [completion_hash])
99
+ end
100
+
101
+ private
102
+
103
+ def call(method, params)
104
+ id = @next_id
105
+ @next_id += 1
106
+ @transport.send(JSON.generate("id" => id, "method" => method, "params" => params, "version" => PROTOCOL_VERSION))
107
+ loop do
108
+ response = decode(@transport.receive)
109
+ if response.key?("notification")
110
+ n = response["notification"]
111
+ handler = @live_handlers[n["id"]]
112
+ handler&.call(n["id"], n["action"], n["result"])
113
+ next
114
+ end
115
+ next if response["id"] != id
116
+ raise rpc_error(response["error"]) if response.key?("error")
117
+ return response["result"]
118
+ end
119
+ end
120
+
121
+ def decode(frame)
122
+ JSON.parse(frame)
123
+ rescue JSON::ParserError
124
+ raise Error, "invalid JSON-RPC frame"
125
+ end
126
+
127
+ def rpc_error(error)
128
+ code, message = error.fetch("code", 0), error.fetch("message", "RPC error")
129
+ case code
130
+ when -32_002 then AuthError.new(message, code)
131
+ when -32_009 then ConflictError.new(message, code)
132
+ when -32_010 then NotLeaderError.new(message, error.fetch("leader", ""), error.fetch("leader_address", ""))
133
+ when -32_011 then NoQuorumError.new(message, code)
134
+ when -32_029 then RateLimitError.new(message, code)
135
+ when -32_600
136
+ return ProtocolMismatchError.new(message, error.fetch("protocol", 0), error.fetch("version", 0)) if error["kind"] == "PROTOCOL_MISMATCH"
137
+ Error.new(message, code)
138
+ else Error.new(message, code)
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AxonBase
4
+ class Error < StandardError
5
+ attr_reader :code
6
+ def initialize(message, code = nil)
7
+ super(message)
8
+ @code = code
9
+ end
10
+ end
11
+
12
+ class AuthError < Error; end
13
+ class ConflictError < Error; end
14
+ class NoQuorumError < Error; end
15
+ class RateLimitError < Error; end
16
+
17
+ class NotLeaderError < Error
18
+ attr_reader :leader, :leader_address
19
+ def initialize(message, leader = "", leader_address = "")
20
+ super(message, -32_010)
21
+ @leader = leader
22
+ @leader_address = leader_address
23
+ end
24
+ end
25
+
26
+ class ProtocolMismatchError < Error
27
+ attr_reader :protocol, :server_version
28
+ def initialize(message, protocol, server_version)
29
+ super(message, -32_600)
30
+ @protocol = protocol
31
+ @server_version = server_version
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module AxonBase
6
+ Migration = Struct.new(:version, :name, :path, :checksum, :sql, keyword_init: true)
7
+
8
+ class Migrator
9
+ DEFAULT_TABLE = "_migration"
10
+
11
+ def initialize(axon, table: DEFAULT_TABLE)
12
+ @axon = axon
13
+ @table = table
14
+ @before_hooks = []
15
+ @after_hooks = []
16
+ end
17
+
18
+ def on_before(&hook)
19
+ @before_hooks << hook
20
+ self
21
+ end
22
+
23
+ def on_after(&hook)
24
+ @after_hooks << hook
25
+ self
26
+ end
27
+
28
+ def ensure_table
29
+ @axon.query("DEFINE TABLE #{@table} SCHEMAFULL;" \
30
+ "DEFINE FIELD version ON TABLE #{@table} TYPE string;" \
31
+ "DEFINE FIELD name ON TABLE #{@table} TYPE string;" \
32
+ "DEFINE FIELD checksum ON TABLE #{@table} TYPE string;" \
33
+ "DEFINE FIELD applied_at ON TABLE #{@table} TYPE datetime DEFAULT time::now();")
34
+ nil
35
+ end
36
+
37
+ def applied
38
+ rows = @axon.query("SELECT version, checksum FROM #{@table} ORDER BY version ASC;")
39
+ return {} unless rows.is_a?(Array)
40
+
41
+ rows.each_with_object({}) do |row, result|
42
+ next unless row.is_a?(Hash) && row.key?("version") && row.key?("checksum")
43
+
44
+ result[row["version"].to_s] = row["checksum"].to_s
45
+ end
46
+ rescue Error
47
+ {}
48
+ end
49
+
50
+ def load(path)
51
+ if File.file?(path)
52
+ raise ArgumentError, "not an .axql file: #{path}" unless path.end_with?(".axql")
53
+
54
+ return [parse_file(path)]
55
+ end
56
+ raise ArgumentError, "migration directory not found: #{path}" unless Dir.exist?(path)
57
+
58
+ Dir.glob(File.join(path, "*.axql")).sort.map { |file| parse_file(file) }
59
+ end
60
+
61
+ def status(path)
62
+ completed = applied
63
+ load(path).map { |migration| { migration: migration, applied: completed.key?(migration.version) } }
64
+ end
65
+
66
+ def up(path)
67
+ completed = applied
68
+ pending = load(path).reject do |migration|
69
+ checksum = completed[migration.version]
70
+ next false if checksum.nil?
71
+
72
+ raise "migration checksum changed: #{migration.name}" unless checksum == migration.checksum
73
+
74
+ true
75
+ end
76
+ return [] if pending.empty?
77
+
78
+ ensure_table
79
+ pending.each do |migration|
80
+ @before_hooks.each { |hook| hook.call(migration) }
81
+ @axon.query(migration.sql) unless migration.sql.strip.empty?
82
+ @axon.query(
83
+ "UPSERT #{@table}:v_#{migration.version} CONTENT { version: $v, name: $n, checksum: $c, applied_at: time::now() }",
84
+ "v" => migration.version,
85
+ "n" => migration.name,
86
+ "c" => migration.checksum
87
+ )
88
+ @after_hooks.each { |hook| hook.call(migration) }
89
+ end
90
+ pending
91
+ end
92
+
93
+ private
94
+
95
+ def parse_file(path)
96
+ name = File.basename(path, ".axql")
97
+ Migration.new(
98
+ version: name.split("_", 2).first,
99
+ name: name,
100
+ path: path,
101
+ checksum: Digest::SHA256.file(path).hexdigest,
102
+ sql: File.read(path)
103
+ )
104
+ rescue Errno::ENOENT, Errno::EACCES => error
105
+ raise "failed to read migration: #{path}: #{error.message}"
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AxonBase
4
+ class SagaParticipantTransaction
5
+ attr_reader :correlation_id
6
+
7
+ def initialize(axon, correlation_id)
8
+ @axon = axon
9
+ @correlation_id = correlation_id
10
+ @begun = false
11
+ @finished = false
12
+ end
13
+
14
+ def is_begun?
15
+ @begun
16
+ end
17
+
18
+ def is_finished?
19
+ @finished
20
+ end
21
+
22
+ def begin
23
+ raise Error, "Transaction already begun: #{@correlation_id}" if @begun
24
+
25
+ @axon.begin
26
+ @axon.let_var("saga_corr", @correlation_id)
27
+ @begun = true
28
+ rescue StandardError
29
+ @axon.cancel rescue nil
30
+ raise
31
+ end
32
+
33
+ def step(axonql)
34
+ assert_active
35
+ @axon.query(axonql)
36
+ end
37
+
38
+ def commit
39
+ assert_active
40
+ @axon.commit
41
+ @finished = true
42
+ end
43
+
44
+ def rollback
45
+ return unless @begun && !@finished
46
+
47
+ @axon.cancel
48
+ @finished = true
49
+ end
50
+
51
+ private
52
+
53
+ def assert_active
54
+ raise Error, "Transaction not begun" unless @begun
55
+ raise Error, "Transaction already finished" if @finished
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AxonBase
4
+ class SagaTransaction
5
+ attr_reader :saga_name, :correlation_id
6
+
7
+ def initialize(axon, saga_name, correlation_id)
8
+ @axon = axon
9
+ @saga_name = saga_name
10
+ @correlation_id = correlation_id
11
+ @begun = false
12
+ @finished = false
13
+ end
14
+
15
+ def is_begun?
16
+ @begun
17
+ end
18
+
19
+ def is_finished?
20
+ @finished
21
+ end
22
+
23
+ def begin
24
+ raise Error, "Saga already begun: #{@correlation_id}" if @begun
25
+
26
+ result = @axon.query("BEGIN SAGA #{escape(@saga_name)} WITH CORRELATION '#{escape(@correlation_id)}'")
27
+ if result.is_a?(Hash) && result["status"] == "RUNNING"
28
+ @begun = true
29
+ return
30
+ end
31
+
32
+ raise Error, "Failed to begin saga: #{result}"
33
+ end
34
+
35
+ def step(axonql)
36
+ assert_active
37
+ @axon.let_var("saga_corr", @correlation_id)
38
+ @axon.query(axonql)
39
+ end
40
+
41
+ def commit
42
+ assert_active
43
+ result = @axon.query("COMMIT SAGA #{escape(@saga_name)} WITH CORRELATION '#{escape(@correlation_id)}'")
44
+ @finished = true
45
+ raise Error, "Saga commit failed: #{result}" unless result.is_a?(Hash) && result["status"] == "COMMITTED"
46
+ end
47
+
48
+ def rollback
49
+ return unless @begun && !@finished
50
+
51
+ @axon.query("CANCEL SAGA #{escape(@saga_name)} WITH CORRELATION '#{escape(@correlation_id)}'")
52
+ @finished = true
53
+ end
54
+
55
+ def describe
56
+ @axon.query("SHOW SAGA TRANSACTION #{escape(@saga_name)} '#{escape(@correlation_id)}'")
57
+ end
58
+
59
+ private
60
+
61
+ def assert_active
62
+ raise Error, "Saga not begun" unless @begun
63
+ raise Error, "Saga already finished" if @finished
64
+ end
65
+
66
+ def escape(value)
67
+ value.gsub("\\") { "\\\\\\\\" }.gsub("'") { "''" }
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AxonBase
4
+ class WebSocketTransport
5
+ def initialize(url, ssl_context: nil)
6
+ require "websocket-client-simple"
7
+ @messages = Queue.new
8
+ options = ssl_context ? { tls: ssl_context } : {}
9
+ @socket = WebSocket::Client::Simple.connect(url, options)
10
+ @socket.on(:message) { |message| @messages << message.data }
11
+ @socket.on(:error) { |error| @messages << error }
12
+ end
13
+
14
+ def send(message)
15
+ @socket.send(message)
16
+ end
17
+
18
+ def receive
19
+ message = @messages.pop
20
+ raise message if message.is_a?(Exception)
21
+ message
22
+ end
23
+
24
+ def close
25
+ @socket.close
26
+ end
27
+ end
28
+ end
data/lib/axonbase.rb ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "thread"
5
+ require_relative "axonbase/errors"
6
+ require_relative "axonbase/transport"
7
+ require_relative "axonbase/client"
8
+ require_relative "axonbase/migration"
9
+ require_relative "axonbase/saga_transaction"
10
+ require_relative "axonbase/saga_participant_transaction"
11
+
12
+ module AxonBase
13
+ PROTOCOL_VERSION = 1
14
+ end
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: axonbase-sdk
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - AxonBase
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: websocket-client-simple
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.7'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.7'
27
+ description:
28
+ email:
29
+ executables: []
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - lib/axonbase.rb
34
+ - lib/axonbase/client.rb
35
+ - lib/axonbase/errors.rb
36
+ - lib/axonbase/migration.rb
37
+ - lib/axonbase/saga_participant_transaction.rb
38
+ - lib/axonbase/saga_transaction.rb
39
+ - lib/axonbase/transport.rb
40
+ homepage: https://github.com/axonbase/axonbase
41
+ licenses:
42
+ - MIT
43
+ metadata:
44
+ source_code_uri: https://github.com/axonbase/axonbase
45
+ bug_tracker_uri: https://github.com/axonbase/axonbase/issues
46
+ post_install_message:
47
+ rdoc_options: []
48
+ require_paths:
49
+ - lib
50
+ required_ruby_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '2.6'
55
+ required_rubygems_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ requirements: []
61
+ rubygems_version: 3.0.3.1
62
+ signing_key:
63
+ specification_version: 4
64
+ summary: AxonBase WebSocket JSON-RPC client
65
+ test_files: []