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.
Files changed (194) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/d1.rb +38 -14
  3. data/lib/hecks/adapters/driven/governance_authorization.rb +41 -2
  4. data/lib/hecks/adapters/driven/heki/journal.rb +7 -2
  5. data/lib/hecks/adapters/driven/heki/saga_store.rb +0 -0
  6. data/lib/hecks/adapters/driven/heki/snapshot.rb +31 -4
  7. data/lib/hecks/adapters/driven/heki.rb +40 -9
  8. data/lib/hecks/adapters/driven/lambda.rb +16 -2
  9. data/lib/hecks/adapters/driven/postgres/codec.rb +8 -0
  10. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +45 -6
  11. data/lib/hecks/adapters/driven/postgres.rb +76 -29
  12. data/lib/hecks/adapters/driven/postgres_era.adapter +11 -0
  13. data/lib/hecks/adapters/driven/sqlite/codec.rb +17 -0
  14. data/lib/hecks/adapters/driven/sqlite/projection.rb +76 -9
  15. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +17 -5
  16. data/lib/hecks/adapters/driven/sqlite.rb +30 -9
  17. data/lib/hecks/adapters/driven.rb +19 -1
  18. data/lib/hecks/behaviors/dsl.rb +29 -0
  19. data/lib/hecks/behaviors/expectations.rb +62 -2
  20. data/lib/hecks/bluebook/assembly/contracts.rb +36 -7
  21. data/lib/hecks/bluebook/assembly/marks.rb +4 -3
  22. data/lib/hecks/bluebook/assembly.rb +14 -1
  23. data/lib/hecks/bluebook/behaviour/lifecycle.rb +18 -1
  24. data/lib/hecks/bluebook/behaviour/process_manager.rb +14 -1
  25. data/lib/hecks/bluebook/chapter.rb +21 -11
  26. data/lib/hecks/bluebook/command.rb +1 -1
  27. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +117 -5
  28. data/lib/hecks/bluebook/dsl/attribute_collector.rb +21 -0
  29. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +71 -2
  30. data/lib/hecks/bluebook/dsl/command_builder.rb +91 -3
  31. data/lib/hecks/bluebook/dsl/entity_builder.rb +129 -4
  32. data/lib/hecks/bluebook/dsl/policy_builder.rb +18 -3
  33. data/lib/hecks/bluebook/dsl/port_builder.rb +12 -3
  34. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +109 -10
  35. data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -0
  36. data/lib/hecks/bluebook/dsl/word_gate.rb +9 -2
  37. data/lib/hecks/bluebook/dsl/world_builder.rb +44 -4
  38. data/lib/hecks/bluebook/expression/canonical_form.rb +71 -3
  39. data/lib/hecks/bluebook/expression/evaluator.rb +50 -7
  40. data/lib/hecks/bluebook/expression/projection.json +48 -0
  41. data/lib/hecks/bluebook/expression/resolver.rb +161 -10
  42. data/lib/hecks/bluebook/hexagon.rb +1 -1
  43. data/lib/hecks/bluebook/meta_validator/judge.rb +78 -16
  44. data/lib/hecks/bluebook/meta_validator/port_judge.rb +4 -0
  45. data/lib/hecks/bluebook/meta_validator/readings.rb +14 -4
  46. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +42 -3
  47. data/lib/hecks/bluebook/meta_validator/shapes.rb +30 -10
  48. data/lib/hecks/bluebook/meta_validator.rb +103 -13
  49. data/lib/hecks/bluebook/model_check.rb +132 -5
  50. data/lib/hecks/bluebook/pattern_subset.rb +66 -2
  51. data/lib/hecks/bluebook/process_manager.rb +53 -11
  52. data/lib/hecks/bluebook/value_object.rb +9 -1
  53. data/lib/hecks/doc/reference.rb +22 -1
  54. data/lib/hecks/facade/cli_door.rb +6 -3
  55. data/lib/hecks/facade/json_door.rb +16 -4
  56. data/lib/hecks/forms/app.rb +47 -6
  57. data/lib/hecks/forms/command_form_renderer.rb +1 -1
  58. data/lib/hecks/forms/field_renderer.rb +11 -4
  59. data/lib/hecks/forms/html.rb +31 -0
  60. data/lib/hecks/forms/params.rb +30 -1
  61. data/lib/hecks/forms/port_argument.rb +46 -0
  62. data/lib/hecks/forms/record_renderer.rb +6 -2
  63. data/lib/hecks/forms/record_table.rb +6 -1
  64. data/lib/hecks/framework/bluebook/console_settings.bluebook +19 -19
  65. data/lib/hecks/framework/bluebook/governance.bluebook +26 -11
  66. data/lib/hecks/framework/bluebook/identity.bluebook +2 -2
  67. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
  68. data/lib/hecks/fuzzing/isolated_boot.rb +212 -18
  69. data/lib/hecks/fuzzing/properties.rb +52 -6
  70. data/lib/hecks/fuzzing/replay.rb +51 -18
  71. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +28 -2
  72. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +71 -6
  73. data/lib/hecks/fuzzing/sequence_generator.rb +5 -4
  74. data/lib/hecks/fuzzing/value_generator.rb +25 -2
  75. data/lib/hecks/grammar/evolve.rb +33 -0
  76. data/lib/hecks/grammar/expression.bluebook +8 -5
  77. data/lib/hecks/grammar/expression_operators.json +497 -1
  78. data/lib/hecks/language/bluebook/aggregate.bluebook +12 -12
  79. data/lib/hecks/language/bluebook/bluebook.bluebook +3 -3
  80. data/lib/hecks/language/bluebook/command.bluebook +42 -8
  81. data/lib/hecks/language/bluebook/entity.bluebook +86 -10
  82. data/lib/hecks/language/bluebook/policy.bluebook +21 -4
  83. data/lib/hecks/language/bluebook/process_manager.bluebook +135 -18
  84. data/lib/hecks/language/bluebook/projection.bluebook +6 -6
  85. data/lib/hecks/language/bluebook/query.bluebook +4 -4
  86. data/lib/hecks/language/bluebook/shape.bluebook +6 -6
  87. data/lib/hecks/language/bluebook/syntax.bluebook +12 -11
  88. data/lib/hecks/language/bluebook/vocabulary.bluebook +22 -7
  89. data/lib/hecks/language/oidc.json +20 -0
  90. data/lib/hecks/language/port.bluebook +30 -2
  91. data/lib/hecks/naming.rb +54 -1
  92. data/lib/hecks/ports/access_control.port +7 -2
  93. data/lib/hecks/ports/access_control.rb +1 -1
  94. data/lib/hecks/ports/agent.port +6 -2
  95. data/lib/hecks/ports/agent.rb +1 -1
  96. data/lib/hecks/ports/authentication.port +4 -2
  97. data/lib/hecks/ports/authentication.rb +1 -1
  98. data/lib/hecks/ports/authorization.port +5 -2
  99. data/lib/hecks/ports/authorization.rb +14 -11
  100. data/lib/hecks/ports/clock.port +3 -2
  101. data/lib/hecks/ports/clock.rb +1 -1
  102. data/lib/hecks/ports/extraction.port +3 -2
  103. data/lib/hecks/ports/extraction.rb +1 -1
  104. data/lib/hecks/ports/identity_assignment.port +3 -2
  105. data/lib/hecks/ports/identity_assignment.rb +1 -1
  106. data/lib/hecks/ports/identity_generation.port +3 -2
  107. data/lib/hecks/ports/identity_generation.rb +1 -1
  108. data/lib/hecks/ports/identity_resolution.port +3 -2
  109. data/lib/hecks/ports/identity_resolution.rb +1 -1
  110. data/lib/hecks/ports/persistence/append_only.rb +40 -4
  111. data/lib/hecks/ports/persistence/execution.rb +6 -1
  112. data/lib/hecks/ports/persistence/plugin.rb +54 -0
  113. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_check.rb +41 -8
  114. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard.rb +24 -56
  115. data/lib/hecks/ports/persistence/{lineage.rb → plugins/era/lineage.rb} +31 -4
  116. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/era_store.rb +3 -3
  117. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/head_compiler.rb +59 -10
  118. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/mint_transaction.rb +2 -2
  119. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/provisioning.rb +29 -1
  120. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/tail_merge.rb +11 -4
  121. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/transform_installer.rb +20 -0
  122. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage.rb +1 -1
  123. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/coverage_check.rb +5 -5
  124. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/era_resolver.rb +5 -2
  125. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/merge_coordinator.rb +2 -2
  126. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/minter.rb +4 -4
  127. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager.rb +2 -2
  128. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era.rb +116 -29
  129. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/approval_digest.rb +1 -1
  130. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_one.rb +14 -5
  131. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_two.rb +31 -6
  132. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/unfed_report.rb +5 -1
  133. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/reattest.rb +3 -3
  134. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/differ.rb +1 -1
  135. data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
  136. data/lib/hecks/ports/persistence.rb +1 -1
  137. data/lib/hecks/ports/projection.rb +20 -0
  138. data/lib/hecks/projections/diagrams.rb +230 -1
  139. data/lib/hecks/projections/shape.rb +15 -2
  140. data/lib/hecks/projector/cli_projector.rb +10 -1
  141. data/lib/hecks/projector/exporter.rb +19 -2
  142. data/lib/hecks/query_ir.rb +19 -0
  143. data/lib/hecks/query_specification/common/null_policy.rb +13 -1
  144. data/lib/hecks/query_specification/field_path.rb +20 -2
  145. data/lib/hecks/query_specification/hop_path.rb +7 -5
  146. data/lib/hecks/runtime/aggregate_lock.rb +45 -0
  147. data/lib/hecks/runtime/boot_gates.rb +41 -0
  148. data/lib/hecks/runtime/caller.rb +19 -3
  149. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +13 -2
  150. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -0
  151. data/lib/hecks/runtime/command_interpreter.rb +97 -13
  152. data/lib/hecks/runtime/command_rules/admissibility.rb +64 -14
  153. data/lib/hecks/runtime/command_rules/arithmetic.rb +7 -1
  154. data/lib/hecks/runtime/command_rules/authorization.rb +2 -1
  155. data/lib/hecks/runtime/command_rules/references.rb +27 -19
  156. data/lib/hecks/runtime/dependency_planning.rb +14 -0
  157. data/lib/hecks/runtime/dispatcher.rb +19 -4
  158. data/lib/hecks/runtime/entity_interpreter.rb +85 -14
  159. data/lib/hecks/runtime/errors.rb +22 -0
  160. data/lib/hecks/runtime/identity.rb +30 -2
  161. data/lib/hecks/runtime/instance.rb +59 -4
  162. data/lib/hecks/runtime/interpreting.rb +21 -0
  163. data/lib/hecks/runtime/loader.rb +59 -18
  164. data/lib/hecks/runtime/query_interpreter.rb +36 -4
  165. data/lib/hecks/runtime/reaction_invocation.rb +9 -1
  166. data/lib/hecks/runtime/read_model_interpreter.rb +76 -1
  167. data/lib/hecks/runtime/refusal_wording.rb +2 -0
  168. data/lib/hecks/runtime/registry/saga_persistence.rb +75 -3
  169. data/lib/hecks/runtime/registry/verification.rb +88 -0
  170. data/lib/hecks/runtime/registry.rb +69 -8
  171. data/lib/hecks/runtime/saga_interpreter.rb +215 -13
  172. data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
  173. data/lib/hecks/runtime/value/admission.rb +19 -1
  174. data/lib/hecks/runtime/value/coercion.rb +75 -10
  175. data/lib/hecks/runtime.rb +17 -5
  176. data/lib/hecks/storehouse.rb +632 -0
  177. data/lib/hecks/version.rb +1 -1
  178. data/lib/hecks/vocabulary.rb +6 -1
  179. data/lib/hecks.rb +7 -2
  180. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
  181. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
  182. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
  183. metadata +48 -37
  184. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard/shape_diff.rb +0 -0
  185. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_tamper.rb +0 -0
  186. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/field_cache.rb +0 -0
  187. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/resumable_backfill.rb +0 -0
  188. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/storage_shape.rb +0 -0
  189. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit.rb +0 -0
  190. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/rule_compiler.rb +0 -0
  191. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/renderer.rb +0 -0
  192. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/writer.rb +0 -0
  193. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold.rb +0 -0
  194. /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: c40a3c6e1879b5df4a299ae07488cffe80fc0c9ee22905044b876b3887c2cfc3
