undertow 0.2.0 → 0.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: 0b2c91ae2e1c3eafff3e52438ca4a8c2e2856fd83c2c1097708d3e7f2ed457be
4
- data.tar.gz: 385394ce28a3034f17d4e89d3d00528931f38c44a180b338c5bfb43c83b4877b
3
+ metadata.gz: 4f841e4e433bebfa87d5086cdf621af5ede70a74754a98031645aa6181fcc8eb
4
+ data.tar.gz: b138e06bb8e54eaf5c40223fc07d08b6148bdbeefc7c9cfb8512b4b23c3a7d7b
5
5
  SHA512:
6
- metadata.gz: 57614c035d133873d7f3c9b881bc6c59de51c0f2add8bc860eb059f955bdd4cc7a57576e99df1085576db5b64fbdfd7c6b4376136093b46ed259e2e70dc3f87b
7
- data.tar.gz: 20cca40fdfe6459d18b264050ce5db678d6d94e537d3bd19a7fbe3a61fb5b7e32bb13bd859a88d263f20d8df4473cc178d6796470d609add092d66ca60229da9
6
+ metadata.gz: 4d31057a9a903b49a76208fbbeca609aa43ae43dcde2bd3dc98b142118f2694bc648cf1a4ee44958a7c52c1bbd9e3a85fc160c67105748096efadd6e7fb101d0
7
+ data.tar.gz: ef556acc038295b60fb3083fd3c79f70537f57d773f8ec10d0ead41b7c37019cdda3e20e035e2d0c407b1a5a4bc220562dcb4bd1beb471b596f9466777e2f8e4
data/lib/undertow/dsl.rb CHANGED
@@ -5,14 +5,14 @@ module Undertow
5
5
  # that calls these methods automatically registers itself with Undertow and
6
6
  # gets Trackable behavior wired in at boot, no include needed.
7
7
  #
8
- # class Activity < ApplicationRecord
9
- # undertow_on_drain ->(model_name, ids, deleted_ids) { ActivityReindexJob.perform_later(ids, deleted_ids) }
10
- # undertow_skip %w[lock_version searchkick_reindexing]
8
+ # class Post < ApplicationRecord
9
+ # undertow_on_drain ->(model_name, ids, deleted_ids) { PostSyncJob.perform_later(ids, deleted_ids) }
10
+ # undertow_skip %w[view_count updated_at]
11
11
  #
12
- # undertow_depends_on :provider, foreign_key: :provider_id, watched_columns: %w[approved mobile]
13
- # undertow_depends_on :location_series,
14
- # resolver: ->(ls) { Activity.where(series_id: ls.series_id) },
15
- # watched_columns: %w[location_id hidden]
12
+ # undertow_depends_on :author, foreign_key: :author_id, watched_columns: %w[name bio]
13
+ # undertow_depends_on :tag,
14
+ # resolver: ->(tag) { Post.joins(:post_tags).where(post_tags: { tag_id: tag.id }) },
15
+ # watched_columns: %w[name slug]
16
16
  # end
17
17
  #
18
18
  module DSL
@@ -21,19 +21,27 @@ module Undertow
21
21
  _undertow_ensure_trackable!
22
22
  end
23
23
 
24
+ # Suppress self-tracking when these root-model columns are the only changes.
25
+ # Accepts strings or symbols; blanks are ignored.
24
26
  def undertow_skip(columns)
25
- _undertow_config.skip_columns = columns
27
+ _undertow_config.skip_columns = Array(columns).map(&:to_s).reject(&:blank?).uniq
26
28
  _undertow_ensure_trackable!
27
29
  end
28
30
 
31
+ # Track invalidations from an upstream association.
32
+ # `watched_columns` accepts strings or symbols; blanks are ignored.
33
+ # Empty/nil means no watched-column filter.
29
34
  def undertow_depends_on(association, foreign_key: nil, resolver: nil, watched_columns: nil)
30
35
  raise ArgumentError, 'provide exactly one of foreign_key: or resolver:' unless foreign_key.nil? ^ resolver.nil?
31
36
 
