briefly 0.1.0 → 0.2.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.
data/README.md CHANGED
@@ -1,27 +1,27 @@
1
1
  # briefly
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/briefly.svg)](https://rubygems.org/gems/briefly)
4
- [![CI](https://github.com/svyatov/briefly/actions/workflows/main.yml/badge.svg)](https://github.com/svyatov/briefly/actions/workflows/main.yml)
5
- [![codecov](https://codecov.io/gh/svyatov/briefly/branch/main/graph/badge.svg)](https://codecov.io/gh/svyatov/briefly)
6
- [![Documentation](https://img.shields.io/badge/docs-rubydoc.info-blue.svg)](https://rubydoc.info/gems/briefly)
7
- [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D.svg)](https://www.ruby-lang.org)
8
- [![Types: RBS](https://img.shields.io/badge/types-RBS-8A2BE2.svg)](https://github.com/svyatov/briefly/tree/main/sig)
3
+ A terse, curated facade over your application's most reached-for objects.
9
4
 
10
- A terse, curated facade over your application's most reached-for objects — **thread-safe**,
11
- **reload-correct**, with a batteries-included Rails pack.
5
+ [![gem](https://img.shields.io/gem/v/briefly)](https://rubygems.org/gems/briefly)
6
+ [![CI](https://github.com/svyatov/briefly/actions/workflows/main.yml/badge.svg?branch=main)](https://github.com/svyatov/briefly/actions/workflows/main.yml)
7
+ [![coverage](https://codecov.io/gh/svyatov/briefly/branch/main/graph/badge.svg)](https://app.codecov.io/gh/svyatov/briefly)
12
8
 
13
- Every app grows an `App` module full of `def self.config = Rails.configuration`. `briefly` gives
14
- you that module without writing it, as **real methods** no `method_missing`, so `respond_to?`,
15
- console tab-completion and test stubbing all just work. Each shortcut carries its body's `arity` and
16
- parameter kinds keyword names are exact, positionals get generated ones — and its `source_location`
17
- is the block you declared, so jump-to-definition lands in your initializer rather than inside the gem.
18
- That fabrication is [candor](https://github.com/svyatov/candor), extracted from this gem and its only
19
- runtime dependency; candor itself has none.
9
+ - **Ruby 3.2 and up.** Rails is optional: the gem does not declare it, and `Briefly::Rails` is
10
+ autoloaded only when you name it. The Rails packs themselves need Rails 7.2 or newer.
11
+ - **25 shortcuts across six packs.** Config, env, view, db, instrument and reload, each usable on
12
+ its own or taken together through the `"rails"` umbrella.
13
+ - **One runtime dependency.** [candor](https://github.com/svyatov/candor), which is itself
14
+ dependency-free.
15
+
16
+ ```bash
17
+ bundle add briefly
18
+ ```
19
+
20
+ Then, in `config/initializers/app.rb`:
20
21
 
21
22
  ```ruby
22
- # config/initializers/app.rb
23
23
  App = Briefly.define do
24
- use Briefly::Rails
24
+ use "rails"
25
25
  shortcut(:redis) { REDIS_POOL }
26
26
  end
27
27
 
@@ -32,27 +32,35 @@ App.redis # => REDIS_POOL
32
32
  App.local? # => true in development and test
33
33
  ```
34
34
 
35
- ## Installation
35
+ Every app grows an `App` module full of `def self.config = Rails.configuration`. `briefly` gives
36
+ you that module without writing it, as real methods. There is no `method_missing`, so `respond_to?`,
37
+ console tab-completion and test stubbing all work. Each shortcut carries its body's `arity` and
38
+ parameter kinds (keyword names are exact, positionals get generated ones), and its `source_location`
39
+ is the block you declared, so jump-to-definition lands in your initializer rather than inside the gem.
40
+ That fabrication is [candor](https://github.com/svyatov/candor), extracted from this gem.
36
41
 
37
- ```ruby
38
- gem "briefly"
39
- ```
42
+ ## The Rails generator
40
43
 
41
- Ruby >= 3.2. The one runtime dependency is `candor`. Rails is **optional**: the gem does not declare
42
- it, and `Briefly::Rails` is autoloaded only when you name it.
44
+ In a Rails app, `rails g briefly:install` writes `config/initializers/briefly.rb`: a working `App`
45
+ facade plus a commented, concern-grouped map of every shortcut the `rails` pack gives you. Pass a
46
+ name (`rails g briefly:install Facade`) to call the constant something else. Re-run it after upgrading
47
+ to refresh the map; Rails prompts before overwriting. The generator loads only under `rails generate`,
48
+ so it adds no runtime dependency.
43
49
 
44
50
  ## Core concepts
45
51
 
46
52
  A **facade** is the object `Briefly.define` returns. You assign it to a constant of your choosing;
47
- `briefly` never installs one for you. Multiple independent facades share no state:
53
+ `briefly` never installs one for you. Multiple independent facades share no state, so an initializer
54
+ can declare as many as it needs:
48
55
 
49
56
  ```ruby
50
- App = Briefly.define { use Briefly::Rails }
57
+ App = Briefly.define { use "rails" }
51
58
  Admin = Briefly.define { shortcut(:audit_log) { AuditLog } }
52
59
  ```
53
60
 
54
- A **shortcut** is a name plus a body. The body is always attached to `shortcut` one block, one
55
- place and runs bound to the facade, so it can reach the facade's other shortcuts:
61
+ A **shortcut** is a name plus a body. The body is always attached to `shortcut`, one block in one
62
+ place, and runs bound to the facade, so it can reach the facade's other shortcuts. Inside the
63
+ initializer's `Briefly.define` block:
56
64
 
57
65
  ```ruby
58
66
  Briefly.define do
@@ -64,66 +72,90 @@ end
64
72
  ```
65
73
 
66
74
  Aliases are real methods delegating to the same body and the same memo cell. Redeclaring a name
67
- overrides it silently that is how you override a pack's shortcut.
75
+ overrides it silently; that is how you override a pack's shortcut.
68
76
 
69
77
  ## `memoize`
70
78
 
71
- Annotate an already-declared shortcut by name, on its own line:
79
+ `shortcut` returns the shortcut you refine. Chain `.memoize` onto it in your initializer to cache the
80
+ value, computed once, then reused for the process lifetime:
72
81
 
73
82
  ```ruby
74
83
  Briefly.define do
75
- shortcut(:catalog) { Catalog.load_from_disk }
76
- memoize :catalog
84
+ shortcut(:catalog) { Catalog.load_from_disk }.memoize
77
85
  end
78
86
  ```
79
87
 
80
- Memoization is **permanent for the process** the core has no idea what a "reload" is. It caches
81
- `nil` and `false` correctly, and a body that takes any parameter positional, keyword, or block —
88
+ To memoize a shortcut you didn't declare yourself, one a pack installed, say, call `shortcut(name)`
89
+ with no block to fetch it, then chain onto that. A bodiless `shortcut` never re-declares, so this
90
+ also belongs in the initializer:
91
+
92
+ ```ruby
93
+ Briefly.define do
94
+ use "rails"
95
+ shortcut(:cache).memoize
96
+ end
97
+ ```
98
+
99
+ Memoization is permanent for the process; the core has no idea what a "reload" is. It caches
100
+ `nil` and `false` correctly, and a body that takes any parameter (positional, keyword, or block)
82
101
  cannot be memoized (raises at build time). The compiled method takes no arguments either, so
83
102
  `App.catalog(:x)` is an `ArgumentError`, never a silent cache hit. If a memoized body raises and a
84
- handler supplies a fallback, **that shortcut's own cell is not filled**: the transient failure is
103
+ handler supplies a fallback, that shortcut's own cell is not filled: the transient failure is
85
104
  retried on next call.
86
105
 
87
106
  That guarantee is per-cell, and does not compose. A memoized shortcut whose body *reads* a
88
- rescue-backed shortcut succeeds, so its own value containing the fallback is cached for the
89
- process lifetime, even after the inner shortcut recovers:
107
+ rescue-backed shortcut succeeds, so its own value (containing the fallback) is cached for the
108
+ process lifetime, even after the inner shortcut recovers. Two initializer declarations that trap
109
+ you:
90
110
 
91
111
  ```ruby
92
- shortcut(:flaky) { external_call } # rescue_from(..., :flaky) { "unknown" }
93
- memoize :flaky
94
- shortcut(:summary) { "build #{flaky}" } # caches "build unknown" forever
95
- memoize :summary # <- don't memoize over a rescue-backed shortcut
112
+ shortcut(:flaky) { external_call }.rescue_from(SomeError) { "unknown" }.memoize
113
+ shortcut(:summary) { "build #{flaky}" }.memoize # <- caches "build unknown" forever
96
114
  ```
97
115
 
98
- Clearing is a neutral primitive:
116
+ Clearing is a neutral primitive. Management lives behind one accessor, `App.briefly`, so names like
117
+ `configure`, `shortcuts`, `shortcut?` and `clear_memos!` stay yours to use as shortcuts. Call it
118
+ from application code:
99
119
 
100
120
  ```ruby
101
- App.clear_memos! # => App (thread-safe; `reset!` is an alias)
121
+ App.briefly.clear_memos! # => App (thread-safe)
102
122
  ```
103
123
 
124
+ Reclaim one of those names and `App.configure` calls *your* shortcut; the old management call now
125
+ lives only at `App.briefly.configure`. Worth knowing when porting pre-0.2.0 code: a leftover
126
+ `App.configure { ... }` won't raise if a `configure` shortcut exists, it just runs the shortcut and,
127
+ like any non-yielding method, drops the block.
128
+
104
129
  *When* to clear is a pack's business. See [Reloading](#reloading-and-thread-safety).
105
130
 
106
131
  ## `rescue_from`
107
132
 
108
- Error class first, shortcut names optional and trailing. The handler's **return value becomes the
109
- shortcut's return value**:
133
+ The handler's return value becomes the shortcut's return value. To guard one shortcut, chain
134
+ `.rescue_from(error_class) { |e, name| ... }` onto it. The top-level `rescue_from` verb is for what a
135
+ single shortcut can't voice: a facade-wide handler, consulted after each shortcut's own. Error class
136
+ first, always. Both forms belong in the initializer:
110
137
 
111
138
  ```ruby
112
139
  Briefly.define do
113
- use Briefly::Rails
114
- shortcut(:redis) { REDIS_POOL }
115
- rescue_from(Redis::BaseError, :redis) { |e| Sentry.capture_exception(e); nil }
116
- rescue_from(StandardError) { |e, name| Rails.logger.warn("#{name}: #{e.message}"); raise }
140
+ use "rails"
141
+ shortcut(:redis) { REDIS_POOL }.rescue_from(Redis::BaseError) { |e| Sentry.capture_exception(e); nil }
142
+ rescue_from(StandardError) { |e, name| Rails.logger.warn("#{name}: #{e.message}"); raise } # facade-wide
117
143
  end
118
144
  ```
119
145
 
120
- Unlike a shortcut body, a handler is **not** bound to the facade — it is called as
146
+ The top-level `rescue_from` takes no shortcut names. Pass any and it raises `ArgumentError`, pointing
147
+ you at `shortcut(name).rescue_from(...)`. Scoping a handler to a shortcut lives in one channel, the
148
+ shortcut itself, so its name is never written twice to annotate it. To share one handler across a few
149
+ shortcuts, chain `.rescue_from` onto each; to cover them all, register it facade-wide.
150
+
151
+ Unlike a shortcut body, a handler is not bound to the facade. It is called as
121
152
  `handler.call(error, name)`, so `self` stays whatever it was where you wrote the block. Reach for
122
153
  constants (`Rails.logger`, `Sentry`) rather than bare shortcut names inside a handler.
123
154
 
124
- > **A facade-wide `rescue_from(StandardError)` catches your own bugs, not just your app's.**
125
- > `briefly` cannot tell an error raised *by* a shortcut body from one raised *about* the call — a
126
- > typo and a dead Redis both arrive as a `StandardError`:
155
+ > [!WARNING]
156
+ > A facade-wide `rescue_from(StandardError)` catches your own bugs, not just your app's.
157
+ > `briefly` cannot tell an error raised *by* a shortcut body from one raised *about* the call; a
158
+ > typo and a dead Redis both arrive as a `StandardError`. Written in an initializer:
127
159
  >
128
160
  > ```ruby
129
161
  > shortcut(:host) { Rails.aplication.config.host } # typo -> NoMethodError
@@ -134,23 +166,28 @@ constants (`Rails.logger`, `Sentry`) rather than bare shortcut names inside a ha
134
166
  >
135
167
  > Three ways out, in order of preference: scope handlers to the shortcuts that can actually fail;
136
168
  > match the narrowest error class you mean; and if you do want a facade-wide handler, make it log
137
- > and `raise` a bare `raise` inside a handler re-raises the original, backtrace intact.
169
+ > and `raise`. A bare `raise` inside a handler re-raises the original, backtrace intact.
138
170
  >
139
171
  > A bad *call* from outside the facade is not affected. Every shortcut carries its body's arity, so
140
172
  > `App.env(1)` raises `ArgumentError` at the call site, before any handler is consulted. One shortcut
141
173
  > body calling another with a bad argument list is a different matter: that raises *inside* the
142
174
  > calling body, where the calling shortcut's own handler sees it like any other error.
143
175
 
144
- > **⚠️ `{}` needs parentheses.** `rescue_from StandardError { ... }` binds the block to
145
- > `StandardError`, not to `rescue_from`, and raises `NoMethodError`. Use **either** form:
176
+ > [!IMPORTANT]
177
+ > `{}` needs parentheses, on the standalone `rescue_from` only. `rescue_from StandardError { ... }`
178
+ > binds the block to `StandardError`, not to `rescue_from`, and raises `NoMethodError`. Use either
179
+ > form inside your `Briefly.define` block:
146
180
  >
147
181
  > ```ruby
148
- > rescue_from StandardError, :redis do |e| ... end # do/end, no parens
149
- > rescue_from(StandardError, :redis) { |e| ... } # braces REQUIRE parens
182
+ > rescue_from StandardError do |e| ... end # do/end, no parens
183
+ > rescue_from(StandardError) { |e| ... } # braces REQUIRE parens
150
184
  > ```
185
+ >
186
+ > Scoping to the shortcut sidesteps this: `shortcut(:redis) { ... }.rescue_from(Redis::BaseError) { |e| ... }`
187
+ > already has a receiver and parens, so `{ }` binds where you mean it.
151
188
 
152
189
  Handlers are plain procs, so `{ |e| }` and `{ |e, name| }` both work. Re-raising propagates. If no
153
- handler matches, the original error propagates unchanged never silently swallowed. Only
190
+ handler matches, the original error propagates unchanged, never silently swallowed. Only
154
191
  `StandardError` and its descendants participate.
155
192
 
156
193
  `Briefly.rescue_from(error_class, &handler)` registers a global default across every facade. It
@@ -162,23 +199,23 @@ For a raised error, the first `is_a?` match wins, searching in this order:
162
199
 
163
200
  | # | Level | Within the level |
164
201
  |---|-------|------------------|
165
- | 1 | Facade handlers scoped to this shortcut | last registered first |
166
- | 2 | Facade handlers with no names | last registered first |
202
+ | 1 | This shortcut's own handlers | last registered first |
203
+ | 2 | Facade-wide handlers | last registered first |
167
204
  | 3 | Global handlers (`Briefly.rescue_from`) | last registered first |
168
205
 
169
- No match the error propagates.
206
+ With no match, the error propagates.
170
207
 
171
208
  ## `namespace`
172
209
 
173
- `namespace` groups shortcuts behind a name, so the root keyspace stays yours:
210
+ `namespace` groups shortcuts behind a name, so the root keyspace stays yours. In the initializer,
211
+ and then anywhere in the app:
174
212
 
175
213
  ```ruby
176
214
  App = Briefly.define do
177
215
  shortcut(:redis) { REDIS_POOL }
178
216
 
179
217
  namespace :db do
180
- shortcut(:pool) { ActiveRecord::Base.connection_pool }
181
- memoize :pool
218
+ shortcut(:pool) { ActiveRecord::Base.connection_pool }.memoize
182
219
  end
183
220
  end
184
221
 
@@ -187,9 +224,9 @@ App.db # => #<Briefly::Facade shortcuts=[:pool]>
187
224
  App.db.pool # => the pool
188
225
  ```
189
226
 
190
- A namespace is a child `Briefly::Facade`, reached by a real method like any other shortcut so
227
+ A namespace is a child `Briefly::Facade`, reached by a real method like any other shortcut, so
191
228
  `App.db` is a value you can pass around, and `App.db.pool` is not a `method_missing` trick. It takes
192
- the whole DSL: `shortcut`, `memoize`, `rescue_from`, `use`, and further namespaces.
229
+ the whole DSL: `shortcut` (and the shortcut it returns), `rescue_from`, `use`, and further namespaces.
193
230
 
194
231
  `clear_memos!` cascades, so one `Briefly::Rails::Reload` on the root clears the whole tree. The child
195
232
  is created once and reused, so its memos survive a later `configure`.
@@ -206,8 +243,9 @@ Two limits, both deliberate:
206
243
 
207
244
  ## Packs
208
245
 
209
- A pack is **any object responding to `#install(builder, **opts)`**. That's the whole protocol. Options
210
- are optional: Ruby drops an empty `**` splat, so a pack taking none needs no keyword parameter.
246
+ A pack is any object responding to `#install(builder, **opts)`. Options are optional: Ruby drops an
247
+ empty `**` splat, so a pack taking none needs no keyword parameter. Put the pack in a file your
248
+ app already loads, such as `lib/redis_pack.rb`, and `use` it from the initializer:
211
249
 
212
250
  ```ruby
213
251
  module RedisPack
@@ -215,21 +253,21 @@ module RedisPack
215
253
 
216
254
  def install(builder)
217
255
  builder.shortcut(:redis) { ConnectionPool.new { Redis.new } }
218
- builder.memoize(:redis)
219
- builder.rescue_from(Redis::CannotConnectError, :redis) { nil }
256
+ .memoize
257
+ .rescue_from(Redis::CannotConnectError) { nil }
220
258
  end
221
259
  end
222
260
 
223
261
  Api = Briefly.define { use RedisPack }
224
262
  ```
225
263
 
226
- Packs may `use` other packs, and may reach `builder.facade` to wire lifecycle hooks that is
264
+ Packs may `use` other packs, and may reach `builder.facade` to wire lifecycle hooks, which is
227
265
  exactly what `Briefly::Rails::Reload` does. The core stays framework-agnostic; packs do not have to.
228
266
 
229
267
  ### Options
230
268
 
231
269
  Keywords passed to `use` reach the pack's `install`. Ruby drops an empty `**` splat, so a pack that
232
- takes no options needs no keyword parameter:
270
+ takes no options needs no keyword parameter. The same pack file, now taking a `url:`:
233
271
 
234
272
  ```ruby
235
273
  module RedisPack
@@ -246,7 +284,8 @@ Api = Briefly.define { use RedisPack, url: "redis://cache:6379" }
246
284
  ### Short names
247
285
 
248
286
  `Briefly.register` maps a name to a pack, so `use` can take a string or symbol. There is no
249
- inflection and no path guessing the registry is the only source of truth:
287
+ inflection and no path guessing; the registry is the only source of truth. Register in the
288
+ initializer, before the `define` that names it:
250
289
 
251
290
  ```ruby
252
291
  Briefly.register("myapp/redis", RedisPack) # a pack object
@@ -256,7 +295,8 @@ Api = Briefly.define { use "myapp/redis", url: "redis://cache:6379" }
256
295
  ```
257
296
 
258
297
  An unregistered name raises `Briefly::UnknownPackError`. The packs this gem ships are registered as
259
- `"rails"`, `"rails/config"`, `"rails/env"`, `"rails/view"`, `"rails/db"` and `"rails/reload"`.
298
+ `"rails"`, `"rails/config"`, `"rails/env"`, `"rails/view"`, `"rails/db"`, `"rails/instrument"` and
299
+ `"rails/reload"`.
260
300
 
261
301
  ### `Briefly::Rails`
262
302
 
@@ -265,42 +305,55 @@ An unregistered name raises `Briefly::UnknownPackError`. The packs this gem ship
265
305
  | `config` | `c` | `Rails.configuration` |
266
306
  | `config_x` | `x` | `Rails.configuration.x` |
267
307
  | `env` | | `Rails.env` |
268
- | `production?` `development?` `test?` `local?` | | `Rails.env.*` |
308
+ | `production?` `development?` `test?` `local?` | `prod?` `dev?` | `Rails.env.*` |
269
309
  | `root` | | `Rails.root` |
270
310
  | `cache` | | `Rails.cache` |
271
311
  | `logger` | `log` | `Rails.logger` |
272
312
  | `credentials` | `cred` | `Rails.application.credentials` |
313
+ | `error` | | `Rails.error` |
314
+ | `config_for` | | `Rails.application.config_for(name, **opts)` |
273
315
  | `helpers` | `h` | `ApplicationController.helpers` |
274
316
  | `routes` | `r` | `Rails.application.routes.url_helpers` |
275
317
  | `renderer` | | `ApplicationController.renderer` |
276
318
  | `render` | | forwards to `renderer.render` |
319
+ | `instrument` | | `ActiveSupport::Notifications.instrument(name, payload) { }` |
277
320
 
278
321
  Plus `db`, a namespace holding `Briefly::Rails::DB`.
279
322
 
323
+ `prod?` and `dev?` alias `production?` and `development?`. `error` is the framework's handled-error
324
+ reporter, so `App.error.report(e)` and `App.error.handle { }` reach it off one live lookup.
325
+ `config_for` reads a per-environment YAML config on every call and forwards any extra keyword (such
326
+ as `env:`) to `Rails.application.config_for`: it takes an argument, so it never memoizes; compose one
327
+ that does by chaining `.memoize` onto a shortcut: `shortcut(:payments) { config_for(:payments) }.memoize`.
328
+
280
329
  Requires Rails >= 7.2. There is no `secrets` shortcut: `Rails.application.secrets` was removed in
281
330
  7.2. Use `credentials`.
282
331
 
283
- **Nothing in the pack is memoized.** `helpers`, `routes` and `renderer` are live lookups: Rails
332
+ Nothing in the pack is memoized. `helpers`, `routes` and `renderer` are live lookups: Rails
284
333
  already caches them on objects it refreshes on reload, so caching them again would only go stale.
285
334
  It still composes `Briefly::Rails::Reload`, because *your* memoized shortcuts need clearing.
286
335
 
287
- Need a custom renderer? Override it last declaration wins:
336
+ Need a custom renderer? Override it in the initializer; last declaration wins:
288
337
 
289
338
  ```ruby
290
339
  App = Briefly.define do
291
- use Briefly::Rails
340
+ use "rails"
292
341
  shortcut(:renderer) { ApplicationController.renderer.new(http_host: x.domain, https: !development?) }
293
342
  end
294
343
  ```
295
344
 
296
- `Briefly::Rails` is an umbrella over four packs, each usable on its own:
345
+ `Briefly::Rails` is an umbrella over six packs, each usable on its own:
297
346
 
298
347
  | pack | short name | shortcuts |
299
348
  |---|---|---|
300
- | `Briefly::Rails::Config` | `"rails/config"` | `config`, `config_x`, `root`, `cache`, `logger`, `credentials` |
349
+ | `Briefly::Rails::Config` | `"rails/config"` | `config`, `config_x`, `root`, `cache`, `logger`, `credentials`, `error`, `config_for` |
301
350
  | `Briefly::Rails::Env` | `"rails/env"` | `env` and its predicates |
302
351
  | `Briefly::Rails::View` | `"rails/view"` | `helpers`, `routes`, `renderer`, `render` |
303
- | `Briefly::Rails::DB` | `"rails/db"` | `connection`, `transaction`, `query` |
352
+ | `Briefly::Rails::DB` | `"rails/db"` | `connection`, `transaction`, `select`, `query`, `connected_to`, `reading`, `writing` |
353
+ | `Briefly::Rails::Instrument` | `"rails/instrument"` | `instrument` |
354
+ | `Briefly::Rails::Reload` | `"rails/reload"` | none; clears memos on every code reload |
355
+
356
+ Take only the parts you want, in the initializer:
304
357
 
305
358
  ```ruby
306
359
  Worker = Briefly.define do
@@ -314,59 +367,101 @@ end
314
367
 
315
368
  | shortcut | aliases | value |
316
369
  |---|---|---|
317
- | `connection` | `conn` | `base.lease_connection` |
370
+ | `connection` | `conn` | forwards keywords and the block to `base.with_connection`, yielding the connection and auto-releasing |
318
371
  | `transaction` | `txn` | forwards keywords and the block to `base.transaction` |
319
- | `query` | | `base.with_connection { \|c\| c.exec_query(sql) }` |
372
+ | `select` | | `base.with_connection { \|c\| c.select_all(sql) }`, a read (SELECT) on the cache-aware path |
373
+ | `query` | | `base.with_connection { \|c\| c.exec_query(sql) }`, arbitrary SQL, writes and DDL included |
374
+ | `connected_to` | | forwards every argument to `base.connected_to` (`role:`, `shard:`, `prevent_writes:`) |
375
+ | `reading` | | runs the block under the `:reading` role |
376
+ | `writing` | | runs the block under the `:writing` role |
377
+
378
+ Declare the namespace in the initializer, then call it from application code:
320
379
 
321
380
  ```ruby
322
381
  App = Briefly.define do
323
- use Briefly::Rails
382
+ use "rails"
324
383
  namespace(:db2) { use "rails/db", base: "SecondaryApplicationRecord" }
325
384
  end
326
385
 
327
- App.db.txn { App.db.query("select * from users where id = ?", 1) }
328
- App.db2.conn
386
+ App.db.txn { App.db.select("select * from users where id = ?", 1) }
387
+ App.db.conn { |c| c.select_value("select count(*) from users") }
329
388
  ```
330
389
 
331
- `query(sql, *binds)` sanitizes through `base.sanitize_sql_array` when binds are given, and passes the
332
- statement through untouched when they are not. Positional and named binds both work:
390
+ `select` and `query` are the two raw-SQL helpers, differing only in which adapter path they take.
391
+ `select(sql, *binds)` runs a read through `select_all`, the path Rails recommends for a raw SELECT,
392
+ returning an `ActiveRecord::Result` without clearing the query cache. `query(sql, *binds)` runs
393
+ arbitrary SQL through `exec_query`: reads, writes, and DDL all execute. The name tells you which
394
+ you're reaching for; neither polices the SQL it's handed, so `select` will happily run a write you
395
+ give it. The split is name and cache-path, not a runtime guard.
396
+
397
+ Both sanitize through `base.sanitize_sql_array` when binds are given, and pass the statement through
398
+ untouched when they are not. Positional and named binds both work, from application code:
333
399
 
334
400
  ```ruby
335
- App.db.query("select * from users where name like '%ada%'") # no binds, passed through
336
- App.db.query("select * from users where id = ?", 123) # positional
337
- App.db.query("select * from users where id = :id", id: 123) # named
401
+ App.db.select("select * from users where name like '%ada%'") # no binds, passed through
402
+ App.db.select("select * from users where id = ?", 123) # positional
403
+ App.db.query("update users set active = true where id = :id", id: 1) # named, a write via `query`
338
404
  ```
339
405
 
340
406
  Binds are bound, never interpolated, so a value like `"x' OR '1'='1"` matches nothing. A bindless
341
- statement is deliberately *not* sanitized: `sanitize_sql_array` would fall through to its
342
- `statement % values` branch and raise on the literal `%` above.
407
+ statement is not sanitized on purpose: `sanitize_sql_array` would fall through to its
408
+ `statement % values` branch and raise on the literal `%` above. That makes binds a safety contract,
409
+ not a convenience: always pass untrusted values as binds. The bindless path is unsanitized, and since
410
+ `query` now runs writes and DDL, an interpolated statement is a destructive-write injection risk, not
411
+ merely a read one.
412
+
413
+ `connection`/`conn` mirrors `transaction`: it forwards to `with_connection`, yields the leased
414
+ connection, and auto-releases at block exit, so nothing leaks outside a request. It requires a block;
415
+ there is no bare-lease accessor. Anyone who genuinely needs a held raw lease calls `lease_connection`
416
+ on their model directly.
417
+
418
+ `reading` and `writing` *route* a block. Everything inside runs under that connection role, so from
419
+ application code you send specific reads to a replica or pin a write to the primary:
420
+
421
+ ```ruby
422
+ App.db.reading { App.db.select("select * from reports") } # runs on the replica
423
+ App.db.writing { Audit.create!(event: "export") } # pinned to the primary
424
+ ```
425
+
426
+ They're sugar over `connected_to`, which is also a shortcut in its own right and forwards the whole
427
+ Rails surface: any role, not just reading and writing, plus `shard:` and `prevent_writes:`. Also
428
+ from application code:
429
+
430
+ ```ruby
431
+ App.db.connected_to(role: :analytics) { Report.all.to_a } # a custom role
432
+ App.db.connected_to(shard: :one) { Order.find(id) } # a shard
433
+ App.db.reading(shard: :two) { ... } # sugar forwards the rest too
434
+ ```
343
435
 
344
- `query` uses `with_connection`, so the connection returns to the pool; `connection`/`conn`
345
- necessarily leases one.
436
+ The `reading`/`writing` sugar pins its role: a `role:` you pass through it can't win, so `reading`
437
+ always reads. All of this needs a multi-database app: `base` must be `ActiveRecord::Base` or the
438
+ abstract class that declared `connects_to(database: { ... })`. On a concrete model `connected_to`
439
+ raises `NotImplementedError`, and in a single-database app with no matching connection
440
+ `App.db.reading { ... }` raises `ActiveRecord::ConnectionNotDefined`. These are a multi-DB tool, not a
441
+ role flag.
346
442
 
347
443
  **Pass `base:` as a String, not the class.** A pack is `use`d from an initializer, where naming an
348
444
  autoloadable constant is what Rails warns about, and the captured class would go stale on the first
349
- code reload permanently, since `Reload` clears memos, not closures. A `Module` is accepted for
445
+ code reload and stay stale, since `Reload` clears memos, not closures. A `Module` is accepted for
350
446
  applications outside the autoloader, with that caveat.
351
447
 
352
448
  The pack memoizes nothing and wires no lifecycle hook, so it works without a booted application.
353
449
 
354
450
  ### `Briefly::Rails::Reload`
355
451
 
356
- `Briefly::Rails` composes it. Use it alone for a facade with no framework shortcuts that still
357
- memoizes objects holding on to reloadable application classes:
452
+ `Briefly::Rails` composes it. Use it alone, in an initializer, for a facade with no framework
453
+ shortcuts that still memoizes objects holding on to reloadable application classes:
358
454
 
359
455
  ```ruby
360
456
  Admin = Briefly.define do
361
- use Briefly::Rails::Reload
362
- shortcut(:policy) { Admin::Policy.new }
363
- memoize :policy
457
+ use "rails/reload"
458
+ shortcut(:policy) { Admin::Policy.new }.memoize
364
459
  end
365
460
  ```
366
461
 
367
- It registers `Rails.application.reloader.to_prepare { facade.clear_memos! }`, so memos are dropped
462
+ It registers `Rails.application.reloader.to_prepare { facade.briefly.clear_memos! }`, so memos are dropped
368
463
  at boot and on every code reload in development. In production nothing reloads, so they persist for
369
- the process lifetime. It raises `Briefly::Error` outside a booted app call it from an initializer.
464
+ the process lifetime. It raises `Briefly::Error` outside a booted app, so call it from an initializer.
370
465
 
371
466
  The callback holds its facade for the process lifetime and cannot be deregistered. Install it on
372
467
  long-lived facades assigned to constants, not on facades built per request.
@@ -377,26 +472,26 @@ Memo reads are lock-free against a frozen snapshot; writes swap in a new frozen
377
472
  reentrant lock, so a memoized body may safely call another memoized shortcut. Under Puma, a
378
473
  memoized body runs exactly once no matter how many threads race for it.
379
474
 
380
- `clear_memos!` guarantees the *next* read recomputes. It does **not** undo in-place mutation of an
475
+ `clear_memos!` guarantees the *next* read recomputes. It does not undo in-place mutation of an
381
476
  already-handed-out object, and it does not survive a process restart. Two facades whose memoized
382
477
  bodies call into each other can deadlock, like any pair of mutually-locking objects; don't do that.
383
478
 
384
479
  ## Testing
385
480
 
386
- Shortcuts are real methods, so nothing special is needed:
481
+ Shortcuts are real methods, so nothing special is needed in a test file:
387
482
 
388
483
  ```ruby
389
484
  allow(App).to receive(:redis).and_return(fake_redis) # rspec-mocks verifies it
390
485
  App.stub(:redis, fake_redis) { ... } # minitest
391
486
  ```
392
487
 
393
- Call `App.reset!` between examples if a memoized value would leak. `Briefly.errors.clear` resets
394
- globally registered handlers.
488
+ Call `App.briefly.clear_memos!` between examples if a memoized value would leak. `Briefly.rescues.clear`
489
+ resets globally registered handlers.
395
490
 
396
491
  ## Types
397
492
 
398
- `briefly` ships RBS signatures in [`sig/`](sig). Shortcuts are compiled at runtime, so **RBS cannot
399
- see them** `Briefly.define`, `Facade`'s lifecycle API and the `Builder` DSL are fully typed, but
493
+ `briefly` ships RBS signatures in [`sig/`](sig). Shortcuts are compiled at runtime, so RBS cannot
494
+ see them. `Briefly.define`, `Facade`'s lifecycle API and the `Builder` DSL are fully typed, but
400
495
  `App.config` is invisible to Steep. Declare the ones you rely on in your own `sig/`:
401
496
 
402
497
  ```rbs
@@ -406,10 +501,12 @@ def App.config: () -> untyped
406
501
  def App.redis: () -> untyped
407
502
  ```
408
503
 
409
- We deliberately do not fake this with an RBS-only `method_missing`; the gem genuinely has none.
504
+ We do not fake this with an RBS-only `method_missing`; the gem has none.
410
505
 
411
506
  ## Migrating a hand-rolled `App`
412
507
 
508
+ What the initializer replaces, and what it becomes:
509
+
413
510
  ```ruby
414
511
  # before
415
512
  module App
@@ -426,19 +523,42 @@ end
426
523
 
427
524
  # after
428
525
  App = Briefly.define do
429
- use Briefly::Rails
430
- shortcut(:redis) { REDIS_POOL }
431
- memoize :redis
526
+ use "rails"
527
+ shortcut(:redis) { REDIS_POOL }.memoize
432
528
  end
433
529
  ```
434
530
 
435
531
  `secrets` becomes `credentials`. `helpers`/`routes` stop going stale because they are no longer
436
- memoized, and `redis` which you *do* want memoized is cleared on every dev reload by the Reload
532
+ memoized, and `redis` (which you *do* want memoized) is cleared on every dev reload by the Reload
437
533
  pack that `Briefly::Rails` composes.
438
534
 
535
+ ## Versioning
536
+
537
+ `briefly` follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). The public API it
538
+ covers is what an application touches: `Briefly.define`, `Briefly.register`, `Briefly.pack`,
539
+ `Briefly.rescue_from` and `Briefly.rescues`; the `Builder` DSL inside a `define` block (`shortcut`,
540
+ `namespace`, `rescue_from`, `use`); the refinements `shortcut` returns (`memoize`, `rescue_from`);
541
+ the management surface behind `App.briefly`; the canonical names and aliases every shipped pack
542
+ installs; and the error classes under `Briefly::Error`.
543
+
544
+ Outside that promise: anything a shortcut compiles to internally, every `__`-prefixed private method
545
+ on `Facade`, and the RBS signatures in `sig/`, which describe the static surface and are revised
546
+ alongside it.
547
+
548
+ While the version stays below `1.0.0`, an incompatible change ships in a MINOR bump and a compatible
549
+ fix in a PATCH, so a MINOR bump is the one that may need you to read the changelog.
550
+
439
551
  ## Contributing
440
552
 
441
- Bug reports and pull requests are welcome see [CONTRIBUTING.md](CONTRIBUTING.md).
553
+ Bug reports and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md), and
554
+ [CHANGELOG.md](CHANGELOG.md) for what has changed between releases.
555
+
556
+ Questions and bug reports both go to [GitHub Issues](https://github.com/svyatov/briefly/issues).
557
+
558
+ ## Maintenance
559
+
560
+ One person maintains `briefly`, actively, in their spare time. Issues and pull requests get a reply,
561
+ and a bug on a supported Ruby or Rails version gets a fix. A large feature request may not.
442
562
 
443
563
  ## License
444
564
 
data/briefly.gemspec CHANGED
@@ -18,7 +18,7 @@ Gem::Specification.new do |spec|
18
18
  spec.required_ruby_version = ">= 3.2.0"
19
19
 
20
20
  spec.require_paths = ["lib"]
21
- spec.files = Dir["lib/**/*.rb"] + Dir["sig/**/*"] +
21
+ spec.files = Dir["lib/**/*.{rb,tt}"] + Dir["sig/**/*"] +
22
22
  %w[.yardopts CHANGELOG.md LICENSE.txt README.md briefly.gemspec]
23
23
 
24
24
  spec.add_dependency "candor", "~> 0.2.0"