poly 1.0.0 → 1.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: 56f2e1989d5977256fd93964e07d62d3a26b696bc469baf3ddf477141eb7f383
4
- data.tar.gz: 33239362e770c93e2871754f9697763c893960af1e0238aac59ddfd94a8b7399
3
+ metadata.gz: ba17496c0d7031f1877d3aa61200842efd24cb1381c12ecb597e5b4c80ac65d7
4
+ data.tar.gz: 9bf0d37b9106badbf5672f3b1534e5c8e8161e3934ead590b56415a17196eda5
5
5
  SHA512:
6
- metadata.gz: 190707ca692b512d914adcd3b5cfbdfab79a14730ce14043a6c361642ebb6b193941ae75909e24a6405b9f96c23b746b61d9b66daa77255cb0a9241cc3b3be5a
7
- data.tar.gz: 6ae262801aaab5a09416080fb36df87a9ee65918698ab08bc12e1e2b6c612c9d9bee288a396842bc89c6b165e6515d8b50f84009f5670b5111a2c1ff2be1735e
6
+ metadata.gz: bf46bda44abfaccfb137c84cf1a6da3a6d3a6d0ad901e5354b3a3031769454777a90d03b2b09f0a5d5f81d4d26fd91458a58ea1de4cb083756fa4cfae6328684
7
+ data.tar.gz: 62b7997d9a53c0ee472f745edaae46e78b6df6d54ff77af8ca4465bbb85afd642636c164863cf77eff4aaa537ed00c3779582203c935b34fa692853457c5b239
data/CHANGELOG.md CHANGED
@@ -5,7 +5,56 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [1.2.0] - 2026-08-16
9
+
10
+ ### Added
11
+
12
+ - `Poly::PolymorphicJoinError` (`lib/poly/polymorphic_join_error.rb`) — the
13
+ join-validation error raised from `lib/poly/joins.rb` is now namespaced
14
+ under `Poly`. The bare top-level `PolymorphicJoinError` constant remains as
15
+ a deprecated alias, so existing `rescue PolymorphicJoinError` call sites
16
+ keep working.
17
+ - CI now covers a database axis (SQLite and PostgreSQL, the latter via a GitHub
18
+ Actions `services:` Postgres container) and an ActiveRecord/Rails version axis
19
+ (7.1, 7.2, 8.x), each matrix cell pinning the loaded AR version via the new
20
+ `ACTIVERECORD_VERSION` env var consumed by the `Gemfile`.
21
+ - `spec/spec_helper.rb`'s database adapter is now parameterized via
22
+ `POLY_TEST_ADAPTER` (`sqlite3` default, or `postgresql`) instead of
23
+ hardcoding an in-memory SQLite connection.
24
+ - README "Supported Databases" section documenting that MySQL is explicitly
25
+ unsupported (`AbstractMysqlAdapter` doesn't implement `supports_partial_index?`,
26
+ silently degrading `poly_prime_index` to a full-table unique index).
27
+ - README and new specs (`spec/models/poly/stack_spec.rb`) documenting
28
+ `Poly::Stack`'s concurrency boundary — the `poly_stack_seize_prime`
29
+ demote-then-insert sequence in `lib/poly/stack.rb`, and the
30
+ `ActiveRecord::RecordNotUnique` failure mode it can hit under concurrent
31
+ writers. No new public API was added.
32
+
33
+ ### Fixed
34
+
35
+ - README §5 ("Poly::Stack")'s "append-only" wording corrected: the contract
36
+ is immutable payload with mutable linkage/index metadata (`is_prime`,
37
+ `superseded_by_id` are mutated in place on supersession), not literally
38
+ immutable/append-only rows.
39
+
40
+ ## [1.1.0] - 2026-07-07
41
+
42
+ ### Added
43
+
44
+ - `Poly::Stack` — polymorphic, role-discriminated append-only history with a single
45
+ "prime" (golden-child) card per `(resource, role)`, enforced by a database-level
46
+ partial unique index. Payload-agnostic: manages only the prime marker
47
+ (`is_prime`) and audit edge (`superseded_by_id`); the payload column, actor, and
48
+ reason belong to the consuming model.
49
+ - `where:` option on `poly_resource_index` and `poly_owner_index` migration
50
+ helpers — passes a partial-index condition through to `add_index`.
51
+
52
+ ### Changed
53
+
54
+ - `poly_prime_index` is now implemented as sugar on top of `poly_resource_index`
55
+ (`where: 'is_prime'`) instead of duplicating its own `add_index` call. The
56
+ generated index name (`index_<table>_prime`) and indexed columns are
57
+ unchanged, so this is not a breaking change for existing schemas.
9
58
 
10
59
  ## [1.0.0] - 2026-02-18
11
60
 
@@ -51,7 +100,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
51
100
  Creates methods like `joins_commentable(ClassName)` that validate the reverse
52
101
  `has_many`/`has_one` association before building the join SQL.
53
102
 
