textus 0.15.0 → 0.20.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 (159) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +50 -55
  3. data/CHANGELOG.md +486 -0
  4. data/README.md +13 -9
  5. data/SPEC.md +13 -10
  6. data/docs/conventions.md +2 -2
  7. data/lib/textus/application/context.rb +20 -34
  8. data/lib/textus/application/policy/predicates/human_accept.rb +30 -0
  9. data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
  10. data/lib/textus/{domain → application}/policy/promotion.rb +20 -3
  11. data/lib/textus/application/projection.rb +91 -0
  12. data/lib/textus/application/reads/audit.rb +4 -4
  13. data/lib/textus/application/reads/blame.rb +11 -8
  14. data/lib/textus/application/reads/deps.rb +14 -3
  15. data/lib/textus/application/reads/freshness.rb +17 -6
  16. data/lib/textus/application/reads/get.rb +37 -11
  17. data/lib/textus/application/reads/get_or_refresh.rb +8 -8
  18. data/lib/textus/application/reads/list.rb +5 -3
  19. data/lib/textus/application/reads/policy_explain.rb +3 -3
  20. data/lib/textus/application/reads/published.rb +5 -3
  21. data/lib/textus/application/reads/rdeps.rb +15 -3
  22. data/lib/textus/application/reads/schema_envelope.rb +6 -3
  23. data/lib/textus/application/reads/stale.rb +3 -3
  24. data/lib/textus/application/reads/uid.rb +11 -3
  25. data/lib/textus/application/reads/validate_all.rb +12 -3
  26. data/lib/textus/application/reads/validator.rb +84 -0
  27. data/lib/textus/application/reads/where.rb +6 -3
  28. data/lib/textus/application/refresh/all.rb +16 -5
  29. data/lib/textus/application/refresh/orchestrator.rb +9 -9
  30. data/lib/textus/application/refresh/worker.rb +59 -32
  31. data/lib/textus/application/tools/migrate_keys.rb +191 -0
  32. data/lib/textus/application/tools/migrate_manifest_to_kinds.rb +31 -0
  33. data/lib/textus/application/writes/accept.rb +36 -13
  34. data/lib/textus/application/writes/delete.rb +13 -15
  35. data/lib/textus/application/writes/envelope_io.rb +166 -0
  36. data/lib/textus/application/writes/materializer.rb +50 -0
  37. data/lib/textus/application/writes/mv.rb +56 -95
  38. data/lib/textus/application/writes/publish.rb +132 -27
  39. data/lib/textus/application/writes/put.rb +17 -20
  40. data/lib/textus/application/writes/reject.rb +18 -9
  41. data/lib/textus/builder/pipeline.rb +21 -15
  42. data/lib/textus/builder/renderer/json.rb +4 -1
  43. data/lib/textus/builder/renderer/markdown.rb +7 -1
  44. data/lib/textus/builder/renderer/yaml.rb +4 -1
  45. data/lib/textus/cli/group/hook.rb +1 -3
  46. data/lib/textus/cli/group/key.rb +1 -4
  47. data/lib/textus/cli/group/refresh.rb +1 -2
  48. data/lib/textus/cli/group/rule.rb +1 -3
  49. data/lib/textus/cli/group/schema.rb +1 -5
  50. data/lib/textus/cli/group.rb +12 -16
  51. data/lib/textus/cli/verb/accept.rb +3 -1
  52. data/lib/textus/cli/verb/audit.rb +3 -1
  53. data/lib/textus/cli/verb/blame.rb +3 -1
  54. data/lib/textus/cli/verb/build.rb +4 -5
  55. data/lib/textus/cli/verb/delete.rb +3 -1
  56. data/lib/textus/cli/verb/deps.rb +3 -1
  57. data/lib/textus/cli/verb/doctor.rb +2 -0
  58. data/lib/textus/cli/verb/freshness.rb +3 -1
  59. data/lib/textus/cli/verb/get.rb +4 -2
  60. data/lib/textus/cli/verb/hook_run.rb +6 -4
  61. data/lib/textus/cli/verb/hooks.rb +8 -5
  62. data/lib/textus/cli/verb/init.rb +2 -0
  63. data/lib/textus/cli/verb/intro.rb +2 -0
  64. data/lib/textus/cli/verb/key_normalize.rb +35 -3
  65. data/lib/textus/cli/verb/list.rb +3 -1
  66. data/lib/textus/cli/verb/mv.rb +4 -1
  67. data/lib/textus/cli/verb/published.rb +3 -1
  68. data/lib/textus/cli/verb/put.rb +5 -4
  69. data/lib/textus/cli/verb/rdeps.rb +3 -1
  70. data/lib/textus/cli/verb/refresh.rb +1 -1
  71. data/lib/textus/cli/verb/refresh_stale.rb +4 -2
  72. data/lib/textus/cli/verb/reject.rb +3 -1
  73. data/lib/textus/cli/verb/rule_explain.rb +4 -1
  74. data/lib/textus/cli/verb/rule_list.rb +3 -0
  75. data/lib/textus/cli/verb/schema.rb +4 -1
  76. data/lib/textus/cli/verb/schema_diff.rb +3 -0
  77. data/lib/textus/cli/verb/schema_init.rb +3 -0
  78. data/lib/textus/cli/verb/schema_migrate.rb +3 -0
  79. data/lib/textus/cli/verb/uid.rb +4 -1
  80. data/lib/textus/cli/verb/where.rb +3 -1
  81. data/lib/textus/cli/verb.rb +30 -0
  82. data/lib/textus/cli.rb +18 -27
  83. data/lib/textus/doctor/check/audit_log.rb +1 -1
  84. data/lib/textus/doctor/check/handler_allowlist.rb +3 -2
  85. data/lib/textus/doctor/check/hooks.rb +4 -2
  86. data/lib/textus/doctor/check/illegal_keys.rb +6 -5
  87. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  88. data/lib/textus/doctor/check/manifest_files.rb +1 -1
  89. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  90. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  91. data/lib/textus/doctor/check/sentinels.rb +2 -2
  92. data/lib/textus/doctor/check/templates.rb +4 -3
  93. data/lib/textus/doctor.rb +3 -4
  94. data/lib/textus/domain/authorizer.rb +37 -0
  95. data/lib/textus/domain/freshness/evaluator.rb +1 -1
  96. data/lib/textus/domain/freshness/policy.rb +1 -1
  97. data/lib/textus/domain/freshness/verdict.rb +1 -1
  98. data/lib/textus/domain/freshness.rb +40 -0
  99. data/lib/textus/{store → domain}/sentinel.rb +1 -1
  100. data/lib/textus/{store → domain}/staleness/generator_check.rb +9 -8
  101. data/lib/textus/{store → domain}/staleness/intake_check.rb +3 -3
  102. data/lib/textus/{store → domain}/staleness.rb +1 -1
  103. data/lib/textus/entry/json.rb +1 -1
  104. data/lib/textus/entry/markdown.rb +1 -1
  105. data/lib/textus/entry/yaml.rb +1 -1
  106. data/lib/textus/envelope.rb +7 -3
  107. data/lib/textus/errors.rb +19 -0
  108. data/lib/textus/hooks/builtin.rb +6 -6
  109. data/lib/textus/hooks/bus.rb +155 -0
  110. data/lib/textus/hooks/context.rb +38 -0
  111. data/lib/textus/hooks/fire_report.rb +23 -0
  112. data/lib/textus/hooks/loader.rb +20 -17
  113. data/lib/textus/{store → infra}/audit_log.rb +1 -1
  114. data/lib/textus/infra/audit_subscriber.rb +43 -0
  115. data/lib/textus/infra/event_bus.rb +3 -3
  116. data/lib/textus/infra/publisher.rb +3 -3
  117. data/lib/textus/infra/refresh/detached.rb +1 -1
  118. data/lib/textus/infra/storage/file_store.rb +26 -0
  119. data/lib/textus/init.rb +14 -11
  120. data/lib/textus/intro.rb +7 -7
  121. data/lib/textus/manifest/entry/base.rb +38 -0
  122. data/lib/textus/manifest/entry/derived.rb +25 -0
  123. data/lib/textus/manifest/entry/intake.rb +19 -0
  124. data/lib/textus/manifest/entry/leaf.rb +16 -0
  125. data/lib/textus/manifest/entry/nested.rb +39 -0
  126. data/lib/textus/manifest/entry/parser.rb +64 -31
  127. data/lib/textus/manifest/entry/validators/events.rb +3 -2
  128. data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -3
  129. data/lib/textus/manifest/entry/validators/index_filename.rb +11 -10
  130. data/lib/textus/manifest/entry/validators/inject_intro.rb +5 -2
  131. data/lib/textus/manifest/entry/validators/publish_each.rb +9 -6
  132. data/lib/textus/manifest/entry.rb +0 -72
  133. data/lib/textus/manifest/resolution.rb +5 -0
  134. data/lib/textus/manifest/resolver.rb +109 -0
  135. data/lib/textus/manifest/schema.rb +1 -1
  136. data/lib/textus/manifest.rb +4 -100
  137. data/lib/textus/operations.rb +147 -23
  138. data/lib/textus/schema/tools.rb +7 -7
  139. data/lib/textus/schemas.rb +46 -0
  140. data/lib/textus/store.rb +12 -49
  141. data/lib/textus/uid.rb +18 -0
  142. data/lib/textus/version.rb +1 -1
  143. data/lib/textus.rb +17 -1
  144. metadata +31 -23
  145. data/lib/textus/application/writes/build.rb +0 -79
  146. data/lib/textus/dependencies.rb +0 -23
  147. data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
  148. data/lib/textus/hooks/dispatcher.rb +0 -63
  149. data/lib/textus/hooks/dsl.rb +0 -11
  150. data/lib/textus/hooks/registry.rb +0 -81
  151. data/lib/textus/migrate_keys.rb +0 -187
  152. data/lib/textus/operations/reads.rb +0 -56
  153. data/lib/textus/operations/refresh.rb +0 -27
  154. data/lib/textus/operations/writes.rb +0 -21
  155. data/lib/textus/projection.rb +0 -89
  156. data/lib/textus/refresh.rb +0 -39
  157. data/lib/textus/store/reader.rb +0 -69
  158. data/lib/textus/store/validator.rb +0 -82
  159. data/lib/textus/store/writer.rb +0 -102
