databasus_ruby 0.1.0 → 0.2.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: bad2c73042db7ba0a3f3ee861359422ee14dc8a842250138e7320fe0c1f40b57
4
- data.tar.gz: 963517d89578177d4f48e06e3be5c146ee973c0ca2a9163cda80bd2262d0d630
3
+ metadata.gz: c06a2f99b31ebac53becd79a06a01790a65e106890f2275f0645548197c33611
4
+ data.tar.gz: 14433d80684697a781025fb4dce7f0839fe5448dbc6cbef9f7c52e257b52c8b9
5
5
  SHA512:
6
- metadata.gz: 990c3c288bce649ac4f8ca2db43b90197bdaeb0e0e3ad34b71aadfc71db58099d1c534fcdd82c2167b1f5b6d6f3b44f1077378447f8611e37faa0d22ddcbfd17
7
- data.tar.gz: e405fe4b643aceec693cdc9b6a1a8f3261a94022271428b3aaa660ae8cb5bcf1a9aa3e4fc726570edd7fd489d64c1671bd7256c198cd6060f400dbccddf6fbdb
6
+ metadata.gz: 25a821696e164674b457941926ae8940ac8656af9106409fe9d63c7c2bb2f429805e52ab057725876f8e57697566d6327a4fe0b55604342cd9ac3793939953c1
7
+ data.tar.gz: a2b67bc8e87d04c3306b4c1198f4352f7cdd2f5af7177c456df0e5c23c46d808a38f17aa35ab1875b2cd5db281c4c4718193f14225ed4265347b6189cc4fcf06
data/CHANGELOG.md CHANGED
@@ -1,5 +1,63 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0] - 2026-11-13
4
+
5
+ Targets the REST API of Databasus 3.65 and later. Databasus 3.65 replaced its
6
+ API (new paths, cursor pagination, RFC 9457 problem responses), so this release
7
+ does not talk to 3.64 or earlier: pin `~> 0.1.0` for those.
8
+
9
+ ### Added
10
+
11
+ - `Restores` endpoint (`start`, `get`, `list`, `cancel`) and
12
+ `Restores.postgresql_target`, which restores without ownership and
13
+ privileges by default.
14
+ - `Backups#download_to(id, io)` and `Client#stream`: backups stream to an IO
15
+ instead of being read into memory.
16
+ - `Backups#get`; `Databases#list_all`, which follows the cursor; `Databases#test`
17
+ / `#connected?`; `Storages#backup_usage`; `Auth#verify_sign_in_code`.
18
+ - `APIError#code` (the problem's `code`); validation field errors are appended
19
+ to the message.
20
+ - `SignInCodeRequiredError`, raised by `Auth#signin` when the account needs an
21
+ emailed sign-in code.
22
+ - `Collection#next_cursor`, `#last_page?` and `#extras` (page-level fields such
23
+ as the backups page's `totalUsageMb`).
24
+
25
+ ### Changed
26
+
27
+ - Every path follows the 3.65 API: `POST /sessions`, `/logical-backups`,
28
+ `POST /download-tokens`, `PUT /databases/{id}`, `/workspace-members`,
29
+ `/audit-events`, `/instance-settings`, `/password-resets`.
30
+ - List methods take `limit:` / `after:` instead of `limit:` / `offset:`.
31
+ - Array query parameters repeat the key (`status=A&status=B`).
32
+ - `Notifiers` and `Storages` split `save` into `create` and `update(id, ...)`.
33
+ - Renamed: `Auth#send_reset_password_code` → `#request_password_reset`,
34
+ `Databases#test_connection` → `#test`, `Workspaces#audit_logs` → `#audit_events` (filter with `author_user_id:`).
35
+ - Methods now take the target id positionally: `Databases#update(id, ...)`,
36
+ `Users#update(id, name:, email:)`, `Users#change_password(id, new_password:)`,
37
+ `Workspaces#update(id, name:)`.
38
+ - Workspace membership is addressed by member id: `remove_member(member_id)`,
39
+ `change_member_role(member_id, role:)`, and
40
+ `transfer_ownership(id, new_owner_member_id:)` replaces `new_owner_email:`.
41
+ - `Backups#list` filters with `created_before:` (was `before_date:`);
42
+ `Databases#list` accepts `notifier_id:`.
43
+ - `APIError` messages come from the problem's `detail` / `title`;
44
+ `APIError.from_response` accepts a `body:` override for streamed responses.
45
+ - JSON responses are parsed for any `*json*` content type
46
+ (e.g. `application/problem+json`).
47
+
48
+ ### Removed
49
+
50
+ - Offset pagination (`Collection#total`, `#limit`, `#offset`, `#next_offset`).
51
+ - Endpoints the API dropped: OAuth callbacks (`Auth#github_callback`,
52
+ `#google_callback`), `Users#me`, `#update_me`, `#invite`, readonly and
53
+ replication users (`Databases#create_readonly_user`,
54
+ `#create_replication_only_user`, `#readonly`, `#readonly?`), `Databases#test_connection_direct`,
55
+ notifier usage counts (`Databases#notifier_databases_count`,
56
+ `#notifier_using?`), and the `direct_test` methods of `Notifiers` and
57
+ `Storages`.
58
+
59
+ ## [0.1.0]
60
+
3
61
  ### Added
4
62
 
5
63
  - `DatabasusRuby::Client` with bearer authentication, an optional default
@@ -4,16 +4,14 @@ module DatabasusRuby
4
4
  # Entry point for the gem. Holds the connection and hands out one endpoint
5
5
  # object per API domain.
6
6
  #
7
- # client = DatabasusRuby::Client.new(
8
- # api_key: ENV["DATABASUS_API_KEY"],
9
- # url: "https://databasus.example.com/api/v1/"
10
- # )
7
+ # client = DatabasusRuby::Client.new(url: "https://databasus.example.com/api/v1/")
8
+ # client.auth.signin(email: "ops@example.com", password: "secret")
11
9
  # client.databases.list(workspace_id: id)
12
10
  #
13
- # `api_key` is sent as `Authorization: Bearer <api_key>`. Databasus accepts
14
- # two kinds of bearer token there: a user JWT (from Auth#signin, used by
15
- # every regular endpoint) and a verification-agent token (used by the
16
- # /agent/* endpoints, which also need `agent_id`).
11
+ # Targets the Databasus REST API introduced in 3.65 (OpenAPI, cursor
12
+ # pagination, RFC 9457 problem responses). `api_key` is sent as
13
+ # `Authorization: Bearer <api_key>`; Auth#signin installs the session token
14
+ # there.
17
15
  class Client
18
16
  attr_reader :adapter, :url
19
17
  attr_accessor :api_key, :workspace_id
@@ -30,8 +28,11 @@ module DatabasusRuby
30
28
  def connection
31
29
  @connection ||= Faraday.new do |conn|
32
30
  conn.url_prefix = url
31
+ # Array filters (`status=A&status=B`) are form-style with explode,
32
+ # which the nested encoder would send as `status[]=`.
33
+ conn.options.params_encoder = Faraday::FlatParamsEncoder
33
34
  conn.request :json
34
- conn.response :json, content_type: "application/json"
35
+ conn.response :json, content_type: /\bjson\b/
35
36
  conn.adapter adapter
36
37
  end
37
38
  end
@@ -52,6 +53,10 @@ module DatabasusRuby
52
53
  @notifiers ||= Notifiers.new(self)
53
54
  end
54
55
 
56
+ def restores
57
+ @restores ||= Restores.new(self)
58
+ end
59
+
55
60
  def storages
56
61
  @storages ||= Storages.new(self)
57
62
  end
@@ -64,8 +69,8 @@ module DatabasusRuby
64
69
  @workspaces ||= Workspaces.new(self)
65
70
  end
66
71
 
67
- # The workspace-scoped list endpoints all require a workspace id; callers
68
- # can either pass one per call or set a default on the client.
72
+ # The workspace-scoped list endpoints filter by workspace; callers can
73
+ # either pass one per call or set a default on the client.
69
74
  def workspace_id!
70
75
  return workspace_id unless workspace_id.nil? || workspace_id.to_s.empty?
71
76
 
@@ -78,9 +83,8 @@ module DatabasusRuby
78
83
  # decide how to interpret the body.
79
84
  def request(method, path, params: nil, body: nil, headers: nil)
80
85
  response = connection.public_send(method, path) do |req|
81
- req.params.update(stringify(params)) if params
86
+ prepare(req, params: params, headers: headers)
82
87
  req.body = body unless body.nil?
83
- req.headers.update(request_headers(headers))
84
88
  end
85
89
 
86
90
  raise APIError.from_response(response) unless response.success?
@@ -88,15 +92,67 @@ module DatabasusRuby
88
92
  response
89
93
  end
90
94
 
95
+ # GETs `path` and writes the body to `io` chunk by chunk, so a multi-GB
96
+ # backup never sits in memory. An error answer is collected instead and
97
+ # raised as the matching APIError. Returns the number of bytes written.
98
+ def stream(path, io:, params: nil)
99
+ sink = StreamSink.new(io)
100
+ response = connection.get(path) do |req|
101
+ prepare(req, params: params)
102
+ # Faraday streams only when on_data is a Proc (RequestOptions#stream_response?).
103
+ req.options.on_data = sink.to_proc
104
+ end
105
+ return sink.written if response.success?
106
+
107
+ raise APIError.from_response(response, body: sink.decoded_error_body)
108
+ end
109
+
110
+ # Routes streamed chunks: a 2xx body goes to the caller's io, anything else
111
+ # is kept so it can be raised as an APIError.
112
+ class StreamSink
113
+ attr_reader :written, :error_body
114
+
115
+ def initialize(io)
116
+ @io = io
117
+ @written = 0
118
+ @error_body = +""
119
+ end
120
+
121
+ def to_proc
122
+ proc { |chunk, _received, env| write(chunk, env) }
123
+ end
124
+
125
+ def decoded_error_body
126
+ return nil if error_body.empty?
127
+
128
+ JSON.parse(error_body)
129
+ rescue JSON::ParserError
130
+ error_body
131
+ end
132
+
133
+ private
134
+
135
+ def write(chunk, env)
136
+ return @error_body << chunk unless env.nil? || (200..299).cover?(env.status)
137
+
138
+ @io.write(chunk)
139
+ @written += chunk.bytesize
140
+ end
141
+ end
142
+ private_constant :StreamSink
143
+
91
144
  def inspect
92
- "#<#{self.class.name} url=#{url.inspect} workspace_id=#{workspace_id.inspect}\
93
- api_key=#{api_key.nil? ? "nil" : "[FILTERED]"}>"
145
+ "#<#{self.class.name} url=#{url.inspect} workspace_id=#{workspace_id.inspect} " \
146
+ "api_key=#{api_key.nil? ? "nil" : "[FILTERED]"}>"
94
147
  end
95
148
 
96
149
  private
97
150
 
98
- # Databasus takes the token as a bearer credential; both a user JWT and a
99
- # verification-agent token go in the same header.
151
+ def prepare(req, params: nil, headers: nil)
152
+ req.params.update(stringify(params)) if params
153
+ req.headers.update(request_headers(headers))
154
+ end
155
+
100
156
  def request_headers(extra)
101
157
  headers = {}
102
158
  headers["Authorization"] = "Bearer #{api_key}" unless api_key.nil? || api_key.to_s.empty?
@@ -110,13 +166,12 @@ module DatabasusRuby
110
166
  url.to_s.end_with?("/") ? url.to_s : "#{url}/"
111
167
  end
112
168
 
169
+ # Drops nils; arrays stay arrays so FlatParamsEncoder repeats the key.
113
170
  def stringify(params)
114
171
  params.each_with_object({}) do |(key, value), acc|
115
172
  next if value.nil?
116
173
 
117
- # The only array-valued query parameter in the API (backups status)
118
- # is declared as collectionFormat: csv.
119
- acc[key.to_s] = value.is_a?(::Array) ? value.join(",") : value
174
+ acc[key.to_s] = value.is_a?(::Array) ? value.map(&:to_s) : value
120
175
  end
121
176
  end
122
177
  end
@@ -3,40 +3,44 @@
3
3
  module DatabasusRuby
4
4
  # Uniform return value for every list endpoint.
5
5
  #
6
- # Databasus answers lists in three different shapes: a bare array
7
- # (GET /databases), a paginated envelope (GET /backups) and an unpaginated
8
- # envelope (GET /workspaces). Collection flattens all three into an
9
- # Enumerable that always answers #total, and answers #limit / #offset when
10
- # the endpoint actually paginates.
6
+ # Databasus answers every list as a cursor page: `{ items: [...],
7
+ # nextCursor: "..." }`, plus page-level extras on some endpoints (the logical
8
+ # backups page carries `totalUsageMb`). Pass #next_cursor back as `after:` to
9
+ # read the next page; it is nil on the last one.
10
+ #
11
+ # page = client.backups.list(database_id: id, limit: 50)
12
+ # page.extras.total_usage_mb
13
+ # page.next_cursor
11
14
  class Collection
12
15
  include Enumerable
13
16
 
14
- attr_reader :data, :total, :limit, :offset
17
+ attr_reader :data, :next_cursor, :extras
15
18
 
16
19
  # `wrap` builds one entry from its raw hash; it defaults to a plain Object
17
20
  # and endpoints pass a Record builder instead.
18
- def initialize(data: [], total: nil, limit: nil, offset: nil, wrap: nil)
21
+ def initialize(data: [], next_cursor: nil, extras: {}, wrap: nil)
19
22
  @data = data.map { |item| item.is_a?(Object) ? item : (wrap || Object.method(:new)).call(item) }
20
- @total = total || @data.size
21
- @limit = limit
22
- @offset = offset
23
+ @next_cursor = next_cursor
24
+ @extras = extras.is_a?(Object) ? extras : Object.new(extras)
23
25
  end
24
26
 
25
- # `key` names the array inside an envelope response and is ignored for
26
- # bare-array responses.
27
- def self.from(body, key:, wrap: nil)
27
+ PAGE_KEYS = %w[items nextCursor].freeze
28
+
29
+ def self.from(body, wrap: nil)
28
30
  case body
29
- when ::Array
30
- new(data: body, wrap: wrap)
31
- when Hash
32
- new(data: body[key] || [], total: body["total"], limit: body["limit"], offset: body["offset"], wrap: wrap)
33
- when nil
34
- new
35
- else
36
- raise Error, "unexpected list payload: #{body.class}"
31
+ when Hash then from_page(body, wrap)
32
+ when ::Array then new(data: body, wrap: wrap)
33
+ when nil, "" then new
34
+ else raise Error, "unexpected list payload: #{body.class}"
37
35
  end
38
36
  end
39
37
 
38
+ def self.from_page(page, wrap)
39
+ extras = page.reject { |key, _| PAGE_KEYS.include?(key.to_s) }
40
+ new(data: page["items"] || [], next_cursor: page["nextCursor"], extras: extras, wrap: wrap)
41
+ end
42
+ private_class_method :from_page
43
+
40
44
  def each(&block)
41
45
  return data.each unless block
42
46
 
@@ -61,25 +65,12 @@ module DatabasusRuby
61
65
  data.last
62
66
  end
63
67
 
64
- def paginated?
65
- !limit.nil?
66
- end
67
-
68
- # Offset to pass to the next call, or nil when this is the last page (or
69
- # the endpoint does not paginate at all).
70
- def next_offset
71
- return nil unless paginated?
72
-
73
- following = (offset || 0) + data.size
74
- following < total ? following : nil
75
- end
76
-
77
68
  def last_page?
78
- next_offset.nil?
69
+ next_cursor.nil? || next_cursor.to_s.empty?
79
70
  end
80
71
 
81
72
  def inspect
82
- "#<#{self.class.name} size=#{size} total=#{total} limit=#{limit.inspect} offset=#{offset.inspect}>"
73
+ "#<#{self.class.name} size=#{size} next_cursor=#{next_cursor.inspect}>"
83
74
  end
84
75
  end
85
76
  end
@@ -49,9 +49,9 @@ module DatabasusRuby
49
49
  build(as, response.body.is_a?(Hash) ? response.body : {})
50
50
  end
51
51
 
52
- # Wraps a list response; `key` names the array inside an envelope payload.
53
- def collection(response, key:, as: Object)
54
- Collection.from(response.body, key: key, wrap: ->(item) { build(as, item) })
52
+ # Wraps a cursor-page list response.
53
+ def collection(response, as: Object)
54
+ Collection.from(response.body, wrap: ->(item) { build(as, item) })
55
55
  end
56
56
 
57
57
  # Records need the client to reach their related endpoints; plain payload
@@ -65,13 +65,6 @@ module DatabasusRuby
65
65
  true
66
66
  end
67
67
 
68
- # Endpoints answering a single-entry map whose key is not documented in the
69
- # swagger schema, e.g. GET /databases/notifier/{id}/is-using.
70
- def single_value(response)
71
- body = response.body
72
- body.is_a?(Hash) ? body.values.first : body
73
- end
74
-
75
68
  # Request bodies are built from keyword arguments, and the API distinguishes
76
69
  # an absent key from a null one in a few places, so nils are dropped.
77
70
  def compact(body)
@@ -5,24 +5,44 @@ module DatabasusRuby
5
5
  class Error < StandardError; end
6
6
 
7
7
  # Raised when the client is missing something it needs before it can talk to
8
- # the API at all (an agent id, a workspace id, ...).
8
+ # the API at all (a workspace id, a url, ...).
9
9
  class ConfigurationError < Error; end
10
10
 
11
+ # Raised by Auth#signin when the account requires an emailed sign-in code
12
+ # (two-factor). An unattended client cannot answer it; use an account
13
+ # without 2FA or finish with Auth#verify_sign_in_code.
14
+ class SignInCodeRequiredError < Error
15
+ attr_reader :pending_sign_in_id, :email
16
+
17
+ def initialize(pending_sign_in_id:, email:)
18
+ @pending_sign_in_id = pending_sign_in_id
19
+ @email = email
20
+ super("sign-in for #{email} needs the emailed code (pending sign-in #{pending_sign_in_id})")
21
+ end
22
+ end
23
+
11
24
  # Raised when Databasus answers with a non-successful status code.
25
+ #
26
+ # Databasus answers errors as RFC 9457 problems:
27
+ # `{ type, title, status, detail, code, errors }`. `code` is the stable
28
+ # machine-readable reason (`database_connection_failed`,
29
+ # `insufficient_disk_space`, `already_running`, ...).
12
30
  class APIError < Error
13
- attr_reader :status, :body, :response
31
+ attr_reader :status, :body, :response, :code
14
32
 
15
- def initialize(message = nil, status: nil, body: nil, response: nil)
33
+ def initialize(message = nil, status: nil, body: nil, response: nil, code: nil)
16
34
  @status = status
17
35
  @body = body
18
36
  @response = response
37
+ @code = code
19
38
  super(message || "Databasus API returned #{status}")
20
39
  end
21
40
 
22
41
  # Maps a Faraday response onto the most specific error class available.
23
- def self.from_response(response)
42
+ # `body` overrides the response body (streamed responses carry none).
43
+ def self.from_response(response, body: response.body)
24
44
  klass = for_status(response.status)
25
- klass.new(message_from(response.body), status: response.status, body: response.body, response: response)
45
+ klass.new(message_from(body), status: response.status, body: body, response: response, code: code_from(body))
26
46
  end
27
47
 
28
48
  def self.for_status(status)
@@ -31,15 +51,25 @@ module DatabasusRuby
31
51
  STATUS_ERRORS.fetch(status, APIError)
32
52
  end
33
53
 
34
- # Every handler in the Go backend answers errors as {"error": "..."}, but
35
- # streaming endpoints can still fail with a plain-text body.
54
+ # A validation problem's `detail` is generic ("the request breaks a
55
+ # validation rule"); the field errors are what say which rule.
36
56
  def self.message_from(body)
37
57
  case body
38
- when Hash then body["error"] || body["message"] || body[:error]
58
+ when Hash then problem_message(body)
39
59
  when String then body.empty? ? nil : body[0, 500]
40
60
  end
41
61
  end
42
- private_class_method :message_from
62
+
63
+ def self.problem_message(problem)
64
+ message = problem["detail"] || problem["title"] || problem["error"] || problem["message"]
65
+ fields = Array(problem["errors"]).filter_map { |e| "#{e["field"]}: #{e["detail"]}" if e.is_a?(Hash) }
66
+ fields.empty? ? message : [message, fields.join("; ")].compact.join(" — ")
67
+ end
68
+
69
+ def self.code_from(body)
70
+ body["code"] if body.is_a?(Hash)
71
+ end
72
+ private_class_method :message_from, :problem_message, :code_from
43
73
  end
44
74
 
45
75
  class BadRequestError < APIError; end
@@ -1,28 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DatabasusRuby
4
- # Obtaining a token. Every other endpoint needs the JWT these methods
5
- # return, so #signin and #signup install it on the client as `api_key`.
4
+ # Obtaining a session token. Every other endpoint needs the token these
5
+ # methods return, so a completed sign-in installs it on the client as
6
+ # `api_key`.
6
7
  #
7
8
  # client.auth.signin(email: "me@example.com", password: "secret")
8
- # client.users.me.name
9
+ # client.databases.list(workspace_id: id)
9
10
  #
10
- # Read the token off the result if you want to keep it for a later client.
11
+ # An account with two-factor sign-in answers CODE_REQUIRED: #signin raises
12
+ # SignInCodeRequiredError and #verify_sign_in_code finishes it.
11
13
  class Auth < Object
12
14
  include Endpoint
13
15
 
14
- # POST /users/signin
16
+ # POST /sessions
17
+ #
18
+ # @return [DatabasusRuby::Object] the completed sign-in (`token`, `userId`, `email`)
19
+ # @raise [DatabasusRuby::SignInCodeRequiredError] when the account needs the emailed code
15
20
  def signin(email:, password:, cloudflare_turnstile_token: nil)
16
- authorize object(http_post("users/signin", {
21
+ outcome = object(http_post("sessions", {
17
22
  email: email,
18
23
  password: password,
19
24
  cloudflareTurnstileToken: cloudflare_turnstile_token
20
25
  }))
26
+ if outcome.outcome == "CODE_REQUIRED"
27
+ raise SignInCodeRequiredError.new(pending_sign_in_id: outcome.pending_sign_in_id, email: outcome.email)
28
+ end
29
+
30
+ authorize outcome
31
+ end
32
+
33
+ # POST /pending-sign-ins/{id}/verify
34
+ def verify_sign_in_code(pending_sign_in_id, code:, cloudflare_turnstile_token: nil)
35
+ authorize object(http_post("pending-sign-ins/#{escape(pending_sign_in_id)}/verify", {
36
+ code: code,
37
+ cloudflareTurnstileToken: cloudflare_turnstile_token
38
+ }))
21
39
  end
22
40
 
23
- # POST /users/signup
41
+ # POST /users — self-registration, when the instance allows it.
24
42
  def signup(email:, name:, password:, cloudflare_turnstile_token: nil)
25
- authorize object(http_post("users/signup", {
43
+ authorize object(http_post("users", {
26
44
  email: email,
27
45
  name: name,
28
46
  password: password,
@@ -30,27 +48,14 @@ module DatabasusRuby
30
48
  }))
31
49
  end
32
50
 
33
- # POST /auth/github/callback
34
- def github_callback(code:, redirect_uri:)
35
- object(http_post("auth/github/callback", { code: code, redirectUri: redirect_uri }))
36
- end
37
-
38
- # POST /auth/google/callback
39
- def google_callback(code:, redirect_uri:)
40
- object(http_post("auth/google/callback", { code: code, redirectUri: redirect_uri }))
41
- end
42
-
43
- # POST /users/send-reset-password-code
44
- def send_reset_password_code(email:, cloudflare_turnstile_token: nil)
45
- object(http_post("users/send-reset-password-code", {
46
- email: email,
47
- cloudflareTurnstileToken: cloudflare_turnstile_token
48
- }))
51
+ # POST /password-resets — emails a reset code.
52
+ def request_password_reset(email:, cloudflare_turnstile_token: nil)
53
+ acknowledged(http_post("password-resets", { email: email, cloudflareTurnstileToken: cloudflare_turnstile_token }))
49
54
  end
50
55
 
51
- # POST /users/reset-password
56
+ # POST /password-reset-completions
52
57
  def reset_password(email:, code:, new_password:)
53
- object(http_post("users/reset-password", { email: email, code: code, newPassword: new_password }))
58
+ acknowledged(http_post("password-reset-completions", { email: email, code: code, newPassword: new_password }))
54
59
  end
55
60
 
56
61
  private
@@ -3,65 +3,90 @@
3
3
  module DatabasusRuby
4
4
  # Logical backups.
5
5
  #
6
- # backups = client.backups.list(database_id: id, status: %w[COMPLETED FAILED])
7
- # backups.total
8
- # backups.first.fileName
6
+ # page = client.backups.list(database_id: id, status: "COMPLETED", limit: 20)
7
+ # page.extras.total_usage_mb # storage used by all of the database's backups
8
+ # page.first.raw_database_size_mb # size of the database when it was dumped
9
9
  #
10
- # Downloads are a two-step flow: mint a short-lived token, then stream the
11
- # file with it. #download does both.
10
+ # File.open("dump", "wb") { |io| client.backups.download_to(backup.id, io) }
11
+ #
12
+ # A download is a single-use token (POST /download-tokens, valid five
13
+ # minutes) spent on GET /logical-backups/{id}/file. #download_to streams the
14
+ # file; #download reads it into memory and is only fit for small dumps.
12
15
  class Backups < Object
13
16
  include Endpoint
14
17
 
15
18
  STATUSES = %w[IN_PROGRESS COMPLETED FAILED CANCELED].freeze
16
- ENCRYPTIONS = %w[NONE ENCRYPTED].freeze
17
19
  VERIFICATION_STATUSES = %w[NOT_VERIFIED VERIFIED_SUCCESSFUL VERIFICATION_FAILED].freeze
18
20
 
19
- # GET /backups — `status` accepts a single value or an array.
20
- def list(database_id:, limit: nil, offset: nil, status: nil, before_date: nil, pg_wal_backup_type: nil)
21
- response = http_get("backups", {
22
- database_id: database_id,
23
- limit: limit,
24
- offset: offset,
21
+ # GET /logical-backups — newest first. `status` takes one value or an array.
22
+ def list(database_id:, status: nil, created_before: nil, limit: nil, after: nil)
23
+ response = http_get("logical-backups", {
24
+ databaseId: require_id(database_id),
25
25
  status: status,
26
- beforeDate: before_date,
27
- pgWalBackupType: pg_wal_backup_type
26
+ createdBefore: created_before,
27
+ limit: limit,
28
+ after: after
28
29
  })
29
- collection(response, key: "backups")
30
+ collection(response)
30
31
  end
31
32
  alias all list
32
33
 
33
- # POST /backups — queues a backup run for the database.
34
+ # GET /logical-backups/{id}
35
+ def get(id)
36
+ object(http_get("logical-backups/#{escape(id)}"))
37
+ end
38
+ alias find get
39
+
40
+ # POST /logical-backups — queues a backup run for the database.
34
41
  def create(database_id:)
35
- object(http_post("backups", { database_id: database_id }))
42
+ object(http_post("logical-backups", { databaseId: require_id(database_id) }))
36
43
  end
37
44
  alias trigger create
38
45
 
39
- # DELETE /backups/{id}
46
+ # DELETE /logical-backups/{id}
40
47
  def delete(id)
41
- acknowledged(http_delete("backups/#{escape(id)}"))
48
+ acknowledged(http_delete("logical-backups/#{escape(id)}"))
42
49
  end
43
50
 
44
- # POST /backups/{id}/cancel
51
+ # POST /logical-backups/{id}/cancel
45
52
  def cancel(id)
46
- acknowledged(http_post("backups/#{escape(id)}/cancel"))
53
+ object(http_post("logical-backups/#{escape(id)}/cancel"))
47
54
  end
48
55
 
49
- # POST /backups/{id}/download-token -> { token, filename, backupId }
56
+ # POST /download-tokens -> { token, fileName, expiresAt }
50
57
  def download_token(id)
51
- object(http_post("backups/#{escape(id)}/download-token"))
58
+ object(http_post("download-tokens", { backupId: require_id(id) }))
52
59
  end
53
60
 
54
- # GET /backups/{id}/file — returns the raw body; pass a token from
55
- # #download_token.
61
+ # GET /logical-backups/{id}/file — the raw body, in memory.
56
62
  def file(id, token:)
57
- http_get("backups/#{escape(id)}/file", { token: token }).body
63
+ http_get("logical-backups/#{escape(id)}/file", { token: token }).body
64
+ end
65
+
66
+ # Mints a token and streams the file into `io`.
67
+ #
68
+ # @return [DatabasusRuby::Object] the token grant (`file_name`, `expires_at`)
69
+ # plus `bytes`, the number of bytes written
70
+ def download_to(id, io)
71
+ grant = download_token(id)
72
+ bytes = file_to(id, io, token: grant.token)
73
+ Object.new(grant.to_h.except("token").merge("bytes" => bytes))
74
+ end
75
+
76
+ # Streams the file into `io` with a token from #download_token. Mint the
77
+ # token first when the caller must fail before it starts answering (an
78
+ # HTTP response whose headers are already sent cannot turn into an error).
79
+ #
80
+ # @return [Integer] bytes written
81
+ def file_to(id, io, token:)
82
+ client.stream("logical-backups/#{escape(id)}/file", io: io, params: { token: token })
58
83
  end
59
84
 
60
- # Mints a token and fetches the file in one call. Returns
61
- # [filename, contents].
85
+ # Mints a token and fetches the file in one call, in memory. Returns
86
+ # [file_name, contents]. Prefer #download_to for real dumps.
62
87
  def download(id)
63
88
  grant = download_token(id)
64
- [grant.filename, file(id, token: grant.token)]
89
+ [grant.file_name, file(id, token: grant.token)]
65
90
  end
66
91
  end
67
92
  end