poly 1.0.0 → 1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 56f2e1989d5977256fd93964e07d62d3a26b696bc469baf3ddf477141eb7f383
4
- data.tar.gz: 33239362e770c93e2871754f9697763c893960af1e0238aac59ddfd94a8b7399
3
+ metadata.gz: b553ab920c023d3f1e23a5de23c35bc75798827f6dfe5db2bd9369e0875586b0
4
+ data.tar.gz: 7ce045f76258951d9b3a281f38c369c81b9decc2cbfaca7ce9a73279605ae792
5
5
  SHA512:
6
- metadata.gz: 190707ca692b512d914adcd3b5cfbdfab79a14730ce14043a6c361642ebb6b193941ae75909e24a6405b9f96c23b746b61d9b66daa77255cb0a9241cc3b3be5a
7
- data.tar.gz: 6ae262801aaab5a09416080fb36df87a9ee65918698ab08bc12e1e2b6c612c9d9bee288a396842bc89c6b165e6515d8b50f84009f5670b5111a2c1ff2be1735e
6
+ metadata.gz: 7242fafe809c07f8b801ed5fd049519ffc7d403dd8254e1323fa7d794dffa549dd8c5070317a1ff6f2dd8f185b603baef121c2ab6eccced5dfd9e542dad006b3
7
+ data.tar.gz: 519f7047228088054991bd4288eb52f1e39b98839976dfaadb1c68b3f46a0b0d97c7f1c088543be9f58838978e989b048d622bef4a768e4df39a9733dee83098
data/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.1.0] - 2026-07-07
11
+
12
+ ### Added
13
+
14
+ - `Poly::Stack` — polymorphic, role-discriminated append-only history with a single
15
+ "prime" (golden-child) card per `(resource, role)`, enforced by a database-level
16
+ partial unique index. Payload-agnostic: manages only the prime marker
17
+ (`is_prime`) and audit edge (`superseded_by_id`); the payload column, actor, and
18
+ reason belong to the consuming model.
19
+ - `where:` option on `poly_resource_index` and `poly_owner_index` migration
20
+ helpers — passes a partial-index condition through to `add_index`.
21
+
22
+ ### Changed
23
+
24
+ - `poly_prime_index` is now implemented as sugar on top of `poly_resource_index`
25
+ (`where: 'is_prime'`) instead of duplicating its own `add_index` call. The
26
+ generated index name (`index_<table>_prime`) and indexed columns are
27
+ unchanged, so this is not a breaking change for existing schemas.
28
+
10
29
  ## [1.0.0] - 2026-02-18
11
30
 
12
31
  ### Added
@@ -51,7 +70,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
51
70
  Creates methods like `joins_commentable(ClassName)` that validate the reverse
52
71
  `has_many`/`has_one` association before building the join SQL.
53
72
 
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
73
+ [Unreleased]: https://github.com/whittakertech/poly/compare/v1.1.0...HEAD
74
+ [1.1.0]: https://github.com/whittakertech/poly/compare/v1.0.0...v1.1.0
75
+ [1.0.0]: https://github.com/whittakertech/poly/compare/v0.2.0...v1.0.0
76
+ [0.2.0]: https://github.com/whittakertech/poly/compare/v0.1.0...v0.2.0
77
+ [0.1.0]: https://github.com/whittakertech/poly/releases/tag/v0.1.0
data/README.md CHANGED
@@ -1,73 +1,170 @@
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"
49
+ ```
50
+
51
+ Then:
52
+
53
+ ```bash
54
+ bundle install
11
55
  ```
12
56
 
13
- Then run `bundle install`.
57
+ ---
14
58
 
15
59
  ## Requirements
16
60
 
17
61
  - Ruby >= 3.2
18
62
  - ActiveRecord >= 7.1
19
63
 
20
- ## Usage
64
+ ---
21
65
 
22
- ### Poly::Joins
66
+ # Quickstart
23
67
 
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.
68
+ ### Migration
25
69
 
26
70
  ```ruby
27
- class Comment < ApplicationRecord
28
- belongs_to :commentable, polymorphic: true
71
+ class CreateItems < ActiveRecord::Migration[7.1]
72
+ include Poly::Migration
29
73
 
