hecks 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/heki/journal.rb +57 -0
  3. data/lib/hecks/adapters/driven/postgres/outbox.rb +7 -4
  4. data/lib/hecks/adapters/driven/postgres/reconnect.rb +57 -0
  5. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +19 -0
  6. data/lib/hecks/adapters/driven/postgres.rb +20 -17
  7. data/lib/hecks/adapters/driven/postgres_era.adapter +5 -0
  8. data/lib/hecks/adapters/driven/sqlite/projection.rb +5 -2
  9. data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
  10. data/lib/hecks/behaviors/expectations.rb +32 -4
  11. data/lib/hecks/bluebook/assembly/marks.rb +13 -3
  12. data/lib/hecks/bluebook/behaviour/domain_port.rb +24 -0
  13. data/lib/hecks/bluebook/behaviour/read_model.rb +53 -8
  14. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +10 -5
  15. data/lib/hecks/bluebook/dsl/read_model_builder.rb +131 -16
  16. data/lib/hecks/bluebook/meta_validator/judge.rb +25 -3
  17. data/lib/hecks/bluebook/model_check.rb +148 -17
  18. data/lib/hecks/forms/field_shape.rb +5 -3
  19. data/lib/hecks/fuzzing/concurrent_dispatch.rb +266 -0
  20. data/lib/hecks/fuzzing/era_boundary.rb +105 -0
  21. data/lib/hecks/fuzzing/form_census.rb +184 -0
  22. data/lib/hecks/fuzzing/isolated_boot.rb +198 -10
  23. data/lib/hecks/fuzzing/persistence_parity.rb +163 -0
  24. data/lib/hecks/fuzzing/properties/corrections.rb +100 -0
  25. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +188 -13
  26. data/lib/hecks/fuzzing/properties/guards.rb +103 -0
  27. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +34 -1
  28. data/lib/hecks/fuzzing/properties/outbox.rb +142 -0
  29. data/lib/hecks/fuzzing/properties.rb +23 -2
  30. data/lib/hecks/fuzzing/replay.rb +130 -20
  31. data/lib/hecks/fuzzing/rotation_priority.rb +94 -0
  32. data/lib/hecks/fuzzing/self_consistency.rb +647 -0
  33. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +526 -0
  34. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +90 -26
  35. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +50 -4
  36. data/lib/hecks/fuzzing/sequence_generator/picker.rb +11 -0
  37. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +73 -9
  38. data/lib/hecks/fuzzing/sequence_generator.rb +47 -14
  39. data/lib/hecks/fuzzing/structural_skips.rb +146 -0
  40. data/lib/hecks/fuzzing/sweep_depth.rb +53 -0
  41. data/lib/hecks/fuzzing/target_capabilities.rb +149 -0
  42. data/lib/hecks/fuzzing/value_generator.rb +55 -3
  43. data/lib/hecks/fuzzing.rb +6 -0
  44. data/lib/hecks/language/bluebook/vocabulary.bluebook +17 -2
  45. data/lib/hecks/naming.rb +70 -2
  46. data/lib/hecks/ports/persistence/append_only.rb +11 -0
  47. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +50 -0
  48. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +14 -0
  49. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +85 -5
  50. data/lib/hecks/ports/persistence/repository_factory.rb +8 -5
  51. data/lib/hecks/projections/glossary/html.rb +250 -0
  52. data/lib/hecks/projections/glossary/markdown.rb +105 -0
  53. data/lib/hecks/projections/glossary/mermaid.rb +110 -0
  54. data/lib/hecks/projections/glossary/page.css +271 -0
  55. data/lib/hecks/projections/glossary/page.js +72 -0
  56. data/lib/hecks/projections/glossary/sections.rb +17 -0
  57. data/lib/hecks/projections/glossary/sentences.rb +205 -0
  58. data/lib/hecks/projections/glossary.rb +302 -0
  59. data/lib/hecks/projections.rb +1 -0
  60. data/lib/hecks/projector/cli_projector.rb +29 -13
  61. data/lib/hecks/projector/exporter.rb +24 -0
  62. data/lib/hecks/projector/narrate_projector.rb +4 -11
  63. data/lib/hecks/query_specification/common/comparison.rb +27 -1
  64. data/lib/hecks/query_specification/common/limit_spec.rb +9 -2
  65. data/lib/hecks/query_specification/common/offset_spec.rb +9 -2
  66. data/lib/hecks/query_specification/common/order_by.rb +9 -2
  67. data/lib/hecks/query_specification/common/where_clause.rb +15 -2
  68. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +26 -8
  69. data/lib/hecks/runtime/command_rules/references.rb +75 -0
  70. data/lib/hecks/runtime/entity_element.rb +168 -16
  71. data/lib/hecks/runtime/entity_interpreter.rb +68 -3
  72. data/lib/hecks/runtime/interpreting.rb +16 -7
  73. data/lib/hecks/runtime/query_interpreter.rb +66 -2
  74. data/lib/hecks/runtime/reaction_invocation.rb +70 -3
  75. data/lib/hecks/runtime/read_model_interpreter.rb +5 -4
  76. data/lib/hecks/runtime/refusal_wording.rb +5 -2
  77. data/lib/hecks/runtime/registry.rb +12 -0
  78. data/lib/hecks/runtime/routing.rb +67 -2
  79. data/lib/hecks/runtime/saga_interpreter.rb +38 -1
  80. data/lib/hecks/runtime/value/coercion.rb +77 -52
  81. data/lib/hecks/runtime/value/entity_list_coercion.rb +248 -0
  82. data/lib/hecks/runtime/value.rb +7 -2
  83. data/lib/hecks/version.rb +1 -1
  84. data/lib/hecks/vocabulary.rb +2 -1
  85. metadata +25 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e2957772a5d25ada566a632461d6aa1829b6fb8016d4b563377ba7c856c1ed7d
