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
@@ -5,78 +5,6 @@ module Textus
5
5
  # constants on Entry. Canonical source is the PublishEach validator.
6
6
  PUBLISH_EACH_VARS = Validators::PublishEach::KNOWN_VARS
7
7
  PUBLISH_EACH_VAR_RE = Validators::PublishEach::VAR_RE
8
-
9
- attr_reader :raw, :key, :path, :zone, :schema, :owner, :nested,
10
- :template, :publish_to, :publish_each,
11
- :events, :inject_intro, :index_filename, :format,
12
- :compute, :projection, :generator,
13
- :intake_handler, :intake_config
14
-
15
- # rubocop:disable Metrics/ParameterLists
16
- def initialize(manifest:, raw:, key:, path:, zone:, schema:, owner:, nested:,
17
- template:, publish_to:, publish_each:, events:, inject_intro:,
18
- index_filename:, format:, compute:, projection:, generator:,
19
- intake_handler:, intake_config:)
20
- @manifest = manifest
21
- @raw = raw
22
- @key = key
23
- @path = path
24
- @zone = zone
25
- @schema = schema
26
- @owner = owner
27
- @nested = nested
28
- @template = template
29
- @publish_to = publish_to
30
- @publish_each = publish_each
31
- @events = events
32
- @inject_intro = inject_intro
33
- @index_filename = index_filename
34
- @format = format
35
- @compute = compute
36
- @projection = projection
37
- @generator = generator
38
- @intake_handler = intake_handler
39
- @intake_config = intake_config
40
- end
41
- # rubocop:enable Metrics/ParameterLists
42
-
43
- # Resolves the per-leaf target path (relative to repo root) for a full
44
- # dotted key under this entry's prefix. Returns nil if this entry has no
45
- # publish_each template.
46
- def publish_target_for(full_key)
47
- return nil if @publish_each.nil?
48
-
49
- entry_segs = @key.split(".")
50
- key_segs = full_key.split(".")
51
- raise UsageError.new("key '#{full_key}' is not under entry '#{@key}'") unless key_segs[0, entry_segs.length] == entry_segs
52
-
53
- remaining = key_segs[entry_segs.length..] || []
54
- leaf = remaining.join("/")
55
- basename = remaining.last || ""
56
- ext = Textus::Entry.for_format(@format).extensions.first.to_s.sub(/^\./, "")
57
-
58
- vars = { "leaf" => leaf, "basename" => basename, "key" => full_key, "ext" => ext }
59
- @publish_each.gsub(PUBLISH_EACH_VAR_RE) { vars.fetch(::Regexp.last_match(1)) }
60
- end
61
-
62
- # Signal-based zone-kind predicates: derive the "kind" of a zone from its
63
- # write_policy signals rather than its literal name, so detection keeps
64
- # working when users rename the default zones.
65
- def in_generator_zone?
66
- zone_writers.include?("builder")
67
- end
68
-
69
- def in_proposal_zone?
70
- zone_writers.include?("agent")
71
- end
72
-
73
- private
74
-
75
- def zone_writers
76
- @manifest.zone_writers(@zone)
77
- rescue UsageError => e
78
- raise UsageError.new("entry '#{@key}': #{e.message}")
79
- end
80
8
  end
81
9
  end
82
10
  end