30
- include Poly::Joins
74
+ def change
75
+ create_table :items do |t|
76
+ poly_resource t, :itemable, null: false
77
+ poly_role t, :itemable, null: false
78
+ poly_owner t, null: false
79
+ t.timestamps
80
+ end
81
+
82
+ poly_resource_index :items, :itemable
83
+ poly_owner_index :items
84
+ end
31
85
  end
86
+ ```
32
87
 
33
- class Post < ApplicationRecord
34
- has_many :comments, as: :commentable
88
+ ---
89
+
90
+ ### Model
91
+
92
+ ```ruby
93
+ class Item < ApplicationRecord
94
+ belongs_to :itemable, polymorphic: true
95
+
96
+ include Poly::Role
97
+ include Poly::Owners
98
+
99
+ poly_role :itemable
100
+ poly_owner :itemable, owner: -> { account }
35
101
  end
102
+ ```
103
+
104
+ ---
105
+
106
+ # Modules
107
+
108
+ ---
109
+
110
+ # 1. Poly::Joins
111
+
112
+ Generates type-safe `INNER JOIN` methods for polymorphic associations.
113
+
114
+ ### Example
36
115
 
37
- class User < ApplicationRecord
38
- has_many :comments, as: :commentable
116
+ ```ruby
117
+ class Comment < ApplicationRecord
118
+ belongs_to :commentable, polymorphic: true
119
+ include Poly::Joins
39
120
  end
40
121
  ```
41
122
 
42
- Now you can join through the polymorphic association by passing the target class:
123
+ Now:
43
124
 
44
125
  ```ruby
45
- # Join comments to the posts table
46
126
  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'
127
+ Comment.joins_commentable(User)
128
+ ```
51
129
 
52
- # Chainable with other scopes
53
- Comment.joins_commentable(Post).where(posts: { title: 'Hello' })
130
+ Generated SQL:
54
131
 
55
- # Join to a different target type
56
- Comment.joins_commentable(User).where(users: { name: 'Lee' })
132
+ ```sql
133
+ INNER JOIN "posts"
134
+ ON "comments"."commentable_id" = "posts"."id"
135
+ AND "comments"."commentable_type" = 'Post'
57
136
  ```
58
137
 
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
138
+ > [!IMPORTANT]
139
+ > The target class must declare the reverse association:
140
+ >
141
+ > ```ruby
142
+ > has_many :comments, as: :commentable
143
+ > # has_one :comment, as: :commentable — also valid
144
+ > ```
145
+ >
146
+ > Otherwise `PolymorphicJoinError` is raised.
147
+
148
+ ### Join Flow
149
+
150
+ ```mermaid
151
+ sequenceDiagram
152
+ participant M as Model
153
+ participant PJ as Poly::Joins
154
+ participant T as Target
155
+
156
+ M->>PJ: joins_commentable(Post)
157
+ PJ->>T: Validate reverse association
158
+ PJ->>M: Generate INNER JOIN
64
159
  ```
65
160
 
66
- ### Poly::Role
161
+ ---
162
+
163
+ # 2. Poly::Role
67
164
 
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.
165
+ Adds semantic identity to polymorphic relationships.
69
166
 
70
- Your table needs a `<association>_role` string column:
167
+ ## Schema
71
168
 
72
169
  ```ruby
73
170
  create_table :taggings do |t|
@@ -76,11 +173,12 @@ create_table :taggings do |t|
76
173
  t.timestamps
77
174
  end
78
175
 
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
176
+ add_index :taggings,
177
+ [:taggable_type, :taggable_id, :taggable_role],
178
+ unique: true
81
179
  ```
82
180
 
83
- Then include the module and declare the role-enabled association:
181
+ ## Model
84
182
 
85
183
  ```ruby
86
184
  class Tagging < ApplicationRecord
@@ -89,80 +187,110 @@ class Tagging < ApplicationRecord
89
187
  include Poly::Role
90
188
 
91
189
  poly_role :taggable
92
- # optionally:
93
- # poly_role :taggable, max_length: 128
94
190
  # poly_role :taggable, immutable: true
95
191
  end
96
192
  ```
97
193
 
98
- This gives you:
194
+ ## What You Get
99
195
 
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
196
+ - Normalization (`strip + downcase`)
197
+ - Format validation (`/\A[a-z0-9_]+\z/`)
198
+ - Length validation (`max_length:`, default `64`)
199
+ - `for_role` scope
200
+ - Optional immutability
104
201
 
105
202
  ```ruby