54
- [Unreleased]: https://github.com/leewhittaker/poly/compare/v1.0.0...HEAD
55
- [1.0.0]: https://github.com/leewhittaker/poly/compare/v0.2.0...v1.0.0
56
- [0.2.0]: https://github.com/leewhittaker/poly/compare/v0.1.0...v0.2.0
57
- [0.1.0]: https://github.com/leewhittaker/poly/releases/tag/v0.1.0
103
+ [1.2.0]: https://github.com/whittakertech/poly/compare/v1.1.0...v1.2.0
104
+ [1.1.0]: https://github.com/whittakertech/poly/compare/v1.0.0...v1.1.0
105
+ [1.0.0]: https://github.com/whittakertech/poly/compare/v0.2.0...v1.0.0
106
+ [0.2.0]: https://github.com/whittakertech/poly/compare/v0.1.0...v0.2.0
107
+ [0.1.0]: https://github.com/whittakertech/poly/releases/tag/v0.1.0
data/README.md CHANGED
@@ -1,73 +1,192 @@
1
1
  # Poly
2
2
 
3
- Type-safe joins, role identity, and owner identity for polymorphic `belongs_to` associations in Rails.
3
+ Type-safe joins, role identity, owner identity, and migration discipline for polymorphic `belongs_to` associations in Rails.
4
+
5
+ ---
6
+
7
+ ## What Is Poly?
8
+
9
+ Poly is a structural identity substrate for Rails polymorphism.
10
+
11
+ It provides:
12
+
13
+ - Type-safe polymorphic joins
14
+ - Role semantics for polymorphic relationships
15
+ - Owner stamping for write-time identity projection
16
+ - Migration helpers for consistent schema topology
17
+
18
+ Poly does **not** implement tenancy, policy, or business logic.
19
+
20
+ ---
21
+
22
+ ## Mental Model
23
+
24
+ ```mermaid
25
+ flowchart LR
26
+ A[ActiveRecord Model]
27
+ B[Polymorphic belongs_to]
28
+ C[Role Column]
29
+ D[Owner Columns]
30
+ E[Composite Indexes]
31
+
32
+ A --> B
33
+ B --> C
34
+ B --> D
35
+ C --> E
36
+ D --> E
37
+ ```
38
+
39
+ Poly strengthens the edges around polymorphic identity.
40
+
41
+ ---
4
42
 
5
43
  ## Installation
6
44
 
7
45
  Add to your Gemfile:
8
46
 
9
47
  ```ruby
10
- gem 'poly'
48
+ gem "poly"
11
49
  ```
12
50
 
13
- Then run `bundle install`.
51
+ Then:
52
+
53
+ ```bash
54
+ bundle install
55
+ ```
56
+
57
+ ---
14
58
 
15
59
  ## Requirements
16
60
 
17
61
  - Ruby >= 3.2
18
62
  - ActiveRecord >= 7.1
19
63
 
20
- ## Usage
64
+ ---
65
+
66
+ ## Supported Databases
21
67
 
22
- ### Poly::Joins
68
+ Poly is tested in CI against **SQLite** and **PostgreSQL** (both Ruby x
69
+ ActiveRecord 7.1/7.2/8.x combinations -- see `.github/workflows/ci.yml`).
23
70
 
24
- Generates type-safe `INNER JOIN` methods for polymorphic associations. Include the module in a model that has a polymorphic `belongs_to`, and it will define a `joins_<association>` class method for each one.
71
+ **MySQL is explicitly not supported.** `Poly::Migration#poly_prime_index`
72
+ (see below) relies on a partial/conditional unique index --
73
+ `add_index table, [...], unique: true, where: 'is_prime'` -- to enforce
74
+ "exactly one prime row per resource+role, many non-primes allowed."
75
+ PostgreSQL's and SQLite3's ActiveRecord adapters both override
76
+ `supports_partial_index?` to `true`; `ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter`
77
+ does **not** override it, so it inherits the abstract default of `false`.
78
+ Because `schema_creation.rb` only emits the index's `WHERE` clause when
79
+ `supports_partial_index?` is true, MySQL silently drops the clause instead
80
+ of raising -- producing a full-table unique index instead of a partial one,
81
+ and quietly breaking the single-prime-per-role invariant (it would instead
82
+ forbid more than one row total per resource+role). This is a silent
83
+ correctness bug, not just reduced support, so running Poly against MySQL is
84
+ unsupported rather than merely uncautioned-against.
85
+
86
+ ---
87
+
88
+ # Quickstart
89
+
90
+ ### Migration
25
91
 
26
92
  ```ruby
27
- class Comment < ApplicationRecord
28
- belongs_to :commentable, polymorphic: true
93
+ class CreateItems < ActiveRecord::Migration[7.1]
94
+ include Poly::Migration
29
95
 
30
- include Poly::Joins
96
+ def change
97
+ create_table :items do |t|
98
+ poly_resource t, :itemable, null: false
99
+ poly_role t, :itemable, null: false
100
+ poly_owner t, null: false
101
+ t.timestamps
102
+ end
103
+
104
+ poly_resource_index :items, :itemable
105
+ poly_owner_index :items
106
+ end
31
107
  end
108
+ ```
32
109
 
33
- class Post < ApplicationRecord
34
- has_many :comments, as: :commentable
110
+ ---
111
+
112
+ ### Model
113
+
114
+ ```ruby
115
+ class Item < ApplicationRecord
116
+ belongs_to :itemable, polymorphic: true
117
+
118
+ include Poly::Role
119
+ include Poly::Owners
120
+
121
+ poly_role :itemable
122
+ poly_owner :itemable, owner: -> { account }
35
123
  end
124
+ ```
125
+
126
+ ---
127
+
128
+ # Modules
129
+
130
+ ---
131
+
132
+ # 1. Poly::Joins
133
+
134
+ Generates type-safe `INNER JOIN` methods for polymorphic associations.
135
+
136
+ ### Example
36
137
 