@@ -0,0 +1,5 @@
1
+ module Textus
2
+ class Manifest
3
+ Resolution = Data.define(:entry, :path, :remaining)
4
+ end
5
+ end
@@ -0,0 +1,109 @@
1
+ module Textus
2
+ class Manifest
3
+ class Resolver
4
+ def initialize(manifest)
5
+ @manifest = manifest
6
+ end
7
+
8
+ def resolve(key)
9
+ @manifest.validate_key!(key)
10
+ segments = key.split(".")
11
+ candidates = @manifest.entries
12
+ .map { |e| [e, e.key.split(".")] }
13
+ .select { |(_, esegs)| esegs == segments[0, esegs.length] }
14
+ .sort_by { |(_, esegs)| -esegs.length }
15
+ raise UnknownKey.new(key, suggestions: suggestions_for(key)) if candidates.empty?
16
+
17
+ entry, esegs = candidates.first
18
+ remaining = segments[esegs.length..]
19
+ build_resolution(entry, remaining, key)
20
+ end
21
+
22
+ def suggestions_for(key)
23
+ candidates = enumerate.map { |r| r[:key] }
24
+ candidates.concat(@manifest.entries.reject { |e| nested_entry?(e) }.map(&:key))
25
+ candidates.uniq!
26
+ Key::Distance.suggest(key, candidates, limit: 5)
27
+ rescue StandardError
28
+ []
29
+ end
30
+
31
+ def enumerate(prefix: nil)
32
+ out = @manifest.entries.flat_map { |entry| nested_entry?(entry) ? enumerate_nested(entry) : enumerate_leaf(entry) }
33
+ out.select! { |row| row[:key] == prefix || row[:key].start_with?("#{prefix}.") } if prefix
34
+ out.sort_by { |row| row[:key] }
35
+ end
36
+
37
+ private
38
+
39
+ # Returns true for entries that behave as nested (Nested subclass, or any
40
+ # entry with nested: true in the raw YAML — e.g. Intake entries covering
41
+ # a directory of leaf files).
42
+ def nested_entry?(entry)
43
+ entry.is_a?(Textus::Manifest::Entry::Nested) || entry.raw["nested"] == true
44
+ end
45
+
46
+ def build_resolution(entry, remaining, key)
47
+ if remaining.empty?
48
+ Resolution.new(entry: entry, path: resolve_leaf_path(entry), remaining: [])
49
+ else
50
+ raise UnknownKey.new(key, suggestions: suggestions_for(key)) unless nested_entry?(entry)
51
+
52
+ index_fn = entry.respond_to?(:index_filename) ? entry.index_filename : nil
53
+ path = if index_fn
54
+ File.join(@manifest.root, "zones", entry.path, *remaining, index_fn)
55
+ else
56
+ primary_ext = Textus::Entry.for_format(entry.format).extensions.first
57
+ File.join(@manifest.root, "zones", entry.path, *remaining) + primary_ext
58
+ end
59
+ Resolution.new(entry: entry, path: path, remaining: remaining)
60
+ end
61
+ end
62
+
63
+ def enumerate_leaf(entry)
64
+ fp = resolve_leaf_path(entry)
65
+ File.exist?(fp) ? [{ key: entry.key, path: fp, manifest_entry: entry }] : []
66
+ end
67
+
68
+ def enumerate_nested(entry)
69
+ base = File.join(@manifest.root, "zones", entry.path)
70
+ return [] unless File.directory?(base)
71
+
72
+ entry_index_filename = entry.respond_to?(:index_filename) ? entry.index_filename : nil
73
+ glob_pattern = entry_index_filename ? "**/#{entry_index_filename}" : nested_glob(entry.format)
74
+ Dir.glob(File.join(base, glob_pattern)).filter_map { |path| nested_row_for(entry, base, path) }
75
+ end
76
+
77
+ def nested_row_for(entry, base, path)
78
+ rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
79
+ entry_if = entry.respond_to?(:index_filename) ? entry.index_filename : nil
80
+ stripped = entry_if ? File.dirname(rel) : rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
81
+ segs = stripped.split("/").reject { |s| s.empty? || s == "." }
82
+ return nil if segs.empty?
83
+
84
+ illegal = segs.find { |s| !valid_segment?(s) }
85
+ if illegal
86
+ warn("textus: skipping illegal key segment '#{illegal}' at #{path} — run 'textus key normalize --dry-run'")
87
+ return nil
88
+ end
89
+
90
+ { key: (entry.key.split(".") + segs).join("."), path: path, manifest_entry: entry }
91
+ end
92
+
93
+ def valid_segment?(seg)
94
+ return false if seg.nil? || seg.empty?
95
+ return false if seg.length > Key::Grammar::MAX_SEGMENT_LEN
96
+
97
+ seg.match?(Key::Grammar::SEGMENT)
98
+ end
99
+
100
+ def resolve_leaf_path(entry)
101
+ Textus::Key::Path.resolve(@manifest, entry)
102
+ end
103
+
104
+ def nested_glob(format)
105
+ Textus::Entry.for_format(format).nested_glob
106
+ end
107
+ end
108
+ end
109
+ end
@@ -4,7 +4,7 @@ module Textus
4
4
  ROOT_KEYS = %w[version zones entries rules].freeze
5
5
  ZONE_KEYS = %w[name write_policy read_policy].freeze
6
6
  ENTRY_KEYS = %w[
7
- key path zone schema owner nested format
7
+ key path zone kind schema owner nested format
8
8
  compute template publish_to publish_each
9
9
  intake events inject_intro index_filename
10
10
  ].freeze
@@ -1,17 +1,10 @@
1
1
  require "yaml"
2
2
  require_relative "manifest/schema"