106
- tagging = Tagging.new(taggable: post, taggable_role: ' Primary ')
107
- tagging.valid?
108
- tagging.taggable_role # => "primary"
203
+ Tagging.for_role(" PRIMARY ")
204
+ # => matches "primary"
205
+ ```
206
+
207
+ > [!NOTE]
208
+ > `immutable: true` prevents role changes after create.
209
+
210
+ ### Role Identity Model
109
211
 
110
- Tagging.for_role(' PRIMARY ')
111
- # => normalizes to 'primary' before querying
212
+ ```mermaid
213
+ flowchart TD
214
+ A[Polymorphic Edge]
215
+ B[Role Column]
216
+ C[Semantic Meaning]
217
+
218
+ A --> B
219
+ B --> C
112
220
  ```
113
221
 
114
- ### Poly::Owners
222
+ ---
223
+
224
+ # 3. Poly::Owners
115
225
 
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.
226
+ Stamps root ownership at write time.
117
227
 
118
- Your table needs `owner_type` and `owner_id` columns (in addition to your polymorphic resource columns):
228
+ No traversal.
229
+ No tenancy logic.
230
+ Just identity projection.
231
+
232
+ ## Schema
119
233
 
120
234
  ```ruby
121
235
  create_table :coins do |t|
122
- t.references :ledger, null: false
123
236
  t.references :resource, polymorphic: true, null: false
124
- t.string :resource_role, null: false
125
- t.string :owner_type
126
- t.integer :owner_id
237
+ t.string :owner_type
238
+ t.string :owner_id
127
239
  t.timestamps
128
240
  end
129
241
 
130
- # Index: always composite — never index owner_type and owner_id separately
131
242
  add_index :coins, [:owner_type, :owner_id]
132
243
  ```
133
244
 
134
- Then declare how the owner should be resolved:
245
+ > [!WARNING]
246
+ > Never index `owner_type` and `owner_id` separately. Always composite.
247
+
248
+ ## Model
135
249
 
136
250
  ```ruby
137
251
  class Coin < ApplicationRecord
138
- belongs_to :ledger
139
252
  belongs_to :resource, polymorphic: true
140
253
 
141
254
  include Poly::Owners
142
255
 
143
256
  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
257
  end
148
258
  ```
149
259
 
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.
260
+ ## Owner Resolution Options
151
261
 
152
- **Options:**
262
+ | Option | Default | Description |
263
+ |----------------|---------------|------------------------------------|
264
+ | `type_column:` | `:owner_type` | Column storing class name |
265
+ | `id_column:` | `:owner_id` | Column storing owner ID |
266
+ | `allow_nil:` | `true` | Allow owner to resolve to nil; if `false`, raise instead |
267
+ | `immutable:` | `false` | Prevent owner changes after create |
153
268
 
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 |
269
+ > [!IMPORTANT]
270
+ > Owner must resolve to a persisted `ActiveRecord::Base`.
271
+ >
272
+ > Otherwise an `ArgumentError` is raised.
160
273
 
161
- ### Poly::Migration
274
+ ### Ownership Flow
162
275
 
163
- Adds migration helpers so polymorphic resource/role/owner columns are declared consistently.
276
+ ```mermaid
277
+ sequenceDiagram
278
+ participant Record
279
+ participant PO as Poly::Owners
280
+ participant Owner
281
+
282
+ Record->>PO: before_validation
283
+ PO->>Owner: resolve owner
284
+ PO->>Record: stamp owner_type + owner_id
285
+ ```
164
286
 
165
- Use it in your migration base class:
287
+ ---
288
+
289
+ # 4. Poly::Migration
290
+
291
+ Migration helpers for consistent polymorphic topology.
292
+
293
+ ## Usage
166
294
 
167
295
  ```ruby
168
296
  class ApplicationMigration < ActiveRecord::Migration[7.1]
@@ -170,65 +298,238 @@ class ApplicationMigration < ActiveRecord::Migration[7.1]
170
298
  end
