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
@@ -0,0 +1,67 @@
1
+ require "json"
2
+ require "digest"
3
+ require "fileutils"
4
+
5
+ module Textus
6
+ module Ports
7
+ # Persistence adapter for sentinel files. Owns the on-disk JSON shape, the
8
+ # path layout (<store_root>/sentinels/<target-rel-to-repo>.textus-managed.json),
9
+ # and all File/FileUtils I/O. Domain::Sentinel is a pure value object that
10
+ # depends on this port for reads and writes.
11
+ class SentinelStore
12
+ SUFFIX = ".textus-managed.json".freeze
13
+ DIR = "sentinels".freeze
14
+
15
+ def write!(target:, source:, store_root:)
16
+ path = sentinel_path(target, store_root)
17
+ FileUtils.mkdir_p(File.dirname(path))
18
+ repo_root = File.dirname(store_root)
19
+ File.write(path, JSON.generate(
20
+ "source" => rel_or_abs(source, repo_root),
21
+ "target" => rel_or_abs(target, repo_root),
22
+ "sha256" => Digest::SHA256.hexdigest(File.binread(target)),
23
+ "mode" => "copy",
24
+ ))
25
+ end
26
+
27
+ def load(path, repo_root)
28
+ raw = JSON.parse(File.read(path))
29
+ Textus::Domain::Sentinel.new(
30
+ target: absolutize(raw["target"], repo_root),
31
+ source: absolutize(raw["source"], repo_root),
32
+ sha256: raw["sha256"],
33
+ mode: raw["mode"],
34
+ )
35
+ rescue JSON::ParserError, Errno::ENOENT
36
+ nil
37
+ end
38
+
39
+ def sentinel_path(target, store_root)
40
+ repo_root = File.dirname(store_root)
41
+ rel = relative_to(target, repo_root) || File.basename(target)
42
+ File.join(store_root, DIR, rel + SUFFIX)
43
+ end
44
+
45
+ private
46
+
47
+ def rel_or_abs(path, repo_root)
48
+ relative_to(path, repo_root) || File.expand_path(path)
49
+ end
50
+
51
+ def relative_to(path, repo_root)
52
+ path = File.expand_path(path)
53
+ base = File.expand_path(repo_root)
54
+ return nil unless path.start_with?(base + File::SEPARATOR)
55
+
56
+ path[(base.length + 1)..]
57
+ end
58
+
59
+ def absolutize(path, repo_root)
60
+ return path if path.nil?
61
+ return path if File.absolute_path?(path)
62
+
63
+ File.expand_path(path, repo_root)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,19 @@
1
+ module Textus
2
+ module Ports
3
+ module Storage
4
+ # Read-only filesystem query port. The narrow interface that pure
5
+ # domain logic (staleness checks, sentinel value) depends on, so the
6
+ # domain never touches File/Dir directly. FileStore owns the write side.
7
+ class FileStat
8
+ def exists?(path) = File.exist?(path)
9
+ def directory?(path) = File.directory?(path)
10
+ def read(path) = File.binread(path)
11
+ def mtime(path) = File.mtime(path)
12
+
13
+ # Ruby 3.3+ guarantees Dir.glob returns a sorted Array; no explicit sort
14
+ # needed, but callers can rely on ordered results for stable behaviour.
15
+ def glob(pattern) = Dir.glob(pattern)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -1,7 +1,7 @@
1
1
  require "fileutils"
2
2
 
3
3
  module Textus
4
- module Infra
4
+ module Ports
5
5
  module Storage
6
6
  # Pure filesystem I/O port. Wraps File/FileUtils/Etag with no knowledge
7
7
  # of envelopes, entries, schemas, or audit.
