hecks 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (247) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/claude_code.rb +3 -3
  3. data/lib/hecks/adapters/driven/d1.rb +48 -28
  4. data/lib/hecks/adapters/driven/folder.rb +10 -3
  5. data/lib/hecks/adapters/driven/google_authentication.rb +2 -4
  6. data/lib/hecks/adapters/driven/heki/journal.rb +1 -1
  7. data/lib/hecks/adapters/driven/heki.rb +3 -5
  8. data/lib/hecks/adapters/driven/in_memory_ordering.rb +4 -1
  9. data/lib/hecks/adapters/driven/lambda.rb +2 -2
  10. data/lib/hecks/adapters/driven/local_storage.adapter +3 -0
  11. data/lib/hecks/adapters/driven/local_storage.rb +120 -0
  12. data/lib/hecks/adapters/driven/memory.rb +48 -3
  13. data/lib/hecks/adapters/driven/postgres/codec.rb +3 -1
  14. data/lib/hecks/adapters/driven/postgres/outbox.rb +100 -0
  15. data/lib/hecks/adapters/driven/postgres/reconnect.rb +57 -0
  16. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +22 -2
  17. data/lib/hecks/adapters/driven/postgres.rb +45 -31
  18. data/lib/hecks/adapters/driven/prism.rb +11 -0
  19. data/lib/hecks/adapters/driven/sql_query_builder.rb +45 -20
  20. data/lib/hecks/adapters/driven/sqlite/codec.rb +3 -1
  21. data/lib/hecks/adapters/driven/sqlite/projection.rb +22 -3
  22. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +23 -1
  23. data/lib/hecks/adapters/driven/sqlite.rb +87 -8
  24. data/lib/hecks/adapters/driven.rb +4 -0
  25. data/lib/hecks/adapters.rb +4 -0
  26. data/lib/hecks/behaviors/dsl.rb +7 -0
  27. data/lib/hecks/behaviors/expectations.rb +32 -4
  28. data/lib/hecks/behaviors/rspec.rb +2 -0
  29. data/lib/hecks/behaviors/runner.rb +4 -0
  30. data/lib/hecks/bluebook/aggregate.rb +6 -5
  31. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +4 -4
  32. data/lib/hecks/bluebook/assembly/build.rb +6 -3
  33. data/lib/hecks/bluebook/assembly/contract.rb +5 -1
  34. data/lib/hecks/bluebook/assembly/contracts.rb +4 -0
  35. data/lib/hecks/bluebook/assembly/marks.rb +15 -4
  36. data/lib/hecks/bluebook/assembly.rb +1 -3
  37. data/lib/hecks/bluebook/attribute.rb +6 -0
  38. data/lib/hecks/bluebook/behaviour/policy.rb +18 -0
  39. data/lib/hecks/bluebook/behaviour/process_manager.rb +17 -1
  40. data/lib/hecks/bluebook/behaviour/read_model.rb +53 -8
  41. data/lib/hecks/bluebook/behaviour/traits.rb +1 -1
  42. data/lib/hecks/bluebook/command.rb +6 -5
  43. data/lib/hecks/bluebook/dsl/adapter_builder.rb +4 -1
  44. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +524 -0
  45. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +29 -477
  46. data/lib/hecks/bluebook/dsl/attribute_collector.rb +14 -6
  47. data/lib/hecks/bluebook/dsl/binding_proxy.rb +5 -0
  48. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +848 -0
  49. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +25 -784
  50. data/lib/hecks/bluebook/dsl/command_builder.rb +53 -13
  51. data/lib/hecks/bluebook/dsl/const_shim.rb +6 -0
  52. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +20 -7
  53. data/lib/hecks/bluebook/dsl/entity_builder.rb +27 -6
  54. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +12 -2
  55. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +8 -1
  56. data/lib/hecks/bluebook/dsl/identity_declaration.rb +24 -12
  57. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +40 -1
  58. data/lib/hecks/bluebook/dsl/policy_builder.rb +10 -1
  59. data/lib/hecks/bluebook/dsl/port_builder.rb +6 -1
  60. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +33 -12
  61. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +56 -12
  62. data/lib/hecks/bluebook/dsl/query_builder.rb +7 -1
  63. data/lib/hecks/bluebook/dsl/read_model_builder.rb +142 -19
  64. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -1
  65. data/lib/hecks/bluebook/dsl/translation_builder.rb +13 -2
  66. data/lib/hecks/bluebook/dsl/value_object_builder.rb +8 -1
  67. data/lib/hecks/bluebook/dsl/word_gate.rb +5 -0
  68. data/lib/hecks/bluebook/dsl/world_builder.rb +13 -3
  69. data/lib/hecks/bluebook/entity.rb +5 -3
  70. data/lib/hecks/bluebook/expression/ast_json.rb +88 -9
  71. data/lib/hecks/bluebook/expression/ast_reader.rb +107 -0
  72. data/lib/hecks/bluebook/expression/canonical_form.rb +7 -0
  73. data/lib/hecks/bluebook/expression/evaluator.rb +64 -11
  74. data/lib/hecks/bluebook/expression/projection.json +12 -0
  75. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +4 -3
  76. data/lib/hecks/bluebook/expression/resolver.rb +83 -11
  77. data/lib/hecks/bluebook/expression.rb +1 -0
  78. data/lib/hecks/bluebook/hexagon.rb +10 -0
  79. data/lib/hecks/bluebook/lifecycle.rb +9 -0
  80. data/lib/hecks/bluebook/meta_validator/judge.rb +10 -4
  81. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -0
  82. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -1
  83. data/lib/hecks/bluebook/meta_validator/shapes.rb +3 -3
  84. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +7 -4
  85. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +42 -3
  86. data/lib/hecks/bluebook/meta_validator/world_judge.rb +1 -1
  87. data/lib/hecks/bluebook/meta_validator.rb +5 -5
  88. data/lib/hecks/bluebook/model_check.rb +119 -82
  89. data/lib/hecks/bluebook/pattern_subset.rb +9 -0
  90. data/lib/hecks/bluebook/policy.rb +2 -1
  91. data/lib/hecks/bluebook/process_manager.rb +6 -0
  92. data/lib/hecks/bluebook/project_register.rb +45 -25
  93. data/lib/hecks/bluebook/query.rb +4 -0
  94. data/lib/hecks/bluebook/read_model.rb +2 -2
  95. data/lib/hecks/bluebook/smoke_test.rb +5 -0
  96. data/lib/hecks/bluebook/synthesizer.rb +2 -1
  97. data/lib/hecks/bluebook/translation.rb +3 -0
  98. data/lib/hecks/bluebook/value_object.rb +2 -2
  99. data/lib/hecks/codemod.rb +13 -0
  100. data/lib/hecks/doc/reference.rb +17 -11
  101. data/lib/hecks/facade/cli_runner.rb +2 -2
  102. data/lib/hecks/facade/command_request.rb +11 -11
  103. data/lib/hecks/facade/handle.rb +3 -3
  104. data/lib/hecks/facade/surface/aggregate_door.rb +52 -38
  105. data/lib/hecks/facade.rb +4 -0
  106. data/lib/hecks/forms/app.rb +10 -6
  107. data/lib/hecks/forms/command_form_renderer.rb +3 -3
  108. data/lib/hecks/forms/field_renderer.rb +14 -9
  109. data/lib/hecks/forms/field_shape.rb +7 -2
  110. data/lib/hecks/forms/html.rb +5 -2
  111. data/lib/hecks/forms/index_renderer.rb +1 -1
  112. data/lib/hecks/forms/page.rb +7 -3
  113. data/lib/hecks/forms/query_form_renderer.rb +3 -2
  114. data/lib/hecks/forms/record_renderer.rb +22 -16
  115. data/lib/hecks/forms.rb +4 -0
  116. data/lib/hecks/fqn.rb +21 -9
  117. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +30 -8
  118. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -1
  119. data/lib/hecks/fuzzing/isolated_boot.rb +14 -6
  120. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +264 -0
  121. data/lib/hecks/fuzzing/properties/guards.rb +251 -0
  122. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +351 -0
  123. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +99 -0
  124. data/lib/hecks/fuzzing/properties/querying.rb +206 -0
  125. data/lib/hecks/fuzzing/properties.rb +41 -1065
  126. data/lib/hecks/fuzzing/replay.rb +60 -16
  127. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +6 -2
  128. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +2 -1
  129. data/lib/hecks/fuzzing/sequence_generator.rb +0 -1
  130. data/lib/hecks/fuzzing/value_generator.rb +2 -2
  131. data/lib/hecks/grammar/evolve.rb +34 -10
  132. data/lib/hecks/grammar/expression_operators.json +124 -0
  133. data/lib/hecks/grammar.rb +15 -5
  134. data/lib/hecks/ir.rb +9 -0
  135. data/lib/hecks/language/bluebook/aggregate.bluebook +4 -2
  136. data/lib/hecks/language/bluebook/command.bluebook +2 -1
  137. data/lib/hecks/language/bluebook/entity.bluebook +2 -1
  138. data/lib/hecks/language/bluebook/projection.bluebook +2 -1
  139. data/lib/hecks/language/bluebook/query.bluebook +4 -2
  140. data/lib/hecks/language/bluebook/shape.bluebook +2 -1
  141. data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -0
  142. data/lib/hecks/literal.rb +18 -8
  143. data/lib/hecks/naming.rb +6 -3
  144. data/lib/hecks/ports/access_control.rb +1 -1
  145. data/lib/hecks/ports/agent.rb +5 -3
  146. data/lib/hecks/ports/authentication.rb +3 -2
  147. data/lib/hecks/ports/authorization.rb +1 -1
  148. data/lib/hecks/ports/clock.rb +1 -1
  149. data/lib/hecks/ports/extraction.rb +5 -1
  150. data/lib/hecks/ports/identity_assignment.rb +1 -1
  151. data/lib/hecks/ports/identity_generation.rb +1 -1
  152. data/lib/hecks/ports/identity_resolution.rb +1 -1
  153. data/lib/hecks/ports/loading.rb +4 -1
  154. data/lib/hecks/ports/persistence/append_only.rb +51 -4
  155. data/lib/hecks/ports/persistence/binding_policy.rb +1 -1
  156. data/lib/hecks/ports/persistence/plugin.rb +3 -0
  157. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -1
  158. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +5 -5
  159. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +14 -5
  160. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +4 -0
  161. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +1 -1
  162. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +80 -16
  163. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +23 -6
  164. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +43 -5
  165. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +25 -4
  166. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +49 -9
  167. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +2 -2
  168. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +10 -0
  169. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +49 -38
  170. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +77 -18
  171. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +19 -21
  172. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +27 -9
  173. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +9 -0
  174. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +25 -10
  175. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +2 -6
  176. data/lib/hecks/ports/persistence/plugins/era.rb +3 -1
  177. data/lib/hecks/ports/persistence/remote_runtime.rb +1 -1
  178. data/lib/hecks/ports/persistence.rb +7 -3
  179. data/lib/hecks/ports/projection.rb +13 -5
  180. data/lib/hecks/ports/query/in_memory.rb +8 -2
  181. data/lib/hecks/ports/query.rb +1 -3
  182. data/lib/hecks/ports.rb +2 -0
  183. data/lib/hecks/projections/diagrams.rb +11 -3
  184. data/lib/hecks/projections/glossary.rb +374 -0
  185. data/lib/hecks/projections/ir.rb +2 -0
  186. data/lib/hecks/projections/model/deviations.rb +2 -1
  187. data/lib/hecks/projections/model.rb +15 -10
  188. data/lib/hecks/projections/vocabulary.rb +1 -3
  189. data/lib/hecks/projections.rb +1 -0
  190. data/lib/hecks/projector/cli_projector.rb +78 -42
  191. data/lib/hecks/projector/docs_projector.rb +21 -12
  192. data/lib/hecks/projector/exporter.rb +34 -1
  193. data/lib/hecks/projector/narrate_projector.rb +83 -25
  194. data/lib/hecks/projector.rb +2 -2
  195. data/lib/hecks/query_ir.rb +60 -36
  196. data/lib/hecks/query_specification/common/authorization_spec.rb +1 -1
  197. data/lib/hecks/query_specification/common/comparators.rb +4 -0
  198. data/lib/hecks/query_specification/common/comparison.rb +8 -1
  199. data/lib/hecks/query_specification/common/dsl.rb +5 -0
  200. data/lib/hecks/query_specification/common/limit_spec.rb +9 -2
  201. data/lib/hecks/query_specification/common/null_policy.rb +6 -2
  202. data/lib/hecks/query_specification/common/offset_spec.rb +9 -2
  203. data/lib/hecks/query_specification/common/options.rb +17 -5
  204. data/lib/hecks/query_specification/common/order_by.rb +9 -2
  205. data/lib/hecks/query_specification/common/where_clause.rb +15 -2
  206. data/lib/hecks/query_specification/field_path.rb +4 -4
  207. data/lib/hecks/query_specification/read_model/specification.rb +5 -2
  208. data/lib/hecks/router/namespace_installer.rb +22 -10
  209. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +57 -22
  210. data/lib/hecks/runtime/command_interpreter.rb +104 -65
  211. data/lib/hecks/runtime/command_rules/admissibility.rb +26 -8
  212. data/lib/hecks/runtime/command_rules/arithmetic.rb +35 -14
  213. data/lib/hecks/runtime/dependency_planning.rb +33 -21
  214. data/lib/hecks/runtime/dispatcher.rb +41 -7
  215. data/lib/hecks/runtime/entity_element.rb +25 -6
  216. data/lib/hecks/runtime/entity_interpreter.rb +28 -15
  217. data/lib/hecks/runtime/errors.rb +28 -2
  218. data/lib/hecks/runtime/instance.rb +9 -3
  219. data/lib/hecks/runtime/interpreting.rb +45 -9
  220. data/lib/hecks/runtime/loader.rb +21 -0
  221. data/lib/hecks/runtime/outbox.rb +365 -0
  222. data/lib/hecks/runtime/policy_interpreter.rb +32 -17
  223. data/lib/hecks/runtime/query_interpreter.rb +36 -16
  224. data/lib/hecks/runtime/reaction_invocation.rb +35 -22
  225. data/lib/hecks/runtime/read_model_interpreter.rb +31 -7
  226. data/lib/hecks/runtime/reference_hop.rb +1 -1
  227. data/lib/hecks/runtime/refusal_wording.rb +55 -49
  228. data/lib/hecks/runtime/registry/saga_persistence.rb +3 -1
  229. data/lib/hecks/runtime/registry/verification.rb +39 -2
  230. data/lib/hecks/runtime/registry.rb +21 -11
  231. data/lib/hecks/runtime/remote_dispatcher.rb +3 -1
  232. data/lib/hecks/runtime/routing.rb +52 -39
  233. data/lib/hecks/runtime/saga_interpreter/correlation.rb +7 -7
  234. data/lib/hecks/runtime/saga_interpreter.rb +119 -90
  235. data/lib/hecks/runtime/saga_pending_dispatch.rb +6 -3
  236. data/lib/hecks/runtime/tenant_scope.rb +7 -0
  237. data/lib/hecks/runtime/value/coercion.rb +271 -49
  238. data/lib/hecks/runtime/value.rb +1 -1
  239. data/lib/hecks/runtime.rb +7 -2
  240. data/lib/hecks/storehouse.rb +15 -8
  241. data/lib/hecks/version.rb +1 -1
  242. data/lib/hecks/vocabulary.rb +1 -0
  243. data/lib/hecks.rb +17 -13
  244. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +1 -1
  245. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +1 -1
  246. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +1 -1
  247. metadata +16 -2
