hecks 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e2957772a5d25ada566a632461d6aa1829b6fb8016d4b563377ba7c856c1ed7d
4
- data.tar.gz: 0e92b2b30420c37c8b92c087e26873223e47cdadcf88d7cbed1583b4a39729ee
3
+ metadata.gz: 0d66cdbdb775a92f3cfcf3f626637cffd6fb81915d7edbcccfe21e1b7c980606
4
+ data.tar.gz: a43786075ea299d26d6d1f23075cf2176fdad15b7efa235234c4a6e321ec15f3
5
5
  SHA512:
6
- metadata.gz: 7a9641aeac769fa7e43131942276f8d5acf121daa2adc5b9d9e1375d5f509588ed3a5bd09b6995bb4c1214dd074dfe27666e8490a44e53f7042131a22f3fd8cd
7
- data.tar.gz: 6cfb8e4e7852d8cbeaf417cf20ae952a94df5d3c12acf79cb8f4c4a0e454ca01a1e04d17956fa2de4f359776f3b6c3b66b199a0fa6eb8fcf7b4389bcdfc834d8
6
+ metadata.gz: ac3883e9e6ebeef723718dfa39785d2dcdd0e57e613f6ca21a9519040eb11278f22eedbe597965d1b28e3dfa08b98ac2d0a989686cd7b8aec0d8d97dd476acec
7
+ data.tar.gz: f610074205ca1a39121169ef02109445b60ca66bf517c1a8351e8c761a1863fccfe0f9b41075fa00cc8225c017c82eefdb47c38566469fe378d0879aec115860
@@ -20,11 +20,14 @@ module Hecks
20
20
  return yield unless @db.transaction_status == PG::PQTRANS_IDLE
21
21
 
22
22
  @db.transaction(&)
23
+ rescue PG::ConnectionBad
24
+ reconnect!
25
+ raise
23
26
  end
24
27
 
25
28
  def outbox_enqueue(rows)
26
29
  rows.filter_map do |row|