@@ -7,7 +7,7 @@ module Textus
7
7
  return [] unless File.directory?(dir)
8
8
 
9
9
  repo_root = File.dirname(store.root)
10
- Dir.glob(File.join(dir, "**", "*#{Textus::Store::Sentinel::SUFFIX}")).flat_map do |sentinel_path|
10
+ Dir.glob(File.join(dir, "**", "*#{Textus::Domain::Sentinel::SUFFIX}")).flat_map do |sentinel_path|
11
11
  inspect_sentinel(sentinel_path, repo_root)
12
12
  end
13
13
  end
@@ -15,7 +15,7 @@ module Textus
15
15
  private
16
16
 
17
17
  def inspect_sentinel(sentinel_path, repo_root)
18
- sentinel = Textus::Store::Sentinel.load(sentinel_path, repo_root)
18
+ sentinel = Textus::Domain::Sentinel.load(sentinel_path, repo_root)
19
19
  return [parse_error_issue(sentinel_path)] if sentinel.nil?
20
20
  return [orphan_issue(sentinel_path, sentinel)] if sentinel.orphan?
21
21
  return [drift_issue(sentinel)] if sentinel.drift?
@@ -5,16 +5,17 @@ module Textus
5
5
  def call
6
6
  out = []
7
7
  store.manifest.entries.each do |entry|
