textus 0.18.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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +43 -48
  3. data/CHANGELOG.md +173 -0
  4. data/lib/textus/application/context.rb +20 -58
  5. data/lib/textus/application/policy/predicates/human_accept.rb +30 -0
  6. data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
  7. data/lib/textus/{domain → application}/policy/promotion.rb +20 -3
  8. data/lib/textus/application/projection.rb +91 -0
  9. data/lib/textus/application/reads/audit.rb +4 -4
  10. data/lib/textus/application/reads/blame.rb +9 -8
  11. data/lib/textus/application/reads/deps.rb +14 -3
  12. data/lib/textus/application/reads/freshness.rb +10 -8
  13. data/lib/textus/application/reads/get.rb +10 -8
  14. data/lib/textus/application/reads/get_or_refresh.rb +3 -3
  15. data/lib/textus/application/reads/list.rb +3 -3
  16. data/lib/textus/application/reads/policy_explain.rb +3 -3
  17. data/lib/textus/application/reads/published.rb +5 -3
  18. data/lib/textus/application/reads/rdeps.rb +15 -3
  19. data/lib/textus/application/reads/schema_envelope.rb +5 -4
  20. data/lib/textus/application/reads/stale.rb +3 -3
  21. data/lib/textus/application/reads/uid.rb +11 -3
  22. data/lib/textus/application/reads/validate_all.rb +10 -6
  23. data/lib/textus/application/reads/validator.rb +2 -2
  24. data/lib/textus/application/reads/where.rb +3 -3
  25. data/lib/textus/application/refresh/all.rb +15 -11
  26. data/lib/textus/application/refresh/orchestrator.rb +9 -8
  27. data/lib/textus/application/refresh/worker.rb +56 -32
  28. data/lib/textus/application/tools/migrate_keys.rb +191 -0
  29. data/lib/textus/application/tools/migrate_manifest_to_kinds.rb +31 -0
  30. data/lib/textus/application/writes/accept.rb +38 -15
  31. data/lib/textus/application/writes/delete.rb +13 -10
  32. data/lib/textus/application/writes/envelope_io.rb +64 -4
  33. data/lib/textus/application/writes/materializer.rb +50 -0
  34. data/lib/textus/application/writes/mv.rb +57 -94
  35. data/lib/textus/application/writes/publish.rb +132 -26
  36. data/lib/textus/application/writes/put.rb +15 -14
  37. data/lib/textus/application/writes/reject.rb +20 -11
  38. data/lib/textus/builder/pipeline.rb +21 -15
  39. data/lib/textus/builder/renderer/json.rb +4 -1
  40. data/lib/textus/builder/renderer/markdown.rb +7 -1
  41. data/lib/textus/builder/renderer/yaml.rb +4 -1
  42. data/lib/textus/cli/verb/build.rb +2 -5
  43. data/lib/textus/cli/verb/get.rb +1 -1
  44. data/lib/textus/cli/verb/hook_run.rb +3 -4
  45. data/lib/textus/cli/verb/hooks.rb +5 -5
  46. data/lib/textus/cli/verb/key_normalize.rb +32 -3
  47. data/lib/textus/cli/verb/put.rb +2 -3
  48. data/lib/textus/cli/verb/refresh_stale.rb +1 -2
  49. data/lib/textus/doctor/check/handler_allowlist.rb +3 -2
  50. data/lib/textus/doctor/check/hooks.rb +2 -2
  51. data/lib/textus/doctor/check/illegal_keys.rb +6 -5
  52. data/lib/textus/doctor/check/intake_registration.rb +2 -2
  53. data/lib/textus/doctor/check/manifest_files.rb +1 -1
  54. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  55. data/lib/textus/doctor/check/templates.rb +4 -3
  56. data/lib/textus/doctor.rb +3 -4
  57. data/lib/textus/domain/authorizer.rb +37 -0
  58. data/lib/textus/domain/staleness/generator_check.rb +8 -7
  59. data/lib/textus/domain/staleness/intake_check.rb +2 -2
  60. data/lib/textus/hooks/builtin.rb +6 -6
  61. data/lib/textus/hooks/bus.rb +155 -0
  62. data/lib/textus/hooks/context.rb +38 -0
  63. data/lib/textus/hooks/fire_report.rb +23 -0
  64. data/lib/textus/hooks/loader.rb +3 -3
  65. data/lib/textus/infra/audit_subscriber.rb +4 -4
  66. data/lib/textus/infra/event_bus.rb +3 -3
  67. data/lib/textus/infra/refresh/detached.rb +1 -1
  68. data/lib/textus/init.rb +3 -2
  69. data/lib/textus/intro.rb +7 -7
  70. data/lib/textus/manifest/entry/base.rb +38 -0
  71. data/lib/textus/manifest/entry/derived.rb +25 -0
  72. data/lib/textus/manifest/entry/intake.rb +19 -0
  73. data/lib/textus/manifest/entry/leaf.rb +16 -0
  74. data/lib/textus/manifest/entry/nested.rb +39 -0
  75. data/lib/textus/manifest/entry/parser.rb +64 -31
  76. data/lib/textus/manifest/entry/validators/events.rb +3 -2
  77. data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -3
  78. data/lib/textus/manifest/entry/validators/index_filename.rb +11 -10
  79. data/lib/textus/manifest/entry/validators/inject_intro.rb +5 -2
  80. data/lib/textus/manifest/entry/validators/publish_each.rb +9 -6
  81. data/lib/textus/manifest/entry.rb +0 -72
  82. data/lib/textus/manifest/resolver.rb +109 -0
  83. data/lib/textus/manifest/schema.rb +1 -1
  84. data/lib/textus/manifest.rb +3 -100
  85. data/lib/textus/operations.rb +131 -74
  86. data/lib/textus/schema/tools.rb +2 -2
  87. data/lib/textus/store.rb +6 -6
  88. data/lib/textus/version.rb +1 -1
  89. metadata +18 -11
  90. data/lib/textus/application/writes/build.rb +0 -78
  91. data/lib/textus/dependencies.rb +0 -23
  92. data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
  93. data/lib/textus/hooks/dispatcher.rb +0 -71
  94. data/lib/textus/hooks/registry.rb +0 -85
  95. data/lib/textus/migrate_keys.rb +0 -187
  96. data/lib/textus/projection.rb +0 -89
  97. data/lib/textus/refresh.rb +0 -39
