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.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/heki/journal.rb +57 -0
- data/lib/hecks/adapters/driven/postgres/outbox.rb +7 -4
- data/lib/hecks/adapters/driven/postgres/reconnect.rb +57 -0
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +19 -0
- data/lib/hecks/adapters/driven/postgres.rb +20 -17
- data/lib/hecks/adapters/driven/postgres_era.adapter +5 -0
- data/lib/hecks/adapters/driven/sqlite/projection.rb +5 -2
- data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
- data/lib/hecks/behaviors/expectations.rb +32 -4
- data/lib/hecks/bluebook/assembly/marks.rb +13 -3
- data/lib/hecks/bluebook/behaviour/domain_port.rb +24 -0
- data/lib/hecks/bluebook/behaviour/read_model.rb +53 -8
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +10 -5
- data/lib/hecks/bluebook/dsl/read_model_builder.rb +131 -16
- data/lib/hecks/bluebook/meta_validator/judge.rb +25 -3
- data/lib/hecks/bluebook/model_check.rb +148 -17
- data/lib/hecks/forms/field_shape.rb +5 -3
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +266 -0
- data/lib/hecks/fuzzing/era_boundary.rb +105 -0
- data/lib/hecks/fuzzing/form_census.rb +184 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +198 -10
- data/lib/hecks/fuzzing/persistence_parity.rb +163 -0
- data/lib/hecks/fuzzing/properties/corrections.rb +100 -0
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +188 -13
- data/lib/hecks/fuzzing/properties/guards.rb +103 -0
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +34 -1
- data/lib/hecks/fuzzing/properties/outbox.rb +142 -0
- data/lib/hecks/fuzzing/properties.rb +23 -2
- data/lib/hecks/fuzzing/replay.rb +130 -20
- data/lib/hecks/fuzzing/rotation_priority.rb +94 -0
- data/lib/hecks/fuzzing/self_consistency.rb +647 -0
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +526 -0
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +90 -26
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +50 -4
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +11 -0
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +73 -9
- data/lib/hecks/fuzzing/sequence_generator.rb +47 -14
- data/lib/hecks/fuzzing/structural_skips.rb +146 -0
- data/lib/hecks/fuzzing/sweep_depth.rb +53 -0
- data/lib/hecks/fuzzing/target_capabilities.rb +149 -0
- data/lib/hecks/fuzzing/value_generator.rb +55 -3
- data/lib/hecks/fuzzing.rb +6 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +17 -2
- data/lib/hecks/naming.rb +70 -2
- data/lib/hecks/ports/persistence/append_only.rb +11 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +50 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +14 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +85 -5
- data/lib/hecks/ports/persistence/repository_factory.rb +8 -5
- data/lib/hecks/projections/glossary/html.rb +250 -0
- data/lib/hecks/projections/glossary/markdown.rb +105 -0
- data/lib/hecks/projections/glossary/mermaid.rb +110 -0
- data/lib/hecks/projections/glossary/page.css +271 -0
- data/lib/hecks/projections/glossary/page.js +72 -0
- data/lib/hecks/projections/glossary/sections.rb +17 -0
- data/lib/hecks/projections/glossary/sentences.rb +205 -0
- data/lib/hecks/projections/glossary.rb +302 -0
- data/lib/hecks/projections.rb +1 -0
- data/lib/hecks/projector/cli_projector.rb +29 -13
- data/lib/hecks/projector/exporter.rb +24 -0
- data/lib/hecks/projector/narrate_projector.rb +4 -11
- data/lib/hecks/query_specification/common/comparison.rb +27 -1
- data/lib/hecks/query_specification/common/limit_spec.rb +9 -2
- data/lib/hecks/query_specification/common/offset_spec.rb +9 -2
- data/lib/hecks/query_specification/common/order_by.rb +9 -2
- data/lib/hecks/query_specification/common/where_clause.rb +15 -2
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +26 -8
- data/lib/hecks/runtime/command_rules/references.rb +75 -0
- data/lib/hecks/runtime/entity_element.rb +168 -16
- data/lib/hecks/runtime/entity_interpreter.rb +68 -3
- data/lib/hecks/runtime/interpreting.rb +16 -7
- data/lib/hecks/runtime/query_interpreter.rb +66 -2
- data/lib/hecks/runtime/reaction_invocation.rb +70 -3
- data/lib/hecks/runtime/read_model_interpreter.rb +5 -4
- data/lib/hecks/runtime/refusal_wording.rb +5 -2
- data/lib/hecks/runtime/registry.rb +12 -0
- data/lib/hecks/runtime/routing.rb +67 -2
- data/lib/hecks/runtime/saga_interpreter.rb +38 -1
- data/lib/hecks/runtime/value/coercion.rb +77 -52
- data/lib/hecks/runtime/value/entity_list_coercion.rb +248 -0
- data/lib/hecks/runtime/value.rb +7 -2
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +2 -1
- metadata +25 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b85a8382bb201f9ab54417b3535d8fe1ede57c842f627e32a2ff75d7f2dd6618
|
|
4
|
+
data.tar.gz: e4852fd6b22145bec8a215e4c46fc6347527f7bd05811ff7da780e74dc24fe1d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
146
|
+
pg_exec("SELECT * FROM #{quoted_table} #{order_sql}").map { |row| instance_from_row(row) }
|
|
144
147
|
end
|
|
145
148
|
|
|
146
|
-
def count =
|
|
149
|
+
def count = pg_exec("SELECT COUNT(*) FROM #{quoted_table}")[0]["count"].to_i
|
|
147
150
|
|
|
148
151
|
def append(entry)
|
|
149
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
|
198
|
-
# "Pizzas::Order.PaymentGateway.Receive") keeps the loose
|
|
199
|
-
# passthrough: its own input already spells the port
|
|
200
|
-
# `to:`/`with:`, which the dispatcher's port branch reads
|
|
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.
|