37
- class User < ApplicationRecord
38
- has_many :comments, as: :commentable
138
+ ```ruby
139
+ class Comment < ApplicationRecord
140
+ belongs_to :commentable, polymorphic: true
141
+ include Poly::Joins
39
142
  end
40
143
  ```
41
144
 
42
- Now you can join through the polymorphic association by passing the target class:
145
+ Now:
43
146
 
44
147
  ```ruby
45
- # Join comments to the posts table
46
148
  Comment.joins_commentable(Post)
47
- # => SELECT "comments".* FROM "comments"
48
- # INNER JOIN "posts"
49
- # ON "comments"."commentable_id" = "posts"."id"
50
- # AND "comments"."commentable_type" = 'Post'
149
+ Comment.joins_commentable(User)
150
+ ```
51
151
 
52
- # Chainable with other scopes
53
- Comment.joins_commentable(Post).where(posts: { title: 'Hello' })
152
+ Generated SQL:
54
153
 
55
- # Join to a different target type
56
- Comment.joins_commentable(User).where(users: { name: 'Lee' })
154
+ ```sql
155
+ INNER JOIN "posts"
156
+ ON "comments"."commentable_id" = "posts"."id"
157
+ AND "comments"."commentable_type" = 'Post'
57
158
  ```
58
159
 
59
- **Safety:** The target class must declare the reverse association (`has_many` or `has_one` with `as: :commentable`). If it doesn't, a `PolymorphicJoinError` is raised:
60
-
61
- ```ruby
62
- Comment.joins_commentable(Unrelated)
63
- # => PolymorphicJoinError: Unrelated must declare has_one/has_many as: :commentable
160
+ > [!IMPORTANT]
161
+ > The target class must declare the reverse association:
162
+ >
163
+ > ```ruby
164
+ > has_many :comments, as: :commentable
165
+ > # has_one :comment, as: :commentable — also valid
166
+ > ```
167
+ >
168
+ > Otherwise `Poly::PolymorphicJoinError` is raised.
169
+
170
+ ### Join Flow
171
+
172
+ ```mermaid
173
+ sequenceDiagram
174
+ participant M as Model
175
+ participant PJ as Poly::Joins
176
+ participant T as Target
177
+
178
+ M->>PJ: joins_commentable(Post)
179
+ PJ->>T: Validate reverse association
180
+ PJ->>M: Generate INNER JOIN
64
181
  ```
65
182
 
66
- ### Poly::Role
183
+ ---
67
184
 
68
- Adds a validated role column to a polymorphic association. This is useful when a single polymorphic relationship needs to distinguish between different roles or categories.
185
+ # 2. Poly::Role
69
186
 
70
- Your table needs a `<association>_role` string column:
187
+ Adds semantic identity to polymorphic relationships.
188
+
189
+ ## Schema
71
190
 
72
191
  ```ruby
73
192
  create_table :taggings do |t|
@@ -76,11 +195,12 @@ create_table :taggings do |t|
76
195
  t.timestamps
77
196
  end
78
197
 
79
- # Index: composite on (taggable_type, taggable_id, taggable_role) if uniqueness is required
80
- add_index :taggings, [:taggable_type, :taggable_id, :taggable_role], unique: true
198
+ add_index :taggings,
199
+ [:taggable_type, :taggable_id, :taggable_role],
200
+ unique: true
81
201
  ```
82
202
 
83
- Then include the module and declare the role-enabled association:
203
+ ## Model
84
204
 
85
205
  ```ruby
86
206
  class Tagging < ApplicationRecord
@@ -89,80 +209,110 @@ class Tagging < ApplicationRecord
89
209
  include Poly::Role
90
210
 
91
211
  poly_role :taggable
92
- # optionally:
93
- # poly_role :taggable, max_length: 128
94
212
  # poly_role :taggable, immutable: true
95
213
  end
96
214
  ```
97
215
 
98
- This gives you:
216
+ ## What You Get
99
217
 
100
- - **Normalization** roles are stripped and downcased before validation and before `for_role` queries
101
- - **Validation** roles must match `/\A[a-z0-9_]+\z/` and be at most 64 characters (configurable via `max_length:`)
102
- - **Scope** `for_role` queries by role, normalizing the input automatically
103
- - **Immutability** — `immutable: true` adds an `on: :update` validation that prevents role changes after create
218
+ - Normalization (`strip + downcase`)
219
+ - Format validation (`/\A[a-z0-9_]+\z/`)
220
+ - Length validation (`max_length:`, default `64`)
221
+ - `for_role` scope
222
+ - Optional immutability
104
223
 
105
224
  ```ruby
106
- tagging = Tagging.new(taggable: post, taggable_role: ' Primary ')
107
- tagging.valid?
108
- tagging.taggable_role # => "primary"
225
+ Tagging.for_role(" PRIMARY ")
226
+ # => matches "primary"
227
+ ```
109
228
 
110
- Tagging.for_role(' PRIMARY ')
111
- # => normalizes to 'primary' before querying
229
+ > [!NOTE]
230
+ > `immutable: true` prevents role changes after create.
231
+
232
+ ### Role Identity Model
233
+
234
+ ```mermaid
235
+ flowchart TD
236
+ A[Polymorphic Edge]
237
+ B[Role Column]
238
+ C[Semantic Meaning]
239
+
240
+ A --> B
241
+ B --> C
112
242
  ```
113
243
 
114
- ### Poly::Owners
244
+ ---
245
+
246
+ # 3. Poly::Owners
247
+
248
+ Stamps root ownership at write time.
115
249
 
116
- Stamps `owner_type`/`owner_id` columns before validation. Useful for recording data ownership at write time without coupling the model to tenancy or policy logic.
250
+ No traversal.
251
+ No tenancy logic.
252
+ Just identity projection.
117
253
 
118
- Your table needs `owner_type` and `owner_id` columns (in addition to your polymorphic resource columns):
254
+ ## Schema
119
255
 
120
256
  ```ruby
