bouncy 0.2.0 → 0.2.2
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 +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +12 -1
- data/guides/api.md +20 -1
- data/guides/compatibility.md +8 -2
- data/guides/configuration.md +1 -1
- data/guides/delivery.md +1 -1
- data/guides/migrating.md +7 -6
- data/guides/operations.md +1 -1
- data/guides/privacy.md +1 -1
- data/guides/recovery.md +1 -1
- data/lib/bouncy/address_lock.rb +5 -6
- data/lib/bouncy/database.rb +33 -0
- data/lib/bouncy/event.rb +2 -1
- data/lib/bouncy/importer.rb +100 -0
- data/lib/bouncy/ingestor.rb +7 -0
- data/lib/bouncy/scope_lock.rb +4 -6
- data/lib/bouncy/status.rb +3 -0
- data/lib/bouncy/store.rb +5 -1
- data/lib/bouncy/version.rb +1 -1
- data/lib/bouncy.rb +12 -2
- data/lib/generators/bouncy/templates/create_bouncy_tables.rb.erb +18 -4
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 454c9ceb5482a94e7885dd6d648b9716e05d24c6ffe6d11cae8e4957297be373
|
|
4
|
+
data.tar.gz: 36c35cc2d4b7b39d63722dd04cdb36a773cd0814dca1b7f8fd12be463ac55222
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8ab0266ad476a1b157171125d6062c3eda54b02b078aaaa244e6e22dee750c7ec19512cd36bec481270a8debe5b578b6502d3267bc01375450ec07990c33398e
|
|
7
|
+
data.tar.gz: '088f91735a153cdf3c6f9c922dbc775641743d375b11733f98438403d50ff0afbfa064e136486a416ccbcad97dc7e5e9887b134a229eb3caa860112f8f4151bc'
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.2.2] - 2026-10-26
|
|
6
|
+
|
|
7
|
+
One fix that an app on the fleet found with the PostGIS adapter. There is no new migration.
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Bouncy knows a database by its Active Record adapter class, so an adapter that subclasses a Rails adapter gets the locks of that database whatever name it reports, because the PostGIS adapter (`activerecord-postgis-adapter`) is a `PostgreSQLAdapter` whose `adapter_name` is `"PostGIS"`, and Bouncy matched the name `"PostgreSQL"` exactly: it raised "Bouncy supports PostgreSQL, MySQL and SQLite", the receiver answered 503 to every signed SNS message, and `Bouncy.hold`, `Bouncy.sync!` and every other write raised. The name is still the fallback for an adapter that only renames itself. One helper, `Bouncy::Database`, now makes this choice for every lock and read.
|
|
12
|
+
- The install migration gives a PostGIS database JSONB columns and native UUID keys, as it gives PostgreSQL, because it looked for `"postgres"` in the adapter name and made JSON columns and 36-character string keys there. Tables made that way keep working and need no change.
|
|
13
|
+
|
|
14
|
+
## [0.2.1] - 2026-10-25
|
|
15
|
+
|
|
16
|
+
Three gaps that the first app to move its own suppression table to Bouncy found. There is no new migration.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `Bouncy.import!(email, reason:, at:, note: nil, actor: nil)` records a block that another system recorded, with its reason (`:hard_bounce`, `:complaint` or `:manual`) and its time, as one `imported` event; it calls no hook, writes nothing when it repeats, never weakens a block in force and changes nothing after a later release, because an app that carried its old suppressions had to call the internal `Bouncy::Ingestor`, which treats each call as new webhook feedback. `Bouncy::Ingestor` keeps its 0.2.0 call shape and is now marked internal.
|
|
21
|
+
- `Status#blocked_at` is the time the block in force began (for an import, its `at:` time) and `nil` when the address is not blocked, because an app that shows "not deliverable since" had to read the suppression row itself; a block that follows an expired soft hold now starts at its own time, not at the soft hold's.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- The 90-day freshness rule (`config.maximum_event_age`) applies to webhook feedback only, not to imports or manual holds, because it exists to stop a late notification from blocking, while a block that another system kept for a year is still a block: an import dated a year ago drops mail, and a webhook bounce dated a year ago still does not.
|
|
26
|
+
|
|
5
27
|
## [0.2.0] - 2026-10-25
|
|
6
28
|
|
|
7
29
|
Eight features that apps need to replace a hand-rolled SES integration with Bouncy. There is no new migration: the address lock uses database locks, not a column.
|
data/README.md
CHANGED
|
@@ -41,7 +41,7 @@ Check out my other 💎 Ruby gems: [`nondisposable`](https://github.com/rameerez
|
|
|
41
41
|
## Requirements
|
|
42
42
|
|
|
43
43
|
- Ruby **3.3+** and Rails **7.2.3.2+**, below Rails 9. Tested on Ruby 3.3/3.4/4.0 and Rails 7.2/8.0/8.1.
|
|
44
|
-
- **PostgreSQL, MySQL or SQLite**, with bigint or UUID primary keys.
|
|
44
|
+
- **PostgreSQL** (including the PostGIS adapter), **MySQL** or **SQLite**, with bigint or UUID primary keys.
|
|
45
45
|
- **Amazon SES**, one account and region. SES SMTP is supported; suppression management needs AWS API credentials.
|
|
46
46
|
|
|
47
47
|
Start in observation mode and verify your sending setup before enabling dropping. See [compatibility and validation](guides/compatibility.md) for the tested matrix and remaining validation limits.
|
|
@@ -131,6 +131,7 @@ status.knowledge # :observed, :no_known_block, :unavailable, :unconfigure
|
|
|
131
131
|
status.provider_listed? # Exact provider identifiers are present locally
|
|
132
132
|
status.policy_reason # Why a listed address's sending policy is unverified
|
|
133
133
|
status.stale? # Address observation is missing or older than stale_after
|
|
134
|
+
status.blocked_at # When the block in force began, or nil when not blocked
|
|
134
135
|
|
|
135
136
|
Bouncy.blocked
|
|
136
137
|
Bouncy.events.for("ada@example.com").recent.limit(20)
|
|
@@ -196,6 +197,16 @@ Bouncy.release!(address, note: "Verified recovery", actor: "support:42")
|
|
|
196
197
|
|
|
197
198
|
Default release removes exact provider variants and verifies absence before clearing local state. Errors leave evidence available for review. Releasing an SES restriction can affect sister apps in that account and region; authorize the action in your app. Release does not resend mail or restore consent after a complaint. See [recovery and partial failures](guides/recovery.md).
|
|
198
199
|
|
|
200
|
+
### Carry blocks from another system
|
|
201
|
+
|
|
202
|
+
An app that moves its own suppression table to Bouncy carries each block with its reason and the time the old table recorded it:
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
Bouncy.import!(address, reason: :hard_bounce, at: old_row.suppressed_at, note: "Carried from the old table", actor: "carry-task")
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`reason` is `:hard_bounce`, `:complaint` or `:manual`. A bounce or a complaint is the same evidence as a webhook block, and a manual block is a manual hold. `at` can be of any age: the 90-day window for late webhook feedback does not apply. The import calls no hook, writes nothing when it repeats, never weakens a block in force, and changes nothing when the address was released after `at`, so a carry task is safe to run again. See [migrating](guides/migrating.md).
|
|
209
|
+
|
|
199
210
|
Use any admin UI. Bouncy has **no Madmin dependency, generator or adapter**. The [admin recipe](guides/admin.md) covers host-owned recovery actions, badges and notifications.
|
|
200
211
|
|
|
201
212
|
### Handle repeated mailbox-full bounces
|
data/guides/api.md
CHANGED
|
@@ -20,6 +20,7 @@ Bouncy.blocked?("ada@example.com") # Same as status.blocked?
|
|
|
20
20
|
| `record` | Underlying `Bouncy::Suppression`, or `nil`, for host-owned admin links. |
|
|
21
21
|
| `provider_listed?` | Exact provider entries exist in the local mirror, even if policy verification has not made them enforceable. |
|
|
22
22
|
| `provider_updated_at` | Latest update time among those provider entries, or `nil`. |
|
|
23
|
+
| `blocked_at` | Time the block in force began: when the address became blocked locally, or the `at:` time of the `Bouncy.import!` that blocked it. Stays while further evidence arrives; a block after an expired soft hold starts anew. `nil` when not blocked. |
|
|
23
24
|
| `observed_at` | Provider check time if present, otherwise the last address event time, or `nil`. |
|
|
24
25
|
| `stale?` | True for unavailable/unconfigured knowledge, missing observation time or an observation older than `stale_after`. This is address freshness; use `Bouncy.sync_fresh?` for mirror health. |
|
|
25
26
|
| `policy_unverified?` | A locally listed address whose latest scoped sync did not report verified sending policy. |
|
|
@@ -81,6 +82,7 @@ The override applies to status and predicates as well as scopes. Your app must p
|
|
|
81
82
|
| `Bouncy.blocked` | Scoped ActiveRecord relation of currently restricted suppression rows. |
|
|
82
83
|
| `Bouncy.events` | Scoped event relation; chain `.for(address).recent.limit(50)`. |
|
|
83
84
|
| `Bouncy.block!(address, note:, actor: nil)` | Add a local manual hold; a nonblank note is required. Returns the suppression row. Does not call SES. |
|
|
85
|
+
| `Bouncy.import!(address, reason:, at:, note: nil, actor: nil)` | Record a block that another system recorded at `at`, with `reason` `:hard_bounce`, `:complaint` or `:manual`. Returns the suppression row. Does not call SES. See [imports](#imports). |
|
|
84
86
|
| `Bouncy.release!(address, note: nil, actor: nil, at: :provider)` | Provider-aware release by default, requiring a note and verified provider snapshot. Returns the row; may raise on partial remote success. |
|
|
85
87
|
| `Bouncy.release!(address, at: :local)` | Clear manual and soft holds and their soft-bounce history; fence delayed pre-release soft feedback and preserve provider/webhook blocks. No provider request. A note is optional. |
|
|
86
88
|
| `Bouncy.forget!(address)` | Delete local state and events, including release metadata; no provider mutation. A later sync can reimport the address. |
|
|
@@ -94,6 +96,23 @@ The override applies to status and predicates as well as scopes. Your app must p
|
|
|
94
96
|
| `Bouncy.unblocked { ... }` | Temporarily bypass interception for synchronous delivery in this execution context. Does not travel with queued jobs. |
|
|
95
97
|
| `Bouncy.hold(address) { ... }` | Hold the address while the block runs, and inside a transaction until it ends, so no Bouncy write to it commits between the app's own check and its send. Returns the block's value. See [a sender that checks and sends](delivery.md#a-sender-that-checks-and-sends-in-one-step). |
|
|
96
98
|
|
|
99
|
+
## Imports
|
|
100
|
+
|
|
101
|
+
`Bouncy.import!` carries a block from another system, such as the suppression table an app kept before Bouncy. `Bouncy.block!` stays a support action: it holds the address now, needs a note and calls `after_block`. An import records what another system already knew:
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
Bouncy.import!("ada@example.com", reason: :complaint, at: Time.utc(2025, 3, 1), note: "Carried from the old table", actor: "carry-task")
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
- A `:hard_bounce` or `:complaint` becomes the same evidence as a webhook block. It drops mail under the same rule (a fresh sync, or `require_fresh_sync = false`), and a sync clears it under the same [absence rule](amazon-ses.md#failure-and-ordering) as webhook evidence. A `:manual` block becomes a manual hold, which applies whatever the sync state and only a release clears.
|
|
108
|
+
- `at` is the time the other system recorded the block, a `Time` or `DateTime` of any age: `maximum_event_age` applies to webhook feedback only. A time more than five minutes ahead raises `ArgumentError`. It becomes `Status#blocked_at` unless the address was blocked earlier.
|
|
109
|
+
- It writes one `imported` event (source `import`, occurred at `at`) and calls no hook: not `after_event`, `after_block` or `on_notification`. It is not new evidence.
|
|
110
|
+
- The same address, reason and time again writes nothing. So does an import that the address already holds.
|
|
111
|
+
- It never weakens a block in force. A complaint outranks a hard bounce, an earlier hold keeps its time and note, and provider evidence and soft holds stay as they are.
|
|
112
|
+
- A release at or after `at` fences it: the import records an `imported` event with `ignored_for_policy` and changes nothing. A newer block applies.
|
|
113
|
+
|
|
114
|
+
`Bouncy::Ingestor` and `Bouncy::Observation` are internal: they record webhook feedback. Their 0.2.0 call shape still works in 0.2.x, but carry blocks with `Bouncy.import!`.
|
|
115
|
+
|
|
97
116
|
## SES notifications
|
|
98
117
|
|
|
99
118
|
`config.on_notification` receives a `Bouncy::Notification` for every SES notification on an authorized topic, Send and Delivery included:
|
|
@@ -126,4 +145,4 @@ All gem errors inherit from `Bouncy::Error`:
|
|
|
126
145
|
| `AuthenticationError` | SNS authorization/signature failure. |
|
|
127
146
|
| `MalformedMessage` | Invalid SNS input. |
|
|
128
147
|
|
|
129
|
-
Missing support notes, unsupported release destinations and `Bouncy.hold` without a block raise `ArgumentError`. An address lock that waits too long raises `ActiveRecord::LockWaitTimeout`. Rails/database errors not recognized as connection outages are not silently swallowed. A successful local database transaction does not make remote provider operations atomic. Never display a recovery success message from a rescue branch.
|
|
148
|
+
Missing support notes, unsupported release destinations, an import reason or time that is not supported, and `Bouncy.hold` without a block raise `ArgumentError`. An address lock that waits too long raises `ActiveRecord::LockWaitTimeout`. Rails/database errors not recognized as connection outages are not silently swallowed. A successful local database transaction does not make remote provider operations atomic. Never display a recovery success message from a rescue branch.
|
data/guides/compatibility.md
CHANGED
|
@@ -6,6 +6,12 @@ The v0.1.0 release run on September 9, 2026 passed all 54 combinations using Rub
|
|
|
6
6
|
|
|
7
7
|
The v0.2.0 local run on October 25, 2026 passed all 54 combinations with the same Ruby, Rails, PostgreSQL and MySQL versions and SQLite 3.54.1, and the same 18 again with Rails 8.0.2, the oldest 8.0 release the transactional-fixture support was checked on: 201 tests on SQLite (one skipped there: a second pool cannot share an in-memory database), 220 on PostgreSQL and 219 on MySQL with Rails 8.1, one fewer with Rails 7.2 and 8.0, and at least 96.5% line and 91.5% branch coverage. It adds the address-lock tests with real connections on PostgreSQL and MySQL. No live SES run was part of it.
|
|
8
8
|
|
|
9
|
+
The v0.2.2 local run on October 26, 2026 used Ruby 3.3.5 and Rails 8.1 only: 236 tests on SQLite (one skipped, as before), 256 on PostgreSQL and 255 on MySQL, all passing. It adds tests for adapters that subclass a Rails adapter under another name, with the suite's own connection renamed `"PostGIS"` on PostgreSQL. No live SES run and no run on the PostGIS adapter itself was part of it.
|
|
10
|
+
|
|
11
|
+
## Database adapters
|
|
12
|
+
|
|
13
|
+
Bouncy knows a database by its Active Record adapter class. An adapter that subclasses `PostgreSQLAdapter`, `AbstractMysqlAdapter` or `SQLite3Adapter` belongs to that database whatever `adapter_name` it reports. The PostGIS adapter (`activerecord-postgis-adapter`) reports `"PostGIS"` and gets everything PostgreSQL gets: advisory locks, the isolation check, JSONB and native UUIDs. An adapter that is no such subclass is known by its name: one that contains `postg`, `mysql`, `trilogy` or `sqlite`. Any other adapter raises `Bouncy::ConfigurationError` at its first lock. The suite runs MySQL through `mysql2` only; the Trilogy adapter shares `AbstractMysqlAdapter` with it and is recognised by its class and its name, but no suite run uses it.
|
|
14
|
+
|
|
9
15
|
The runtime JSON gem is constrained below version 3 because the supported Rails releases still pass a positional options hash to `JSON.parse`. Optional AWS SDKs load lazily and are host dependencies. There is no database or AWS lookup during boot.
|
|
10
16
|
|
|
11
17
|
## Migrations
|
|
@@ -25,11 +31,11 @@ Without a setting, Rails chooses its standard primary key. Explicit bigint/integ
|
|
|
25
31
|
|
|
26
32
|
| Database | JSON storage | JSON defaults | UUID storage |
|
|
27
33
|
|---|---|---|---|
|
|
28
|
-
| PostgreSQL | JSONB | Database and model defaults | Native UUID |
|
|
34
|
+
| PostgreSQL, PostGIS included | JSONB | Database and model defaults | Native UUID |
|
|
29
35
|
| MySQL 8.4 | JSON | Model defaults; no literal SQL JSON default | 36-character string |
|
|
30
36
|
| SQLite | JSON | Database and model defaults | 36-character string |
|
|
31
37
|
|
|
32
|
-
The gem's JSON attributes inherit the database's native type. Every new record gets an independent hash/array default. No adapter probe or attribute-schema query is performed at boot. Unsupported database adapters raise before reconciliation.
|
|
38
|
+
The gem's JSON attributes inherit the database's native type. Every new record gets an independent hash/array default. No adapter probe or attribute-schema query is performed at boot. Unsupported database adapters raise before reconciliation. Before 0.2.2 the migration made JSON columns and 36-character string keys on PostGIS; those tables keep working.
|
|
33
39
|
|
|
34
40
|
MySQL identity columns explicitly use `utf8mb4_bin`. Case/accent-insensitive equality must not collapse addresses beyond Bouncy's trimmed lowercase policy. Scope and address limits keep compound indexes within supported database key limits. Use an application-normalized host column for model scopes.
|
|
35
41
|
|
data/guides/configuration.md
CHANGED
|
@@ -28,7 +28,7 @@ Replace the examples with your actual deployment. Set `all_sending_paths_listed`
|
|
|
28
28
|
| `webhook_blocking` | `:cautious` with `require_fresh_sync`, `:strict` without | Which notifications block. `:cautious` blocks General/NoEmail bounces and complaints naming one recipient; the sync covers the rest. `:strict` blocks every Permanent bounce and every recipient a complaint names. See [bounce handling](bounce-handling.md#ses-event-classification). |
|
|
29
29
|
| `record_deliveries` | `false` | Record normalized per-recipient SES Delivery events when enabled and routed to the topic. Server acceptance is not inbox placement. |
|
|
30
30
|
| `retention` | `90.days` | Delete events older than this by local creation time when the prune job runs. Does not delete state rows. |
|
|
31
|
-
| `maximum_event_age` | `90.days` |
|
|
31
|
+
| `maximum_event_age` | `90.days` | Webhook feedback older than this cannot apply a new restriction. `Bouncy.import!` and `Bouncy.block!` do not use it. Retention must be at least this long; both must be positive. |
|
|
32
32
|
| `soft_bounce_threshold` | `nil` | Record-only by default. An integer from 1 to 50 enables escalation for repeated `MailboxFull` bounces. |
|
|
33
33
|
| `soft_bounce_window` | `30.days` | Positive rolling window of eligible occurrence times. |
|
|
34
34
|
| `soft_bounce_block_for` | `30.days` | Positive duration of the temporary local hold triggered by escalation. |
|
data/guides/delivery.md
CHANGED
|
@@ -32,7 +32,7 @@ Order.transaction do
|
|
|
32
32
|
end
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
`Bouncy.hold` takes a shared lock for the address. Every Bouncy write to that address (webhook, sync, `block!`, `release!`) takes it exclusively first, before any row lock, until its own transaction ends. A new block therefore commits before the check or after the send. Inside a transaction the hold lasts until that transaction ends, so the outcome commits before the block. Take your own row locks before the hold, as above.
|
|
35
|
+
`Bouncy.hold` takes a shared lock for the address. Every Bouncy write to that address (webhook, sync, `block!`, `import!`, `release!`) takes it exclusively first, before any row lock, until its own transaction ends. A new block therefore commits before the check or after the send. Inside a transaction the hold lasts until that transaction ends, so the outcome commits before the block. Take your own row locks before the hold, as above.
|
|
36
36
|
|
|
37
37
|
| Database | Lock | Two senders to one address |
|
|
38
38
|
|---|---|---|
|
data/guides/migrating.md
CHANGED
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
Migration and cutover belong in the implementing app. Bouncy's install migration creates its own two tables; it does not inspect application models or silently replace an existing webhook/interceptor.
|
|
4
4
|
|
|
5
5
|
1. Inventory every existing writer: webhook controllers, jobs, support actions, interceptors and console operations. Record the source account, region, reason mapping, timestamps and existing soft-hold policy.
|
|
6
|
-
2. Install the additive schema.
|
|
7
|
-
3. Preserve manual holds independently. Map complaints as complaints and hard bounces as hard bounces. Account-list refusal is evidence of an existing restriction. Soft trackers are history; active legacy soft holds require an explicit preserved policy, not an accidental new threshold engine. Preserve original expiries for timed soft holds. Preserve indefinite holds as explicit manual holds with a migration note; do not invent a 30-day expiry. New escalation applies only to mailbox-specific evidence. Document that change if the old rule included content failures.
|
|
8
|
-
4.
|
|
9
|
-
5.
|
|
10
|
-
6.
|
|
11
|
-
7.
|
|
6
|
+
2. Install the additive schema. Never call live Bouncy/host models, callbacks, jobs or AWS from a historical migration. Carry the blocks from a task instead (step 3); a migration that reads the old table uses migration-local ActiveRecord classes or explicit SQL pinned to its schema.
|
|
7
|
+
3. Preserve manual holds independently. Map complaints as complaints and hard bounces as hard bounces. Carry each block with `Bouncy.import!(address, reason:, at:, note:, actor:)`, with `reason` `:hard_bounce`, `:complaint` or `:manual` and `at` the time the old table recorded it. Do not call `Bouncy::Ingestor`: it records webhook feedback, and feedback older than `maximum_event_age` blocks nothing. An import applies at any age and calls no hook. Run it from a task that you can run again, not from a historical migration: a repeat writes nothing, and an address released after `at` stays released. Account-list refusal is evidence of an existing restriction. Soft trackers are history; active legacy soft holds require an explicit preserved policy, not an accidental new threshold engine. Preserve original expiries for timed soft holds. Preserve indefinite holds as explicit manual holds with a migration note; do not invent a 30-day expiry. New escalation applies only to mailbox-specific evidence. Document that change if the old rule included content failures.
|
|
8
|
+
4. `Bouncy.import!` identifies a block by four values: the normalized address (trimmed and lowercased), the configured scope, the reason, and `at` truncated to microseconds. A call that repeats all four writes nothing, so the old table needs no source ID of its own: read `at` from it unchanged, and a second run of the task writes nothing. A different reason or time is a different block, and the same values in another scope are an independent block. An import takes no provider feedback ID and counts no occurrences: carry the blocks in force, not each historical event. Pass the address as the old table spells it; for a bounce or a complaint, Bouncy keeps that spelling for the sync's provider lookups. Missing historical case variants are resolved by complete provider enumeration before recovery.
|
|
9
|
+
5. Historical webhook feedback is a separate matter, and an import makes a replay unnecessary: it carries what that feedback decided. Bouncy has no public call to replay old notifications. Do not feed them to `Bouncy::Ingestor`: it is internal, and it records feedback older than `maximum_event_age` without blocking. Feedback that arrives during the switch reaches the receiver, which deduplicates each recipient by the provider's feedback ID, so SNS retries count once.
|
|
10
|
+
6. Rehearse the import against isolated synthetic fixtures. Compare every predicate, support action and customer-facing status that depended on the old table. Rehearse bootstrap as part of this isolated cutover sequence.
|
|
11
|
+
7. Quiesce old writers for cutover, or implement and test dual-write delivery with deterministic identity. Perform the final import, switch the receiver/readers, then run `bouncy:bootstrap` before starting mail workers. It raises unless the mirror is fresh, complete and policy-verified. Ensure feedback received during the switch is retried or durably captured. Do not replace readers while another process continues writing exclusively to the old table.
|
|
12
|
+
8. Rehearse rollback, including restrictions and releases created after cutover. Keeping an old table is not a rollback plan if it missed later changes. Keep old schema until the agreed window ends. Do not delete imported rows in a schema rollback: they may now contain newer state. Fail conflicting imports and stop writers before copying in either direction.
|
|
12
13
|
|
|
13
14
|
Use the app's existing public webhook URL where possible. Test routing alongside unrelated webhooks, and preserve the existing interception mode deliberately. Host admin resources call Bouncy's recovery API; stock row deletion cannot stand in for provider release.
|
|
14
15
|
|
data/guides/operations.md
CHANGED
|
@@ -78,7 +78,7 @@ Bouncy.configure do |config|
|
|
|
78
78
|
end
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
-
Implement the example jobs in the host. Callbacks receive a committed `Bouncy::Event`. `after_event` runs for every newly created event, including sync summaries and interception observations. `after_block` runs when `details["became_blocked"]` is true, not for every repeated bounce. `after_release` runs for explicit `release` events, including local-only releases; it does not run for `sync_released`. Use `after_event` with a kind check if you need to observe reconciliation releases.
|
|
81
|
+
Implement the example jobs in the host. Callbacks receive a committed `Bouncy::Event`. `after_event` runs for every newly created event, including sync summaries and interception observations, except `imported` events: `Bouncy.import!` calls no hook. `after_block` runs when `details["became_blocked"]` is true, not for every repeated bounce. `after_release` runs for explicit `release` events, including local-only releases; it does not run for `sync_released`. Use `after_event` with a kind check if you need to observe reconciliation releases.
|
|
82
82
|
|
|
83
83
|
These hooks are best effort. Exceptions emit `hook_error.bouncy` with `hook` and `error_class`, and do not undo the event. They run after the commit of the outermost transaction, after Bouncy released every address lock of it, and, for a write inside `Bouncy.hold`, when the hold ends. A rollback drops them. Rails transactional tests keep 0.1's timing. Bouncy marks the fixture transaction of each pool that `ActiveRecord::TestFixtures` enrolls, as listed in its own `@fixture_connection_pools`: at `setup_transactional_fixtures`, and for a pool established later from a `!connection.active_record` subscriber that runs after Rails' own. It clears the marks in `teardown_transactional_fixtures` (Rails 7.2, 8.0 and 8.1). While a marked transaction is the only one open, hooks run when the app's own transaction commits. A database excluded with `skip_transactional_tests_for_database` (Rails 8.1), or a connection pinned in any other way, keeps the outer-commit rule. A process can stop after commit before notification completes. Use idempotent consumers and a host outbox/reconciliation mechanism for guaranteed external work. Jobs should tolerate an event being pruned before execution.
|
|
84
84
|
|
data/guides/privacy.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Bouncy stores the normalized address, exact provider identifiers, reasons, event IDs/times, bounded provider diagnostics, support notes and actor references. Those fields can contain personal information. Raw payloads, subjects, bodies and attachments are not stored by this release.
|
|
4
4
|
|
|
5
|
-
Default event retention is 90 days, measured from local event creation (`created_at`), not provider occurrence time. Schedule `Bouncy::PruneJob` daily; no pruning happens merely by configuring retention. Retention must cover `maximum_event_age`.
|
|
5
|
+
Default event retention is 90 days, measured from local event creation (`created_at`), not provider occurrence time. Schedule `Bouncy::PruneJob` daily; no pruning happens merely by configuring retention. Retention must cover `maximum_event_age`. Webhook feedback older than that acceptance window can be recorded for diagnostics without applying a new restriction, then ages out under normal pruning. A block carried with `Bouncy.import!` is not feedback and applies at any age. Current state and release ordering metadata survive event pruning.
|
|
6
6
|
|
|
7
7
|
`Bouncy.forget!(address)` erases local state and events in the configured scope. It does not change the provider list. A subsequent sync or eligible notification can create state again, and erasure also removes local release fences. It is not a complete legal/compliance workflow or a provider recovery action.
|
|
8
8
|
|
data/guides/recovery.md
CHANGED
|
@@ -10,7 +10,7 @@ Bouncy obtains a complete supported-scope snapshot, finds the exact provider var
|
|
|
10
10
|
|
|
11
11
|
A partial provider operation raises `Bouncy::ReleaseFailed`. Inspect `error.outcomes` or the `release_failed` event: some variants may have been removed while another failed. Local provider evidence remains blocked until a successful recovery/reconciliation. Remote calls cannot be rolled back with your database transaction.
|
|
12
12
|
|
|
13
|
-
Inactive rows retain a release-time fence. Older feedback
|
|
13
|
+
Inactive rows retain a release-time fence. Older feedback, stale snapshots and imports dated before the release cannot simply reapply the released evidence. A genuinely newer failure can block the address again. Provider changes after a completed observation remain possible; this is not a permanent deliverability guarantee.
|
|
14
14
|
|
|
15
15
|
## Local holds
|
|
16
16
|
|
data/lib/bouncy/address_lock.rb
CHANGED
|
@@ -170,7 +170,7 @@ module Bouncy
|
|
|
170
170
|
# stays invisible. Hold the address outside the transaction instead; the lock then lasts for
|
|
171
171
|
# the whole block, and the transaction's snapshot starts after it.
|
|
172
172
|
def require_read_committed(connection)
|
|
173
|
-
return unless connection
|
|
173
|
+
return unless Database.family(connection) == :postgresql && connection.transaction_open?
|
|
174
174
|
|
|
175
175
|
isolation = connection.uncached { connection.select_value("SHOW transaction_isolation") }
|
|
176
176
|
return if ["read committed", "read uncommitted"].include?(isolation)
|
|
@@ -181,11 +181,10 @@ module Bouncy
|
|
|
181
181
|
|
|
182
182
|
# Takes the lock now and returns what releases it, or nil when the database releases it.
|
|
183
183
|
def take(connection, key, exclusive:)
|
|
184
|
-
case connection
|
|
185
|
-
when
|
|
186
|
-
when
|
|
187
|
-
when
|
|
188
|
-
else raise ConfigurationError, "Bouncy supports PostgreSQL, MySQL and SQLite"
|
|
184
|
+
case Database.family!(connection)
|
|
185
|
+
when :postgresql then take_advisory(connection, key, exclusive)
|
|
186
|
+
when :mysql then take_named(connection, key)
|
|
187
|
+
when :sqlite then take_file(connection, exclusive ? File::LOCK_EX : File::LOCK_SH)
|
|
189
188
|
end
|
|
190
189
|
end
|
|
191
190
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bouncy
|
|
4
|
+
# Names the database family of a connection, which decides the lock Bouncy takes. A subclass
|
|
5
|
+
# of a Rails adapter belongs to the family of its parent class: the PostGIS adapter is a
|
|
6
|
+
# PostgreSQLAdapter whose adapter_name is "PostGIS", and the Trilogy and Mysql2 adapters share
|
|
7
|
+
# AbstractMysqlAdapter. The adapter_name is the fallback for an adapter that only renames
|
|
8
|
+
# itself. Each class is looked up only once its adapter is loaded, so no driver gem loads.
|
|
9
|
+
module Database
|
|
10
|
+
FAMILIES = {
|
|
11
|
+
postgresql: ["PostgreSQLAdapter", /postg/i],
|
|
12
|
+
mysql: ["AbstractMysqlAdapter", /mysql|trilogy/i],
|
|
13
|
+
sqlite: ["SQLite3Adapter", /sqlite/i]
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# :postgresql, :mysql, :sqlite, or nil for any other database.
|
|
19
|
+
def family(connection)
|
|
20
|
+
FAMILIES.find { |_, (adapter, _)| subclass?(connection, adapter) }&.first ||
|
|
21
|
+
FAMILIES.find { |_, (_, name)| connection.adapter_name.match?(name) }&.first
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def family!(connection)
|
|
25
|
+
family(connection) || raise(ConfigurationError, "Bouncy supports PostgreSQL, MySQL and SQLite")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def subclass?(connection, adapter)
|
|
29
|
+
ActiveRecord::ConnectionAdapters.const_defined?(adapter, false) &&
|
|
30
|
+
connection.is_a?(ActiveRecord::ConnectionAdapters.const_get(adapter, false))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/bouncy/event.rb
CHANGED
|
@@ -9,7 +9,8 @@ module Bouncy
|
|
|
9
9
|
scope :for, ->(email) { where(email: Identity.normalize(email)) }
|
|
10
10
|
scope :recent, -> { order(occurred_at: :desc, id: :desc) }
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
# An import is not new evidence, so it calls no hook.
|
|
13
|
+
after_create_commit :notify_host, unless: -> { kind == "imported" }
|
|
13
14
|
|
|
14
15
|
private
|
|
15
16
|
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bouncy
|
|
4
|
+
# Records a block that another system recorded first, for Bouncy.import!.
|
|
5
|
+
#
|
|
6
|
+
# A bounce or a complaint becomes the same evidence that a webhook records, and a manual block
|
|
7
|
+
# becomes a manual hold. The time is the time the other system recorded the block. It is kept
|
|
8
|
+
# as given, however old: config.maximum_event_age is for webhook feedback that arrives late,
|
|
9
|
+
# and an old block that another system kept still stops mail.
|
|
10
|
+
#
|
|
11
|
+
# An import is not new evidence, so it calls no hook. It is idempotent: the same address,
|
|
12
|
+
# reason and time a second time writes nothing. It never removes or weakens a block that is
|
|
13
|
+
# in force, and a release recorded at or after its time fences it.
|
|
14
|
+
class Importer
|
|
15
|
+
REASONS = %w[hard_bounce complaint manual].freeze
|
|
16
|
+
FUTURE_SKEW = 5.minutes
|
|
17
|
+
# The attributes that an import can change. A difference in them is a change to record.
|
|
18
|
+
CHANGES = %w[manual_blocked_at manual_note event_blocked_at event_reason last_event_at blocked_at details].freeze
|
|
19
|
+
|
|
20
|
+
def call(email, reason:, at:, note:, actor:)
|
|
21
|
+
reason = reason.to_s
|
|
22
|
+
raise ArgumentError, "reason must be :hard_bounce, :complaint or :manual" unless REASONS.include?(reason)
|
|
23
|
+
|
|
24
|
+
time = time_of(at)
|
|
25
|
+
exact = email.to_s.strip
|
|
26
|
+
key = Identity.normalize(email)
|
|
27
|
+
dedupe_key = Digest::SHA256.hexdigest(JSON.generate(["import", Bouncy.scope, key, reason, time.iso8601(6)]))
|
|
28
|
+
Store.change(key) do |row|
|
|
29
|
+
next row if Bouncy.events.exists?(dedupe_key: dedupe_key)
|
|
30
|
+
|
|
31
|
+
record(row, reason, time, dedupe_key, exact: exact, note: note.to_s.strip.truncate(1000).presence,
|
|
32
|
+
actor: actor.to_s.truncate(200))
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def record(row, reason, time, dedupe_key, exact:, note:, actor:)
|
|
39
|
+
was_blocked = row.blocked?
|
|
40
|
+
released_at = fence(row, reason)
|
|
41
|
+
fenced = !released_at.nil? && time <= released_at
|
|
42
|
+
unless fenced
|
|
43
|
+
before = row.attributes.slice(*CHANGES).deep_dup
|
|
44
|
+
reason == "manual" ? hold(row, time, note) : feedback(row, reason, time, exact)
|
|
45
|
+
row.blocked_at = [row.blocked_at, time].compact.min
|
|
46
|
+
return row if row.attributes.slice(*CHANGES) == before
|
|
47
|
+
|
|
48
|
+
row.summarize!
|
|
49
|
+
end
|
|
50
|
+
Store.event!("imported", email: row.email, source: "import", occurred_at: time, dedupe_key: dedupe_key, details: {
|
|
51
|
+
"reason" => reason, "note" => note, "actor" => actor,
|
|
52
|
+
"ignored_for_policy" => fenced, "became_blocked" => !was_blocked && row.blocked?
|
|
53
|
+
})
|
|
54
|
+
row
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A release clears manual holds and records "soft_released_before". A provider release, or a
|
|
58
|
+
# sync that no longer finds webhook evidence, clears it and records released_before. An
|
|
59
|
+
# imported block at or before that time is older than the release, so it changes nothing.
|
|
60
|
+
def fence(row, reason)
|
|
61
|
+
return row.released_before unless reason == "manual"
|
|
62
|
+
|
|
63
|
+
Time.iso8601(row.details["soft_released_before"].to_s)
|
|
64
|
+
rescue ArgumentError
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Keeps an earlier hold and its support note.
|
|
69
|
+
def hold(row, time, note)
|
|
70
|
+
return if row.manual_blocked_at && row.manual_blocked_at <= time
|
|
71
|
+
|
|
72
|
+
row.manual_note = note unless row.manual_blocked_at
|
|
73
|
+
row.manual_blocked_at = time
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Webhook evidence keeps the time of its newest block and the stronger reason: a complaint
|
|
77
|
+
# outranks a hard bounce, so an imported hard bounce never replaces a complaint.
|
|
78
|
+
def feedback(row, reason, time, exact)
|
|
79
|
+
previous = row.event_reason if row.event_blocked_at
|
|
80
|
+
return if previous == reason && row.event_blocked_at >= time
|
|
81
|
+
return if previous == "complaint" && row.event_blocked_at >= time
|
|
82
|
+
|
|
83
|
+
row.event_reason = previous == "complaint" ? "complaint" : reason
|
|
84
|
+
row.event_blocked_at = [row.event_blocked_at, time].compact.max
|
|
85
|
+
row.last_event_at = [row.last_event_at, time].compact.max
|
|
86
|
+
row.details = row.details.except("absence_count").reverse_merge("exact_email" => exact)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Microsecond precision, as every supported database stores it, so a second import of the
|
|
90
|
+
# same time compares and deduplicates as equal.
|
|
91
|
+
def time_of(value)
|
|
92
|
+
raise ArgumentError, "at must be a Time" unless value.is_a?(Time) || value.is_a?(DateTime)
|
|
93
|
+
|
|
94
|
+
time = value.to_time.utc.floor(6)
|
|
95
|
+
raise ArgumentError, "at must not be in the future" if time > FUTURE_SKEW.from_now
|
|
96
|
+
|
|
97
|
+
time
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
data/lib/bouncy/ingestor.rb
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Bouncy
|
|
4
|
+
# Internal: one webhook observation for Ingestor. See Ingestor.
|
|
4
5
|
Observation = Data.define(:email, :kind, :provider_event_id, :message_id, :occurred_at, :details, :provider_reason, :status_code, :diagnostic)
|
|
5
6
|
|
|
7
|
+
# Internal: the receiver's step for one webhook observation. It is not a public API. Use
|
|
8
|
+
# Bouncy.import! to record a block from another system. The call shape of Ingestor#call and
|
|
9
|
+
# Observation stays the same in 0.2.x, because apps on 0.2.0 call them to carry blocks.
|
|
10
|
+
#
|
|
11
|
+
# config.maximum_event_age applies here only. It keeps a late webhook notification from
|
|
12
|
+
# blocking. Bouncy.import! and Bouncy.block! do not apply it.
|
|
6
13
|
class Ingestor
|
|
7
14
|
def call(observation)
|
|
8
15
|
email = observation.email && Identity.normalize(observation.email)
|
data/lib/bouncy/scope_lock.rb
CHANGED
|
@@ -11,8 +11,8 @@ module Bouncy
|
|
|
11
11
|
def synchronize
|
|
12
12
|
Record.connection_pool.with_connection do |connection|
|
|
13
13
|
key = Digest::SHA256.hexdigest(Bouncy.scope)[0, 15].to_i(16)
|
|
14
|
-
case connection
|
|
15
|
-
when
|
|
14
|
+
case Database.family!(connection)
|
|
15
|
+
when :postgresql
|
|
16
16
|
# Uncached: a cached answer would report the lock without taking it.
|
|
17
17
|
acquired = connection.uncached { connection.select_value("SELECT pg_try_advisory_lock(#{key})") }
|
|
18
18
|
raise ProviderError, "Another sync holds this scope lock" unless [true, "t"].include?(acquired)
|
|
@@ -22,7 +22,7 @@ module Bouncy
|
|
|
22
22
|
ensure
|
|
23
23
|
connection.execute("SELECT pg_advisory_unlock(#{key})")
|
|
24
24
|
end
|
|
25
|
-
when
|
|
25
|
+
when :mysql
|
|
26
26
|
name = Digest::SHA256.hexdigest("bouncy:#{connection.pool.db_config.database}:#{Bouncy.scope}")
|
|
27
27
|
acquired = connection.uncached { connection.select_value("SELECT GET_LOCK('#{name}', 0)") }
|
|
28
28
|
raise ProviderError, "Another sync holds this scope lock" unless acquired == 1
|
|
@@ -32,7 +32,7 @@ module Bouncy
|
|
|
32
32
|
ensure
|
|
33
33
|
connection.execute("SELECT RELEASE_LOCK('#{name}')")
|
|
34
34
|
end
|
|
35
|
-
when
|
|
35
|
+
when :sqlite
|
|
36
36
|
database = connection.pool.db_config.database
|
|
37
37
|
path = database == ":memory:" ? File.join(Dir.tmpdir, "bouncy-#{Process.pid}-#{key}.lock") : "#{File.expand_path(database)}.bouncy-#{key}.lock"
|
|
38
38
|
File.open(path, File::RDWR | File::CREAT, 0o600) do |file|
|
|
@@ -44,8 +44,6 @@ module Bouncy
|
|
|
44
44
|
file.flock(File::LOCK_UN)
|
|
45
45
|
end
|
|
46
46
|
end
|
|
47
|
-
else
|
|
48
|
-
raise ConfigurationError, "Bouncy supports PostgreSQL, MySQL and SQLite"
|
|
49
47
|
end
|
|
50
48
|
end
|
|
51
49
|
end
|
data/lib/bouncy/status.rb
CHANGED
|
@@ -29,6 +29,9 @@ module Bouncy
|
|
|
29
29
|
def blocked? = @row ? @row.blocked? : false
|
|
30
30
|
def reasons = @row ? @row.reasons : []
|
|
31
31
|
def reason = (%i[complaint hard_bounce provider_list manual soft_bounces] & reasons).first
|
|
32
|
+
# The time the block in force began: when the address became blocked here, or the at: time
|
|
33
|
+
# of the Bouncy.import! that blocked it. nil when the address is not blocked.
|
|
34
|
+
def blocked_at = blocked? ? @row.blocked_at : nil
|
|
32
35
|
def observed_at = @row&.provider_checked_at || @row&.last_event_at
|
|
33
36
|
def provider_updated_at = @row&.provider_entries&.filter_map { |entry| Time.iso8601(entry.fetch("provider_updated_at")) }&.max
|
|
34
37
|
def last_event = @row && Bouncy.events.for(@row.email).recent.first
|
data/lib/bouncy/store.rb
CHANGED
|
@@ -17,7 +17,11 @@ module Bouncy
|
|
|
17
17
|
row = Suppression.find_or_create_by!(scope: Bouncy.scope, email: key) do |record|
|
|
18
18
|
record.provider = Bouncy.configuration.provider.to_s
|
|
19
19
|
end
|
|
20
|
-
row.with_lock
|
|
20
|
+
row.with_lock do
|
|
21
|
+
# A soft hold that ran out leaves its start in blocked_at. The next block starts anew.
|
|
22
|
+
row.blocked_at = nil unless row.blocked?
|
|
23
|
+
yield row
|
|
24
|
+
end
|
|
21
25
|
end
|
|
22
26
|
rescue ActiveRecord::RecordNotUnique, ActiveRecord::StaleObjectError, ActiveRecord::Deadlocked
|
|
23
27
|
attempts += 1
|
data/lib/bouncy/version.rb
CHANGED
data/lib/bouncy.rb
CHANGED
|
@@ -48,7 +48,7 @@ module Bouncy
|
|
|
48
48
|
# True when config.scope is set. An unconfigured Bouncy is inactive: it intercepts nothing,
|
|
49
49
|
# reports no restrictions and returns empty relations, so a host can install the gem before
|
|
50
50
|
# its environment variables exist without breaking mail delivery. Explicit operations
|
|
51
|
-
# (sync!, block!, release!, forget!) still raise ConfigurationError.
|
|
51
|
+
# (sync!, block!, import!, release!, forget!) still raise ConfigurationError.
|
|
52
52
|
def configured? = configuration.configured?
|
|
53
53
|
|
|
54
54
|
def unconfigured!(operation)
|
|
@@ -132,6 +132,14 @@ module Bouncy
|
|
|
132
132
|
end
|
|
133
133
|
end
|
|
134
134
|
|
|
135
|
+
# Records a block that another system recorded at the time at, for an app that moves its own
|
|
136
|
+
# suppression table to Bouncy. reason is :hard_bounce, :complaint or :manual. A bounce or a
|
|
137
|
+
# complaint is the same evidence as a webhook block; a manual block is a manual hold. The
|
|
138
|
+
# time can be of any age. The import calls no hook, writes nothing when it repeats, never
|
|
139
|
+
# weakens a block in force, and changes nothing when a release came at or after its time.
|
|
140
|
+
# Returns the suppression row. Does not call the provider.
|
|
141
|
+
def import!(email, reason:, at:, note: nil, actor: nil) = Importer.new.call(email, reason: reason, at: at, note: note, actor: actor)
|
|
142
|
+
|
|
135
143
|
def forget!(email)
|
|
136
144
|
current_scope = scope
|
|
137
145
|
key = Identity.normalize(email)
|
|
@@ -165,7 +173,7 @@ module Bouncy
|
|
|
165
173
|
# committed state: a REPEATABLE READ snapshot from before the hold cannot hide a newer block
|
|
166
174
|
# or release. PostgreSQL at READ COMMITTED reads the latest state with every statement.
|
|
167
175
|
def current_state(relation)
|
|
168
|
-
return relation unless AddressLock.holding? && Record.connection_pool.with_connection
|
|
176
|
+
return relation unless AddressLock.holding? && Record.connection_pool.with_connection { |connection| Database.family(connection) } == :mysql
|
|
169
177
|
|
|
170
178
|
relation.lock("LOCK IN SHARE MODE")
|
|
171
179
|
end
|
|
@@ -193,6 +201,7 @@ end
|
|
|
193
201
|
|
|
194
202
|
require_relative "bouncy/configuration"
|
|
195
203
|
require_relative "bouncy/identity"
|
|
204
|
+
require_relative "bouncy/database"
|
|
196
205
|
require_relative "bouncy/record"
|
|
197
206
|
require_relative "bouncy/suppression"
|
|
198
207
|
require_relative "bouncy/event"
|
|
@@ -205,6 +214,7 @@ require_relative "bouncy/providers/ses"
|
|
|
205
214
|
require_relative "bouncy/providers/ses_parser"
|
|
206
215
|
require_relative "bouncy/notification"
|
|
207
216
|
require_relative "bouncy/ingestor"
|
|
217
|
+
require_relative "bouncy/importer"
|
|
208
218
|
require_relative "bouncy/recovery"
|
|
209
219
|
require_relative "bouncy/scope_lock"
|
|
210
220
|
require_relative "bouncy/reconciler"
|
|
@@ -87,11 +87,25 @@ class CreateBouncyTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
87
87
|
mysql? ? { collation: "utf8mb4_bin" } : {}
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
-
def postgresql?
|
|
91
|
-
|
|
90
|
+
def postgresql? = adapter_family == :postgresql
|
|
91
|
+
def mysql? = adapter_family == :mysql
|
|
92
|
+
|
|
93
|
+
# Mirrors Bouncy::Database.family. It is inlined because the migration must not depend on
|
|
94
|
+
# Bouncy's code when it runs. Every adapter class comes before any name: a subclass of a Rails
|
|
95
|
+
# adapter keeps the family of its parent class whatever it reports, as the PostGIS adapter
|
|
96
|
+
# reports "PostGIS". The name is the fallback for an adapter that only renames itself.
|
|
97
|
+
def adapter_family
|
|
98
|
+
families = {
|
|
99
|
+
postgresql: ["PostgreSQLAdapter", /postg/i],
|
|
100
|
+
mysql: ["AbstractMysqlAdapter", /mysql|trilogy/i],
|
|
101
|
+
sqlite: ["SQLite3Adapter", /sqlite/i]
|
|
102
|
+
}
|
|
103
|
+
families.find { |_, (adapter, _)| adapter_subclass?(adapter) }&.first ||
|
|
104
|
+
families.find { |_, (_, name)| connection.adapter_name.match?(name) }&.first
|
|
92
105
|
end
|
|
93
106
|
|
|
94
|
-
def
|
|
95
|
-
|
|
107
|
+
def adapter_subclass?(adapter)
|
|
108
|
+
ActiveRecord::ConnectionAdapters.const_defined?(adapter, false) &&
|
|
109
|
+
connection.is_a?(ActiveRecord::ConnectionAdapters.const_get(adapter, false))
|
|
96
110
|
end
|
|
97
111
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: bouncy
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-26 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -79,9 +79,11 @@ files:
|
|
|
79
79
|
- lib/bouncy/address_lock.rb
|
|
80
80
|
- lib/bouncy/body_limit.rb
|
|
81
81
|
- lib/bouncy/configuration.rb
|
|
82
|
+
- lib/bouncy/database.rb
|
|
82
83
|
- lib/bouncy/engine.rb
|
|
83
84
|
- lib/bouncy/event.rb
|
|
84
85
|
- lib/bouncy/identity.rb
|
|
86
|
+
- lib/bouncy/importer.rb
|
|
85
87
|
- lib/bouncy/ingestor.rb
|
|
86
88
|
- lib/bouncy/interceptor.rb
|
|
87
89
|
- lib/bouncy/model.rb
|