@@ -17,7 +17,7 @@ module Hecks
17
17
  def create_aggregate_table!
18
18
  columns = persisted_fields.map { |field| "#{quote_ident(field[:name])} #{field[:sql_type]}" }
19
19
  @db.exec(
20
- "CREATE TABLE IF NOT EXISTS #{quoted_table} (id text PRIMARY KEY#{columns.empty? ? '' : ', '}#{columns.join(', ')})"
20
+ "CREATE TABLE IF NOT EXISTS #{quoted_table} (id text PRIMARY KEY#{', ' unless columns.empty?}#{columns.join(', ')})"
21
21
  )
22
22
  # SELF-HEALING, SAME IDIOM AS `ensure_indexes!` BELOW —
23
23
  # `CREATE TABLE IF NOT EXISTS` above does NOT retroactively add a
@@ -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
@@ -84,7 +103,8 @@ module Hecks
84
103
  # — the same idiom `rust/host/src/journal.rs`'s own
85
104
  # `sagas_backfilled` column addition already uses, for the
86
105
  # identical reason.
87
- @db.exec("ALTER TABLE hecks_saga_instances ADD COLUMN IF NOT EXISTS completed_compensations jsonb NOT NULL DEFAULT '[]'::jsonb")
106
+ @db.exec("ALTER TABLE hecks_saga_instances ADD COLUMN IF NOT EXISTS completed_compensations jsonb " \
107
+ "NOT NULL DEFAULT '[]'::jsonb")
88
108
  end