3
+ require_relative "manifest/resolution"
4
+ require_relative "manifest/resolver"
3
5
 
4
6
  module Textus
5
7
  class Manifest
6
- TEXTUS_2_HINT = "Install textus 0.11.x to run the migrator, then upgrade to this version. " \
7
- "See https://github.com/patrick204nqh/textus/blob/main/CHANGELOG.md#0110".freeze
8
-
9
- def self.version_hint_for(version)
10
- version == "textus/2" ? TEXTUS_2_HINT : nil
11
- end
12
-
13
- private_class_method :version_hint_for
14
-
15
8
  attr_reader :root, :entries, :raw
16
9
 
17
10
  def zones
@@ -58,7 +51,6 @@ module Textus
58
51
  raise BadFrontmatter.new(
59
52
  source,
60
53
  "unsupported manifest version #{raw["version"].inspect}; expected #{PROTOCOL.inspect}",
61
- hint: version_hint_for(raw["version"]),
62
54
  )
63
55
  end
64
56
  private_class_method :check_version!
@@ -86,53 +78,8 @@ module Textus
86
78
  rules.for(key)
87
79
  end
88
80
 
89
- # Returns [Manifest::Entry, resolved_path, remaining_segments]
90
- def resolve(key)
91
- validate_key!(key)
92
- segments = key.split(".")
93
- # longest-prefix match
94
- candidates = @entries
95
- .map { |e| [e, e.key.split(".")] }
96
- .select { |(_, esegs)| esegs == segments[0, esegs.length] }
97
- .sort_by { |(_, esegs)| -esegs.length }
98
- raise UnknownKey.new(key, suggestions: suggestions_for(key)) if candidates.empty?
99
-
100
- entry, esegs = candidates.first
101
- remaining = segments[esegs.length..]
102
- if remaining.empty?
103
- path = resolve_leaf_path(entry)
104
- [entry, path, []]
105
- else
106
- raise UnknownKey.new(key, suggestions: suggestions_for(key)) unless entry.nested
107
-
108
- path = if entry.index_filename
109
- File.join(@root, "zones", entry.path, *remaining, entry.index_filename)
110
- else
111
- primary_ext = Textus::Entry.for_format(entry.format).extensions.first
112
- File.join(@root, "zones", entry.path, *remaining) + primary_ext
113
- end
114
- [entry, path, remaining]
115
- end
116
- end
117
-
118
- # Returns up to 5 dotted keys from the manifest that look similar to the
119
- # requested key, ranked by shared-prefix length then Levenshtein distance.
120
- def suggestions_for(key)
121
- candidates = enumerate.map { |r| r[:key] }
122
- # Include declared (non-nested) entry keys even if file is missing.
123
- candidates.concat(@entries.reject(&:nested).map(&:key))
124
- candidates.uniq!
125
- Key::Distance.suggest(key, candidates, limit: 5)
126
- rescue StandardError
127
- []
128
- end
129
-
130
- # Enumerate all entry files reachable through the manifest. Returns
131
- # [{ key:, path:, manifest_entry: }, ...]
132
- def enumerate(prefix: nil)
133
- out = @entries.flat_map { |entry| entry.nested ? enumerate_nested(entry) : enumerate_leaf(entry) }
134
- out.select! { |row| row[:key] == prefix || row[:key].start_with?("#{prefix}.") } if prefix
135
- out.sort_by { |row| row[:key] }
81
+ def resolver
82
+ @resolver ||= Resolver.new(self)
136
83
  end
137
84
 
138
85
  def validate_key!(key)
@@ -143,51 +90,8 @@ module Textus
143
90
 
144
91
  private
145
92
 
146
- def enumerate_leaf(entry)
147
- fp = resolve_leaf_path(entry)
148
- File.exist?(fp) ? [{ key: entry.key, path: fp, manifest_entry: entry }] : []
149
- end
150
-
151
- def enumerate_nested(entry)
152
- base = File.join(@root, "zones", entry.path)
153
- return [] unless File.directory?(base)
154
-
155
- glob_pattern = entry.index_filename ? "**/#{entry.index_filename}" : nested_glob(entry.format)
156
- Dir.glob(File.join(base, glob_pattern)).filter_map { |path| nested_row_for(entry, base, path) }
157
- end
158
-
159
- def nested_row_for(entry, base, path)
160
- rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
161
- stripped = entry.index_filename ? File.dirname(rel) : rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
162
- segs = stripped.split("/").reject { |s| s.empty? || s == "." }
163
- return nil if segs.empty?
164
-
165
- illegal = segs.find { |s| !valid_segment?(s) }
166
- if illegal
167
- warn("textus: skipping illegal key segment '#{illegal}' at #{path} — run 'textus key normalize --dry-run'")
168
- return nil
169
- end
170
-
171
- { key: (entry.key.split(".") + segs).join("."), path: path, manifest_entry: entry }
172
- end
173
-
174
- def valid_segment?(seg)
175
- return false if seg.nil? || seg.empty?
176
- return false if seg.length > Key::Grammar::MAX_SEGMENT_LEN
177
-
178
- seg.match?(Key::Grammar::SEGMENT)
179
- end
180
-
181
93
  def validate_declared_keys!