8
- next if entry.template.nil?
8
+ template = entry.respond_to?(:template) ? entry.template : nil
9
+ next if template.nil?
9
10
 
10
- tp = File.join(store.root, "templates", entry.template)
11
+ tp = File.join(store.root, "templates", template)
11
12
  next if File.exist?(tp)
12
13
 
13
14
  out << {
14
15
  "code" => "template.missing",
15
16
  "level" => "error",
16
17
  "subject" => entry.key,
17
- "message" => "template '#{entry.template}' not found at #{tp}",
18
+ "message" => "template '#{template}' not found at #{tp}",
18
19
  "fix" => "create the file at #{tp} or update the entry's template: field",
19
20
  }
20
21
  end
data/lib/textus/doctor.rb CHANGED
@@ -54,11 +54,10 @@ module Textus
54
54
 
55
55
  def run_registered_checks(store)
56
56
  out = []
57
- view = Application::Context.system(store)
58
- store.registry.rpc_names(:validate).each do |name|
59
- callable = store.registry.rpc_callable(:validate, name)
57
+ store.bus.rpc_names(:validate).each do |name|
58
+ callable = store.bus.rpc_callable(:validate, name)
60
59
  begin
61
- result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) { callable.call(store: view) }
60
+ result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) { callable.call(store: store) }
62
61
  if result.is_a?(Array)
63
62
  out.concat(result.map { |h| h.transform_keys(&:to_s) })
64
63
  else
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Domain
5
+ # Authorization service. Single source of truth for "given a manifest
6
+ # entry and a role, may this caller read/write?". Extracted from
7
+ # Application::Context so the rule lives in Domain alongside Permission.
8
+ class Authorizer
9
+ def initialize(manifest:)
10
+ @manifest = manifest
11
+ end
12
+
13
+ def can_write?(zone, role:)
14
+ @manifest.permission_for(zone.to_s).allows_write?(role)
15
+ end
16
+
17
+ def can_read?(zone, role:)
18
+ @manifest.permission_for(zone.to_s).allows_read?(role)
19
+ end
20
+
21
+ def authorize_write!(mentry, role:)
22
+ return if can_write?(mentry.zone, role: role)
23
+
24
+ writers = @manifest.zone_writers(mentry.zone)
25
+ raise WriteForbidden.new(mentry.key, mentry.zone, writers: writers)
26
+ end
27
+
28
+ def authorize_read!(mentry, role:)
29
+ return if can_read?(mentry.zone, role: role)
30
+
31
+ readers = @manifest.zone_readers[mentry.zone]
32
+ readers = nil if readers == :all
33
+ raise ReadForbidden.new(mentry.key, mentry.zone, readers: readers)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -2,7 +2,7 @@ require "time"
2
2
 