171
299
  ```
172
300
 
173
- Supported styles:
301
+ Supports:
302
+
303
+ - `create_table`
304
+ - `change_table`
305
+ - direct `add_column` style
306
+
307
+ ## Helpers
308
+
309
+ | Helper | Purpose |
310
+ |--------|----------|
311
+ | `poly_resource` | Adds `<name>_type` + `<name>_id` |
312
+ | `poly_role` | Adds `<name>_role` |
313
+ | `poly_owner` | Adds owner columns |
314
+ | `poly_stack` | Adds `is_prime` + `superseded_by_id` |
315
+ | `poly_resource_index` | Composite resource index (supports `where:`, `unique:`, `index_name:`) |
316
+ | `poly_owner_index` | Composite owner index (supports `where:`, `unique:`, `index_name:`) |
317
+ | `poly_prime_index` | Partial unique index (one prime per resource/role); sugar for `poly_resource_index(..., where: 'is_prime')` |
318
+
319
+ ## ID Flexibility
320
+
321
+ `id_type` defaults to `:string`.
322
+
323
+ Supports:
324
+
325
+ - UUID
326
+ - ULID
327
+ - bigint
328
+ - custom identifiers
329
+
330
+ ---
331
+
332
+ # 5. Poly::Stack
333
+
334
+ A polymorphic, role-discriminated **history** where one entry is the current
335
+ **prime** — the top of an append-only stack. `Poly::Role` is
336
+ the same idea at cardinality 1; `Poly::Stack` opens it up to many entries per
337
+ `(resource, role)`, with the most-recently created always prime.
338
+
339
+ It is **append-only** and **payload agnostic**: it manages the prime marker and
340
+ an audit edge only. The payload column, the actor, and the reason belong to your
341
+ model — Poly::Stack never reads them.
342
+
343
+ ## Schema
344
+
345
+ ```ruby
346
+ create_table :statuses do |t|
347
+ t.references :resource, polymorphic: true, null: false
348
+ t.string :resource_role, null: false
349
+ t.string :state, null: false # your payload — Poly::Stack is agnostic about it
350
+ t.boolean :is_prime, null: false, default: false
351
+ t.integer :superseded_by_id # audit edge (unconstrained)
352
+ t.timestamps
353
+ end
354
+
355
+ # Exactly one prime per resource per role, enforced by the database.
356
+ add_index :statuses, %i[resource_type resource_id resource_role],
357
+ unique: true, where: 'is_prime', name: 'index_statuses_prime'
358
+ ```
359
+
360
+ Or with `Poly::Migration`:
174
361
 
175
- - `create_table` / `change_table` via a table builder (`t`)
176
- - direct existing-table operations via `add_column` style (pass table name)
362
+ ```ruby
363
+ create_table :statuses do |t|
364
+ poly_resource t, :resource, null: false
365
+ poly_role t, :resource, null: false
366
+ t.string :state, null: false
367
+ poly_stack t
368
+ t.timestamps
369
+ end
370
+
371
+ poly_prime_index :statuses, :resource
372
+ ```
177
373
 
178
- #### Create Table / Change Table
374
+ ## The Entry Model
375
+
376
+ `Poly::Stack` is an **entry-side** concern — the externality includes it, exactly
377
+ as `Coin` includes `Poly::Role`:
179
378
 
180
379
  ```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
380
+ class Status < ApplicationRecord
381
+ belongs_to :resource, polymorphic: true
189
382
 
190
- poly_resource_index :coins, :resource
191
- poly_owner_index :coins
192
- end
383
+ include Poly::Joins
384
+ include Poly::Stack
385
+
386
+ poly_stack :resource
193
387
  end
194
388
  ```
195
389
 
196
- #### Existing Table (add_column style)
390
+ That gives the `prime` scope and append-only priming — the newest entry per
391
+ `(resource, role)` becomes prime, the prior prime is demoted, and its
392
+ `superseded_by_id` is linked:
197
393
 
198
394
  ```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
