poly 1.1.0 → 1.3.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: b553ab920c023d3f1e23a5de23c35bc75798827f6dfe5db2bd9369e0875586b0
4
- data.tar.gz: 7ce045f76258951d9b3a281f38c369c81b9decc2cbfaca7ce9a73279605ae792
3
+ metadata.gz: '07925273fda2c3e5292b1f56d776f448d7542c1c28bc5f210e6738e2d682fa39'
4
+ data.tar.gz: a5797ce40826d0f0ee5f3a9835e38ebaae0e85219e6b7c0877effac315640d0b
5
5
  SHA512:
6
- metadata.gz: 7242fafe809c07f8b801ed5fd049519ffc7d403dd8254e1323fa7d794dffa549dd8c5070317a1ff6f2dd8f185b603baef121c2ab6eccced5dfd9e542dad006b3
7
- data.tar.gz: 519f7047228088054991bd4288eb52f1e39b98839976dfaadb1c68b3f46a0b0d97c7f1c088543be9f58838978e989b048d622bef4a768e4df39a9733dee83098
6
+ metadata.gz: '01933f6479724033886a320905e2149d8487d3a3dcee193550cebe7b8b9b38fa38a4b477b9c92ba2fd455d83087dccec3a70b664fed5e603489d91e48ef33114'
7
+ data.tar.gz: cade850271da454356ecce2ac07e316ccf5c4b36b6f03f164827c27f2af986abc4c4804b9198115cbf38502111bd32fef8e08f3eec8cbcbfb8e7323bb87d1577
data/CHANGELOG.md CHANGED
@@ -5,7 +5,75 @@ 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.3.0] - 2026-08-22
9
+
10
+ ### Changed
11
+
12
+ - **Lowered the supported ActiveRecord/ActiveSupport floor from `>= 7.1` to
13
+ `>= 6.1`.** No library code changed: a full scan of `lib/poly/*.rb` found no
14
+ Rails 7.1+ APIs, and the only ActiveRecord internals Poly touches
15
+ (`arel_table`, `reflect_on_all_associations`, `reflect_on_association`,
16
+ `base_class`) have been stable since well before 6.1. The previous floor was
17
+ an end-of-life policy choice, not a technical constraint. Lowered so
18
+ hellodancerrails (Rails 6.1.7.10 / Ruby 3.3.11) can adopt Midas, which
19
+ depends on Poly.
20
+ - CI gains a `6.1` cell on both the SQLite and PostgreSQL lanes, pinned to Ruby
21
+ 3.3 -- the pairing hellodancerrails actually runs. 6.1 predates Ruby 3.4
22
+ entirely, so that combination is excluded.
23
+
24
+ ### Fixed
25
+
26
+ - The PostgreSQL CI lane's `ruby/setup-ruby` step in `.gitea/workflows/ci.yml`
27
+ hardcoded `ruby-version: '3.4'` instead of reading `${{ matrix.ruby }}`, so
28
+ the matrix's Ruby axis was ignored on that lane.
29
+ - `spec/models/poly/migration_spec.rb` hardcoded
30
+ `ActiveRecord::Migration[7.1]`, which raises `Unknown migration version` on
31
+ Rails 6.1. Test migrations now build against `POLY_MIGRATION_VERSION`,
32
+ derived from the ActiveRecord actually under test.
33
+ - Shortened the `where:`-passthrough spec's table name, whose generated index
34
+ name (`index_poly_migration_where_indexes_on_resource_type_and_resource_id`,
35
+ 67 chars) exceeded Rails 6.1's SQLite 64-character limit -- and PostgreSQL's
36
+ 63-character limit on every version.
37
+
38
+ ### Development
39
+
40
+ - The `6.1` bundle lane pins `concurrent-ruby < 1.3.5` (1.3.5 dropped the
41
+ implicit `require 'logger'` that ActiveSupport 6.1 relies on) and
42
+ `sqlite3 ~> 1.4` (Rails 6.1's SQLite3Adapter requires it; sqlite3 2.x needs
43
+ Rails 7.2+). Both are Gemfile-only -- neither is a runtime requirement of the
44
+ gem.
45
+
46
+ ## [1.2.0] - 2026-08-16
47
+
48
+ ### Added
49
+
50
+ - `Poly::PolymorphicJoinError` (`lib/poly/polymorphic_join_error.rb`) — the
51
+ join-validation error raised from `lib/poly/joins.rb` is now namespaced
52
+ under `Poly`. The bare top-level `PolymorphicJoinError` constant remains as
53
+ a deprecated alias, so existing `rescue PolymorphicJoinError` call sites
54
+ keep working.
55
+ - CI now covers a database axis (SQLite and PostgreSQL, the latter via a GitHub
56
+ Actions `services:` Postgres container) and an ActiveRecord/Rails version axis
57
+ (7.1, 7.2, 8.x), each matrix cell pinning the loaded AR version via the new
58
+ `ACTIVERECORD_VERSION` env var consumed by the `Gemfile`.
59
+ - `spec/spec_helper.rb`'s database adapter is now parameterized via
60
+ `POLY_TEST_ADAPTER` (`sqlite3` default, or `postgresql`) instead of
61
+ hardcoding an in-memory SQLite connection.
62
+ - README "Supported Databases" section documenting that MySQL is explicitly
63
+ unsupported (`AbstractMysqlAdapter` doesn't implement `supports_partial_index?`,
64
+ silently degrading `poly_prime_index` to a full-table unique index).
65
+ - README and new specs (`spec/models/poly/stack_spec.rb`) documenting
66
+ `Poly::Stack`'s concurrency boundary — the `poly_stack_seize_prime`
67
+ demote-then-insert sequence in `lib/poly/stack.rb`, and the
68
+ `ActiveRecord::RecordNotUnique` failure mode it can hit under concurrent
69
+ writers. No new public API was added.
70
+
71
+ ### Fixed
72
+
73
+ - README §5 ("Poly::Stack")'s "append-only" wording corrected: the contract
74
+ is immutable payload with mutable linkage/index metadata (`is_prime`,
75
+ `superseded_by_id` are mutated in place on supersession), not literally
76
+ immutable/append-only rows.
9
77
 
