pocketbase 0.1.1

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: a51b5e2d68dec39bf14a8b65a1dec80e58ad2f4cd326ce0bcaf1137c9b05ddc5
4
+ data.tar.gz: 2f9ac3ae8bed190a05489dbe74944ee56b2da2f4f7376783ba8d8b5be57301aa
5
+ SHA512:
6
+ metadata.gz: dd15eda4c54cc09950085ff926ff8554a3ddef11693c8bcba35188f8291e219ff63273c8c1cf6c6f4949dbfd12e6687ef54f44244feedb8caf731c4ec7216976
7
+ data.tar.gz: 7502f62ac5d8feb3d7f24a40564d27a7a639d3c6ee72ad52316f283378bce16fedc001ebcd9f92fd028c68f008f96b8be84c4fb1f7e0884ae1bdb2be406860f9
data/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1 (2026-10-12)
4
+
5
+ - Prevent forwarding authorization and cookies across redirect origins; preserve HTTP redirect method/body semantics.
6
+ - Honor string-keyed hook results and preserve request options when `before_send` returns nil.
7
+ - Normalize realtime subscription query options; roll back failed listeners and retry failed subscription updates.
8
+ - Terminate idle SSE readers on disconnect and notify `on_disconnect` on stream interruption before reconnecting.
9
+ - Deliver auth changes reliably when callbacks unsubscribe themselves.
10
+ - Expand home-relative auth file paths and raise persistence errors instead of silently dropping writes.
11
+ - Add offline HTTP/SSE regression checks using local TCP servers.
12
+
13
+ ## 0.1.0 (2026-10-12)
14
+
15
+ Initial release — full Ruby port of [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk):
16
+
17
+ - `PocketBase::Client` with `send_request`, `before_send`/`after_send` hooks, `filter` builder, `build_url`
18
+ - `PocketBase::AuthStore` (in-memory) and `PocketBase::FileAuthStore` (persisted), cookie import/export, `valid?`/`superuser?`/`auth_record?`, `on_change`
19
+ - `PocketBase::ClientResponseError` (`status`, `url`, `response`/`data`, `original_error`)
20
+ - Services: records (CRUD + password/OAuth2/OTP auth, password reset, verification, email change, impersonate, superuser auto-refresh), collections, files, logs, settings, backups, health, crons, SQL, batch
21
+ - Realtime SSE subscriptions with background thread + auto-reconnect
22
+ - Zero runtime dependencies (stdlib only)
23
+ - 65 integration tests against a live PocketBase server
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Azmi Muwahid
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,275 @@
1
+ # pocketbase (Ruby)
2
+
3
+ A Ruby port of the official [PocketBase JavaScript SDK](https://github.com/pocketbase/js-sdk) — the full client API for [PocketBase](https://pocketbase.io), written in idiomatic Ruby with **zero runtime dependencies** (stdlib only: `net/http`, `json`, `openssl`…).
4
+
5
+ > Credit: this gem is a port of [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk), created and maintained by the [PocketBase](https://pocketbase.io) team. All API design credit goes to them; this port just brings it to Ruby.
6
+
7
+ ```ruby
8
+ require "pocketbase"
9
+
10
+ pb = PocketBase.new("http://127.0.0.1:8090")
11
+
12
+ # auth
13
+ pb.collection("users").auth_with_password("test@example.com", "1234567890")
14
+ puts pb.auth_store.token
15
+
16
+ # crud
17
+ post = pb.collection("posts").create("title" => "Hello", "views" => 1)
18
+ list = pb.collection("posts").get_list(1, 30, filter: pb.filter("title ~ {:t}", t: "Hello"), sort: "-created")
19
+ pb.collection("posts").update(post["id"], "views" => 2)
20
+ pb.collection("posts").delete(post["id"])
21
+
22
+ # realtime
23
+ unsub = pb.collection("posts").subscribe("*") { |e| puts "#{e["action"]}: #{e["record"]["id"]}" }
24
+ sleep 60
25
+ unsub.call
26
+ ```
27
+
28
+ ## Installation
29
+
30
+ ```ruby
31
+ # Gemfile
32
+ gem "pocketbase", "~> 0.1"
33
+ ```
34
+
35
+ Or install directly:
36
+
37
+ ```
38
+ gem install pocketbase
39
+ ```
40
+
41
+ Requires Ruby >= 2.7.
42
+
43
+ ## Client basics
44
+
45
+ ```ruby
46
+ pb = PocketBase.new("http://127.0.0.1:8090")
47
+ # or PocketBase::Client.new(url, auth_store: ..., lang: "en-US")
48
+
49
+ pb.build_url("/api/health") # full URL builder
50
+ pb.filter("title ~ {:t} && views > {:v}", t: "hi", v: 5)
51
+ pb.collection("posts") # cached RecordService per collection
52
+ pb.create_batch # batch request builder
53
+ ```
54
+
55
+ Low-level requests (named `send_request` because `send` is taken by `Object#send`):
56
+
57
+ ```ruby
58
+ pb.send_request("/api/health", method: "GET", timeout: 10)
59
+ ```
60
+
61
+ Options accepted by every service method (all optional):
62
+
63
+ | Option | Meaning |
64
+ |---|---|
65
+ | `query:` / `params:` | extra query params (merged) |
66
+ | `headers:` | extra headers |
67
+ | `timeout:` | seconds |
68
+ | `filter:`, `sort:`, `expand:`, `fields:` … | any other top-level key becomes a query param (like the js-sdk) |
69
+
70
+ Hooks:
71
+
72
+ ```ruby
73
+ pb.before_send = ->(url, options) { { url: url, options: options } }
74
+ pb.after_send = ->(response, data, options) { data } # response.status / .headers / .url
75
+ ```
76
+
77
+ `before_send` accepts symbol- or string-keyed `url`/`options` results. Returning nil keeps current request options.
78
+ Redirects preserve methods and bodies except POST on 301/302 and non-GET/HEAD on 303. Credentials are removed when redirect origin changes.
79
+
80
+ Errors raise `PocketBase::ClientResponseError` with `#status`, `#url`, `#response` (aliased `#data`), `#original_error`, `#is_abort`.
81
+
82
+ ## Auth store
83
+
84
+ ```ruby
85
+ pb.auth_store.token # raw JWT
86
+ pb.auth_store.record # authed record hash
87
+ pb.auth_store.valid? # token present and unexpired
88
+ pb.auth_store.superuser? # _superusers collection
89
+ pb.auth_store.auth_record? # regular auth record
90
+ pb.auth_store.save(token, record)
91
+ pb.auth_store.clear
92
+ pb.auth_store.on_change { |token, record| ... } # returns unsubscribe lambda
93
+
94
+ # cookie helpers (e.g. for Rails controllers)
95
+ cookie = pb.auth_store.export_to_cookie
96
+ pb.auth_store.load_from_cookie(request.headers["Cookie"])
97
+
98
+ # file-persisted store (like the js-sdk LocalAuthStore)
99
+ pb2 = PocketBase.new(url, auth_store: PocketBase::FileAuthStore.new("~/.pb_auth.json"))
100
+ ```
101
+
102
+ `FileAuthStore` expands `~` in paths. Write failures raise their original filesystem error; do not treat failed saves or clears as persisted.
103
+
104
+ ## Records & auth
105
+
106
+ ```ruby
107
+ posts = pb.collection("posts")
108
+ posts.get_list(1, 30, filter: "...", sort: "-created", expand: "author")
109
+ posts.get_full_list(batch: 200, filter: "...") # or get_full_list(200, filter: "...")
110
+ posts.get_first_list_item("title = 'Hello'") # 404 when missing
111
+ posts.get_one("RECORD_ID")
112
+ posts.create("title" => "Hi", "image" => File.open("a.png", "rb")) # file upload -> multipart
113
+ posts.update("RECORD_ID", "title" => "Hi again")
114
+ posts.delete("RECORD_ID")
115
+
116
+ users = pb.collection("users")
117
+ auth = users.auth_with_password("test@example.com", "secret")
118
+ users.auth_refresh
119
+ users.list_auth_methods
120
+ users.request_password_reset("test@example.com")
121
+ users.confirm_password_reset("TOKEN", "new-pass", "new-pass")
122
+ users.request_verification("test@example.com")
123
+ users.confirm_verification("TOKEN")
124
+ users.request_email_change("new@example.com")
125
+ users.confirm_email_change("TOKEN", "password")
126
+ users.request_otp("test@example.com") # => { "otpId" => ... }
127
+ users.auth_with_otp("OTP_ID", "password")
128
+
129
+ # superuser impersonation -> brand new client with the impersonated session
130
+ other = pb.collection("users").impersonate("RECORD_ID")
131
+
132
+ # superuser token auto-refresh (mirrors js-sdk autoRefreshThreshold, seconds)
133
+ pb.collection("_superusers").auth_with_password("admin@example.com", "secret",
134
+ auto_refresh_threshold: 1800)
135
+ ```
136
+
137
+ ### OAuth2
138
+
139
+ Assisted flow (opens the provider URL via your block, waits on the realtime `@oauth2` channel, exchanges the code):
140
+
141
+ ```ruby
142
+ auth = pb.collection("users").auth_with_oauth2(provider: "google") do |url|
143
+ puts "Open: #{url}" # or `system("open", url)`
144
+ end
145
+ ```
146
+
147
+ Manual flow:
148
+
149
+ ```ruby
150
+ info = pb.collection("users").oauth2_login_url("google", scopes: ["email"])
151
+ # redirect the user to info["url"], then:
152
+ pb.collection("users").auth_with_oauth2_code("google", code, info["code_verifier"], redirect_url)
153
+ ```
154
+
155
+ ## Files
156
+
157
+ ```ruby
158
+ pb.files.get_url(record, "avatar.png") # absolute URL
159
+ pb.files.get_url(record, "avatar.png", thumb: "100x100") # thumbnail
160
+ pb.files.get_url(record, "avatar.png", download: true) # force download
161
+ pb.files.get_url(record, "avatar.png", token: pb.files.get_token) # protected file
162
+ ```
163
+
164
+ ## Realtime
165
+
166
+ The SSE stream runs in a background thread with automatic reconnect (same backoff as the js-sdk).
167
+
168
+ ```ruby
169
+ unsub = pb.realtime.subscribe("posts/*") { |e| p e["action"] }
170
+ # or via the collection helper:
171
+ unsub = pb.collection("posts").subscribe("*") { |e| p e }
172
+
173
+ unsub.call # remove this listener
174
+ pb.collection("posts").unsubscribe("*")
175
+ pb.realtime.unsubscribe_by_prefix("posts")
176
+ pb.realtime.disconnect # close the stream (auto-closes when no subs remain)
177
+ pb.realtime.on_disconnect = ->(subs) { puts "disconnected, active: #{subs}" }
178
+ ```
179
+
180
+ ## Batch
181
+
182
+ ```ruby
183
+ batch = pb.create_batch
184
+ batch.collection("posts").create("title" => "a")
185
+ batch.collection("posts").update("RECORD_ID", "title" => "b")
186
+ batch.collection("posts").upsert("id" => "MAYBE_ID", "title" => "c")
187
+ batch.collection("posts").delete("OTHER_ID")
188
+ results = batch.send # => [{ "status" => 200, "body" => {...} }, ...]
189
+ ```
190
+
191
+ Note: `/api/batch` is **disabled by default** on the server — enable it in *Settings → Batch* (or via `pb.settings.update`).
192
+
193
+ ## Admin services
194
+
195
+ ```ruby
196
+ pb.settings.get_all
197
+ pb.settings.update(full_settings_hash) # nested structs are replaced wholesale: round-trip get_all
198
+ pb.settings.test_s3("storage")
199
+ pb.settings.test_email("users", "to@example.com", "verification")
200
+
201
+ pb.backups.get_full_list
202
+ pb.backups.create("backup.zip") # name must end in .zip
203
+ pb.backups.upload("file" => File.open("backup.zip", "rb"))
204
+ pb.backups.delete("backup.zip")
205
+ pb.backups.restore("backup.zip")
206
+ pb.backups.get_download_url(pb.files.get_token, "backup.zip")
207
+
208
+ pb.logs.get_list(1, 30, filter: "...")
209
+ pb.logs.get_one("LOG_ID")
210
+ pb.logs.get_stats
211
+ pb.logs.truncate
212
+
213
+ pb.crons.get_full_list
214
+ pb.crons.run("job-id")
215
+
216
+ pb.sql.run("SELECT id FROM posts") # superusers only
217
+
218
+ pb.collections.get_one("COLLECTION_ID")
219
+ pb.collections.create(base_hash)
220
+ pb.collections.update("COLLECTION_ID", "listRule" => "")
221
+ pb.collections.delete("COLLECTION_ID")
222
+ pb.collections.import([col1, col2], delete_missing: false)
223
+ pb.collections.truncate("posts")
224
+ pb.collections.get_scaffolds
225
+ pb.collections.get_all_oauth2_providers
226
+ pb.collections.dry_run_view_query("SELECT id FROM posts")
227
+
228
+ pb.health.check
229
+ ```
230
+
231
+ ## Notes & differences from the js-sdk
232
+
233
+ - **Synchronous.** No promises; no auto-cancellation of in-flight requests (`auto_cancellation`/`cancel_request` exist as no-ops for API compatibility).
234
+ - **`send_request`** instead of `send` (`Object#send` collision).
235
+ - **Options are plain hashes**, e.g. `get_list(1, 30, filter: "...", timeout: 5)`. Pass request bodies as the first positional hash: `create("title" => "Hi")`.
236
+ - **Responses are hashes** with string keys (parsed JSON), not model classes.
237
+ - **Realtime** uses a background thread + `Queue`-friendly blocks instead of `EventSource`.
238
+ - **OAuth2 popup flow** is replaced by a block-based assisted flow (`auth_with_oauth2 { |url| ... }`) plus a manual `oauth2_login_url` helper.
239
+ - `settings.update` needs the **full settings object** (PocketBase replaces nested structs wholesale — partial nested hashes don't merge).
240
+ - `backups.create` names must end with `.zip`; `/api/batch` must be enabled server-side.
241
+
242
+ ## Development
243
+
244
+ ```bash
245
+ # start a local PocketBase for the test suite
246
+ ./pocketbase serve --http 127.0.0.1:8090
247
+ ./pocketbase superuser upsert admin@test.com password123
248
+
249
+ ruby -Ilib -Itest -e 'Dir.glob("test/test_*.rb").each { |f| require_relative f }'
250
+ # or: POCKETBASE_URL=http://127.0.0.1:8090 rake test
251
+ ```
252
+
253
+ Offline checks need no PocketBase process:
254
+
255
+ ```bash
256
+ rake test_offline
257
+ ```
258
+
259
+ ### RubyGems release
260
+
261
+ ```bash
262
+ rake build # runs offline checks, then builds pocketbase-0.1.1.gem
263
+ gem push pocketbase-0.1.1.gem --host https://rubygems.org
264
+ ```
265
+
266
+ Publishing requires a RubyGems account with access to the `pocketbase` name and a push-enabled API key (or interactive login). Complete any required MFA challenge. Never commit credentials or API keys. RubyDoc links use the published gem version and its source comments.
267
+
268
+ ## Credits
269
+
270
+ - [PocketBase](https://pocketbase.io) team — authors of the original [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk) this gem is ported from.
271
+ - [Azmi Muwahid](https://github.com/azmi2409) — Ruby port.
272
+
273
+ ## License
274
+
275
+ MIT. Port of [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk) (also MIT).
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module PocketBase
6
+ # In-memory auth store mirroring the js-sdk BaseAuthStore.
7
+ class AuthStore
8
+ DEFAULT_COOKIE_KEY = "pb_auth"
9
+ # crc32 checksum id of the _superusers collection (fallback check, as in js-sdk)
10
+ SUPERUSERS_COLLECTION_ID = "pbc_3142635823"
11
+
12
+ attr_reader :token, :record
13
+
14
+ def initialize(token = nil, record = nil)
15
+ @token = ""
16
+ @record = nil
17
+ @on_change_callbacks = []
18
+ save(token, record) if token || record
19
+ end
20
+
21
+ # Saves the token and record model, triggering on_change callbacks.
22
+ def save(token, record = nil)
23
+ @token = token.to_s
24
+ @record = record
25
+ trigger_change
26
+ self
27
+ end
28
+
29
+ # Clears the stored token and record, triggering on_change callbacks.
30
+ def clear
31
+ @token = ""
32
+ @record = nil
33
+ trigger_change
34
+ self
35
+ end
36
+
37
+ # Loosely checks whether the store holds a non-expired token.
38
+ def valid?
39
+ !Utils.token_expired?(@token)
40
+ end
41
+
42
+ # Loosely checks whether the stored state belongs to a superuser.
43
+ def superuser?
44
+ payload = Utils.token_payload(@token)
45
+ return false unless payload["type"] == "auth"
46
+
47
+ collection_name = @record.is_a?(Hash) ? (@record["collectionName"] || @record[:collectionName]) : nil
48
+ return true if collection_name == "_superusers"
49
+ return true if collection_name.nil? && payload["collectionId"] == SUPERUSERS_COLLECTION_ID
50
+
51
+ false
52
+ end
53
+
54
+ # Loosely checks whether the stored state belongs to a regular auth record.
55
+ def auth_record?
56
+ Utils.token_payload(@token)["type"] == "auth" && !superuser?
57
+ end
58
+
59
+ # Registers a callback invoked on every store change with (token, record).
60
+ # Returns an unsubscribe lambda. Pass fire_immediately: true to invoke
61
+ # the callback right away with the current state.
62
+ # Listeners present at dispatch start receive the change even when another
63
+ # listener unsubscribes itself. New listeners start with the next change.
64
+ #
65
+ # @param fire_immediately [Boolean] Deliver current state upon registration
66
+ # @yield [token, record] Called synchronously when authentication changes
67
+ # @yieldparam token [String] Current token
68
+ # @yieldparam record [Hash, nil] Current authenticated record
69
+ # @return [Proc] Unsubscribe callable
70
+ # @raise [ArgumentError] If no callback block is given
71
+ def on_change(fire_immediately: false, &callback)
72
+ raise ArgumentError, "callback block required" unless callback
73
+
74
+ @on_change_callbacks << callback
75
+ callback.call(@token, @record) if fire_immediately
76
+ -> { @on_change_callbacks.delete(callback) }
77
+ end
78
+
79
+ # Parses a cookie string and loads the token/record stored under `key`.
80
+ # (Does not validate the token, same as the js-sdk.)
81
+ def load_from_cookie(cookie, key = DEFAULT_COOKIE_KEY)
82
+ raw = parse_cookie(cookie)[key.to_s] || ""
83
+ data = {}
84
+ begin
85
+ parsed = JSON.parse(Utils.decode_uri_component(raw))
86
+ data = parsed if parsed.is_a?(Hash)
87
+ rescue StandardError
88
+ data = {}
89
+ end
90
+ save(data["token"] || data[:token] || "", data["record"] || data["model"] || data[:record] || data[:model])
91
+ end
92
+
93
+ # Exports the current store state as a cookie string.
94
+ # Supported options: :secure, :http_only, :same_site, :path, :domain,
95
+ # :expires (Time), :max_age (seconds).
96
+ def export_to_cookie(options = {}, key = DEFAULT_COOKIE_KEY)
97
+ opts = {
98
+ secure: true,
99
+ http_only: true,
100
+ same_site: "Strict",
101
+ path: "/",
102
+ }.merge(options)
103
+
104
+ payload = Utils.token_payload(@token)
105
+ opts[:expires] ||= payload["exp"] ? Time.at(payload["exp"].to_i) : Time.at(0)
106
+
107
+ raw_data = {
108
+ "token" => @token,
109
+ "record" => @record ? JSON.parse(JSON.generate(@record)) : nil,
110
+ }
111
+
112
+ result = serialize_cookie(key, JSON.generate(raw_data), opts)
113
+
114
+ # strip the model down to the bare minimum if the cookie is too large
115
+ if raw_data["record"] && result.bytesize > 4096
116
+ rec = raw_data["record"]
117
+ raw_data["record"] = {
118
+ "id" => rec["id"],
119
+ "email" => rec["email"],
120
+ "collectionId" => rec["collectionId"],
121
+ "collectionName" => rec["collectionName"],
122
+ "verified" => rec["verified"],
123
+ }.compact
124
+ result = serialize_cookie(key, JSON.generate(raw_data), opts)
125
+ end
126
+
127
+ result
128
+ end
129
+
130
+ private
131
+
132
+ def trigger_change
133
+ @on_change_callbacks.dup.each { |cb| cb&.call(@token, @record) }
134
+ end
135
+
136
+ def parse_cookie(cookie)
137
+ result = {}
138
+ cookie.to_s.split(";").each do |pair|
139
+ k, v = pair.strip.split("=", 2)
140
+ next if k.nil? || k.empty?
141
+
142
+ result[k] = v || ""
143
+ end
144
+ result
145
+ end
146
+
147
+ def serialize_cookie(key, value, options)
148
+ parts = ["#{key}=#{Utils.encode_uri_component(value)}"]
149
+ parts << "Path=#{options[:path]}" if options[:path]
150
+ parts << "Domain=#{options[:domain]}" if options[:domain]
151
+ parts << "Expires=#{options[:expires].utc.strftime("%a, %d %b %Y %H:%M:%S GMT")}" if options[:expires]
152
+ parts << "Max-Age=#{options[:max_age].to_i}" if options[:max_age]
153
+ parts << "Secure" if options[:secure]
154
+ parts << "HttpOnly" if options[:http_only]
155
+ same_site = options[:same_site]
156
+ parts << "SameSite=#{same_site == true ? "Strict" : same_site}" if same_site
157
+ parts.join("; ")
158
+ end
159
+ end
160
+
161
+ # File-persisted auth store (the Ruby equivalent of js-sdk's LocalAuthStore,
162
+ # which uses browser localStorage). State is stored as JSON at `path`.
163
+ class FileAuthStore < AuthStore
164
+ attr_reader :path
165
+
166
+ # Loads existing auth state, or saves supplied initial state.
167
+ # Parent directory must exist. Missing or unreadable state loads empty.
168
+ #
169
+ # @param path [String] File path; relative paths and ~ are expanded
170
+ # @param token [String, nil] Optional initial token
171
+ # @param record [Hash, nil] Optional initial authenticated record
172
+ # @raise [SystemCallError, IOError] If initial state cannot be persisted
173
+ def initialize(path, token = nil, record = nil)
174
+ @path = File.expand_path(path.to_s)
175
+ super()
176
+ if token || record
177
+ save(token, record)
178
+ else
179
+ load_from_file
180
+ end
181
+ end
182
+
183
+ # Updates memory, notifies listeners, then writes authentication to disk.
184
+ # Write failures propagate; memory may already contain the new state.
185
+ #
186
+ # @param token [String, nil] Token to save
187
+ # @param record [Hash, nil] Authenticated record
188
+ # @return [FileAuthStore] This store
189
+ # @raise [SystemCallError, IOError] If state cannot be written
190
+ def save(token, record = nil)
191
+ super
192
+ persist_to_file
193
+ self
194
+ end
195
+
196
+ # Clears authentication in memory and persists the cleared state.
197
+ # A write failure does not guarantee removal of the previous disk token.
198
+ #
199
+ # @return [FileAuthStore] This store
200
+ # @raise [SystemCallError, IOError] If cleared state cannot be written
201
+ def clear
202
+ super
203
+ persist_to_file
204
+ self
205
+ end
206
+
207
+ private
208
+
209
+ def load_from_file
210
+ return unless File.exist?(@path)
211
+
212
+ data = JSON.parse(File.read(@path))
213
+ @token = data["token"].to_s
214
+ @record = data["record"]
215
+ rescue StandardError
216
+ @token = ""
217
+ @record = nil
218
+ end
219
+
220
+ def persist_to_file
221
+ File.write(@path, JSON.generate({ "token" => @token, "record" => @record }))
222
+ end
223
+ end
224
+ end