3
3
  module Textus
4
4
  module Domain
5
- module Freshness
5
+ class Freshness
6
6
  module Evaluator
7
7
  module_function
8
8
 
@@ -1,6 +1,6 @@
1
1
  module Textus
2
2
  module Domain
3
- module Freshness
3
+ class Freshness
4
4
  Policy = Data.define(:ttl_seconds, :on_stale, :sync_budget_ms) do
5
5
  def decide(verdict)
6
6
  return Action::Return.new if verdict.fresh?
@@ -1,6 +1,6 @@
1
1
  module Textus
2
2
  module Domain
3
- module Freshness
3
+ class Freshness
4
4
  Verdict = Data.define(:fresh, :reason) do
5
5
  def self.fresh = new(fresh: true, reason: nil)
6
6
  def self.stale(reason) = new(fresh: false, reason: reason)
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Domain
5
+ # Value object describing the freshness annotation attached to an Envelope
6
+ # after a freshness evaluation. Replaces the loose Hash that used to live
7
+ # on `Envelope#freshness`.
8
+ #
9
+ # Note on wire format: `#to_h_for_wire` is intentionally narrower than the
10
+ # full field set. It emits the legacy keys ("stale", "stale_reason",
11
+ # "refreshing", and "refresh_error" when present) so the CLI JSON wire
12
+ # stays byte-identical with textus/3. The gem-side fields `checked_at`
13
+ # and `ttl_remaining_ms` are NOT emitted on the wire in this phase.
14
+ Freshness = Data.define(
15
+ :stale, :refreshing, :reason, :refresh_error, :checked_at, :ttl_remaining_ms
16
+ ) do
17
+ def self.build(stale:, refreshing: false, reason: nil, refresh_error: nil,
18
+ checked_at: nil, ttl_remaining_ms: nil)
19
+ new(
20
+ stale: stale,
21
+ refreshing: refreshing,
22
+ reason: reason,
23
+ refresh_error: refresh_error,
24
+ checked_at: checked_at,
25
+ ttl_remaining_ms: ttl_remaining_ms,
26
+ )
27
+ end
28
+
29
+ def to_h_for_wire
30
+ h = {
31
+ "stale" => stale,
32
+ "stale_reason" => reason,
33
+ "refreshing" => refreshing,
34
+ }
35
+ h["refresh_error"] = refresh_error unless refresh_error.nil?
36
+ h
37
+ end
38
+ end
39
+ end
40
+ end
@@ -3,7 +3,7 @@ require "digest"
3
3
  require "fileutils"
4
4
 
5
5
  module Textus
6
- class Store
6
+ module Domain
7
7
  # Value object for sentinel files written by Infra::Publisher and inspected
8
8
  # by Doctor::Check::Sentinels. Owns the JSON shape ({source, target,
9
9
  # sha256, mode}) and the on-disk path layout (<store_root>/sentinels/
@@ -1,7 +1,7 @@
1
1
  require "time"
2
2
 
3
3
  module Textus
4
- class Store
4
+ module Domain
5
5
  class Staleness
6
6
  # Reports staleness for generator-zone entries — derived files whose
7
7
  # generator's listed sources have been modified more recently than the
@@ -14,9 +14,10 @@ module Textus
14
14
 
15
15
  def rows_for(mentry)
16
16
  return [] unless mentry.in_generator_zone?
17
+ return [] unless mentry.is_a?(Textus::Manifest::Entry::Derived)
17
18
 
18
- gen = mentry.generator
19
- return [] unless gen
19
+ src = mentry.source
20
+ return [] unless src.is_a?(Textus::Manifest::Entry::Derived::External)
20
21
 
21
22
  path = Textus::Key::Path.resolve(@manifest, mentry)
22
23
  return [stale_row(mentry, path, "derived entry has never been generated")] unless File.exist?(path)
@@ -28,7 +29,7 @@ module Textus
28
29
  gen_time = parse_time(generated_at)
29
30
  return [stale_row(mentry, path, "unparseable generated.at: #{generated_at.inspect}")] unless gen_time
30
31
 
31
- offender = newest_source_after(gen, gen_time)
32
+ offender = newest_source_after(src, gen_time)
32
33
  return [stale_row(mentry, path, "source '#{offender}' modified after generated.at")] if offender
33
34
 
34
35
  []
@@ -42,8 +43,8 @@ module Textus
42
43
  nil
43
44
  end
44
45
 
