custom_id 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.
data/llms/overview.md ADDED
@@ -0,0 +1,274 @@
1
+ # CustomId – Architecture Overview
2
+
3
+ > Intended audience: LLMs and AI agents that need a precise mental model of
4
+ > the gem internals before generating code.
5
+
6
+ ---
7
+
8
+ ## Purpose
9
+
10
+ `custom_id` gives ActiveRecord models human-readable, prefixed string IDs in
11
+ the style popularised by Stripe:
12
+
13
+ ```
14
+ usr_7xKmN2pQaBcDeFgH ← "usr" prefix + 16 Base58 chars
15
+ doc_ABCDEF7xKmN2pQaBcD ← "doc" prefix + 6 shared chars from parent + 18 random
16
+ ```
17
+
18
+ IDs are generated in Ruby inside a `before_create` callback. An optional
19
+ database-trigger alternative (`CustomId::DbExtension`) produces identical IDs
20
+ at the database level and supports PostgreSQL, MySQL, and SQLite.
21
+
22
+ ---
23
+
24
+ ## Module structure
25
+
26
+ ```
27
+ CustomId (top-level namespace, lib/custom_id.rb)
28
+ ├── Concern (lib/custom_id/concern.rb)
29
+ │ └── class_methods { cid } → registers before_create on the calling model
30
+ ├── Installer (lib/custom_id/installer.rb)
31
+ │ ├── .install!(root) → writes config/initializers/custom_id.rb
32
+ │ └── .uninstall!(root) → removes the initializer file
33
+ ├── Railtie < Rails::Railtie (lib/custom_id/railtie.rb)
34
+ │ └── rake_tasks { load rake } → exposes all custom_id:* rake tasks
35
+ ├── DbExtension (lib/custom_id/db_extension.rb)
36
+ │ ├── .supported?(connection) → true for pg/mysql/sqlite adapters
37
+ │ ├── .install_generate_function! → creates custom_id_base58() PG/MySQL function
38
+ │ ├── .uninstall_generate_function! → drops the PG/MySQL function
39
+ │ ├── .install_trigger! → writes trigger (PG/MySQL/SQLite)
40
+ │ └── .uninstall_trigger! → drops trigger
41
+ └── Error < StandardError
42
+ ```
43
+
44
+ ---
45
+
46
+ ## ID generation algorithm (Ruby path)
47
+
48
+ ```
49
+ before_create callback fires
50
+
51
+ ├─ send(name).nil? → false → skip (ID already set by caller)
52
+
53
+ └─ true → generate
54
+
55
+ ├─ resolve shared_chars
56
+ │ └─ related.present?
57
+ │ ├─ no → shared = ""
58
+ │ └─ yes → reflection = self.class.reflections[assoc_name]
59
+ │ foreign_key = reflection.foreign_key
60
+ │ ref_id = read_attribute(foreign_key)
61
+ │ shared = ref_id.split("_", 2).last.first(borrow_count)
62
+ │ or "" if ref_id is nil
63
+
64
+ └─ collision-resistant loop
65
+ generate = "#{prefix}_#{shared}#{SecureRandom.base58(size - shared.length)}"
66
+ send(:"#{name}=", generate)
67
+ break unless Model.exists?(name => generate)
68
+ ```
69
+
70
+ ### Complexity notes
71
+
72
+ - For the primary-key column (`name: :id`) the `exists?` check hits the PK
73
+ index – O(log n), fast even for large tables.
74
+ - For non-PK columns (`name: :slug`) an index on that column is recommended.
75
+ - Base58 alphabet: `123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz`
76
+ (58 chars, no `0`, `O`, `I`, `l` to avoid visual ambiguity).
77
+ - With `size: 16` the collision probability at 1 million rows is ≈ 5 × 10⁻⁹.
78
+
79
+ ---
80
+
81
+ ## ID generation algorithm (database trigger paths)
82
+
83
+ ### PostgreSQL
84
+
85
+ ```
86
+ BEFORE INSERT trigger fires on each row
87
+
88
+ └─ IF NEW.column IS NULL
89
+ NEW.column := prefix || '_' || custom_id_base58(size)
90
+
91
+ custom_id_base58(n):
92
+ rand_bytes := gen_random_bytes(n) -- pgcrypto extension (required)
93
+ FOR i IN 0..n-1:
94
+ result += chars[ get_byte(rand_bytes, i) % 58 ]
95
+ RETURN result
96
+ ```
97
+
98
+ PostgreSQL uses two objects per table:
99
+ - a **trigger function** `#{table}_#{column}_custom_id()` (PL/pgSQL)
100
+ - a **BEFORE INSERT trigger** `#{table}_#{column}_before_insert_custom_id`
101
+
102
+ The shared `custom_id_base58()` function is created once per database.
103
+ Requires the `pgcrypto` extension (`enable_extension "pgcrypto"` or
104
+ `rails custom_id:db:enable_pgcrypto`).
105
+
106
+ ### MySQL
107
+
108
+ ```
109
+ BEFORE INSERT trigger fires on each row
110
+
111
+ └─ IF NEW.column IS NULL
112
+ SET NEW.column = CONCAT(prefix, '_', custom_id_base58(size))
113
+
114
+ custom_id_base58(n):
115
+ WHILE i < n:
116
+ result += SUBSTR(chars, (ORD(RANDOM_BYTES(1)) % 58) + 1, 1)
117
+ RETURN result
118
+ ```
119
+
120
+ MySQL uses a single `BEFORE INSERT` trigger per table and a shared
121
+ `custom_id_base58()` stored function. Both DROP and CREATE are sent as
122
+ separate `connection.execute` calls because `mysql2`/`trilogy` reject
123
+ multi-statement strings.
124
+
125
+ **⚠ MySQL + ActiveRecord string PKs:** MySQL's `LAST_INSERT_ID()` returns `0`
126
+ for non-`AUTO_INCREMENT` columns. After an AR `create` without an explicit
127
+ `id`, the in-memory record has `id = "0"` even though the DB row is correct.
128
+ **Fix:** also declare `cid` on the model so AR generates the ID before INSERT.
129
+
130
+ ### SQLite
131
+
132
+ SQLite has two strategies depending on the column type:
133
+
134
+ **Non-PK or nullable column → AFTER INSERT trigger**
135
+ ```
136
+ AFTER INSERT trigger updates the row in-place using rowid.
137
+ ```
138
+
139
+ **NOT NULL primary key → BEFORE INSERT + RAISE(IGNORE)**
140
+ ```
141
+ BEFORE INSERT trigger fires:
142
+ 1. Generates ID with substr/abs(random())/% 58 inline expressions
143
+ 2. Inner INSERT with generated ID (WHEN NEW.id IS NULL guard prevents recursion)
144
+ 3. SELECT RAISE(IGNORE) abandons the outer NULL-id statement before
145
+ SQLite evaluates the NOT NULL constraint
146
+ ```
147
+
148
+ Note: when RAISE(IGNORE) abandons the outer INSERT, `RETURNING "id"` returns
149
+ nothing. Call `record.reload` after `create` to read the correct ID from the DB.
150
+
151
+ Shared-character support is **not** available in any trigger path because
152
+ cross-table reads inside a trigger are a concurrency anti-pattern.
153
+
154
+ ---
155
+
156
+ ## ActiveSupport integration
157
+
158
+ ### Auto-include mechanism
159
+
160
+ The gem ships a Railtie. When a developer runs `rails custom_id:install`, the
161
+ Installer writes:
162
+
163
+ ```ruby
164
+ # config/initializers/custom_id.rb
165
+ ActiveSupport.on_load(:active_record) do
166
+ include CustomId::Concern
167
+ end
168
+ ```
169
+
170
+ `ActiveSupport.on_load(:active_record)` defers the `include` until
171
+ `ActiveRecord::Base` is fully loaded, preventing load-order issues. After this
172
+ runs, every class that inherits from `ActiveRecord::Base` automatically has the
173
+ `cid` class macro available.
174
+
175
+ ### Without the initializer
176
+
177
+ Include manually in individual models or a base class:
178
+
179
+ ```ruby
180
+ class ApplicationRecord < ActiveRecord::Base
181
+ include CustomId::Concern
182
+ primary_abstract_class
183
+ end
184
+ ```
185
+
186
+ ---
187
+
188
+ ## Callback inheritance and STI
189
+
190
+ `cid "usr"` registers a `before_create` callback on the **class that calls
191
+ `cid`**, not on its subclasses. Because ActiveRecord inherits callbacks,
192
+ subclasses in an STI hierarchy will fire the parent's callback – which is
193
+ usually correct.
194
+
195
+ **Pitfall**: if a subclass calls `cid` again with different options, both
196
+ callbacks run. The first one sees `nil` and generates an ID; the second sees a
197
+ non-nil value and skips. Result: only the parent's options take effect. To
198
+ override, either use a fresh non-inheriting class in tests, or avoid double
199
+ `cid` calls in the same hierarchy.
200
+
201
+ ---
202
+
203
+ ## Multi-database support (rake tasks)
204
+
205
+ All `custom_id:db:*` rake tasks accept an optional `DATABASE` positional
206
+ argument (last position). It is matched against named database configs in
207
+ `database.yml` via `ActiveRecord::Base.configurations.find_db_config`.
208
+
209
+ A named abstract AR subclass (`CustomId::RakeDbProxy`) is created via
210
+ `const_set` and used for the alternate connection. This avoids replacing the
211
+ global default connection and satisfies Rails 7.2+'s requirement that
212
+ `establish_connection` only be called on named (non-anonymous) classes.
213
+
214
+ ---
215
+
216
+ ## File inclusion in gem package
217
+
218
+ ```ruby
219
+ spec.files = Dir[
220
+ "lib/**/*.rb",
221
+ "lib/tasks/*.rake",
222
+ "llms/**/*.md", # ← these files ship inside the gem
223
+ "AGENTS.md",
224
+ "CLAUDE.md",
225
+ "README.md",
226
+ "LICENSE.txt",
227
+ "CHANGELOG.md"
228
+ ]
229
+ ```
230
+
231
+ LLM context files ship **inside the released gem** so that tools that install
232
+ the gem can serve them as context to AI assistants.
233
+
234
+ ---
235
+
236
+ ## Rake task reference
237
+
238
+ All `db:*` tasks accept an optional `DATABASE` arg (matches a name from
239
+ `database.yml`). Omit it to use the default connection.
240
+
241
+ | Task | Depends on | Description |
242
+ |------|-----------|-------------|
243
+ | `custom_id:install` | — | Delegates to `CustomId::Installer.install!(Rails.root)` |
244
+ | `custom_id:uninstall` | — | Delegates to `CustomId::Installer.uninstall!(Rails.root)` |
245
+ | `custom_id:db:enable_pgcrypto[DATABASE]` | `:environment` | `CREATE EXTENSION IF NOT EXISTS pgcrypto` on PG connection |
246
+ | `custom_id:db:install_function[DATABASE]` | `:environment` | `DbExtension.install_generate_function!(conn)` |
247
+ | `custom_id:db:uninstall_function[DATABASE]` | `:environment` | `DbExtension.uninstall_generate_function!(conn)` |
248
+ | `custom_id:db:add_trigger[table,prefix,column,size,DATABASE]` | `:environment` | `DbExtension.install_trigger!(conn, ...)` + MySQL warning |
249
+ | `custom_id:db:remove_trigger[table,column,DATABASE]` | `:environment` | `DbExtension.uninstall_trigger!(conn, ...)` |
250
+
251
+ All `db:*` tasks rescue `NotImplementedError` and call `abort` with a
252
+ human-readable message when the adapter is not supported.
253
+
254
+ ---
255
+
256
+ ## Testing architecture
257
+
258
+ | Component | Tool | Database |
259
+ |-----------|------|----------|
260
+ | Ruby concern | Minitest | SQLite in-memory |
261
+ | Installer | Minitest | `Dir.mktmpdir` (filesystem) |
262
+ | DB extension – nullable column | Minitest | SQLite in-memory |
263
+ | DB extension – NOT NULL PK | Minitest | SQLite in-memory |
264
+
265
+ `test_helper.rb` boots ActiveRecord against SQLite and calls
266
+ `ActiveSupport.on_load(:active_record) { include CustomId::Concern }` to
267
+ replicate what the Rails initializer does.
268
+
269
+ Each test creates its own tables in `setup` and drops them in `teardown`,
270
+ providing full isolation without transactions.
271
+
272
+ PostgreSQL and MySQL paths are not covered by the automated test suite
273
+ (they require a live server). The SQLite tests exercise the same
274
+ `install_trigger!` / `uninstall_trigger!` public interface.