textus 0.26.0 → 0.29.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 (142) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +111 -67
  3. data/CHANGELOG.md +76 -0
  4. data/README.md +55 -13
  5. data/SPEC.md +75 -38
  6. data/docs/conventions.md +4 -4
  7. data/lib/textus/boot.rb +14 -10
  8. data/lib/textus/builder/pipeline.rb +13 -12
  9. data/lib/textus/call.rb +28 -0
  10. data/lib/textus/cli/verb/audit.rb +1 -1
  11. data/lib/textus/cli/verb/boot.rb +1 -1
  12. data/lib/textus/cli/verb/build.rb +2 -2
  13. data/lib/textus/cli/verb/doctor.rb +1 -1
  14. data/lib/textus/cli/verb/hook_run.rb +2 -2
  15. data/lib/textus/cli/verb/put.rb +3 -3
  16. data/lib/textus/cli/verb.rb +6 -6
  17. data/lib/textus/cli.rb +0 -7
  18. data/lib/textus/container.rb +23 -0
  19. data/lib/textus/dispatcher.rb +49 -0
  20. data/lib/textus/doctor/check/audit_log.rb +1 -1
  21. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  22. data/lib/textus/doctor/check/sentinels.rb +10 -8
  23. data/lib/textus/doctor/check.rb +12 -5
  24. data/lib/textus/doctor.rb +7 -7
  25. data/lib/textus/domain/authorizer.rb +2 -2
  26. data/lib/textus/domain/sentinel.rb +9 -65
  27. data/lib/textus/domain/staleness/generator_check.rb +46 -26
  28. data/lib/textus/domain/staleness/intake_check.rb +18 -10
  29. data/lib/textus/domain/staleness.rb +3 -3
  30. data/lib/textus/{application/envelope → envelope/io}/reader.rb +2 -2
  31. data/lib/textus/{application/envelope → envelope/io}/writer.rb +11 -11
  32. data/lib/textus/hooks/context.rb +30 -13
  33. data/lib/textus/hooks/rpc_registry.rb +1 -1
  34. data/lib/textus/maintenance/key_delete_prefix.rb +36 -0
  35. data/lib/textus/maintenance/key_mv_prefix.rb +46 -0
  36. data/lib/textus/maintenance/migrate.rb +51 -0
  37. data/lib/textus/maintenance/rule_lint.rb +56 -0
  38. data/lib/textus/maintenance/zone_mv.rb +51 -0
  39. data/lib/textus/maintenance.rb +15 -0
  40. data/lib/textus/manifest/data.rb +4 -3
  41. data/lib/textus/manifest/entry/base.rb +38 -18
  42. data/lib/textus/manifest/entry/derived.rb +6 -6
  43. data/lib/textus/manifest/entry/nested.rb +7 -9
  44. data/lib/textus/manifest/entry/parser.rb +2 -2
  45. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  46. data/lib/textus/manifest/entry/validators/format_matrix.rb +2 -2
  47. data/lib/textus/manifest/entry/validators/index_filename.rb +1 -1
  48. data/lib/textus/manifest/entry/validators/inject_boot.rb +4 -2
  49. data/lib/textus/manifest/entry/validators/publish_each.rb +1 -1
  50. data/lib/textus/manifest/entry/validators.rb +2 -2
  51. data/lib/textus/manifest/entry.rb +0 -5
  52. data/lib/textus/manifest.rb +1 -6
  53. data/lib/textus/mcp/server.rb +1 -2
  54. data/lib/textus/mcp/session.rb +10 -1
  55. data/lib/textus/mcp/tools.rb +2 -2
  56. data/lib/textus/mcp.rb +1 -1
  57. data/lib/textus/{infra → ports}/audit_log.rb +1 -1
  58. data/lib/textus/{infra → ports}/audit_subscriber.rb +2 -2
  59. data/lib/textus/{infra → ports}/build_lock.rb +1 -1
  60. data/lib/textus/{infra → ports}/clock.rb +1 -1
  61. data/lib/textus/{infra → ports}/publisher.rb +6 -6
  62. data/lib/textus/{infra → ports}/refresh/detached.rb +3 -3
  63. data/lib/textus/{infra → ports}/refresh/lock.rb +1 -1
  64. data/lib/textus/ports/sentinel_store.rb +67 -0
  65. data/lib/textus/ports/storage/file_stat.rb +19 -0
  66. data/lib/textus/{infra → ports}/storage/file_store.rb +1 -1
  67. data/lib/textus/projection.rb +91 -0
  68. data/lib/textus/read/audit.rb +111 -0
  69. data/lib/textus/read/blame.rb +81 -0
  70. data/lib/textus/read/boot.rb +18 -0
  71. data/lib/textus/read/deps.rb +24 -0
  72. data/lib/textus/read/doctor.rb +19 -0
  73. data/lib/textus/read/freshness.rb +101 -0
  74. data/lib/textus/read/get.rb +66 -0
  75. data/lib/textus/read/get_or_refresh.rb +69 -0
  76. data/lib/textus/read/list.rb +15 -0
  77. data/lib/textus/read/policy_explain.rb +37 -0
  78. data/lib/textus/read/published.rb +15 -0
  79. data/lib/textus/read/pulse.rb +89 -0
  80. data/lib/textus/read/rdeps.rb +25 -0
  81. data/lib/textus/read/schema_envelope.rb +16 -0
  82. data/lib/textus/read/stale.rb +17 -0
  83. data/lib/textus/read/uid.rb +20 -0
  84. data/lib/textus/read/validate_all.rb +22 -0
  85. data/lib/textus/read/validator.rb +84 -0
  86. data/lib/textus/read/where.rb +16 -0
  87. data/lib/textus/role_scope.rb +49 -0
  88. data/lib/textus/schema/tools.rb +3 -3
  89. data/lib/textus/store.rb +16 -7
  90. data/lib/textus/version.rb +1 -1
  91. data/lib/textus/write/accept.rb +86 -0
  92. data/lib/textus/write/authority_gate.rb +24 -0
  93. data/lib/textus/write/delete.rb +54 -0
  94. data/lib/textus/write/materializer.rb +48 -0
  95. data/lib/textus/write/mv.rb +123 -0
  96. data/lib/textus/write/publish.rb +66 -0
  97. data/lib/textus/write/put.rb +59 -0
  98. data/lib/textus/write/refresh_all.rb +44 -0
  99. data/lib/textus/write/refresh_orchestrator.rb +102 -0
  100. data/lib/textus/write/refresh_worker.rb +138 -0
  101. data/lib/textus/write/reject.rb +54 -0
  102. data/lib/textus.rb +1 -2
  103. metadata +54 -50
  104. data/lib/textus/application/caps.rb +0 -49
  105. data/lib/textus/application/context.rb +0 -34
  106. data/lib/textus/application/maintenance/key_delete_prefix.rb +0 -44
  107. data/lib/textus/application/maintenance/key_mv_prefix.rb +0 -57
  108. data/lib/textus/application/maintenance/migrate.rb +0 -59
  109. data/lib/textus/application/maintenance/rule_lint.rb +0 -65
  110. data/lib/textus/application/maintenance/zone_mv.rb +0 -60
  111. data/lib/textus/application/maintenance.rb +0 -17
  112. data/lib/textus/application/projection.rb +0 -93
  113. data/lib/textus/application/read/audit.rb +0 -106
  114. data/lib/textus/application/read/blame.rb +0 -91
  115. data/lib/textus/application/read/deps.rb +0 -34
  116. data/lib/textus/application/read/freshness.rb +0 -110
  117. data/lib/textus/application/read/get.rb +0 -75
  118. data/lib/textus/application/read/get_or_refresh.rb +0 -63
  119. data/lib/textus/application/read/list.rb +0 -25
  120. data/lib/textus/application/read/policy_explain.rb +0 -47
  121. data/lib/textus/application/read/published.rb +0 -25
  122. data/lib/textus/application/read/pulse.rb +0 -101
  123. data/lib/textus/application/read/rdeps.rb +0 -35
  124. data/lib/textus/application/read/schema_envelope.rb +0 -26
  125. data/lib/textus/application/read/stale.rb +0 -23
  126. data/lib/textus/application/read/uid.rb +0 -30
  127. data/lib/textus/application/read/validate_all.rb +0 -32
  128. data/lib/textus/application/read/validator.rb +0 -86
  129. data/lib/textus/application/read/where.rb +0 -26
  130. data/lib/textus/application/use_case.rb +0 -22
  131. data/lib/textus/application/write/accept.rb +0 -102
  132. data/lib/textus/application/write/authority_gate.rb +0 -26
  133. data/lib/textus/application/write/delete.rb +0 -45
  134. data/lib/textus/application/write/materializer.rb +0 -49
  135. data/lib/textus/application/write/mv.rb +0 -118
  136. data/lib/textus/application/write/publish.rb +0 -96
  137. data/lib/textus/application/write/put.rb +0 -49
  138. data/lib/textus/application/write/refresh_all.rb +0 -63
  139. data/lib/textus/application/write/refresh_orchestrator.rb +0 -102
  140. data/lib/textus/application/write/refresh_worker.rb +0 -134
  141. data/lib/textus/application/write/reject.rb +0 -62
  142. data/lib/textus/session.rb +0 -84
