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
@@ -2,12 +2,14 @@ module Textus
2
2
  module Application
3
3
  module Reads
4
4
  class List
5
- def initialize(ctx:)
6
- @ctx = ctx
5
+ def initialize(manifest:)
6
+ @manifest = manifest
7
7
  end
8
8
 
9
9
  def call(prefix: nil, zone: nil)
10
- @ctx.store.reader.list(prefix: prefix, zone: zone)
10
+ rows = @manifest.resolver.enumerate(prefix: prefix)
11
+ rows = rows.select { |r| r[:manifest_entry].zone == zone } if zone
12
+ rows.map { |row| { "key" => row[:key], "zone" => row[:manifest_entry].zone, "path" => row[:path] } }
11
13
  end
12
14
  end
13
15
  end
@@ -4,12 +4,12 @@ module Textus
4
4
  # For one key, surface every matching policy block along with the
5
5
  # per-slot effective value (which loses ties win-by-specificity).
6
6
  class PolicyExplain
7
- def initialize(ctx:)
8
- @ctx = ctx
7
+ def initialize(manifest:)
8
+ @manifest = manifest
9
9
  end
10
10
 
11
11
  def call(key:)
12
- policies = @ctx.store.manifest.rules
12
+ policies = @manifest.rules
13
13
  matching = policies.explain(key)
14
14
  winners = policies.for(key)
15
15
 
@@ -2,12 +2,14 @@ module Textus
2
2
  module Application
3
3
  module Reads
4
4
  class Published
5
- def initialize(ctx:)
6
- @ctx = ctx
5
+ def initialize(manifest:)
6
+ @manifest = manifest
7
7
  end
8
8
 
9
9
  def call
10
- @ctx.store.reader.published
10
+ @manifest.entries.reject { |e| e.publish_to.empty? }.map do |e|
11
+ { "key" => e.key, "publish_to" => e.publish_to }
12
+ end
11
13
  end
12
14
  end
13
15
  end
@@ -2,12 +2,24 @@ module Textus
2
2
  module Application
3
3
  module Reads
4
4
  class Rdeps
5
- def initialize(ctx:)
6
- @ctx = ctx
5
+ def initialize(manifest:)
6
+ @manifest = manifest
7
7
  end
8
8
 
9
9
  def call(key)
10
- @ctx.store.reader.rdeps(key)
10
+ @manifest.entries.each_with_object([]) do |e, acc|
11
+ next unless e.is_a?(Textus::Manifest::Entry::Derived)
12
+
13
+ src = e.source
14
+ sources = if src.is_a?(Textus::Manifest::Entry::Derived::Projection)
15
+ Array(src.select).compact
16
+ elsif src.is_a?(Textus::Manifest::Entry::Derived::External)
17
+ Array(src.sources).compact
18
+ else
19
+ []
20
+ end
21
+ acc << e.key if sources.any? { |s| s == key || key.start_with?("#{s}.") }
22
+ end
11
23
  end
12
24
  end
13
25
  end
@@ -2,12 +2,15 @@ module Textus
2
2
  module Application
3
3
  module Reads
4
4
  class SchemaEnvelope
5
- def initialize(ctx:)
6
- @ctx = ctx
5
+ def initialize(manifest:, schemas:)
6
+ @manifest = manifest
7
+ @schemas = schemas
7
8
  end
8
9
 
9
10
  def call(key)
10
- @ctx.store.reader.schema_envelope(key)
11
+ mentry = @manifest.resolver.resolve(key).entry
12
+ schema = @schemas.fetch_or_nil(mentry.schema)
13
+ { "protocol" => PROTOCOL, "key" => key, "schema_ref" => mentry.schema, "schema" => schema&.to_h }
11
14
  end
12
15
  end
13
16
  end
@@ -2,12 +2,12 @@ module Textus
2
2
  module Application
3
3
  module Reads
4
4
  class Stale
5
- def initialize(ctx:)
6
- @ctx = ctx
5
+ def initialize(manifest:)
6
+ @manifest = manifest
7
7
  end
8
8
 
9
9
  def call(prefix: nil, zone: nil)
10
- @ctx.store.reader.stale(prefix: prefix, zone: zone)
10
+ Textus::Domain::Staleness.new(manifest: @manifest).call(prefix: prefix, zone: zone)
11
11
  end
12
12
  end
13
13
  end
@@ -2,12 +2,20 @@ module Textus
2
2
  module Application
3
3
  module Reads
4
4
  class Uid