4
- data.tar.gz: 0e92b2b30420c37c8b92c087e26873223e47cdadcf88d7cbed1583b4a39729ee
3
+ metadata.gz: b85a8382bb201f9ab54417b3535d8fe1ede57c842f627e32a2ff75d7f2dd6618
4
+ data.tar.gz: e4852fd6b22145bec8a215e4c46fc6347527f7bd05811ff7da780e74dc24fe1d
5
5
  SHA512:
6
- metadata.gz: 7a9641aeac769fa7e43131942276f8d5acf121daa2adc5b9d9e1375d5f509588ed3a5bd09b6995bb4c1214dd074dfe27666e8490a44e53f7042131a22f3fd8cd
7
- data.tar.gz: 6cfb8e4e7852d8cbeaf417cf20ae952a94df5d3c12acf79cb8f4c4a0e454ca01a1e04d17956fa2de4f359776f3b6c3b66b199a0fa6eb8fcf7b4389bcdfc834d8
6
+ metadata.gz: a538ddbe90fed2ccfa8c066f00777699b0eadade3c6523e6fbf5f62c19385ab7ace8e2c8de6fabafffce46abbefd36642401f6e5f23696127445378e95d98aad
7
+ data.tar.gz: e22574943d4246998ba9121c0c84d7b39ec944416066ad881f144f166a09deb5c1a1fbb8b6c8378084b89856ed9a4e5bfd9c66a3cd6d07fa9a9a21eee77d63fc
@@ -17,8 +17,65 @@ module Hecks
17
17
  end
18
18
  end
19
19
 