@@ -3,22 +3,24 @@ module Textus
3
3
  class Check
4
4
  class Sentinels < Check
5
5
  def call
6
- dir = File.join(root, "sentinels")
7
- return [] unless File.directory?(dir)
6
+ store = Textus::Ports::SentinelStore.new
7
+ file_stat = Textus::Ports::Storage::FileStat.new
8
+ dir = File.join(root, "sentinels")
9
+ return [] unless file_stat.directory?(dir)
8
10
 
9
11
  repo_root = File.dirname(root)
10
- Dir.glob(File.join(dir, "**", "*#{Textus::Domain::Sentinel::SUFFIX}")).flat_map do |sentinel_path|
11
- inspect_sentinel(sentinel_path, repo_root)
12
+ file_stat.glob(File.join(dir, "**", "*#{Textus::Ports::SentinelStore::SUFFIX}")).flat_map do |sentinel_path|
13
+ inspect_sentinel(sentinel_path, repo_root, store, file_stat)
12
14
  end
13
15
  end
14
16
 
15
17
  private
16
18
 
17
- def inspect_sentinel(sentinel_path, repo_root)
18
- sentinel = Textus::Domain::Sentinel.load(sentinel_path, repo_root)
19
+ def inspect_sentinel(sentinel_path, repo_root, store, file_stat)
20
+ sentinel = store.load(sentinel_path, repo_root)
19
21
  return [parse_error_issue(sentinel_path)] if sentinel.nil?
