hecks 1.2.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_era.adapter +5 -0
- data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
- data/lib/hecks/behaviors/expectations.rb +32 -4
- data/lib/hecks/bluebook/behaviour/domain_port.rb +24 -0
- 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/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 +31 -0
- 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 +214 -286
- data/lib/hecks/projector/narrate_projector.rb +4 -11
- data/lib/hecks/query_specification/common/comparison.rb +27 -1
- 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/query_interpreter.rb +66 -2
- data/lib/hecks/runtime/reaction_invocation.rb +70 -3
- 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 -115
- 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 +23 -2
|
@@ -53,6 +53,51 @@ module Hecks
|
|
|
53
53
|
# they're included so a real generated sequence occasionally
|
|
54
54
|
# produces the value at all, since nothing had, repo-wide, before.
|
|
55
55
|
INTEGER_EDGE_CASES = [0, -1, 2_147_483_647, -2_147_483_648, 2**100, -(2**100)].freeze
|
|
56
|
+
# BUG#35 (QualityControl QA ledger, `lease-clock-json-precision`) —
|
|
57
|
+
# a Bignum edge case (`2**100`, above) landing on an Integer-typed
|
|
58
|
+
# CLOCK or COUNT reading fires the already-catalogued `Json::Num`/
|
|
59
|
+
# f64 precision-loss class (`rust/src/kernel/json.rs`'s
|
|
60
|
+
# `parse_number` parses every number through `s.parse::<f64>()`
|
|
61
|
+
# before any target-type conversion runs, and `Json::Num` is a
|
|
62
|
+
# plain `f64` end to end — see that file's own header) on a NEW
|
|
63
|
+
# site every time a new clock/count-shaped field is authored,
|
|
64
|
+
# without adding any new coverage: both engines already refuse
|
|
65
|
+
# (`TypeMismatch`, out of `i64` range either way) for this shape,
|
|
66
|
+
# `tenant_ledger`'s own NOTES.md already logged the identical class
|
|
67
|
+
# on a stored money attribute, and `spec/support/
|
|
68
|
+
# rust_conformance_helpers.rb`'s own `reduce_to_wire_precision`
|
|
69
|
+
# already documents and normalizes past the wire format's own loss
|
|
70
|
+
# for a query's echoed args. An exact-integer Rust JSON
|
|
71
|
+
# deserialization path was considered and rejected as this
|
|
72
|
+
# generator's own fix instead: `Json::Num(f64)` is pattern-matched
|
|
73
|
+
# throughout `rust/src/kernel` (query_comparators.rs,
|
|
74
|
+
# query_ordering.rs, read_model.rs, named_query.rs, this kernel's
|
|
75
|
+
# own `Fielded` impl, arithmetic overflow checks) and emitted by
|
|
76
|
+
# codegen (`rust/project/reactions.rb`, `rust/codegen/src/
|
|
77
|
+
# reactions.rs`) — adding a second, exact-integer numeric variant
|
|
78
|
+
# would mean auditing and updating every one of those sites, a
|
|
79
|
+
# refactor of the JSON layer itself, not a one-domain fix (exactly
|
|
80
|
+
# the ledger's own stated concern: "risks affecting every other
|
|
81
|
+
# domain's large-integer handling"). So instead, per the ledger's
|
|
82
|
+
# own sanctioned fallback: an Integer-typed field whose name reads
|
|
83
|
+
# as a clock or a count is capped to a narrower, still-real
|
|
84
|
+
# edge-case pool below (still exercises the ordinary i32
|
|
85
|
+
# boundaries, just never a value past f64's own 2**53 exact-
|
|
86
|
+
# integer ceiling) — every OTHER Integer-typed field (a money
|
|
87
|
+
# amount, an identity sequence, anything not clock/count-shaped)
|
|
88
|
+
# still draws from the full `INTEGER_EDGE_CASES` pool above,
|
|
89
|
+
# unchanged.
|
|
90
|
+
SAFE_INTEGER_EDGE_CASES = [0, -1, 2_147_483_647, -2_147_483_648].freeze
|
|
91
|
+
# Matched against the value object's own declared name plus the
|
|
92
|
+
# attribute's own name (`value_for`'s `"#{context} #{attribute.
|
|
93
|
+
# name}"`, e.g. "LeaseInstant value", "RetryCount value") — a
|
|
94
|
+
# clock reading ("instant"/"clock"/"expir(es/y)"/"ttl"/"now"/
|
|
95
|
+
# "timestamp"/"epoch") or a quantity capped by a policy ("count").
|
|
96
|
+
# Deliberately narrow: matches the ledger's own "clock/count"
|
|
97
|
+
# wording exactly, not every plausibly-large-looking name (an
|
|
98
|
+
# "amount"/"cents"/"sequence" field is not clock/count-shaped and
|
|
99
|
+
# keeps the full Bignum edge-case pool).
|
|
100
|
+
CLOCK_OR_COUNT_NAME_PATTERN = /clock|instant|expir|ttl|\bnow\b|timestamp|epoch|count/i
|
|
56
101
|
# NaN and +/-Infinity — the real find (see `spec/runtime/
|
|
57
102
|
# numeric_boundary_spec.rb`): `Value::Coercion#check_numeric_fields`
|
|
58
103
|
# used to let all three sail through untyped-checked (each really
|
|
@@ -148,7 +193,7 @@ module Hecks
|
|
|
148
193
|
def primitive(type_name, random:, name: nil)
|
|
149
194
|
case type_name
|
|
150
195
|
when "String" then string_value(random, name: name)
|
|
151
|
-
when "Integer" then integer_value(random)
|
|
196
|
+
when "Integer" then integer_value(random, name: name)
|
|
152
197
|
when "Float" then float_value(random)
|
|
153
198
|
when "TrueClass", "FalseClass" then random.rand(2).zero?
|
|
154
199
|
else raise ArgumentError, "ValueGenerator does not know primitive type #{type_name.inspect}"
|
|
@@ -172,12 +217,19 @@ module Hecks
|
|
|
172
217
|
# sequence that can never get past one never reaches the state a deeper
|
|
173
218
|
# bug would need. Zero and negative are still real, reachable outcomes —
|
|
174
219
|
# via the edge-case pool, deliberately, not by starving them entirely.
|
|
175
|
-
def integer_value(random)
|
|
176
|
-
|
|
220
|
+
def integer_value(random, name: nil)
|
|
221
|
+
if random.rand < EDGE_CASE_PROBABILITY
|
|
222
|
+
pool = clock_or_count_shaped?(name) ? SAFE_INTEGER_EDGE_CASES : INTEGER_EDGE_CASES
|
|
223
|
+
return pool.sample(random: random)
|
|
224
|
+
end
|
|
177
225
|
|
|
178
226
|
random.rand(1..1000)
|
|
179
227
|
end
|
|
180
228
|
|
|
229
|
+
def clock_or_count_shaped?(name)
|
|
230
|
+
name.to_s.match?(CLOCK_OR_COUNT_NAME_PATTERN)
|
|
231
|
+
end
|
|
232
|
+
|
|
181
233
|
def float_value(random)
|
|
182
234
|
return FLOAT_EDGE_CASES.sample(random: random) if random.rand < EDGE_CASE_PROBABILITY
|
|
183
235
|
|
data/lib/hecks/fuzzing.rb
CHANGED
|
@@ -10,3 +10,9 @@ require_relative "fuzzing/invalid_value_generator"
|
|
|
10
10
|
require_relative "fuzzing/sequence_generator"
|
|
11
11
|
require_relative "fuzzing/replay"
|
|
12
12
|
require_relative "fuzzing/properties"
|
|
13
|
+
require_relative "fuzzing/rotation_priority"
|
|
14
|
+
require_relative "fuzzing/sweep_depth"
|
|
15
|
+
require_relative "fuzzing/persistence_parity"
|
|
16
|
+
require_relative "fuzzing/form_census"
|
|
17
|
+
require_relative "fuzzing/target_capabilities"
|
|
18
|
+
require_relative "fuzzing/structural_skips"
|
|
@@ -284,8 +284,20 @@ Hecks.bluebook "Bluebook" do
|
|
|
284
284
|
template: "{command} refused — {field} is {current}, and {command} moves it only from {allowed}"
|
|
285
285
|
member refusal: "TypeMismatch", site: "value_object_shape",
|
|
286
286
|
template: "{name} is a {type} — pass its fields as an object, not {offered}"
|
|
287
|
-
|
|
288
|
-
|
|
287
|
+
# RENAMED FROM `reference_as_object` (BUG#27, QualityControl ledger) —
|
|
288
|
+
# a reference command argument that is an object was never the only
|
|
289
|
+
# wrong shape, only the first one anyone noticed: a bare Boolean,
|
|
290
|
+
# Array, or `null` used to sail past this refusal entirely, get
|
|
291
|
+
# `.to_s`'d into a lookup key by `CommandRules::References#
|
|
292
|
+
# reference_key` ("true", "false", "[8, 8]"), and answer NotFound (or,
|
|
293
|
+
# for `null`, skip the lookup outright and answer whatever the
|
|
294
|
+
# command's own `given` said instead) — a shape error misreading as a
|
|
295
|
+
# missing record. `{offered}` now names what actually arrived; the
|
|
296
|
+
# Hash/Value case still renders "an object" (Value::Coercion's own
|
|
297
|
+
# `reference_shape_description`), so the wording already pinned byte
|
|
298
|
+
# for byte in spec/runtime/reference_shape_spec.rb is unchanged.
|
|
299
|
+
member refusal: "TypeMismatch", site: "reference_wrong_shape",
|
|
300
|
+
template: "{command} refused — a reference is an id, and {attribute} arrived as {offered}{known_by}"
|
|
289
301
|
member refusal: "TypeMismatch", site: "multi_field_scalar",
|
|
290
302
|
template: "{type} has multiple fields and cannot stand in for a scalar"
|
|
291
303
|
member refusal: "TypeMismatch", site: "composite_identity",
|
|
@@ -322,6 +334,9 @@ Hecks.bluebook "Bluebook" do
|
|
|
322
334
|
"{field} this ask is scoped to"
|
|
323
335
|
member refusal: "Unauthorized", site: "role_mismatch",
|
|
324
336
|
template: "{command} refused — role: {role}, and the caller stated {caller_role}"
|
|
337
|
+
member refusal: "Unauthorized", site: "cross_tenant_reference",
|
|
338
|
+
template: "{aggregate} {field} is {tenant}, but {attribute} names a {target} whose own " \
|
|
339
|
+
"{target_field} is {other} — a cross-tenant reference"
|
|
325
340
|
member refusal: "AttributeAbsent", site: "absent_read",
|
|
326
341
|
template: "{aggregate} {field} is absent on this record — declared, not optional, and " \
|
|
327
342
|
"added since it was written. Backfill it in a translation (backfill :{field}, " \
|
data/lib/hecks/naming.rb
CHANGED
|
@@ -39,6 +39,47 @@ module Hecks
|
|
|
39
39
|
.downcase
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# AN IDENTIFIER AS A PERSON WOULD SAY IT — `ATMCard` -> "ATM card",
|
|
43
|
+
# `AccrueInterest` -> "Accrue interest", `daily_limit` -> "Daily
|
|
44
|
+
# limit", `Back office` -> "Back office". The same two word-boundary
|
|
45
|
+
# splits `snake` uses, with a space instead of an underscore — plus
|
|
46
|
+
# the one thing `snake` cannot give back: an all-caps run stays an
|
|
47
|
+
# acronym ("ATM", "KYC") instead of being lowercased into a word
|
|
48
|
+
# nobody says ("Atm"). First word capitalized, the rest lowercased,
|
|
49
|
+
# so a headword reads as sentence case whatever casing it was
|
|
50
|
+
# declared in.
|
|
51
|
+
def words(text)
|
|
52
|
+
parts = text.to_s
|
|
53
|
+
.tr("_", " ")
|
|
54
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1 \2')
|
|
55
|
+
.gsub(/([a-z\d])([A-Z])/, '\1 \2')
|
|
56
|
+
.split
|
|
57
|
+
parts.each_with_index.map do |part, index|
|
|
58
|
+
next part if part.match?(/\A[A-Z]{2,}\z/)
|
|
59
|
+
|
|
60
|
+
index.zero? ? part.capitalize : part.downcase
|
|
61
|
+
end.join(" ")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# "A, B, and C" / "A or B" — the Oxford-comma list every English
|
|
65
|
+
# sentence a projection writes wants; lived in `NarrateProjector`
|
|
66
|
+
# alone until a second projection needed it.
|
|
67
|
+
def to_sentence_list(items, conj: "and")
|
|
68
|
+
case items.size
|
|
69
|
+
when 0 then ""
|
|
70
|
+
when 1 then items[0].to_s
|
|
71
|
+
when 2 then "#{items[0]} #{conj} #{items[1]}"
|
|
72
|
+
else "#{items[0..-2].join(', ')}, #{conj} #{items[-1]}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The vowel-LETTER heuristic — safe here for the same reason
|
|
77
|
+
# `Projections::Statements#article` gives: a construct name is a
|
|
78
|
+
# plain word, never "hour" or "university".
|
|
79
|
+
def a_or_an(word)
|
|
80
|
+
%w[a e i o u].include?(word.to_s[0].to_s.downcase) ? "an" : "a"
|
|
81
|
+
end
|
|
82
|
+
|
|
42
83
|
# The name a COLLECTION of something takes.
|
|
43
84
|
#
|
|
44
85
|
# There were two of these and one was wrong. A read model's gathered heads
|
|
@@ -102,10 +143,37 @@ module Hecks
|
|
|
102
143
|
text.include?(".") ? text.split(".", 2).last : text
|
|
103
144
|
end
|
|
104
145
|
|
|
146
|
+
# DOMAIN, AGGREGATE, then the REST dot-joined into one command path.
|
|
147
|
+
#
|
|
148
|
+
# The `::` boundary between domain and aggregate is unambiguous by
|
|
149
|
+
# construction — every caller here has already prefixed the domain
|
|
150
|
+
# itself (`PolicyInterpreter#deliver`, `SagaInterpreter#qualified`,
|
|
151
|
+
# `Router#dispatch`'s own rebuilt string) before this ever runs. A
|
|
152
|
+
# THIRD `::` segment can still show up past that boundary: a bare
|
|
153
|
+
# `ScopedConstant` naming a port operation (`command_ref`'s own
|
|
154
|
+
# comment — `Aggregate::Port::Operation`, three colon-joined
|
|
155
|
+
# segments with no `.` of its own) only gets its LAST `::` rewritten
|
|
156
|
+
# to `.` there, at DSL-build time, because nothing at that point
|
|
157
|
+
# knows yet whether the constant names a port operation or a
|
|
158
|
+
# domain-qualified command (`Domain::Aggregate::Command`, the OTHER
|
|
159
|
+
# shape `command_ref` documents) — both are textually identical.
|
|
160
|
+
# Here, past the already-resolved domain boundary, any leftover
|
|
161
|
+
# `::` is unambiguous: it is that same rewrite artifact, and folding
|
|
162
|
+
# it into the dot-joined tail recovers exactly the
|
|
163
|
+
# `Aggregate::Port.Operation` shape a working port dispatch already
|
|
164
|
+
# expects (`spec/port_operation_interpreter_spec.rb`'s own
|
|
165
|
+
# `"Payments::Payment.PaymentGateway.Receive"`). `Outbox::Fanout
|
|
166
|
+
# .kind_for` and `ReactionInvocation#resolve_target` both already
|
|
167
|
+
# assume this contract on their own end; this is what actually
|
|
168
|
+
# delivers it to them.
|
|
105
169
|
def split_verb(verb)
|
|
106
170
|
path, command = verb.to_s.split(".", 2)
|
|
107
|
-
|
|
108
|
-
|
|
171
|
+
return nil unless path && command
|
|
172
|
+
|
|
173
|
+
domain, aggregate, *rest = path.to_s.split("::")
|
|
174
|
+
return nil unless domain && aggregate
|
|
175
|
+
|
|
176
|
+
command = "#{rest.join('.')}.#{command}" unless rest.empty?
|
|
109
177
|
|
|
110
178
|
[domain, aggregate, command]
|
|
111
179
|
end
|
|
@@ -287,6 +287,56 @@ module Hecks
|
|
|
287
287
|
raise Runtime::WiringError, "cannot rename #{@formerly_known_as} to #{@domain}: #{e.message.strip}"
|
|
288
288
|
end
|
|
289
289
|
|
|
290
|
+
# THE FENCE HAS TO BE ABLE TO BITE THIS CONNECTION, OR NOTHING
|
|
291
|
+
# ensure_base! BUILDS MEANS ANYTHING. The whole write-fence is
|
|
292
|
+
# row-level security (FORCE ROW LEVEL SECURITY above, plus
|
|
293
|
+
# advance_era!'s one INSERT policy), and Postgres exempts a
|
|
294
|
+
# superuser — or any role granted BYPASSRLS — from every policy
|
|
295
|
+
# on every table, unconditionally: FORCE only narrows the
|
|
296
|
+
# OWNER's exemption and has no lever against either of those.
|
|
297
|
+
# Found live, not reasoned about (BUG#24): the QA ledger's own
|
|
298
|
+
# `.world` named a bare database, so every session connected as
|
|
299
|
+
# the machine's default Postgres user — a superuser — and an
|
|
300
|
+
# old checkout wrote its own superseded era straight through
|
|
301
|
+
# two mints, 37 rows no newer head could read, and nothing
|
|
302
|
+
# warned. Asked of the catalog ONCE, at boot, BEFORE anything
|
|
303
|
+
# is provisioned: the answer is a fact about the ROLE, not about
|
|
304
|
+
# any table, so there is nothing to wait for and nothing to
|
|
305
|
+
# half-build first.
|
|
306
|
+
#
|
|
307
|
+
# Refuses by default — quiet divergence is the enemy — naming
|
|
308
|
+
# the role and both ways out. `allow_superuser` boots anyway,
|
|
309
|
+
# but says so on stderr on EVERY boot, because the only guard
|
|
310
|
+
# left standing then is PostgresEra#append's own in-process
|
|
311
|
+
# superseded-era check (the belt to this suspender), and an
|
|
312
|
+
# operator reading the log deserves to know which one they are
|
|
313
|
+
# relying on. Presence-over-truthiness for the setting itself
|
|
314
|
+
# is `PostgresEra.setting`'s job (the caller's); here a truthy
|
|
315
|
+
# value opts in and anything else does not.
|
|
316
|
+
def check_fence_applies!(allow_superuser: false)
|
|
317
|
+
row = @db.exec("SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user")[0]
|
|
318
|
+
exempt = []
|
|
319
|
+
exempt << "a superuser" if row["rolsuper"] == "t"
|
|
320
|
+
exempt << "granted BYPASSRLS" if row["rolbypassrls"] == "t"
|
|
321
|
+
return if exempt.empty?
|
|
322
|
+
|
|
323
|
+
role = row["rolname"].inspect
|
|
324
|
+
unless allow_superuser
|
|
325
|
+
raise Runtime::WiringError,
|
|
326
|
+
"cannot boot #{@domain}: PostgresEra's era write-fence is row-level security, and this " \
|
|
327
|
+
"connection's role #{role} is #{exempt.join(' and ')} — Postgres exempts it from every " \
|
|
328
|
+
"policy, FORCE included, so an old checkout connected this way keeps writing a superseded " \
|
|
329
|
+
"era and nothing refuses. Connect as an ordinary role instead (database " \
|
|
330
|
+
"\"postgres://<role>@<host>/<db>\" in the .world — a non-superuser OWNER still provisions " \
|
|
331
|
+
"and mints), or declare `allow_superuser true` in the same persisted_by block to boot with " \
|
|
332
|
+
"the fence void, on the record."
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
warn "[hecks] #{@domain}: booting PostgresEra as #{role}, #{exempt.join(' and ')}, under " \
|
|
336
|
+
"allow_superuser — the era write-fence is void for this connection; only this process's own " \
|
|
337
|
+
"superseded-era check (PostgresEra#append) stands between an old checkout and a superseded era"
|
|
338
|
+
end
|
|
339
|
+
|
|
290
340
|
# Nothing provisioned yet — build it. Provisioned and owned —
|
|
291
341
|
# keep it current. Provisioned by SOMEONE ELSE — this is an app
|
|
292
342
|
# role, and the owner has already done this work.
|
|
@@ -22,6 +22,12 @@ module Hecks
|
|
|
22
22
|
def check!(registry:, bluebook:, current_text:, settings:, directory: nil)
|
|
23
23
|
db = PostgresEra.connect_for(bluebook.name, settings)
|
|
24
24
|
lineage = Lineage.new(db, bluebook.name, formerly_known_as: bluebook.formerly_known_as)
|
|
25
|
+
# FIRST, before ensure_base! provisions or verifies anything —
|
|
26
|
+
# the one point every PostgresEra boot passes through, and the
|
|
27
|
+
# earliest at which the connection's own role is known. See
|
|
28
|
+
# check_fence_applies!'s own header (BUG#24) for why a
|
|
29
|
+
# superuser connection refuses here by default.
|
|
30
|
+
lineage.check_fence_applies!(allow_superuser: PostgresEra.setting(settings, :allow_superuser, default: false))
|
|
25
31
|
lineage.ensure_base!
|
|
26
32
|
# Both spellings honored, key? first — never `||`, which cannot
|
|
27
33
|
# tell a genuinely stored `false` apart from an absent key (see
|
|
@@ -78,6 +84,14 @@ module Hecks
|
|
|
78
84
|
# called with a superseded ordinal.
|
|
79
85
|
lineage.grant_role!(role, aggregates: bluebook.aggregates, era: matched[:ordinal]) if role
|
|
80
86
|
registry.resolved_eras[bluebook.name] = matched[:ordinal]
|
|
87
|
+
# ...and the boot REMEMBERS that it is superseded, not only
|
|
88
|
+
# which era it is: `RepositoryFactory.build` hands this to
|
|
89
|
+
# the adapter as `superseded_by:`, and `PostgresEra#append`
|
|
90
|
+
# refuses on it before issuing the INSERT. That is the
|
|
91
|
+
# in-process half of "may not keep WRITING" — the half that
|
|
92
|
+
# holds even for a connection the RLS fence cannot bite
|
|
93
|
+
# (a superuser under allow_superuser; BUG#24).
|
|
94
|
+
registry.superseded_eras[bluebook.name] = latest[:ordinal]
|
|
81
95
|
return
|
|
82
96
|
end
|
|
83
97
|
|
|
@@ -209,6 +209,20 @@ module Hecks
|
|
|
209
209
|
# promise.
|
|
210
210
|
@era = settings.key?(:era) ? settings[:era] : settings["era"]
|
|
211
211
|
@era ||= @lineage.current_era
|
|
212
|
+
# THE IN-PROCESS HALF OF THE ERA FENCE. `EraResolver.check!` sets
|
|
213
|
+
# `registry.superseded_eras[domain]` for a held-but-superseded
|
|
214
|
+
# boot only, and `RepositoryFactory.build` merges it in here as
|
|
215
|
+
# `superseded_by:` — so this is nil for every current-era boot AND
|
|
216
|
+
# for a directly-instantiated adapter (specs, consoles), which,
|
|
217
|
+
# like `era:` above, self-resolves rather than being told. When
|
|
218
|
+
# set, `append`/`atomic_put` refuse BEFORE issuing an INSERT (see
|
|
219
|
+
# `refuse_superseded_write!`): the RLS fence already refuses the
|
|
220
|
+
# same write for an ordinary role, but a superuser walks through
|
|
221
|
+
# RLS (BUG#24), and this checkout's own knowledge that it is stale
|
|
222
|
+
# is the one guard no role attribute can void. Same coalescing as
|
|
223
|
+
# `era:` — the key is always PRESENT from the factory, holding nil
|
|
224
|
+
# for the ordinary case.
|
|
225
|
+
@superseded_by = settings.key?(:superseded_by) ? settings[:superseded_by] : settings["superseded_by"]
|
|
212
226
|
# Unconditional and idempotent, regardless of era — belt-and-
|
|
213
227
|
# suspenders self-healing (compile_head! already ensures this for
|
|
214
228
|
# a freshly-minted era's own name; ensure_first_head! for era 1's)
|
|
@@ -341,6 +355,7 @@ module Hecks
|
|
|
341
355
|
# same already-held (per-session-reentrant) advisory lock; a bare
|
|
342
356
|
# `repository.save` outside a full dispatch still takes it fresh.
|
|
343
357
|
def append(entry)
|
|
358
|
+
refuse_superseded_write!
|
|
344
359
|
transaction do
|
|
345
360
|
lock_writes!
|
|
346
361
|
append_and_project!(entry)
|
|
@@ -348,11 +363,27 @@ module Hecks
|
|
|
348
363
|
entry
|
|
349
364
|
end
|
|
350
365
|
|
|
366
|
+
# BEFORE the transaction, before the lock, before the INSERT — a
|
|
367
|
+
# superseded checkout takes nothing and touches nothing. Reads are
|
|
368
|
+
# deliberately untouched: `EraResolver.check!`'s own contract for an
|
|
369
|
+
# old checkout is "may keep BOOTING and READING, but may not keep
|
|
370
|
+
# WRITING", and the head views it reads through are its own era's.
|
|
371
|
+
def refuse_superseded_write!
|
|
372
|
+
return unless @superseded_by
|
|
373
|
+
|
|
374
|
+
raise Runtime::WiringError,
|
|
375
|
+
"cannot write #{table} for #{@domain}: this checkout booted era #{@era}, which era " \
|
|
376
|
+
"#{@superseded_by} has superseded — its shape was replaced by a mint, and a write here would " \
|
|
377
|
+
"land in a partition no newer head reads. Reads still work; pull the current bluebook and " \
|
|
378
|
+
"reboot to write again."
|
|
379
|
+
end
|
|
380
|
+
|
|
351
381
|
# Outcome detection, journal append and every derived projection share
|
|
352
382
|
# the SAME transaction and domain write lock. The lineage-aware head
|
|
353
383
|
# determines whether this id is already visible; no repository `find`
|
|
354
384
|
# occurs before entering this adapter-native operation.
|
|
355
385
|
def atomic_put(entry, insert_only: false)
|
|
386
|
+
refuse_superseded_write!
|
|
356
387
|
status = nil
|
|
357
388
|
transaction do
|
|
358
389
|
lock_writes!
|
|
@@ -13,13 +13,16 @@ module Hecks
|
|
|
13
13
|
settings = (registry.world(domain)&.for_binding(settings_verb, bind.adapter) || {})
|
|
14
14
|
.reject { |key, _| key.to_sym == :role }
|
|
15
15
|
registry.check_settings(bind, settings)
|
|
16
|
-
# The domain
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
16
|
+
# The domain, the resolved era, and (for an old checkout) the era
|
|
17
|
+
# that superseded it ride along after the declared-settings
|
|
18
|
+
# check: a lineage adapter journals per DOMAIN, writes into its
|
|
19
|
+
# own ERA's partition, and refuses to write at all once that era
|
|
20
|
+
# is superseded (`PostgresEra#append`, BUG#24) — none of which a
|
|
21
|
+
# world's settings carry.
|
|
20
22
|
adapter = registry.adapter_class(bind.adapter)
|
|
21
23
|
.new(aggregate: aggregate,
|
|
22
|
-
settings: settings.merge(domain: domain.to_s, era: registry.resolved_eras[domain.to_s]
|
|
24
|
+
settings: settings.merge(domain: domain.to_s, era: registry.resolved_eras[domain.to_s],
|
|
25
|
+
superseded_by: registry.superseded_eras[domain.to_s]),
|
|
23
26
|
root: registry.root)
|
|
24
27
|
repository = AppendOnly.new(adapter)
|
|
25
28
|
recover ? repository.recover! : repository
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
require_relative "../../forms/html"
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
module Projections
|
|
5
|
+
module Glossary
|
|
6
|
+
# THE PAGE, RENDERED FROM THE MARKDOWN — `html/index.html` is
|
|
7
|
+
# `glossary.md` read back and dressed: a navigation rail built from
|
|
8
|
+
# its `##` headings, each `###` a term entry, every ```mermaid fence
|
|
9
|
+
# a diagram, every `[x](#y)` an in-page link. Nothing here reads
|
|
10
|
+
# the bluebook; if the page shows it, the Markdown says it, so the
|
|
11
|
+
# two cannot drift.
|
|
12
|
+
#
|
|
13
|
+
# A SUBSET RENDERER, NOT A MARKDOWN LIBRARY — the projector emits
|
|
14
|
+
# a fixed handful of constructs (`Markdown`'s own header lists
|
|
15
|
+
# them), and this reads exactly those. The Gemfile keeps every
|
|
16
|
+
# dependency justified in its own comment; a full Markdown engine
|
|
17
|
+
# for six constructs would not earn one.
|
|
18
|
+
#
|
|
19
|
+
# The look lives in page.css / page.js beside this file — read at
|
|
20
|
+
# render time and inlined, so the page is one self-contained file.
|
|
21
|
+
# The only things it fetches are the two typefaces (Google Fonts)
|
|
22
|
+
# and Mermaid itself (cdnjs), which draws the diagrams in the
|
|
23
|
+
# browser; without a network the diagrams show as their source.
|
|
24
|
+
module Html
|
|
25
|
+
Block = Struct.new(:type, :text, :items, :level, keyword_init: true)
|
|
26
|
+
|
|
27
|
+
FONTS = "https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;" \
|
|
28
|
+
"0,6..72,600;1,6..72,400&family=Instrument+Sans:wght@400;500;600&display=swap".freeze
|
|
29
|
+
MERMAID = "https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.9.1/mermaid.min.js".freeze
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
def render(markdown)
|
|
34
|
+
blocks = parse(markdown)
|
|
35
|
+
slugs = heading_slugs(blocks)
|
|
36
|
+
title = blocks.find { |block| block.type == :heading && block.level == 1 }&.text.to_s
|
|
37
|
+
sections = split_sections(blocks)
|
|
38
|
+
page(title, sections, slugs)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# ── reading the Markdown ─────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
def parse(markdown)
|
|
44
|
+
lines = markdown.lines.map(&:chomp)
|
|
45
|
+
blocks = []
|
|
46
|
+
index = 0
|
|
47
|
+
while index < lines.size
|
|
48
|
+
line = lines[index]
|
|
49
|
+
if line.start_with?("```mermaid")
|
|
50
|
+
block, index = fence(lines, index)
|
|
51
|
+
blocks << block
|
|
52
|
+
elsif (heading = line.match(/\A(\#{1,3}) (.+)\z/))
|
|
53
|
+
blocks << Block.new(type: :heading, text: heading[2], level: heading[1].size)
|
|
54
|
+
index += 1
|
|
55
|
+
elsif line.start_with?("> ")
|
|
56
|
+
blocks << Block.new(type: :quote, text: line[2..])
|
|
57
|
+
index += 1
|
|
58
|
+
elsif line.start_with?("- ")
|
|
59
|
+
items, index = run(lines, index) { |text| text.start_with?("- ") }
|
|
60
|
+
blocks << Block.new(type: :list, items: items.map { |item| item[2..] })
|
|
61
|
+
elsif line.strip.empty?
|
|
62
|
+
index += 1
|
|
63
|
+
else
|
|
64
|
+
para, index = run(lines, index) { |text| !text.strip.empty? }
|
|
65
|
+
blocks << Block.new(type: :paragraph, text: para.join(" "))
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
blocks
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The mermaid source between the opening fence at `index` and its
|
|
72
|
+
# closing one, and the index just past that.
|
|
73
|
+
def fence(lines, index)
|
|
74
|
+
close = ((index + 1)...lines.size).find { |at| lines[at] == "```" } || lines.size
|
|
75
|
+
[Block.new(type: :mermaid, text: lines[(index + 1)...close].join("\n")), close + 1]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The consecutive lines from `index` that satisfy the block, and
|
|
79
|
+
# the index just past them.
|
|
80
|
+
def run(lines, index)
|
|
81
|
+
taken = []
|
|
82
|
+
while index < lines.size && yield(lines[index])
|
|
83
|
+
taken << lines[index]
|
|
84
|
+
index += 1
|
|
85
|
+
end
|
|
86
|
+
[taken, index]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The same slugs GitHub would give these headings, in the same
|
|
90
|
+
# order — so the links the Markdown carries land here too.
|
|
91
|
+
#
|
|
92
|
+
# KEYED BY THE BLOCK ITSELF, NOT ITS VALUE — a Struct compares by
|
|
93
|
+
# members, so two "### Open" headings in different sections would
|
|
94
|
+
# otherwise be one key, and the first would answer with the
|
|
95
|
+
# second's "-1" slug, leaving `#open` with nothing to land on.
|
|
96
|
+
def heading_slugs(blocks)
|
|
97
|
+
seen = Hash.new(0)
|
|
98
|
+
slugs = {}.compare_by_identity
|
|
99
|
+
blocks.select { |block| block.type == :heading }.each do |block|
|
|
100
|
+
base = Slugs.github(block.text)
|
|
101
|
+
seen[base] += 1
|
|
102
|
+
slugs[block] = seen[base] == 1 ? base : "#{base}-#{seen[base] - 1}"
|
|
103
|
+
end
|
|
104
|
+
slugs
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Everything before the first `##` is the front matter; each `##`
|
|
108
|
+
# opens a section that runs to the next.
|
|
109
|
+
def split_sections(blocks)
|
|
110
|
+
sections = [{ heading: nil, blocks: [] }]
|
|
111
|
+
blocks.each do |block|
|
|
112
|
+
sections << { heading: block, blocks: [] } if block.type == :heading && block.level == 2
|
|
113
|
+
sections.last[:blocks] << block
|
|
114
|
+
end
|
|
115
|
+
sections
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# ── writing the page ─────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
def page(title, sections, slugs)
|
|
121
|
+
front, *rest = sections
|
|
122
|
+
<<~HTML
|
|
123
|
+
<!doctype html>
|
|
124
|
+
<html lang="en">
|
|
125
|
+
<head>
|
|
126
|
+
<meta charset="utf-8">
|
|
127
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
128
|
+
<title>#{escape(title)}</title>
|
|
129
|
+
<link rel="stylesheet" href="#{FONTS}">
|
|
130
|
+
<script src="#{MERMAID}"></script>
|
|
131
|
+
<style>
|
|
132
|
+
#{asset('page.css')}
|
|
133
|
+
</style>
|
|
134
|
+
</head>
|
|
135
|
+
<body>
|
|
136
|
+
<div class="page">
|
|
137
|
+
#{rail(title, rest, slugs)}
|
|
138
|
+
<main>
|
|
139
|
+
#{front_matter(front[:blocks])}
|
|
140
|
+
#{rest.map { |section| section_html(section, slugs) }.join("\n")}
|
|
141
|
+
</main>
|
|
142
|
+
</div>
|
|
143
|
+
<script>
|
|
144
|
+
#{asset('page.js')}
|
|
145
|
+
</script>
|
|
146
|
+
</body>
|
|
147
|
+
</html>
|
|
148
|
+
HTML
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def rail(title, sections, slugs)
|
|
152
|
+
domain = title.split(" — ").first
|
|
153
|
+
items = sections.map do |section|
|
|
154
|
+
heading = section[:heading]
|
|
155
|
+
%(<li><a href="##{slugs[heading]}">#{inline(heading.text)}</a></li>)
|
|
156
|
+
end
|
|
157
|
+
<<~HTML
|
|
158
|
+
<nav class="rail" aria-label="Sections">
|
|
159
|
+
<p class="domain">#{escape(domain)}</p>
|
|
160
|
+
<p class="sub">Glossary</p>
|
|
161
|
+
<input type="search" placeholder="Find a term" aria-label="Find a term" autocomplete="off">
|
|
162
|
+
<p class="count"></p>
|
|
163
|
+
<ol>
|
|
164
|
+
#{items.join("\n")}
|
|
165
|
+
</ol>
|
|
166
|
+
</nav>
|
|
167
|
+
HTML
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def front_matter(blocks)
|
|
171
|
+
parts = ["<header>"]
|
|
172
|
+
blocks.each do |block|
|
|
173
|
+
case block.type
|
|
174
|
+
when :heading then parts << "<h1>#{inline(block.text.split(' — ').first)}</h1>"
|
|
175
|
+
when :quote then parts << "<p class=\"vision\">#{inline(block.text)}</p>"
|
|
176
|
+
when :paragraph then parts << "<p class=\"lede\">#{inline(block.text)}</p>"
|
|
177
|
+
when :mermaid then parts << figure(block, "How it all fits together")
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
parts << "</header>"
|
|
181
|
+
parts.join("\n")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# A section is its heading, then its opening (lede, diagrams, the
|
|
185
|
+
# rules list), then its terms — each `###` opens an article that
|
|
186
|
+
# runs to the next. A bold-only paragraph is the caption of
|
|
187
|
+
# whatever figure or list follows it.
|
|
188
|
+
def section_html(section, slugs)
|
|
189
|
+
heading = section[:heading]
|
|
190
|
+
state = { parts: [%(<section id="#{slugs[heading]}">), "<h2>#{inline(heading.text)}</h2>"],
|
|
191
|
+
caption: nil, in_terms: false }
|
|
192
|
+
section[:blocks].drop(1).each { |block| section_block(block, state, slugs) }
|
|
193
|
+
state[:parts] << "</article></div>" if state[:in_terms]
|
|
194
|
+
state[:parts] << "</section>"
|
|
195
|
+
state[:parts].join("\n")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def section_block(block, state, slugs)
|
|
199
|
+
parts = state[:parts]
|
|
200
|
+
case block.type
|
|
201
|
+
when :heading
|
|
202
|
+
parts << (state[:in_terms] ? "</article>" : "<div class=\"terms\">")
|
|
203
|
+
parts << %(<article class="term" id="#{slugs[block]}">) << "<h3>#{inline(block.text)}</h3>"
|
|
204
|
+
state[:in_terms] = true
|
|
205
|
+
when :mermaid
|
|
206
|
+
parts << figure(block, state.delete(:caption))
|
|
207
|
+
when :list
|
|
208
|
+
parts << "<p class=\"caption\">#{escape(state[:caption])}</p>" if state.delete(:caption)
|
|
209
|
+
parts << "<ul class=\"rules\">#{block.items.map { |item| "<li>#{inline(item)}</li>" }.join}</ul>"
|
|
210
|
+
when :quote
|
|
211
|
+
parts << "<p class=\"about\">#{inline(block.text)}</p>"
|
|
212
|
+
when :paragraph
|
|
213
|
+
paragraph(block.text, state)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def paragraph(text, state)
|
|
218
|
+
if text.match?(/\A\*\*[^*]+\*\*\z/)
|
|
219
|
+
state[:caption] = text.delete("*")
|
|
220
|
+
else
|
|
221
|
+
klass = text.start_with?("Always true:") ? " class=\"rule\"" : ""
|
|
222
|
+
state[:parts] << "<p#{klass}>#{inline(text)}</p>"
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def figure(block, caption)
|
|
227
|
+
parts = ["<figure>"]
|
|
228
|
+
parts << "<figcaption>#{escape(caption)}</figcaption>" if caption
|
|
229
|
+
parts << "<pre class=\"mermaid\">#{escape(block.text)}</pre>" << "</figure>"
|
|
230
|
+
parts.join("\n")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# ── inline text ──────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
def escape(text) = Forms::Escape.html(text)
|
|
236
|
+
|
|
237
|
+
# Escape first, so nothing in the prose ever becomes a tag; then
|
|
238
|
+
# the three inline forms the Markdown uses, on the escaped text.
|
|
239
|
+
def inline(text)
|
|
240
|
+
escape(text)
|
|
241
|
+
.gsub(/\[([^\]]+)\]\(#([^)]+)\)/) { %(<a href="##{Regexp.last_match(2)}">#{Regexp.last_match(1)}</a>) }
|
|
242
|
+
.gsub(/\*\*(.+?)\*\*/) { "<strong>#{Regexp.last_match(1)}</strong>" }
|
|
243
|
+
.gsub(/\*(.+?)\*/) { "<em>#{Regexp.last_match(1)}</em>" }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def asset(name) = File.read(File.join(__dir__, name))
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|