5
- def initialize(ctx:)
6
- @ctx = ctx
5
+ def initialize(ctx:, manifest:, file_store:)
6
+ @ctx = ctx
7
+ @manifest = manifest
8
+ @file_store = file_store
7
9
  end
8
10
 
9
11
  def call(key)
10
- @ctx.store.reader.uid(key)
12
+ get.get(key).uid
13
+ end
14
+
15
+ private
16
+
17
+ def get
18
+ @get ||= Get.new(ctx: @ctx, manifest: @manifest, file_store: @file_store)
11
19
  end
12
20
  end
13
21
  end
@@ -2,12 +2,21 @@ module Textus
2
2
  module Application
3
3
  module Reads
4
4
  class ValidateAll
5
- def initialize(ctx:)
6
- @ctx = ctx
5
+ def initialize(ctx:, manifest:, file_store:, schemas:, audit_log:)
6
+ @ctx = ctx
7
+ @manifest = manifest
8
+ @file_store = file_store
9
+ @schemas = schemas
10
+ @audit_log = audit_log
7
11
  end
8
12
 
9
13
  def call
10
- @ctx.store.reader.validate_all
14
+ Validator.new(
15
+ reader: Get.new(ctx: @ctx, manifest: @manifest, file_store: @file_store),
16
+ manifest: @manifest,
17
+ audit_log: @audit_log,
18
+ schema_for: ->(name) { @schemas.fetch_or_nil(name) },
19
+ ).call
11
20
  end
12
21
  end
13
22
  end
@@ -0,0 +1,84 @@
1
+ module Textus
2
+ module Application
3
+ module Reads
4
+ class Validator
5
+ def initialize(reader:, manifest:, audit_log:, schema_for:)
6
+ @reader = reader
7
+ @manifest = manifest
8
+ @audit_log = audit_log
9
+ @schema_for = schema_for
10
+ end
11
+
12
+ def call
13
+ violations = []
14
+ check_content_violations(violations)
15
+ check_role_authority_violations(violations)
16
+ { "protocol" => PROTOCOL, "ok" => violations.empty?, "violations" => violations }
17
+ end
18
+
19
+ private
20
+
21
+ def check_content_violations(violations)
22
+ @manifest.resolver.enumerate.each do |row|
23
+ key = row[:key]
24
+ mentry = row[:manifest_entry]
25
+ env = fetch_envelope(key, violations) or next
26
+ schema = mentry.schema && @schema_for.call(mentry.schema)
27
+ next unless schema
28
+
29
+ begin
30
+ validate_schema!(schema, env, mentry.format)
31
+ rescue Textus::Error => e
32
+ violations << { "key" => key, "code" => e.code, "message" => e.message }
33
+ end
34
+ end
35
+ end
36
+
37
+ def check_role_authority_violations(violations)
38
+ @manifest.resolver.enumerate.each do |row|
39
+ mentry = row[:manifest_entry]
40
+ next unless mentry.schema
41
+
42
+ schema = @schema_for.call(mentry.schema)
43
+ next unless schema
44
+
45
+ env = begin
46
+ @reader.get(row[:key])
47
+ rescue StandardError
48
+ next
49
+ end
50
+ append_authority_violations(violations, row[:key], env, schema)
51
+ end
52
+ end
53
+
54
+ def append_authority_violations(violations, key, env, schema)
55
+ last_writer = @audit_log.last_writer_for(key)
56
+ return if last_writer.nil?
57
+
58
+ env.meta.each_key do |field|
59
+ owner = schema.maintained_by(field)
60
+ next if owner.nil? || last_writer == owner || last_writer == "human"
61
+
62
+ violations << { "key" => key, "code" => "role_authority",
63
+ "field" => field, "expected" => owner, "last_writer" => last_writer }
64
+ end
65
+ end
66
+
67
+ def fetch_envelope(key, violations)
68
+ @reader.get(key)
69
+ rescue Textus::Error => e
70
+ violations << { "key" => key, "code" => e.code, "message" => e.message }
71
+ nil
72
+ end
73
+
74
+ def validate_schema!(schema, envelope, format)
75
+ payload = case format
76
+ when "json", "yaml" then envelope.content || {}
77
+ else envelope.meta || {}
78
+ end
79
+ schema.validate!(payload)
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -2,12 +2,15 @@ module Textus
2
2
  module Application
3
3
  module Reads
4
4
  class Where
5
- def initialize(ctx:)
6
- @ctx = ctx
5
+ def initialize(manifest:)
6
+ @manifest = manifest
7
7
  end