20
- return [orphan_issue(sentinel_path, sentinel)] if sentinel.orphan?
21
- return [drift_issue(sentinel)] if sentinel.drift?
22
+ return [orphan_issue(sentinel_path, sentinel)] if sentinel.orphan?(file_stat)
23
+ return [drift_issue(sentinel)] if sentinel.drift?(file_stat)
22
24
 
23
25
  []
24
26
  end
@@ -14,8 +14,8 @@ module Textus
14
14
  .downcase
15
15
  end
16
16
 
17
- def initialize(session)
18
- @session = session
17
+ def initialize(container)
18
+ @container = container
19
19
  end
20
20
 
21
21
  def call
@@ -24,9 +24,16 @@ module Textus
24
24
 
25
25
  protected
26
26
 
27
- def root = @session.read_caps.root
28
- def manifest = @session.read_caps.manifest
29
- def rpc = @session.rpc
27
+ def root = @container.root
28
+ def manifest = @container.manifest
29
+ def rpc = @container.rpc
30
+
31
+ # Dispatch a verb through the static Dispatcher table.
32
+ def dispatch(verb, *, **)
33
+ klass = Textus::Dispatcher.fetch(verb)
34
+ call_value = Textus::Call.build(role: Textus::Role::DEFAULT)
35
+ klass.new(container: @container, call: call_value).call(*, **)
36
+ end
30
37
  end
31
38
  end
32
39
  end
data/lib/textus/doctor.rb CHANGED
@@ -30,7 +30,7 @@ module Textus
30
30
 
31
31
  module_function
32
32
 
33
- def run(session, checks: nil)
33
+ def build(container:, checks: nil)
34
34
  selected_keys = checks ? Array(checks).map(&:to_s) : ALL_CHECKS
35
35
  unknown = selected_keys - ALL_CHECKS
36
36
  unless unknown.empty?
@@ -40,8 +40,8 @@ module Textus
40
40
  end
41
41
 
42
42
  selected = CHECKS.select { |c| selected_keys.include?(c.name_key) }
43
- issues = selected.flat_map { |c| c.new(session).call }
44
- issues.concat(run_registered_checks(session))
43
+ issues = selected.flat_map { |c| c.new(container).call }
44
+ issues.concat(run_registered_checks(container))
45
45
 
46
46
  summary = LEVELS.to_h { |l| [l, issues.count { |i| i["level"] == l }] }
47
47
  {
@@ -52,13 +52,13 @@ module Textus
52
52
  }
53
53
  end
54
54
 
55
- def run_registered_checks(session)
56
- session.rpc.names(:validate).flat_map { |name| invoke_registered_check(session, name) }
55
+ def run_registered_checks(container)
56
+ container.rpc.names(:validate).flat_map { |name| invoke_registered_check(container, name) }
57
57
  end
58
58
 
59
- def invoke_registered_check(session, name)
59
+ def invoke_registered_check(container, name)
60
60
  result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) do