89
109
 
90
110
  def sql_type(attr)
@@ -9,6 +9,8 @@ require_relative "../../query_specification/common/order_by"
9
9
  require_relative "../../query_specification/field_path"
10
10
  require_relative "../../runtime/errors"
11
11
  require_relative "../../runtime/event"
12
+ require_relative "postgres/outbox"
13
+ require_relative "postgres/reconnect"
12
14
  require_relative "../../runtime/instance"
13
15
 
14
16
  module Hecks
@@ -44,6 +46,8 @@ module Hecks
44
46
  include SqlQueryBuilder
45
47
  include SchemaBuilder
46
48
  include Codec
49
+ include PostgresOutbox
50
+ include PostgresReconnect
47
51
 
48
52
  SQL_TYPES = { "Integer" => "bigint", "Float" => "double precision" }.freeze
49
53
 
@@ -84,13 +88,14 @@ module Hecks
84
88
  # boot after the first, not news.
85
89
  connection.exec("SET client_min_messages = warning")
86
90
  connection
87
- rescue PG::Error => error
91
+ rescue PG::Error => e
88
92
  raise Runtime::WiringError,
89
- "cannot bind Postgres at #{declared} for #{name}: #{error.message.strip}"
93
+ "cannot bind Postgres at #{declared} for #{name}: #{e.message.strip}"
90
94
  end
91
95
 
92
96
  def initialize(aggregate:, settings: {}, root: nil)