4
- data.tar.gz: 983121c63439d9f7d79c77e0dabcf34e1625df7067368c27438ff64f0065291e
3
+ metadata.gz: 9d056aad218f4fe26394319c5ce486211ac9790022de8159ba23f050a634f5df
4
+ data.tar.gz: d6fa0c2f693152be740b0c5f658008a6ba71ae9bd80e175e8da78b142f00f133
5
5
  SHA512:
6
- metadata.gz: 17ffccfc67272cb90216229ebb41e6fdf4199b6ca6f4e1f5ecee7964191190c2e0f562b2c1fd0c0f7d304f3da95ab3521a3ca13fdcc162935fc99c9a33aca866
7
- data.tar.gz: 9cb2f68d53120a1e7a7ab59e91387661151243d51927f7de690986597c3e2932f09b7680cabb0933e1b82683dffd29b9581167f05799864c56e3ddfa1b502970
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 = failed["error"] || failed["message"] || messages
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] || settings["account_id"]
116
- database_id = settings[:database_id] || settings["database_id"]
117
- api_token = settings[: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 = (settings[:domain] || settings["domain"] || aggregate.name).to_s
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
- [entry.id, entry.operation, JSON.generate(entry.state), JSON.generate(entry.mirrors)]
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), JSON.generate(entry.mirrors), entry.id.to_s]
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), JSON.generate(entry.mirrors)]
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
- def holds_role?(registry, actor_id:, role:)
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? { |row| row[:role_name][:value] == role.to_s && row[:ends_at].nil? }
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(JSON.generate(operation: operation, id: id.to_s, state: state, mirrors: @entry_mirrors))
43
- journal.write("\n")
48
+ journal.write(line)
44
49
  journal.flush