45
- def newest_source_after(gen, gen_time)
46
- Array(gen["sources"]).each do |src|
46
+ def newest_source_after(external_src, gen_time)
47
+ Array(external_src.sources).each do |src|
47
48
  offender = check_source(src, gen_time)
48
49
  return offender if offender
49
50
  end
@@ -52,7 +53,7 @@ module Textus
52
53
 
53
54
  def check_source(src, gen_time)
54
55
  if src.match?(/\A[a-z0-9.][a-z0-9._-]*\z/) && !src.include?("/")
55
- @manifest.enumerate(prefix: src).each do |row|
56
+ @manifest.resolver.enumerate(prefix: src).each do |row|
56
57
  return src if File.mtime(row[:path]) > gen_time
57
58
  end
58
59
  nil
@@ -78,7 +79,7 @@ module Textus
78
79
  {
79
80
  "key" => mentry.key,
80
81
  "path" => path,
81
- "generator" => mentry.generator,
82
+ "generator" => mentry.raw["compute"],
82
83
  "reason" => reason,
83
84
  }
84
85
  end
@@ -1,7 +1,7 @@
1
1
  require "time"
2
2
 
3
3
  module Textus
4
- class Store
4
+ module Domain
5
5
  class Staleness
6
6
  # Reports TTL-exceeded staleness for intake-handler entries. Returns an
7
7
  # Array of row hashes (possibly empty) per entry.
@@ -11,7 +11,7 @@ module Textus
11
11
  end
12
12
 
13
13
  def rows_for(mentry)
14
- return [] unless mentry.intake_handler
14
+ return [] unless mentry.is_a?(Textus::Manifest::Entry::Intake)
15
15
 
16
16
  ttl = @manifest.rules_for(mentry.key).refresh&.ttl_seconds
17
17
  return [] unless ttl
@@ -38,7 +38,7 @@ module Textus
38
38
  end
39
39
 
40
40
  def row(mentry, path, reason)
41
- { "key" => mentry.key, "path" => path, "handler" => mentry.intake_handler, "reason" => reason }
41
+ { "key" => mentry.key, "path" => path, "handler" => mentry.handler, "reason" => reason }
42
42
  end
43
43
  end
44
44
  end
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- class Store
2
+ module Domain
3
3
  class Staleness
4
4
  def initialize(manifest:)
5
5
  @manifest = manifest
@@ -85,7 +85,7 @@ module Textus
85
85
 
86
86
  def self.inject_uid(meta, content, existing_uid)
87
87
  m = meta.is_a?(Hash) ? meta.dup : {}
88
- m["uid"] = existing_uid || Textus::Store.mint_uid unless m["uid"].is_a?(String) && !m["uid"].empty?
88
+ m["uid"] = existing_uid || Textus::Uid.mint unless m["uid"].is_a?(String) && !m["uid"].empty?
89
89
  [m, content]
90
90
  end
91
91
 
@@ -70,7 +70,7 @@ module Textus
70
70
 
71
71
  def self.inject_uid(meta, content, existing_uid)
72
72
  m = meta.is_a?(Hash) ? meta.dup : {}
73
- m["uid"] = existing_uid || Textus::Store.mint_uid unless m["uid"].is_a?(String) && !m["uid"].empty?
73
+ m["uid"] = existing_uid || Textus::Uid.mint unless m["uid"].is_a?(String) && !m["uid"].empty?
74
74
  [m, content]
75
75
  end
76
76
 
@@ -83,7 +83,7 @@ module Textus
83
83
 
84
84
  def self.inject_uid(meta, content, existing_uid)
85
85
  m = meta.is_a?(Hash) ? meta.dup : {}
86
- m["uid"] = existing_uid || Textus::Store.mint_uid unless m["uid"].is_a?(String) && !m["uid"].empty?
86
+ m["uid"] = existing_uid || Textus::Uid.mint unless m["uid"].is_a?(String) && !m["uid"].empty?
87
87
  [m, content]
88
88
  end
89
89
 
@@ -45,16 +45,20 @@ module Textus
45
45
  "uid" => uid,
46
46
  }
47
47
  h["content"] = content unless content.nil?
48
- freshness.each { |k, v| h[k.to_s] = v } if freshness.is_a?(Hash)
48
+ freshness&.to_h_for_wire&.each { |k, v| h[k] = v }
49
49
  h
50
50
  end
51
51
 
52
52
  def stale?
53
- freshness.is_a?(Hash) && (freshness["stale"] == true || freshness[:stale] == true)
53
+ return false if freshness.nil?
54
+
55
+ freshness.stale == true
54
56
  end
55
57
 
56
58
  def refreshing?
57
- freshness.is_a?(Hash) && (freshness["refreshing"] == true || freshness[:refreshing] == true)
59
+ return false if freshness.nil?
60
+
61
+ freshness.refreshing == true
58
62
  end
