briefly 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: b06a4a7f10ae74a9abd66fd882812d0af1a469d09916da7f898375b659bbdbdf
4
- data.tar.gz: a72ed9ae644fa4bca67fa2f671486945ddd90872a78171864fa896b33d778443
3
+ metadata.gz: b2caa5442969820b1605dee751c3e544987d5edc7d89e701ef7a66b222ba1e20
4
+ data.tar.gz: b4aa66328b61c6fe4e56f9b7b4fec9798ad396fd486b3957152aecb227aa7244
5
5
  SHA512:
6
- metadata.gz: 72e7861ca614204a8536bdd8b0c40c48c480981c9cd494dc5d4de6b14bdbca1e016c0c658e728843b29afcf5b878020ae7e511869ad5ec666d825938afdfc885
7
- data.tar.gz: ed27d0120874e0ff825a0ac0859b71c75947bccbe5c9dadf70b20de8d7f7eb46b3212e1f23b52e924e2a8b46b4c31b8950944875fcc94b341039e21b80c816c4
6
+ metadata.gz: ebe66d5820348b8a635abd9f2870458aca11a905c6718399001099ae60e7a340b75c5f0af0f5cb096f7f0482ef7ddb99b7856d3efefdf9437cd4401fdead04a2
7
+ data.tar.gz: 8b3b5ecd63e4b14ca45d614f55214d2fd22abadb107d57e320758058d7d402819e08e8e0614548fd6805646dfecb6e0dbf60f5cb9d6d2dc023b1620452d9951b
data/CHANGELOG.md CHANGED
@@ -6,6 +6,69 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## v0.2.0 (2026-07-14)
10
+
11
+ ### Added
12
+ - `rails g briefly:install` — a Rails generator that writes `config/initializers/briefly.rb`: a
13
+ working `App` facade plus a commented, concern-grouped map of every shortcut `use "rails"` installs.
14
+ Pass a name (`rails g briefly:install Facade`) to rename the constant; re-run it after upgrading to
15
+ refresh the map, as Rails prompts before overwriting. It loads only under `rails generate`, so no
16
+ Rails runtime dependency is added.
17
+ - `Briefly::Rails::Config` gains `error` (`Rails.error`, the framework's handled-error reporter) and
18
+ `config_for` (per-environment YAML via `Rails.application.config_for`). Both are live lookups;
19
+ `config_for` takes an argument, so it never memoizes — compose one that does by chaining `.memoize`
20
+ onto a shortcut: `shortcut(:x) { config_for(:x) }.memoize`.
21
+ - `Briefly::Rails::DB` gains `connected_to`, `reading` and `writing` for multi-database routing.
22
+ `connected_to` forwards the full Rails surface (`role:`, `shard:`, `prevent_writes:`, custom roles);
23
+ `reading`/`writing` are sugar that pin their role and forward the rest. `base` must be
24
+ `ActiveRecord::Base` or an abstract connection class; a concrete model raises `NotImplementedError`.
25
+ - `Briefly::Rails::DB` gains `select` — a raw-SQL read through `select_all`, returning an
26
+ `ActiveRecord::Result` on the query-cache-preserving path, with the same positional and named
27
+ bind-safety as `query`. `query` keeps running arbitrary SQL (writes and DDL included) via `exec_query`.
28
+ - `Briefly::Rails::Env` gains `dev?` and `prod?`, aliases of `development?` and `production?`.
29
+ - `Briefly::Rails::Instrument` — a new `"rails/instrument"` pack with one `instrument` shortcut over
30
+ `ActiveSupport::Notifications.instrument(name, payload) { }`. Usable on its own; `use "rails"`
31
+ includes it, so `App.instrument` comes for free.
32
+
33
+ ### Changed
34
+ - **BREAKING:** Facade management moved behind a single `App.briefly` accessor —
35
+ `App.briefly.configure`, `App.briefly.shortcuts`, `App.briefly.shortcut?` and
36
+ `App.briefly.clear_memos!`. This frees `configure`, `shortcuts`, `shortcut?` and `clear_memos!` for
37
+ use as your own shortcut names; only `briefly`, `inspect` and `to_s` stay reserved on the facade's
38
+ public surface.
39
+ - **BREAKING:** `Briefly::Rails::DB#connection`/`#conn` is now an auto-releasing block that forwards to
40
+ `base.with_connection`, yielding the leased connection and releasing it at block exit — the shape of
41
+ `transaction`. It requires a block; the old bare `base.lease_connection` accessor (held on the thread
42
+ and never released, a leak outside a request) is gone, with no compatibility shim. Anyone needing a
43
+ held raw lease calls `lease_connection` on their model directly.
44
+ - The DB pack's tests now run against real Active Record on in-memory SQLite, so its Active Record
45
+ calls are verified rather than mocked. `activerecord` and `sqlite3` join `activesupport` as dev-only
46
+ dependencies; the gem still declares no Rails runtime dependency.
47
+ - **BREAKING:** `shortcut` now returns the `Briefly::Shortcut` it declares instead of the canonical
48
+ name Symbol. Refine it in place — `shortcut(:x) { ... }.memoize`, `.rescue_from(Error) { fallback }`,
49
+ in any order — so a shortcut's name is never written twice to annotate it. A bodiless `shortcut(:x)`
50
+ fetches an already-declared shortcut (canonical or alias) to refine, raising
51
+ `Briefly::UnknownShortcutError` on an unknown name; it never re-declares. A shortcut's memoization
52
+ and its own error handlers live on the shortcut itself, so refining it after a redeclaration affects
53
+ the declaration you named, exactly as its body does.
54
+ - **BREAKING:** The top-level `rescue_from(error_class)` verb is now facade-wide only and takes no
55
+ shortcut names; passing any raises `ArgumentError` pointing at `shortcut(name).rescue_from(...)`.
56
+ Scope a handler to a shortcut by chaining `.rescue_from` onto it. Global `Briefly.rescue_from` is
57
+ unchanged.
58
+ - **BREAKING:** the rescue-handler registry is now `Briefly::Rescues` (was
59
+ `Briefly::ErrorRegistry`), reached through `Briefly.rescues` (was `Briefly.errors`) — it holds
60
+ `rescue_from` handlers, not errors. `Briefly.rescues.clear` still resets globally registered
61
+ handlers; the internal `#wide` enumerator is gone, replaced by `#size` for counting registrations.
62
+
63
+ ### Removed
64
+ - **BREAKING:** `App.reset!` — use `App.briefly.clear_memos!`. It was a pure alias for `clear_memos!`
65
+ with no internal callers.
66
+ - **BREAKING:** The top-level `memoize` DSL verb — chain `.memoize` onto the shortcut `shortcut`
67
+ returns (`shortcut(:x) { ... }.memoize`), or `shortcut(:x).memoize` for one declared elsewhere.
68
+ - **BREAKING:** Scoping `rescue_from` by shortcut name on the top-level verb — both single-name
69
+ `rescue_from(Error, :x)` and multi-name `rescue_from(Error, :a, :b)`. Scope on the shortcut instead:
70
+ `shortcut(:x).rescue_from(Error) { ... }`, chaining onto each of several.
71
+
9
72
  ## v0.1.0 (2026-07-10)
10
73
 
11
74
  ### Added
data/README.md CHANGED
@@ -1,19 +1,12 @@
1
- # briefly
1
+ # briefly   [![Gem Version](https://badge.fury.io/rb/briefly.svg)](https://rubygems.org/gems/briefly) [![CI](https://github.com/svyatov/briefly/actions/workflows/main.yml/badge.svg)](https://github.com/svyatov/briefly/actions/workflows/main.yml) [![codecov](https://codecov.io/gh/svyatov/briefly/branch/main/graph/badge.svg)](https://codecov.io/gh/svyatov/briefly) [![Documentation](https://img.shields.io/badge/docs-rubydoc.info-blue.svg)](https://rubydoc.info/gems/briefly) [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D.svg)](https://www.ruby-lang.org) [![Types: RBS](https://img.shields.io/badge/types-RBS-8A2BE2.svg)](https://github.com/svyatov/briefly/tree/main/sig)
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)
9
-
10
- A terse, curated facade over your application's most reached-for objects — **thread-safe**,
11
- **reload-correct**, with a batteries-included Rails pack.
3
+ A terse, curated facade over your application's most reached-for objects. Thread-safe, reload-correct,
4
+ with a Rails pack included.
12
5
 