61
- session.rpc.invoke(:validate, name, caps: session.write_caps)
61
+ container.rpc.invoke(:validate, name, caps: container)
62
62
  end
63
63
  return result.map { |h| h.transform_keys(&:to_s) } if result.is_a?(Array)
64
64
 
@@ -3,8 +3,8 @@
3
3
  module Textus
4
4
  module Domain
5
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.
6
+ # entry and a role, may this caller read/write?". Lives in Domain
7
+ # alongside Permission.
8
8
  class Authorizer
9
9
  def initialize(manifest:)
10
10
  @manifest = manifest
@@ -1,69 +1,15 @@
1
- require "json"
2
1
  require "digest"
3
- require "fileutils"
4
2
 
5
3
  module Textus
6
4
  module Domain
7
- # Value object for sentinel files written by Infra::Publisher and inspected
8
- # by Doctor::Check::Sentinels. Owns the JSON shape ({source, target,
9
- # sha256, mode}) and the on-disk path layout (<store_root>/sentinels/
10
- # <target-rel-to-repo>.textus-managed.json). Target/source are repo-relative
11
- # when the published file is under the repo root, absolute otherwise.
5
+ # Pure value object representing a published-file sentinel. Holds the
6
+ # recorded target path, source path, sha256 checksum, and publish mode.
7
+ # Has no filesystem I/O path layout and persistence live in
8
+ # Ports::SentinelStore; predicate methods accept a FileStat port for
9
+ # existence and content checks.
12
10
  class Sentinel
13
- SUFFIX = ".textus-managed.json".freeze
14
- DIR = "sentinels".freeze
15
-
16
11
  attr_reader :target, :source, :sha256, :mode
17
12
 
18
- def self.write!(target:, source:, store_root:)
19
- path = sentinel_path(target, store_root)
20
- FileUtils.mkdir_p(File.dirname(path))
21
- repo_root = File.dirname(store_root)
22
- File.write(path, JSON.generate(
23
- "source" => rel_or_abs(source, repo_root),
24
- "target" => rel_or_abs(target, repo_root),
25
- "sha256" => Digest::SHA256.hexdigest(File.binread(target)),
26
- "mode" => "copy",
27
- ))
28
- end
29
-
30
- def self.load(path, repo_root)
31
- raw = JSON.parse(File.read(path))
32
- new(
33
- target: absolutize(raw["target"], repo_root),
34
- source: absolutize(raw["source"], repo_root),
35
- sha256: raw["sha256"],
36
- mode: raw["mode"],
37
- )
38
- rescue JSON::ParserError, Errno::ENOENT
39
- nil
40
- end
41
-
42
- def self.sentinel_path(target, store_root)
43
- repo_root = File.dirname(store_root)
44
- rel = relative_to(target, repo_root) || File.basename(target)
45
- File.join(store_root, DIR, rel + SUFFIX)
46
- end
47
-
48
- def self.rel_or_abs(path, repo_root)
49
- relative_to(path, repo_root) || File.expand_path(path)
50
- end
51
-
52
- def self.relative_to(path, repo_root)
53
- path = File.expand_path(path)
54
- base = File.expand_path(repo_root)
55
- return nil unless path.start_with?(base + File::SEPARATOR)
56
-
57
- path[(base.length + 1)..]
58
- end
59
-
60
- def self.absolutize(path, repo_root)
61
- return path if path.nil?
62
- return path if File.absolute_path?(path)
63
-
64
- File.expand_path(path, repo_root)
65
- end
66
-
67
13
  def initialize(target:, source:, sha256:, mode:)
68
14
  @target = target
69
15
  @source = source
@@ -71,15 +17,13 @@ module Textus
71
17
  @mode = mode
72
18
  end
73
19
 
74
- def orphan?
75
- @target.nil? || !File.exist?(@target)
76
- end
20
+ def orphan?(file_stat) = @target.nil? || !file_stat.exists?(@target)
77
21
 
78
- def drift?
79
- return false if orphan?
22
+ def drift?(file_stat)
23
+ return false if orphan?(file_stat)
80
24
  return false if @sha256.nil?
81
25
 
82
- Digest::SHA256.hexdigest(File.binread(@target)) != @sha256
26
+ Digest::SHA256.hexdigest(file_stat.read(@target)) != @sha256
83
27
  end
84
28
  end
85
29
  end
@@ -8,34 +8,43 @@ module Textus
8
8
  # entry's `_meta.generated.at` timestamp. Returns an Array of row hashes
9
9
  # (possibly empty) per entry.
10
10
  class GeneratorCheck