182
94
  @entries.each { |e| validate_key!(e.key) }
183
95
  end
184
-
185
- def resolve_leaf_path(entry)
186
- Textus::Key::Path.resolve(self, entry)
187
- end
188
-
189
- def nested_glob(format)
190
- Textus::Entry.for_format(format).nested_glob
191
- end
192
96
  end
193
97
  end
@@ -1,45 +1,169 @@
1
1
  module Textus
2
2
  # Single canonical entrypoint for invoking application use-cases against a
3
- # store. Mirrors the directory structure under `lib/textus/application/`:
3
+ # store. Public surface is flat one method per use case:
4
4
  #
5
5
  # ops = Textus::Operations.for(store, role: "agent")
6
- # ops.writes.put.call(key, body: "...")
7
- # ops.reads.get.call(key) # pure read
8
- # ops.reads.get_or_refresh.call(key) # read + refresh-on-stale
9
- # ops.refresh.worker.call(key)
10
- #
11
- # Replaces the prior `Textus::Composition` module (deleted in v0.12.2).
6
+ # ops.put(key, body: "...")
7
+ # ops.get(key) # pure read
8
+ # ops.get_or_refresh(key) # read + refresh-on-stale
9
+ # ops.refresh(key) # synchronous worker refresh
10
+ # ops.refresh_all(prefix: ..., zone: ...)
12
11
  class Operations
13
12
  def self.for(store, role: Role::DEFAULT, correlation_id: nil, dry_run: false)
14
- ctx = Application::Context.new(
13
+ new(
14
+ ctx: Application::Context.build(role: role, correlation_id: correlation_id, dry_run: dry_run),
15
+ manifest: store.manifest,
16
+ file_store: store.file_store,
17
+ schemas: store.schemas,
18
+ audit_log: store.audit_log,
19
+ bus: store.bus,
20
+ root: store.root,
15
21
  store: store,
16
- role: role,
17
- correlation_id: correlation_id,
18
- dry_run: dry_run,
19
22
  )
20
- new(ctx)
21
23
  end
22
24
 
23
- attr_reader :ctx
25
+ attr_reader :ctx, :store
24
26
 
25
- def initialize(ctx)
26
- @ctx = ctx
27
+ # rubocop:disable Metrics/ParameterLists
28
+ def initialize(ctx:, manifest:, file_store:, schemas:, audit_log:, bus:, root:, store:)
29
+ @ctx = ctx
30
+ @manifest = manifest
31
+ @file_store = file_store
32
+ @schemas = schemas
33
+ @audit_log = audit_log
34
+ @bus = bus
35
+ @root = root
36
+ @store = store
37
+ @authorizer = Textus::Domain::Authorizer.new(manifest: @manifest)
27
38
  end
39
+ # rubocop:enable Metrics/ParameterLists
28
40
 
29
- def writes
30
- @writes ||= Writes.new(@ctx)
41
+ def with_role(role)
42
+ self.class.new(
43
+ ctx: @ctx.with_role(role),
44
+ manifest: @manifest, file_store: @file_store, schemas: @schemas,
45
+ audit_log: @audit_log, bus: @bus,
46
+ root: @root, store: @store
47
+ )
31
48
  end
32
49
 
33
- def reads
34
- @reads ||= Reads.new(@ctx)
50
+ def hook_context
51
+ @hook_context ||= Textus::Hooks::Context.new(ops: self)
35
52
  end
36
53
 
37
- def refresh
38
- @refresh ||= Refresh.new(@ctx)
54
+ # writes
55
+ def put(...)
56
+ Application::Writes::Put.new(
57
+ ctx: @ctx, manifest: @manifest, envelope_io: envelope_io,
58
+ bus: @bus, authorizer: @authorizer, hook_context: hook_context
59
+ ).call(...)
39
60
  end
40
61
 
41
- def with_role(role)
42
- self.class.new(@ctx.with_role(role))
62
+ def delete(...)
63
+ Application::Writes::Delete.new(
64
+ ctx: @ctx, manifest: @manifest, envelope_io: envelope_io,
65
+ bus: @bus, authorizer: @authorizer, hook_context: hook_context
66
+ ).call(...)
67
+ end
68
+
69
+ def mv(...)
70
+ Application::Writes::Mv.new(
71
+ ctx: @ctx, manifest: @manifest, envelope_io: envelope_io,
72
+ bus: @bus, authorizer: @authorizer, hook_context: hook_context
73
+ ).call(...)
74
+ end
75
+
76
+ def accept(...)
77
+ Application::Writes::Accept.new(
78
+ ctx: @ctx, manifest: @manifest, file_store: @file_store, schemas: @schemas,
79
+ envelope_io: envelope_io, bus: @bus, authorizer: @authorizer, hook_context: hook_context
80
+ ).call(...)
81
+ end
82
+
83
+ def reject(...)
84
+ Application::Writes::Reject.new(
85
+ ctx: @ctx, manifest: @manifest, file_store: @file_store,
86
+ envelope_io: envelope_io, bus: @bus, authorizer: @authorizer, hook_context: hook_context
87
+ ).call(...)
88
+ end
89
+
90
+ def publish(...)
91
+ Application::Writes::Publish.new(
92
+ ctx: @ctx, manifest: @manifest, file_store: @file_store,
93
+ bus: @bus, root: @root, store: @store, hook_context: hook_context
94
+ ).call(...)
95
+ end
96
+
97
+ # reads
98
+ def get(...)
99
+ Application::Reads::Get.new(ctx: @ctx, manifest: @manifest, file_store: @file_store).call(...)
100
+ end
101
+
102
+ def get_or_refresh(...)
103
+ Application::Reads::GetOrRefresh.new(
104
+ manifest: @manifest,
105
+ get: Application::Reads::Get.new(ctx: @ctx, manifest: @manifest, file_store: @file_store),
106
+ orchestrator: orchestrator,
107
+ ).call(...)
108
+ end
109
+
110
+ def list(...) = Application::Reads::List.new(manifest: @manifest).call(...)
111
+ def where(...) = Application::Reads::Where.new(manifest: @manifest).call(...)
112
+ def uid(...) = Application::Reads::Uid.new(ctx: @ctx, manifest: @manifest, file_store: @file_store).call(...)
113
+ def schema_envelope(...) = Application::Reads::SchemaEnvelope.new(manifest: @manifest, schemas: @schemas).call(...)
114
+ def deps(...) = Application::Reads::Deps.new(manifest: @manifest).call(...)
115
+ def rdeps(...) = Application::Reads::Rdeps.new(manifest: @manifest).call(...)
116
+ def published(...) = Application::Reads::Published.new(manifest: @manifest).call(...)
117
+ def stale(...) = Application::Reads::Stale.new(manifest: @manifest).call(...)
118
+ def audit(...) = Application::Reads::Audit.new(manifest: @manifest, root: @root).call(...)
119
+ def blame(...) = Application::Reads::Blame.new(manifest: @manifest, root: @root).call(...)
120
+ def policy_explain(...) = Application::Reads::PolicyExplain.new(manifest: @manifest).call(...)
121
+ def freshness(...) = Application::Reads::Freshness.new(ctx: @ctx, manifest: @manifest, file_store: @file_store).call(...)
122
+
123
+ def validate_all(...)
124
+ Application::Reads::ValidateAll.new(
125
+ ctx: @ctx, manifest: @manifest, file_store: @file_store, schemas: @schemas, audit_log: @audit_log,
126
+ ).call(...)
127
+ end
128
+
129
+ # refresh
130
+ def refresh(key) = refresh_worker.run(key)
131
+
132
+ def refresh_all(**)
133
+ Application::Refresh::All.new(
134
+ ctx: @ctx, manifest: @manifest, envelope_io: envelope_io, bus: @bus,
135
+ store: @store, authorizer: @authorizer, hook_context: hook_context
136
+ ).call(**)
137
+ end
138
+
139
+ private
140
+
141
+ def envelope_io
142
+ @envelope_io ||= Application::Writes::EnvelopeIO.new(
143
+ file_store: @file_store,
144
+ manifest: @manifest,
145
+ schemas: @schemas,
146
+ audit_log: @audit_log,
147
+ ctx: @ctx,
148
+ )
149
+ end
150
+
151
+ def refresh_worker
152
+ @refresh_worker ||= Application::Refresh::Worker.new(
153
+ ctx: @ctx, manifest: @manifest, envelope_io: envelope_io, bus: @bus,
154
+ store: @store, authorizer: @authorizer, hook_context: hook_context
155
+ )
156
+ end
157
+
158
+ def orchestrator
159
+ @orchestrator ||= Application::Refresh::Orchestrator.new(
160
+ worker: refresh_worker,
161
+ store_root: @root,
162
+ bus: @bus,
163
+ store: @store,
164
+ ctx: @ctx,
165
+ hook_context: hook_context,
166
+ )
43
167
  end
44
168
  end
45
169
  end
@@ -6,7 +6,7 @@ module Textus
6
6
  module Tools
7
7
  # textus schema init NAME --from=KEY → infer YAML schema from an entry's frontmatter
8
8
  def self.init(store, name:, from:)
9
- env = Textus::Operations.for(store).reads.get.call(from)
9
+ env = Textus::Operations.for(store).get(from)
10
10
  meta = env.meta
11
11
  schema = {
12
12
  "name" => name,
@@ -24,8 +24,8 @@ module Textus
24
24
  def self.diff(store, name:)
25
25
  schema = load_schema(store, name)
26
26
  drift = []
27
- store.manifest.enumerate.each do |row|
28
- env = Textus::Operations.for(store).reads.get.call(row[:key])
27
+ store.manifest.resolver.enumerate.each do |row|
28
+ env = Textus::Operations.for(store).get(row[:key])
29
29
  begin
30
30
  schema.validate!(env.meta)
31
31
  rescue SchemaViolation => e
@@ -51,8 +51,8 @@ module Textus
51
51
 
52
52
  ops = Textus::Operations.for(store, role: "human")
53
53
  touched = []
54
- store.manifest.enumerate.each do |row|
55
- env = ops.reads.get.call(row[:key])
54
+ store.manifest.resolver.enumerate.each do |row|
55
+ env = ops.get(row[:key])
56
56
  meta = env.meta.dup
57
57
  changed = false
58
58
  renames.each do |old, new|
@@ -63,7 +63,7 @@ module Textus
63
63
  end
64
64
  next unless changed
65
65
 
66
- ops.writes.put.call(row[:key], meta: meta, body: env.body)
66
+ ops.put(row[:key], meta: meta, body: env.body)
67
67
  touched << row[:key]
68
68
  end
69
69
  { "protocol" => PROTOCOL, "migrated" => touched, "renames" => renames }
@@ -81,7 +81,7 @@ module Textus
81
81
  end
82
82
 
83
83
  def self.load_schema(store, name)
84
- store.schema_for(name)
84
+ store.schemas.fetch(name)
85
85
  rescue IoError
86
86
  raise UsageError.new("schema not found: #{name}")
87
87
  end
@@ -0,0 +1,46 @@
1
+ module Textus
2
+ # Eager-loading schema cache. Loads every *.yaml under +dir+ at construction.
3
+ # A missing directory is treated as "no schemas" (does not raise) to mirror
4
+ # the lazy behavior previously embedded in Store#schema_for.
5
+ class Schemas
6
+ def initialize(dir)
7
+ @dir = dir
8
+ @schemas = {}
9
+ load_all
10
+ end
11
+
12
+ def fetch(name)
13
+ @schemas[name] || raise(IoError.new("schema not found: #{File.join(@dir, "#{name}.yaml")}"))
14
+ end
15
+
16
+ # Only nil short-circuits. A missing-but-named schema still raises IoError.
17
+ def fetch_or_nil(name)
18
+ return nil if name.nil?
19
+
20
+ fetch(name)
21
+ end
22
+
23
+ def all
24
+ @schemas.values
25
+ end
26
+
27
+ private
28
+
29
+ def load_all
30
+ return unless File.directory?(@dir)
31
+
32
+ Dir.glob(File.join(@dir, "*.yaml")).each do |path|
33
+ name = File.basename(path, ".yaml")
34
+ begin
35
+ @schemas[name] = Schema.load(path)
36
+ rescue StandardError
37
+ # Tolerate broken schema files at construction time so the rest of
38
+ # the store remains loadable. Surfacing the failure is the job of
39
+ # Doctor::Check::SchemaParseError. Lookups via #fetch still raise
40
+ # IoError for the missing-but-named schema.
41
+ next
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end