13
6
  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`
7
+ you that module without writing it, as real methods. There is no `method_missing`, so `respond_to?`,
8
+ console tab-completion and test stubbing all work. Each shortcut carries its body's `arity` and
9
+ parameter kinds (keyword names are exact, positionals get generated ones), and its `source_location`
17
10
  is the block you declared, so jump-to-definition lands in your initializer rather than inside the gem.
18
11
  That fabrication is [candor](https://github.com/svyatov/candor), extracted from this gem and its only
19
12
  runtime dependency; candor itself has none.
@@ -21,7 +14,7 @@ runtime dependency; candor itself has none.
21
14
  ```ruby
22
15
  # config/initializers/app.rb
23
16
  App = Briefly.define do
24
- use Briefly::Rails
17
+ use "rails"
25
18
  shortcut(:redis) { REDIS_POOL }
26
19
  end
27
20
 
@@ -38,21 +31,27 @@ App.local? # => true in development and test
38
31
  gem "briefly"
39
32
  ```
40
33
 
41
- Ruby >= 3.2. The one runtime dependency is `candor`. Rails is **optional**: the gem does not declare
34
+ Ruby >= 3.2. The one runtime dependency is `candor`. Rails is optional: the gem does not declare
42
35
  it, and `Briefly::Rails` is autoloaded only when you name it.
43
36
 
37
+ In a Rails app, `rails g briefly:install` writes `config/initializers/briefly.rb` — a working `App`
38
+ facade plus a commented, concern-grouped map of every shortcut the `rails` pack gives you. Pass a
39
+ name (`rails g briefly:install Facade`) to call the constant something else. Re-run it after upgrading
40
+ to refresh the map; Rails prompts before overwriting. The generator loads only under `rails generate`,
41
+ so it adds no runtime dependency.
42
+
44
43
  ## Core concepts
45
44
 
46
45
  A **facade** is the object `Briefly.define` returns. You assign it to a constant of your choosing;
47
46
  `briefly` never installs one for you. Multiple independent facades share no state:
48
47
 
49
48
  ```ruby