20
+ # An EXPLICIT, opt-in maintenance operation — never run
21
+ # automatically after an ordinary save/delete. Heki's journal is
22
+ # not a disposable write-ahead log: it is this adapter's own
23
+ # answer to `entries`, and `entries` is a real port contract
24
+ # (`Ports::Persistence::AppendOnly`'s own required-methods list)
25
+ # read in full, forever, by `Ports::Projection::Worker#catch_up!`
26
+ # and `Registry#projection_current?` to catch a projection up to
27
+ # its authoritative source, and by `bin/history` to show "every
28
+ # journal entry a domain's append-only adapters hold" — the same
29
+ # contract Postgres/Sqlite/D1 uphold by way of a journal TABLE
30
+ # that is never pruned. A real example (`examples/banking`,
31
+ # `persisted_by("Heki")` + `projected_by("SqliteProjection")`)
32
+ # depends on this today. Compacting throws that full history away
33
+ # for whatever happened before the call — correct only for an
34
+ # aggregate nothing ever projects from; callers (`bin/
35
+ # heki_compact`) are responsible for confirming that first.
36
+ #
37
+ # Crash-safety ordering: `write` below is the exact same
38
+ # temp-file, fsync, atomic-rename sequence `save`/`delete`
39
+ # already use — the snapshot it produces is confirmed durably on
40
+ # disk before this method ever touches the journal. Only once
41
+ # that succeeds does `truncate_journal!` run. A crash between the
42
+ # two leaves old (now fully redundant) journal lines in place;
43
+ # replaying them again over the fresh snapshot on the next boot
44
+ # is idempotent — the same value gets set again, never a wrong
45
+ # one — so nothing is lost, just a little wasted replay work
46
+ # once. A crash before `write` completes leaves the journal
47
+ # fully intact and the prior snapshot untouched, exactly today's
48
+ # existing crash-recovery guarantee.
49
+ def compact!
50
+ with_lock do
51
+ current = replay_journal(read_snapshot)
52
+ write(current)
53
+ truncate_journal!
54
+ @store = current
55
+ end
56
+ end
57
+
20
58
  private
21
59
 
60
+ def truncate_journal!
61
+ return unless File.exist?(@journal_path)
62
+
63
+ # A single `truncate(0)` syscall on an already-open file
64
+ # descriptor — the file's length changes atomically at the
65
+ # filesystem level, so there is no "half truncated" state to
66
+ # observe even under a crash mid-call. `fsync` below makes
67
+ # that change durable before this method returns; without it
68
+ # a crash could still leave the old (harmless-to-replay)
69
+ # content on disk after a normal return, which is fine per
70
+ # the crash-safety note above, but the durable case is the
71
+ # one actually worth returning success for.
72
+ File.open(@journal_path, "r+b") do |file|
73
+ file.truncate(0)
74
+ file.flush
75
+ file.fsync
76
+ end
77
+ end
78
+
22
79
  def replay_journal(records)
23
80
  return records unless File.exist?(@journal_path)
24
81
 
@@ -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
@@ -14,4 +14,9 @@ Hecks.adapter "PostgresEra" do
14
14
  field :database
15
15
  field :role
16
16
  field :schema
17
+ # `allow_superuser true` — the explicit, on-the-record opt-in to boot
18
+ # over a connection whose role is a Postgres superuser or carries
19
+ # BYPASSRLS, which the era write-fence (row-level security) cannot
20
+ # bite. Refused by default; see `Lineage#check_fence_applies!` (BUG#24).
21
+ field :allow_superuser
17
22
  end