8
8
 
9
9
  def call(key)
10
- @ctx.store.reader.where(key)
10
+ res = @manifest.resolver.resolve(key)
11
+ mentry = res.entry
12
+ path = res.path
13
+ { "protocol" => PROTOCOL, "key" => key, "zone" => mentry.zone, "owner" => mentry.owner, "path" => path }
11
14
  end
12
15
  end
13
16
  end
@@ -1,13 +1,24 @@
1
1
  module Textus
2
2
  module Application
3
3
  module Refresh
4
- module All
5
- module_function
4
+ class All
5
+ def initialize(ctx:, manifest:, envelope_io:, bus:, store:, authorizer:, hook_context:) # rubocop:disable Metrics/ParameterLists
6
+ @ctx = ctx
7
+ @manifest = manifest
8
+ @envelope_io = envelope_io
9
+ @bus = bus
10
+ @store = store
11
+ @authorizer = authorizer
12
+ @hook_context = hook_context
13
+ end
6
14
 
7
- def call(ctx, prefix: nil, zone: nil)
8
- worker = Textus::Application::Refresh::Worker.new(ctx: ctx, bus: ctx.store.bus)
15
+ def call(prefix: nil, zone: nil)
16
+ worker = Textus::Application::Refresh::Worker.new(
17
+ ctx: @ctx, manifest: @manifest, envelope_io: @envelope_io, bus: @bus,
18
+ store: @store, authorizer: @authorizer, hook_context: @hook_context
19
+ )
9
20
 
10
- stale_rows = Textus::Application::Reads::Stale.new(ctx: ctx).call(prefix: prefix, zone: zone)
21
+ stale_rows = Textus::Application::Reads::Stale.new(manifest: @manifest).call(prefix: prefix, zone: zone)
11
22
  refreshed = []
12
23
  failed = []
13
24
  skipped = []
@@ -2,12 +2,13 @@ module Textus
2
2
  module Application
3
3
  module Refresh
4
4
  class Orchestrator
5
- def initialize(worker:, bus:, store_root:, store: nil, role: "human", detached_spawner: nil)
6
- @worker = worker
7
- @bus = bus
8
- @store_root = store_root
9
- @store = store
10
- @role = role
5
+ def initialize(worker:, store_root:, bus: nil, store: nil, ctx: nil, hook_context: nil, detached_spawner: nil) # rubocop:disable Metrics/ParameterLists
6
+ @worker = worker
7
+ @store_root = store_root
8
+ @bus = bus
9
+ @store = store
10
+ @ctx = ctx
11
+ @hook_context = hook_context
11
12
  @detached_spawner = detached_spawner || default_spawner
12
13
  end
13
14
 
@@ -56,10 +57,9 @@ module Textus
56
57
 
57
58
  probe.release
58
59
 
59
- store_view = @store ? Textus::Application::Context.new(store: @store, role: @role) : nil
60
60
  payload = { key: key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms }
61
- payload[:store] = store_view if store_view
62
- @bus.publish(:refresh_backgrounded, **payload)
61
+ payload[:ctx] = @hook_context if @hook_context
62
+ @bus&.publish(:refresh_backgrounded, **payload)
63
63
  @detached_spawner.call(store_root: @store_root, key: key)
64
64
  Textus::Domain::Outcome::Detached.new
65
65
  elsif result.is_a?(Textus::Error)
@@ -6,14 +6,22 @@ module Textus
6
6
  class Worker
7
7
  FETCH_TIMEOUT_SECONDS = 30
8
8
 
9
- def initialize(ctx:, bus:)
10
- @ctx = ctx
11
- @bus = bus
9
+ def initialize(ctx:, manifest:, envelope_io:, bus:, store:, authorizer:, hook_context:) # rubocop:disable Metrics/ParameterLists
10
+ @ctx = ctx
11
+ @manifest = manifest
12
+ @envelope_io = envelope_io
13
+ @bus = bus
14
+ @store = store
15
+ @authorizer = authorizer
16
+ @hook_context = hook_context
12
17
  end
13
18
 
14
19
  def run(key)
15
- mentry, path, remaining = @ctx.store.manifest.resolve(key)
16
- raise UsageError.new("no intake declared for '#{key}'") unless mentry.intake_handler
20
+ res = @manifest.resolver.resolve(key)
21
+ mentry = res.entry
22
+ path = res.path
23
+ remaining = res.remaining
24
+ raise UsageError.new("no intake declared for '#{key}'") unless mentry.is_a?(Textus::Manifest::Entry::Intake)
17
25
 