50
- App = Briefly.define { use Briefly::Rails }
49
+ App = Briefly.define { use "rails" }
51
50
  Admin = Briefly.define { shortcut(:audit_log) { AuditLog } }
52
51
  ```
53
52
 
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:
53
+ A **shortcut** is a name plus a body. The body is always attached to `shortcut`, one block in one
54
+ place, and runs bound to the facade, so it can reach the facade's other shortcuts:
56
55
 
57
56
  ```ruby
58
57
  Briefly.define do
@@ -64,65 +63,85 @@ end
64
63
  ```
65
64
 
66
65
  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.
66
+ overrides it silently; that is how you override a pack's shortcut.
68
67
 
69
68
  ## `memoize`
70
69
 
71
- Annotate an already-declared shortcut by name, on its own line:
70
+ `shortcut` returns the shortcut you refine. Chain `.memoize` onto it to cache the value — computed once,
71
+ then reused for the process lifetime:
72
72
 
73
73
  ```ruby
74
74
  Briefly.define do
75
- shortcut(:catalog) { Catalog.load_from_disk }
76
- memoize :catalog
75
+ shortcut(:catalog) { Catalog.load_from_disk }.memoize
77
76
  end
78
77
  ```
79
78
 
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 —
79
+ To memoize a shortcut you didn't declare here one a pack installed, say call `shortcut(name)`
80
+ with no block to fetch it, then chain onto that. A bodiless `shortcut` never re-declares:
81
+
82
+ ```ruby
83
+ Briefly.define do
84
+ use "rails"
85
+ shortcut(:cache).memoize
86
+ end
87
+ ```
88
+
89
+ Memoization is permanent for the process; the core has no idea what a "reload" is. It caches
90
+ `nil` and `false` correctly, and a body that takes any parameter (positional, keyword, or block)
82
91
  cannot be memoized (raises at build time). The compiled method takes no arguments either, so
83
92
  `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
93
+ handler supplies a fallback, that shortcut's own cell is not filled: the transient failure is
85
94
  retried on next call.
86
95
 
87
96
  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
97
+ rescue-backed shortcut succeeds, so its own value (containing the fallback) is cached for the
89
98
  process lifetime, even after the inner shortcut recovers:
90
99
 
91
100
  ```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
101
+ shortcut(:flaky) { external_call }.rescue_from(SomeError) { "unknown" }.memoize
102
+ shortcut(:summary) { "build #{flaky}" }.memoize # <- caches "build unknown" forever
96
103
  ```
97
104
 
98
- Clearing is a neutral primitive:
105
+ Clearing is a neutral primitive. Management lives behind one accessor, `App.briefly`, so names like
106
+ `configure`, `shortcuts`, `shortcut?` and `clear_memos!` stay yours to use as shortcuts:
99
107
 