@@ -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) }
@@ -0,0 +1,145 @@
1
+ require "openssl"
2
+ require "json"
3
+ require "rack"
4
+
5
+ module Hecks
6
+ module Adapters
7
+ # THE DRIVING SIDE — code an OUTSIDE caller reaches IN through,
8
+ # rather than code the domain reaches OUT through. Every existing
9
+ # file under `adapters/driven/` is the latter: a store or reader a
10
+ # `persisted_by`/`port` binding resolves TO, called BY this
11
+ # framework's own runtime. Nothing under this repository has ever
12
+ # been the mirror image before — code that receives a request FROM
13
+ # the outside world and turns it into a dispatch — so this is the
14
+ # first entry, and the directory itself is new.
15
+ module Driving
16
+ # A GITHUB WEBHOOK RECEIVER, TRANSPORT ONLY — the same split
17
+ # `Hecks::Adapters::GithubChecks` (qa/adapters/github_checks.rb,
18
+ # this class's own PULL-side sibling) already draws for itself:
19
+ # THIS file owns proving a request really came from GitHub and
20
+ # unwrapping GitHub's own webhook envelope (`X-GitHub-Event`, the
21
+ # JSON body, GitHub's own automatic `ping` check) — never which
22
+ # commands to dispatch about what it finds inside. That is exactly
23
+ # as domain-specific as `GithubChecks#run` turning `check-runs`
24
+ # JSON into green-or-raise, and lives exactly where that class's
25
+ # own header explains such logic belongs: outside this library,
26
+ # in `qa/adapters/github_ci_webhook.rb`, the subclass of this file
27
+ # that actually knows what a `QualityControl::Clearance` is.
28
+ #
29
+ # A PLAIN RACK APP (`#call(env)`) — no Sinatra, no Rails — the same
30
+ # shape `Hecks::Forms::App` (lib/hecks/forms/app.rb) already
31
+ # established for the one other HTTP-facing surface this library
32
+ # ships. `rack` is a LAZY Gemfile dependency for exactly the reason
33
+ # that file's own header gives: this file is never required by
34
+ # `require "hecks"` (nothing under `adapters.rb`'s own eager
35
+ # `adapters/driven` load names it — see that file's own header),
36
+ # so a project that never mounts a driving adapter never needs
37
+ # `rack` installed, the same "opt in by requiring the file at all"
38
+ # contract `hecks/forms.rb` already has for `Forms::App`.
39
+ #
40
+ # SUBCLASS RESPONSIBILITY: implement `#handle_event(event, action,
41
+ # payload)`, returning `[http_status, response_body_hash]`. Called
42
+ # ONLY after the signature has verified and the body has parsed as
43
+ # JSON — a subclass never has to re-check either. `event` is
44
+ # GitHub's own `X-GitHub-Event` header value ("check_suite",
45
+ # "check_run", "pull_request", ...); `action` is the payload's own
46
+ # top-level `"action"` field when it has one (GitHub's webhooks
47
+ # nearly all carry one — "completed", "requested", "opened", ...)
48
+ # and nil when it does not. `ping` — GitHub's own automatic
49
+ # connectivity check, sent once when a webhook is first saved in
50
+ # repository settings — is answered here and never reaches a
51
+ # subclass at all; there is nothing domain-specific to decide
52
+ # about it.
53
+ class GithubWebhook
54
+ # REFUSED, LOUDLY — the same shape a domain refusal already takes
55
+ # everywhere else in this codebase (`Runtime::DOMAIN_REFUSALS`,
56
+ # `Forms::App`'s own `{error:, message:}` JSON body for a bad
57
+ # command). A request that cannot prove it came from GitHub gets
58
+ # a real 401 and a named reason, never a silent 200 that would
59
+ # let a forged "CI passed" payload regress nothing while looking
60
+ # exactly like success in a log nobody re-reads.
61
+ class InvalidSignature < StandardError; end
62
+
63
+ # THE BODY DID NOT EVEN PARSE — distinct from a signature refusal:
64
+ # this body genuinely came from whoever signed it (checked
65
+ # FIRST, before parsing ever runs — see `#call`), and simply
66
+ # is not JSON. Still refused, never guessed at.
67
+ class MalformedPayload < StandardError; end
68
+
69
+ SIGNATURE_HEADER = "HTTP_X_HUB_SIGNATURE_256".freeze
70
+ EVENT_HEADER = "HTTP_X_GITHUB_EVENT".freeze
71
+
72
+ # `secret:` HAS NO DEFAULT, ON PURPOSE — the same rule
73
+ # `GoogleAuthentication`'s own header states for its own
74
+ # `ENV.fetch`, restated here because the consequence is worse for
75
+ # a webhook: an unverified signature check is not "half
76
+ # configured", it is NO verification at all, silently accepting
77
+ # anything claiming to be GitHub. A caller passes the real
78
+ # secret explicitly — from `ENV.fetch("GITHUB_WEBHOOK_SECRET")`
79
+ # or wherever it keeps one — rather than this class reaching into
80
+ # the environment itself and hiding that requirement inside a
81
+ # default.
82
+ def initialize(secret:)
83
+ raise ArgumentError, "no webhook secret configured" if secret.to_s.empty?
84
+
85
+ @secret = secret
86
+ end
87
+
88
+ def call(env)
89
+ request = Rack::Request.new(env)
90
+ return respond(405, error: "MethodNotAllowed", message: "POST only") unless request.post?
91
+
92
+ body = request.body.read
93
+ verify_signature!(request, body)
94
+
95
+ event = request.get_header(EVENT_HEADER)
96
+ return respond(200, ok: true, event: "ping") if event == "ping"
97
+ return respond(400, error: "MissingEvent", message: "no #{EVENT_HEADER} header") if event.to_s.empty?
98
+
99
+ payload = parse_json(body)
100
+ status, result = handle_event(event, payload["action"], payload)
101
+ respond(status, result)
102
+ rescue InvalidSignature => e
103
+ respond(401, error: "InvalidSignature", message: e.message)
104
+ rescue MalformedPayload => e
105
+ respond(400, error: "MalformedPayload", message: e.message)
106
+ end
107
+
108
+ private
109
+
110
+ # CONSTANT-TIME COMPARE, NOT `==`. A byte-by-byte `==` returns
111
+ # the moment it finds the first mismatching byte, so how LONG
112
+ # that took leaks how many leading bytes of a forged signature
113
+ # were already right to anyone timing the response — GitHub's
114
+ # own webhook documentation calls this out by name and recommends
115
+ # exactly the constant-time compare `Rack::Utils.secure_compare`
116
+ # already gives for free, reused rather than hand-rolled.
117
+ def verify_signature!(request, body)
118
+ header = request.get_header(SIGNATURE_HEADER)
119
+ raise InvalidSignature, "missing #{SIGNATURE_HEADER.sub('HTTP_', '').tr('_', '-')} header" if header.to_s.empty?
120
+
121
+ digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), @secret, body)
122
+ expected = "sha256=#{digest}"
123
+ return if Rack::Utils.secure_compare(expected, header)
124
+
125
+ raise InvalidSignature,
126
+ "signature does not match — refusing a payload that cannot be proven to be GitHub's own"
127
+ end
128
+
129
+ def parse_json(body)
130
+ JSON.parse(body)
131
+ rescue JSON::ParserError => e
132
+ raise MalformedPayload, e.message
133
+ end
134
+
135
+ def handle_event(event, action, payload)
136
+ raise NotImplementedError, "#{self.class} must implement #handle_event(event, action, payload)"
137
+ end
138
+
139
+ def respond(status, body)
140
+ [status, { "content-type" => "application/json" }, [JSON.generate(body)]]
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -194,11 +194,24 @@ module Hecks
194
194
  # mixed facts into the strict envelope — identities lifted into