@@ -7,7 +7,7 @@ module Textus
7
7
 
8
8
  option :event_filter, "--event=E"
9
9
 
10
- def call(store) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
10
+ def call(store) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
11
11
  subcommand = positional.first
12
12
  if subcommand
13
13
  raise UsageError.new("hook requires 'list'") unless subcommand == "list"
@@ -16,15 +16,15 @@ module Textus
16
16
  end
17
17
 
18
18
  rows = []
19
- Textus::Hooks::Registry::EVENTS.each do |event, spec|
19
+ Textus::Hooks::Bus::EVENTS.each do |event, spec|
20
20
  mode = spec[:mode].to_s
21
21
  case spec[:mode]
22
22
  when :rpc
23
- store.registry.rpc_names(event).each do |name|
23
+ store.bus.rpc_names(event).each do |name|
24
24
  rows << { "event" => event.to_s, "mode" => mode, "name" => name.to_s }
25
25
  end
26
26
  when :pubsub
27
- store.registry.pubsub_handlers(event).each do |h|
27
+ store.bus.pubsub_handlers(event).each do |h|
28
28
  row = { "event" => event.to_s, "mode" => mode, "name" => h[:name].to_s }
29
29
  row["keys"] = Array(h[:keys]) if h[:keys]
30
30
  rows << row
@@ -32,7 +32,7 @@ module Textus
32
32
  end
33
33
  end
34
34
  store.manifest.entries.each do |e|
35
- e.events.each do |evt, defs|
35
+ (e.respond_to?(:events) ? e.events : {}).each do |evt, defs|
36
36
  Array(defs).each do |defn|
37
37
  next unless defn["exec"]
38
38
 
@@ -7,11 +7,40 @@ module Textus
7
7
 
8
8
  option :write, "--write"
9
9
  option :dry_run, "--dry-run"
10
+ option :upgrade_manifest, "--upgrade-manifest"
10
11
 
11
12
  def call(store)
12
- effective_write = write && !dry_run
13
- res = Textus::MigrateKeys.run(store, write: effective_write || false)
14
- emit(res, exit_code: res["ok"] ? 0 : 1)
13
+ if upgrade_manifest
14
+ run_upgrade_manifest(store)
15
+ else
16
+ effective_write = write && !dry_run
17
+ res = Textus::Application::Tools::MigrateKeys.run(store, write: effective_write || false)
18
+ emit(res, exit_code: res["ok"] ? 0 : 1)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def run_upgrade_manifest(store)
25
+ manifest_path = File.join(store.root, "manifest.yaml")
26
+ orig = File.read(manifest_path)
27
+ new_yaml = Textus::Application::Tools::MigrateManifestToKinds.upgrade_yaml(orig)
28
+
29
+ if dry_run
30
+ diff_lines = unified_diff(orig, new_yaml, manifest_path)
31
+ emit({ "protocol" => PROTOCOL, "dry_run" => true, "diff" => diff_lines, "ok" => true }, exit_code: 0)
32
+ else
33
+ File.write(manifest_path, new_yaml)
34
+ puts "upgraded manifest at #{manifest_path}"
35
+ emit({ "protocol" => PROTOCOL, "upgraded" => manifest_path, "ok" => true }, exit_code: 0)
36
+ end
37
+ end
38
+
39
+ def unified_diff(before, after, _path)
40
+ before.lines.zip(after.lines).each_with_object([]) do |(a, b), acc|
41
+ acc << "- #{a.chomp}" if a && a != b
42
+ acc << "+ #{b.chomp}" if b && a != b
43
+ end
15
44
  end