@@ -0,0 +1,91 @@
1
+ require "time"
2
+ require "timeout"
3
+
4
+ module Textus
5
+ class Projection
6
+ MAX_LIMIT = 1000
7
+ REDUCER_TIMEOUT_SECONDS = 2
8
+
9
+ # `reader` — a callable `->(key) { envelope_or_nil }`. Caller picks
10
+ # semantics: pure read (`ops.get`) for materialization paths;
11
+ # `ops.get_or_refresh` if you want refresh-on-stale.
12
+ # `lister` — a callable `->(prefix:) { [ { "key" => ... }, ... ] }`.
13
+ # `rpc` — a `Hooks::RpcRegistry` used to dispatch `transform_rows` callables.
14
+ # `transform_context` — capability object handed to transform reducers as `caps:`.
15
+ def initialize(reader:, spec:, lister:, rpc:, transform_context:)
16
+ @reader = reader
17
+ @spec = spec || {}
18
+ @lister = lister
19
+ @rpc = rpc
20
+ @transform_context = transform_context
21
+ @limit = (@spec["limit"] || MAX_LIMIT).to_i
22
+ raise InvalidProjection.new("limit #{@limit} exceeds max #{MAX_LIMIT}") if @limit > MAX_LIMIT
23
+ end
24
+
25
+ def run
26
+ keys = collect_keys
27
+ explicit_pluck = !@spec["pluck"].nil? && @spec["pluck"] != "*"
28
+ rows = keys.map do |key|
29
+ env = @reader.call(key)
30
+ row = pluck(env.meta, env.body)
31
+ explicit_pluck ? row : row.merge("_key" => key)
32
+ end
33
+ reduced = apply_reducer(rows)
34
+ # Reducers may return either an Array of rows (legacy / templated builds)
35
+ # or a Hash that becomes the structured-format payload base. In the Hash
36
+ # case, downstream sort/limit/position markers don't apply, and the
37
+ # builder owns `_meta.generated_at` so we don't stamp it here.
38
+ return reduced if reduced.is_a?(Hash)
39
+
40
+ rows = reduced
41
+ rows = sort(rows)
42
+ rows = rows.first(@limit)
43
+ mark_positions(rows)
44
+ { "entries" => rows, "count" => rows.length, "generated_at" => Time.now.utc.iso8601 }
45
+ end
46
+
47
+ private
48
+
49
+ def apply_reducer(rows)
50
+ name = @spec["transform"] or return rows
51
+ Timeout.timeout(REDUCER_TIMEOUT_SECONDS) do
52
+ @rpc.invoke(:transform_rows, name,
53
+ caps: @transform_context,
54
+ rows: rows,
55
+ config: @spec["transform_config"] || {})
56
+ end
57
+ rescue Timeout::Error
58
+ raise UsageError.new("transform_rows '#{name}' exceeded #{REDUCER_TIMEOUT_SECONDS}s timeout")
59
+ end
60
+
61
+ def collect_keys
62
+ prefixes = Array(@spec["select"])
63
+ prefixes.flat_map { |p| @lister.call(prefix: p).map { |row| row["key"] } }.uniq
64
+ end
65
+
66
+ def pluck(frontmatter, _body)
67
+ fields = @spec["pluck"]
68
+ if fields.nil? || fields == "*"
69
+ frontmatter
70
+ else
71
+ Array(fields).each_with_object({}) { |f, h| h[f] = frontmatter[f] if frontmatter.key?(f) }
72
+ end
73
+ end
74
+
75
+ # Adds `_first`, `_last`, and `_index` markers so templates can emit
76
+ # delimiters (e.g. JSON commas) via {{^_last}},{{/_last}}.
77
+ def mark_positions(rows)
78
+ last_idx = rows.length - 1
79
+ rows.each_with_index do |row, i|
80
+ row["_index"] = i
81
+ row["_first"] = i.zero?
82
+ row["_last"] = (i == last_idx)
83
+ end
84
+ end
85
+
86
+ def sort(rows)
87
+ sb = @spec["sort_by"] or return rows
88
+ rows.sort_by { |r| r[sb].to_s }
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,111 @@
1
+ require "json"
2
+ require "time"
3
+
4
+ module Textus
5
+ module Read
6
+ # Queries .textus/audit.log. Filters: key, zone, role, verb, since,
7
+ # correlation_id, limit. Reads the log file as JSON-Lines (legacy TSV
8
+ # rows produce nil and are skipped).
9
+ class Audit
10
+ # Value object that carries all filter parameters for an audit query.
11
+ # `matches?` checks the manifest-independent predicates so the loop body
12
+ # only needs to handle the zone check (which requires manifest access).
13
+ Query = Data.define(:key, :zone, :role, :verb, :since, :seq_since, :correlation_id, :limit) do
14
+ # rubocop:disable Metrics/ParameterLists
15
+ def self.build(key: nil, zone: nil, role: nil, verb: nil,
16
+ since: nil, seq_since: nil, correlation_id: nil, limit: nil)
17
+ new(key:, zone:, role:, verb:, since:, seq_since:, correlation_id:, limit:)
18
+ end
19
+ # rubocop:enable Metrics/ParameterLists
20
+
21
+ def matches?(row)
22
+ return false if key && row["key"] != key
23
+ return false if role && row["role"] != role
24
+ return false if verb && row["verb"] != verb
25
+ return false if since && (row["ts"].nil? || Time.parse(row["ts"]) < since)
26
+ return false if seq_since && (row["seq"].nil? || row["seq"] <= seq_since)
27
+ return false if correlation_id && row.dig("extras", "correlation_id") != correlation_id
28
+
29
+ true
30
+ end
31
+ end
32
+
33
+ def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
34
+ @manifest = container.manifest
35
+ @root = container.root
36
+ @log_path = File.join(container.root, "audit.log")
37
+ @audit_log = container.audit_log
38
+ end
39
+
40
+ def call(**filters)
41
+ query = Query.build(**filters)
42
+ check_cursor_expiry!(query.seq_since)
43
+
44
+ files = all_log_files
45
+ return [] if files.empty?
46
+
47
+ rows = []
48
+ files.each do |file|
49
+ File.foreach(file) do |line|
50
+ parsed = parse_row(line.chomp)
51
+ next unless parsed
52
+ next unless query.matches?(parsed)
53
+ next if query.zone && !key_in_zone?(parsed["key"], query.zone)
54
+
55
+ rows << parsed
56
+ break if limit_reached?(rows, query)
57
+ end
58
+ break if limit_reached?(rows, query)
59
+ end
60
+ rows
61
+ end
62
+
63
+ # Accepts ISO8601 ("2026-01-15", "2026-01-15T10:00:00Z") or a relative
64
+ # offset matching /\A(\d+)([smhd])\z/. Returns nil for unparseable input.
65
+ def self.parse_since(str, now: Time.now.utc)
66
+ return nil if str.nil? || str.empty?
67
+ return Time.parse(str) if str =~ /\A\d{4}-\d{2}-\d{2}/
68
+
69
+ m = str.match(/\A(\d+)([smhd])\z/) or return nil
70
+ mult = { "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }[m[2]]
71
+ now - (m[1].to_i * mult)
72
+ end
73
+
74
+ private
75
+
76
+ def limit_reached?(rows, query) = query.limit && rows.length >= query.limit
77
+
78
+ def check_cursor_expiry!(seq_since)
79
+ return unless seq_since
80
+
81
+ log = @audit_log || Textus::Ports::AuditLog.new(@root)
82
+ min = log.min_available_seq
83
+ raise Textus::CursorExpired.new(requested: seq_since, min_available: min) if min && seq_since < min - 1
84
+ end
85
+
86
+ def all_log_files
87
+ rotated = Dir.glob(File.join(@root, "audit.log.*"))
88
+ .reject { |p| p.end_with?(".meta.json") }
89
+ .sort_by { |p| -p.scan(/\d+$/).first.to_i } # .5 .4 .3 .2 .1 → oldest first
90
+ active = File.exist?(@log_path) ? [@log_path] : []
91
+ rotated + active
92
+ end
93
+
94
+ def parse_row(line)
95
+ return nil if line.empty?
96
+ return nil unless line.start_with?("{")
97
+
98
+ JSON.parse(line)
99
+ rescue JSON::ParserError
100
+ nil
101
+ end
102
+
103
+ def key_in_zone?(key, zone)
104
+ mentry = @manifest.resolver.resolve(key).entry
105
+ mentry && mentry.zone == zone
106
+ rescue Textus::Error
107
+ false
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,81 @@
1
+ require "open3"
2
+
3
+ module Textus
4
+ module Read
5
+ # For one key, joins every audit-log row with the git commit (sha,
6
+ # author, date, subject) that introduced the file state at that audit
7
+ # row. Falls back to `git => nil` when not in a git repo or when the
8
+ # file is untracked.
9
+ class Blame
10
+ def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
11
+ @container = container
12
+ @manifest = container.manifest
13
+ @root = container.root
14
+ end
15
+
16
+ def call(key:, limit: nil)
17
+ audit_rows = Textus::Read::Audit.new(container: @container).call(key: key, limit: limit)
18
+ path = resolve_path(key)
19
+ return audit_rows.map { |r| r.merge("git" => nil) } unless git_tracked?(path)
20
+
21
+ audit_rows.map { |r| r.merge("git" => git_commit_at(path, timestamp: r["ts"])) }
22
+ end
23
+
24
+ private
25
+
26
+ def resolve_path(key)
27
+ res = @manifest.resolver.resolve(key)
28
+ mentry = res.entry
29
+ path = res.path
30
+ # Nested entries resolve to a file under the entry path; leaf entries
31
+ # already have a fully-resolved path. Either way `path` is what git
32
+ # needs to know about.
33
+ path || Textus::Key::Path.resolve(@manifest.data, mentry)
34
+ rescue Textus::Error
35
+ nil
36
+ end
37
+
38
+ def git_tracked?(path)
39
+ return false if path.nil?
40
+ return false unless File.exist?(path)
41
+ return false unless git_repo?
42
+
43
+ _out, _err, status = Open3.capture3(
44
+ "git", "ls-files", "--error-unmatch", path,
45
+ chdir: @root
46
+ )
47
+ status.success?
48
+ rescue Errno::ENOENT
49
+ false
50
+ end
51
+
52
+ def git_repo?
53
+ # Walk up from store root to find a .git directory.
54
+ dir = @root
55
+ loop do
56
+ return true if File.directory?(File.join(dir, ".git"))
57
+
58
+ parent = File.dirname(dir)
59
+ return false if parent == dir
60
+
61
+ dir = parent
62
+ end
63
+ end
64
+
65
+ def git_commit_at(path, timestamp:)
66
+ args = ["git", "log", "-1"]
67
+ args << "--before=#{timestamp}" if timestamp
68
+ args += ["--format=%H%x09%an%x09%aI%x09%s", "--", path]
69
+ out, _err, status = Open3.capture3(*args, chdir: @root)
70
+ return nil unless status.success?
71
+
72
+ sha, author, date, subject = out.strip.split("\t", 4)
73
+ return nil if sha.nil? || sha.empty?
74
+
75
+ { "sha" => sha, "author" => author, "date" => date, "subject" => subject }
76
+ rescue Errno::ENOENT
77
+ nil
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,18 @@
1
+ module Textus
2
+ module Read
3
+ # Dispatched use case for the `boot` verb. The orientation envelope is
4
+ # built by the Textus::Boot library module; this class is the uniform
5
+ # (container:, call:) entry point that Dispatcher::VERBS resolves to.
6
+ # Boot is role-independent, so `call` is not consulted.
7
+ class Boot
8
+ def initialize(container:, call:)
9
+ @container = container
10
+ @call = call
11
+ end
12
+
13
+ def call
14
+ Textus::Boot.build(container: @container)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,24 @@
1
+ module Textus
2
+ module Read
3
+ class Deps
4
+ def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
5
+ @manifest = container.manifest
6
+ end
7
+
8
+ def call(key)
9
+ entry = @manifest.data.entries.find { |e| e.key == key } or return []
10
+ return [] unless entry.is_a?(Textus::Manifest::Entry::Derived)
11
+
12
+ src = entry.source
13
+ result = if src.is_a?(Textus::Manifest::Entry::Derived::Projection)
14
+ Array(src.select).compact
15
+ elsif src.is_a?(Textus::Manifest::Entry::Derived::External)
16
+ Array(src.sources).compact
17
+ else
18
+ []
19
+ end
20
+ result.uniq
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,19 @@
1
+ module Textus
2
+ module Read
3
+ # Dispatched use case for the `doctor` verb. The health-check report is
4
+ # built by the Textus::Doctor library module; this class is the uniform
5
+ # (container:, call:) entry point that Dispatcher::VERBS resolves to.
6
+ # The acting role is irrelevant to a read-only health check, so `call`
7
+ # is not consulted.
8
+ class Doctor
9
+ def initialize(container:, call:)
10
+ @container = container
11
+ @call = call
12
+ end
13
+
14
+ def call(checks: nil)
15
+ Textus::Doctor.build(container: @container, checks: checks)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,101 @@
1
+ require "time"
2
+
3
+ module Textus
4
+ module Read
5
+ # Per-entry freshness report. Walks every entry declared in the manifest,
6
+ # consults `rules_for(key)` for a refresh rule, and reports the
7
+ # current status. Status is one of :fresh, :stale, :never_refreshed, or
8
+ # :no_policy.
9
+ class Freshness
10
+ def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator)
11
+ @container = container
12
+ @call = call
13
+ @manifest = container.manifest
14
+ @file_store = container.file_store
15
+ @evaluator = evaluator
16
+ @cache = {}
17
+ end
18
+
19
+ # Returns the soonest `next_due_at` across all entries with a refresh
20
+ # policy, as an ISO-8601 string, or nil if none.
21
+ def soonest_due(prefix: nil, zone: nil)
22
+ times = call(prefix: prefix, zone: zone)
23
+ .map { |r| r[:next_due_at] }
24
+ .compact
25
+ .map { |t| Time.parse(t) }
26
+ return nil if times.empty?
27
+
28
+ times.min.utc.iso8601
29
+ end
30
+
31
+ def call(prefix: nil, zone: nil)
32
+ rows = []
33
+ @manifest.data.entries.each do |mentry|
34
+ next if prefix && !mentry.key.start_with?(prefix)
35
+ next if zone && mentry.zone != zone
36
+
37
+ rows << row_for(mentry)
38
+ end
39
+ rows
40
+ end
41
+
42
+ private
43
+
44
+ def row_for(mentry)
45
+ set = @manifest.rules.for(mentry.key)
46
+ refresh = set.refresh
47
+ envelope = safe_get(mentry.key)
48
+ last = envelope&.meta&.dig("last_refreshed_at")
49
+
50
+ return base_row(mentry, last).merge(status: :no_policy) if refresh.nil?
51
+
52
+ fp = refresh.to_freshness_policy
53
+ cache_key = [mentry.key, last]
54
+ verdict = (@cache[cache_key] ||= @evaluator.call(fp, envelope, now: @call.now))
55
+ status = if verdict.fresh? then :fresh
56
+ elsif last.nil? then :never_refreshed
57
+ else :stale
58
+ end
59
+
60
+ base_row(mentry, last).merge(
61
+ ttl_seconds: fp.ttl_seconds,
62
+ on_stale: fp.on_stale,
63
+ status: status,
64
+ next_due_at: next_due(last, fp.ttl_seconds),
65
+ )
66
+ end
67
+
68
+ def base_row(mentry, last)
69
+ {
70
+ key: mentry.key,
71
+ zone: mentry.zone,
72
+ last_refreshed_at: last,
73
+ age_seconds: last ? (@call.now - Time.parse(last)).to_i : nil,
74
+ }
75
+ end
76
+
77
+ # Returns the raw envelope or nil. Nested entries (mentry.key is a
78
+ # prefix, not a leaf) and missing files both resolve to nil.
79
+ def safe_get(key)
80
+ res = @manifest.resolver.resolve(key)
81
+ return nil unless @file_store.exists?(res.path)
82
+
83
+ raw = @file_store.read(res.path)
84
+ parsed = Entry.for_format(res.entry.format).parse(raw, path: res.path)
85
+ Textus::Envelope.build(
86
+ key: key, mentry: res.entry, path: res.path,
87
+ meta: parsed["_meta"], body: parsed["body"],
88
+ etag: Etag.for_bytes(raw), content: parsed["content"]
89
+ )
90
+ rescue Textus::Error
91
+ nil
92
+ end
93
+
94
+ def next_due(last, ttl)
95
+ return nil if last.nil? || ttl.nil?
96
+
97
+ (Time.parse(last) + ttl).utc.iso8601
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,66 @@
1
+ module Textus
2
+ module Read
3
+ # Pure read: returns the on-disk envelope annotated with a freshness
4
+ # verdict. Never triggers refresh; never invokes the orchestrator.
5
+ #
6
+ # For interactive reads that want refresh-on-stale, use
7
+ # `Read::GetOrRefresh`, which composes this with the orchestrator.
8
+ class Get
9
+ def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator)
10
+ @container = container
11
+ @call = call
12
+ @manifest = container.manifest
13
+ @file_store = container.file_store
14
+ @evaluator = evaluator
15
+ end
16
+
17
+ def call(key)
18
+ envelope = read_raw_envelope(key)
19
+ return nil if envelope.nil?
20
+
21
+ policy_set = @manifest.rules.for(key)
22
+ refresh_policy = policy_set.refresh
23
+ return annotate_fresh(envelope) if refresh_policy.nil?
24
+
25
+ policy = refresh_policy.to_freshness_policy
26
+ verdict = @evaluator.call(policy, envelope, now: @call.now)
27
+
28
+ envelope.with(freshness: Textus::Domain::Freshness.build(
29
+ stale: verdict.stale?,
30
+ reason: verdict.reason,
31
+ refreshing: false,
32
+ ))
33
+ end
34
+
35
+ # Strict variant: raises UnknownKey when the entry is missing.
36
+ # Used by consumers (e.g. Validator) that need to distinguish absence
37
+ # from emptiness.
38
+ def get(key)
39
+ call(key) || raise(UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)))
40
+ end
41
+
42
+ private
43
+
44
+ def read_raw_envelope(key)
45
+ res = @manifest.resolver.resolve(key)
46
+ mentry = res.entry
47
+ path = res.path
48
+ return nil unless @file_store.exists?(path)
49
+
50
+ raw = @file_store.read(path)
51
+ parsed = Entry.for_format(mentry.format).parse(raw, path: path)
52
+ Textus::Envelope.build(
53
+ key: key, mentry: mentry, path: path,
54
+ meta: parsed["_meta"], body: parsed["body"],
55
+ etag: Etag.for_bytes(raw), content: parsed["content"]
56
+ )
57
+ end
58
+
59
+ def annotate_fresh(envelope)
60
+ envelope.with(freshness: Textus::Domain::Freshness.build(
61
+ stale: false, reason: nil, refreshing: false,
62
+ ))
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,69 @@
1
+ module Textus
2
+ module Read
3
+ # Composes pure `Read::Get` with the refresh orchestrator: runs Get
4
+ # to obtain the envelope and freshness verdict, then if the verdict
5
+ # is stale and the rule's `on_stale` policy demands action, hands
6
+ # off to the orchestrator. Use for interactive reads where the
7
+ # caller wants the freshest obtainable envelope.
8
+ #
9
+ # Pure reads (build, projection, schema tooling) should use
10
+ # `Read::Get` directly; it has no orchestrator dependency.
11
+ class GetOrRefresh
12
+ def initialize(container:, call:, get: nil, orchestrator: nil)
13
+ @container = container
14
+ @call = call
15
+ @manifest = container.manifest
16
+ @get = get || Read::Get.new(container: container, call: call)
17
+ @orchestrator = orchestrator || build_orchestrator
18
+ end
19
+
20
+ private
21
+
22
+ def hook_context
23
+ @hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
24
+ end
25
+
26
+ def build_orchestrator
27
+ worker = Textus::Write::RefreshWorker.new(
28
+ container: @container, call: @call,
29
+ )
30
+ Textus::Write::RefreshOrchestrator.new(
31
+ worker: worker, store_root: @container.root, events: @container.events,
32
+ hook_context: hook_context
33
+ )
34
+ end
35
+
36
+ public
37
+
38
+ def call(key)
39
+ envelope = @get.call(key)
40
+ return nil if envelope.nil?
41
+ return envelope unless envelope.freshness&.stale
42
+
43
+ policy_set = @manifest.rules.for(key)
44
+ refresh_policy = policy_set.refresh
45
+ return envelope if refresh_policy.nil?
46
+
47
+ policy = refresh_policy.to_freshness_policy
48
+ verdict = Textus::Domain::Freshness::Verdict.stale(envelope.freshness.reason)
49
+ action = policy.decide(verdict)
50
+ outcome = @orchestrator.execute(action, key: key)
51
+
52
+ case outcome
53
+ when Textus::Domain::Outcome::Skipped
54
+ envelope
55
+ when Textus::Domain::Outcome::Refreshed
56
+ outcome.envelope.with(
57
+ freshness: Textus::Domain::Freshness.build(stale: false, reason: nil, refreshing: false),
58
+ )
59
+ when Textus::Domain::Outcome::Detached
60
+ envelope.with(freshness: envelope.freshness.with(refreshing: true))
61
+ when Textus::Domain::Outcome::Failed
62
+ envelope.with(
63
+ freshness: envelope.freshness.with(refresh_error: outcome.error.message),
64
+ )
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end