395
+ Status.create!(resource: post, resource_role: 'status', state: 'draft')
396
+ Status.create!(resource: post, resource_role: 'status', state: 'public')
397
+
398
+ Status.where(resource: post, resource_role: 'status').prime # => the 'public' entry
399
+ ```
400
+
401
+ ## Wiring a Parent
204
402
 
205
- poly_resource_index :coins, :resource
206
- poly_owner_index :coins
403
+ Poly ships **only the entry concern**. The parent-side accessor macro is yours to
404
+ write — the way Midas writes `has_coin` on `Poly::Role`. It's just `for_role`
405
+ (from `Poly::Role`) plus the `prime` scope, composed into associations and
406
+ scopes:
407
+
408
+ ```ruby
409
+ # in your app — a Stackable concern providing a has_stack macro
410
+ module Stackable
411
+ extend ActiveSupport::Concern
412
+
413
+ class_methods do
414
+ def has_stack(name, class_name: name.to_s.classify, value: :state, dependent: :destroy)
415
+ label = name.to_s
416
+ plural = label.pluralize.to_sym
417
+ stamp = :"stack_stamp_#{name}"
418
+
419
+ # so `post.statuses << Status.new(...)` stamps the role on add
420
+ define_method(stamp) do |entry|
421
+ entry.resource_role = label if entry.respond_to?(:resource_role=) && entry.resource_role.blank?
422
+ end
423
+
424
+ has_many plural, -> { for_role(label).order(created_at: :desc) },
425
+ as: :resource, class_name: class_name, dependent: dependent, before_add: stamp
426
+ has_one name, -> { for_role(label).prime },
427
+ as: :resource, class_name: class_name
428
+
429
+ entries = -> { class_name.constantize }
430
+ scope :"where_#{name}", ->(*v) { joins(name).merge(entries.call.where(value => v.flatten)) }
431
+ scope :"ever_#{name}", ->(*v) { where(id: joins(plural).merge(entries.call.where(value => v.flatten)).select(:id)) }
432
+ scope :"without_#{name}", ->(*v) { where.not(id: public_send(:"where_#{name}", *v).select(:id)) }
433
+ scope :"never_#{name}", ->(*v) { where.not(id: public_send(:"ever_#{name}", *v).select(:id)) }
434
+ end
207
435
  end
208
436
  end
437
+
438
+ class Post < ApplicationRecord
439
+ include Stackable
440
+
441
+ has_stack :status
442
+ has_stack :visibility, class_name: 'Status' # second stack, same table, own prime
443
+ end
209
444
  ```
210
445
 
211
- #### Helper Reference
446
+ Which gives you:
212
447
 
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 |
448
+ ```ruby
449
+ post.status # => prime Status entry (preloadable has_one)
450
+ post.statuses # => full stack, newest-first (has_many)
451
+
452
+ post.statuses.create!(state: 'trash') # append an entry; it becomes prime
453
+ post.statuses << Status.new(state: 'public') # also appends; role stamped on add
454
+
455
+ Post.where_status('public') # prime state = public
456
+ Post.without_status('trash') # prime != trash, OR no entry yet
457
+ Post.ever_status('trash') # any entry in history = trash (de-duped)
458
+ Post.never_status('trash') # no entry ever = trash
459
+
460
+ Post.includes(:status) # preload the prime to avoid N+1
461
+ ```
462
+
463
+ ## Soft-Delete
464
+
465
+ `Poly::Stack` is the primitive behind soft-delete-as-history: a `trash` entry is a
466
+ deletion, a later entry is a restore, and the stack is the audit trail. The
467
+ *meaning* stays in your app — one-liners over your `has_stack` scopes:
468
+
469
+ ```ruby
470
+ scope :live, -> { without_status(:trash) }
471
+ scope :trashed, -> { where_status(:trash) }
472
+ ```
473
+
474
+ > [!NOTE]
475
+ > Prefer explicit scopes over `default_scope` for soft-delete.
476
+
477
+ ## Priming Flow
478
+
479
+ ```mermaid
480
+ sequenceDiagram
481
+ participant Entry as New Entry
482
+ participant PS as Poly::Stack
483
+ participant Prior as Prior Prime
484
+
485
+ Entry->>PS: before_create
486
+ PS->>Prior: demote (is_prime = false)
487
+ PS->>Entry: claim prime (is_prime = true)
488
+ Entry->>PS: after_create
489
+ PS->>Prior: link superseded_by_id
490
+ ```
491
+
492
+ ---
493
+
494
+ # Design Principles
495
+
496
+ Poly is intentionally minimal.
220
497
 
221
- `id_type` defaults to `:string` so owner/resource IDs can store bigint, UUID, ULID, or other identifier formats consistently.
498
+ It does not:
222
499
 
223
- ## Development
500
+ - Implement tenancy
501
+ - Infer ownership
502
+ - Traverse associations
503
+ - Inject business logic
504
+ - Generate constraints automatically
505
+ - Enforce policy
506
+
507
+ It provides structure only.
508
+
509
+ ---
510
+
511
+ # Development
224
512
 
225
513
  ```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