100
108
  ```ruby
101
- App.clear_memos! # => App (thread-safe; `reset!` is an alias)
109
+ App.briefly.clear_memos! # => App (thread-safe)
102
110
  ```
103
111
 
112
+ Reclaim one of those names and `App.configure` calls *your* shortcut — the old management call now
113
+ lives only at `App.briefly.configure`. Worth knowing when porting pre-0.2.0 code: a leftover
114
+ `App.configure { ... }` won't raise if a `configure` shortcut exists, it just runs the shortcut and,
115
+ like any non-yielding method, drops the block.
116
+
104
117
  *When* to clear is a pack's business. See [Reloading](#reloading-and-thread-safety).
105
118
 
106
119
  ## `rescue_from`
107
120
 
108
- Error class first, shortcut names optional and trailing. The handler's **return value becomes the
109
- shortcut's return value**:
121
+ The handler's return value becomes the shortcut's return value. To guard one shortcut, chain
122
+ `.rescue_from(error_class) { |e, name| ... }` onto it. The top-level `rescue_from` verb is for what a
123
+ single shortcut can't voice: a facade-wide handler, consulted after each shortcut's own. Error class
124
+ first, always:
110
125
 
111
126
  ```ruby
112
127
  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 }
128
+ use "rails"
129
+ shortcut(:redis) { REDIS_POOL }.rescue_from(Redis::BaseError) { |e| Sentry.capture_exception(e); nil }
130
+ rescue_from(StandardError) { |e, name| Rails.logger.warn("#{name}: #{e.message}"); raise } # facade-wide
117
131
  end
118
132
  ```
119
133
 
120
- Unlike a shortcut body, a handler is **not** bound to the facade — it is called as
134
+ The top-level `rescue_from` takes no shortcut names pass any and it raises `ArgumentError`, pointing
135
+ you at `shortcut(name).rescue_from(...)`. Scoping a handler to a shortcut lives in one channel, the
136
+ shortcut itself, so its name is never written twice to annotate it. To share one handler across a few
137
+ shortcuts, chain `.rescue_from` onto each; to cover them all, register it facade-wide.
138
+
139
+ Unlike a shortcut body, a handler is not bound to the facade. It is called as
121
140
  `handler.call(error, name)`, so `self` stays whatever it was where you wrote the block. Reach for
122
141
  constants (`Rails.logger`, `Sentry`) rather than bare shortcut names inside a handler.
123
142
 
124
143
  > **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
144
+ > `briefly` cannot tell an error raised *by* a shortcut body from one raised *about* the call; a
126
145
  > typo and a dead Redis both arrive as a `StandardError`:
127
146
  >
128
147
  > ```ruby
@@ -134,23 +153,26 @@ constants (`Rails.logger`, `Sentry`) rather than bare shortcut names inside a ha
134
153
  >
135
154
  > Three ways out, in order of preference: scope handlers to the shortcuts that can actually fail;
136
155
  > 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.
156
+ > and `raise`. A bare `raise` inside a handler re-raises the original, backtrace intact.
138
157
  >
139
158
  > A bad *call* from outside the facade is not affected. Every shortcut carries its body's arity, so
140
159
  > `App.env(1)` raises `ArgumentError` at the call site, before any handler is consulted. One shortcut
141
160
  > body calling another with a bad argument list is a different matter: that raises *inside* the
142
161
  > calling body, where the calling shortcut's own handler sees it like any other error.
143
162
 
144
- > **⚠️ `{}` needs parentheses.** `rescue_from StandardError { ... }` binds the block to
145
- > `StandardError`, not to `rescue_from`, and raises `NoMethodError`. Use **either** form:
163
+ > **⚠️ `{}` needs parentheses (standalone `rescue_from` only).** `rescue_from StandardError { ... }`
164
+ > binds the block to `StandardError`, not to `rescue_from`, and raises `NoMethodError`. Use either form:
146
165
  >
147
166
  > ```ruby