45
50
  journal.fsync
46
51
  end
@@ -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.binwrite(
31
- @path,
32
- MAGIC + [sorted.size].pack("N") + Zlib::Deflate.deflate(json, Zlib::BEST_COMPRESSION)
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 = (settings[:domain] || settings["domain"] || aggregate.name).to_s
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 = store
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
- append(entry)
86
- project(entry)
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
- append(entry)
95
- project(entry)
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 = settings[:dir] || settings["dir"] || "data"
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 = settings[:domain] || settings["domain"] || aggregate.name
35
- region = settings[:region] || settings["region"] || "us-east-1"
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 text NOT NULL,
64
- process_manager text NOT NULL,
65
- correlation text NOT NULL,
66
- state text NOT NULL,
67
- memory jsonb NOT NULL,
68
- updated_at timestamptz NOT NULL DEFAULT now(),
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] || 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] || 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 = (settings[:domain] || settings["domain"] || aggregate.storage_name).to_s
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
- Runtime::Instance.new(aggregate: @aggregate, id: result[0]["id"], state: decode(result[0]))
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 do |row|
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
- [entry.id, entry.operation, JSON.generate(entry.state), JSON.generate(entry.mirrors)]
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
- def project(entry)
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
- "INSERT INTO #{quoted_table} (#{columns.map { |c| quote_ident(c) }.join(', ')}) " \
154
- "VALUES (#{(1..columns.size).map { |n| "$#{n}" }.join(', ')}) " \
155
- "ON CONFLICT (id) DO UPDATE SET #{updates.join(', ')}",
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, updated_at = now()",
254
- [@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory)]
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 do |row|
336
- Runtime::Instance.new(aggregate: @aggregate, id: row["id"], state: decode(row))
337
- end
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 ─────────────────────────────────────