18
26
  before_etag = File.exist?(path) ? Etag.for_file(path) : nil
19
27
  result = fetch_with_bus(key, mentry, remaining)
@@ -22,19 +30,14 @@ module Textus
22
30
 
23
31
  private
24
32
 
25
- def read_view
26
- Application::Context.new(store: @ctx.store, role: @ctx.role)
27
- end
28
-
29
33
  def fetch_timeout_for(key)
30
- rule = @ctx.store.manifest.rules_for(key)
34
+ rule = @manifest.rules_for(key)
31
35
  rule&.refresh&.fetch_timeout_seconds || FETCH_TIMEOUT_SECONDS
32
36
  end
33
37
 
34
38
  def fetch_with_bus(key, mentry, remaining)
35
- callable = @ctx.store.registry.rpc_callable(:resolve_intake, mentry.intake_handler)
36
- @bus.publish(:refresh_started, store: read_view, key: key, mode: :sync,
37
- correlation_id: @ctx.correlation_id)
39
+ callable = @bus.rpc_callable(:resolve_intake, mentry.handler)
40
+ @bus.publish(:refresh_started, ctx: @hook_context, key: key, mode: :sync)
38
41
  call_intake(key, mentry, callable, remaining)
39
42
  end
40
43
 
@@ -42,38 +45,38 @@ module Textus
42
45
  timeout = fetch_timeout_for(key)
43
46
  Timeout.timeout(timeout) do
44
47
  callable.call(
45
- store: @ctx,
46
- config: mentry.intake_config,
48
+ store: @store,
49
+ config: mentry.config,
47
50
  args: { trigger_key: key, leaf_segments: remaining || [] },
48
51
  )
49
52
  end
50
53
  rescue Timeout::Error
51
- @bus.publish(:refresh_failed, store: read_view, key: key, error_class: "Timeout::Error",
52
- error_message: "intake '#{mentry.intake_handler}' exceeded #{timeout}s",
53
- correlation_id: @ctx.correlation_id)
54
- raise UsageError.new("intake '#{mentry.intake_handler}' exceeded #{timeout}s timeout")
54
+ @bus.publish(:refresh_failed, ctx: @hook_context, key: key,
55
+ error_class: "Timeout::Error",
56
+ error_message: "intake '#{mentry.handler}' exceeded #{timeout}s")
57
+ raise UsageError.new("intake '#{mentry.handler}' exceeded #{timeout}s timeout")
55
58
  rescue Textus::Error => e
56
- @bus.publish(:refresh_failed, store: read_view, key: key, error_class: e.class.name,
57
- error_message: e.message, correlation_id: @ctx.correlation_id)
59
+ @bus.publish(:refresh_failed, ctx: @hook_context, key: key, error_class: e.class.name,
60
+ error_message: e.message)
58
61
  raise
59
62
  rescue StandardError => e
60
- @bus.publish(:refresh_failed, store: read_view, key: key, error_class: e.class.name,
61
- error_message: e.message, correlation_id: @ctx.correlation_id)
62
- raise UsageError.new("intake '#{mentry.intake_handler}' raised: #{e.class}: #{e.message}")
63
+ @bus.publish(:refresh_failed, ctx: @hook_context, key: key, error_class: e.class.name,
64
+ error_message: e.message)
65
+ raise UsageError.new("intake '#{mentry.handler}' raised: #{e.class}: #{e.message}")
63
66
  end
64
67
 
65
68
  def persist_and_notify(key, mentry, result, before_etag)