148
- > rescue_from StandardError, :redis do |e| ... end # do/end, no parens
149
- > rescue_from(StandardError, :redis) { |e| ... } # braces REQUIRE parens
167
+ > rescue_from StandardError do |e| ... end # do/end, no parens
168
+ > rescue_from(StandardError) { |e| ... } # braces REQUIRE parens
150
169
  > ```
170
+ >
171
+ > Scoping to the shortcut sidesteps this: `shortcut(:redis) { ... }.rescue_from(Redis::BaseError) { |e| ... }`
172
+ > already has a receiver and parens, so `{ }` binds where you mean it.
151
173
 
152
174
  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
175
+ handler matches, the original error propagates unchanged, never silently swallowed. Only
154
176
  `StandardError` and its descendants participate.
155
177
 
156
178
  `Briefly.rescue_from(error_class, &handler)` registers a global default across every facade. It
@@ -162,8 +184,8 @@ For a raised error, the first `is_a?` match wins, searching in this order:
162
184
 
163
185
  | # | Level | Within the level |
164
186
  |---|-------|------------------|
165
- | 1 | Facade handlers scoped to this shortcut | last registered first |
166
- | 2 | Facade handlers with no names | last registered first |
187
+ | 1 | This shortcut's own handlers | last registered first |
188
+ | 2 | Facade-wide handlers | last registered first |
167
189
  | 3 | Global handlers (`Briefly.rescue_from`) | last registered first |
168
190
 
169
191
  No match → the error propagates.
@@ -177,8 +199,7 @@ App = Briefly.define do
177
199
  shortcut(:redis) { REDIS_POOL }
178
200
 
179
201
  namespace :db do
180
- shortcut(:pool) { ActiveRecord::Base.connection_pool }
181
- memoize :pool
202
+ shortcut(:pool) { ActiveRecord::Base.connection_pool }.memoize
182
203
  end
183
204
  end
184
205
 
@@ -187,9 +208,9 @@ App.db # => #<Briefly::Facade shortcuts=[:pool]>
187
208
  App.db.pool # => the pool
188
209
  ```
189
210
 
190
- A namespace is a child `Briefly::Facade`, reached by a real method like any other shortcut so
211
+ A namespace is a child `Briefly::Facade`, reached by a real method like any other shortcut, so
191
212
  `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.
213
+ the whole DSL: `shortcut` (and the shortcut it returns), `rescue_from`, `use`, and further namespaces.
193
214
 
194
215
  `clear_memos!` cascades, so one `Briefly::Rails::Reload` on the root clears the whole tree. The child
195
216
  is created once and reused, so its memos survive a later `configure`.
@@ -206,8 +227,8 @@ Two limits, both deliberate:
206
227
 
207
228
  ## Packs
208
229
 
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.
230
+ A pack is any object responding to `#install(builder, **opts)`. Options are optional: Ruby drops an
231
+ empty `**` splat, so a pack taking none needs no keyword parameter.
211
232
 
212
233
  ```ruby
213
234
  module RedisPack
@@ -215,15 +236,15 @@ module RedisPack
215
236
 
216
237
  def install(builder)
217
238
  builder.shortcut(:redis) { ConnectionPool.new { Redis.new } }
218
- builder.memoize(:redis)
219
- builder.rescue_from(Redis::CannotConnectError, :redis) { nil }
239
+ .memoize
240
+ .rescue_from(Redis::CannotConnectError) { nil }
220
241
  end
221
242
  end
222
243
 
223
244
  Api = Briefly.define { use RedisPack }
224
245
  ```
225
246
 
226
- Packs may `use` other packs, and may reach `builder.facade` to wire lifecycle hooks that is
247
+ Packs may `use` other packs, and may reach `builder.facade` to wire lifecycle hooks, which is
227
248
  exactly what `Briefly::Rails::Reload` does. The core stays framework-agnostic; packs do not have to.
228
249
 
229
250
  ### Options
@@ -246,7 +267,7 @@ Api = Briefly.define { use RedisPack, url: "redis://cache:6379" }
246
267
  ### Short names
247
268
 
248
269
  `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:
270
+ inflection and no path guessing; the registry is the only source of truth:
250
271
 
251
272
  ```ruby
252
273
  Briefly.register("myapp/redis", RedisPack) # a pack object
@@ -256,7 +277,8 @@ Api = Briefly.define { use "myapp/redis", url: "redis://cache:6379" }
256
277
  ```
257
278
 
258
279
  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"`.
280
+ `"rails"`, `"rails/config"`, `"rails/env"`, `"rails/view"`, `"rails/db"`, `"rails/instrument"` and
281
+ `"rails/reload"`.
260
282
 
261
283
  ### `Briefly::Rails`
262
284
 
@@ -265,42 +287,52 @@ An unregistered name raises `Briefly::UnknownPackError`. The packs this gem ship
265
287
  | `config` | `c` | `Rails.configuration` |