195
195
  # `to:`, declared facts into `with:` — so a behaviors dispatch now
196
196
  # goes through the exact same separation a policy's projection
197
- # does. A verb that resolves to no command (a port operation
198
- # "Pizzas::Order.PaymentGateway.Receive") keeps the loose
199
- # passthrough: its own input already spells the port form's
200
- # `to:`/`with:`, which the dispatcher's port branch reads directly.
197
+ # does. A verb that names a port operation (checked explicitly,
198
+ # below — "Pizzas::Order.PaymentGateway.Receive") keeps the loose
199
+ # passthrough instead: its own input already spells the port
200
+ # form's `to:`/`with:`, which the dispatcher's port branch reads
201
+ # directly, and `ReactionInvocation.build`'s explicit envelope
202
+ # expects a command's own declared attributes at the top level,
203
+ # not a port operation's already-wrapped `to:`/`with:` shape.
204
+ #
205
+ # THIS USED TO RELY ON `resolve_target` RAISING `UnknownVerb` for
206
+ # any port-operation verb — true only so long as nothing else ever
207
+ # asked it to resolve one. Now that a `policy` can legitimately
208
+ # `trigger` a port operation (`ReactionInvocation#resolve_target`'s
209
+ # own port-operation branch), that raise is gone, so this checks
210
+ # for a port operation directly instead of leaning on a refusal
211
+ # that no longer happens.
201
212
  def dispatch_command(runtime, verb, args)