59
63
  end
60
64
  end
data/lib/textus/errors.rb CHANGED
@@ -108,6 +108,25 @@ module Textus
108
108
  end
109
109
  end
110
110
 
111
+ class ReadForbidden < Error
112
+ def initialize(k, z, readers: nil)
113
+ readers_str =
114
+ if readers && !readers.empty?
115
+ readers.join(", ")
116
+ else
117
+ "the role(s) listed in the manifest 'read_policy:'"
118
+ end
119
+ details = { "key" => k, "zone" => z }
120
+ details["readers"] = readers if readers
121
+ super(
122
+ "read_forbidden",
123
+ "zone '#{z}' is not readable by role for key '#{k}'",
124
+ details: details,
125
+ hint: "this zone is readable by #{readers_str}; pass --as=<role>",
126
+ )
127
+ end
128
+ end
129
+
111
130
  class EtagMismatch < Error
112
131
  def initialize(k, w, g)
113
132
  super(
@@ -7,22 +7,22 @@ module Textus
7
7
  module Hooks
8
8
  module Builtin
9
9
  # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
10
- def self.register_all
11
- Textus.on(:resolve_intake, :json) do |store:, config:, args:|
10
+ def self.register_all(bus)
11
+ bus.on(:resolve_intake, :json) do |store:, config:, args:|
12
12
  _ = store
13
13
  _ = args
14
14
  data = JSON.parse(config["bytes"].to_s)
15
15
  { _meta: {}, body: YAML.dump(data) }
16
16
  end
17
17
 
18
- Textus.on(:resolve_intake, :csv) do |store:, config:, args:|
18
+ bus.on(:resolve_intake, :csv) do |store:, config:, args:|
19
19
  _ = store
20
20
  _ = args
21
21
  rows = CSV.parse(config["bytes"].to_s, headers: true).map(&:to_h)
22
22
  { _meta: {}, body: YAML.dump(rows) }
23
23
  end
24
24
 
25
- Textus.on(:resolve_intake, :"markdown-links") do |store:, config:, args:|
25
+ bus.on(:resolve_intake, :"markdown-links") do |store:, config:, args:|
26
26
  _ = store
27
27
  _ = args
28
28
  links = config["bytes"].to_s.scan(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}).map do |text, href|
@@ -31,7 +31,7 @@ module Textus
31
31
  { _meta: {}, body: YAML.dump(links) }
32
32
  end
33
33
 
34
- Textus.on(:resolve_intake, :"ical-events") do |store:, config:, args:|
34
+ bus.on(:resolve_intake, :"ical-events") do |store:, config:, args:|
35
35
  _ = store
36
36
  _ = args
37
37
  events = []
@@ -50,7 +50,7 @@ module Textus
50
50
  { _meta: {}, body: YAML.dump(events) }
51
51
  end
52
52
 
53
- Textus.on(:resolve_intake, :rss) do |store:, config:, args:|
53
+ bus.on(:resolve_intake, :rss) do |store:, config:, args:|
54
54
  _ = store
55
55
  _ = args