266
288
  | `config_x` | `x` | `Rails.configuration.x` |
267
289
  | `env` | | `Rails.env` |
268
- | `production?` `development?` `test?` `local?` | | `Rails.env.*` |
290
+ | `production?` `development?` `test?` `local?` | `prod?` `dev?` | `Rails.env.*` |
269
291
  | `root` | | `Rails.root` |
270
292
  | `cache` | | `Rails.cache` |
271
293
  | `logger` | `log` | `Rails.logger` |
272
294
  | `credentials` | `cred` | `Rails.application.credentials` |
295
+ | `error` | | `Rails.error` |
296
+ | `config_for` | | `Rails.application.config_for(name, **opts)` |
273
297
  | `helpers` | `h` | `ApplicationController.helpers` |
274
298
  | `routes` | `r` | `Rails.application.routes.url_helpers` |
275
299
  | `renderer` | | `ApplicationController.renderer` |
276
300
  | `render` | | forwards to `renderer.render` |
301
+ | `instrument` | | `ActiveSupport::Notifications.instrument(name, payload) { }` |
277
302
 
278
303
  Plus `db`, a namespace holding `Briefly::Rails::DB`.
279
304
 
305
+ `prod?` and `dev?` alias `production?` and `development?`. `error` is the framework's handled-error
306
+ reporter, so `App.error.report(e)` and `App.error.handle { }` reach it off one live lookup.
307
+ `config_for` reads a per-environment YAML config on every call and forwards any extra keyword (such
308
+ as `env:`) to `Rails.application.config_for`: it takes an argument, so it never memoizes; compose one
309
+ that does by chaining `.memoize` onto a shortcut: `shortcut(:payments) { config_for(:payments) }.memoize`.
310
+
280
311
  Requires Rails >= 7.2. There is no `secrets` shortcut: `Rails.application.secrets` was removed in
281
312
  7.2. Use `credentials`.
282
313
 
283
- **Nothing in the pack is memoized.** `helpers`, `routes` and `renderer` are live lookups: Rails
314
+ Nothing in the pack is memoized. `helpers`, `routes` and `renderer` are live lookups: Rails
284
315
  already caches them on objects it refreshes on reload, so caching them again would only go stale.
285
316
  It still composes `Briefly::Rails::Reload`, because *your* memoized shortcuts need clearing.
286
317
 
287
- Need a custom renderer? Override it last declaration wins:
318
+ Need a custom renderer? Override it; last declaration wins:
288
319
 
289
320
  ```ruby
290
321
  App = Briefly.define do
291
- use Briefly::Rails
322
+ use "rails"
292
323
  shortcut(:renderer) { ApplicationController.renderer.new(http_host: x.domain, https: !development?) }
293
324
  end
294
325
  ```
295
326
 
296
- `Briefly::Rails` is an umbrella over four packs, each usable on its own:
327
+ `Briefly::Rails` is an umbrella over five packs, each usable on its own:
297
328
 
298
329
  | pack | short name | shortcuts |
299
330
  |---|---|---|
300
- | `Briefly::Rails::Config` | `"rails/config"` | `config`, `config_x`, `root`, `cache`, `logger`, `credentials` |
331
+ | `Briefly::Rails::Config` | `"rails/config"` | `config`, `config_x`, `root`, `cache`, `logger`, `credentials`, `error`, `config_for` |
301
332
  | `Briefly::Rails::Env` | `"rails/env"` | `env` and its predicates |
302
333
  | `Briefly::Rails::View` | `"rails/view"` | `helpers`, `routes`, `renderer`, `render` |
303
- | `Briefly::Rails::DB` | `"rails/db"` | `connection`, `transaction`, `query` |
334
+ | `Briefly::Rails::DB` | `"rails/db"` | `connection`, `transaction`, `select`, `query`, `connected_to`, `reading`, `writing` |
335
+ | `Briefly::Rails::Instrument` | `"rails/instrument"` | `instrument` |
304
336
 