213
+ return runtime.dispatch(verb, **args) if port_operation?(runtime, verb)
214
+
202
215
  invocation = begin
203
216
  Runtime::ReactionInvocation.build(registry: runtime.registry, verb: verb,
204
217
  projected: args, explicit: true)
@@ -214,6 +227,21 @@ module Hecks
214
227
  end
215
228
  end
216
229
 
230
+ # THE SAME "Head.Rest" SHAPE `Dispatcher#dispatch` AND
231
+ # `ReactionInvocation#resolve_target` BOTH ALREADY CHECK — a bare
232
+ # domain/aggregate lookup plus a port-name lookup, no command
233
+ # resolution needed since all this asks is whether one exists.
234
+ def port_operation?(runtime, verb)
235
+ domain, aggregate_name, command_path = Naming.split_verb(verb)
236
+ return false unless command_path
237
+
238
+ aggregate = runtime.registry.bluebook(domain)&.aggregate(aggregate_name)
239
+ return false unless aggregate
240
+
241
+ head, rest = command_path.split(".", 2)
242
+ rest && !!aggregate.port(head)
243
+ end
244
+
217
245
  def run_query(test, runtime, verb)
218
246
  rows = runtime.query(verb, **test.input)
219
247
 
@@ -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.
@@ -11,9 +11,33 @@ module Hecks
11
11
  # what it acts for, and `identity_attribute` is how that is found.
12
12
  def references = nil
13
13
 
14
+ # NEVER a creating command — a port operation always acts on an
15
+ # aggregate that already exists (`operation.to`/`identity_attribute`
16
+ # both name where its RECEIVER comes from, never a birth). Answered
17
+ # explicitly, not derived from `references` the way `Command
18
+ # #creates?` is (`references.nil?` would read every operation as
19
+ # creating, since `references` above is unconditionally nil) —
20
+ # needed so `ReactionInvocation#source_receiver_for` can call
21
+ # `target.command.creates?` on a port operation the same way it
22
+ # already does on an ordinary command, and correctly lift a
23
+ # same-aggregate policy's own Event.id as the operation's receiver.
24
+ def creates? = false
25
+
14
26
  def identity_attribute(owner_name)
15
27
  @attributes.find { |attribute| attribute.reference? && attribute.type.target_name == owner_name.to_s }
16
28
  end
29
+
30
+ # THE SAME READING `Command#addressing_key_for` gives, minus its
31
+ # self-addressing branch — a port operation's `references` is
32
+ # unconditionally nil (above), so it never means "this verb is
33
+ # declared ON the very aggregate it acts on" the way a command's
34
+ # does; a port operation's only path back to its owner is a real,
35
+ # declared reference-typed attribute, which is exactly what
36
+ # `identity_attribute` already finds. Needed for the identical
37
+ # reason `Command#addressing_key_for` is: `ReactionInvocation
38
+ # #aggregate_aliases` calls it on whatever `target.command` holds,
39
+ # a `PortOperation` now included since a policy can trigger one.
40
+ def addressing_key_for(aggregate_name) = identity_attribute(aggregate_name)&.name
17
41
  end
18
42
 
19
43
  # WHAT A PORT DOES — one finder over its declared operations.