11
- def initialize(manifest:)
12
- @manifest = manifest
11
+ def initialize(manifest:, file_stat:)
12
+ @manifest = manifest
13
+ @file_stat = file_stat
13
14
  end
14
15
 
15
16
  def rows_for(mentry)
16
- return [] unless mentry.in_generator_zone?
17
- return [] unless mentry.is_a?(Textus::Manifest::Entry::Derived)
18
-
19
- src = mentry.source
20
- return [] unless src.is_a?(Textus::Manifest::Entry::Derived::External)
17
+ return [] unless applicable?(mentry)
21
18
 
22
19
  path = Textus::Key::Path.resolve(@manifest.data, mentry)
23
- return [stale_row(mentry, path, "derived entry has never been generated")] unless File.exist?(path)
20
+ reason = stale_reason(mentry, path)
21
+ reason ? [stale_row(mentry, path, reason)] : []
22
+ end
24
23
 
25
- parsed = Entry.for_format(mentry.format).parse(File.binread(path), path: path)
26
- generated_at = parsed["_meta"].dig("generated", "at")
27
- return [stale_row(mentry, path, "missing generated.at frontmatter")] unless generated_at
24
+ private
28
25
 
29
- gen_time = parse_time(generated_at)
30
- return [stale_row(mentry, path, "unparseable generated.at: #{generated_at.inspect}")] unless gen_time
26
+ def applicable?(mentry)
27
+ mentry.in_generator_zone?(@manifest.policy) &&
28
+ mentry.is_a?(Textus::Manifest::Entry::Derived) &&
29
+ mentry.source.is_a?(Textus::Manifest::Entry::Derived::External)
30
+ end
31
31
 
32
- offender = newest_source_after(src, gen_time)
33
- return [stale_row(mentry, path, "source '#{offender}' modified after generated.at")] if offender
32
+ def stale_reason(mentry, path)
33
+ return "derived entry has never been generated" unless @file_stat.exists?(path)
34
34
 
35
- []
35
+ generated_at = generated_at_of(mentry, path)
36
+ return "missing generated.at frontmatter" unless generated_at
37
+
38
+ gen_time = parse_time(generated_at)
39
+ return "unparseable generated.at: #{generated_at.inspect}" unless gen_time
40
+
41
+ offender = newest_source_after(mentry.source, gen_time)
42
+ "source '#{offender}' modified after generated.at" if offender
36
43
  end
37
44
 
38
- private
45
+ def generated_at_of(mentry, path)
46
+ Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"].dig("generated", "at")
47
+ end
39
48
 
40
49
  def parse_time(str)
41
50
  Time.parse(str.to_s)
@@ -54,7 +63,7 @@ module Textus
54
63
  def check_source(src, gen_time)
55
64
  if src.match?(/\A[a-z0-9.][a-z0-9._-]*\z/) && !src.include?("/")
56
65
  @manifest.resolver.enumerate(prefix: src).each do |row|
57
- return src if File.mtime(row[:path]) > gen_time
66
+ return src if @file_stat.mtime(row[:path]) > gen_time
58
67
  end
59
68
  nil
60
69
  else
@@ -63,18 +72,29 @@ module Textus
63
72
  end
64
73
 
65
74
  def check_filesystem_source(src, gen_time)
66
- abs = File.absolute_path?(src) ? src : File.join(File.dirname(@manifest.data.root), src)
67
- if File.directory?(abs)
68
- Dir.glob(File.join(abs, "**", "*")).each do |fp|
69
- next unless File.file?(fp)
70
- return src if File.mtime(fp) > gen_time
71
- end
72
- nil
73
- elsif File.exist?(abs) && File.mtime(abs) > gen_time
75
+ abs = absolutize_source(src)
76
+ if @file_stat.directory?(abs)
77
+ dir_has_newer_file?(abs, gen_time) ? src : nil
78
+ elsif @file_stat.exists?(abs) && @file_stat.mtime(abs) > gen_time
74
79
  src
75
80
  end
76
81
  end
77
82
 
83
+ def absolutize_source(src)
84
+ File.absolute_path?(src) ? src : File.join(File.dirname(@manifest.data.root), src)
85
+ end
86
+
87
+ def dir_has_newer_file?(abs, gen_time)
88
+ @file_stat.glob(File.join(abs, "**", "*")).any? do |fpath|
89
+ file?(fpath) && @file_stat.mtime(fpath) > gen_time
90
+ end
91
+ end
92
+
93
+ # FileStat substitute for File.file?: excludes directories but treats
94
+ # special files (FIFOs/sockets/devices) as regular files — acceptable
95
+ # because a generator source tree won't contain them.
96
+ def file?(fpath) = !@file_stat.directory?(fpath) && @file_stat.exists?(fpath)
97
+
78
98
  def stale_row(mentry, path, reason)
