hecks 0.3.0 → 1.0.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/d1.rb +38 -14
- data/lib/hecks/adapters/driven/governance_authorization.rb +41 -2
- data/lib/hecks/adapters/driven/heki/journal.rb +7 -2
- data/lib/hecks/adapters/driven/heki/saga_store.rb +0 -0
- data/lib/hecks/adapters/driven/heki/snapshot.rb +31 -4
- data/lib/hecks/adapters/driven/heki.rb +40 -9
- data/lib/hecks/adapters/driven/lambda.rb +16 -2
- data/lib/hecks/adapters/driven/postgres/codec.rb +8 -0
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +45 -6
- data/lib/hecks/adapters/driven/postgres.rb +76 -29
- data/lib/hecks/adapters/driven/postgres_era.adapter +11 -0
- data/lib/hecks/adapters/driven/sqlite/codec.rb +17 -0
- data/lib/hecks/adapters/driven/sqlite/projection.rb +76 -9
- data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +17 -5
- data/lib/hecks/adapters/driven/sqlite.rb +30 -9
- data/lib/hecks/adapters/driven.rb +19 -1
- data/lib/hecks/behaviors/dsl.rb +29 -0
- data/lib/hecks/behaviors/expectations.rb +62 -2
- data/lib/hecks/bluebook/assembly/contracts.rb +36 -7
- data/lib/hecks/bluebook/assembly/marks.rb +4 -3
- data/lib/hecks/bluebook/assembly.rb +14 -1
- data/lib/hecks/bluebook/behaviour/lifecycle.rb +18 -1
- data/lib/hecks/bluebook/behaviour/process_manager.rb +14 -1
- data/lib/hecks/bluebook/chapter.rb +21 -11
- data/lib/hecks/bluebook/command.rb +1 -1
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +117 -5
- data/lib/hecks/bluebook/dsl/attribute_collector.rb +21 -0
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +71 -2
- data/lib/hecks/bluebook/dsl/command_builder.rb +91 -3
- data/lib/hecks/bluebook/dsl/entity_builder.rb +129 -4
- data/lib/hecks/bluebook/dsl/policy_builder.rb +18 -3
- data/lib/hecks/bluebook/dsl/port_builder.rb +12 -3
- data/lib/hecks/bluebook/dsl/process_manager_builder.rb +109 -10
- data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -0
- data/lib/hecks/bluebook/dsl/word_gate.rb +9 -2
- data/lib/hecks/bluebook/dsl/world_builder.rb +44 -4
- data/lib/hecks/bluebook/expression/canonical_form.rb +71 -3
- data/lib/hecks/bluebook/expression/evaluator.rb +50 -7
- data/lib/hecks/bluebook/expression/projection.json +48 -0
- data/lib/hecks/bluebook/expression/resolver.rb +161 -10
- data/lib/hecks/bluebook/hexagon.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/judge.rb +78 -16
- data/lib/hecks/bluebook/meta_validator/port_judge.rb +4 -0
- data/lib/hecks/bluebook/meta_validator/readings.rb +14 -4
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +42 -3
- data/lib/hecks/bluebook/meta_validator/shapes.rb +30 -10
- data/lib/hecks/bluebook/meta_validator.rb +103 -13
- data/lib/hecks/bluebook/model_check.rb +132 -5
- data/lib/hecks/bluebook/pattern_subset.rb +66 -2
- data/lib/hecks/bluebook/process_manager.rb +53 -11
- data/lib/hecks/bluebook/value_object.rb +9 -1
- data/lib/hecks/doc/reference.rb +22 -1
- data/lib/hecks/facade/cli_door.rb +6 -3
- data/lib/hecks/facade/json_door.rb +16 -4
- data/lib/hecks/forms/app.rb +47 -6
- data/lib/hecks/forms/command_form_renderer.rb +1 -1
- data/lib/hecks/forms/field_renderer.rb +11 -4
- data/lib/hecks/forms/html.rb +31 -0
- data/lib/hecks/forms/params.rb +30 -1
- data/lib/hecks/forms/port_argument.rb +46 -0
- data/lib/hecks/forms/record_renderer.rb +6 -2
- data/lib/hecks/forms/record_table.rb +6 -1
- data/lib/hecks/framework/bluebook/console_settings.bluebook +19 -19
- data/lib/hecks/framework/bluebook/governance.bluebook +26 -11
- data/lib/hecks/framework/bluebook/identity.bluebook +2 -2
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +212 -18
- data/lib/hecks/fuzzing/properties.rb +52 -6
- data/lib/hecks/fuzzing/replay.rb +51 -18
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +28 -2
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +71 -6
- data/lib/hecks/fuzzing/sequence_generator.rb +5 -4
- data/lib/hecks/fuzzing/value_generator.rb +25 -2
- data/lib/hecks/grammar/evolve.rb +33 -0
- data/lib/hecks/grammar/expression.bluebook +8 -5
- data/lib/hecks/grammar/expression_operators.json +497 -1
- data/lib/hecks/language/bluebook/aggregate.bluebook +12 -12
- data/lib/hecks/language/bluebook/bluebook.bluebook +3 -3
- data/lib/hecks/language/bluebook/command.bluebook +42 -8
- data/lib/hecks/language/bluebook/entity.bluebook +86 -10
- data/lib/hecks/language/bluebook/policy.bluebook +21 -4
- data/lib/hecks/language/bluebook/process_manager.bluebook +135 -18
- data/lib/hecks/language/bluebook/projection.bluebook +6 -6
- data/lib/hecks/language/bluebook/query.bluebook +4 -4
- data/lib/hecks/language/bluebook/shape.bluebook +6 -6
- data/lib/hecks/language/bluebook/syntax.bluebook +12 -11
- data/lib/hecks/language/bluebook/vocabulary.bluebook +22 -7
- data/lib/hecks/language/oidc.json +20 -0
- data/lib/hecks/language/port.bluebook +30 -2
- data/lib/hecks/naming.rb +54 -1
- data/lib/hecks/ports/access_control.port +7 -2
- data/lib/hecks/ports/access_control.rb +1 -1
- data/lib/hecks/ports/agent.port +6 -2
- data/lib/hecks/ports/agent.rb +1 -1
- data/lib/hecks/ports/authentication.port +4 -2
- data/lib/hecks/ports/authentication.rb +1 -1
- data/lib/hecks/ports/authorization.port +5 -2
- data/lib/hecks/ports/authorization.rb +14 -11
- data/lib/hecks/ports/clock.port +3 -2
- data/lib/hecks/ports/clock.rb +1 -1
- data/lib/hecks/ports/extraction.port +3 -2
- data/lib/hecks/ports/extraction.rb +1 -1
- data/lib/hecks/ports/identity_assignment.port +3 -2
- data/lib/hecks/ports/identity_assignment.rb +1 -1
- data/lib/hecks/ports/identity_generation.port +3 -2
- data/lib/hecks/ports/identity_generation.rb +1 -1
- data/lib/hecks/ports/identity_resolution.port +3 -2
- data/lib/hecks/ports/identity_resolution.rb +1 -1
- data/lib/hecks/ports/persistence/append_only.rb +40 -4
- data/lib/hecks/ports/persistence/execution.rb +6 -1
- data/lib/hecks/ports/persistence/plugin.rb +54 -0
- data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_check.rb +41 -8
- data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard.rb +24 -56
- data/lib/hecks/ports/persistence/{lineage.rb → plugins/era/lineage.rb} +31 -4
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/era_store.rb +3 -3
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/head_compiler.rb +59 -10
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/mint_transaction.rb +2 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/provisioning.rb +29 -1
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/tail_merge.rb +11 -4
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/transform_installer.rb +20 -0
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage.rb +1 -1
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/coverage_check.rb +5 -5
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/era_resolver.rb +5 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/merge_coordinator.rb +2 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/minter.rb +4 -4
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager.rb +2 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era.rb +116 -29
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/approval_digest.rb +1 -1
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_one.rb +14 -5
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_two.rb +31 -6
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/unfed_report.rb +5 -1
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/reattest.rb +3 -3
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/differ.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
- data/lib/hecks/ports/persistence.rb +1 -1
- data/lib/hecks/ports/projection.rb +20 -0
- data/lib/hecks/projections/diagrams.rb +230 -1
- data/lib/hecks/projections/shape.rb +15 -2
- data/lib/hecks/projector/cli_projector.rb +10 -1
- data/lib/hecks/projector/exporter.rb +19 -2
- data/lib/hecks/query_ir.rb +19 -0
- data/lib/hecks/query_specification/common/null_policy.rb +13 -1
- data/lib/hecks/query_specification/field_path.rb +20 -2
- data/lib/hecks/query_specification/hop_path.rb +7 -5
- data/lib/hecks/runtime/aggregate_lock.rb +45 -0
- data/lib/hecks/runtime/boot_gates.rb +41 -0
- data/lib/hecks/runtime/caller.rb +19 -3
- data/lib/hecks/runtime/command_interpreter/argument_gate.rb +13 -2
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -0
- data/lib/hecks/runtime/command_interpreter.rb +97 -13
- data/lib/hecks/runtime/command_rules/admissibility.rb +64 -14
- data/lib/hecks/runtime/command_rules/arithmetic.rb +7 -1
- data/lib/hecks/runtime/command_rules/authorization.rb +2 -1
- data/lib/hecks/runtime/command_rules/references.rb +27 -19
- data/lib/hecks/runtime/dependency_planning.rb +14 -0
- data/lib/hecks/runtime/dispatcher.rb +19 -4
- data/lib/hecks/runtime/entity_interpreter.rb +85 -14
- data/lib/hecks/runtime/errors.rb +22 -0
- data/lib/hecks/runtime/identity.rb +30 -2
- data/lib/hecks/runtime/instance.rb +59 -4
- data/lib/hecks/runtime/interpreting.rb +21 -0
- data/lib/hecks/runtime/loader.rb +59 -18
- data/lib/hecks/runtime/query_interpreter.rb +36 -4
- data/lib/hecks/runtime/reaction_invocation.rb +9 -1
- data/lib/hecks/runtime/read_model_interpreter.rb +76 -1
- data/lib/hecks/runtime/refusal_wording.rb +2 -0
- data/lib/hecks/runtime/registry/saga_persistence.rb +75 -3
- data/lib/hecks/runtime/registry/verification.rb +88 -0
- data/lib/hecks/runtime/registry.rb +69 -8
- data/lib/hecks/runtime/saga_interpreter.rb +215 -13
- data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
- data/lib/hecks/runtime/value/admission.rb +19 -1
- data/lib/hecks/runtime/value/coercion.rb +75 -10
- data/lib/hecks/runtime.rb +17 -5
- data/lib/hecks/storehouse.rb +632 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +6 -1
- data/lib/hecks.rb +7 -2
- data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
- data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
- data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
- metadata +48 -37
- /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard/shape_diff.rb +0 -0
- /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_tamper.rb +0 -0
- /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/field_cache.rb +0 -0
- /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/resumable_backfill.rb +0 -0
- /data/lib/hecks/{runtime → ports/persistence/plugins/era}/storage_shape.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/rule_compiler.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/renderer.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/writer.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold.rb +0 -0
- /data/lib/hecks/{translation.rb → ports/persistence/plugins/era/translation.rb} +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9d056aad218f4fe26394319c5ce486211ac9790022de8159ba23f050a634f5df
|
|
4
|
+
data.tar.gz: d6fa0c2f693152be740b0c5f658008a6ba71ae9bd80e175e8da78b142f00f133
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4ae526f5a34b194a9b3d23751fae0bb6bdee1bcd8011653904d2b324bf4478ee7b4049d96f0b2a74d005a810ac7023d6efa5a8d0639fb079ed8691da93a5f217
|
|
7
|
+
data.tar.gz: c2b1a7be12ebfbd52ad5cb13ddd1aa7693a7d903d1402c580baad40414a7f3318d7263379658debc502492986956f97b5377681ac93cf5cb36e058c3e1ccfb46
|
|
@@ -81,7 +81,14 @@ module Hecks
|
|
|
81
81
|
failed = results.find { |result| result["success"] == false }
|
|
82
82
|
if failed
|
|
83
83
|
messages = (body["errors"] || []).map { |error| error["message"] }.join("; ")
|
|
84
|
-
detail =
|
|
84
|
+
detail =
|
|
85
|
+
if failed.key?("error")
|
|
86
|
+
failed["error"]
|
|
87
|
+
elsif failed.key?("message")
|
|
88
|
+
failed["message"]
|
|
89
|
+
else
|
|
90
|
+
messages
|
|
91
|
+
end
|
|
85
92
|
raise Runtime::WiringError, "D1 query failed: #{detail.to_s.empty? ? 'a batched statement failed' : detail}"
|
|
86
93
|
end
|
|
87
94
|
|
|
@@ -112,9 +119,9 @@ module Hecks
|
|
|
112
119
|
def initialize(aggregate:, settings: {}, root: nil)
|
|
113
120
|
@aggregate = aggregate
|
|
114
121
|
|
|
115
|
-
account_id = settings[:account_id]
|
|
116
|
-
database_id = settings[:database_id]
|
|
117
|
-
api_token = settings[:api_token]
|
|
122
|
+
account_id = settings.key?(:account_id) ? settings[:account_id] : settings["account_id"]
|
|
123
|
+
database_id = settings.key?(:database_id) ? settings[:database_id] : settings["database_id"]
|
|
124
|
+
api_token = settings.key?(:api_token) ? settings[:api_token] : settings["api_token"]
|
|
118
125
|
{ "account_id" => account_id, "database_id" => database_id, "api_token" => api_token }.each do |name, value|
|
|
119
126
|
raise Runtime::WiringError, "D1 needs a #{name.inspect} in its world settings" if value.to_s.empty?
|
|
120
127
|
end
|
|
@@ -126,7 +133,15 @@ module Hecks
|
|
|
126
133
|
# own per-aggregate-file default, there's no "which file does
|
|
127
134
|
# this end up in" ambiguity here: every aggregate on D1 within a
|
|
128
135
|
# domain already shares one database.
|
|
129
|
-
@domain = (
|
|
136
|
+
@domain = (
|
|
137
|
+
if settings.key?(:domain)
|
|
138
|
+
settings[:domain]
|
|
139
|
+
elsif settings.key?("domain")
|
|
140
|
+
settings["domain"]
|
|
141
|
+
else
|
|
142
|
+
aggregate.name
|
|
143
|
+
end
|
|
144
|
+
).to_s
|
|
130
145
|
|
|
131
146
|
# No PRAGMA synchronous here, unlike Sqlite — D1 is a managed
|
|
132
147
|
# durable service; there is no local fsync policy for a caller to
|
|
@@ -171,7 +186,12 @@ module Hecks
|
|
|
171
186
|
def append(entry)
|
|
172
187
|
@db.execute(
|
|
173
188
|
"INSERT INTO #{quoted_entry_table} (aggregate_id, operation, state, mirrors) VALUES (?, ?, ?, ?)",
|
|
174
|
-
|
|
189
|
+
# `mirrors` (unlike `state`) is a NULLABLE column — an absent
|
|
190
|
+
# mirrors hash must bind a real SQL NULL, not the four-character
|
|
191
|
+
# JSON text `"null"` (`JSON.generate(nil)`), or a future `IS NULL`
|
|
192
|
+
# check against it would never match. Same guard `postgres_era.rb`
|
|
193
|
+
# already uses for its own journal's `mirrors` column.
|
|
194
|
+
[entry.id, entry.operation, JSON.generate(entry.state), entry.mirrors && JSON.generate(entry.mirrors)]
|
|
175
195
|
)
|
|
176
196
|
entry
|
|
177
197
|
end
|
|
@@ -252,17 +272,20 @@ module Hecks
|
|
|
252
272
|
"THEN 'replaced' ELSE 'inserted' END AS status"
|
|
253
273
|
end
|
|
254
274
|
|
|
275
|
+
# `mirrors` is NULLABLE (unlike `state`) — see `append`'s own comment.
|
|
276
|
+
encoded_mirrors = entry.mirrors && JSON.generate(entry.mirrors)
|
|
277
|
+
|
|
255
278
|
entry_sql, entry_binds =
|
|
256
279
|
if insert_only
|
|
257
280
|
[
|
|
258
281
|
"INSERT INTO #{quoted_entry_table} (aggregate_id, operation, state, mirrors) " \
|
|
259
282
|
"SELECT ?, ?, ?, ? #{not_exists}",
|
|
260
|
-
[entry.id, entry.operation, JSON.generate(entry.state),
|
|
283
|
+
[entry.id, entry.operation, JSON.generate(entry.state), encoded_mirrors, entry.id.to_s]
|
|
261
284
|
]
|
|
262
285
|
else
|
|
263
286
|
[
|
|
264
287
|
"INSERT INTO #{quoted_entry_table} (aggregate_id, operation, state, mirrors) VALUES (?, ?, ?, ?)",
|
|
265
|
-
[entry.id, entry.operation, JSON.generate(entry.state),
|
|
288
|
+
[entry.id, entry.operation, JSON.generate(entry.state), encoded_mirrors]
|
|
266
289
|
]
|
|
267
290
|
end
|
|
268
291
|
|
|
@@ -318,11 +341,11 @@ module Hecks
|
|
|
318
341
|
# `Sqlite::SchemaBuilder` already shares with Sqlite (`d1.rb`'s own
|
|
319
342
|
# file header). Same `?`-placeholder shape every other write here
|
|
320
343
|
# already uses through `Connection#execute`.
|
|
321
|
-
def save_saga(process_manager:, correlation:, state:, memory:)
|
|
344
|
+
def save_saga(process_manager:, correlation:, state:, memory:, completed_compensations: [])
|
|
322
345
|
@db.execute(
|
|
323
|
-
"INSERT OR REPLACE INTO hecks_saga_instances (domain, process_manager, correlation, state, memory) " \
|
|
324
|
-
"VALUES (?, ?, ?, ?, ?)",
|
|
325
|
-
[@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory)]
|
|
346
|
+
"INSERT OR REPLACE INTO hecks_saga_instances (domain, process_manager, correlation, state, memory, completed_compensations) " \
|
|
347
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
348
|
+
[@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory), JSON.generate(completed_compensations)]
|
|
326
349
|
)
|
|
327
350
|
end
|
|
328
351
|
|
|
@@ -337,11 +360,12 @@ module Hecks
|
|
|
337
360
|
return enum_for(:each_saga) unless block_given?
|
|
338
361
|
|
|
339
362
|
@db.execute(
|
|
340
|
-
"SELECT process_manager, correlation, state, memory FROM hecks_saga_instances WHERE domain = ?",
|
|
363
|
+
"SELECT process_manager, correlation, state, memory, completed_compensations FROM hecks_saga_instances WHERE domain = ?",
|
|
341
364
|
[@domain]
|
|
342
365
|
).each do |row|
|
|
343
366
|
yield row["process_manager"], row["correlation"], row["state"],
|
|
344
|
-
JSON.parse(row["memory"], symbolize_names: true)
|
|
367
|
+
JSON.parse(row["memory"], symbolize_names: true),
|
|
368
|
+
JSON.parse(row["completed_compensations"] || "[]", symbolize_names: true)
|
|
345
369
|
end
|
|
346
370
|
end
|
|
347
371
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
1
3
|
module Hecks
|
|
2
4
|
module Adapters
|
|
3
5
|
# THE `authorization` PORT, FULFILLED BY GOVERNANCE — same registry,
|
|
@@ -19,13 +21,50 @@ module Hecks
|
|
|
19
21
|
module GovernanceAuthorization
|
|
20
22
|
module_function
|
|
21
23
|
|
|
22
|
-
|
|
24
|
+
# `as_of` and `scope` are BOTH optional, same opt-in shape
|
|
25
|
+
# `refuse_role_mismatch` already gives `actor_id` itself — an
|
|
26
|
+
# unbound `as_of` skips the `starts_at` check and an unbound
|
|
27
|
+
# `scope` skips the `scope` check, exactly the behavior before
|
|
28
|
+
# either existed. Neither is fetched here: `as_of` arrives already
|
|
29
|
+
# resolved from `Ports::Clock.now`, called by the caller at the
|
|
30
|
+
# door, never by this adapter — see `Ports::Clock`'s own header
|
|
31
|
+
# for why the dispatch path must not consult the clock itself.
|
|
32
|
+
def holds_role?(registry, actor_id:, role:, as_of: nil, scope: nil)
|
|
23
33
|
rows = Runtime::Dispatcher.new(registry).query(
|
|
24
34
|
"Governance::RoleAssignment.AssignmentsForActor",
|
|
25
35
|
actor_id: { value: actor_id.to_s }
|
|
26
36
|
)
|
|
27
37
|
|
|
28
|
-
rows.any?
|
|
38
|
+
rows.any? do |row|
|
|
39
|
+
row[:role_name][:value] == role.to_s &&
|
|
40
|
+
row[:ends_at].nil? &&
|
|
41
|
+
in_scope?(row, scope) &&
|
|
42
|
+
started?(row, as_of)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# `scope` UNCHECKED WHEN NOT STATED, same as every other opt-in
|
|
47
|
+
# field here — a caller that never says which scope it is acting
|
|
48
|
+
# in gets the pre-scope behavior: any live assignment for the role
|
|
49
|
+
# authorizes, everywhere. A caller that does state one only
|
|
50
|
+
# authorizes against an assignment granted for THAT scope.
|
|
51
|
+
def in_scope?(row, scope)
|
|
52
|
+
scope.nil? || row[:scope][:value] == scope.to_s
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# `starts_at` IS A FREE-TEXT STRING in the bluebook (`Timestamp`'s
|
|
56
|
+
# only invariant is "present", not any particular format) — parsed
|
|
57
|
+
# here with `Time.parse` rather than compared lexically, since
|
|
58
|
+
# nothing guarantees every caller writes it zero-padded ISO 8601.
|
|
59
|
+
# FAILS CLOSED : a `starts_at` that does not parse is treated as
|
|
60
|
+
# not-yet-started rather than silently ignored, the same direction
|
|
61
|
+
# every other check in this method already fails.
|
|
62
|
+
def started?(row, as_of)
|
|
63
|
+
return true if as_of.nil?
|
|
64
|
+
|
|
65
|
+
Time.parse(row[:starts_at][:value].to_s).to_i <= as_of
|
|
66
|
+
rescue ArgumentError, TypeError
|
|
67
|
+
false
|
|
29
68
|
end
|
|
30
69
|
|
|
31
70
|
# THE OTHER HALF — may role X act as role Y. `RoleTransition.Allowed`
|
|
@@ -38,9 +38,14 @@ module Hecks
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def append_entry(operation, id, state)
|
|
41
|
+
line = "#{JSON.generate(operation: operation, id: id.to_s, state: state, mirrors: @entry_mirrors)}\n"
|
|
42
|
+
|
|
43
|
+
# One write, not JSON-then-newline as two: two concurrent
|
|
44
|
+
# appends can only interleave *between* writes, never inside
|
|
45
|
+
# one, so this line can't come out split by another process's
|
|
46
|
+
# line landing in the middle of it.
|
|
41
47
|
File.open(@journal_path, "ab") do |journal|
|
|
42
|
-
journal.write(
|
|
43
|
-
journal.write("\n")
|
|
48
|
+
journal.write(line)
|
|
44
49
|
journal.flush
|
|
45
50
|
journal.fsync
|
|
46
51
|
end
|
|
Binary file
|
|
@@ -23,15 +23,42 @@ module Hecks
|
|
|
23
23
|
raise Malformed, "#{@path}: json error: #{e.message}"
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
# Temp-file-plus-rename, fsynced before the rename: a reader can
|
|
27
|
+
# only ever see the last complete snapshot or the one before it,
|
|
28
|
+
# never a truncated or partial one — `File.binwrite`'s old
|
|
29
|
+
# truncate-then-write left a window, proportional to the whole
|
|
30
|
+
# dataset, where the file on disk was neither.
|
|
26
31
|
def write(records)
|
|
27
32
|
sorted = records.sort_by { |id, _| id }.to_h
|
|
28
33
|
json = JSON.generate(sorted)
|
|
34
|
+
body = MAGIC + [sorted.size].pack("N") + Zlib::Deflate.deflate(json, Zlib::BEST_COMPRESSION)
|
|
35
|
+
tmp = "#{@path}.tmp.#{Process.pid}.#{object_id}"
|
|
29
36
|
|
|
30
|
-
File.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
File.open(tmp, "wb") do |file|
|
|
38
|
+
file.write(body)
|
|
39
|
+
file.flush
|
|
40
|
+
file.fsync
|
|
41
|
+
end
|
|
42
|
+
File.rename(tmp, @path)
|
|
34
43
|
end
|
|
44
|
+
|
|
45
|
+
# Serializes the read-modify-write each save/delete does against
|
|
46
|
+
# the snapshot (and the journal append that precedes it) across
|
|
47
|
+
# processes — `flock` is per-process advisory, so every writer
|
|
48
|
+
# has to go through this to matter, which `with_lock`'s only two
|
|
49
|
+
# callers (`Heki#save`/`#delete`, `SagaStore#save_saga`/
|
|
50
|
+
# `#delete_saga`) both do. The lock file is separate from
|
|
51
|
+
# `@path` so a held lock never blocks `write`'s own rename.
|
|
52
|
+
def with_lock
|
|
53
|
+
File.open(lock_path, File::CREAT | File::RDWR, 0o644) do |lock|
|
|
54
|
+
lock.flock(File::LOCK_EX)
|
|
55
|
+
yield
|
|
56
|
+
ensure
|
|
57
|
+
lock.flock(File::LOCK_UN)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def lock_path = "#{@path}.lock"
|
|
35
62
|
end
|
|
36
63
|
end
|
|
37
64
|
end
|
|
@@ -34,7 +34,15 @@ module Hecks
|
|
|
34
34
|
# — falls back to the aggregate's own name for a directly-
|
|
35
35
|
# instantiated adapter (specs), same fallback shape Postgres's
|
|
36
36
|
# own @domain already uses.
|
|
37
|
-
@domain = (
|
|
37
|
+
@domain = (
|
|
38
|
+
if settings.key?(:domain)
|
|
39
|
+
settings[:domain]
|
|
40
|
+
elsif settings.key?("domain")
|
|
41
|
+
settings["domain"]
|
|
42
|
+
else
|
|
43
|
+
aggregate.name
|
|
44
|
+
end
|
|
45
|
+
).to_s
|
|
38
46
|
|
|
39
47
|
FileUtils.mkdir_p(File.dirname(@path))
|
|
40
48
|
end
|
|
@@ -72,8 +80,12 @@ module Hecks
|
|
|
72
80
|
@entry_mirrors = nil
|
|
73
81
|
end
|
|
74
82
|
|
|
83
|
+
# Reads fresh rather than trusting the memoized `store` — under
|
|
84
|
+
# `with_lock`, another process may have projected a snapshot since
|
|
85
|
+
# this one last read it, and mutating *its* stale copy would
|
|
86
|
+
# overwrite that write on disk rather than layer on top of it.
|
|
75
87
|
def project(entry)
|
|
76
|
-
current =
|
|
88
|
+
current = read
|
|
77
89
|
entry.save? ? current[entry.id] = entry.state.dup : current.delete(entry.id)
|
|
78
90
|
write(current)
|
|
79
91
|
@store = current
|
|
@@ -82,8 +94,10 @@ module Hecks
|
|
|
82
94
|
|
|
83
95
|
def save(instance)
|
|
84
96
|
entry = Ports::Persistence::Entry.new(operation: "save", id: instance.id.to_s, state: instance.state.dup)
|
|
85
|
-
|
|
86
|
-
|
|
97
|
+
with_lock do
|
|
98
|
+
append(entry)
|
|
99
|
+
project(entry)
|
|
100
|
+
end
|
|
87
101
|
instance
|
|
88
102
|
end
|
|
89
103
|
|
|
@@ -91,8 +105,10 @@ module Hecks
|
|
|
91
105
|
return false unless find(id)
|
|
92
106
|
|
|
93
107
|
entry = Ports::Persistence::Entry.new(operation: "delete", id: id.to_s, state: nil)
|
|
94
|
-
|
|
95
|
-
|
|
108
|
+
with_lock do
|
|
109
|
+
append(entry)
|
|
110
|
+
project(entry)
|
|
111
|
+
end
|
|
96
112
|
true
|
|
97
113
|
end
|
|
98
114
|
|
|
@@ -104,8 +120,8 @@ module Hecks
|
|
|
104
120
|
# shape (a sibling snapshot+journal file pair, `SagaStore`,
|
|
105
121
|
# heki/saga_store.rb) rather than a table in a store this adapter
|
|
106
122
|
# doesn't have.
|
|
107
|
-
def save_saga(process_manager:, correlation:, state:, memory:)
|
|
108
|
-
saga_store.save_saga(@domain, process_manager.to_s, correlation.to_s, state.to_s, memory)
|
|
123
|
+
def save_saga(process_manager:, correlation:, state:, memory:, completed_compensations: [])
|
|
124
|
+
saga_store.save_saga(@domain, process_manager.to_s, correlation.to_s, state.to_s, memory, completed_compensations)
|
|
109
125
|
end
|
|
110
126
|
|
|
111
127
|
def delete_saga(process_manager:, correlation:)
|
|
@@ -137,8 +153,23 @@ module Hecks
|
|
|
137
153
|
replay_journal(snapshot)
|
|
138
154
|
end
|
|
139
155
|
|
|
156
|
+
# `dir: :default` — a bare Symbol, the framework's own convention
|
|
157
|
+
# for "a DECLARED value that resolves by convention, never a silent
|
|
158
|
+
# fallback" — used to crash `File.join` outright
|
|
159
|
+
# (`TypeError: no implicit conversion of Symbol into String`):
|
|
160
|
+
# `resolve_path` only ever checked for a MISSING `dir` setting,
|
|
161
|
+
# never a Symbol one. Treated the same as no setting at all — falls
|
|
162
|
+
# back to the existing "data" default, not a new special case.
|
|
140
163
|
def resolve_path(settings, root)
|
|
141
|
-
declared =
|
|
164
|
+
declared =
|
|
165
|
+
if settings.key?(:dir)
|
|
166
|
+
settings[:dir]
|
|
167
|
+
elsif settings.key?("dir")
|
|
168
|
+
settings["dir"]
|
|
169
|
+
else
|
|
170
|
+
"data"
|
|
171
|
+
end
|
|
172
|
+
declared = "data" if declared == :default
|
|
142
173
|
dir = declared.start_with?("/") ? declared : File.join(root || Dir.pwd, declared)
|
|
143
174
|
|
|
144
175
|
File.join(dir, "#{@aggregate.storage_name}.heki")
|
|
@@ -31,8 +31,22 @@ module Hecks
|
|
|
31
31
|
|
|
32
32
|
def initialize(aggregate:, settings: {}, root: nil)
|
|
33
33
|
@aggregate = aggregate
|
|
34
|
-
domain =
|
|
35
|
-
|
|
34
|
+
domain =
|
|
35
|
+
if settings.key?(:domain)
|
|
36
|
+
settings[:domain]
|
|
37
|
+
elsif settings.key?("domain")
|
|
38
|
+
settings["domain"]
|
|
39
|
+
else
|
|
40
|
+
aggregate.name
|
|
41
|
+
end
|
|
42
|
+
region =
|
|
43
|
+
if settings.key?(:region)
|
|
44
|
+
settings[:region]
|
|
45
|
+
elsif settings.key?("region")
|
|
46
|
+
settings["region"]
|
|
47
|
+
else
|
|
48
|
+
"us-east-1"
|
|
49
|
+
end
|
|
36
50
|
# TWO DIFFERENT "domain"s, deliberately not conflated: `domain`
|
|
37
51
|
# (this aggregate's OWN bluebook name — "Identity", "Governance")
|
|
38
52
|
# only ever prefixes the instances lookup, since that's how
|
|
@@ -21,6 +21,14 @@ module Hecks
|
|
|
21
21
|
end
|
|
22
22
|
lifecycle = @aggregate.lifecycle
|
|
23
23
|
fields << { name: lifecycle.field, attribute: nil, sql_type: "text" } if lifecycle && !fields.any? { |field| field[:name] == lifecycle.field }
|
|
24
|
+
# `projects` FIELDS (S12, ADR 0025) ARE A LOCAL COLUMN TOO — see
|
|
25
|
+
# Sqlite::Codec#persisted_fields' own comment; identical reasoning,
|
|
26
|
+
# `text` to match this file's own lowercase SQL type spelling.
|
|
27
|
+
@aggregate.projected_fields.each do |field|
|
|
28
|
+
next if fields.any? { |f| f[:name] == field.name }
|
|
29
|
+
|
|
30
|
+
fields << { name: field.name, attribute: nil, sql_type: "text" }
|
|
31
|
+
end
|
|
24
32
|
fields
|
|
25
33
|
end
|
|
26
34
|
|
|
@@ -19,6 +19,15 @@ module Hecks
|
|
|
19
19
|
@db.exec(
|
|
20
20
|
"CREATE TABLE IF NOT EXISTS #{quoted_table} (id text PRIMARY KEY#{columns.empty? ? '' : ', '}#{columns.join(', ')})"
|
|
21
21
|
)
|
|
22
|
+
# SELF-HEALING, SAME IDIOM AS `ensure_indexes!` BELOW —
|
|
23
|
+
# `CREATE TABLE IF NOT EXISTS` above does NOT retroactively add a
|
|
24
|
+
# column to an already-existing table (a committed database from
|
|
25
|
+
# before optimistic-concurrency CAS existed), so this runs
|
|
26
|
+
# unconditionally on every boot and no-ops once the column is
|
|
27
|
+
# there. ADAPTER BOOKKEEPING ONLY — never listed in
|
|
28
|
+
# `persisted_fields` (Codec), so it never appears in `decode`'s
|
|
29
|
+
# domain-state hash or `Instance#to_h`.
|
|
30
|
+
@db.exec("ALTER TABLE #{quoted_table} ADD COLUMN IF NOT EXISTS hecks_version bigint NOT NULL DEFAULT 1")
|
|
22
31
|
# RIGHT HERE, NOT A SEPARATE STEP IN `Postgres#initialize` —
|
|
23
32
|
# same idiom Sqlite::SchemaBuilder's own `create_aggregate_table!`
|
|
24
33
|
# uses: index creation runs unconditionally, right after the
|
|
@@ -60,15 +69,22 @@ module Hecks
|
|
|
60
69
|
def create_saga_table!
|
|
61
70
|
@db.exec(<<~SQL)
|
|
62
71
|
CREATE TABLE IF NOT EXISTS hecks_saga_instances (
|
|
63
|
-
domain
|
|
64
|
-
process_manager
|
|
65
|
-
correlation
|
|
66
|
-
state
|
|
67
|
-
memory
|
|
68
|
-
|
|
72
|
+
domain text NOT NULL,
|
|
73
|
+
process_manager text NOT NULL,
|
|
74
|
+
correlation text NOT NULL,
|
|
75
|
+
state text NOT NULL,
|
|
76
|
+
memory jsonb NOT NULL,
|
|
77
|
+
completed_compensations jsonb NOT NULL DEFAULT '[]'::jsonb,
|
|
78
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
69
79
|
PRIMARY KEY (domain, process_manager, correlation)
|
|
70
80
|
)
|
|
71
81
|
SQL
|
|
82
|
+
# `CREATE TABLE IF NOT EXISTS` above is a no-op against a table
|
|
83
|
+
# this same domain already created before this column existed
|
|
84
|
+
# — the same idiom `rust/host/src/journal.rs`'s own
|
|
85
|
+
# `sagas_backfilled` column addition already uses, for the
|
|
86
|
+
# identical reason.
|
|
87
|
+
@db.exec("ALTER TABLE hecks_saga_instances ADD COLUMN IF NOT EXISTS completed_compensations jsonb NOT NULL DEFAULT '[]'::jsonb")
|
|
72
88
|
end
|
|
73
89
|
|
|
74
90
|
def sql_type(attr)
|
|
@@ -123,6 +139,29 @@ module Hecks
|
|
|
123
139
|
# compiles a list `contains` to. An index here would be dead
|
|
124
140
|
# weight, not free correctness, so none is attempted.
|
|
125
141
|
def index_field!(field)
|
|
142
|
+
# A HOP PATH ("owner/field" — `Bluebook::AggregateBuilder`'s own
|
|
143
|
+
# convention for a field reached by crossing a reference,
|
|
144
|
+
# `aggregate_builder.rb`'s own `field.to_s.include?("/")`
|
|
145
|
+
# checks) IS NEVER INDEXED HERE — same reasoning as `list_of`
|
|
146
|
+
# below: `query_expression`/`nested_expression` below have no
|
|
147
|
+
# dialect for this shape at all (they'd compile the whole
|
|
148
|
+
# "owner/field" string as one bare column/jsonb-path segment,
|
|
149
|
+
# which is not a real column and errors PG::UndefinedColumn on
|
|
150
|
+
# `CREATE INDEX`, discovered fuzzing a real Postgres boot of
|
|
151
|
+
# `examples/banking` — Account's own `projects :customer_status,
|
|
152
|
+
# from: :"customer.status"` compiles to exactly this shape).
|
|
153
|
+
# Skipping the index is always safe, the same way skipping a
|
|
154
|
+
# `list_of` index is: an index is a perf optimization, not
|
|
155
|
+
# correctness, so "never built" beats "built wrong." Whether a
|
|
156
|
+
# hop-path field can be QUERIED at all against this adapter is
|
|
157
|
+
# a separate, still-open question this skip does not answer —
|
|
158
|
+
# see docs/future-features.md's fuzzer-adapter entry, and
|
|
159
|
+
# docs/1.0-readiness.md item 2 for this gap's OTHER two
|
|
160
|
+
# independent failure points (Postgres querying, Rust codegen's
|
|
161
|
+
# `OpenForSuspendedCustomers` — bin/rust_coverage's own
|
|
162
|
+
# allowlist) — tracked together, not as three unrelated bugs.
|
|
163
|
+
return if field.to_s.include?("/")
|
|
164
|
+
|
|
126
165
|
name, *_path = field.to_s.split(".")
|
|
127
166
|
attribute = @aggregate.attribute(name)
|
|
128
167
|
return if attribute&.list?
|
|
@@ -49,7 +49,7 @@ module Hecks
|
|
|
49
49
|
|
|
50
50
|
attr_reader :aggregate
|
|
51
51
|
|
|
52
|
-
def persistence_capabilities = [:atomic_put]
|
|
52
|
+
def persistence_capabilities = [:atomic_put, :optimistic_concurrency]
|
|
53
53
|
|
|
54
54
|
def self.connect_for(name, settings)
|
|
55
55
|
# LAZY, ON PURPOSE — same reasoning as PostgresEra's own
|
|
@@ -57,7 +57,7 @@ module Hecks
|
|
|
57
57
|
# need the gem installed.
|
|
58
58
|
require "pg"
|
|
59
59
|
|
|
60
|
-
declared = settings[:database]
|
|
60
|
+
declared = settings.key?(:database) ? settings[:database] : settings["database"]
|
|
61
61
|
if declared.to_s.empty?
|
|
62
62
|
raise Runtime::WiringError,
|
|
63
63
|
"#{name} binds Postgres, which needs a database connection, " \
|
|
@@ -76,7 +76,7 @@ module Hecks
|
|
|
76
76
|
# with other domains, so every unqualified reference this
|
|
77
77
|
# adapter constructs resolves through search_path. A domain with
|
|
78
78
|
# no `schema` setting keeps Postgres's own default (public).
|
|
79
|
-
schema = settings[:schema]
|
|
79
|
+
schema = settings.key?(:schema) ? settings[:schema] : settings["schema"]
|
|
80
80
|
connection.exec("SET search_path TO #{connection.quote_ident(schema)}") if schema.to_s != ""
|
|
81
81
|
|
|
82
82
|
# QUIET ON PURPOSE — same reasoning as PostgresEra's own: a
|
|
@@ -96,7 +96,15 @@ module Hecks
|
|
|
96
96
|
# (§2/§4) — falls back to the aggregate's own storage name for a
|
|
97
97
|
# directly-instantiated adapter (specs), same fallback shape
|
|
98
98
|
# Sqlite's own @domain already uses.
|
|
99
|
-
@domain = (
|
|
99
|
+
@domain = (
|
|
100
|
+
if settings.key?(:domain)
|
|
101
|
+
settings[:domain]
|
|
102
|
+
elsif settings.key?("domain")
|
|
103
|
+
settings["domain"]
|
|
104
|
+
else
|
|
105
|
+
aggregate.storage_name
|
|
106
|
+
end
|
|
107
|
+
).to_s
|
|
100
108
|
|
|
101
109
|
create_aggregate_table!
|
|
102
110
|
create_entry_table!
|
|
@@ -110,7 +118,7 @@ module Hecks
|
|
|
110
118
|
result = @db.exec_params("SELECT * FROM #{quoted_table} WHERE id = $1", [id.to_s])
|
|
111
119
|
return nil if result.ntuples.zero?
|
|
112
120
|
|
|
113
|
-
|
|
121
|
+
instance_from_row(result[0])
|
|
114
122
|
end
|
|
115
123
|
|
|
116
124
|
# order_by IS A RUNTIME VALUE — see Sqlite#all's own reasoning;
|
|
@@ -126,9 +134,7 @@ module Hecks
|
|
|
126
134
|
order_sql = "ORDER BY #{order_clause(spec, nil)}"
|
|
127
135
|
end
|
|
128
136
|
|
|
129
|
-
@db.exec("SELECT * FROM #{quoted_table} #{order_sql}").map
|
|
130
|
-
Runtime::Instance.new(aggregate: @aggregate, id: row["id"], state: decode(row))
|
|
131
|
-
end
|
|
137
|
+
@db.exec("SELECT * FROM #{quoted_table} #{order_sql}").map { |row| instance_from_row(row) }
|
|
132
138
|
end
|
|
133
139
|
|
|
134
140
|
def count = @db.exec("SELECT COUNT(*) FROM #{quoted_table}")[0]["count"].to_i
|
|
@@ -136,25 +142,55 @@ module Hecks
|
|
|
136
142
|
def append(entry)
|
|
137
143
|
@db.exec_params(
|
|
138
144
|
"INSERT INTO #{quoted_entry_table} (aggregate_id, operation, state, mirrors) VALUES ($1, $2, $3, $4)",
|
|
139
|
-
|
|
145
|
+
# `mirrors` (unlike `state`) is a NULLABLE column — an absent
|
|
146
|
+
# mirrors hash must bind a real SQL NULL, not the four-character
|
|
147
|
+
# JSON text `"null"` (`JSON.generate(nil)`), or a future `IS NULL`
|
|
148
|
+
# check against it would never match. Same guard `sqlite.rb`/
|
|
149
|
+
# `d1.rb`/`postgres_era.rb` already use for their own journal's
|
|
150
|
+
# `mirrors` column.
|
|
151
|
+
[entry.id, entry.operation, JSON.generate(entry.state), entry.mirrors && JSON.generate(entry.mirrors)]
|
|
140
152
|
)
|
|
141
153
|
entry
|
|
142
154
|
end
|
|
143
155
|
|
|
144
|
-
|
|
156
|
+
# `expected_version:` requests optimistic-concurrency CAS (see
|
|
157
|
+
# `persistence_capabilities`/`Ports::Persistence::AppendOnly#save`).
|
|
158
|
+
# `hecks_version` is ADAPTER BOOKKEEPING — never in `persisted_fields`
|
|
159
|
+
# (Codec), so it never reaches `decode`'s domain-state hash. It goes
|
|
160
|
+
# in the INSERT column list at `1` (a genuinely new row) and bumps by
|
|
161
|
+
# one in the `ON CONFLICT DO UPDATE` branch; when `expected_version`
|
|
162
|
+
# is given, that UPDATE branch additionally requires
|
|
163
|
+
# `hecks_version = expected_version` to apply at all — Postgres's own
|
|
164
|
+
# `INSERT ... ON CONFLICT DO UPDATE ... WHERE`, which gates only
|
|
165
|
+
# whether the CONFLICT branch's update applies. A genuinely new row
|
|
166
|
+
# never reaches that branch at all, so it always inserts regardless
|
|
167
|
+
# of this WHERE. `RETURNING hecks_version` plus `ntuples.zero?` is
|
|
168
|
+
# how a real version mismatch is told apart from an ordinary write:
|
|
169
|
+
# zero rows back means the conflict branch's WHERE excluded the row
|
|
170
|
+
# entirely — the version had already moved — so `nil` is returned
|
|
171
|
+
# for the caller (`AppendOnly#save`) to treat as "stale, no-op".
|
|
172
|
+
def project(entry, expected_version: nil)
|
|
145
173
|
return @db.exec_params("DELETE FROM #{quoted_table} WHERE id = $1", [entry.id]) if entry.delete?
|
|
146
174
|
|
|
147
175
|
instance = Runtime::Instance.new(aggregate: @aggregate, id: entry.id, state: entry.state)
|
|
148
|
-
columns = (["id"] + persisted_fields.map { |field| field[:name].to_s })
|
|
149
|
-
values = [instance.id.to_s] + persisted_fields.map { |field| encode_field(field, instance[field[:name]]) }
|
|
150
|
-
updates = persisted_fields.map { |field| "#{quote_ident(field[:name])} = EXCLUDED.#{quote_ident(field[:name])}" }
|
|
176
|
+
columns = (["id"] + persisted_fields.map { |field| field[:name].to_s } + ["hecks_version"])
|
|
177
|
+
values = [instance.id.to_s] + persisted_fields.map { |field| encode_field(field, instance[field[:name]]) } + [1]
|
|
178
|
+
updates = persisted_fields.map { |field| "#{quote_ident(field[:name])} = EXCLUDED.#{quote_ident(field[:name])}" } +
|
|
179
|
+
["hecks_version = #{quoted_table}.hecks_version + 1"]
|
|
180
|
+
|
|
181
|
+
sql = "INSERT INTO #{quoted_table} (#{columns.map { |c| quote_ident(c) }.join(', ')}) " \
|
|
182
|
+
"VALUES (#{(1..columns.size).map { |n| "$#{n}" }.join(', ')}) " \
|
|
183
|
+
"ON CONFLICT (id) DO UPDATE SET #{updates.join(', ')}"
|
|
184
|
+
if expected_version
|
|
185
|
+
values += [expected_version]
|
|
186
|
+
sql += " WHERE #{quoted_table}.hecks_version = $#{values.size}"
|
|
187
|
+
end
|
|
188
|
+
sql += " RETURNING hecks_version"
|
|
151
189
|
|
|
152
|
-
@db.exec_params(
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
values
|
|
157
|
-
)
|
|
190
|
+
result = @db.exec_params(sql, values)
|
|
191
|
+
return nil if result.ntuples.zero?
|
|
192
|
+
|
|
193
|
+
instance.version = result[0]["hecks_version"].to_i
|
|
158
194
|
instance
|
|
159
195
|
end
|
|
160
196
|
|
|
@@ -245,13 +281,14 @@ module Hecks
|
|
|
245
281
|
# ── the OPTIONAL saga-persistence capability (§2) — same DDL and
|
|
246
282
|
# shape as PostgresEra's own (postgres_era.rb), not lineage-
|
|
247
283
|
# specific, copied verbatim.
|
|
248
|
-
def save_saga(process_manager:, correlation:, state:, memory:)
|
|
284
|
+
def save_saga(process_manager:, correlation:, state:, memory:, completed_compensations: [])
|
|
249
285
|
@db.exec_params(
|
|
250
|
-
"INSERT INTO hecks_saga_instances (domain, process_manager, correlation, state, memory) " \
|
|
251
|
-
"VALUES ($1, $2, $3, $4, $5) " \
|
|
286
|
+
"INSERT INTO hecks_saga_instances (domain, process_manager, correlation, state, memory, completed_compensations) " \
|
|
287
|
+
"VALUES ($1, $2, $3, $4, $5, $6) " \
|
|
252
288
|
"ON CONFLICT (domain, process_manager, correlation) DO UPDATE " \
|
|
253
|
-
"SET state = EXCLUDED.state, memory = EXCLUDED.memory,
|
|
254
|
-
|
|
289
|
+
"SET state = EXCLUDED.state, memory = EXCLUDED.memory, " \
|
|
290
|
+
"completed_compensations = EXCLUDED.completed_compensations, updated_at = now()",
|
|
291
|
+
[@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory), JSON.generate(completed_compensations)]
|
|
255
292
|
)
|
|
256
293
|
end
|
|
257
294
|
|
|
@@ -266,11 +303,12 @@ module Hecks
|
|
|
266
303
|
return enum_for(:each_saga) unless block_given?
|
|
267
304
|
|
|
268
305
|
@db.exec_params(
|
|
269
|
-
"SELECT process_manager, correlation, state, memory FROM hecks_saga_instances WHERE domain = $1",
|
|
306
|
+
"SELECT process_manager, correlation, state, memory, completed_compensations FROM hecks_saga_instances WHERE domain = $1",
|
|
270
307
|
[@domain]
|
|
271
308
|
).each do |row|
|
|
272
309
|
yield row["process_manager"], row["correlation"], row["state"],
|
|
273
|
-
JSON.parse(row["memory"], symbolize_names: true)
|
|
310
|
+
JSON.parse(row["memory"], symbolize_names: true),
|
|
311
|
+
JSON.parse(row["completed_compensations"] || "[]", symbolize_names: true)
|
|
274
312
|
end
|
|
275
313
|
end
|
|
276
314
|
|
|
@@ -332,9 +370,18 @@ module Hecks
|
|
|
332
370
|
end
|
|
333
371
|
|
|
334
372
|
def execute_query(sql, binds)
|
|
335
|
-
@db.exec_params(sql, binds).map
|
|
336
|
-
|
|
337
|
-
|
|
373
|
+
@db.exec_params(sql, binds).map { |row| instance_from_row(row) }
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Stamps `.version` (adapter bookkeeping, never domain state — see
|
|
377
|
+
# `Instance`'s own comment) from the row's `hecks_version` column on
|
|
378
|
+
# every Instance this adapter builds from a real stored row, so a
|
|
379
|
+
# later `save`'s optimistic-concurrency CAS has something to check
|
|
380
|
+
# against.
|
|
381
|
+
def instance_from_row(row)
|
|
382
|
+
instance = Runtime::Instance.new(aggregate: @aggregate, id: row["id"], state: decode(row))
|
|
383
|
+
instance.version = row["hecks_version"].to_i
|
|
384
|
+
instance
|
|
338
385
|
end
|
|
339
386
|
|
|
340
387
|
# ── the rest of the dialect ─────────────────────────────────────
|