37
+ normalized_watched = Array(watched_columns).map(&:to_s).
38
+ reject(&:blank?).uniq.presence
39
+
32
40
  _undertow_config.dependencies << {
33
41
  association: association,
34
42
  foreign_key: foreign_key,
35
43
  resolver: resolver,
36
- watched_columns: watched_columns
44
+ watched_columns: normalized_watched
37
45
  }.freeze
38
46
  _undertow_ensure_trackable!
39
47
  end
@@ -4,8 +4,9 @@ module Undertow
4
4
  # ActiveRecord concern mixed in automatically when a model uses the Undertow DSL
5
5
  # (undertow_on_drain, undertow_skip, undertow_depends_on). Never included manually.
6
6
  #
7
- # Provides class-level callback registration and the skip_columns guard.
8
- # Callbacks are wired at boot by the Railtie after all models are loaded.
7
+ # Provides class-level callback registration and dependency push handlers, plus
8
+ # instance-level self-tracking handlers. Callbacks are wired at boot by the Railtie
9
+ # after all models are loaded.
9
10
  module Trackable
10
11
  extend ActiveSupport::Concern
11
12
 
@@ -30,86 +31,117 @@ module Undertow
30
31
  (config.dependencies || []).each { |dep| _register_dep_callbacks!(dep) }
31
32
  end
32
33
 
34
+ def _push_undertow_pending(ids)
35
+ Buffer.push_pending(name, ids)
36
+ end
37
+
38
+ def _push_undertow_deleted(ids)
39
+ Buffer.push_deleted(name, ids)
40
+ end
41
+
42
+ # Enqueues records into the pending buffer without going through AR
43
+ # callbacks. Use this to replay a batch through the drain handler without
44
+ # triggering other model lifecycle callbacks, e.g. from a console or a
45
+ # data migration.
46
+ #
47
+ # Post.undertow_requeue(Post.where(author_id: 123))
48
+ # Post.undertow_requeue([136543, 136544])
49
+ # Post.undertow_requeue # all records
50
+ def undertow_requeue(scope_or_ids = :all)
51
+ ids = case scope_or_ids
52
+ when :all then pluck(:id)
53
+ when ActiveRecord::Relation then scope_or_ids.pluck(:id)
54
+ else Array(scope_or_ids)
55
+ end
56
+ _push_undertow_pending(ids)
57
+ end
58
+
59
+ def _push_dep_pending(record, dep)
60
+ ids = _dep_ids_for(record, dep)
61
+ _push_undertow_pending(ids) if ids.any?
62
+ end
63
+
64
+ def _push_dep_updated(record, dep)
65
+ watched = dep[:watched_columns]
66
+ # Two non-obvious cases where after_commit on :update fires:
67
+ # - no-op save: saved_changes is {}, suppressed when watched_columns is configured
68
+ # - touch (e.g. belongs_to touch: true): saved_changes contains only updated_at,
69
+ # suppressed only when watched_columns is configured and updated_at is not in it
70
+ return if watched && (record.saved_changes.keys & watched).none?
71
+
72
+ _push_dep_pending(record, dep)
73
+ end
74
+
33
75
  private
34
76
 
35
77
  def _register_self_callbacks!
36
- after_commit :_push_self_pending, on: %i[create update]
78
+ after_commit :_push_self_created, on: :create
79
+ after_commit :_push_self_updated, on: :update
37
80
  after_destroy :_push_self_deleted
38
- after_restore :_push_self_pending if respond_to?(:after_restore)
81
+ after_restore :_push_self_restored if respond_to?(:after_restore)
39
82
  end
40
83
 
41
84
  def _register_dep_callbacks!(dep)
42
85
  dep_class = _resolve_dep_class(dep)
43
- return unless dep_class
44
-
45
86
  root_class = self
46
- watched = dep[:watched_columns].presence # [] treated same as nil, watch all
47
-
48
- resolver = dep[:resolver] || begin
49
- fk = dep[:foreign_key]
50
- ->(record) { root_class.where(fk => record.id) }
51
- end
52
87
 