66
- normalized = Textus::Refresh.normalize_action_result(result, format: mentry.format)
67
- envelope = Textus::Application::Writes::Put.new(ctx: @ctx, bus: @bus).call(
69
+ normalized = self.class.send(:normalize_action_result, result, format: mentry.format)
70
+ @authorizer.authorize_write!(mentry, role: @ctx.role)
71
+ envelope = @envelope_io.write(
68
72
  key,
69
- meta: normalized[:meta], body: normalized[:body], content: normalized[:content],
70
- suppress_events: true
73
+ mentry: mentry,
74
+ payload: Textus::Application::Writes::EnvelopeIO::Payload.new(
75
+ meta: normalized[:meta], body: normalized[:body], content: normalized[:content],
76
+ ),
71
77
  )
72
78
  change = detect_change(before_etag, envelope)
73
- unless change == :unchanged
74
- @bus.publish(:entry_refreshed, store: read_view, key: key, envelope: envelope, change: change,
75
- correlation_id: @ctx.correlation_id)
76
- end
79
+ @bus.publish(:entry_refreshed, ctx: @hook_context, key: key, envelope: envelope, change: change) unless change == :unchanged
77
80
  envelope
78
81
  end
79
82
 
@@ -83,6 +86,30 @@ module Textus
83
86
  else :updated
84
87
  end
85
88
  end
89
+
90
+ def self.normalize_action_result(res, format:)
91
+ res = res.transform_keys(&:to_s) if res.is_a?(Hash)
92
+ res ||= {}
93
+ meta_val = res["_meta"]
94
+ body = res["body"]
95
+ content = res["content"]
96
+
97
+ case format
98
+ when "markdown" then { meta: meta_val || {}, body: body.to_s, content: nil }
99
+ when "text" then { meta: {}, body: body.to_s, content: nil }
100
+ when "json", "yaml"
101
+ if !content.nil?
102
+ { meta: meta_val || {}, body: nil, content: content }
103
+ elsif !body.nil?
104
+ { meta: {}, body: body.to_s, content: nil }
105
+ else
106
+ raise Textus::UsageError.new("intake for #{format} returned neither content nor body")
107
+ end
108
+ else
109
+ raise Textus::UsageError.new("unknown format #{format.inspect}")
110
+ end
111
+ end
112
+ private_class_method :normalize_action_result
86
113
  end
87
114
  end
88
115
  end
@@ -0,0 +1,191 @@
1
+ module Textus
2
+ module Application
3
+ module Tools
4
+ # Run-once helper that renames files/directories whose basenames don't
5
+ # conform to the strict key grammar (§3 of plan-1.2). Only walks
6
+ # nested: true manifest entries — leaf entries with illegal declared
7
+ # keys are caught by Manifest load and must be fixed by hand.
8
+ module MigrateKeys
9
+ SEGMENT = /\A[a-z0-9][a-z0-9-]*\z/
10
+
11
+ module_function
12
+
13
+ # Returns the envelope hash described in plan-1.2 §3.
14
+ def run(store, write: false)
15
+ plan = build_plan(store)
16
+ collisions = plan[:collisions]
17
+ renames = plan[:renames]
18
+
19
+ ok = collisions.empty?
20
+ apply!(store, renames) if write && ok
21
+
22
+ {
23
+ "protocol" => Textus::PROTOCOL,
24
+ "mode" => write ? "write" : "dry-run",
25
+ "renames" => renames.map { |r| envelope_rename(r) },
26
+ "collisions" => collisions.map { |c| envelope_collision(c) },
27
+ "ok" => ok,
28
+ }
29
+ end
30
+
31
+ # ------------------------------------------------------------------
32
+ # Plan construction
33
+ # ------------------------------------------------------------------
34
+
35
+ # Returns { renames: [...], collisions: [...] }
36
+ # Each rename: { from:, to:, old_key:, new_key:, kind: :file|:dir }
37
+ # Each collision: { target:, sources: [...] }
38
+ def build_plan(store) # rubocop:disable Metrics/AbcSize
39
+ renames = []
40
+ target_buckets = Hash.new { |h, k| h[k] = [] } # target_path => [source_path, ...]
41
+
42
+ store.manifest.entries.each do |entry|
43
+ next unless entry.nested?
44
+
45
+ base = File.join(store.root, "zones", entry.path)
46
+ next unless File.directory?(base)
47
+
48
+ # Walk depth-first. Order matters when computing the "new key"
49
+ # for files inside a renamed directory: we record renames bottom-up,
50
+ # so children are renamed before their parents on apply.
51
+ walk(base) do |abs_path, is_dir|
52
+ next if abs_path == base
53
+
54
+ basename = File.basename(abs_path)
55
+ stem = is_dir ? basename : basename.sub(/#{Regexp.escape(File.extname(basename))}\z/, "")
56
+ next if stem.match?(SEGMENT)
57
+
58
+ new_stem = normalize(stem)
59
+ # Skip if normalization yields the same stem (e.g. already-legal
60
+ # under a different lens). In practice match?(SEGMENT) catches that
61
+ # above; this is a safety net.
62
+ next if new_stem == stem
63
+
64
+ new_basename = is_dir ? new_stem : new_stem + File.extname(basename)
65
+ target = File.join(File.dirname(abs_path), new_basename)
66
+ target_buckets[target] << abs_path
67
+
68
+ renames << {
69
+ from: abs_path,
70
+ to: target,
71
+ kind: is_dir ? :dir : :file,
72
+ entry: entry,
73
+ base: base,
74
+ }
75
+ end
76
+ end
77
+
78
+ collisions = target_buckets.select { |_, srcs| srcs.length > 1 }
79
+ .map { |t, srcs| { target: t, sources: srcs.sort } }
80
+
81
+ # Drop colliding entries from renames (we won't apply any of them)
82
+ colliding_targets = collisions.to_set { |c| c[:target] }
83
+ renames.reject! { |r| colliding_targets.include?(r[:to]) }
84
+
85
+ # Sort renames bottom-up (deepest path first) so children move before parents.
86
+ renames.sort_by! { |r| -r[:from].count("/") }
87
+
88
+ { renames: renames, collisions: collisions }
89
+ end
90
+
91
+ # Yields [absolute_path, is_dir] for every entry under root. Depth-first.
92
+ def walk(root, &block)
93
+ Dir.each_child(root) do |name|
94
+ abs = File.join(root, name)
95
+ if File.directory?(abs)
96
+ walk(abs, &block)
97
+ yield abs, true
98
+ else
99
+ yield abs, false
100
+ end
101
+ end
102
+ end
103
+
104
+ # Deterministic transform per plan §3.
105
+ def normalize(s)
106
+ s = s.downcase
107
+ s = s.gsub(/[^a-z0-9-]/, "-") # ., _, and anything else become -
108
+ s = s.gsub(/-+/, "-")
109
+ s.sub(/\A-+/, "").sub(/-+\z/, "")
110
+ end
111
+
112
+ # ------------------------------------------------------------------
113
+ # Apply
114
+ # ------------------------------------------------------------------
115
+
116
+ def apply!(store, renames)
117
+ audit = Textus::Infra::AuditLog.new(store.root)
118
+ renames.each do |r|
119
+ # Bottom-up order means a child's ancestors haven't moved yet, so
120
+ # `from`/`to` are valid as-recorded. The audit `key` reflects the
121
+ # eventual full key once every rename in this batch has applied.
122
+ from = r[:from]
123
+ to = r[:to]
124
+ File.rename(from, to)
125
+ new_key = compute_new_key(r, renames)
126
+ audit.append(
127
+ role: "runner",
128
+ verb: "migrate-keys",
129
+ key: new_key,
130
+ etag_before: nil,
131
+ etag_after: nil,
132
+ extras: { "from" => from, "to" => to },
133
+ )
134
+ end
135
+ end
136
+
137
+ # If an ancestor of `path` was renamed earlier in this batch, rewrite the path.
138
+ def resolve_current_path(path, renames)
139
+ out = path
140
+ renames.each do |r|
141
+ prefix = r[:from] + "/"
142
+ out = r[:to] + out[r[:from].length..] if out.start_with?(prefix)
143
+ end
144
+ out
145
+ end
146
+
147
+ # New full key after applying all renames up through this one.
148
+ def compute_new_key(rename, renames)
149
+ base = rename[:base]
150
+ entry = rename[:entry]
151
+ new_to = resolve_current_path(rename[:to], renames)
152
+
153
+ rel = new_to.sub(%r{\A#{Regexp.escape(base)}/?}, "")
154
+ stripped = rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "") unless rename[:kind] == :dir
155
+ stripped ||= rel
156
+ segs = stripped.split("/").reject(&:empty?)
157
+ (entry.key.split(".") + segs).join(".")
158
+ end
159
+
160
+ # ------------------------------------------------------------------
161
+ # Envelope helpers
162
+ # ------------------------------------------------------------------
163
+
164
+ def envelope_rename(r)
165
+ {
166
+ "from" => r[:from],
167
+ "to" => r[:to],
168
+ "old_key" => path_to_key(r[:from], r[:base], r[:entry], r[:kind]),
169
+ "new_key" => path_to_key(r[:to], r[:base], r[:entry], r[:kind]),
170
+ }
171
+ end
172
+
173
+ def envelope_collision(col)
174
+ { "target" => col[:target], "sources" => col[:sources] }
175
+ end
176
+
177
+ def path_to_key(path, base, entry, kind)
178
+ rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
179
+ stripped =
180
+ if kind == :dir
181
+ rel
182
+ else
183
+ rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
184
+ end
185
+ segs = stripped.split("/").reject(&:empty?)
186
+ (entry.key.split(".") + segs).join(".")
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end