79
99
  {
80
100
  "key" => mentry.key,
@@ -6,8 +6,10 @@ module Textus
6
6
  # Reports TTL-exceeded staleness for intake-handler entries. Returns an
7
7
  # Array of row hashes (possibly empty) per entry.
8
8
  class IntakeCheck
9
- def initialize(manifest:)
10
- @manifest = manifest
9
+ def initialize(manifest:, file_stat:, clock:)
10
+ @manifest = manifest
11
+ @file_stat = file_stat
12
+ @clock = clock
11
13
  end
12
14
 
13
15
  def rows_for(mentry)
@@ -17,19 +19,25 @@ module Textus
17
19
  return [] unless ttl
18
20
 
19
21
  path = Textus::Key::Path.resolve(@manifest.data, mentry)
20
- return [row(mentry, path, "never refreshed")] unless File.exist?(path)
22
+ reason = ttl_reason(mentry, path, ttl)
23
+ reason ? [row(mentry, path, reason)] : []
24
+ end
25
+
26
+ private
21
27
 
22
- meta = Entry.for_format(mentry.format).parse(File.binread(path), path: path)["_meta"]
23
- last_str = meta["last_refreshed_at"]
24
- return [row(mentry, path, "never refreshed (no last_refreshed_at)")] if last_str.nil?
28
+ def ttl_reason(mentry, path, ttl)
29
+ return "never refreshed" unless @file_stat.exists?(path)
25
30
 
26
- last = parse_time(last_str)
27
- return [row(mentry, path, "ttl exceeded (#{ttl}s)")] if last.nil? || (Time.now - last) > ttl
31
+ last_str = last_refreshed_of(mentry, path)
32
+ return "never refreshed (no last_refreshed_at)" if last_str.nil?
28
33
 
29
- []
34
+ last = parse_time(last_str)
35
+ "ttl exceeded (#{ttl}s)" if last.nil? || (@clock.now - last) > ttl
30
36
  end
31
37
 
32
- private
38
+ def last_refreshed_of(mentry, path)
39
+ Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"]["last_refreshed_at"]
40
+ end
33
41
 
34
42
  def parse_time(str)
35
43
  Time.parse(str.to_s)
@@ -1,10 +1,10 @@
1
1
  module Textus
2
2
  module Domain
3
3
  class Staleness
4
- def initialize(manifest:)
4
+ def initialize(manifest:, file_stat:, clock:)
5
5
  @manifest = manifest
6
- @generator_check = GeneratorCheck.new(manifest: manifest)
7
- @intake_check = IntakeCheck.new(manifest: manifest)
6
+ @generator_check = GeneratorCheck.new(manifest: manifest, file_stat: file_stat)
7
+ @intake_check = IntakeCheck.new(manifest: manifest, file_stat: file_stat, clock: clock)
8
8
  end
9
9
 
10
10
  def call(prefix: nil, zone: nil)
@@ -1,6 +1,6 @@
1
1
  module Textus
2
- module Application
3
- module Envelope
2
+ class Envelope
3
+ module IO
4
4
  # Read-only counterpart to EnvelopeWriter. Resolves a key, reads the
5
5
  # bytes, parses them via the format strategy, and hands back an
6
6
  # Envelope. Used by Mv (pre-move inspection) and by EnvelopeWriter
@@ -1,8 +1,8 @@
1
1
  require "fileutils"
2
2
 
3
3
  module Textus
4
- module Application
5
- module Envelope
4
+ class Envelope
5
+ module IO
6
6
  # Owns the write pipeline (validate, serialize, etag-check, write, audit).
7
7
  # Talks to ports (FileStore, Schemas, AuditLog, Manifest) and an
8
8
  # Reader for the existing-uid lookup.
@@ -10,16 +10,16 @@ module Textus
10
10
  # Invariant: every public method's final action is @audit_log.append(...).
11
11
  #
12
12
  # No permission check, no event firing — those belong to the caller
13
- # (Application::Write::Put / ::Delete / ::Mv).
13
+ # (Write::Put / ::Delete / ::Mv).
14
14
  class Writer
15
15
  Payload = Data.define(:meta, :body, :content)
16
16
 
17
- def initialize(file_store:, manifest:, schemas:, audit_log:, ctx:, reader:)
17
+ def initialize(file_store:, manifest:, schemas:, audit_log:, call:, reader:)
18
18
  @file_store = file_store
19
19
  @manifest = manifest
20
20
  @schemas = schemas
21
21
  @audit_log = audit_log
22
- @ctx = ctx
22
+ @call = call
23
23
  @reader = reader
24
24
  end
25
25
 
@@ -56,9 +56,9 @@ module Textus
56
56
  meta: eff_meta, body: eff_body, etag: etag_after, content: eff_content
57
57
  )