53
- push_pending = ->(record) {
54
- ids = resolver.call(record).pluck(:id)
55
- next unless ids.any?
88
+ dep_class.after_commit(on: :create) { root_class._push_dep_pending(self, dep) }
89
+ dep_class.after_commit(on: :update) { root_class._push_dep_updated(self, dep) }
56
90
 
57
- root_class._push_undertow_pending(ids)
58
- }
91
+ # Soft-delete gems fire run_callbacks(:destroy), triggering after_destroy, but
92
+ # mark the record deleted via update_columns (bypassing after_commit),
93
+ # so the create/update callbacks above never double-fire on a soft delete.
94
+ dep_class.after_destroy { root_class._push_dep_pending(self, dep) }
59
95
 
60
- # Skip create/update callback when watched_columns is set and none changed.
61
- # Note: saved_changes is empty when touched via belongs_to touch: true (bypasses
62
- # dirty tracking), that correctly falls through to skip here.
63
- dep_class.after_commit on: %i[create update] do
64
- next if watched && (saved_changes.keys & watched).none?
65
-
66
- push_pending.call(self)
67
- end
68
-
69
- # Dep destroyed, reindex surviving root records. SoftDeletable calls
70
- # run_callbacks(:destroy), which fires after_destroy, but update_columns does NOT
71
- # trigger after_commit, so scoping after_commit to [:create, :update] above
72
- # ensures destroy commits don't double-fire.
73
- dep_class.after_destroy { push_pending.call(self) }
74
-
75
- # Dep restored, after_restore is the only hook that fires because restore!
76
- # uses update_columns, bypassing after_commit.
77
- if dep_class.respond_to?(:after_restore)
78
- dep_class.after_restore { push_pending.call(self) }
79
- end
96
+ # Soft-delete restores use update_columns to unmark the record, bypassing
97
+ # after_commit. after_restore is the only hook that fires for restores.
98
+ dep_class.after_restore { root_class._push_dep_pending(self, dep) } if dep_class.respond_to?(:after_restore)
80
99
  end
81
100
 
82
101
  def _resolve_dep_class(dep)
83
- if dep[:resolver]
102
+ reflect_on_association(dep[:association])&.klass ||
84
103
  dep[:association].to_s.classify.constantize
104
+ end
105
+
106
+ def _dep_ids_for(record, dep)
107
+ if dep[:resolver]
108
+ dep[:resolver].call(record).pluck(:id)
85
109
  else
86
- reflect_on_association(dep[:association])&.klass ||
87
- dep[:association].to_s.classify.constantize
110
+ where(dep[:foreign_key] => record.id).pluck(:id)
88
111
  end
89
112
  end
113
+ end
90
114
 
91
- public
92
-
93
- def _push_undertow_pending(ids)
94
- Buffer.push_pending(name, ids)
95
- end
115
+ private
96
116
 
97
- def _push_undertow_deleted(ids)
98
- Buffer.push_deleted(name, ids)
99
- end
117
+ def _push_self_created
118
+ _enqueue_self_pending
100
119
  end
101
120
 
102
- private
121
+ def _push_self_updated
122
+ return unless _self_update_requires_invalidation?
103
123
 
104
- def _push_self_pending
105
- ignored = self.class._undertow_ignored_columns
106
- return if ignored.any? && saved_changes.any? && (saved_changes.keys - ignored).empty?
124
+ _enqueue_self_pending
125
+ end
107
126
 
108
- self.class._push_undertow_pending([id])
127
+ def _push_self_restored
128
+ _enqueue_self_pending
109
129
  end
110
130
 
111
131
  def _push_self_deleted
112
132
  self.class._push_undertow_deleted([id])
113
133
  end
134
+
135
+ def _enqueue_self_pending
136
+ self.class._push_undertow_pending([id])
137
+ end
138
+
139
+ def _self_update_requires_invalidation?
140
+ changed = saved_changes.keys
141
+ return false if changed.empty?
142
+
143
+ ignored = self.class._undertow_ignored_columns
144
+ (changed - ignored).any?
145
+ end
114
146
  end
115
147
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Undertow
4
- VERSION = '0.2.0'
4
+ VERSION = '0.3.0'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: undertow
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Allen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-06 00:00:00.000000000 Z
11
+ date: 2026-07-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord