hecks 1.1.0 → 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.
@@ -77,15 +77,24 @@ module Hecks
77
77
  # lost-update gap itself, via `step_save`'s CAS + `#call`'s own
78
78
  # `StaleWrite` retry loop — an extra in-process lock here would be
79
79
  # pointless overhead, not incorrect, so it's skipped for clarity.
80
- # Every other repository (Heki, Memory confirmed process-local
81
- # data, never a second process writing the same store) gets a
82
- # striped `Mutex` held for the WHOLE dispatch-order run, so a second
83
- # thread's own hydrate can't start until the first thread's save has
84
- # landed. `lock_key_id` is best-effort (`Identity.best_effort`)
85
- # `nil` still locks correctly, just coarser (by aggregate type).
80
+ # A repository that declares `:cross_process_lock` (PostgresEra
81
+ # ADR 0036) holds a REAL Postgres advisory lock for the whole
82
+ # dispatch order instead: unlike Heki/Memory (confirmed
83
+ # process-local, never a second process writing the same store),
84
+ # PostgresEra's own tables can be dispatched against concurrently
85
+ # by `rust/host` from a separate OS process, and an in-process
86
+ # `Mutex` is invisible to that. Every OTHER repository gets the
87
+ # striped `Mutex` below, held for the WHOLE dispatch-order run, so
88
+ # a second thread's own hydrate can't start until the first
89
+ # thread's save has landed. `lock_key_id` is best-effort
90
+ # (`Identity.best_effort`) — `nil` still locks correctly, just
91
+ # coarser (by aggregate type).
86
92
  def run_dispatch_order_with_isolation(order, ctx, lock_key_id:)
87
- if ctx.repository.capabilities.include?(:optimistic_concurrency)
93
+ capabilities = ctx.repository.capabilities
94
+ if capabilities.include?(:optimistic_concurrency)
88
95
  run_dispatch_order(order, ctx)
96
+ elsif capabilities.include?(:cross_process_lock)
97
+ ctx.repository.with_write_lock { run_dispatch_order(order, ctx) }
89
98
  else
90
99
  AggregateLock.for(ctx.domain, ctx.aggregate, lock_key_id).synchronize { run_dispatch_order(order, ctx) }
91
100
  end
@@ -50,10 +50,11 @@ module Hecks
50
50
  refuse_object_reference(model, args) unless rootless
51
51
  reference_id = reference(args.fetch(model.reference_name)) unless rootless
52
52
  # Computed off the ORIGINAL model, before TenantScope wraps it — the
53
- # "which head do options apply to" question is about what the
53
+ # "which head(s) do options apply to" question is about what the
54
54
  # bluebook author declared, not about the synthetic tenant clause
55
- # the wrapper adds underneath.
56
- eligible = model.filtered_head_name
55
+ # the wrapper adds underneath. Plural (ADR 0055) — `on:` lets more
56
+ # than one many-side head be eligible at once.
57
+ eligible = model.filtered_head_names
57
58
  model = TenantScope.apply(model, args)
58
59
  # A ROOTLESS, `group_by`-declared, or `count`/`median`-declared
59
60
  # model skips the SQLite native escape hatch entirely (there is
@@ -119,7 +120,7 @@ module Hecks
119
120
  end
120
121
  end
121
122
  end
122
- rows = Ports::Query::InMemory.execute(rows, model, args) if head[:as] == eligible
123
+ rows = Ports::Query::InMemory.execute(rows, model.options_for(head[:as]), args) if eligible.include?(head[:as])
123
124
  projected << { aggregate: head[:aggregate], rows: rows }
124
125
  rows_by_as[head[:as]] = head[:many] ? rows : rows.first
125
126
  end
@@ -395,9 +395,27 @@ module Hecks
395
395
 
396
396
  # Frozen through: a list read back out of the store is an answer,
397
397
  # not a handle on what is stored.
398
+ #
399
+ # ADR 0047 — this used to bail (`return value unless entity`) the
400
+ # moment `attribute.type` named a value object rather than an
401
+ # entity, handing back the raw, un-hydrated argument untouched.
402
+ # A `sets :field` mutation sourced from a whole-array argument (as
403
+ # opposed to element-by-element `append:`) went straight through
404
+ # `for_attribute`'s `:list` branch, so `Banking::CardPayment.
405
+ # Authorize`'s own `sets :tags` (`list_of(Tag)`) stored plain
406
+ # Ruby Hashes as its `tags` elements forever — never a real
407
+ # `Value`, never through `Tag`'s own `pattern:`/`invariant`
408
+ # checks. `remove:`'s `==` comparison (a real `Value` against a
409
+ # raw `Hash`) then always failed, since `Hash#==` refuses anything
410
+ # that isn't itself a compatible Hash — the bug ADR 0047 traces in
411
+ # full. Delegating to `hydrate_value_object_list` below closes
412
+ # that gap the same way the ENTITY branch already worked: build a
413
+ # real, validated `Value` per element, reusing `for_attribute`'s
414
+ # own composite-construction path rather than inventing a second
415
+ # one.
398
416
  def hydrate_entity_list(aggregate, attribute, value)
399
417
  entity = find_entity(aggregate, attribute.type.to_s)
400
- return value unless entity
418
+ return hydrate_value_object_list(aggregate, attribute, value) unless entity
401
419
 
402
420
  hydrated = Array(value).map do |element|
403
421
  next element unless element.is_a?(Hash)
@@ -411,6 +429,51 @@ module Hecks
411
429
  Freezer.deep(hydrated)
412
430
  end
413
431
 
432
+ # The value-object sibling of the entity branch above: an element
433
+ # already shaped like the target `Value` (or a `Hash`/scalar that
434
+ # `fields_for` can still open) is rebuilt through the SAME `build`
435
+ # a scalar composite attribute already uses (`for_attribute`'s own
436
+ # `coerced = ... build(value_object, fields_for(...), aggregate)`
437
+ # line) — same defaults, same `pattern:`/`admits:`/invariant
438
+ # checks, same `trusting_stored_state?` bypass on a trusted load.
439
+ # `attribute.type` naming neither an entity nor a value object
440
+ # (a `list_of(String)`, say) has no shape to rebuild into, so the
441
+ # element passes through unchanged, exactly as the entity branch's
442
+ # own non-Hash elements do.
443
+ #
444
+ # NOT `Array(value).map` (unlike the entity branch above) — `value`
445
+ # here is not always genuinely list-shaped. `MutationApplier#
446
+ # removed`'s own `Value.for_attribute(aggregate, attribute, value)`
447
+ # call (`attribute` = the LIST attribute, `mutation.target`; `value`
448
+ # = the single REMOVE-target argument, already a real `Value` by
449
+ # the time it gets here) reuses this exact branch — for `remove:`,
450
+ # not for a whole-list `sets`. `Array(a_real_Value)` alone would be
451
+ # harmless (`Value` defines neither `to_a` nor `to_ary`, so Kernel
452
+ # wraps it `[value]`), but `Array(a_Hash)` is NOT harmless: Ruby's
453
+ # `Array()` opens a bare Hash into its own `[[k, v], ...]` pairs,
454
+ # not `[hash]` — silently shredding a single-element Hash-shaped
455
+ # target into garbage instead of hydrating it. Branching on
456
+ # `value.is_a?(Array)` up front (true only for a genuine whole-list
457
+ # `sets`/hydrate load) keeps the single-target shape a single
458
+ # target, hydrated the same way, never listified.
459
+ def hydrate_value_object_list(aggregate, attribute, value)
460
+ return value unless aggregate.respond_to?(:value_object)
461
+
462
+ value_object = value_object_for(aggregate, attribute.type)
463
+ return value unless value_object
464
+
465
+ return hydrate_value_object_element(aggregate, attribute, value_object, value) unless value.is_a?(Array)
466
+
467
+ hydrated = value.map { |element| hydrate_value_object_element(aggregate, attribute, value_object, element) }
468
+ Freezer.deep(hydrated)
469
+ end
470
+
471
+ def hydrate_value_object_element(aggregate, attribute, value_object, element)
472
+ return element if element.is_a?(self) && element.type_name == value_object.hecks_name
473
+
474
+ build(value_object, fields_for(value_object, attribute.name, element), aggregate)
475
+ end
476
+
414
477
  # `Value.identifier` used to live here: hand it a one-field value object
415
478
  # and it opened it, so `identified_by :number` could pass for an identity
416
479
  # and the runtime would guess which field was meant. THAT GUESS IS GONE.
data/lib/hecks/version.rb CHANGED
@@ -12,5 +12,5 @@ module Hecks
12
12
  # it there, or even in the consuming Gemfile, never closed the gap,
13
13
  # because gemspec evaluation happens before anything Bundler
14
14
  # resolves is actually loadable yet.
15
- VERSION = "1.1.0".freeze
15
+ VERSION = "1.2.0".freeze
16
16
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hecks
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Young
@@ -68,6 +68,7 @@ files:
68
68
  - lib/hecks/adapters/driven/postgres.rb
69
69
  - lib/hecks/adapters/driven/postgres/codec.rb
70
70
  - lib/hecks/adapters/driven/postgres/outbox.rb
71
+ - lib/hecks/adapters/driven/postgres/reconnect.rb
71
72
  - lib/hecks/adapters/driven/postgres/schema_builder.rb
72
73
  - lib/hecks/adapters/driven/postgres_era.adapter
73
74
  - lib/hecks/adapters/driven/prism.adapter
@@ -343,6 +344,7 @@ files:
343
344
  - lib/hecks/ports/query/ordering.rb
344
345
  - lib/hecks/projections.rb
345
346
  - lib/hecks/projections/diagrams.rb
347
+ - lib/hecks/projections/glossary.rb
346
348
  - lib/hecks/projections/ir.rb
347
349
  - lib/hecks/projections/model.rb
348
350
  - lib/hecks/projections/model/deviations.rb