305
337
  ```ruby
306
338
  Worker = Briefly.define do
@@ -314,39 +346,79 @@ end
314
346
 
315
347
  | shortcut | aliases | value |
316
348
  |---|---|---|
317
- | `connection` | `conn` | `base.lease_connection` |
349
+ | `connection` | `conn` | forwards keywords and the block to `base.with_connection` — yields the connection, auto-releases |
318
350
  | `transaction` | `txn` | forwards keywords and the block to `base.transaction` |
319
- | `query` | | `base.with_connection { \|c\| c.exec_query(sql) }` |
351
+ | `select` | | `base.with_connection { \|c\| c.select_all(sql) }` — a read (SELECT) on the cache-aware path |
352
+ | `query` | | `base.with_connection { \|c\| c.exec_query(sql) }` — arbitrary SQL, writes and DDL included |
353
+ | `connected_to` | | forwards every argument to `base.connected_to` (`role:`, `shard:`, `prevent_writes:`) |
354
+ | `reading` | | runs the block under the `:reading` role |
355
+ | `writing` | | runs the block under the `:writing` role |
320
356
 
321
357
  ```ruby
322
358
  App = Briefly.define do
323
- use Briefly::Rails
359
+ use "rails"
324
360
  namespace(:db2) { use "rails/db", base: "SecondaryApplicationRecord" }
325
361
  end
326
362
 
327
- App.db.txn { App.db.query("select * from users where id = ?", 1) }
328
- App.db2.conn
363
+ App.db.txn { App.db.select("select * from users where id = ?", 1) }
364
+ App.db.conn { |c| c.select_value("select count(*) from users") }
329
365
  ```
330
366
 
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:
367
+ `select` and `query` are the two raw-SQL helpers, differing only in which adapter path they take.
368
+ `select(sql, *binds)` runs a read through `select_all` the path Rails recommends for a raw SELECT,
369
+ returning an `ActiveRecord::Result` without clearing the query cache. `query(sql, *binds)` runs
370
+ arbitrary SQL through `exec_query`: reads, writes, and DDL all execute. The name tells you which
371
+ you're reaching for; neither polices the SQL it's handed, so `select` will happily run a write you
372
+ give it — the split is name and cache-path, not a runtime guard.
373
+
374
+ Both sanitize through `base.sanitize_sql_array` when binds are given, and pass the statement through
375
+ untouched when they are not. Positional and named binds both work:
333
376
 
334
377
  ```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
378
+ App.db.select("select * from users where name like '%ada%'") # no binds, passed through
379
+ App.db.select("select * from users where id = ?", 123) # positional
380
+ App.db.query("update users set active = true where id = :id", id: 1) # named, a write via `query`
338
381
  ```
339
382
 
340
383
  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.
384
+ statement is not sanitized on purpose: `sanitize_sql_array` would fall through to its
385
+ `statement % values` branch and raise on the literal `%` above. That makes binds a safety contract,
386
+ not a convenience: always pass untrusted values as binds. The bindless path is unsanitized, and since
387
+ `query` now runs writes and DDL, an interpolated statement is a destructive-write injection risk, not
388
+ merely a read one.
389
+
390
+ `connection`/`conn` mirrors `transaction`: it forwards to `with_connection`, yields the leased
391
+ connection, and auto-releases at block exit, so nothing leaks outside a request. It requires a block —
392
+ there is no bare-lease accessor. Anyone who genuinely needs a held raw lease calls `lease_connection`
393
+ on their model directly.
394
+
395
+ `reading` and `writing` *route* a block — everything inside runs under that connection role, so you
396
+ send specific reads to a replica or pin a write to the primary:
397
+
398
+ ```ruby
399
+ App.db.reading { App.db.select("select * from reports") } # runs on the replica
400
+ App.db.writing { Audit.create!(event: "export") } # pinned to the primary
401
+ ```
343
402
 
344
- `query` uses `with_connection`, so the connection returns to the pool; `connection`/`conn`
345
- necessarily leases one.
403
+ They're sugar over `connected_to`, which is also a shortcut in its own right and forwards the whole
404
+ Rails surface — any role (not just reading/writing), plus `shard:` and `prevent_writes:`:
405
+
406
+ ```ruby
407
+ App.db.connected_to(role: :analytics) { Report.all.to_a } # a custom role
408
+ App.db.connected_to(shard: :one) { Order.find(id) } # a shard
409
+ App.db.reading(shard: :two) { ... } # sugar forwards the rest too
410
+ ```
411
+
412
+ The `reading`/`writing` sugar pins its role: a `role:` you pass through it can't win, so `reading`
413
+ always reads. All of this needs a multi-database app: `base` must be `ActiveRecord::Base` or the
414
+ abstract class that declared `connects_to(database: { ... })`. On a concrete model `connected_to`
415
+ raises `NotImplementedError`, and in a single-database app with no matching connection
416
+ `App.db.reading { ... }` raises `ActiveRecord::ConnectionNotDefined` — these are a multi-DB tool, not a
417
+ role flag.
346
418
 
347
419
  **Pass `base:` as a String, not the class.** A pack is `use`d from an initializer, where naming an
348
420
  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
421
+ code reload and stay stale, since `Reload` clears memos, not closures. A `Module` is accepted for
350
422
  applications outside the autoloader, with that caveat.
351
423
 
352
424
  The pack memoizes nothing and wires no lifecycle hook, so it works without a booted application.
@@ -358,15 +430,14 @@ memoizes objects holding on to reloadable application classes:
358
430
 
359
431
  ```ruby