121
257
  create_table :coins do |t|
122
- t.references :ledger, null: false
123
258
  t.references :resource, polymorphic: true, null: false
124
- t.string :resource_role, null: false
125
- t.string :owner_type
126
- t.integer :owner_id
259
+ t.string :owner_type
260
+ t.string :owner_id
127
261
  t.timestamps
128
262
  end
129
263
 
130
- # Index: always composite — never index owner_type and owner_id separately
131
264
  add_index :coins, [:owner_type, :owner_id]
132
265
  ```
133
266
 
134
- Then declare how the owner should be resolved:
267
+ > [!WARNING]
268
+ > Never index `owner_type` and `owner_id` separately. Always composite.
269
+
270
+ ## Model
135
271
 
136
272
  ```ruby
137
273
  class Coin < ApplicationRecord
138
- belongs_to :ledger
139
274
  belongs_to :resource, polymorphic: true
140
275
 
141
276
  include Poly::Owners
142
277
 
143
278
  poly_owner :resource, owner: -> { ledger&.account }
144
- # optionally:
145
- # poly_owner :resource, owner: -> { ledger&.account }, allow_nil: false
146
- # poly_owner :resource, owner: -> { ledger&.account }, immutable: true
147
279
  end
148
280
  ```
149
281
 
150
- **`owner` resolution** can be a `Proc` (evaluated in instance context), a `Symbol`/`String` (method name called on the record), or a direct `ActiveRecord::Base` instance. The owner must be persisted; an `ArgumentError` is raised otherwise.
282
+ ## Owner Resolution Options
283
+
284
+ | Option | Default | Description |
285
+ |----------------|---------------|------------------------------------|
286
+ | `type_column:` | `:owner_type` | Column storing class name |
287
+ | `id_column:` | `:owner_id` | Column storing owner ID |
288
+ | `allow_nil:` | `true` | Allow owner to resolve to nil; if `false`, raise instead |
289
+ | `immutable:` | `false` | Prevent owner changes after create |
290
+
291
+ > [!IMPORTANT]
292
+ > Owner must resolve to a persisted `ActiveRecord::Base`.
293
+ >
294
+ > Otherwise an `ArgumentError` is raised.
295
+
296
+ ### Ownership Flow
151
297
 
152
- **Options:**
298
+ ```mermaid
299
+ sequenceDiagram
300
+ participant Record
301
+ participant PO as Poly::Owners
302
+ participant Owner
153
303
 
154
- | Option | Default | Description |
155
- |---|---|---|
156
- | `type_column:` | `:owner_type` | Column to store the owner class name |
157
- | `id_column:` | `:owner_id` | Column to store the owner id |
158
- | `allow_nil:` | `true` | When `false`, raises if the owner resolves to `nil` |
159
- | `immutable:` | `false` | When `true`, prevents owner changes after create via `on: :update` validation |
304
+ Record->>PO: before_validation
305
+ PO->>Owner: resolve owner
306
+ PO->>Record: stamp owner_type + owner_id
307
+ ```
308
+
309
+ ---
160
310
 
161
- ### Poly::Migration
311
+ # 4. Poly::Migration
162
312
 
163
- Adds migration helpers so polymorphic resource/role/owner columns are declared consistently.
313
+ Migration helpers for consistent polymorphic topology.
164
314
 
165
- Use it in your migration base class:
315
+ ## Usage
166
316
 
167
317
  ```ruby
168
318
  class ApplicationMigration < ActiveRecord::Migration[7.1]
@@ -170,65 +320,272 @@ class ApplicationMigration < ActiveRecord::Migration[7.1]
170
320
  end
