You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat: track association membership across sources - #16
Stacked on #15. Association tracking is experimental; the README says so.
Changes
belongs_to: recording an association with a record (or nil) stores its foreign key, and type when polymorphic, so it resolves across sources like any attribute. Values JSON cannot hold, such as objects, now raise LaForge::InvalidValue when recorded instead of coming back as hashes.
has_many and has_one:laforged associations: { … } declares tracked associations (a has_and_belongs_to_many raises), with keys derived from the reflection where possible. A polymorphic reference adds its type column to a derived key. Member order is written to the integer column the scope orders by, in the scope's direction, never one that identifies the member or its owner, or to the column order_column: names; order_column: false writes none.
Membership per member:record_data_entries(..., mode:) says what a source's list is: :complete (the default), the source's whole member list; :partial, the members it includes or excludes; or :edit, changes to the members it names, the rest of its report kept; forgetting a member of a complete list raises, since it would exclude it. The highest-precedence report with an opinion on a member decides it. A source that stops including a member withdraws it, and rows no report has named, such as ones from before tracking, are left alone.
Editing one member:mode: :edit takes { include: [...], exclude: [...], forget: [...] }, as an editor makes changes, and accepts the shapes a Rails form submits: *_attributes for a tracked association, and *_ids. Where the owner declares accepts_nested_attributes_for, its reject_if: and allow_destroy: apply to the rows as they do in Rails, a method-based reject_if: called on the owner itself, new or not; limit: and update_only: raise, as LaForge does not apply them.
Removed members reported again: excluding a member keyed by id destroys it and keeps a tombstone, which tombstone_matcher: uses to recognise the member when a source reports it again, so it stays out until a source that outranks the exclusion includes it. LaForge.tombstone_blocking lets code that creates members itself check first.
Orphan sweeps:withdraw_source withdraws everything a source reported, instead of destroying its entries.
Member attributes: a list names its members by key, and keeps what the source says about each one as pending until a forge of the owner includes that member. That forge records them on the member as that source's entries, as if the source had recorded them on the member directly, nested tracked associations included. Nothing is created for a member membership excludes; its attributes wait in the report until membership includes it or the source's next report replaces them, so an owner can be saved and forged later. A newer value the member has from the same source is not overwritten, judged by a new recorded_at column on data entries (updated_at keeps its usual meaning). Existing installs add it with rails generate laforge:recorded_at, which fills it from updated_at. A saved record can be listed in place of a hash; an unsaved one raises.
Keys: members need a value for each key column. Rows missing one are left alone; rows sharing a key raise LaForge::DuplicateMemberKey at forge, and a list naming a member twice raises.
Members that can't be created: a member included that no report describes is built from its key, or skipped with a warning and reported under skipped: in forge_diff when invalid with its key alone.
Precedence: a precedence: given with a list is given to everything the payload records, nested lists included. Sources rank by level first, a tie at a source's most specific level falling to the sources' own precedence, and update_precedence_order sets the order of sources for one record's attribute, a model's attribute, a whole model, or globally.
Attribute groups, inactive sources and change events:laforged attribute_groups: keeps attributes such as an address's lines from one source together; a source's active flag (rails generate laforge:active_sources) leaves its entries out of forging while keeping them; forge_attributes(except_sources:) answers what would be forged without a source; and data_entry_changed.laforge is published for each entry saved with a changed value or destroyed, once the write is committed, and not for a write that rolls back, with the values as they were saved. Change events need Rails 7.2 or later.
Merging duplicates:LaForge.merge and LaForge.reverse_merge move a duplicate's entries, precedence rules and tracked members into the survivor.
Removal: members no longer included are removed as assigning the collection would remove them, per dependent:: destroyed for :destroy, deleted without callbacks for :delete_all, detached without validation otherwise, once the owner is saved. A replaced has_one member is removed when the owner is saved, and a member a later forge includes again before the save, has_one or not, is kept. Destroying records is otherwise out of scope.
Inspection:source_values returns only the members the association's scope includes, and forge_diff(attributes:) previews only the associations given.
Specs:spec/lib/association_capabilities_spec.rb covers each capability by id, and spec/lib/association_importer_scenarios_spec.rb walks through importer runs.
Structure: eight refactor: commits at the top restructure association tracking around LaForge::TrackedAssociation (one object per owner and association) and MemberList::Complete/MemberList::Partial reports with methods for each change, hold the forge's writes in PendingWrites, and move LaForge's methods onto LaForge::Record. Each laforged model has its own subclass, Model.laforge, holding the model-level methods, and each record an instance, record.laforge, holding its methods, pending writes and precedence resolver. The concerns AttributeGroups, Recording, Forging, Precedence and Inspection make it up, and the model delegates the documented API to it, so place.forge! is place.laforge.forge!. Behaviour is unchanged; the suite proves it at each commit.
Follow-ups
A reactivated source's exclusion doesn't apply to a member the application created itself while the source was inactive, after LaForge.tombstone_blocking allowed it, since later reports name it by its id rather than a tombstone. Matching saved members against tombstones would change tombstone_matcher:'s contract and could exclude a legitimate row; the README now describes the narrower behaviour.
forge(sources:) filters attribute entries only; association membership is still decided by every active report. Limiting membership to some sources needs its own semantics for the reports left out.
forge_diff doesn't preview what withdrawing a source from retained members changes, down nested lists.
recorded_at advances only when a value changes, so re-recording an unchanged value doesn't protect it from an older report kept pending on an owner; refreshing it for every unchanged attribute would write a row per attribute on each import.
Replacing a keyed has_one member whose owner foreign key has a unique index: the old member is removed after the save, so autosave's insert of the new one fails first.
Key columns whose type JSON doesn't keep, such as dates, compare as strings once a report is reloaded.
A saved record listed in a member's nested list is kept in the pending JSON as its attributes rather than its key.
LaForge.merge doesn't remap entries recorded on moved members that point back at the duplicate owner through a belongs_to.
A has_one member's own nested lists are recorded as complete reports in edit mode, not as edits.
LaForge.merge keeps both records' un-forged pending attributes for the same member, so the newer one wins rather than the merge winner's. Forging both records before merging avoids it.
We tested whether moving Sparkle from the LaForge it pins today (dbc77faa) to this stack changes what any record forges.
How
For each client database, copied laforge_data_sources and laforge_data_entries into two scratch databases ("before" and "after"). The source databases were only read.
Forged every record in both copies without saving: "before" with LaForge at dbc77faa, "after" with this PR's head (838a041), on the same Rails 8.1 bundle. Each record type gets a stand-in model with a setter for every attribute its entries name, so the gem's own forge_attributes picks the winners.
Diffed the winning value of every record and attribute.
Results
Client
Copy as of
Forged values
Changed
Atrium
2026-10-01 (production data)
499,843
0
Scripps
2026-08-23
202,345
0
UCSF
2026-05-28
372,142
0
AtlantiCare
2025-11-12
53,361
0
Westchester
2025-01-10
46,418
0
Control. Before upgrading, we added one Sparkle entry to both Atrium copies: an admin edit of department_id on an address where Epic also has one. The diff showed exactly that change, with the edit winning before and Epic's value winning after. So the harness does catch a changed winner, and level-first ranking is the cause. The cause is EpicDepartmentImporter recording Epic's address fields with priority: 100. The migration turns those into per-record rules (19,092 on Atrium), which now outrank Sparkle's global precedence.
No current Atrium data hits this, but future admin edits to department_id, exists_in_ehr and scheduling_enabled on Epic addresses would be overwritten. We reran the control with a candidate Sparkle-side data migration added: it deletes those record rules and sets Address.update_precedence_order([sparkle, epic], attribute: ...) for each of the three fields. The diff was back to zero, with the edit holding. We'll ship that conversion, and drop priority: 100 from the importer, in the same Sparkle deploy as the gem bump. Scripps has the same Epic rules (about 2,100) but no Sparkle source, and Epic already outranked MDStaff and UHC there, so nothing changes.
Caveats
Only Atrium's copy is recent. The Scripps, UCSF, AtlantiCare and Westchester copies are local snapshots that are months old, and Westchester's is from January 2025. We'll rerun the check against fresh copies before upgrading.
Clients without a local copy (UMMS, Christus and others) haven't been checked yet.
This covers attribute forging only, which is all Sparkle records through LaForge today. No associations are tracked yet. Sparkle's own model code, such as setters with side effects, isn't exercised.
The reason will be displayed to describe this comment to others. Learn more.
Requesting changes for the inline findings. Each one has a repro on 838a041 and a suggested fix that keeps the full suite green (269 examples, 0 failures, 2 pending):
High:derived_order_column can choose the primary key, so forge rewrites member ids, or raises RecordNotUnique. Sparkle's Address hits this, and should also be declared with key: :id.
Payload ignores reject_if: and allow_destroy:. A blank form row fails the save, and _destroy deletes members that Rails would keep.
forge_diff leaves a precedence rule in memory, and the next owner save persists it.
Minor: precedence_order with no attribute reports a model-wide rule as :attribute.
Change events for rolled-back writes are covered in the earlier comment: #16 (comment)
The PR description is also out of date. It says partial: true, but the code uses mode: :partial/mode: :edit. It also says 221 examples; it's 269 now.
The reason will be displayed to describe this comment to others. Learn more.
Forging a tracked association can rewrite its members' primary keys
derived_order_column excludes key columns, but not the primary key. That's only safe when members are keyed by id. Once a member has a second belongs_to, derived_key makes that foreign key the key. Then id becomes "an integer column the scope orders by", and forge runs record[config.order_column] = index on it.
Sparkle's Address is shaped like this. It belongs to locale (the owner) plus location, address_group and department. The core AddressBehavior scope for addresses ends in .order(virtual: :asc, id: :asc), and the position ordering is a raw CASE, so derivation doesn't see it. laforged associations: { addresses: {} } would therefore derive key: [location_id, address_group_id, department_id] and order_column: "id".
Here's what forge issued for an owner with addresses 2 and 3:
If the new id is free, the row moves silently. Its own laforge_data_entries still point at the old id, and so does anything else that references it (appointments, Kyruus ids, caches).
If the new id is taken, forge! raises ActiveRecord::RecordNotUnique: Duplicate entry '0' for key 'repro_addresses.PRIMARY'.
Repro. Drop this in spec/lib/. All 3 examples fail on 838a041:
spec
require'spec_helper'describe'association tracking on a member with a second belongs_to'dobefore(:all)doActiveRecord::Schema.definedocreate_table(:repro_owners,force: true){ |t| t.string:name}create_table(:repro_departments,force: true){ |t| t.string:name}create_table(:repro_addresses,force: true){ |t| t.bigint:repro_owner_id;t.bigint:repro_department_id;t.string:city}endObject.const_set(:ReproDepartment,Class.new(ActiveRecord::Base))Object.const_set(:ReproAddress,Class.new(ActiveRecord::Base))Object.const_set(:ReproOwner,Class.new(ActiveRecord::Base))ReproAddress.class_eval{belongs_to:repro_owner;belongs_to:repro_department,optional: true;laforged}ReproOwner.class_evaldohas_many:repro_addresses,->{order(id: :asc)}laforgedassociations: {repro_addresses: {}}endendafter(:all){%w[repro_ownersrepro_departmentsrepro_addresses].each{ |t| ActiveRecord::Base.connection.drop_table(t,if_exists: true)}}let(:source){LaForge::DataSource.find_or_create_by!(name: 'Import'){ |s| s.precedence=1}}let(:department){ReproDepartment.create!(name: 'Cardiology')}it'does not use the primary key as the order column'doexpect(LaForge::AssociationConfig.new(ReproOwner,:repro_addresses).order_column).not_toeq('id')endit'does not change the id of the address it matches'doowner=ReproOwner.create!address=owner.repro_addresses.create!(repro_department: department,city: 'Charlotte')address.record_data_entries({city: 'Charlotte'},source)address.save!owner.reload.record_data_entries({repro_addresses: [{'repro_department_id'=>department.id}]},source)owner.forge!expect(ReproAddress.exists?(address.id)).tobe(true)expect(LaForge::DataEntry.where(record_type: 'ReproAddress').pluck(:record_id)).toall(eq(address.id))endit'forges when another row already holds the position number'doReproAddress.create!(city: 'unrelated')owner=ReproOwner.create!owner.repro_addresses.create!(repro_department: department,city: 'Charlotte')owner.repro_addresses.create!(repro_department: ReproDepartment.create!(name: 'Oncology'),city: 'Concord')owner.reload.record_data_entries({repro_addresses: owner.repro_addresses.map{ |a| {'repro_department_id'=>a.repro_department_id}}},source)expect{owner.forge!}.not_toraise_errorendend
1) does not use the primary key as the order column
expected: value != "id"
got: "id"
2) does not change the id of the address it matches
expected true
got false
3) forges when another row already holds the position number
expected no Exception, got #<ActiveRecord::RecordNotUnique: Mysql2::Error: Duplicate entry '0' for key 'repro_addresses.PRIMARY'>
Suggested fix. Never derive a column that identifies the member or its owner:
- # An integer column the association scope orders by, other than a key column, is where member order is written+ # An integer column the association scope orders by is where member order is written, unless it identifies the+ # member or its owner: the primary key, a key column, or the foreign key back to the owner
def derived_order_column
@@
- columns.find { |column| !key.include?(column) && klass.columns_hash[column]&.type == :integer }+ identifying = key + [klass.primary_key, owner_foreign_key, owner_type_column].compact+ columns.find { |column| identifying.exclude?(column) && klass.columns_hash[column]&.type == :integer }
With that change, the repro passes 3/3 and the full suite passes (269 examples, 0 failures, 2 pending).
Separately, for Sparkle: the README (line 379) says an owned model that happens to belong to another record needs key: :id. Address is exactly that. Without it, addresses would be keyed by [location_id, address_group_id, department_id], and any address missing one of those values can't be identified at all. The upgrade-path doc's addresses: { tombstone_matcher: … } example raises at declaration for the same reason. It should read addresses: { key: :id, tombstone_matcher: … }.
A follow-up worth considering: since derivation can't see position inside Sparkle's CASE, an explicit order_column: (and order_column: false) option would let an owner name its order column or opt out. That's better than relying on whatever the scope happens to mention.
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 0203833: the derived order column skips the primary key, key columns and owner columns, and order_column: (a column or false) names it outright.
The reason will be displayed to describe this comment to others. Learn more.
Payload ignores accepts_nested_attributes_for's reject_if: and allow_destroy:
A Rails form for an owner that declares accepts_nested_attributes_for :tenants, reject_if: :all_blank, allow_destroy: false behaves differently when recorded through LaForge than when assigned directly:
included, so forge! raises RecordInvalid: Tenants name can't be blank
{ 'id' => cafe.id, '_destroy' => '1' }
ignored, tenant kept
excluded, so the tenant is destroyed
Sparkle's addresses use accepts_nested_attributes_for :addresses, allow_destroy: true, reject_if: :all_blank_with_exceptions, so admin forms with an empty "add address" row would hit the first case.
Suggested fix: where the owner declares nested attributes, apply them the way Rails does. Otherwise keep the current behaviour, which the existing specs rely on.
def nested_attributes(config, value, owner)
+ options = config.owner_class.nested_attributes_options[config.name.to_sym] # nil where the owner does not accept nested attributes
unless config.collection?
attributes = value.to_h.stringify_keys
- return destroy?(attributes) ? nil : member(config, attributes.except(DESTROY), owner)+ return destroy?(attributes, options) ? nil : member(config, attributes.except(DESTROY), owner)
end
...
Array(items).map { |attributes| attributes.to_h.stringify_keys }.each do |attributes|
- if !destroy?(attributes)- include << member(config, attributes.except(DESTROY), owner)+ if !destroy?(attributes, options)+ include << member(config, attributes.except(DESTROY), owner) unless reject?(config, attributes, owner, options)
...
- def destroy?(attributes)- ::ActiveModel::Type::Boolean.new.cast(attributes[DESTROY]) == true+ # Where the owner accepts nested attributes, `_destroy` counts only if they allow it, as in Rails+ def destroy?(attributes, options)+ (options.nil? || options[:allow_destroy]) && ::ActiveModel::Type::Boolean.new.cast(attributes[DESTROY]) == true+ end++ # Whether the association's `reject_if:` drops the row, as assigning it through Rails would+ def reject?(config, attributes, owner, options)+ case (reject_if = options&.dig(:reject_if))+ when Symbol+ target = owner || config.owner_class.new+ target.method(reject_if).arity.zero? ? target.send(reject_if) : target.send(reject_if, attributes)+ when Proc+ reject_if.call(attributes.with_indifferent_access)+ end
end
(:all_blank is stored as a Proc by Rails, so the Proc branch covers it.) With this change, both rows match Rails: the blank row is dropped and the tenant is kept. The full suite passes (269 examples, 0 failures, 2 pending). Rails' limit: isn't handled either; I left it out because it's rarely used.
The reason will be displayed to describe this comment to others. Learn more.
forge_diff can leave a precedence rule behind that the next save persists
laforge_forge_diff_with puts data_entries back after the preview, but not precedence_rules. When a pending report carries a precedence, laforge_record_reported → write_data_entry builds a rule on the member, and it stays in memory. precedence_rules is autosave: true, so any later save of the owner writes it, even though nothing was forged.
place=Place.create!(name: 'P')place.record_data_entries({tenants: [{name: 'Cafe'}]},low);place.forge!tenant=place.tenants.firstplace.reloadplace.record_data_entries({tenants: [{id: tenant.id,unit: '2B'}]},low,precedence: 50)place.forge_diff# a previewplace.update!(name: 'P2')# an unrelated save, no forgeLaForge::PrecedenceRule.where(record_type: 'Tenant').pluck(:attribute_name,:precedence)# => [["unit", 50]], but the tenant's unit is still nil and it has no "unit" entry
Suggested fix: snapshot and restore the rules alongside the entries.
With this change, the same steps leave no Tenant rules after the save, and a real forge! afterwards still writes ["unit", 50] and sets unit to "2B". The full suite passes (269 examples, 0 failures, 2 pending).
With attribute: nil, rank looks up [ANY_ID, '', source] at the model-attribute level, which is the model-wide rule. The order is unaffected, because the same value appears at both levels. The label is wrong, though, and anything that shows it (an admin "why does this source win" view) would mislead.
… and accept `order_column:`
Root cause: `AssociationConfig#derived_order_column` took the first integer column the association scope orders by, skipping only key columns. When a member's key is derived from a second `belongs_to`, its primary key is no longer a key column, so a scope ending in `order(:id)` made `id` the order column and `forge` wrote list positions into it: a member moved to another id, leaving its entries pointing at the old one, or `forge!` raised `ActiveRecord::RecordNotUnique` when the id was taken. A scope that orders by an expression, such as `Arel.sql(...)`, names no column, so its order column could not be found at all.
Derivation now skips the primary key, the key columns, and the foreign key and type column pointing back at the owner. `laforged associations: { name: { order_column: :position } }` names the column outright, and `order_column: false` writes no order; a column that does not exist, is not an integer, or identifies the member or its owner raises an `ArgumentError` saying which and what to pass instead. The README documents both under "Declaring has_many and has_one associations".
Refs #16
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…` previews a report
Root cause: `laforge_forge_diff_with` records what an owner's pending report says on a member to preview the changes, then puts the member's `data_entries` back, but not its `precedence_rules`. When the report carries a precedence, `write_data_entry` builds or changes a rule on the member, and since `precedence_rules` is `autosave: true` the owner's next save wrote it, though nothing was forged.
The preview now snapshots the member's rules, with each one's precedence and whether it is marked for destruction, and restores them alongside the entries, so a later `forge!` still writes the rule.
Refs #16
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ttribute
Root cause: with no attribute, `PrecedenceRule::Resolver#rank` looks up the rule for the attribute `''` at the model-attribute level, and `''` is how a model-wide rule for every attribute is stored, so the same rule counted at both levels and `Model.precedence_order` labelled it `:attribute`. The order was right, since the precedence appeared at both levels, but a screen showing why a source wins would have shown the wrong level.
With no attribute, the model-attribute level is now empty, so a model-wide rule ranks and is labelled `:model`, as it is when an attribute is given.
Refs #16
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, and not for a rolled-back write
Root cause: `DataEntry` published `CHANGED` from `after_save` and `after_destroy`, and the owner's `laforge_after_save` published the entries a forge withdrew from members as it deleted them, all inside the transaction. A write that then rolled back had already been announced, so a subscriber that enqueues a job, notifies someone or updates another store acted on a change that never happened.
`DataEntry.publish_change` now takes the payload when the write is made, which keeps `value_was` right when one transaction saves an entry more than once, and instruments it from the current transaction's `after_commit`, which drops it if the transaction rolls back. Every event goes through it, the withdrawn entries' included. A write outside an explicit transaction is published as it is saved. This uses `ActiveRecord::Base.current_transaction`, so the gemspec now requires Rails 7.2 or later. The README's "Following changes" says the event fires once the write is committed.
Refs #16
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…orm's nested rows
Root cause: `Payload#nested_attributes` translated every `*_attributes` row into an edit without reading the owner's `accepts_nested_attributes_for` options. A blank template row that `reject_if: :all_blank` drops when Rails assigns it was included, so `forge!` raised `RecordInvalid` for the empty member, and a row with `_destroy` excluded its member even where `allow_destroy: false` makes Rails ignore the flag, so the member was destroyed.
Where the owner declares nested attributes for the association, a row its `reject_if:` rejects (a Proc, `:all_blank`, or a method on the owner, called on an unsaved instance for a new owner) is left out, new and existing rows alike but never a row being destroyed, and `_destroy` counts only with `allow_destroy: true`, as Rails does. A rejected has_one row records nothing. `limit:` and `update_only:` are not applied, so recording `*_attributes` for an association that declares them raises an `ArgumentError` naming the model, the association and the option. Owners without nested attributes are recorded as before. The README's "Recording a Rails form" describes it.
Refs #16
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Thanks for the thorough review — each point is fixed in its own commit:
d98725f: the derived order column now skips the primary key, the key columns, and the owner's foreign key and type column. order_column: (a column name, or false) states it explicitly, and a bad column raises naming what to pass. Your repro is C107; the option and its errors are C108.
419d7ce: Payload applies the owner's reject_if: (Proc, Symbol or :all_blank) and allow_destroy: to nested rows as Rails does. That includes existing rows, a row being destroyed is never rejected, and a new owner's rows are checked on an unsaved instance. limit: and update_only: raise rather than being silently ignored. Your repro is C111; C112 and C113 cover the rest.
c58cfa6: forge_diff snapshots and restores the member's precedence rules, with their precedence and destruction flags. Your repro is C109.
bdd02bb: a model-wide rule is labelled :model.
42f1b05: we went with your preference. The payload is captured at save time and published after commit, and dropped on rollback, withdrawn-entry events included. Specs cover a save outside a transaction, inside one, a rollback, and a rolled-back forge (C110). This uses current_transaction, so the gemspec now requires Rails 7.2 or later.
The description is updated: mode:, the example count (278), and a few other claims that had gone stale.
The expiry work that builds on this is stacked above it in #18.
Adds experimental association tracking to LaForge’s source-based attribute resolution, addressing association support requested in #10 and building on #15’s precedence rules.
Changes:
Tracks association membership, deferred member attributes, nested form edits, withdrawals, and tombstones.
… and accept `order_column:`
Root cause: `AssociationConfig#derived_order_column` took the first integer column the association scope orders by, skipping only key columns. When a member's key is derived from a second `belongs_to`, its primary key is no longer a key column, so a scope ending in `order(:id)` made `id` the order column and `forge` wrote list positions into it: a member moved to another id, leaving its entries pointing at the old one, or `forge!` raised `ActiveRecord::RecordNotUnique` when the id was taken. A scope that orders by an expression, such as `Arel.sql(...)`, names no column, so its order column could not be found at all.
Derivation now skips the primary key, the key columns, and the foreign key and type column pointing back at the owner. `laforged associations: { name: { order_column: :position } }` names the column outright, and `order_column: false` writes no order; a column that does not exist, is not an integer, or identifies the member or its owner raises an `ArgumentError` saying which and what to pass instead. The README documents both under "Declaring has_many and has_one associations".
Refs #16
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…` previews a report
Root cause: `laforge_forge_diff_with` records what an owner's pending report says on a member to preview the changes, then puts the member's `data_entries` back, but not its `precedence_rules`. When the report carries a precedence, `write_data_entry` builds or changes a rule on the member, and since `precedence_rules` is `autosave: true` the owner's next save wrote it, though nothing was forged.
The preview now snapshots the member's rules, with each one's precedence and whether it is marked for destruction, and restores them alongside the entries, so a later `forge!` still writes the rule.
Refs #16
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ttribute
Root cause: with no attribute, `PrecedenceRule::Resolver#rank` looks up the rule for the attribute `''` at the model-attribute level, and `''` is how a model-wide rule for every attribute is stored, so the same rule counted at both levels and `Model.precedence_order` labelled it `:attribute`. The order was right, since the precedence appeared at both levels, but a screen showing why a source wins would have shown the wrong level.
With no attribute, the model-attribute level is now empty, so a model-wide rule ranks and is labelled `:model`, as it is when an attribute is given.
Refs #16
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, and not for a rolled-back write
Root cause: `DataEntry` published `CHANGED` from `after_save` and `after_destroy`, and the owner's `laforge_after_save` published the entries a forge withdrew from members as it deleted them, all inside the transaction. A write that then rolled back had already been announced, so a subscriber that enqueues a job, notifies someone or updates another store acted on a change that never happened.
`DataEntry.publish_change` now takes the payload when the write is made, which keeps `value_was` right when one transaction saves an entry more than once, and instruments it from the current transaction's `after_commit`, which drops it if the transaction rolls back. Every event goes through it, the withdrawn entries' included. A write outside an explicit transaction is published as it is saved. This uses `ActiveRecord::Base.current_transaction`, so the gemspec now requires Rails 7.2 or later. The README's "Following changes" says the event fires once the write is committed.
Refs #16
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…orm's nested rows
Root cause: `Payload#nested_attributes` translated every `*_attributes` row into an edit without reading the owner's `accepts_nested_attributes_for` options. A blank template row that `reject_if: :all_blank` drops when Rails assigns it was included, so `forge!` raised `RecordInvalid` for the empty member, and a row with `_destroy` excluded its member even where `allow_destroy: false` makes Rails ignore the flag, so the member was destroyed.
Where the owner declares nested attributes for the association, a row its `reject_if:` rejects (a Proc, `:all_blank`, or a method on the owner, called on an unsaved instance for a new owner) is left out, new and existing rows alike but never a row being destroyed, and `_destroy` counts only with `allow_destroy: true`, as Rails does. A rejected has_one row records nothing. `limit:` and `update_only:` are not applied, so recording `*_attributes` for an association that declares them raises an `ArgumentError` naming the model, the association and the option. Owners without nested attributes are recorded as before. The README's "Recording a Rails form" describes it.
Refs #16
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…orm payloads need mode: :edit
`record_data_entry` wrote its value with `laforge_record_attribute` directly, skipping `Payload.translate`. A Rails form field such as `tenant_ids` or `tenants_attributes` was accepted wherever its setter existed and stored as an attribute entry, and forging that entry called Rails' collection setter, which writes to the database during `forge` and bypasses tracked membership. A tracked association raised a misleading "is not a tracked association".
`record_data_entry` now records through `record_data_entries` in its default mode, so form shapes raise `ArgumentError` asking for `mode: :edit`, and a tracked association is recorded as a complete report.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gain
`write_data_entry` reused an entry marked for destruction without clearing the mark. A partial report that a forge emptied and `prune_withdrawn` marked, or an attribute entry `remove_data_entries` marked, kept its mark when the source recorded it again before the owner was saved. Since a forge leaves marked entries out, the next forge ignored the new value, falling back to other sources or withdrawing the members the report included again, and the save then destroyed the entry.
`write_data_entry` now clears the mark when it writes onto an entry, as `write_precedence_rule` does for rules, so recording again undoes the unsaved removal for reports and attribute entries alike. `laforge_forge_diff_with` restores each entry's mark along with its value, since it records onto the member's entries only to compute the diff.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… before the owner was saved
When a forge built a new has_one member and a second forge before the owner's save dropped it, `forge` scheduled that unsaved member for removal like a saved one. For an association with no `dependent:` option, `laforge_after_save` then nullified its owner key and called `save!`, inserting an orphan row, or failing where that column is NOT NULL.
`laforge_after_save` now skips scheduled members that were never saved, so they are discarded, and only persisted members are detached, deleted or destroyed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…erent keys
`merge_members` moved every duplicate member the survivor had no member with the same key for. In a keyed has_one, such as `marker: { key: :label }`, a duplicate's marker with another label moved to the survivor alongside its own, leaving two rows behind a reader that shows one, or failing on a unique owner index after the entries had already moved.
`merge_records` now checks each tracked has_one first and raises `ArgumentError` when both records have a member and their keys differ, before anything moves, naming both keys and asking for one to be removed or given the same key. Members with the same key are still merged into the survivor's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…other source reported as new meanwhile
Issue: "Deactivating a source" said a tenant created while the excluding source was inactive is removed by the first forge after it is activated again. That holds for a tenant another source reported as new, which the forge matches against the tombstone, but not for one the application created itself after `LaForge.tombstone_blocking` allowed it: later reports name it by its id, which the tombstone does not.
The sentence now covers only tenants reported as new, and says the exclusion does not apply to one the application created itself.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r`, with no level
Root cause: `LaForge::ActiveRecord.order_by_rank` named each source's level by the first precedence in its rank that was not `-Float::INFINITY`. A source with no global rule now ranks as `-Float::INFINITY` there too, as an upgrade can leave for a source deleted before the precedence rules migration, so `precedence_order` raised `TypeError` indexing the levels with `nil`.
Such a source is now listed last with a `nil` level.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…LE_TYPES`
Issue: the types `DataEntry.storable?` accepts were listed inside its `case`, halfway down the file, so finding what a source can record meant searching for the method.
`DataEntry::STORABLE_TYPES` lists them at the top of the class, and `storable?` matches against it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y there
Issue: the `MemberList` class comment opened with a fragment ("One source's report of …") rather than saying what the class is for, and wove the terms of a report (complete, partial, include, exclude, withdrawn, pending, tombstones) and its stored shapes through prose, leaving the stored shape of a complete report with both withdrawn and pending items undescribed. The method comments described their results without saying what they return, left out branches (exclusions and tombstones given for a complete report are dropped by `MemberList.value`, and `marked: true` also drops pending items and tombstones), and `reported_keys` claimed keys the report "has ever" had an opinion on, though withdrawn keys are pruned by the next forge.
The class comment now says it is used to read and build the value of a source's `DataEntry` for a tracked association, then defines complete and partial reports, each list a report holds, the fields of a pending item, and the stored shapes, as structured lists. The method comments open with what they return and cover their branches, `renamed` gains an example, and the comment on `reported_keys`, which restated its one line, is removed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issue: the comments in `AssociationTracking`, `AssociationConfig` and `Tombstone` opened with nuances or situations instead of what each method returns, left out raise and nil branches, re-derived membership and withdrawal in every method that touched them, used colons and semicolons to splice ideas together, and in places misstated the code: an excluded member is removed per `dependent:` rather than always destroyed, `reported_columns` and `tombstone_attributes` drop the primary key rather than the key, and `resolve` leaves alone rows no report names now rather than ever. A comment on removing members sat above `private`, attached to nothing.
The `AssociationTracking` module comment now defines membership, a forge's removal of members, pending attributes and withdrawing a source from a member, and `AssociationConfig` lists how a member's key is chosen and defines placeholder and tombstone keys and `tombstone_matcher:`, so method comments use those terms without re-deriving them. Each method comment now says first what it returns or does and covers its raise and nil branches, multi-part returns and options are lists, the stray comment is deleted, and `AssociationTracking.tombstone_blocking` points at the public `LaForge.tombstone_blocking` instead of repeating it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… payloads
Issue: the comments on recording, forging, precedence, merging and Rails form payloads re-derived vocabulary that `MemberList`, `AssociationTracking` and `PrecedenceRule` own, opened with nuances instead of saying what each method returns, left out branches such as the nil level, the attribute-group raises and members scheduled by `laforge_remove`, and argued against code that does not exist. Some had drifted from the code, naming `attributes` where `remove_data_entries` takes `attribute_names`, saying `record_data_entry` records a grouped attribute when it raises, and calling `effective_precedence` the way entries are ordered.
Levels-first ranking and precedence orders are now defined once in the `PrecedenceRule` overview, and attribute groups in the `laforged` docs. Method comments say what they return first, describe every branch, and use the report vocabulary without redefining it. Per-member facts (the `DataEntry::CHANGED` payload, the `LaForge.merge` report, `mode:` options, removal per dependent option, attribute group checks) are now lists, and lines that ran past 120 characters are rewrapped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issue: the association spec comments re-derived the vocabulary that `LaForge::MemberList`, `LaForge::AssociationConfig` and `LaForge::AssociationTracking` already define, in a header essay. Some comments made claims the code does not bear out, such as the importer sweep closing a record "or reports it in a list" and the registry helper keeping the tenant "only if another source still reports it". Others joined sentences with semicolons or mid-sentence colons, sat above the line they described, or named a process ("noted for later consideration"). Three spec names stacked clauses ("an earlier forge, not saved yet, removed") that were hard to parse.
The capability spec header now points to the modules that own the vocabulary, lists the experimental API names, and lists where a forge removes rows, defining the `# noted:` marker the examples use. Helper comments say what each helper does and returns, the inaccurate claims are gone, the misplaced comment sits on its line, and the stacked spec names read as one clause. Every `capability:` tag is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…odel's own method would hide LaForge's
Root cause: two errors misdescribed what happens. Excluding a member keyed by primary key without a `tombstone_matcher:` said "excluding it destroys it", but a removed member goes the way `dependent:` says, which can delete or nullify it. `laforged` raising for a method the model already defines said `laforged` "would replace" it, but a method the model defines hides the one LaForge includes.
The messages now say "excluding it removes it per `dependent:`" and "which would hide LaForge's of the same name", and the spec that removes a source's global rule says why such a source can exist.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issue: `AssociationTracking.record` and `.edit_member` built each new report by hand, passing every list of the previous report back into `MemberList.value` or `MemberList.partial` with a lambda to drop the member, a `previous&.` guard for a source without a report, and a branch for each action and report kind. Reading what one edit does to a report meant tracing five lists through two branches.
`MemberList` now returns the stored value for each change to a report: `#including` (with the member's pending item), `#excluding`, `#forgetting`, `#replaced` (a new report that withdraws what it no longer names) and `#withdrawing_all`. `MemberList.none` is the report of a source without one, so callers no longer guard for nil. `edit_member` checks the member, asks the report for the change and writes it, and `record` and `withdraw` do the same.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
member_scope = member_scope.where(config.owner_type_column => owner_class.polymorphic_name) if config.owner_type_column
scope = config.reflection.scope
member_scope = member_scope.merge(config.klass.unscoped.instance_exec(&scope)) if scope&.arity&.zero? # a scope taking the owner cannot be applied to several owners at once
…:Partial`
Issue: `MemberList` held both kinds of report in one class, so `initialize`, `.value`, `#decision` and every change method branched on `partial?`, and callers repeated the branch: `record` chose between `MemberList.value` and `MemberList.partial`, `prune_withdrawn` rebuilt each kind by hand, and `entomb` and `Merge.combined_report` passed `partial: report.partial?` back in. Each method had to be read twice, once per kind.
`MemberList.new` now returns a `MemberList::Complete` or a `MemberList::Partial`, which share one interface. Each kind reads and writes its own stored value, decides membership its own way and implements `#excluding`, `#vacant?` and `.replacing`; only a partial report can be forgotten from. Shared changes live on `MemberList`, and `#with` returns the stored value for a report of the same kind with some lists replaced, which `#renamed`, `clear_pending`, `prune_withdrawn`, `entomb` and `combined_report` use. The stored JSON is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issue: every `AssociationTracking` function took `(owner, config, ...)` and passed the reports, existing members, created members and pending items it read between a dozen helpers as arguments. `forge` was 42 lines that read the reports, built and claimed members, recorded pending items, wrote member order, withdrew sources, pruned the reports, entombed removed members and scheduled removals inline, and `diff` and `source_values` mixed their queries with the per-member logic.
`LaForge::TrackedAssociation.new(owner, config)` holds the owner and config, and reads the reports, existing members, created members, membership resolution and pending items when first needed. `record`, `edit`, `edit_member`, `forge`, `diff`, `name_created`, `tombstones`, `member_sources` and `unsaved_member` are instance methods, and `forge` reads as a sequence of named steps. Work across owners, `declare`, `source_values` and `tombstone_blocking`, are class methods, with `source_values` split into its queries and the per-owner and per-source member hashes. Callers in `activerecord.rb` and `tombstone.rb` build one per operation, and `withdraw_source` and `laforge_withdraw` call `MemberList#withdrawing_all` directly. `withdraw_from_members` is private, and `new_member` takes `attributes`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issue: `normalize_member` was 55 lines that turned a record, a `Tombstone` or a hash into a key hash, cast the key, gave a new member its placeholder key, then validated every field through a five-way `elsif` with the nested-list validation inline, the attribute groups and the key, so finding the check behind one error meant reading the whole method.
`normalize_member` now reads as its steps: `member_hash` turns each accepted shape into a hash, `named_tombstone` returns a tombstone's key, `keyed_member` casts the key and adds the placeholder key, and `assert_attribute!`, `assert_nested_list!`, `assert_whole_groups!` and `assert_key!` each validate one part.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issue: `lib/laforge/activerecord.rb` held every method `laforged` adds in one 711-line file, so recording, forging, precedence rules and the bookkeeping for what forges schedule for the next save were interleaved in one `InstanceMethods` and one `ClassMethods`. `laforged` declared its associations and scopes inline, `laforge_after_save` finished three kinds of scheduled work in 26 lines, and four places reached into Active Record with `instance_variable_set(:@marked_for_destruction, ...)`.
`InstanceMethods` now includes `Recording`, `Forging`, `Precedence` and `ScheduledChanges`, each in its own file, and `ClassMethods` includes `Precedence::ClassMethods`, so `laforged` adds them as before. `assert_no_collisions!` reads the methods of the included modules too, so the check covers the same names. `laforged` calls `declare_data_associations` and `declare_scopes`, `laforge_after_save` calls a step for each kind of scheduled work, and `LaForge::ActiveRecord.set_marked_for_destruction` is the one place that sets the flag. Methods that called one defined above them now come after it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
In general there are a lot of methods in the ActiveRecord submodules and many we end up prefixing with laforge_. Are they all reading instance data, or could we avoid cluttering the model namespace and extract some of these into singleton methods on the module that do not get mixed in. Let's discuss.
The reason will be displayed to describe this comment to others. Learn more.
"Schedule" is the wrong word to use. It implies setting a date. How else could we describe these? Or do the belong with recording.rb since it's about recording that we want a specific change to happen. Or do they belong with forging, because these are for manipulating metadata used in forging, not the data we're storing on the record itself?
The reason will be displayed to describe this comment to others. Learn more.
Fixed in d8e3bf3: these are now pending writes, the writes a forge has decided on but not made, which the record's next save makes. They belong with forging, as its output. ScheduledChanges is replaced by LaForge::PendingWrites, one per record, reached through laforge_pending_writes, with remove, remove_on_save, removed_members, cancel_removal, delete_entry, created_members and write!. The model loses ten laforge_* methods and four instance variables, and no "schedule" wording is left.
…e::PendingWrites`
Issue: what forges decide on but leave for a record's next save lived on the model itself, in four ivars (`@laforge_marked_members`, `@laforge_replaced_members`, `@laforge_withdrawn_entries`, `@laforge_created_members`) and a dozen `laforge_` methods mixed in by `ScheduledChanges`, so a reader had to piece the concept together from the model's method list. "Scheduled" also implied the writes were set for a date, when they are made by the record's next save.
`LaForge::PendingWrites` now holds a record's pending writes, one instance per record reached through `laforge_pending_writes`, which `Forging` creates on first use and drops on `reload` and once `laforge_after_save` has called its `write!`. Its `remove`, `remove_on_save`, `removed_members`, `cancel_removal`, `delete_entry` and `created_members` replace `laforge_remove`, `laforge_remove_on_save`, `laforge_scheduled_members`, `laforge_cancel_removal`, `laforge_destroy_on_save` and `laforge_created_members`, and `TrackedAssociation` and `Recording` call them through the accessor. `write!` makes the writes in the order `laforge_after_save` did, and the `laforged` collision check now covers `laforge_pending_writes` in place of the removed methods.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
For a natural-key collection without dependent:, forging a new member and then forging an empty list before saving leaves that unsaved member in the autosaved target. Autosave inserts it before the queued removal nullifies its owner, creating an orphan that the final report excludes. Remove never-persisted members from the autosaved target while preserving reuse if another unsaved forge includes their key again.
Use the association primary key for polymorphic foreign keys
lib/laforge/recording.rb:155
With belongs_to :subject, polymorphic: true, primary_key: :uuid, foreign_key: :subject_uuid, this records the target's model primary key (id) instead of its uuid. Forging then produces a foreign key that cannot resolve to the intended target. Use the reflection's association primary key with the target class for polymorphic associations too.
Register validation callbacks when enabling autosave
lib/laforge/tracked_association.rb:27
For a has_one declared without autosave: true or validate: true, setting autosave here does not install Rails' validation callbacks, yet autosave saves the member with validation disabled. Consequently, forge! can persist a member that fails its model validations instead of rolling back. Register the autosave validation callbacks when enabling autosave, as Rails' nested-attributes setup does.
Use polymorphic_name for polymorphic entry queries
lib/laforge/tracked_association.rb:37
These polymorphic entry queries use the Ruby class name rather than Active Record's stored polymorphic name. With a namespaced model and store_full_class_name = false, source_values misses the owner's reports and the member-entry query below misses member attributes. Use polymorphic_name for both queries, as the owner-type filter already does.
Reject conflicting first reports for keyed has_one associations
lib/laforge/tracked_association.rb:209
For a keyed has_one whose existing row has never been reported, a first report naming a different key appends the old row to records as undecided and removes it from the removal candidates. Only records.first becomes the target, so saving leaves both rows attached or fails a unique owner constraint. Reject this conflict to preserve the unreported row, or explicitly schedule its replacement according to dependent:.
Destroy vacant partial reports without withdrawal keys
lib/laforge/tracked_association.rb:451
Forgetting a source's last exclusion or tombstone leaves a vacant partial report with no withdrawal keys, so this guard prevents its destruction even after forge!. The source remains in data_source_names and data_source? despite having no opinion, which can prevent orphan checks from recognizing an unclaimed record. Check for vacant reports even when there is nothing to withdraw.
Preserve tombstone mappings until exclusions are saved
lib/laforge/tracked_association.rb:476
After a higher-precedence source excludes a saved tenant and forge runs without saving, the exclusion names only a tombstone while the original row still exists. If a lower source re-reports that row's id before forge!, the exclusion no longer matches and the tenant's pending destruction is cancelled. Preserve the id-to-tombstone mapping until removal is saved so the lower source cannot bypass the exclusion.
…ecord.laforge`
Issue: a laforged model carried LaForge's internals alongside the API the application calls. `Recording`, `Forging` and `Precedence` defined `@api private` methods such as `laforge_record_reported`, `laforge_rank_of` and `laforge_pending_writes` on the model, `TrackedAssociation` reached private ones such as `write_data_entry` and `laforge_association_reports` with `send`, and the model held `@laforge_pending_writes` and `@laforge_precedence_resolver`, so every model exposed these names and `laforged`'s collision check guarded them as if they were API.
The new `LaForge::RecordState`, reached through `record.laforge` and made on first use, holds what LaForge keeps for one record, its pending writes and precedence resolver, and the low-level methods bound to it: `forge_diff_with`, `record_attribute`, `record_reported`, `withdraw`, `write_pending_writes!`, `pending_writes`, `association_reports`, `winning_entries`, `forging_entries`, `write_data_entry`, `write_precedence_rule`, `filter_data_entries_in_memory`, `precedence_of`, `rank_of`, `outranks?`, `resolver` and `reset_resolver`. `TrackedAssociation`, `DataEntry` and the model's own methods call them there, without `send`. The model keeps the API the README documents, its associations, scopes and callbacks, and `reload`, which drops the `RecordState`; `@laforge` is the one instance variable LaForge sets on it. `laforged`'s collision check covers `laforge` and leaves out the moved methods. The unused `laforge_association_lists` is gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Register validation callback when enabling autosave
lib/laforge/tracked_association.rb:27
Enabling autosave after declaration does not install the associated-record validation callback that a plain has_one lacks by default. Its autosave path then saves the member without validation, allowing forge! to persist invalid reported attributes instead of raising and rolling back. Register the autosave validation callback when enabling autosave, as Rails does for nested attributes.
Reapply association order after forging members
lib/laforge/tracked_association.rb:211
When an owner forge applies pending member attributes, the member's own forge can reapply a directly recorded order-column value after write_order assigns the list position. Saving then persists that value instead of the resolved position, so reloading can reorder the members. Assign the association's order after forging its members so the list remains authoritative.
…ge`, which the model delegates to
Issue: `record.laforge` held only LaForge's low-level methods, so its API stayed spread over the model. `Recording`, `Forging` and `Precedence` were included into the model, which called into `record.laforge` for its internals, and `ClassMethods` added model-level helpers such as `laforged_associations`, `laforged_association`, `laforged_attribute_groups`, `laforge_with_groups`, `laforge_attribute_group`, `laforge_assert_whole_groups!` and `laforge_assert_rule_attribute!`. LaForge could not be used apart from the model's own methods.
`laforged` now gives each model its own subclass of the new `LaForge::Record`, reached through `Model.laforge`, and each record an instance of it, reached through `record.laforge`. `LaForge::Record` replaces `LaForge::RecordState` and includes the concerns `AttributeGroups`, `Recording`, `Forging`, `Precedence` and the new `Inspection`, whose instance methods act on the record and whose `class_methods` act on the model. Each concern lists its API in `API`, and `CLASS_API` where it has one, and `LaForge::ActiveRecord::Model` delegates them from the model and its records, so `place.forge!` is `place.laforge.forge!` and `Place.update_source_precedence` is `Place.laforge.update_source_precedence`. `Model.laforge` also holds `model`, `associations`, `association(name)` and `attribute_groups`, and `with_groups`, `group_of`, `assert_whole_groups!` and `assert_rule_attribute!` take the place of the `laforge_*` helpers. A subclass of a laforged model gets a subclass of its parent's `laforge`, so it keeps what the parent declared. `place.laforge.tombstones(:tenants)` joins `place.data_entries.tombstones(:tenants)`, which calls it. The model keeps its associations, scopes, after-save callback, `reload` and `initialize_dup`. The Readme notes the `laforge` form, and the Upgrading section says that `filter_data_entries_in_memory` is called on `record.laforge`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Use collection deletion API to preserve counter-cache updates
lib/laforge/pending_writes.rb:98
For a collection with dependent: :delete_all and a belongs_to counter cache, member.delete bypasses both callbacks and Rails' association-level counter update. Removing a member through forge! therefore leaves the owner's cached count too high, unlike ordinary collection removal. Use the collection's deletion API here so it preserves callback-free deletion while maintaining the counter cache.
Until a forge creates a new member, its source's report names it by a placeholder. Once the owner is saved, the placeholder is replaced by the new row's `id`. Forging again before saving reuses the member already built, rather than building another.
Sources rarely know the host application's ids, so an importer finds each record it reported before by its own means, such as the identifier its feed uses, and names it by `id`. Recording twice before a forge creates the member once, since a source's report replaces its previous one. A new member is created twice only when:
The reason will be displayed to describe this comment to others. Learn more.
The recording twice scenario is presented as though it was a common scenario, but maybe we should treat the first part of any subsection as the "golden path" and then talk about more complex scenarios in a subsubsection. I'm not sure if there are enough of these to make it a rule, but in this specific case it feels like the complex case is presented as if it was the natural continuation of the normal usage.
The reason will be displayed to describe this comment to others. Learn more.
Agreed, and made it the rule for the whole README: each section opens with the common case and its example, and situations that only arise in unusual cases follow in a subsection named for the situation. Here that's When a new member is created twice. The same pass moved edge cases in Edits (Several edits before a forge, What an edit can't do) and Recording a source's members (When a source also records on the member directly) in 4064ce1, Expiring a change on #18, and Rulesets on #19.
Issue: several association sections went straight from the common case into situations that only arise when something unusual happens, such as an importer reporting a member it already created, so the unusual case read as the natural continuation of normal use.
Each section now opens with the common case and its example, and the unusual situations follow in a subsection named for the situation. `Naming members` gains `When a new member is created twice`, `Edits` gains `Several edits before a forge` and `What an edit can't do` (which takes the exclusion rule from `Recording a Rails form`), and `Recording a source's members` gains `When a source also records on the member directly`.
Refs #16
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Use subclass payload options for inherited associations
lib/laforge/payload.rb:174
Inherited tracked associations keep the parent as config.owner_class, so this lookup ignores accepts_nested_attributes_for options declared or overridden on the subclass. A subclass's allow_destroy: false, reject_if:, or unsupported-option checks can therefore be bypassed when recording its form. Pass the actual payload model through to the options lookup instead of using the class that originally declared tracking.
Query members by the configured owner key
lib/laforge/tracked_association.rb:36
owner_ids contains record ids, but the member foreign key contains values of the association's configured primary_key:. When those differ, source_values queries the wrong members and can omit their recorded attributes or associate another owner's rows with this owner. Query and group members by the configured owner key, while keeping the returned results keyed by record id.
Use polymorphic_name for association queries
lib/laforge/tracked_association.rb:37
These owner and member entry queries use base_class.name, but Active Record stores a polymorphic association's type using polymorphic_name. With a namespaced model and store_full_class_name = false, the stored type is demodulized, so source_values misses the reports and member attributes even though recording and forging work. Please use the polymorphic name in both queries, as PrecedenceRule.model_type already does.
Apply owner-dependent association scopes per owner
lib/laforge/tracked_association.rb:69
Owner-dependent scopes are silently dropped here, so source_values can include attributes from members outside an association such as has_many :tenants, ->(place) { where(unit: place.name) }. The instance method uses this batch path too, so even a single-owner lookup is affected. Apply the association's scope separately for each owner when it cannot be shared across the batch.
Handle conflicting keyed has_one reports
lib/laforge/tracked_association.rb:209
For a keyed has_one, a first report naming a different key retains the existing, unreported member as undecided while assigning the new member as the target. The old member is removed from existing, so it is never scheduled for removal; saving can leave both rows attached, and reloading can return the old one. Explicitly reject this singleton conflict or schedule the old member's replacement.
Respect the association's configured owner primary key
lib/laforge/tracked_association.rb:537
When an association declares a different owner primary_key:, this overwrites the foreign key supplied by scope_for_create with owner.id. The unsaved member then carries the wrong owner reference during key-only validation and tombstone matching, even if autosave later corrects it. Use the owner key specified by the reflection here.
Review effort: Balanced
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #15. Association tracking is experimental; the README says so.
Changes
belongs_to: recording an association with a record (or
nil) stores its foreign key, and type when polymorphic, so it resolves across sources like any attribute. Values JSON cannot hold, such as objects, now raiseLaForge::InvalidValuewhen recorded instead of coming back as hashes.has_many and has_one:
laforged associations: { … }declares tracked associations (ahas_and_belongs_to_manyraises), with keys derived from the reflection where possible. A polymorphic reference adds its type column to a derived key. Member order is written to the integer column the scope orders by, in the scope's direction, never one that identifies the member or its owner, or to the columnorder_column:names;order_column: falsewrites none.Membership per member:
record_data_entries(..., mode:)says what a source's list is::complete(the default), the source's whole member list;:partial, the members it includes or excludes; or:edit, changes to the members it names, the rest of its report kept; forgetting a member of a complete list raises, since it would exclude it. The highest-precedence report with an opinion on a member decides it. A source that stops including a member withdraws it, and rows no report has named, such as ones from before tracking, are left alone.Editing one member:
mode: :edittakes{ include: [...], exclude: [...], forget: [...] }, as an editor makes changes, and accepts the shapes a Rails form submits:*_attributesfor a tracked association, and*_ids. Where the owner declaresaccepts_nested_attributes_for, itsreject_if:andallow_destroy:apply to the rows as they do in Rails, a method-basedreject_if:called on the owner itself, new or not;limit:andupdate_only:raise, as LaForge does not apply them.Removed members reported again: excluding a member keyed by
iddestroys it and keeps a tombstone, whichtombstone_matcher:uses to recognise the member when a source reports it again, so it stays out until a source that outranks the exclusion includes it.LaForge.tombstone_blockinglets code that creates members itself check first.Orphan sweeps:
withdraw_sourcewithdraws everything a source reported, instead of destroying its entries.Member attributes: a list names its members by key, and keeps what the source says about each one as pending until a forge of the owner includes that member. That forge records them on the member as that source's entries, as if the source had recorded them on the member directly, nested tracked associations included. Nothing is created for a member membership excludes; its attributes wait in the report until membership includes it or the source's next report replaces them, so an owner can be saved and forged later. A newer value the member has from the same source is not overwritten, judged by a new
recorded_atcolumn on data entries (updated_atkeeps its usual meaning). Existing installs add it withrails generate laforge:recorded_at, which fills it fromupdated_at. A saved record can be listed in place of a hash; an unsaved one raises.Keys: members need a value for each key column. Rows missing one are left alone; rows sharing a key raise
LaForge::DuplicateMemberKeyat forge, and a list naming a member twice raises.Members that can't be created: a member included that no report describes is built from its key, or skipped with a warning and reported under
skipped:inforge_diffwhen invalid with its key alone.Precedence: a
precedence:given with a list is given to everything the payload records, nested lists included. Sources rank by level first, a tie at a source's most specific level falling to the sources' own precedence, andupdate_precedence_ordersets the order of sources for one record's attribute, a model's attribute, a whole model, or globally.Attribute groups, inactive sources and change events:
laforged attribute_groups:keeps attributes such as an address's lines from one source together; a source'sactiveflag (rails generate laforge:active_sources) leaves its entries out of forging while keeping them;forge_attributes(except_sources:)answers what would be forged without a source; anddata_entry_changed.laforgeis published for each entry saved with a changed value or destroyed, once the write is committed, and not for a write that rolls back, with the values as they were saved. Change events need Rails 7.2 or later.Merging duplicates:
LaForge.mergeandLaForge.reverse_mergemove a duplicate's entries, precedence rules and tracked members into the survivor.Removal: members no longer included are removed as assigning the collection would remove them, per
dependent:: destroyed for:destroy, deleted without callbacks for:delete_all, detached without validation otherwise, once the owner is saved. A replacedhas_onemember is removed when the owner is saved, and a member a later forge includes again before the save, has_one or not, is kept. Destroying records is otherwise out of scope.Inspection:
source_valuesreturns only the members the association's scope includes, andforge_diff(attributes:)previews only the associations given.Specs:
spec/lib/association_capabilities_spec.rbcovers each capability by id, andspec/lib/association_importer_scenarios_spec.rbwalks through importer runs.Structure: eight
refactor:commits at the top restructure association tracking aroundLaForge::TrackedAssociation(one object per owner and association) andMemberList::Complete/MemberList::Partialreports with methods for each change, hold the forge's writes inPendingWrites, and move LaForge's methods ontoLaForge::Record. Each laforged model has its own subclass,Model.laforge, holding the model-level methods, and each record an instance,record.laforge, holding its methods, pending writes and precedence resolver. The concernsAttributeGroups,Recording,Forging,PrecedenceandInspectionmake it up, and the model delegates the documented API to it, soplace.forge!isplace.laforge.forge!. Behaviour is unchanged; the suite proves it at each commit.Follow-ups
LaForge.tombstone_blockingallowed it, since later reports name it by its id rather than a tombstone. Matching saved members against tombstones would changetombstone_matcher:'s contract and could exclude a legitimate row; the README now describes the narrower behaviour.forge(sources:)filters attribute entries only; association membership is still decided by every active report. Limiting membership to some sources needs its own semantics for the reports left out.forge_diffdoesn't preview what withdrawing a source from retained members changes, down nested lists.recorded_atadvances only when a value changes, so re-recording an unchanged value doesn't protect it from an older report kept pending on an owner; refreshing it for every unchanged attribute would write a row per attribute on each import.LaForge.mergedoesn't remap entries recorded on moved members that point back at the duplicate owner through a belongs_to.LaForge.mergekeeps both records' un-forged pending attributes for the same member, so the newer one wins rather than the merge winner's. Forging both records before merging avoids it.UAT Steps
bundle exec rspec: 309 examples, 0 failures, 2 pending.Closes
Closes #10
Special Handling
🤖 Generated with Claude Code