58
58
  @audit_log.append(
59
- role: @ctx.role, verb: "put", key: key,
59
+ role: @call.role, verb: "put", key: key,
60
60
  etag_before: etag_before, etag_after: etag_after,
61
- extras: @ctx.correlation_id ? { "correlation_id" => @ctx.correlation_id } : nil
61
+ extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
62
62
  )
63
63
  envelope
64
64
  end
@@ -75,9 +75,9 @@ module Textus
75
75
 
76
76
  @file_store.delete(path)
77
77
  @audit_log.append(
78
- role: @ctx.role, verb: "delete", key: key,
78
+ role: @call.role, verb: "delete", key: key,
79
79
  etag_before: etag_before, etag_after: nil,
80
- extras: @ctx.correlation_id ? { "correlation_id" => @ctx.correlation_id } : nil
80
+ extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
81
81
  )
82
82
  end
83
83
 
@@ -108,10 +108,10 @@ module Textus
108
108
  "from_path" => from_path, "to_path" => to_path,
109
109
  "uid" => envelope.uid
110
110
  }
111
- extras["correlation_id"] = @ctx.correlation_id if @ctx.correlation_id
111
+ extras["correlation_id"] = @call.correlation_id if @call.correlation_id
112
112
 
113
113
  @audit_log.append(
114
- role: @ctx.role, verb: "mv", key: to_key,
114
+ role: @call.role, verb: "mv", key: to_key,
115
115
  etag_before: etag_before, etag_after: etag_after,
116
116
  extras: extras
117
117
  )
@@ -3,31 +3,48 @@
3
3
  module Textus
4
4
  module Hooks
5
5
  # A narrow handle passed to user hooks in place of the raw Store.
6
- # All writes route back through the Session so authorization, audit
6
+ # All writes route back through the RoleScope so authorization, audit
7
7
  # logging, and schema validation always fire.
8
8
  class Context
9
9
  attr_reader :role, :correlation_id
10
10
 
11
- def initialize(session:)
12
- @session = session
13
- @role = session.ctx.role
14
- @correlation_id = session.ctx.correlation_id
11
+ def self.for(container:, call:)
12
+ scope = Textus::RoleScope.new(
13
+ container: container,
14
+ role: call.role,
15
+ correlation_id: call.correlation_id,
16
+ dry_run: call.dry_run,
17
+ )
18
+ new(scope: scope)
19
+ end
20
+
21
+ def initialize(scope:)
22
+ @scope = scope
23
+ @role = scope.role
24
+ @correlation_id = scope.correlation_id
25
+ end
26
+
27
+ def backend
28
+ @scope
15
29
  end
16
30
 
17
31
  # read
18
- def get(key) = @session.get(key)
19
- def list(**) = @session.list(**)
20
- def deps(key) = @session.deps(key)
21
- def freshness(key) = @session.freshness(key)
32
+ def get(key) = @scope.get(key)
33
+ def list(**) = @scope.list(**)
34
+ def deps(key) = @scope.deps(key)
35
+ def freshness(key) = @scope.freshness(key)
22
36
 
23
37
  # write (authorized + audited)
24
- def put(key, **) = @session.put(key, **)
25
- def delete(key, **) = @session.delete(key, **)
26
- def audit(verb, key:, **) = @session.write_caps.audit_log.append(role: @role, verb: verb, key: key, **)
38
+ def put(key, **) = @scope.put(key, **)
39
+ def delete(key, **) = @scope.delete(key, **)
40
+
41
+ def audit(verb, key:, **)
42
+ @scope.container.audit_log.append(role: @role, verb: verb, key: key, **)
43
+ end
27
44
 
28
45
  # fan-out
29
46
  def publish_followup(event, **)
30
- @session.write_caps.events.publish(event, ctx: self, **)
47
+ @scope.container.events.publish(event, ctx: self, **)
31
48
  end
32
49
 
33
50
  def inspect
@@ -44,7 +44,7 @@ module Textus
44
44
  if declared.include?(:store)
45
45
  raise UsageError.new(
46
46
  "RPC callable for #{event} '#{name}' declares legacy `store:`; rename to `caps:` " \
47
- "(Textus::Application::ReadCaps / WriteCaps)",
47
+ "(Textus::Container)",
48
48
  )
49
49
  end
50
50
 