171
321
  ```
172
322
 
173
- Supported styles:
323
+ Supports:
324
+
325
+ - `create_table`
326
+ - `change_table`
327
+ - direct `add_column` style
328
+
329
+ ## Helpers
330
+
331
+ | Helper | Purpose |
332
+ |--------|----------|
333
+ | `poly_resource` | Adds `<name>_type` + `<name>_id` |
334
+ | `poly_role` | Adds `<name>_role` |
335
+ | `poly_owner` | Adds owner columns |
336
+ | `poly_stack` | Adds `is_prime` + `superseded_by_id` |
337
+ | `poly_resource_index` | Composite resource index (supports `where:`, `unique:`, `index_name:`) |
338
+ | `poly_owner_index` | Composite owner index (supports `where:`, `unique:`, `index_name:`) |
339
+ | `poly_prime_index` | Partial unique index (one prime per resource/role); sugar for `poly_resource_index(..., where: 'is_prime')` |
340
+
341
+ ## ID Flexibility
342
+
343
+ `id_type` defaults to `:string`.
344
+
345
+ Supports:
346
+
347
+ - UUID
348
+ - ULID
349
+ - bigint
350
+ - custom identifiers
351
+
352
+ ---
353
+
354
+ # 5. Poly::Stack
355
+
356
+ A polymorphic, role-discriminated **history** where one entry is the current
357
+ **prime** — the top of the stack. `Poly::Role` is
358
+ the same idea at cardinality 1; `Poly::Stack` opens it up to many entries per
359
+ `(resource, role)`, with the most-recently created always prime.
360
+
361
+ It is **payload agnostic** with an **immutable payload, mutable linkage/index
362
+ metadata** contract: it manages the prime marker and an audit edge only. The
363
+ payload column, the actor, and the reason belong to your model — Poly::Stack
364
+ never reads or rewrites them. What Poly::Stack *does* mutate in place, on
365
+ supersession, are the prior prime row's own `is_prime` and `superseded_by_id`
366
+ columns — historical rows are never deleted, but their linkage/index metadata
367
+ is updated, so this is not a literally append-only/immutable-row contract.
368
+
369
+ ## Schema
370
+
371
+ ```ruby
372
+ create_table :statuses do |t|
373
+ t.references :resource, polymorphic: true, null: false
374
+ t.string :resource_role, null: false
375
+ t.string :state, null: false # your payload — Poly::Stack is agnostic about it
376
+ t.boolean :is_prime, null: false, default: false
377
+ t.integer :superseded_by_id # audit edge (unconstrained)
378
+ t.timestamps
379
+ end
380
+
381
+ # Exactly one prime per resource per role, enforced by the database.
382
+ add_index :statuses, %i[resource_type resource_id resource_role],
383
+ unique: true, where: 'is_prime', name: 'index_statuses_prime'
384
+ ```
385
+
386
+ Or with `Poly::Migration`:
174
387
 
175
- - `create_table` / `change_table` via a table builder (`t`)
176
- - direct existing-table operations via `add_column` style (pass table name)
388
+ ```ruby
389
+ create_table :statuses do |t|
390
+ poly_resource t, :resource, null: false
391
+ poly_role t, :resource, null: false
392
+ t.string :state, null: false
393
+ poly_stack t
394
+ t.timestamps
395
+ end
177
396
 
178
- #### Create Table / Change Table
397
+ poly_prime_index :statuses, :resource
398
+ ```
399
+
400
+ ## The Entry Model
401
+
402
+ `Poly::Stack` is an **entry-side** concern — the externality includes it, exactly
403
+ as `Coin` includes `Poly::Role`:
179
404
 
180
405
  ```ruby
181
- class CreateCoins < ApplicationMigration
182
- def change
183
- create_table :coins do |t|
184
- poly_resource t, :resource, null: false
185
- poly_role t, :resource, null: false
186
- poly_owner t, null: false
187
- t.timestamps
188
- end
406
+ class Status < ApplicationRecord
407
+ belongs_to :resource, polymorphic: true
189
408
 
190
- poly_resource_index :coins, :resource
191
- poly_owner_index :coins
192
- end
409
+ include Poly::Joins
410
+ include Poly::Stack
411
+
412
+ poly_stack :resource
193
413
  end
194
414
  ```
195
415
 
196
- #### Existing Table (add_column style)
416
+ That gives the `prime` scope and append-only priming — the newest entry per
417
+ `(resource, role)` becomes prime, the prior prime is demoted, and its
418
+ `superseded_by_id` is linked:
197
419
 
198
420
  ```ruby
199
- class AddPolyColumnsToCoins < ApplicationMigration
200
- def change
201
- poly_resource :coins, :resource, null: false
202
- poly_role :coins, :resource, null: false
203
- poly_owner :coins, null: false
421
+ Status.create!(resource: post, resource_role: 'status', state: 'draft')
422
+ Status.create!(resource: post, resource_role: 'status', state: 'public')
423
+
424
+ Status.where(resource: post, resource_role: 'status').prime # => the 'public' entry
425
+ ```
426
+
427
+ ## Wiring a Parent
428
+
429
+ Poly ships **only the entry concern**. The parent-side accessor macro is yours to
430
+ write — the way Midas writes `has_coin` on `Poly::Role`. It's just `for_role`
431
+ (from `Poly::Role`) plus the `prime` scope, composed into associations and
432
+ scopes:
204
433
 
205
- poly_resource_index :coins, :resource
206
- poly_owner_index :coins
434
+ ```ruby
435
+ # in your app — a Stackable concern providing a has_stack macro
436
+ module Stackable
437
+ extend ActiveSupport::Concern
438
+
439
+ class_methods do
440
+ def has_stack(name, class_name: name.to_s.classify, value: :state, dependent: :destroy)
441
+ label = name.to_s
442
+ plural = label.pluralize.to_sym
443
+ stamp = :"stack_stamp_#{name}"
444
+
445
+ # so `post.statuses << Status.new(...)` stamps the role on add
446
+ define_method(stamp) do |entry|
447
+ entry.resource_role = label if entry.respond_to?(:resource_role=) && entry.resource_role.blank?
448
+ end
449
+
450
+ has_many plural, -> { for_role(label).order(created_at: :desc) },
451
+ as: :resource, class_name: class_name, dependent: dependent, before_add: stamp
452
+ has_one name, -> { for_role(label).prime },
453
+ as: :resource, class_name: class_name
454
+
455
+ entries = -> { class_name.constantize }
456
+ scope :"where_#{name}", ->(*v) { joins(name).merge(entries.call.where(value => v.flatten)) }
457
+ scope :"ever_#{name}", ->(*v) { where(id: joins(plural).merge(entries.call.where(value => v.flatten)).select(:id)) }
458
+ scope :"without_#{name}", ->(*v) { where.not(id: public_send(:"where_#{name}", *v).select(:id)) }
459
+ scope :"never_#{name}", ->(*v) { where.not(id: public_send(:"ever_#{name}", *v).select(:id)) }
460
+ end
207
461
  end
