discord_store 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: beaeca859e4b101de87ff0fd0c779311606c36bf800f2ed766066bbe868f4847
4
+ data.tar.gz: 3b2dc1bead35992ed63ba051a32e7867b19bb0c6e50e3c02dd3977ab12fe5902
5
+ SHA512:
6
+ metadata.gz: 14516705bd0e8d8af2272b6383de32c9faa3497b3f3b3f9dc1468732c5fa84f34f77da5b9d50d81259354e804b93f0aa6fff38eea6e738c5a972d26eadb3132b
7
+ data.tar.gz: fe1d4aee25808ee3c4be6372e3e953c557f806812055a79eae16d57e2dad5624ae5637a80f0407479abce31e61c967061332fadb4dd0a2ff91e154a10b28c4c8
data/CHANGELOG.md ADDED
@@ -0,0 +1,60 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. This project follows
4
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+
6
+ ## [0.1.0] - 2026-09-09
7
+
8
+ First release.
9
+
10
+ ### Added
11
+
12
+ - **Transport** — a rate-limit-aware Discord REST client that learns bucket
13
+ topology from response headers, honours `X-RateLimit-Scope` on 429s, retries
14
+ with full jitter, and caches connections per fiber rather than per thread.
15
+ - **Quota** — a token-bucket semaphore over the bot's global request budget,
16
+ replacing the connection pool. The scarce resource over an HTTP API is
17
+ permission to ask, not sockets.
18
+ - **Log** — an encrypted append-only log across one or more channels. Records
19
+ are packed many per message, spilled to attachments when oversized, and
20
+ deduplicated on retry via the Discord message `nonce`. Channels are
21
+ partitions: total order within one, none across them.
22
+ - **Cipher** — AES-256-GCM with additional authenticated data binding each
23
+ payload to its channel, and a separate binary envelope for attachments that
24
+ avoids the 33% base64 penalty.
25
+ - **Snowflake** — treats Discord message IDs as the k-sortable, time-encoded
26
+ primary keys they are, including synthetic bounds for time-range pagination.
27
+ - **BlobStore** — chunked object storage that never persists a CDN URL and
28
+ re-resolves on every read, so it survives Discord's expiring signed links.
29
+ Range reads fetch only the chunks they touch.
30
+ - **`ActiveStorage::Service::DiscordService`** — with proxy mode as the
31
+ documented configuration. Passes Rails' own `SharedServiceTests` conformance
32
+ suite in full, including `compose` and 5 MB streaming slices.
33
+ - **`ActiveRecord::ConnectionAdapters::DiscordAdapter`** — a SQLite adapter that
34
+ mirrors every write into a Discord channel and can rebuild the database from
35
+ it. Journal modes `:sync`, `:async` and `:off`. Savepoints are refused while
36
+ journalling, because a sent message cannot be un-sent.
37
+ - **Replay** — idempotent, resumable reconstruction of a local database from the
38
+ log, with cursor tracking.
39
+ - **KV** — Kredis-shaped values. Scalars are last-write-wins documents; counters
40
+ and lists are append-only, so concurrent writers do not lose each other's work.
41
+ - **`Transport::Fake`** — an in-memory Discord that models monotonic snowflakes,
42
+ nonce deduplication, the 14-day bulk-delete window, rate limiting, foreign
43
+ authors, and CDN links that genuinely expire.
44
+ - **Rake tasks** — `discord:replay`, `discord:status`, `discord:compact`,
45
+ `discord:doctor`, `discord:probe`, `discord:key`.
46
+ - **Safety** — the library refuses to make a request without an explicit
47
+ Terms of Service acknowledgement, and reads only messages its own bot wrote.
48
+
49
+ ### Verified against
50
+
51
+ - Ruby 3.1, 3.2, 3.3 and 3.4.
52
+ - Rails 7.2, 8.0 and 8.1, via Appraisal. The floor is 7.2 because
53
+ `ActiveRecord::ConnectionAdapters.register` does not exist before it.
54
+ - Rails' vendored ActiveStorage conformance suite, 14 cases, nothing skipped.
55
+
56
+ Not verified against live Discord. The attachment ceiling, CDN `Range` support
57
+ and real throughput are discovered at runtime rather than hardcoded, and none of
58
+ them have been measured against a real guild.
59
+
60
+ [0.1.0]: https://github.com/chayuto/discord_store/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Chayut Orapinpatipat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,439 @@
1
+ # discord_store
2
+
3
+ [![CI](https://github.com/chayuto/discord_store/actions/workflows/main.yml/badge.svg)](https://github.com/chayuto/discord_store/actions/workflows/main.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/discord_store.svg)](https://rubygems.org/gems/discord_store)
5
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.1-CC342D.svg)](https://www.ruby-lang.org)
6
+ [![Rails](https://img.shields.io/badge/rails-7.2%20%7C%208.0%20%7C%208.1-D30001.svg)](https://rubyonrails.org)
7
+
8
+ Uses Discord as a database.
9
+
10
+ It works. The data goes in, the data comes back out, ActiveRecord can query it,
11
+ ActiveStorage can serve files from it, and a machine with an empty disk can
12
+ rebuild the whole database from a chat channel and a bot token.
13
+
14
+ You should almost certainly not use it.
15
+
16
+ ---
17
+
18
+ ## Read this part first
19
+
20
+ Storing application data in Discord messages and attachments is an explicit
21
+ violation of the [Discord Developer Terms of Service][tos] and the
22
+ [Discord API Developer Policy][policy]. Discord has acted against this class of
23
+ project before — in late 2023 it began signing CDN links with an expiring HMAC,
24
+ which broke essentially every Discord-backed filesystem in existence within
25
+ about twenty-four hours — and it can revoke your bot token, remove your
26
+ application, and action your account without warning.
27
+
28
+ This library will not make a network request until you say so in code:
29
+
30
+ ```ruby
31
+ DiscordStore.configure do |config|
32
+ config.i_understand_this_violates_discord_tos = true
33
+ end
34
+ ```
35
+
36
+ Do not put this in an application you did not build for yourself, and do not put
37
+ anybody else's data in it.
38
+
39
+ [tos]: https://support-dev.discord.com/hc/en-us/articles/8562894815383-Discord-Developer-Terms-of-Service
40
+ [policy]: https://support-dev.discord.com/hc/en-us/articles/8563934450327-Discord-Developer-Policy
41
+
42
+ ### What it will not do
43
+
44
+ discord_store only ever reads messages **its own bot wrote**. That check lives in
45
+ code, not in this README:
46
+
47
+ ```ruby
48
+ # lib/discord_store/transport/rest.rb
49
+ def own_message?(message)
50
+ message.dig("author", "id").to_s == config.application_id.to_s
51
+ end
52
+ ```
53
+
54
+ There is no message search, no member enumeration, no history export, and there
55
+ will not be. The distance between a storage backend and a scraper is exactly
56
+ whether it can read other people's messages, and this one cannot.
57
+
58
+ ---
59
+
60
+ ## The honest numbers
61
+
62
+ | | |
63
+ |---|---|
64
+ | Write throughput | ~1 transaction/second (5 messages per 5 seconds, per channel) |
65
+ | Bulk throughput | ~12 log records per request when batched |
66
+ | Object storage | a few MiB/s, hard-capped by rate limits nobody can raise for you |
67
+ | Read latency (adapter) | SQLite speed — reads never touch the network |
68
+ | Durability | at the pleasure of a company that owes you nothing |
69
+ | Transactions | one message = one atomic append; that is all there is |
70
+ | Deletes | expensive, and worse after 14 days |
71
+
72
+ If those numbers work for your problem, your problem is small enough that
73
+ SQLite would also have worked, and SQLite does not have a Terms of Service.
74
+
75
+ ---
76
+
77
+ ## Why it isn't a stunt
78
+
79
+ The naive version of this idea maps a channel to a table and scans it for every
80
+ query. That version tops out in the single-digit megabytes per second and cannot
81
+ do a join, because Discord gives a bot no query interface at all: no `WHERE`, no
82
+ index, no server-side filter. There is a channel, and you may page through it a
83
+ hundred messages at a time.
84
+
85
+ So Discord is not asked to be a query engine here. It is asked to be a durable,
86
+ replicated, ordered **write-ahead log** — which it is unexpectedly good at — and
87
+ a local SQLite file is the materialized view you actually query.
88
+
89
+ ```
90
+ writes ──▶ Discord channel (the log, source of truth)
91
+
92
+ └── replay ──▶ SQLite file (the materialized view)
93
+
94
+ reads ───────────────────────────────┘
95
+ ```
96
+
97
+ That is a materialized view over a replicated log, which is a normal thing that
98
+ normal systems do. The unusual part is only where the log segment lives.
99
+
100
+ The test that decides whether this is a real design:
101
+
102
+ ```
103
+ $ rm storage/production.sqlite3 && rake discord:replay
104
+ replayed 9 transactions (10 statements) in 0.3s
105
+ ```
106
+
107
+ Binary columns, timestamps, schema and all, on any machine, from nothing but a
108
+ token and a channel ID.
109
+
110
+ ### Three things Discord is accidentally good at
111
+
112
+ **Snowflake IDs are a real clustered primary key.** Message IDs encode a
113
+ millisecond timestamp in their high bits and are monotonic within a channel, so
114
+ the log sequence number, the `created_at`, and cursor pagination are all free.
115
+ Better still, a wall-clock range maps onto an ID range, and Discord's
116
+ `before`/`after` parameters take snowflakes — so "everything written between
117
+ 09:00 and 10:00" is a server-side range scan, not a client-side filter.
118
+
119
+ ```ruby
120
+ DiscordStore::Snowflake.at(175928847299117063)
121
+ # => 2016-04-30 11:18:25 UTC
122
+
123
+ # a synthetic bound that sorts exactly where a real ID would
124
+ DiscordStore::Snowflake.from_time(Time.utc(2026, 9, 9))
125
+ ```
126
+
127
+ **Editing is a real UPDATE.** A bot can edit its own messages forever, so a
128
+ mutable value is one message that gets rewritten, not a delete-and-reinsert.
129
+
130
+ **Deleting is not.** Bulk deletion only covers messages under two weeks old;
131
+ past that it is one request per message against the limit that was already
132
+ binding. So the cheap way to retire a record is to append a marker saying it is
133
+ gone and let readers skip it.
134
+
135
+ That marker is a tombstone. Cassandra — the database Discord itself ran on —
136
+ does exactly this, for exactly this reason, and the volume of tombstones a chat
137
+ app generates is precisely what made Cassandra untenable for them and forced
138
+ their migration to ScyllaDB. Building a store on Discord means writing
139
+ tombstones into a database whose tombstones are stored as tombstones.
140
+
141
+ Compaction is therefore not optional. `rake discord:compact STREAM=orders`.
142
+
143
+ ---
144
+
145
+ ## Compatibility
146
+
147
+ | | |
148
+ |---|---|
149
+ | Ruby | 3.1, 3.2, 3.3, 3.4 |
150
+ | Rails | 7.2, 8.0, 8.1 — for the ActiveRecord and ActiveStorage layers |
151
+
152
+ The gem itself depends on no part of Rails; the adapters are optional and load
153
+ only if you require them. `base64` is the single runtime dependency, and only
154
+ because it leaves the default gems in Ruby 3.4.
155
+
156
+ The Rails floor is 7.2 and it is a hard one:
157
+ `ActiveRecord::ConnectionAdapters.register` arrived in 7.2, and registering an
158
+ adapter before that means defining a `discord_connection` factory method
159
+ instead. Supporting both paths is possible; claiming to support both without
160
+ running the suite against both is not.
161
+
162
+ CI runs every supported combination — ten of them — on every push.
163
+
164
+ ## Install
165
+
166
+ ```ruby
167
+ # Gemfile
168
+ gem "discord_store"
169
+ ```
170
+
171
+ The version number is an honest 0. Three values in here are discovered at
172
+ runtime rather than hardcoded — the real attachment ceiling, whether the CDN
173
+ honours HTTP `Range`, and sharded throughput — and none of them has been
174
+ measured against a live guild. That is deliberate, because hardcoding them is
175
+ exactly how every previous library in this genre broke, but it does mean the
176
+ library has never met the thing it is named after. `rake discord:doctor` and
177
+ `rake discord:probe` are how you find out; reports are welcome.
178
+
179
+ Create a bot, invite it to a server you own with **Send Messages**, **Read
180
+ Message History** and **Manage Messages**, and make some channels for it.
181
+
182
+ ```bash
183
+ $ rake discord:key
184
+ K7XmPq2vN8wLdR4tYbG9hJfE3sA6uZcW1nT5oI0pQxM=
185
+ ```
186
+
187
+ ```ruby
188
+ DiscordStore.configure do |config|
189
+ config.i_understand_this_violates_discord_tos = true
190
+ config.token = ENV.fetch("DISCORD_BOT_TOKEN")
191
+ config.application_id = ENV.fetch("DISCORD_APPLICATION_ID")
192
+ config.guild_id = ENV.fetch("DISCORD_GUILD_ID")
193
+ config.secret_key = ENV.fetch("DISCORD_STORE_KEY")
194
+ config.log_channel_ids = ["1234567890123456789"]
195
+ end
196
+
197
+ DiscordStore.client.verify!
198
+ ```
199
+
200
+ `rake discord:doctor` checks the token, the channels, and the attachment ceiling.
201
+
202
+ ---
203
+
204
+ ## The four layers
205
+
206
+ Each is usable on its own.
207
+
208
+ ### L0 — Transport
209
+
210
+ A rate-limit-aware REST client. It learns Discord's bucket topology from
211
+ response headers rather than hardcoding a table of limits that will be wrong by
212
+ next quarter, honours `X-RateLimit-Scope` on 429s, and retries with full jitter.
213
+
214
+ The interesting part is what replaces the connection pool. A conventional
215
+ adapter checks out a socket, because sockets are scarce and the database will
216
+ answer as fast as you can ask. Over an HTTP API the opposite holds: connections
217
+ are free and *permission to ask* is what runs out. So the checkout primitive is
218
+ a semaphore over quota, `pool:` in `database.yml` means nothing, and the timeout
219
+ error says so:
220
+
221
+ > waited 15.0s for Discord request quota (45/s) and never got it. This is not a
222
+ > connection pool problem and a larger pool will not fix it.
223
+
224
+ Connections are cached per fiber, not per thread — under a fiber scheduler two
225
+ fibers on one thread would otherwise interleave bytes on one socket.
226
+
227
+ ### L1 — Log
228
+
229
+ An encrypted, append-only log across one or more channels.
230
+
231
+ ```ruby
232
+ log = DiscordStore.client.log
233
+
234
+ log.append(stream: "orders", data: { id: 1, total: 9.99 })
235
+
236
+ log.transaction do |tx| # one message = one atomic append
237
+ tx.append(stream: "orders", data: { id: 2 })
238
+ tx.append(stream: "orders", data: { id: 3 })
239
+ end
240
+
241
+ log.read(stream: "orders", after: cursor, limit: 100)
242
+ ```
243
+
244
+ Records are packed many-per-message (a message holds 2000 characters, and a
245
+ request that carries one 80-byte record spends the same permit as one carrying
246
+ twenty), encrypted with AES-256-GCM, and spilled to an attachment when too
247
+ large. Retries are deduplicated by Discord itself using the message `nonce`.
248
+
249
+ **Channels are partitions.** More channels means more throughput, because the
250
+ harshest limit is per-channel — and it means less ordering, because message IDs
251
+ are only ordered *within* a channel. So the promise is Kafka's: total order
252
+ within a partition, none across them, and a stream always lands in the same
253
+ partition. Rendezvous hashing, so adding a channel moves 1/n of the streams
254
+ rather than most of them.
255
+
256
+ ### L2a — ActiveStorage
257
+
258
+ ```yaml
259
+ # config/storage.yml
260
+ discord:
261
+ service: Discord
262
+ token: <%= ENV["DISCORD_BOT_TOKEN"] %>
263
+ application_id: <%= ENV["DISCORD_APPLICATION_ID"] %>
264
+ secret_key: <%= ENV["DISCORD_STORE_KEY"] %>
265
+ blob_channel_ids: ["...", "..."]
266
+ manifest_channel_id: "..."
267
+ i_understand_this_violates_discord_tos: true
268
+ ```
269
+
270
+ ```ruby
271
+ # config/environments/production.rb
272
+ config.active_storage.resolve_model_to_route = :rails_storage_proxy
273
+ ```
274
+
275
+ That last line is the whole reason this layer is worth building on Rails.
276
+
277
+ When Discord started signing CDN links with an expiring HMAC, every project that
278
+ had stored URLs in a database broke about a day later. The community's fix was
279
+ to deploy caching proxies on Cloudflare Workers that re-fetch the message and
280
+ hand back a fresh link — which works, and which concedes the entire premise,
281
+ because the storage is only free if you ignore the server you now have to run.
282
+
283
+ Rails has shipped that proxy for years. It is
284
+ `ActiveStorage::Blobs::ProxyController`. In proxy mode the browser gets a Rails
285
+ URL that never expires, and the Discord link is re-resolved per request,
286
+ server-side, where it belongs.
287
+
288
+ This library never stores a URL — only `channel_id`, `message_id` and
289
+ `attachment_id` — and resolves at read time. Redirect mode works, but only for
290
+ blobs small enough to be a single attachment: a blob split across attachments has
291
+ no single URL, and there is no honest way to invent one.
292
+
293
+ ### L2b — ActiveRecord
294
+
295
+ ```yaml
296
+ # config/database.yml
297
+ production:
298
+ adapter: discord
299
+ database: storage/production.sqlite3
300
+ discord:
301
+ i_understand_this_violates_discord_tos: true
302
+ token: <%= ENV["DISCORD_BOT_TOKEN"] %>
303
+ application_id: <%= ENV["DISCORD_APPLICATION_ID"] %>
304
+ secret_key: <%= ENV["DISCORD_STORE_KEY"] %>
305
+ log_channel_ids: ["1234567890123456789"]
306
+ ```
307
+
308
+ Every write is mirrored to the channel; every read is served by SQLite. Joins,
309
+ indexes, aggregates and migrations all work, because SQLite does them.
310
+
311
+ ```bash
312
+ rake discord:status # how far behind the local file is
313
+ rake discord:replay # rebuild it from the channel
314
+ rake discord:compact STREAM=wal DRY_RUN=1
315
+ ```
316
+
317
+ Three details that are load-bearing:
318
+
319
+ - **The log is written before SQLite commits, not after.** There is no two-phase
320
+ commit between a SQLite file and a chat server, so one has to go first, and it
321
+ has to be the one that can reconstruct the other.
322
+ - **Savepoints are refused while journalling.** A sent message cannot be
323
+ un-sent, so a rolled-back savepoint would still replay.
324
+ - **`journal_mode:`** is `:sync` (one message per transaction, ~1 tx/s),
325
+ `:async` (batched, ~12 tx/s, with a durability window), or `:off`.
326
+
327
+ ### L3 — KV
328
+
329
+ Kredis-shaped values, with the storage strategy chosen per type according to
330
+ what Discord can actually promise.
331
+
332
+ ```ruby
333
+ kv = DiscordStore.client.kv
334
+
335
+ kv.string("layout").set("cards") # one message, edited in place
336
+ kv.counter("views").increment # an append; sums on read
337
+ kv.list("recent").append("a", "b")
338
+ kv.flag("onboarded").mark(expires_in: 1.hour)
339
+ ```
340
+
341
+ Scalars are last-write-wins, because Discord has no compare-and-swap and there is
342
+ no way to build one on top of message editing. Counters are append-only for that
343
+ exact reason: two processes incrementing at once both count, because appends do
344
+ not collide. It is the reason distributed systems reach for CRDTs, arrived at
345
+ from the same constraint.
346
+
347
+ ```ruby
348
+ a.increment # from one process
349
+ b.increment # from another, no coordination
350
+ a.value # => 2
351
+ ```
352
+
353
+ ---
354
+
355
+ ## What does not work
356
+
357
+ - **No server-side query.** Anything not in the local view means scanning a
358
+ channel.
359
+ - **No cross-partition atomicity.** One message is the largest all-or-nothing
360
+ unit that exists.
361
+ - **No compare-and-swap**, so no safe read-modify-write on a document.
362
+ - **No direct upload.** Every byte goes through your application.
363
+ - **Anyone with Manage Messages can delete your database** from the Discord
364
+ client, and `NotFoundError` is a routine occurrence rather than an exceptional
365
+ one. AES-GCM at least means tampering surfaces as a decryption failure instead
366
+ of silently corrupt data.
367
+ - **Non-deterministic SQL** (`RANDOM()`, `CURRENT_TIMESTAMP`) will not replay
368
+ identically. The adapter warns when it journals one.
369
+ - **The attachment ceiling moves.** It has been 8 MiB, 25 MB and 10 MB, and
370
+ published write-ups disagree because each was right when written. This library
371
+ discovers it from the guild's boost tier, and `rake discord:probe` measures it
372
+ empirically.
373
+
374
+ ---
375
+
376
+ ## Testing without a token
377
+
378
+ The whole stack runs against an in-memory Discord, including the parts that only
379
+ break in production:
380
+
381
+ ```ruby
382
+ fake = DiscordStore::Transport::Fake.new(application_id: "111111111111111111")
383
+ client = DiscordStore::Client.new(config: my_config, http: fake)
384
+ ```
385
+
386
+ It models monotonic snowflakes, nonce deduplication, the 14-day bulk-delete
387
+ window, rate-limit headers and 429s on demand, messages from other authors — and
388
+ CDN links that carry a real expiry and stop working when it passes. Code that
389
+ caches a URL instead of re-resolving it fails in the test suite rather than in
390
+ production a day after deploy:
391
+
392
+ ```ruby
393
+ @now += 48 * 3600
394
+ assert_equal data, @blobs.get("k") # passes: it re-resolves
395
+ ```
396
+
397
+ ```sh
398
+ bundle exec rake # 145 tests and RuboCop, no network
399
+ bundle exec appraisal rake # the same, against Rails 7.2, 8.0 and 8.1
400
+ ruby -Ilib examples/replay_demo.rb
401
+ ```
402
+
403
+ ### Somebody else's tests
404
+
405
+ Everything above tests this library against expectations this library's author
406
+ wrote down, which is a closed loop. So the suite also vendors
407
+ [Rails' own ActiveStorage conformance tests][shared] verbatim and points them at
408
+ the Discord service — the same suite Disk, S3, GCS and Azure have to pass,
409
+ written by people who have never heard of this gem and have no stake in it
410
+ passing.
411
+
412
+ It found two real bugs on the first run. `compose` was simply missing. And
413
+ streaming downloads were yielding one Discord chunk at a time — sized by the
414
+ guild's attachment ceiling, 8 MiB to 100 MiB — where every other service yields
415
+ 5 MB slices and Rails asserts it exactly. That is storage geometry leaking into
416
+ an interface, and no test written from inside this project was ever going to
417
+ catch it.
418
+
419
+ All 14 pass, with nothing skipped.
420
+
421
+ [shared]: https://github.com/rails/rails/blob/v8.1.3.1/activestorage/test/service/shared_service_tests.rb
422
+
423
+ ---
424
+
425
+ ## Prior art
426
+
427
+ This is a Ruby entry in a long tradition, and the interesting parts of the design
428
+ came from reading what broke for everyone else: `discord-fs` for chunking and
429
+ journaling, `DiscordFS` for streaming rather than buffering, `discord-drive` for
430
+ the semaphore-bounded concurrency, `discbase` and `Discord_DB` for the mapping,
431
+ and `discord-cdn-proxy` for the re-resolution trick that Rails turns out to ship
432
+ already.
433
+
434
+ The tombstone observation is owed to Discord's own engineering write-ups on
435
+ migrating from Cassandra to ScyllaDB.
436
+
437
+ ## License
438
+
439
+ MIT. See [LICENSE.txt](LICENSE.txt).