360
432
  Admin = Briefly.define do
361
- use Briefly::Rails::Reload
362
- shortcut(:policy) { Admin::Policy.new }
363
- memoize :policy
433
+ use "rails/reload"
434
+ shortcut(:policy) { Admin::Policy.new }.memoize
364
435
  end
365
436
  ```
366
437
 
367
- It registers `Rails.application.reloader.to_prepare { facade.clear_memos! }`, so memos are dropped
438
+ It registers `Rails.application.reloader.to_prepare { facade.briefly.clear_memos! }`, so memos are dropped
368
439
  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.
440
+ the process lifetime. It raises `Briefly::Error` outside a booted app, so call it from an initializer.
370
441
 
371
442
  The callback holds its facade for the process lifetime and cannot be deregistered. Install it on
372
443
  long-lived facades assigned to constants, not on facades built per request.
@@ -377,7 +448,7 @@ Memo reads are lock-free against a frozen snapshot; writes swap in a new frozen
377
448
  reentrant lock, so a memoized body may safely call another memoized shortcut. Under Puma, a
378
449
  memoized body runs exactly once no matter how many threads race for it.
379
450
 
380
- `clear_memos!` guarantees the *next* read recomputes. It does **not** undo in-place mutation of an
451
+ `clear_memos!` guarantees the *next* read recomputes. It does not undo in-place mutation of an
381
452
  already-handed-out object, and it does not survive a process restart. Two facades whose memoized
382
453
  bodies call into each other can deadlock, like any pair of mutually-locking objects; don't do that.
383
454
 
@@ -390,13 +461,13 @@ allow(App).to receive(:redis).and_return(fake_redis) # rspec-mocks verifies it
390
461
  App.stub(:redis, fake_redis) { ... } # minitest
391
462
  ```
392
463
 
393
- Call `App.reset!` between examples if a memoized value would leak. `Briefly.errors.clear` resets
394
- globally registered handlers.
464
+ Call `App.briefly.clear_memos!` between examples if a memoized value would leak. `Briefly.rescues.clear`
465
+ resets globally registered handlers.
395
466
 
396
467
  ## Types
397
468
 
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
469
+ `briefly` ships RBS signatures in [`sig/`](sig). Shortcuts are compiled at runtime, so RBS cannot
470
+ see them. `Briefly.define`, `Facade`'s lifecycle API and the `Builder` DSL are fully typed, but
400
471
  `App.config` is invisible to Steep. Declare the ones you rely on in your own `sig/`:
401
472
 
402
473
  ```rbs
@@ -406,7 +477,7 @@ def App.config: () -> untyped
406
477
  def App.redis: () -> untyped
407
478
  ```
408
479
 
409
- We deliberately do not fake this with an RBS-only `method_missing`; the gem genuinely has none.
480
+ We do not fake this with an RBS-only `method_missing`; the gem has none.
410
481
 
411
482
  ## Migrating a hand-rolled `App`
412
483
 
@@ -426,19 +497,18 @@ end
426
497
 
427
498
  # after
428
499
  App = Briefly.define do
429
- use Briefly::Rails
430
- shortcut(:redis) { REDIS_POOL }
431
- memoize :redis
500
+ use "rails"
501
+ shortcut(:redis) { REDIS_POOL }.memoize
432
502
  end
433
503
  ```
434
504
 
435
505
  `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
506
+ memoized, and `redis` (which you *do* want memoized) is cleared on every dev reload by the Reload
437
507
  pack that `Briefly::Rails` composes.
438
508
 
439
509
  ## Contributing
440
510
 
441
- Bug reports and pull requests are welcome see [CONTRIBUTING.md](CONTRIBUTING.md).
511
+ Bug reports and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
442
512
 
443
513
  ## License
444
514
 
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"