27
- result = @db.exec_params(
30
+ result = pg_exec_params(
28
31
  "INSERT INTO hecks_outbox (delivery_id, event_uid, aggregate, domain, kind, consumer, event, status, attempts) " \
29
32
  "VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', 0) ON CONFLICT (delivery_id) DO NOTHING RETURNING id",
30
33
  [row.delivery_id, row.event_uid, row.aggregate, row.domain, row.kind, row.consumer, JSON.generate(row.event)]
@@ -38,7 +41,7 @@ module Hecks
38
41
  end
39
42
 
40
43
  def outbox_claim(id) # rubocop:disable Naming/PredicateMethod
41
- @db.exec_params(
44
+ pg_exec_params(
42
45
  "UPDATE hecks_outbox SET status = 'claimed', attempts = attempts + 1, claimed_at = now() " \
43
46
  "WHERE id = $1 AND status = 'pending'",
44
47
  [id]
@@ -46,7 +49,7 @@ module Hecks
46
49
  end
47
50
 
48
51
  def outbox_settle(id, status:, error: nil) # rubocop:disable Naming/PredicateMethod
49
- @db.exec_params(
52
+ pg_exec_params(
50
53
  "UPDATE hecks_outbox SET status = $2, error = $3, settled_at = now() WHERE id = $1",
51
54
  [id, status.to_s, error]
52
55
  ).cmd_tuples == 1
@@ -59,7 +62,7 @@ module Hecks
59
62
  sql << " AND status = $2"
60
63
  binds << status.to_s
61
64
  end
62
- @db.exec_params("#{sql} ORDER BY id", binds).map do |row|
65
+ pg_exec_params("#{sql} ORDER BY id", binds).map do |row|
63
66
  Runtime::Outbox::Row.new(
64
67
  id: row["id"].to_i, delivery_id: row["delivery_id"], event_uid: row["event_uid"], aggregate: row["aggregate"],
65
68
  domain: row["domain"], kind: row["kind"], consumer: row["consumer"],
@@ -0,0 +1,57 @@
1
+ module Hecks
2
+ module Adapters
3
+ # SELF-HEALING CONNECTION — shared verbatim by `Postgres` and the era
4
+ # plugin's `PostgresEra`, the same way `PostgresOutbox` (outbox.rb) is:
5
+ # nothing here is lineage-specific, and `PostgresOutbox`'s own
6
+ # `@db.exec*` calls route through this module's `pg_exec`/
7
+ # `pg_exec_params` too, since both classes mix it in. Needs `@db` (a
8
+ # `PG::Connection`), `@aggregate`, and `@settings` from the including
9
+ # class — the same three `self.class.connect_for(@aggregate.name,
10
+ # @settings)` already needs to build one in the first place.
11
+ #
12
+ # A backend killed out from under an adapter (a DBA's own
13
+ # `pg_terminate_backend`, a load balancer's failover, a restart) —
14
+ # chaos-tested against the plain `Postgres` adapter: `PG::ConnectionBad`
15
+ # on the query that hit it, and PERMANENTLY on every query after,
16
+ # since nothing ever replaced `@db` with a live connection.
17
+ # `pg_exec`/`pg_exec_params` are the two primitives every other method
18
+ # in either class funnels through — wrapping them here, once,
19
+ # self-heals `@db` for the NEXT caller. THE CURRENT CALL STILL
20
+ # RAISES — reconnecting cannot tell a caller whether ITS OWN write
21
+ # reached the server before the connection died, so silently
22
+ # retrying it here could silently double it; that ambiguity is
23
+ # exactly why `Runtime::SagaInterpreter`'s own defect-retry exists
24
+ # ONE LAYER UP, where a dispatch is retried as a whole (fresh
25
+ # hydrate, fresh `given`s), not as a lone SQL statement.
26
+ module PostgresReconnect
27
+ def pg_exec(sql)
28
+ @db.exec(sql)
29
+ rescue PG::ConnectionBad
30
+ reconnect!
31
+ raise
32
+ end
33
+
34
+ def pg_exec_params(sql, binds)
35
+ @db.exec_params(sql, binds)
36
+ rescue PG::ConnectionBad
37
+ reconnect!
38
+ raise
39
+ end
40
+
41
+ private
42
+
43
+ # BEST-EFFORT — a reconnect attempt that itself fails (the server
44
+ # is actually down, not just this one backend) leaves `@db`
45
+ # unchanged; the `PG::ConnectionBad` already being re-raised by
46
+ # `pg_exec`/`pg_exec_params` above still reaches the caller either
47
+ # way, so swallowing a failed RECONNECT attempt here loses no
48
+ # information — it only avoids masking the original error with a
49
+ # second one.
50
+ def reconnect!
51
+ @db = self.class.connect_for(@aggregate.name, @settings)
52
+ rescue PG::Error
53
+ nil
54
+ end
55
+ end
56
+ end
57
+ end
@@ -28,6 +28,25 @@ module Hecks
28
28
  # `persisted_fields` (Codec), so it never appears in `decode`'s
29
29
  # domain-state hash or `Instance#to_h`.
30
30
  @db.exec("ALTER TABLE #{quoted_table} ADD COLUMN IF NOT EXISTS hecks_version bigint NOT NULL DEFAULT 1")
31
+ # SAME SELF-HEALING SHAPE, FOR DOMAIN ATTRIBUTES THEMSELVES —
32
+ # `hecks_version` above only heals the adapter's own bookkeeping
33
+ # column; a bluebook attribute added (or, via `translations/`,
34
+ # renamed) after this table already exists is not bookkeeping,
35
+ # but the identical gap applies: `CREATE TABLE IF NOT EXISTS`
36
+ # is a no-op against the existing table, so without this a new
37
+ # attribute boots clean and then dies `PG::UndefinedColumn` on
38
+ # the first `project` — discovered chaos-testing a live rename
39
+ # against this adapter (no era, no translation prompt; those
40
+ # live in `PostgresEra` — this is the plain adapter's own,
41
+ # simpler contract: "the table always has every column the
42
+ # bluebook currently declares"). No `NOT NULL`, no `DEFAULT` —
43
+ # existing rows get SQL NULL for a column they never had a
44
+ # value for, exactly what a fresh row would get for an unset
45
+ # optional attribute (`encode_field`'s own nil handling).
46
+ persisted_fields.each do |field|
47
+ @db.exec("ALTER TABLE #{quoted_table} ADD COLUMN IF NOT EXISTS " \
48
+ "#{quote_ident(field[:name])} #{field[:sql_type]}")
49
+ end
31
50
  # RIGHT HERE, NOT A SEPARATE STEP IN `Postgres#initialize` —
32
51
  # same idiom Sqlite::SchemaBuilder's own `create_aggregate_table!`
33
52
  # uses: index creation runs unconditionally, right after the
@@ -10,6 +10,7 @@ require_relative "../../query_specification/field_path"
10
10
  require_relative "../../runtime/errors"
11
11
  require_relative "../../runtime/event"
12
12
  require_relative "postgres/outbox"
13
+ require_relative "postgres/reconnect"
13
14
  require_relative "../../runtime/instance"
14
15
 
15
16
  module Hecks
@@ -46,6 +47,7 @@ module Hecks
46
47
  include SchemaBuilder
47
48
  include Codec
48
49
  include PostgresOutbox
50
+ include PostgresReconnect
49
51
 
50
52
  SQL_TYPES = { "Integer" => "bigint", "Float" => "double precision" }.freeze
51
53
 
@@ -93,6 +95,7 @@ module Hecks
93
95
 
94
96
  def initialize(aggregate:, settings: {}, root: nil)
95
97
  @aggregate = aggregate
98
+ @settings = settings
96
99
  @db = self.class.connect_for(aggregate.name, settings)
97
100
  # THE OPTIONAL saga-persistence capability's own scoping column
98
101
  # (§2/§4) — falls back to the aggregate's own storage name for a
@@ -118,7 +121,7 @@ module Hecks
118
121
  def table = @aggregate.storage_name
119
122
 
120
123
  def find(id)
121
- result = @db.exec_params("SELECT * FROM #{quoted_table} WHERE id = $1", [id.to_s])
124
+ result = pg_exec_params("SELECT * FROM #{quoted_table} WHERE id = $1", [id.to_s])
122
125
  return nil if result.ntuples.zero?
123
126
 
124
127
  instance_from_row(result[0])
@@ -140,13 +143,13 @@ module Hecks
140
143
  order_sql = "ORDER BY #{order_clause(spec, nil)}"
141
144
  end
142
145
 
143
- @db.exec("SELECT * FROM #{quoted_table} #{order_sql}").map { |row| instance_from_row(row) }
146
+ pg_exec("SELECT * FROM #{quoted_table} #{order_sql}").map { |row| instance_from_row(row) }
144
147
  end
145
148
 
146
- def count = @db.exec("SELECT COUNT(*) FROM #{quoted_table}")[0]["count"].to_i
149
+ def count = pg_exec("SELECT COUNT(*) FROM #{quoted_table}")[0]["count"].to_i
147
150
 
148
151
  def append(entry)
149
- @db.exec_params(
152
+ pg_exec_params(
150
153
  "INSERT INTO #{quoted_entry_table} (aggregate_id, operation, state, mirrors) VALUES ($1, $2, $3, $4)",
151
154
  # `mirrors` (unlike `state`) is a NULLABLE column — an absent
152
155
  # mirrors hash must bind a real SQL NULL, not the four-character
@@ -178,7 +181,7 @@ module Hecks
178
181
  # rubocop:disable Metrics/AbcSize -- the CAS/plain upsert split is one
179
182
  # protocol; splitting it would hide the version handshake.
180
183
  def project(entry, expected_version: nil)
181
- return @db.exec_params("DELETE FROM #{quoted_table} WHERE id = $1", [entry.id]) if entry.delete?
184
+ return pg_exec_params("DELETE FROM #{quoted_table} WHERE id = $1", [entry.id]) if entry.delete?
182
185
 
183
186
  instance = Runtime::Instance.new(aggregate: @aggregate, id: entry.id, state: entry.state)
184
187
  columns = (["id"] + persisted_fields.map { |field| field[:name].to_s } + ["hecks_version"])
@@ -195,7 +198,7 @@ module Hecks
195
198
  end
196
199
  sql += " RETURNING hecks_version"
197
200
 
198
- result = @db.exec_params(sql, values)
201
+ result = pg_exec_params(sql, values)
199
202
  return nil if result.ntuples.zero?
200
203
 
201
204
  instance.version = result[0]["hecks_version"].to_i
@@ -204,7 +207,7 @@ module Hecks
204
207
  # rubocop:enable Metrics/AbcSize
205
208
 
206
209
  def entries
207
- @db.exec("SELECT aggregate_id, operation, state, mirrors FROM #{quoted_entry_table} ORDER BY sequence").map do |row|
210
+ pg_exec("SELECT aggregate_id, operation, state, mirrors FROM #{quoted_entry_table} ORDER BY sequence").map do |row|
208
211
  state = JSON.parse(row["state"])
209
212
  Ports::Persistence::Entry.new(
210
213
  operation: row["operation"] || "save",
@@ -216,8 +219,8 @@ module Hecks
216
219
  end
217
220
 
218
221
  def reset!
219
- @db.exec("DELETE FROM #{quoted_table}")
220
- @db.exec("DELETE FROM #{quoted_entry_table}")
222
+ pg_exec("DELETE FROM #{quoted_table}")
223
+ pg_exec("DELETE FROM #{quoted_entry_table}")
221
224
  self
222
225
  end
223
226
 
@@ -239,12 +242,12 @@ module Hecks
239
242
  def atomic_put(entry, insert_only: false)
240
243
  status = nil
241
244
  transaction do
242
- @db.exec_params(
245
+ pg_exec_params(
243
246
  "SELECT pg_advisory_xact_lock(" \
244
247
  "hashtext(current_schema() || ':' || $1), hashtext($2))",
245
248
  [table, entry.id.to_s]
246
249
  )
247
- exists = !@db.exec_params(
250
+ exists = !pg_exec_params(
248
251
  "SELECT 1 FROM #{quoted_table} WHERE id = $1",
249
252
  [entry.id.to_s]
250
253
  ).ntuples.zero?
@@ -269,14 +272,14 @@ module Hecks
269
272
  end
270
273
 
271
274
  def record_event(event)
272
- @db.exec_params(
275
+ pg_exec_params(
273
276
  "INSERT INTO events (name, aggregate, aggregate_id, payload, occurred_at) VALUES ($1, $2, $3, $4, $5)",
274
277
  [event.name, event.aggregate, event.id.to_s, JSON.generate(event.payload), event.occurred_at]
275
278
  )
276
279
  end
277
280
 
278
281
  def events
279
- @db.exec("SELECT * FROM events ORDER BY id").map do |row|
282
+ pg_exec("SELECT * FROM events ORDER BY id").map do |row|
280
283
  Runtime::Event.new(
281
284
  name: row["name"],
282
285
  aggregate: row["aggregate"],
@@ -291,7 +294,7 @@ module Hecks
291
294
  # shape as PostgresEra's own (postgres_era.rb), not lineage-
292
295
  # specific, copied verbatim.
293
296
  def save_saga(process_manager:, correlation:, state:, memory:, completed_compensations: [])
294
- @db.exec_params(
297
+ pg_exec_params(
295
298
  "INSERT INTO hecks_saga_instances (domain, process_manager, correlation, state, memory, completed_compensations) " \
296
299
  "VALUES ($1, $2, $3, $4, $5, $6) " \
297
300
  "ON CONFLICT (domain, process_manager, correlation) DO UPDATE " \
@@ -303,7 +306,7 @@ module Hecks
303
306
  end
304
307
 
305
308
  def delete_saga(process_manager:, correlation:)
306
- @db.exec_params(
309
+ pg_exec_params(
307
310
  "DELETE FROM hecks_saga_instances WHERE domain = $1 AND process_manager = $2 AND correlation = $3",
308
311
  [@domain, process_manager.to_s, correlation.to_s]
309
312
  )
@@ -312,7 +315,7 @@ module Hecks
312
315
  def each_saga
313
316
  return enum_for(:each_saga) unless block_given?
314
317
 
315
- @db.exec_params(
318
+ pg_exec_params(
316
319
  "SELECT process_manager, correlation, state, memory, completed_compensations " \
317
320
  "FROM hecks_saga_instances WHERE domain = $1",
318
321
  [@domain]
@@ -381,7 +384,7 @@ module Hecks
381
384
  end
382
385
 
383
386
  def execute_query(sql, binds)
384
- @db.exec_params(sql, binds).map { |row| instance_from_row(row) }
387
+ pg_exec_params(sql, binds).map { |row| instance_from_row(row) }
385
388
  end
386
389
 
387
390
  # Stamps `.version` (adapter bookkeeping, never domain state — see
@@ -67,7 +67,10 @@ module Hecks
67
67
  # reads it — not the identity unwrap, which is gone : an identity is
68
68
  # declared as a path and followed.
69
69
  reference_id = args.fetch(model.reference_name).to_s
70
- eligible = model.filtered_head_name
70
+ # Plural (ADR 0055) — `on:` lets `where`/`order_by`/`limit`/`offset`
71
+ # each name a different many-side head, so more than one can be
72
+ # eligible in the same read model now.
73
+ eligible = model.filtered_head_names
71
74
 
72
75
  # ROOT FIRST, ALWAYS — see this method's own header. Mirrors
73
76
  # `ReadModelInterpreter#project`'s identical partition, for the
@@ -87,7 +90,7 @@ module Hecks
87
90
  else
88
91
  select_related(aggregate, projected)
89
92
  end
90
- rows = Ports::Query::InMemory.execute(rows, model, args) if head[:as] == eligible
93
+ rows = Ports::Query::InMemory.execute(rows, model.options_for(head[:as]), args) if eligible.include?(head[:as])
91
94
  projected << { aggregate: head[:aggregate], rows: rows }
92
95
  reports[head[:as]] = if head[:many]
93
96
  rows.map { |row| Runtime::Value.materialize(row.to_h) }
@@ -170,9 +170,19 @@ module Hecks
170
170
  # a saga with nothing to do.
171
171
  def read(value) = Literal.read(value)
172
172
 
173
+ # `target:` (ADR 0055) — read straight off the wire, unconverted:
174
+ # it's already the bare aggregate-name STRING `WhereClause#to_h`/
175
+ # `OrderBy#to_h`/`LimitSpec#to_h` wrote (`resolve_target`'s own
176
+ # `Naming.demodulise` already ran once, at DSL-build time; this is
177
+ # the REPLAY path every real boot actually goes through, reading
178
+ # that same wire shape back — see this class's own header). Absent
179
+ # from `clause`/`declared` entirely on older wire data that never
180
+ # declared `on:` — `clause[:target]`/`declared[:target]` reads
181
+ # `nil` for a missing key exactly like an explicit `nil` would,
182
+ # so this is additive, not a migration.
173
183
  def where_clause(clause)
174
184
  QuerySpecification::Common::WhereClause.new(
175
- field: clause[:field], op: clause[:op].to_sym, value: read(clause[:value])
185
+ field: clause[:field], op: clause[:op].to_sym, value: read(clause[:value]), target: clause[:target]
176
186
  )
177
187
  end
178
188
 
@@ -180,14 +190,14 @@ module Hecks
180
190
  return nil unless declared
181
191
 
182
192
  QuerySpecification::Common::OrderBy.new(
183
- field: declared[:field], direction: declared[:direction].to_sym
193
+ field: declared[:field], direction: declared[:direction].to_sym, target: declared[:target]
184
194
  )
185
195
  end
186
196
 
187
197
  def limit(declared)
188
198
  return nil unless declared
189
199
 
190
- QuerySpecification::Common::LimitSpec.new(value: read(declared[:value]))
200
+ QuerySpecification::Common::LimitSpec.new(value: read(declared[:value]), target: declared[:target])
191
201
  end
192
202
 
193
203
  # EVERY OTHER SPECIFICATION OPTION, from one table.
@@ -14,14 +14,59 @@ module Hecks
14
14
 
15
15
  def query_name = Naming.snake(@name)
16
16
 
17
- # WHICH GATHERED HEAD THE FILTERING APPLIES TO, so the read-model
18
- # interpreter can ask this directly rather than re-deriving or
19
- # re-checking it.
20
- def filtered_head_name
21
- return nil unless wheres.any? || order_by || limit || offset || authorization&.tenant ||
22
- @group_by.any? || count? || @median_field
23
-
24
- @aggregate_heads.find { |head| head[:many] }&.fetch(:as)
17
+ # WHICH GATHERED HEADS THE FILTERING APPLIES TO (ADR 0055) — plural,
18
+ # since `where`/`order_by`/`limit`/`offset` can now each independently
19
+ # name a many-side head via `on:` once there's more than one. A read
20
+ # model with a single many-side head keeps the old reading: every
21
+ # UNTARGETED option (plus `group_by`/`count`/`median`, still
22
+ # single-head-only ADR 0055) applies to it, same as before `on:`
23
+ # existed. With several many-side heads, only the ones actually named
24
+ # by a targeted option are eligible.
25
+ def filtered_head_names
26
+ many = @aggregate_heads.select { |head| head[:many] }
27
+ return [] if many.empty?
28
+
29
+ return single_filtered_head_name(many) if many.one?
30
+
31
+ targets = (wheres.map(&:target) + [order_by&.target, limit&.target, offset&.target]).compact.uniq
32
+ targets.filter_map { |target| many.find { |head| head[:aggregate] == target.to_s } }.map { |head| head[:as] }
33
+ end
34
+
35
+ # The pre-`on:` reading (ADR 0055), unchanged: with exactly one
36
+ # many-side head, every UNTARGETED option (plus `group_by`/`count`/
37
+ # `median`, still single-head-only) applies to it — split out only
38
+ # to keep `filtered_head_names` itself under this file's own
39
+ # complexity budget, not because the two questions differ in kind.
40
+ def single_filtered_head_name(many)
41
+ declared = wheres.any? || order_by || limit || offset || authorization&.tenant ||
42
+ @group_by.any? || count? || @median_field
43
+ declared ? [many.first[:as]] : []
44
+ end
45
+
46
+ # THE where/order_by/limit/offset THAT APPLY TO ONE ELIGIBLE HEAD
47
+ # (ADR 0055) — a small view `Ports::Query::InMemory.execute` reads
48
+ # exactly the way it already reads a whole `Query`/`ReadModel`
49
+ # (`.wheres`/`.order_by`/`.limit`/`.offset`/`.null_semantics`), scoped
50
+ # to `head_as`'s own aggregate: an UNTARGETED option applies when
51
+ # `head_as` is the read model's ONE many-side head (the pre-`on:`
52
+ # reading, unchanged) ; a TARGETED one applies when its `target`
53
+ # resolves to `head_as`'s own aggregate.
54
+ FilteredOptions = Struct.new(:wheres, :order_by, :limit, :offset, :null_semantics)
55
+
56
+ def options_for(head_as)
57
+ many = @aggregate_heads.select { |head| head[:many] }
58
+ aggregate_name = @aggregate_heads.find { |head| head[:as] == head_as }&.fetch(:aggregate)
59
+ applies = lambda do |target|
60
+ target.nil? ? many.one? : target.to_s == aggregate_name
61
+ end
62
+
63
+ FilteredOptions.new(
64
+ wheres.select { |where| applies.call(where.target) },
65
+ order_by && applies.call(order_by.target) ? order_by : nil,
66
+ limit && applies.call(limit.target) ? limit : nil,
67
+ offset && applies.call(offset.target) ? offset : nil,
68
+ null_semantics
69
+ )
25
70
  end
26
71
  end
27
72
  end
@@ -88,13 +88,18 @@ module Hecks
88
88
  end
89
89
 
90
90
  # `optional:` — matching `CommandBuilder#reference_to`'s own
91
- # signature, which already had it; this one never forwarded it
92
- # to `attribute_impl()` even though `attribute_impl()` itself
93
- # already accepts it. A real gap: an aggregate that can point at
94
- # ONE OF several targets (Item's own `personal_list_id`/
91
+ # signature, which already had it; this one used to never
92
+ # forward it to `attribute_impl()`/`relationship_attribute`
93
+ # even though those already accept it closed in the same
94
+ # commit that added this comment (`optional: optional`, below).
95
+ # It was a real gap because an aggregate that can point at ONE
96
+ # OF several targets (Item's own `personal_list_id`/
95
97
  # `camping_list_id`, never both) needs each reference optional
96
98
  # on the aggregate's own persisted schema, not just as a
97
- # command's input.
99
+ # command's input — real corpus use:
100
+ # `spec/fixtures/hop_chain.bluebook`'s own `Proposal` aggregate
101
+ # declares `reference_to Engagement, optional: true` at the
102
+ # aggregate head.
98
103
  # RENAMED FROM `reference_to` — item #13's full metaprogrammed
99
104
  # dispatch (slice 4b). Bootstrap-reachable (every core/attached
100
105
  # grammar chapter uses reference_to to describe itself), so also
@@ -54,6 +54,72 @@ module Hecks
54
54
  @includes << [Naming.demodulise(type), as]
55
55
  end
56
56
 
57
+ # `on:` (ADR 0055) — OVERRIDES of `QuerySpecification::Common::DSL`'s
58
+ # shared `where_impl`/`order_by_impl`/`limit_impl`/`offset_impl`,
59
+ # scoped to `ReadModelBuilder` alone rather than added to the shared
60
+ # module `Query` also mixes in: a plain `query` has no
61
+ # `aggregate_heads` at all, so `on:` there would be a silently-
62
+ # ignored no-op rather than a real answer. Overriding only here
63
+ # means a `Query`'s own `where(..., on: X)` gets Ruby's own loud
64
+ # `unknown keyword: :on` instead of quietly doing nothing.
65
+ #
66
+ # `on:` names the target by TYPE (`on: Character`), resolved the
67
+ # same way `reference_to`/`include` already resolve their own type
68
+ # argument (`Naming.demodulise`) — not by the include's own `as:`
69
+ # alias. A read model that `include`s the SAME type twice under two
70
+ # different `as:` has no way to say which one `on:` means today; no
71
+ # real corpus read model does this, so it's a real, deliberate scope
72
+ # limit (see ADR 0055), not an oversight.
73
+ #
74
+ # `*positional, on:, **rest` rather than a plain `(clauses, on: nil)`
75
+ # — found necessary, not stylistic, by reproducing the failure
76
+ # directly: `where(status: "disputed")` reaches here with its
77
+ # `status: "disputed"` captured as `**kwargs` (GenericDispatch's own
78
+ # `builder.send(calls, *args, **kwargs, &block)`), and Ruby stops
79
+ # auto-converting a bare `**hash` call into a plain positional Hash
80
+ # THE MOMENT a method declares any real keyword parameter — so a
81
+ # `(clauses, on: nil)` signature raised "wrong number of arguments
82
+ # (given 0, expected 1)" on every ordinary `where(field: value)`
83
+ # call, never reaching `on:` at all. `**rest` sidesteps this: Ruby
84
+ # still auto-splits `on:` into the declared keyword and gathers
85
+ # every OTHER key into `rest` regardless of how the caller wrote it.
86
+ #
87
+ # `QuerySpecification::Common::WhereClause` etc — FULLY QUALIFIED,
88
+ # not the bare names `dsl.rb`'s own shared `where_impl` gets away
89
+ # with. That file is lexically nested inside `Common` itself, so
90
+ # `WhereClause` resolves directly; this class is nested inside
91
+ # `Bluebook::DSL`, which has no lexical or ancestor path to
92
+ # `QuerySpecification::Common` at all — a bare `WhereClause` here
93
+ # falls through to `const_missing` and, mid-bluebook-load, that's
94
+ # `ConstShim`, which resolves it against the self-hosted grammar
95
+ # domain's OWN unrelated `WhereClause` construct instead (a `Module`,
96
+ # not this `Struct`) — found directly by reproducing "undefined
97
+ # method `new' for module WhereClause" against a real corpus load,
98
+ # not guessed.
99
+ def where_impl(*positional, on: nil, **rest)
100
+ raise ArgumentError, "wrong number of arguments (given #{positional.size}, expected 1)" if positional.size > 1
101
+
102
+ @wheres ||= []
103
+ target = resolve_target(on)
104
+ clauses = (positional.first || {}).merge(rest)
105
+ clauses.each do |field, value|
106
+ op, operand = split_comparator(value)
107
+ @wheres << QuerySpecification::Common::WhereClause.new(field: field, op: op, value: operand, target: target)
108
+ end
109
+ end
110
+
111
+ def order_by_impl(field, direction = :asc, on: nil)
112
+ @order_by = QuerySpecification::Common::OrderBy.new(field: field, direction: direction, target: resolve_target(on))
113
+ end
114
+
115
+ def limit_impl(value, on: nil)
116
+ @limit = QuerySpecification::Common::LimitSpec.new(value: value, target: resolve_target(on))
117
+ end
118
+
119
+ def offset_impl(value, on: nil)
120
+ @offset = QuerySpecification::Common::OffsetSpec.new(value: value, target: resolve_target(on))
121
+ end
122
+
57
123
  # NAMES which of the eligible head's own fields to nest its rows
58
124
  # under — one level per field, the leaf being that row with the
59
125
  # named fields removed (they're already spent, as the keys that
@@ -142,27 +208,70 @@ module Hecks
142
208
 
143
209
  private
144
210
 
145
- # where/order_by/limit/offset/authorize's tenant all apply to exactly
146
- # one collection — the single `include`d aggregate whose head is
147
- # "many" (the "one" side, the reference target itself, is a single
148
- # row; ordering, paging, or tenant-scoping one row means nothing). A
149
- # read model with zero many-heads has nothing for them to filter ;
150
- # one with several has no way to say WHICH of several unrelated
151
- # collections a caller meant — so both refuse here rather than
152
- # silently applying to an arbitrary one. `authorize`'s tenant counts
153
- # here too TenantScope enforces it against this same head, so an
154
- # ambiguous target is exactly as unusable as it is for the others.
211
+ # where/order_by/limit/offset/authorize's tenant all apply to
212
+ # collections — the `include`d aggregates whose heads are "many"
213
+ # (the "one" side, the reference target itself, is a single row;
214
+ # ordering, paging, or tenant-scoping one row means nothing). ADR
215
+ # 0055 gave `where`/`order_by`/`limit`/`offset` an `on:` to name
216
+ # WHICH many-side collection they mean, so this asks two questions
217
+ # now instead of one:
218
+ #
219
+ # 1. Does every declared `on:` actually name a many-side included
220
+ # aggregate? Checked regardless of how many many-side heads
221
+ # exist — a typo refuses immediately, not only once ambiguity
222
+ # would otherwise bite.
223
+ # 2. Is there still an UNTARGETED option declared (including
224
+ # `authorize`'s own `tenant:`, which has no `on:` of its own —
225
+ # a real, deliberate scope limit, see ADR 0055)? An untargeted
226
+ # option still needs exactly one many-side head to mean
227
+ # anything unambiguous — the ORIGINAL rule, unchanged, and
228
+ # still worded the same way (`spec/runtime/
229
+ # read_model_interpreter_spec.rb`'s existing refusal regex
230
+ # still matches).
231
+ #
232
+ # A read model with several many-side heads is legal precisely when
233
+ # every declared option names one; a read model with a single
234
+ # many-side head is unaffected either way, `on:` or not.
155
235
  def seal_query_options
156
- declared = @wheres&.any? || @order_by || @limit || @offset || @authorization&.tenant
157
- return unless declared
236
+ many = Array(@aggregate_heads).select { |head| head[:many] }
158
237
 
159
- many = Array(@aggregate_heads).count { |head| head[:many] }
160
- return if many == 1
238
+ validate_declared_targets!(many)
239
+ return unless untargeted_option_declared?
240
+ return if many.size == 1
161
241
 
162
242
  raise Malformed,
163
- "#{@name} declares where/order_by/limit/offset but includes #{many} many-side " \
243
+ "#{@name} declares where/order_by/limit/offset but includes #{many.size} many-side " \
164
244
  "aggregates, not exactly one — these options apply to a single collection; " \
165
- "name which one by including only it, or drop the options"
245
+ "name which one with `on:` (e.g. `where(field: value, on: Character)`), or drop the options"
246
+ end
247
+
248
+ # Question 1 of `seal_query_options`'s own two, split out to keep
249
+ # both under the same "one job per method" shape every OTHER seal in
250
+ # this file already holds to (each raises its own one Malformed, for
251
+ # its own one reason).
252
+ def validate_declared_targets!(many)
253
+ many_by_aggregate = many.to_h { |head| [head[:aggregate], head] }
254
+ declared_targets = Array(@wheres).map(&:target) + [@order_by&.target, @limit&.target, @offset&.target]
255
+
256
+ declared_targets.compact.uniq.each do |target|
257
+ next if many_by_aggregate.key?(target)
258
+
259
+ raise Malformed,
260
+ "#{@name}'s `on: #{target}` doesn't name one of its own many-side included " \
261
+ "aggregates (it includes #{many.map { |head| head[:aggregate] }.join(', ')} as " \
262
+ "many-side heads)"
263
+ end
264
+ end
265
+
266
+ # Question 2 of `seal_query_options`'s own two — see that method's
267
+ # header. `authorize`'s own `tenant:` has no `on:` at all (ADR 0055's
268
+ # own documented scope limit), so it always counts as untargeted.
269
+ def untargeted_option_declared?
270
+ Array(@wheres).any? { |where| where.target.nil? } ||
271
+ (@order_by && @order_by.target.nil?) ||
272
+ (@limit && @limit.target.nil?) ||
273
+ (@offset && @offset.target.nil?) ||
274
+ @authorization&.tenant
166
275
  end
167
276
 
168
277
  # Same shape as `seal_query_options`, same reason — `group_by`
@@ -227,6 +336,12 @@ module Hecks
227
336
  "pagination — use limit/offset instead"
228
337
  end
229
338
 
339
+ # `on:`'s own resolution (ADR 0055) — same demodulise `reference_to`/
340
+ # `include` already use for their own type argument. `nil` when `on:`
341
+ # is omitted, matching every other optional field's "absent, not
342
+ # false" reading in this file.
343
+ def resolve_target(on) = on && Naming.demodulise(on)
344
+
230
345
  def add_aggregate_head(type, name, many:)
231
346
  @aggregate_heads ||= []
232
347
  target = Naming.demodulise(type)
@@ -147,6 +147,17 @@ module Hecks
147
147
  yield
148
148
  end
149
149
 
150
+ # ONLY an adapter advertising `:cross_process_lock` (PostgresEra —
151
+ # see ADR 0036) implements this; `run_dispatch_order_with_isolation`
152
+ # (runtime/interpreting.rb) checks `capabilities` before ever
153
+ # calling it, so the plain `yield` fallback here only guards
154
+ # against a stray direct call, not the real dispatch path.
155
+ def with_write_lock(&)
156
+ return @adapter.with_write_lock(&) if @adapter.respond_to?(:with_write_lock)
157
+
158
+ yield
159
+ end
160
+
150
161
  # THE OUTBOX CONTRACT — four optional adapter methods, probed
151
162
  # together the way `save_saga`/`delete_saga`/`each_saga` are
152
163
  # (`Registry::SagaPersistence`): an adapter either has an outbox