208
462
  end
463
+
464
+ class Post < ApplicationRecord
465
+ include Stackable
466
+
467
+ has_stack :status
468
+ has_stack :visibility, class_name: 'Status' # second stack, same table, own prime
469
+ end
209
470
  ```
210
471
 
211
- #### Helper Reference
472
+ Which gives you:
212
473
 
213
- | Helper | Purpose |
214
- |---|---|
215
- | `poly_resource(table_or_builder, name, null: true, id_type: :string)` | Adds `<name>_type` and `<name>_id` |
216
- | `poly_role(table_or_builder, name, null: true)` | Adds `<name>_role` |
217
- | `poly_owner(table_or_builder, type_column: :owner_type, id_column: :owner_id, id_type: :string, null: true)` | Adds owner type/id columns |
218
- | `poly_resource_index(table, name, unique: false)` | Adds index on `<name>_type`, `<name>_id` |
219
- | `poly_owner_index(table, type_column: :owner_type, id_column: :owner_id, unique: false)` | Adds index on owner columns |
474
+ ```ruby
475
+ post.status # => prime Status entry (preloadable has_one)
476
+ post.statuses # => full stack, newest-first (has_many)
477
+
478
+ post.statuses.create!(state: 'trash') # append an entry; it becomes prime
479
+ post.statuses << Status.new(state: 'public') # also appends; role stamped on add
220
480
 
221
- `id_type` defaults to `:string` so owner/resource IDs can store bigint, UUID, ULID, or other identifier formats consistently.
481
+ Post.where_status('public') # prime state = public
482
+ Post.without_status('trash') # prime != trash, OR no entry yet
483
+ Post.ever_status('trash') # any entry in history = trash (de-duped)
484
+ Post.never_status('trash') # no entry ever = trash
222
485
 
223
- ## Development
486
+ Post.includes(:status) # preload the prime to avoid N+1
487
+ ```
488
+
489
+ ## Soft-Delete
490
+
491
+ `Poly::Stack` is the primitive behind soft-delete-as-history: a `trash` entry is a
492
+ deletion, a later entry is a restore, and the stack is the audit trail. The
493
+ *meaning* stays in your app — one-liners over your `has_stack` scopes:
494
+
495
+ ```ruby
496
+ scope :live, -> { without_status(:trash) }
497
+ scope :trashed, -> { where_status(:trash) }
498
+ ```
499
+
500
+ > [!NOTE]
501
+ > Prefer explicit scopes over `default_scope` for soft-delete.
502
+
503
+ ## Concurrency Boundary
504
+
505
+ `poly_stack_seize_prime` (the `before_create` callback that demotes the prior
506
+ prime and claims the new one) is **not** wrapped in an explicit row lock or
507
+ transaction. Two writers racing the same `(resource, role)` at the same time
508
+ can both read the same prior prime, both demote it, and both attempt to
509
+ insert with `is_prime: true`.
510
+
511
+ When that happens, it is the partial unique index (`add_index ..., unique:
512
+ true, where: 'is_prime'`, built by `poly_prime_index`) — not the
513
+ callback — that enforces "at most one prime per `(resource, role)`". The
514
+ losing writer's `INSERT` raises `ActiveRecord::RecordNotUnique`, at the
515
+ `INSERT` itself, after the callback has already run.
516
+
517
+ Poly::Stack does not catch or retry this internally. **Callers that may write
518
+ concurrently to the same `(resource, role)` should be prepared to rescue
519
+ `ActiveRecord::RecordNotUnique` around the create call and retry** — e.g.
520
+ re-fetch the current prime and re-attempt the create — rather than assuming a
521
+ single `create!` is race-safe:
522
+
523
+ ```ruby
524
+ begin
525
+ post.statuses.create!(state: 'public')
526
+ rescue ActiveRecord::RecordNotUnique
527
+ # another writer won the race for this (resource, role); re-fetch and
528
+ # decide whether to retry, merge, or surface a conflict to the caller.
529
+ retry_or_handle_conflict
530
+ end
531
+ ```
532
+
533
+ ## Priming Flow
534
+
535
+ ```mermaid
536
+ sequenceDiagram
537
+ participant Entry as New Entry
538
+ participant PS as Poly::Stack
539
+ participant Prior as Prior Prime
540
+
541
+ Entry->>PS: before_create
542
+ PS->>Prior: demote (is_prime = false)
543
+ PS->>Entry: claim prime (is_prime = true)
544
+ Entry->>PS: after_create
545
+ PS->>Prior: link superseded_by_id
546
+ ```
547
+
548
+ ---
549
+
550
+ # Design Principles
551
+
552
+ Poly is intentionally minimal.
553
+
554
+ It does not:
555
+
556
+ - Implement tenancy
557
+ - Infer ownership
558
+ - Traverse associations
559
+ - Inject business logic
560
+ - Generate constraints automatically
561
+ - Enforce policy
562
+
563
+ It provides structure only.
564
+
565
+ ---
566
+
567
+ # Development
224
568
 
225
569
  ```bash