16
45
  end
17
46
  end
@@ -17,12 +17,11 @@ module Textus
17
17
  raw = @stdin.read
18
18
  payload =
19
19
  if fetch_name
20
- callable = store.registry.rpc_callable(:resolve_intake, fetch_name)
20
+ callable = store.bus.rpc_callable(:resolve_intake, fetch_name)
21
21
  result =
22
22
  begin
23
23
  Timeout.timeout(Textus::Application::Refresh::Worker::FETCH_TIMEOUT_SECONDS) do
24
- callable.call(config: { "bytes" => raw },
25
- store: Textus::Application::Context.new(store: store, role: role), args: {})
24
+ callable.call(config: { "bytes" => raw }, store: store, args: {})
26
25
  end
27
26
  rescue Timeout::Error
28
27
  raise UsageError.new(
@@ -10,8 +10,7 @@ module Textus
10
10
  option :as_flag, "--as=ROLE"
11
11
 
12
12
  def call(store)
13
- ctx = context_for(store)
14
- result = Textus::Application::Refresh::All.call(ctx, prefix: prefix, zone: zone)
13
+ result = operations_for(store).refresh_all(prefix: prefix, zone: zone)
15
14
  emit(result)
16
15
  result["ok"] ? 0 : 1
17
16
  end
@@ -8,8 +8,9 @@ module Textus
8
8
  def call
9
9
  out = []
10
10
  store.manifest.entries.each do |mentry|
11
- handler = mentry.intake_handler
12
- next if handler.nil?
11
+ next unless mentry.is_a?(Textus::Manifest::Entry::Intake)
12
+
13
+ handler = mentry.handler
13
14
 
14
15
  allow = store.manifest.rules_for(mentry.key).handler_allowlist
15
16
  next if allow.nil?
@@ -8,11 +8,11 @@ module Textus
8
8
  return out unless File.directory?(dir)
9
9
 
10
10
  Dir.glob(File.join(dir, "*.rb")).sort.each do |f| # rubocop:disable Lint/RedundantDirGlobSort
11
- registry = Textus::Hooks::Registry.new
11
+ bus = Textus::Hooks::Bus.new
12
12
  Textus.drain_hook_blocks
13
13
  begin
14
14
  load(f)
15
- Textus.drain_hook_blocks.each { |b| b.call(registry) }
15
+ Textus.drain_hook_blocks.each { |b| b.call(bus) }
16
16
  end
17
17
  rescue StandardError, ScriptError => e
18
18
  out << {
@@ -5,12 +5,13 @@ module Textus
5
5
  def call
6
6
  out = []
7
7
  store.manifest.entries.each do |entry|
8
- next unless entry.nested
8
+ next unless entry.nested?
9
9
 
10
10
  base = File.join(store.root, "zones", entry.path)
11
11
  next unless File.directory?(base)
12
12
 
13
- entry.index_filename ? check_index_paths(entry, base, out) : check_all_paths(base, out)
13
+ index_fn = entry.respond_to?(:index_filename) ? entry.index_filename : nil
14
+ index_fn ? check_index_paths(entry, index_fn, base, out) : check_all_paths(base, out)
14
15
  end
15
16
  out
16
17
  end
@@ -31,8 +32,8 @@ module Textus
31
32
  # segments leading to each index file participate in keys. Sibling
32
33
  # files and unrelated subtrees are not enumerated and must not be
33
34
  # flagged. Each illegal segment is reported once per path.
34
- def check_index_paths(entry, base, out)
35
- Dir.glob(File.join(base, "**", entry.index_filename)).each do |fp|
35
+ def check_index_paths(_entry, index_fn, base, out)
36
+ Dir.glob(File.join(base, "**", index_fn)).each do |fp|
36
37
  rel = fp.sub(%r{\A#{Regexp.escape(base)}/?}, "")
37
38
  File.dirname(rel).split("/").reject { |s| s.empty? || s == "." }.each do |seg|
38
39
  next if seg.match?(Key::Grammar::SEGMENT)
@@ -43,7 +44,7 @@ module Textus
43
44
  end
44
45
 
45
46
  def issue(abs_path, stem)
46
- proposed = Textus::MigrateKeys.normalize(stem)
47
+ proposed = Textus::Application::Tools::MigrateKeys.normalize(stem)
47
48
  {
48
49
  "code" => "key.illegal",
49
50
  "level" => "error",
@@ -6,7 +6,7 @@ module Textus
6
6
 
7
7
  def call
8
8
  declared = collect_declared_handlers
9
- registered = store.registry.rpc_names(:resolve_intake).to_set
9
+ registered = store.bus.rpc_names(:resolve_intake).to_set
10
10
 
11
11
  out = (declared - registered).map do |name|
12
12
  {
@@ -36,7 +36,7 @@ module Textus
36
36
  def collect_declared_handlers
37
37
  set = Set.new
38
38
  store.manifest.entries.each do |mentry|
39
- set << mentry.intake_handler.to_sym if mentry.intake_handler
39
+ set << mentry.handler.to_sym if mentry.is_a?(Textus::Manifest::Entry::Intake)
40
40
  end
41
41
  set
42
42
  end
@@ -4,7 +4,7 @@ module Textus
4
4
  class ManifestFiles < Check
5
5
  def call
6
6
  store.manifest.entries.each_with_object([]) do |entry, out|
7
- next if entry.nested
7
+ next if entry.nested?
8
8
 
9
9
  path = Textus::Key::Path.resolve(store.manifest, entry)
10
10
  next if File.exist?(path)
@@ -19,7 +19,7 @@ module Textus
19
19
  "code" => "protocol_mismatch",
20
20
  "severity" => "error",
21
21
  "message" => "Store reports version=#{version.inspect}; this gem expects textus/3.",
22
- "hint" => "Install textus 0.11.x to run the migrator, then upgrade to this version. See https://github.com/patrick204nqh/textus/blob/main/CHANGELOG.md#0110",
22
+ "hint" => "Upgrade the store's manifest version to textus/3 (see CHANGELOG for breaking changes).",
23
23
  }]
24
24
  end
25
25
 
@@ -38,7 +38,7 @@ module Textus
38
38
  "level" => "error",
39
39
  "subject" => path,
40
40
  "message" => "Store reports version=#{version.inspect}; this gem expects textus/3.",
41
- "fix" => "Install textus 0.11.x to run the migrator, then upgrade to this version. See https://github.com/patrick204nqh/textus/blob/main/CHANGELOG.md#0110",
41
+ "fix" => "Upgrade the store's manifest version to textus/3 (see CHANGELOG for breaking changes).",
42
42
  }]
43
43
  end
44
44
  end
@@ -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
@@ -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
@@ -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
@@ -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(registry)
11
- registry.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
- registry.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
- registry.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
- registry.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
- registry.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,8 +1,8 @@
1
1
  module Textus
2
2
  module Hooks
3
3
  class Loader
4
- def initialize(registry:)
5
- @registry = registry
4
+ def initialize(bus:)
5
+ @bus = bus
6
6
  end
7
7
 
8
8
  def load_dir(dir)
@@ -18,7 +18,7 @@ module Textus
18
18
  end
19
19
 
20
20
  Textus.drain_hook_blocks.each do |blk|
21
- blk.call(@registry)
21
+ blk.call(@bus)
22
22
  rescue StandardError, ScriptError => e
23
23
  raise UsageError.new("failed registering hook: #{e.class}: #{e.message}")
24
24
  end
@@ -3,11 +3,11 @@
3
3
  module Textus
4
4
  module Infra
5
5
  # Writes an "event_error" audit row when a user hook raises during
6
- # Hooks::Dispatcher publish. Attached at Store boot.
6
+ # Hooks::Bus publish. Attached at Store boot.
7
7
  #
8
- # Integration: uses Hooks::Dispatcher#on_error callback (chosen over a
9
- # synthetic :hook_error event because the dispatcher already owns the
10
- # rescue and the failure is a dispatcher-internal concern, not a domain
8
+ # Integration: uses Hooks::Bus#on_error callback (chosen over a
9
+ # synthetic :hook_error event because the bus already owns the
10
+ # rescue and the failure is a bus-internal concern, not a domain
11
11
  # event subscribers should be able to filter by key glob).
12
12
  #
13
13
  # NOTE (0.16 scope): lifecycle audit rows for verb: "put" / "delete" /