514
+ bundle install
515
+ bundle exec rspec
516
+ bundle exec rubocop
517
+ COVERAGE=true bundle exec rspec
230
518
  ```
231
519
 
232
- ## License
520
+ ---
521
+
522
+ # Stability
523
+
524
+ Poly v1.0.0 declares:
525
+
526
+ - Public API is stable
527
+ - Breaking changes follow SemVer
528
+ - New features are additive
529
+ - No structural refactors planned
530
+
531
+ ---
532
+
533
+ # License
233
534
 
234
- Released under the [MIT License](https://opensource.org/licenses/MIT).
535
+ MIT see [LICENSE](LICENSE)
data/lib/poly/joins.rb CHANGED
@@ -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,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ class PolymorphicJoinError < StandardError; end
data/lib/poly/stack.rb ADDED
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Card-side concern. Turns a polymorphic, role-discriminated model (the "card"
4
+ # model, e.g. Status) into an append-only stack where the most-recently created
5
+ # card per (resource, role) is the current "prime" — the top of the stack.
6
+ #
7
+ # class Status < ApplicationRecord
8
+ # belongs_to :resource, polymorphic: true
9
+ # include Poly::Joins
10
+ # include Poly::Stack
11
+ # poly_stack :resource
12
+ # end
13
+ #
14
+ # Poly::Stack builds on Poly::Role (the resource_role discriminator) and adds
15
+ # only `is_prime` (the enforced golden-child marker) and `superseded_by_id`
16
+ # (an unconstrained audit edge). It is deliberately payload agnostic: it does
17
+ # not define the payload column, the actor, or the reason — those belong to the
18
+ # host model. The parent-side accessor macro (`has_stack`) is the consumer's to
19
+ # write, the way Midas writes `has_coin` on Poly::Role — see the README.
20
+ module Poly::Stack
21
+ extend ActiveSupport::Concern
22
+ include Poly::Role
23
+
24
+ included do
25
+ class_attribute :poly_stack_association, instance_accessor: false
26
+ end
27
+
28
+ class_methods do
29
+ # Declares the stack. `assoc_name` is the polymorphic belongs_to whose
30
+ # *_role column discriminates independent stacks on the same table.
31
+ def poly_stack(assoc_name, max_length: 64)
32
+ poly_role(assoc_name, max_length: max_length)
33
+ self.poly_stack_association = assoc_name.to_sym
34
+
35
+ scope :prime, -> { where(is_prime: true) }
36
+
37
+ before_create :poly_stack_seize_prime
38
+ after_create :poly_stack_chain_superseded
39
+ end
40
+
41
+ def poly_stack_columns
42
+ assoc = poly_stack_association
43
+ { type: :"#{assoc}_type", id: :"#{assoc}_id", role: :"#{assoc}_role" }
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ # Demote the current prime (if any) so this card can take its place, and
50
+ # remember it so the supersession edge can be linked once we have an id.
51
+ # Runs before insert: at INSERT time there is exactly one is_prime row, so
52
+ # the partial unique index is satisfied (zero primes momentarily is legal).
53
+ def poly_stack_seize_prime
54
+ cols = self.class.poly_stack_columns
55
+ @poly_stack_superseded = self.class
56
+ .where(cols[:type] => self[cols[:type]],
57
+ cols[:id] => self[cols[:id]],
58
+ cols[:role] => self[cols[:role]])
59
+ .prime
60
+ .first
61
+ @poly_stack_superseded&.update_columns(is_prime: false)
62
+ self.is_prime = true
63
+ end
64
+
65
+ # Audit edge (decoration, unconstrained): the old prime points forward to the
66
+ # card that replaced it. Safe to run after insert since it carries no index.
67
+ def poly_stack_chain_superseded
68
+ @poly_stack_superseded&.update_columns(superseded_by_id: id)
69
+ end
70
+ 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.1.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.1.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-07-07 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: []