226
- bundle install # Install dependencies
227
- bundle exec rspec # Run tests
228
- bundle exec rubocop # Lint
229
- COVERAGE=true bundle exec rspec # Run tests with coverage report
570
+ bundle install
571
+ bundle exec rspec
572
+ bundle exec rubocop
573
+ COVERAGE=true bundle exec rspec
230
574
  ```
231
575
 
232
- ## License
576
+ ---
577
+
578
+ # Stability
579
+
580
+ Poly v1.0.0 declares:
581
+
582
+ - Public API is stable
583
+ - Breaking changes follow SemVer
584
+ - New features are additive
585
+ - No structural refactors planned
586
+
587
+ ---
588
+
589
+ # License
233
590
 
234
- Released under the [MIT License](https://opensource.org/licenses/MIT).
591
+ MIT see [LICENSE](LICENSE)
data/lib/poly/joins.rb CHANGED
@@ -19,12 +19,12 @@ module Poly::Joins
19
19
  next if singleton_class.method_defined?(method_name)
20
20
 
21
21
  define_singleton_method(method_name) do |klass|
22
- raise PolymorphicJoinError, 'Expected an ActiveRecord model' unless klass <= ActiveRecord::Base
22
+ raise Poly::PolymorphicJoinError, 'Expected an ActiveRecord model' unless klass <= ActiveRecord::Base
23
23
 
24
24
  base_klass = klass.base_class
25
25
 
26
26
  unless join_allowed?(klass, as: assoc_name)
27
- raise PolymorphicJoinError,
27
+ raise Poly::PolymorphicJoinError,
28
28
  "Polymorphic join requires #{base_klass} to declare: " \
29
29
  "has_many :#{name.underscore.pluralize}, as: :#{assoc_name}"
30
30
  end
@@ -54,5 +54,3 @@ module Poly::Joins
54
54
  end
55
55
  end
56
56
  end
57
-
58
- class PolymorphicJoinError < StandardError; end
@@ -40,12 +40,37 @@ module Poly::Migration
40
40
  end
41
41
  end
42
42
 
43
- def poly_resource_index(table, name, unique: false)
44
- add_index table, [:"#{name}_type", :"#{name}_id"], unique: unique
43
+ # Stack columns (golden-child history): the prime marker and the audit edge.
44
+ # Table-builder helper (create_table/change_table):
45
+ # poly_stack t
46
+ # Direct helper (add_column style):
47
+ # poly_stack :statuses
48
+ def poly_stack(table_or_builder, id_type: :string)
49
+ if table_builder?(table_or_builder)
50
+ table_or_builder.boolean :is_prime, null: false, default: false
51
+ table_or_builder.public_send(id_type, :superseded_by_id, null: true)
52
+ else
53
+ add_column table_or_builder, :is_prime, :boolean, null: false, default: false
54
+ add_column table_or_builder, :superseded_by_id, id_type, null: true
55
+ end
56
+ end
57
+
58
+ def poly_resource_index(table, name, unique: false, where: nil, index_name: nil, columns: nil)
59
+ add_index table, columns || [:"#{name}_type", :"#{name}_id"],
60
+ unique: unique, where: where, name: index_name
61
+ end
62
+
63
+ # Partial unique index enforcing exactly one prime per (resource, role).
64
+ def poly_prime_index(table, name = :resource)
65
+ poly_resource_index table, name,
66
+ unique: true, where: 'is_prime',
67
+ index_name: "index_#{table}_prime",
68
+ columns: [:"#{name}_type", :"#{name}_id", :"#{name}_role"]
45
69
  end
46
70
 
47
- def poly_owner_index(table, type_column: :owner_type, id_column: :owner_id, unique: false)
48
- add_index table, [type_column, id_column], unique: unique
71
+ def poly_owner_index(table, type_column: :owner_type, id_column: :owner_id, unique: false, where: nil,
72
+ index_name: nil)
73
+ add_index table, [type_column, id_column], unique: unique, where: where, name: index_name
49
74
  end
50
75
 
51
76
  private
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Poly::PolymorphicJoinError < StandardError; end
4
+
5
+ # Deprecated: use Poly::PolymorphicJoinError. Kept as an alias so existing
6
+ # `rescue PolymorphicJoinError` / `is_a?(PolymorphicJoinError)` callers keep
7
+ # working across the 1.2.0 rename.
8
+ PolymorphicJoinError = Poly::PolymorphicJoinError
data/lib/poly/stack.rb ADDED
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Card-side concern. Turns a polymorphic, role-discriminated model (the "card"
4
+ # model, e.g. Status) into a stack where the most-recently created card per
5
+ # (resource, role) is the current "prime" — the top of the stack. The
6
+ # contract is immutable payload, mutable linkage/index metadata: prior rows
7
+ # are never deleted, but their `is_prime` and `superseded_by_id` columns are
8
+ # mutated in place when a new card supersedes them — this is not a literally
9
+ # append-only/immutable-row stack.
10
+ #
11
+ # class Status < ApplicationRecord
12
+ # belongs_to :resource, polymorphic: true
13
+ # include Poly::Joins
14
+ # include Poly::Stack
15
+ # poly_stack :resource
16
+ # end
17
+ #
18
+ # Poly::Stack builds on Poly::Role (the resource_role discriminator) and adds
19
+ # only `is_prime` (the enforced golden-child marker) and `superseded_by_id`
20
+ # (an unconstrained audit edge). It is deliberately payload agnostic: it does
21
+ # not define the payload column, the actor, or the reason — those belong to the
22
+ # host model. The parent-side accessor macro (`has_stack`) is the consumer's to
23
+ # write, the way Midas writes `has_coin` on Poly::Role — see the README.
24
+ module Poly::Stack
25
+ extend ActiveSupport::Concern
26
+ include Poly::Role
27
+
28
+ included do
29
+ class_attribute :poly_stack_association, instance_accessor: false
30
+ end
31
+
32
+ class_methods do
33
+ # Declares the stack. `assoc_name` is the polymorphic belongs_to whose
34
+ # *_role column discriminates independent stacks on the same table.
35
+ def poly_stack(assoc_name, max_length: 64)
36
+ poly_role(assoc_name, max_length: max_length)
37
+ self.poly_stack_association = assoc_name.to_sym
38
+
39
+ scope :prime, -> { where(is_prime: true) }
40
+
41
+ before_create :poly_stack_seize_prime
42
+ after_create :poly_stack_chain_superseded
43
+ end
44
+
45
+ def poly_stack_columns
46
+ assoc = poly_stack_association
47
+ { type: :"#{assoc}_type", id: :"#{assoc}_id", role: :"#{assoc}_role" }
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ # Demote the current prime (if any) so this card can take its place, and
54
+ # remember it so the supersession edge can be linked once we have an id.
55
+ # Runs before insert: at INSERT time there is exactly one is_prime row, so
56
+ # the partial unique index is satisfied (zero primes momentarily is legal).
57
+ #
58
+ # Concurrency boundary: this demote-then-insert sequence is NOT wrapped in
59
+ # an explicit row lock or transaction. Two concurrent writers racing the
60
+ # same (resource, role) can both read the same prior prime, both demote it
61
+ # via #update_columns, and both attempt to insert with is_prime: true. The
62
+ # second writer's INSERT then raises ActiveRecord::RecordNotUnique — not
63
+ # here in the callback, but afterward, once ActiveRecord issues the actual
64
+ # INSERT. It is the partial unique index (`poly_prime_index` /
65
+ # `index_#{table}_prime`, see lib/poly/migration.rb), not this callback,
66
+ # that actually enforces "at most one prime per (resource, role)" under a
67
+ # race. Poly::Stack does not retry internally; see README's "Poly::Stack"
68
+ # section for the recommended caller-side rescue-and-retry pattern.
69
+ def poly_stack_seize_prime
70
+ cols = self.class.poly_stack_columns
71
+ @poly_stack_superseded = self.class
72
+ .where(cols[:type] => self[cols[:type]],
73
+ cols[:id] => self[cols[:id]],
74
+ cols[:role] => self[cols[:role]])
75
+ .prime
76
+ .first
77
+ @poly_stack_superseded&.update_columns(is_prime: false)
78
+ self.is_prime = true
79
+ end
80
+
81
+ # Audit edge (decoration, unconstrained): the old prime points forward to the
82
+ # card that replaced it. Safe to run after insert since it carries no index.
83
+ def poly_stack_chain_superseded
84
+ @poly_stack_superseded&.update_columns(superseded_by_id: id)
85
+ end
86
+ end
data/lib/poly/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Poly
4
- VERSION = '1.0.0'
4
+ VERSION = '1.2.0'
5
5
  end