93
97
  @aggregate = aggregate
98
+ @settings = settings
94
99
  @db = self.class.connect_for(aggregate.name, settings)
95
100
  # THE OPTIONAL saga-persistence capability's own scoping column
96
101
  # (§2/§4) — falls back to the aggregate's own storage name for a
@@ -110,12 +115,13 @@ module Hecks
110
115
  create_entry_table!
111
116
  create_event_table!
112
117
  create_saga_table!
118
+ create_outbox_table!
113
119
  end
114
120
 
115
121
  def table = @aggregate.storage_name
116
122
 
117
123
  def find(id)
118
- result = @db.exec_params("SELECT * FROM #{quoted_table} WHERE id = $1", [id.to_s])
124
+ result = pg_exec_params("SELECT * FROM #{quoted_table} WHERE id = $1", [id.to_s])
119
125
  return nil if result.ntuples.zero?
120
126
 
121
127
  instance_from_row(result[0])
@@ -128,19 +134,22 @@ module Hecks
128
134
  order_sql = "ORDER BY id"
129
135
  if order_by
130
136
  name = order_by.to_s.split(".").first
131
- raise Runtime::WiringError, "#{@aggregate.name} has no attribute #{order_by.inspect} to order by" unless @aggregate.lifecycle&.field.to_s == name || @aggregate.attribute(name)
137
+ unless @aggregate.lifecycle&.field.to_s == name || @aggregate.attribute(name)
138
+ raise Runtime::WiringError,
139
+ "#{@aggregate.name} has no attribute #{order_by.inspect} to order by"
140
+ end
132
141
 
133
142
  spec = QuerySpecification::Common::OrderBy.new(field: order_by, direction: direction)
134
143
  order_sql = "ORDER BY #{order_clause(spec, nil)}"
135
144
  end
136
145
 
137
- @db.exec("SELECT * FROM #{quoted_table} #{order_sql}").map { |row| instance_from_row(row) }
146
+ pg_exec("SELECT * FROM #{quoted_table} #{order_sql}").map { |row| instance_from_row(row) }
138
147
  end
139
148
 
140
- def count = @db.exec("SELECT COUNT(*) FROM #{quoted_table}")[0]["count"].to_i
149
+ def count = pg_exec("SELECT COUNT(*) FROM #{quoted_table}")[0]["count"].to_i
141
150
 
142
151
  def append(entry)