@@ -0,0 +1,36 @@
1
+ module Textus
2
+ module Maintenance
3
+ # Bulk-delete every leaf key under `prefix`.
4
+ class KeyDeletePrefix
5
+ def initialize(container:, call:)
6
+ @container = container
7
+ @call = call
8
+ end
9
+
10
+ def call(prefix:, dry_run: false)
11
+ raise UsageError.new("prefix required") if prefix.nil? || prefix.empty?
12
+
13
+ leaves = Read::List.new(container: @container)
14
+ .call(prefix: prefix)
15
+ .map { |r| r.is_a?(Hash) ? (r["key"] || r[:key]) : r }
16
+
17
+ warnings = leaves.empty? ? ["no keys under #{prefix}"] : []
18
+ steps = leaves.map { |k| { "op" => "delete", "key" => k } }
19
+
20
+ plan = Plan.new(steps: steps, warnings: warnings)
21
+ return plan if dry_run
22
+
23
+ steps.each do |s|
24
+ delete.call(s["key"])
25
+ end
26
+ plan
27
+ end
28
+
29
+ private
30
+
31
+ def delete
32
+ Write::Delete.new(container: @container, call: @call)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,46 @@
1
+ module Textus
2
+ module Maintenance
3
+ # Bulk-rename every leaf key under `from_prefix` to `to_prefix`.
4
+ # Calls Write::Mv directly for each entry — emits one audit row per file moved.
5
+ class KeyMvPrefix
6
+ def initialize(container:, call:)
7
+ @container = container
8
+ @call = call
9
+ end
10
+
11
+ def call(from_prefix:, to_prefix:, dry_run: false)
12
+ raise UsageError.new("from_prefix and to_prefix required") if from_prefix.nil? || to_prefix.nil?
13
+
14
+ leaves = list_leaves_under(from_prefix)
15
+ warnings = []
16
+ warnings << "no keys under #{from_prefix}" if leaves.empty?
17
+
18
+ steps = leaves.map do |old_key|
19
+ tail = old_key.delete_prefix("#{from_prefix}.")
20
+ new_key = "#{to_prefix}.#{tail}"
21
+ { "op" => "mv", "from" => old_key, "to" => new_key }
22
+ end
23
+
24
+ plan = Plan.new(steps: steps, warnings: warnings)
25
+ return plan if dry_run
26
+
27
+ steps.each do |s|
28
+ mv.call(s["from"], s["to"], dry_run: false)
29
+ end
30
+ plan
31
+ end
32
+
33
+ private
34
+
35
+ def list_leaves_under(prefix)
36
+ Read::List.new(container: @container)
37
+ .call(prefix: prefix)
38
+ .map { |row| row.is_a?(Hash) ? (row["key"] || row[:key]) : row }
39
+ end
40
+
41
+ def mv
42
+ Write::Mv.new(container: @container, call: @call)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,51 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ module Maintenance
5
+ # Loads a YAML migration plan and dispatches each op to the
6
+ # appropriate Maintenance use case. Concatenates resulting Plans.
7
+ class Migrate
8
+ def initialize(container:, call:)
9
+ @container = container
10
+ @call = call
11
+ end
12
+
13
+ def call(plan_yaml:, dry_run: false)
14
+ raw = YAML.safe_load(plan_yaml, permitted_classes: [Symbol], aliases: false)
15
+ raise UsageError.new("migration plan must be a YAML mapping") unless raw.is_a?(Hash)
16
+
17
+ ops = Array(raw["operations"])
18
+ all_steps = []
19
+ warnings = []
20
+
21
+ ops.each do |op_hash|
22
+ op_name = op_hash["op"]
23
+ sub_plan = invoke_op(op_name, op_hash, dry_run: dry_run)
24
+ all_steps.concat(sub_plan.steps)
25
+ warnings.concat(sub_plan.warnings)
26
+ end
27
+
28
+ Plan.new(steps: all_steps, warnings: warnings)
29
+ end
30
+
31
+ private
32
+
33
+ def invoke_op(op_name, op_hash, dry_run:)
34
+ kwargs = op_hash.except("op").transform_keys(&:to_sym).merge(dry_run: dry_run)
35
+ klass = op_class(op_name)
36
+ klass.new(
37
+ container: @container, call: @call,
38
+ ).call(**kwargs)
39
+ end
40
+
41
+ def op_class(op_name)
42
+ case op_name
43
+ when "key_mv_prefix" then KeyMvPrefix
44
+ when "key_delete_prefix" then KeyDeletePrefix
45
+ when "zone_mv" then ZoneMv
46
+ else raise UsageError.new("unknown op: #{op_name}")
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end