56
56
  doc = REXML::Document.new(config["bytes"].to_s)
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Hooks
5
+ class Bus
6
+ HOOK_TIMEOUT_SECONDS = 2
7
+
8
+ class HookTimeout < StandardError; end
9
+
10
+ EVENTS = {
11
+ # RPC events — gem-internal, keep :store
12
+ resolve_intake: { mode: :rpc, args: %i[store config args] },
13
+ transform_rows: { mode: :rpc, args: %i[store rows config] },
14
+ validate: { mode: :rpc, args: %i[store] },
15
+
16
+ # Pubsub events — ship :ctx (Hooks::Context) instead of raw store
17
+ entry_put: { mode: :pubsub, args: %i[ctx key envelope] },
18
+ entry_deleted: { mode: :pubsub, args: %i[ctx key] },
19
+ entry_refreshed: { mode: :pubsub, args: %i[ctx key envelope change] },
20
+ entry_renamed: { mode: :pubsub, args: %i[ctx key from_key to_key envelope] },
21
+ build_completed: { mode: :pubsub, args: %i[ctx key envelope sources] },
22
+ proposal_accepted: { mode: :pubsub, args: %i[ctx key target_key] },
23
+ proposal_rejected: { mode: :pubsub, args: %i[ctx key target_key] },
24
+ file_published: { mode: :pubsub, args: %i[ctx key envelope source target] },
25
+ store_loaded: { mode: :pubsub, args: %i[ctx] },
26
+ refresh_started: { mode: :pubsub, args: %i[ctx key mode] },
27
+ refresh_failed: { mode: :pubsub, args: %i[ctx key error_class error_message] },
28
+ refresh_backgrounded: { mode: :pubsub, args: %i[ctx key started_at budget_ms] },
29
+ }.freeze
30
+
31
+ def initialize
32
+ @rpc = Hash.new { |h, k| h[k] = {} }
33
+ @pubsub = Hash.new { |h, k| h[k] = [] }
34
+ @error_handlers = []
35
+ end
36
+
37
+ def on(event, name, keys: nil, &) = register(event, name, keys: keys, &)
38
+
39
+ def register(event, name, keys: nil, &blk)
40
+ event_sym = event.to_sym
41
+ spec = EVENTS[event_sym] or raise UsageError.new("unknown event: #{event}")
42
+ shape_check!(event_sym, spec, blk)
43
+ name = name.to_sym
44
+
45
+ case spec[:mode]
46
+ when :rpc
47
+ raise UsageError.new("#{event_sym} '#{name}' already registered") if @rpc[event_sym].key?(name)
48
+
49
+ @rpc[event_sym][name] = blk
50
+ when :pubsub
51
+ raise UsageError.new("#{event_sym} hook '#{name}' already registered") if @pubsub[event_sym].any? { |h| h[:name] == name }
52
+
53
+ @pubsub[event_sym] << { name: name, callable: blk, keys: keys }
54
+ end
55
+ end
56
+
57
+ def on_error(&block) = @error_handlers << block
58
+
59
+ def rpc_callable(event, name)
60
+ @rpc[event.to_sym][name.to_sym] or raise UsageError.new("unknown #{event}: #{name}")
61
+ end
62
+
63
+ def rpc_names(event) = @rpc[event.to_sym].keys
64
+ def pubsub_handlers(event) = @pubsub[event.to_sym]
65
+ def listeners(event, key:) = @pubsub[event.to_sym].select { |h| h[:keys].nil? || matches_any?(h[:keys], key) }
66
+
67
+ def publish(event, strict: false, **kwargs)
68
+ key = kwargs[:key] || "-"
69
+ fired = []
70
+ errored = []
71
+ timed_out = []
72
+ raised = nil
73
+
74
+ @pubsub[event.to_sym].each do |sub|
75
+ next unless match?(sub[:keys], key)
76
+
77
+ outcome, err = invoke(event, sub, key, kwargs)
78
+ case outcome
79
+ when :ok then fired << sub[:name]
80
+ when :errored then errored << sub[:name]
81
+ when :timed_out then timed_out << sub[:name]
82
+ end
83
+ raised ||= err if strict && err
84
+ end
85
+
86
+ raise raised if strict && raised
87
+
88
+ FireReport.new(fired: fired, errored: errored, timed_out: timed_out)
89
+ end
90
+
91
+ private
92
+
93
+ def invoke(event, sub, key, kwargs)
94
+ accepted = filter_kwargs(sub[:callable], kwargs)
95
+ error = nil
96
+
97
+ thread = Thread.new do
98
+ sub[:callable].call(**accepted)
99
+ rescue StandardError => e
100
+ error = e
101
+ end
102
+
103
+ if thread.join(HOOK_TIMEOUT_SECONDS).nil?
104
+ thread.kill
105
+ err = HookTimeout.new("hook #{sub[:name]} exceeded #{HOOK_TIMEOUT_SECONDS}s on event #{event}")
106
+ notify_error(event, sub, key, kwargs, err)
107
+ return [:timed_out, err]
108
+ end
109
+
110
+ if error
111
+ notify_error(event, sub, key, kwargs, error)
112
+ return [:errored, error]
113
+ end
114
+
115
+ [:ok, nil]
116
+ end
117
+
118
+ def notify_error(event, sub, key, kwargs, error)
119
+ @error_handlers.each do |handler|
120
+ handler.call(event: event, hook: sub[:name], key: key, kwargs: kwargs, error: error)
121
+ rescue StandardError => e
122
+ warn "[textus] error handler failed: #{e.class}: #{e.message}"
123
+ end
124
+ end
125
+
126
+ def filter_kwargs(callable, kwargs)
127
+ params = callable.parameters
128
+ return kwargs if params.any? { |type, _| type == :keyrest }
129
+
130
+ accepted = params.each_with_object([]) do |(type, name), acc|
131
+ acc << name if %i[key keyreq].include?(type)
132
+ end
133
+ kwargs.slice(*accepted)
134
+ end
135
+
136
+ def shape_check!(event, spec, blk)
137
+ required = spec[:args]
138
+ provided = blk.parameters.select { |t, _| %i[keyreq key keyrest].include?(t) } # rubocop:disable Style/HashSlice
139
+ keyrest = provided.any? { |t, _| t == :keyrest }
140
+ missing = required - provided.map { |_, n| n }
141
+ return if keyrest || missing.empty?
142
+
143
+ raise UsageError.new("#{event} hooks must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
144
+ end
145
+
146
+ def match?(globs, key)
147
+ return true if globs.nil?
148
+
149
+ Array(globs).any? { |g| File.fnmatch?(g, key.to_s, File::FNM_PATHNAME) }
150
+ end
151
+
152
+ def matches_any?(globs, key) = match?(globs, key)
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Hooks
5
+ # A narrow handle passed to user hooks in place of the raw Store.
6
+ # All writes route back through Operations so authorization, audit
7
+ # logging, and schema validation always fire.
8
+ class Context
9
+ attr_reader :role, :correlation_id
10
+
11
+ def initialize(ops:)
12
+ @ops = ops
13
+ @role = ops.ctx.role
14
+ @correlation_id = ops.ctx.correlation_id
15
+ end
16
+
17
+ # read
18
+ def get(key) = @ops.get(key)
19
+ def list(**) = @ops.list(**)
20
+ def deps(key) = @ops.deps(key)
21
+ def freshness(key) = @ops.freshness(key)
22
+
23
+ # write (authorized + audited)
24
+ def put(key, **) = @ops.put(key, **)
25
+ def delete(key, **) = @ops.delete(key, **)
26
+ def audit(verb, key:, **) = @ops.store.audit_log.append(role: @role, verb: verb, key: key, **)
27
+
28
+ # fan-out
29
+ def publish_followup(event, **)
30
+ @ops.store.bus.publish(event, ctx: self, **)
31
+ end
32
+
33
+ def inspect
34
+ "#<Textus::Hooks::Context role=#{@role} correlation_id=#{@correlation_id}>"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Hooks
5
+ # Outcome of a single Dispatcher#publish call.
6
+ #
7
+ # fired — hook names that ran to completion
8
+ # errored — hook names that raised
9
+ # timed_out — hook names whose worker thread exceeded the deadline
10
+ #
11
+ # Callers that care about hook health (tests, strict embedders) can
12
+ # check #ok? or inspect #failures. The dispatcher itself never raises
13
+ # on a hook failure unless strict: true was passed to #publish.
14
+ FireReport = Data.define(:fired, :errored, :timed_out) do
15
+ def initialize(fired:, errored:, timed_out:)
16
+ super(fired: fired.dup.freeze, errored: errored.dup.freeze, timed_out: timed_out.dup.freeze)
17
+ end
18
+
19
+ def ok? = errored.empty? && timed_out.empty?
20
+ def failures = errored + timed_out
21
+ end
22
+ end
23
+ end
@@ -1,25 +1,28 @@
1
1
  module Textus
2
2
  module Hooks
3
- module Loader
4
- THREAD_REGISTRY_KEY = :__textus_active_registry__
5
- private_constant :THREAD_REGISTRY_KEY
6
-
7
- def self.with_registry(registry)
8
- prev = Thread.current[THREAD_REGISTRY_KEY]
9
- Thread.current[THREAD_REGISTRY_KEY] = registry
10
- yield
11
- ensure
12
- Thread.current[THREAD_REGISTRY_KEY] = prev
3
+ class Loader
4
+ def initialize(bus:)
5
+ @bus = bus
13
6
  end
14
7
 
15
- def self.current_registry
16
- Thread.current[THREAD_REGISTRY_KEY] or
17
- raise UsageError.new("no active registry; hook code must be loaded by a Store")
8
+ def load_dir(dir)
9
+ return unless File.directory?(dir)
10
+
11
+ # Discard any leftover blocks from a prior partial load.
12
+ Textus.drain_hook_blocks
13
+
14
+ Dir.glob(File.join(dir, "**/*.rb")).sort.each do |f| # rubocop:disable Lint/RedundantDirGlobSort
15
+ load(f)
16
+ rescue StandardError, ScriptError => e
17
+ raise UsageError.new("failed loading hook #{File.basename(f)}: #{e.class}: #{e.message}")
18
+ end
19
+
20
+ Textus.drain_hook_blocks.each do |blk|
21
+ blk.call(@bus)
22
+ rescue StandardError, ScriptError => e
23
+ raise UsageError.new("failed registering hook: #{e.class}: #{e.message}")
24
+ end
18
25
  end
19
26
  end
20
27
  end
21
-
22
- # Public DSL
23
- def self.with_registry(registry, &) = Hooks::Loader.with_registry(registry, &)
24
- def self.current_registry = Hooks::Loader.current_registry
25
28
  end
@@ -2,7 +2,7 @@ require "json"
2
2
  require "time"
3
3
 
4
4
  module Textus
5
- class Store
5
+ module Infra
6
6
  class AuditLog
7
7
  def initialize(root)
8
8
  @path = File.join(root, "audit.log")