143
- @db.exec_params(
152
+ pg_exec_params(
144
153
  "INSERT INTO #{quoted_entry_table} (aggregate_id, operation, state, mirrors) VALUES ($1, $2, $3, $4)",
145
154
  # `mirrors` (unlike `state`) is a NULLABLE column — an absent
146
155
  # mirrors hash must bind a real SQL NULL, not the four-character
@@ -169,8 +178,10 @@ module Hecks
169
178
  # zero rows back means the conflict branch's WHERE excluded the row
170
179
  # entirely — the version had already moved — so `nil` is returned
171
180
  # for the caller (`AppendOnly#save`) to treat as "stale, no-op".
181
+ # rubocop:disable Metrics/AbcSize -- the CAS/plain upsert split is one
182
+ # protocol; splitting it would hide the version handshake.
172
183
  def project(entry, expected_version: nil)
173
- return @db.exec_params("DELETE FROM #{quoted_table} WHERE id = $1", [entry.id]) if entry.delete?
184
+ return pg_exec_params("DELETE FROM #{quoted_table} WHERE id = $1", [entry.id]) if entry.delete?
174
185
 
175
186
  instance = Runtime::Instance.new(aggregate: @aggregate, id: entry.id, state: entry.state)
176
187
  columns = (["id"] + persisted_fields.map { |field| field[:name].to_s } + ["hecks_version"])
@@ -187,15 +198,16 @@ module Hecks
187
198
  end
188
199
  sql += " RETURNING hecks_version"
189
200
 
190
- result = @db.exec_params(sql, values)
201
+ result = pg_exec_params(sql, values)
191
202
  return nil if result.ntuples.zero?
192
203
 
193
204
  instance.version = result[0]["hecks_version"].to_i
194
205
  instance
195
206
  end
207
+ # rubocop:enable Metrics/AbcSize
196
208
 
197
209
  def entries
198
- @db.exec("SELECT aggregate_id, operation, state, mirrors FROM #{quoted_entry_table} ORDER BY sequence").map do |row|
210
+ pg_exec("SELECT aggregate_id, operation, state, mirrors FROM #{quoted_entry_table} ORDER BY sequence").map do |row|
199
211
  state = JSON.parse(row["state"])
200
212
  Ports::Persistence::Entry.new(
201
213
  operation: row["operation"] || "save",
@@ -207,8 +219,8 @@ module Hecks
207
219
  end
208
220
 
209
221
  def reset!
210
- @db.exec("DELETE FROM #{quoted_table}")
211
- @db.exec("DELETE FROM #{quoted_entry_table}")
222
+ pg_exec("DELETE FROM #{quoted_table}")
223
+ pg_exec("DELETE FROM #{quoted_entry_table}")
212
224
  self
213
225
  end
214
226
 
@@ -221,24 +233,21 @@ module Hecks
221
233
  # transaction lives here, the one caller that runs both together.
222
234
  def save(instance)
223
235
  entry = Ports::Persistence::Entry.new(operation: "save", id: instance.id.to_s, state: instance.state.dup)
224
- @db.transaction { append(entry); project(entry) }
236
+ transaction do
237
+ append(entry)
238
+ project(entry)
239
+ end
225
240
  end
226
241
 
227
- # Classification, journal append and snapshot replacement are one
228
- # Postgres transaction. A row lock cannot serialize two first writers —
229
- # there is no row to lock yet — so a transaction-scoped advisory lock on
230
- # schema/table + aggregate id owns that missing-row race as well. Once a
231
- # writer acquires it, the preceding writer has committed and status can
232
- # be read from the materialized table without a runtime-side find.
233
242
  def atomic_put(entry, insert_only: false)
234
243
  status = nil
235
- @db.transaction do
236
- @db.exec_params(
244
+ transaction do
245
+ pg_exec_params(
237
246
  "SELECT pg_advisory_xact_lock(" \
238
247
  "hashtext(current_schema() || ':' || $1), hashtext($2))",
239
248
  [table, entry.id.to_s]
240
249
  )
241
- exists = !@db.exec_params(
250
+ exists = !pg_exec_params(
242
251
  "SELECT 1 FROM #{quoted_table} WHERE id = $1",
243
252
  [entry.id.to_s]
244
253
  ).ntuples.zero?
@@ -255,19 +264,22 @@ module Hecks
255
264
 
256
265
  def delete(id)
257
266
  entry = Ports::Persistence::Entry.new(operation: "delete", id: id.to_s, state: nil)
258
- @db.transaction { append(entry); project(entry) }
267
+ transaction do
268
+ append(entry)
269
+ project(entry)
270
+ end
259
271
  true
260
272
  end
261
273
 
262
274
  def record_event(event)
263
- @db.exec_params(
275
+ pg_exec_params(
264
276
  "INSERT INTO events (name, aggregate, aggregate_id, payload, occurred_at) VALUES ($1, $2, $3, $4, $5)",
265
277
  [event.name, event.aggregate, event.id.to_s, JSON.generate(event.payload), event.occurred_at]
266
278
  )
267
279
  end
268
280
 
269
281
  def events
270
- @db.exec("SELECT * FROM events ORDER BY id").map do |row|
282
+ pg_exec("SELECT * FROM events ORDER BY id").map do |row|
271
283
  Runtime::Event.new(
272
284
  name: row["name"],
273
285
  aggregate: row["aggregate"],
@@ -282,18 +294,19 @@ module Hecks
282
294
  # shape as PostgresEra's own (postgres_era.rb), not lineage-
283
295
  # specific, copied verbatim.
284
296
  def save_saga(process_manager:, correlation:, state:, memory:, completed_compensations: [])
285
- @db.exec_params(
297
+ pg_exec_params(
286
298
  "INSERT INTO hecks_saga_instances (domain, process_manager, correlation, state, memory, completed_compensations) " \
287
299
  "VALUES ($1, $2, $3, $4, $5, $6) " \
288
300
  "ON CONFLICT (domain, process_manager, correlation) DO UPDATE " \
289
301
  "SET state = EXCLUDED.state, memory = EXCLUDED.memory, " \
290
302
  "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)]
303
+ [@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory),
304
+ JSON.generate(completed_compensations)]
292
305
  )
293
306
  end
294
307
 
295
308
  def delete_saga(process_manager:, correlation:)
296
- @db.exec_params(
309
+ pg_exec_params(
297
310
  "DELETE FROM hecks_saga_instances WHERE domain = $1 AND process_manager = $2 AND correlation = $3",
298
311
  [@domain, process_manager.to_s, correlation.to_s]
299
312
  )
@@ -302,8 +315,9 @@ module Hecks
302
315
  def each_saga
303
316
  return enum_for(:each_saga) unless block_given?
304
317
 
305
- @db.exec_params(
306
- "SELECT process_manager, correlation, state, memory, completed_compensations FROM hecks_saga_instances WHERE domain = $1",
318
+ pg_exec_params(
319
+ "SELECT process_manager, correlation, state, memory, completed_compensations " \
320
+ "FROM hecks_saga_instances WHERE domain = $1",
307
321
  [@domain]
308
322
  ).each do |row|
309
323
  yield row["process_manager"], row["correlation"], row["state"],
@@ -370,7 +384,7 @@ module Hecks
370
384
  end
371
385
 
372
386
  def execute_query(sql, binds)
373
- @db.exec_params(sql, binds).map { |row| instance_from_row(row) }
387
+ pg_exec_params(sql, binds).map { |row| instance_from_row(row) }
374
388
  end
375
389
 
376
390
  # Stamps `.version` (adapter bookkeeping, never domain state — see
@@ -5,7 +5,18 @@ module Hecks
5
5
  module Adapters
6
6
  class NotExtractable < StandardError; end
7
7
 
8
+ # Extracts a `given`/`ensures`/invariant block's own source text back out
9
+ # of the `.rb` file it was defined in (via `block.source_location`),
10
+ # parses it with the `prism` gem, and canonicalises it — how a rule's
11
+ # predicate becomes readable, comparable text (era diffing, docs) rather
12
+ # than an opaque compiled Proc. Per-process `TREES` cache keyed by file
13
+ # path; `forget`/`forget_all` exist for a caller that reloads an edited
14
+ # file in-process (see their own comment).
8
15
  module Prism
16
+ # NOT frozen — a real cache, keyed by file path and mutated by
17
+ # #tree_for below (`TREES[file] ||= ...`) and #forget/#forget_all.
18
+ # False positive for Style/MutableConstant.
19
+ # rubocop:disable-next Style/MutableConstant
9
20
  TREES = {}
10
21
 
11
22
  module_function
@@ -34,24 +34,11 @@ module Hecks
34
34
  }.freeze
35
35
 
36
36
  def query(declared, args = {}, context: {})
37
- sql = +"SELECT #{select_list} FROM #{from_relation}"
38
- clauses = []
37
+ sql = "SELECT #{select_list} FROM #{from_relation}"
39
38
  binds = []
40
- declared.wheres.each do |clause|
41
- value = query_value(clause.value, args)
42
- expression = query_expression(clause.field, value: value)
43
- if (null_predicate = QuerySpecification::Common::NullPolicy.sql_predicate(expression, clause.op, value))
44
- clauses << null_predicate.first
45
- next
46
- end
47
- clauses << where_clause(clause.op.to_s, expression, value, binds, field: clause.field)
48
- end
39
+ clauses = where_clauses(declared, args, binds)
49
40
  sql << " WHERE #{clauses.join(' AND ')}" unless clauses.empty?
50
- sql << if declared.order_by
51
- " ORDER BY #{order_clause(declared.order_by, declared.null_semantics)}"
52
- else
53
- " ORDER BY id"
54
- end
41
+ sql << order_by_sql(declared)
55
42
  sql << " LIMIT #{placeholder(binds, query_value(declared.limit.value, args).to_i)}" if declared.limit
56
43
  # An offset with no declared limit — SQLite refuses a bare OFFSET
57
44
  # outright (LIMIT -1 is its unbounded idiom) while Postgres accepts
@@ -65,10 +52,34 @@ module Hecks
65
52
 
66
53
  private
67
54
 
68
- def where_clause(op, expression, value, binds, field: nil)
69
- case op
55
+ # Every declared `where` clause, compiled in declaration order — a
56
+ # null-policy predicate short-circuits the ordinary comparator path
57
+ # per clause, same as it did inline. `binds` is populated in place
58
+ # (the same array `query` goes on to push LIMIT/OFFSET placeholders
59
+ # into afterward), so pulling this loop out changes nothing about
60
+ # bind-parameter order.
61
+ def where_clauses(declared, args, binds)
62
+ declared.wheres.each_with_object([]) do |clause, clauses|
63
+ value = query_value(clause.value, args)
64
+ expression = query_expression(clause.field, value: value)
65
+ if (null_predicate = QuerySpecification::Common::NullPolicy.sql_predicate(expression, clause.op, value))
66
+ clauses << null_predicate.first
67
+ next
68
+ end
69
+ clauses << where_clause(clause.op.to_s, expression, value, binds, field: clause.field)
70
+ end
71
+ end
72
+
73
+ def order_by_sql(declared)
74
+ return " ORDER BY #{order_clause(declared.order_by, declared.null_semantics)}" if declared.order_by
75
+
76
+ " ORDER BY id"
77
+ end
78
+
79
+ def where_clause(oper, expression, value, binds, field: nil)
80
+ case oper
70
81
  when "eq", "ne", "gt", "gte", "lt", "lte"
71
- "#{comparable_expression(expression, value)} #{COMPARATORS.fetch(op)} #{placeholder(binds, value)}"
82
+ "#{comparable_expression(expression, value)} #{COMPARATORS.fetch(oper)} #{placeholder(binds, value)}"
72
83
  when "contains"
73
84
  member = field && list_member(field)
74
85
  if member
@@ -97,7 +108,7 @@ module Hecks
97
108
  # which Postgres overrides to cast numerics.
98
109
  "CAST(#{expression} AS TEXT) IN (#{members.map { |member| placeholder(binds, member) }.join(', ')})"
99
110
  else
100
- raise ArgumentError, "#{dialect_name} query adapter does not support #{op.inspect}"
111
+ raise ArgumentError, "#{dialect_name} query adapter does not support #{oper.inspect}"
101
112
  end
102
113
  end
103
114
 
@@ -137,6 +148,16 @@ module Hecks
137
148
  # path that can never match. Measured, not assumed: a `where` on a
138
149
  # has_one/belongs_to/reference_to field returned zero rows against
139
150
  # real data until that exclusion was removed.
151
+ # `member` resolves through two ORDERED fallback tiers — the bound
152
+ # value's own numeric field first, then the declared value object's
153
+ # numeric-or-sole attribute — each documented above as fixing a
154
+ # real, measured bug (a reference field matching nothing, a
155
+ # single-attribute VO silently falling to the wrong convention).
156
+ # Splitting the tiers apart would separate two writes to the same
157
+ # `member` local across method boundaries, hiding the fallback
158
+ # order that makes them correct together.
159
+ # rubocop:disable-next Metrics/CyclomaticComplexity
160
+ # rubocop:disable-next Metrics/PerceivedComplexity
140
161
  def query_expression(field, value: nil)
141
162
  name, *path = field.to_s.split(".")
142
163
  attribute = @aggregate.attribute(name)
@@ -147,6 +168,10 @@ module Hecks
147
168
  member = if path.empty? && value
148
169
  hash = value.is_a?(Runtime::Value) ? value.to_h : value
149
170
  numeric = hash.is_a?(Hash) && hash.find { |_key, item| item.is_a?(Numeric) }
171
+ # NOT &.-able: `numeric` can be `false` (hash.is_a?(Hash) came
172
+ # back false) as well as nil (.find came back empty) — `&.`
173
+ # only guards nil, so `false.first` raises. False positive.
174
+ # rubocop:disable-next Style/SafeNavigation
150
175
  numeric ? numeric.first : nil
151
176
  end
152
177
  member ||= if path.empty? && attribute && value_object?(attribute)
@@ -13,7 +13,9 @@ module Hecks
13
13
  { name: attribute.name, attribute: attribute, sql_type: sql_type(attribute) }
14
14
  end
15
15
  lifecycle = @aggregate.lifecycle
16
- fields << { name: lifecycle.field, attribute: nil, sql_type: "TEXT" } if lifecycle && !fields.any? { |field| field[:name] == lifecycle.field }
16
+ fields << { name: lifecycle.field, attribute: nil, sql_type: "TEXT" } if lifecycle && fields.none? do |field|
17
+ field[:name] == lifecycle.field
18
+ end
17
19
  # `projects` FIELDS (S12, ADR 0025) ARE A LOCAL COLUMN TOO —
18
20
  # `CommandInterpreter#seed_projected_fields`/`RebuildSweep`
19
21
  # both write one straight into `Instance#state` the same as
@@ -67,7 +67,10 @@ module Hecks
67
67
  # reads it — not the identity unwrap, which is gone : an identity is
68
68
  # declared as a path and followed.
69
69
  reference_id = args.fetch(model.reference_name).to_s
70
- eligible = model.filtered_head_name
70
+ # Plural (ADR 0055) — `on:` lets `where`/`order_by`/`limit`/`offset`
71
+ # each name a different many-side head, so more than one can be
72
+ # eligible in the same read model now.
73
+ eligible = model.filtered_head_names
71
74
 
72
75
  # ROOT FIRST, ALWAYS — see this method's own header. Mirrors
73
76
  # `ReadModelInterpreter#project`'s identical partition, for the
@@ -87,7 +90,7 @@ module Hecks
87
90
  else
88
91
  select_related(aggregate, projected)
89
92
  end
90
- rows = Ports::Query::InMemory.execute(rows, model, args) if head[:as] == eligible
93
+ rows = Ports::Query::InMemory.execute(rows, model.options_for(head[:as]), args) if eligible.include?(head[:as])
91
94
  projected << { aggregate: head[:aggregate], rows: rows }
92
95
  reports[head[:as]] = if head[:many]
93
96
  rows.map { |row| Runtime::Value.materialize(row.to_h) }
@@ -140,8 +143,15 @@ module Hecks
140
143
  end
141
144
 
142
145
  def decode_for(aggregate, row)
146
+ decode_fields(fields_for(aggregate), aggregate, row)
147
+ end
148
+
149
+ # The field list a row decodes against: every declared attribute,
150
+ # plus the lifecycle field and any `projects` fields not already
151
+ # among them (both read back raw — see `decode_fields`).
152
+ def fields_for(aggregate)
143
153
  fields = aggregate.attributes.map { |attribute| [attribute.name, attribute] }
144
- if (lifecycle = aggregate.lifecycle) && !fields.any? { |name, _| name == lifecycle.field }
154
+ if (lifecycle = aggregate.lifecycle) && fields.none? { |name, _| name == lifecycle.field }
145
155
  fields << [lifecycle.field, nil]
146
156
  end
147
157
  # `projects` FIELDS (S12, ADR 0025) NEED READING BACK TOO — `project`
@@ -154,8 +164,16 @@ module Hecks
154
164
  aggregate.projected_fields.each do |field|
155
165
  fields << [field.name, nil] unless fields.any? { |name, _| name == field.name }
156
166
  end
167
+ fields
168
+ end
169
+
170
+ def decode_fields(fields, aggregate, row)
157
171
  fields.each_with_object({}) do |(name, attribute), state|
158
172
  raw = row[name.to_s]
173
+ # rubocop:disable Lint/DuplicateBranch -- the nil-attribute and
174
+ # reference-id branches both just answer `raw`, coincidentally, for
175
+ # two unrelated reasons (see each branch's own comment); merging
176
+ # them would blur that distinction.
159
177
  state[name] =
160
178
  if attribute.nil?
161
179
  raw
@@ -165,6 +183,7 @@ module Hecks
165
183
  # A reference is a scalar id — see Codec#decode.
166
184
  raw
167
185
  end
186
+ # rubocop:enable Lint/DuplicateBranch
168
187
  end
169
188
  end
170
189
  end
@@ -10,7 +10,7 @@ module Hecks
10
10
  def create_aggregate_table!
11
11
  columns = persisted_fields.map { |field| "#{quote_ident(field[:name])} #{field[:sql_type]}" }
12
12
  @db.execute(
13
- "CREATE TABLE IF NOT EXISTS #{quoted_table} (id TEXT PRIMARY KEY#{columns.empty? ? '' : ', '}#{columns.join(', ')})"
13
+ "CREATE TABLE IF NOT EXISTS #{quoted_table} (id TEXT PRIMARY KEY#{', ' unless columns.empty?}#{columns.join(', ')})"
14
14
  )
15
15
  # RIGHT HERE, NOT AS A SEPARATE STEP IN `Sqlite#initialize` —
16
16
  # `create_aggregate_table!` is the one piece of DDL `D1`
@@ -96,6 +96,28 @@ module Hecks
96
96
  raise unless e.message.include?("duplicate column name")
97
97
  end
98
98
 
99
+ def create_outbox_table!
100
+ @db.execute(<<~SQL)
101
+ CREATE TABLE IF NOT EXISTS hecks_outbox (
102
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
103
+ delivery_id TEXT NOT NULL UNIQUE,
104
+ event_uid TEXT NOT NULL,
105
+ aggregate TEXT NOT NULL,
106
+ domain TEXT NOT NULL,
107
+ kind TEXT NOT NULL,
108
+ consumer TEXT NOT NULL,
109
+ event TEXT NOT NULL,
110
+ status TEXT NOT NULL DEFAULT 'pending',
111
+ attempts INTEGER NOT NULL DEFAULT 0,
112
+ error TEXT,
113
+ enqueued_at TEXT NOT NULL,
114
+ claimed_at TEXT,
115
+ settled_at TEXT
116
+ )
117
+ SQL
118
+ @db.execute("CREATE INDEX IF NOT EXISTS idx_hecks_outbox_status ON hecks_outbox(aggregate, status)")
119
+ end
120
+
99
121
  def sql_type(attr)
100
122
  return "TEXT" if attr.list?
101
123
 
@@ -9,6 +9,7 @@ require_relative "../../query_specification/common/null_policy"
9
9
  require_relative "../../query_specification/common/order_by"
10
10
  require_relative "../../runtime/errors"
11
11
  require_relative "../../runtime/event"
12
+ require_relative "../../runtime/outbox"
12
13
  require_relative "../../runtime/instance"
13
14
 
14
15
  module Hecks
@@ -63,6 +64,17 @@ module Hecks
63
64
  ensure_entry_mirrors_column!
64
65
  create_event_table!
65
66
  create_saga_table!
67
+ create_outbox_table!
68
+ end
69
+
70
+ # RE-ENTRANT ON PURPOSE — `atomic_put` opens its own transaction
71
+ # and `Interpreting#run_dispatch_order` opens one around the whole
72
+ # save+emit pair; SQLite3 refuses a BEGIN inside a BEGIN, so the
73
+ # inner call joins the outer one instead. Same shape Postgres uses.
74
+ def transaction(&)
75
+ return yield if @db.transaction_active?
76
+
77
+ @db.transaction(&)
66
78
  end
67
79
 
68
80
  def table = @aggregate.storage_name
@@ -81,7 +93,10 @@ module Hecks
81
93
  order_sql = "ORDER BY id"
82
94
  if order_by
83
95
  name = order_by.to_s.split(".").first
84
- raise Runtime::WiringError, "#{@aggregate.name} has no attribute #{order_by.inspect} to order by" unless @aggregate.lifecycle&.field.to_s == name || @aggregate.attribute(name)
96
+ unless @aggregate.lifecycle&.field.to_s == name || @aggregate.attribute(name)
97
+ raise Runtime::WiringError,
98
+ "#{@aggregate.name} has no attribute #{order_by.inspect} to order by"
99
+ end
85
100
 
86
101
  spec = QuerySpecification::Common::OrderBy.new(field: order_by, direction: direction)
87
102
  order_sql = "ORDER BY #{order_clause(spec, nil)}"
@@ -142,8 +157,10 @@ module Hecks
142
157
 
143
158
  def save(instance)
144
159
  entry = Ports::Persistence::Entry.new(operation: "save", id: instance.id.to_s, state: instance.state.dup)
145
- append(entry)
146
- project(entry)
160
+ transaction do
161
+ append(entry)
162
+ project(entry)
163
+ end
147
164
  end
148
165
 
149
166
  # The outcome lookup, journal append and snapshot replacement share one
@@ -151,7 +168,7 @@ module Hecks
151
168
  # adapter-native operation owns both concurrency and outcome reporting.
152
169
  def atomic_put(entry, insert_only: false)
153
170
  status = nil
154
- @db.transaction do
171
+ transaction do
155
172
  exists = !@db.get_first_value("SELECT 1 FROM #{quoted_table} WHERE id = ?", [entry.id.to_s]).nil?
156
173
  if insert_only && exists
157
174
  status = :conflicted
@@ -202,11 +219,63 @@ module Hecks
202
219
  # either way; a domain that wants an obviously-named saga store
203
220
  # already gets one by sharing `database` across its aggregates,
204
221
  # the recommended, common case.
222
+ # THE OUTBOX — see `Runtime::Outbox`. Rows land in the SAME
223
+ # database as this aggregate (the only way the enqueue shares the
224
+ # save's transaction), keyed by the aggregate's storage name so an
225
+ # adapter instance only ever reads back its own rows even when
226
+ # several aggregates share one file. `INSERT OR IGNORE` on the
227
+ # UNIQUE delivery_id makes a re-enqueue of the same (event,
228
+ # consumer) a no-op; `outbox_claim`'s `WHERE status = 'pending'`
229
+ # is the compare-and-set that lets exactly one relay win a row.
230
+ def outbox_enqueue(rows)
231
+ rows.filter_map do |row|
232
+ @db.execute(
233
+ "INSERT OR IGNORE INTO hecks_outbox (delivery_id, event_uid, aggregate, domain, kind, consumer, event, " \
234
+ "status, attempts, enqueued_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?)",
235
+ [row.delivery_id, row.event_uid, row.aggregate, row.domain, row.kind, row.consumer,
236
+ JSON.generate(row.event), Time.now.utc.iso8601]
237
+ )
238
+ next nil if @db.changes.zero?
239
+
240
+ row.id = @db.last_insert_row_id
241
+ row.status = "pending"
242
+ row
243
+ end
244
+ end
245
+
246
+ def outbox_claim(id)
247
+ @db.execute(
248
+ "UPDATE hecks_outbox SET status = 'claimed', attempts = attempts + 1, claimed_at = ? " \
249
+ "WHERE id = ? AND status = 'pending'",
250
+ [Time.now.utc.iso8601, id]
251
+ )
252
+ @db.changes == 1
253
+ end
254
+
255
+ def outbox_settle(id, status:, error: nil)
256
+ @db.execute(
257
+ "UPDATE hecks_outbox SET status = ?, error = ?, settled_at = ? WHERE id = ?",
258
+ [status.to_s, error, Time.now.utc.iso8601, id]
259
+ )
260
+ @db.changes == 1
261
+ end
262
+
263
+ def outbox_rows(status: nil)
264
+ sql = "SELECT * FROM hecks_outbox WHERE aggregate = ?"
265
+ binds = [table]
266
+ if status
267
+ sql << " AND status = ?"
268
+ binds << status.to_s
269
+ end
270
+ @db.execute("#{sql} ORDER BY id", binds).map { |row| outbox_row(row) }
271
+ end
272
+
205
273
  def save_saga(process_manager:, correlation:, state:, memory:, completed_compensations: [])
206
274
  @db.execute(
207
- "INSERT OR REPLACE INTO hecks_saga_instances (domain, process_manager, correlation, state, memory, completed_compensations) " \
208
- "VALUES (?, ?, ?, ?, ?, ?)",
209
- [@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory), JSON.generate(completed_compensations)]
275
+ "INSERT OR REPLACE INTO hecks_saga_instances (domain, process_manager, correlation, state, memory, " \
276
+ "completed_compensations) VALUES (?, ?, ?, ?, ?, ?)",
277
+ [@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory),
278
+ JSON.generate(completed_compensations)]
210
279
  )
211
280
  end
212
281
 
@@ -221,7 +290,8 @@ module Hecks
221
290
  return enum_for(:each_saga) unless block_given?
222
291
 
223
292
  @db.execute(
224
- "SELECT process_manager, correlation, state, memory, completed_compensations FROM hecks_saga_instances WHERE domain = ?",
293
+ "SELECT process_manager, correlation, state, memory, completed_compensations " \
294
+ "FROM hecks_saga_instances WHERE domain = ?",
225
295
  [@domain]
226
296
  ).each do |row|
227
297
  yield row["process_manager"], row["correlation"], row["state"],
@@ -234,6 +304,15 @@ module Hecks
234
304
 
235
305
  # ── SqlQueryBuilder's dialect hooks ─────────────────────────────
236
306
 
307
+ def outbox_row(row)
308
+ Runtime::Outbox::Row.new(
309
+ id: row["id"], delivery_id: row["delivery_id"], event_uid: row["event_uid"], aggregate: row["aggregate"],
310
+ domain: row["domain"], kind: row["kind"], consumer: row["consumer"],
311
+ event: JSON.parse(row["event"], symbolize_names: true), status: row["status"],
312
+ attempts: row["attempts"].to_i, error: row["error"]
313
+ )
314
+ end
315
+
237
316
  def select_list = "*"
238
317
  def from_relation = quoted_table
239
318
  def dialect_name = "SQLite"
@@ -1,4 +1,7 @@
1
1
  module Hecks
2
+ # Reopened here purely to require in every driven adapter under
3
+ # adapters/driven/ below (see the comment inside for what "driven side"
4
+ # means) — the actual namespace is declared in adapters.rb.
2
5
  module Adapters
3
6
  # The driven side — every store or reader an adapter declaration can
4
7
  # bind. A small adapter is one file beside this one; sqlite, postgres
@@ -32,6 +35,7 @@ require_relative "driven/postgres"
32
35
  Hecks::Adapters.autoload(:PostgresEra, "hecks/ports/persistence/plugins/era")
33
36
  require_relative "driven/lambda"
34
37
  require_relative "driven/heki"
38
+ require_relative "driven/local_storage"
35
39
  require_relative "driven/prism"
36
40
  require_relative "driven/folder"
37
41
  require_relative "driven/d1"