data/lib/poly.rb CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  require 'active_support/concern'
4
4
  require 'poly/version'
5
+ require 'poly/polymorphic_join_error'
5
6
  require 'poly/joins'
6
7
  require 'poly/role'
7
8
  require 'poly/owners'
9
+ require 'poly/stack'
8
10
  require 'poly/migration'
9
11
 
10
12
  module Poly
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: poly
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lee Whittaker
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-02-19 00:00:00.000000000 Z
11
+ date: 2026-08-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -38,7 +38,8 @@ dependencies:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: '7.1'
41
- description: Type-safe joins and role identity for polymorphic belongs_to associations.
41
+ description: Type-safe joins, role identity, owner stamping, and append-only golden-child
42
+ history (Poly::Stack) for polymorphic belongs_to associations.
42
43
  email:
43
44
  - lee@whittakertech.com
44
45
  executables: []
@@ -53,15 +54,17 @@ files:
53
54
  - lib/poly/joins.rb
54
55
  - lib/poly/migration.rb
55
56
  - lib/poly/owners.rb
57
+ - lib/poly/polymorphic_join_error.rb
56
58
  - lib/poly/role.rb
59
+ - lib/poly/stack.rb
57
60
  - lib/poly/version.rb
58
- homepage: https://github.com/leewhittaker/poly
61
+ homepage: https://github.com/whittakertech/poly
59
62
  licenses:
60
63
  - MIT
61
64
  metadata:
62
- homepage_uri: https://github.com/leewhittaker/poly
63
- source_code_uri: https://github.com/leewhittaker/poly
64
- changelog_uri: https://github.com/leewhittaker/poly/blob/main/CHANGELOG.md
65
+ homepage_uri: https://github.com/whittakertech/poly
66
+ source_code_uri: https://github.com/whittakertech/poly
67
+ changelog_uri: https://github.com/whittakertech/poly/blob/master/CHANGELOG.md
65
68
  rubygems_mfa_required: 'true'
66
69
  post_install_message:
67
70
  rdoc_options: []