10
78
  ## [1.1.0] - 2026-07-07
11
79
 
@@ -70,7 +138,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
70
138
  Creates methods like `joins_commentable(ClassName)` that validate the reverse
71
139
  `has_many`/`has_one` association before building the join SQL.
72
140
 
73
- [Unreleased]: https://github.com/whittakertech/poly/compare/v1.1.0...HEAD
141
+ [1.2.0]: https://github.com/whittakertech/poly/compare/v1.1.0...v1.2.0
74
142
  [1.1.0]: https://github.com/whittakertech/poly/compare/v1.0.0...v1.1.0
75
143
  [1.0.0]: https://github.com/whittakertech/poly/compare/v0.2.0...v1.0.0
76
144
  [0.2.0]: https://github.com/whittakertech/poly/compare/v0.1.0...v0.2.0
data/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Poly
2
2
 
3
+ [![Gem Version](https://img.shields.io/gem/v/poly)](https://rubygems.org/gems/poly)
4
+ [![CI](https://github.com/whittakertech/poly/actions/workflows/ci.yml/badge.svg)](https://github.com/whittakertech/poly/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/whittakertech/poly/blob/master/LICENSE)
6
+ [![Docs](https://img.shields.io/badge/docs-poly.whittakertech.com-blue)](https://poly.whittakertech.com)
7
+
3
8
  Type-safe joins, role identity, owner identity, and migration discipline for polymorphic `belongs_to` associations in Rails.
4
9
 
5
10
  ---
@@ -59,7 +64,35 @@ bundle install
59
64
  ## Requirements
60
65
 
61
66
  - Ruby >= 3.2
62
- - ActiveRecord >= 7.1
67
+ - ActiveRecord >= 6.1
68
+
69
+ Rails 6.1 is supported for legacy consumers and is exercised in CI on Ruby 3.3
70
+ only (6.1 predates Ruby 3.4). On 6.1 the SQLite3 adapter enforces a 64-character
71
+ index-name limit, so long table names may need an explicit `index_name:` on the
72
+ `poly_*_index` helpers -- PostgreSQL applies a 63-character limit on every
73
+ version, so this is good practice regardless.
74
+
75
+ ---
76
+
77
+ ## Supported Databases
78
+
79
+ Poly is tested in CI against **SQLite** and **PostgreSQL** (both Ruby x
80
+ ActiveRecord 6.1/7.1/7.2/8.x combinations -- see `.github/workflows/ci.yml`).
81
+
82
+ **MySQL is explicitly not supported.** `Poly::Migration#poly_prime_index`
83
+ (see below) relies on a partial/conditional unique index --
84
+ `add_index table, [...], unique: true, where: 'is_prime'` -- to enforce
85
+ "exactly one prime row per resource+role, many non-primes allowed."
86
+ PostgreSQL's and SQLite3's ActiveRecord adapters both override
87
+ `supports_partial_index?` to `true`; `ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter`
88
+ does **not** override it, so it inherits the abstract default of `false`.
89
+ Because `schema_creation.rb` only emits the index's `WHERE` clause when
90
+ `supports_partial_index?` is true, MySQL silently drops the clause instead
91
+ of raising -- producing a full-table unique index instead of a partial one,
92
+ and quietly breaking the single-prime-per-role invariant (it would instead
93
+ forbid more than one row total per resource+role). This is a silent
94
+ correctness bug, not just reduced support, so running Poly against MySQL is
95
+ unsupported rather than merely uncautioned-against.
63
96
 
64
97
  ---
65
98
 
@@ -143,7 +176,7 @@ AND "comments"."commentable_type" = 'Post'
143
176
  > # has_one :comment, as: :commentable — also valid
144
177
  > ```
145
178
  >
146
- > Otherwise `PolymorphicJoinError` is raised.
179
+ > Otherwise `Poly::PolymorphicJoinError` is raised.
147
180
 
148
181
  ### Join Flow
149
182
 
@@ -332,13 +365,17 @@ Supports:
332
365
  # 5. Poly::Stack
333
366
 
334
367
  A polymorphic, role-discriminated **history** where one entry is the current
335
- **prime** — the top of an append-only stack. `Poly::Role` is
368
+ **prime** — the top of the stack. `Poly::Role` is
336
369
  the same idea at cardinality 1; `Poly::Stack` opens it up to many entries per
337
370
  `(resource, role)`, with the most-recently created always prime.
338
371
 
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.
372
+ It is **payload agnostic** with an **immutable payload, mutable linkage/index
373
+ metadata** contract: it manages the prime marker and an audit edge only. The
374
+ payload column, the actor, and the reason belong to your model — Poly::Stack
375
+ never reads or rewrites them. What Poly::Stack *does* mutate in place, on
376
+ supersession, are the prior prime row's own `is_prime` and `superseded_by_id`
377
+ columns — historical rows are never deleted, but their linkage/index metadata
378
+ is updated, so this is not a literally append-only/immutable-row contract.
342
379
 
343
380
  ## Schema
344
381
 
@@ -474,6 +511,36 @@ scope :trashed, -> { where_status(:trash) }
474
511
  > [!NOTE]
475
512
  > Prefer explicit scopes over `default_scope` for soft-delete.
476
513
 
514
+ ## Concurrency Boundary
515
+
516
+ `poly_stack_seize_prime` (the `before_create` callback that demotes the prior
517
+ prime and claims the new one) is **not** wrapped in an explicit row lock or
518
+ transaction. Two writers racing the same `(resource, role)` at the same time
519
+ can both read the same prior prime, both demote it, and both attempt to
520
+ insert with `is_prime: true`.
521
+
522
+ When that happens, it is the partial unique index (`add_index ..., unique:
523
+ true, where: 'is_prime'`, built by `poly_prime_index`) — not the
524
+ callback — that enforces "at most one prime per `(resource, role)`". The
525
+ losing writer's `INSERT` raises `ActiveRecord::RecordNotUnique`, at the
526
+ `INSERT` itself, after the callback has already run.
527
+
528
+ Poly::Stack does not catch or retry this internally. **Callers that may write
529
+ concurrently to the same `(resource, role)` should be prepared to rescue
530
+ `ActiveRecord::RecordNotUnique` around the create call and retry** — e.g.
531
+ re-fetch the current prime and re-attempt the create — rather than assuming a
532
+ single `create!` is race-safe:
533
+
534
+ ```ruby
535
+ begin
536
+ post.statuses.create!(state: 'public')
537
+ rescue ActiveRecord::RecordNotUnique
538
+ # another writer won the race for this (resource, role); re-fetch and
539
+ # decide whether to retry, merge, or surface a conflict to the caller.
540
+ retry_or_handle_conflict
541
+ end
542
+ ```
543
+
477
544
  ## Priming Flow
478
545
 
479
546
  ```mermaid
data/Rakefile CHANGED
@@ -6,4 +6,9 @@ require 'bundler/gem_tasks'
6
6
  require 'rspec/core/rake_task'
7
7
  RSpec::Core::RakeTask.new(:spec)
8
8
 
9
+ require 'yard'
10
+ namespace :docs do
11
+ YARD::Rake::YardocTask.new(:api)
12
+ end
13
+
9
14
  task default: :spec
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
@@ -1,3 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class PolymorphicJoinError < StandardError; end
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 CHANGED
@@ -1,8 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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.
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.
6
10
  #
7
11
  # class Status < ApplicationRecord
8
12
  # belongs_to :resource, polymorphic: true
@@ -50,6 +54,18 @@ module Poly::Stack
50
54
  # remember it so the supersession edge can be linked once we have an id.
51
55
  # Runs before insert: at INSERT time there is exactly one is_prime row, so
52
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.
53
69
  def poly_stack_seize_prime
54
70
  cols = self.class.poly_stack_columns
55
71
  @poly_stack_superseded = self.class
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.1.0'
4
+ VERSION = '1.3.0'
5
5
  end
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.1.0
4
+ version: 1.3.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-07-07 00:00:00.000000000 Z
11
+ date: 2026-08-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -16,28 +16,28 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '7.1'
19
+ version: '6.1'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: '7.1'
26
+ version: '6.1'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: activesupport
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - ">="
32
32
  - !ruby/object:Gem::Version
33
- version: '7.1'
33
+ version: '6.1'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
- version: '7.1'
40
+ version: '6.1'
41
41
  description: Type-safe joins, role identity, owner stamping, and append-only golden-child
42
42
  history (Poly::Stack) for polymorphic belongs_to associations.
43
43
  email: