textus 0.12.1 → 0.14.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +60 -40
  3. data/CHANGELOG.md +214 -0
  4. data/README.md +6 -12
  5. data/SPEC.md +4 -1
  6. data/docs/conventions.md +8 -8
  7. data/lib/textus/application/context.rb +4 -0
  8. data/lib/textus/application/reads/blame.rb +1 -1
  9. data/lib/textus/application/reads/deps.rb +15 -0
  10. data/lib/textus/application/reads/freshness.rb +2 -2
  11. data/lib/textus/application/reads/get.rb +8 -11
  12. data/lib/textus/application/reads/list.rb +15 -0
  13. data/lib/textus/application/reads/published.rb +15 -0
  14. data/lib/textus/application/reads/rdeps.rb +15 -0
  15. data/lib/textus/application/reads/schema_envelope.rb +15 -0
  16. data/lib/textus/application/reads/stale.rb +15 -0
  17. data/lib/textus/application/reads/uid.rb +15 -0
  18. data/lib/textus/application/reads/validate_all.rb +15 -0
  19. data/lib/textus/application/reads/where.rb +15 -0
  20. data/lib/textus/application/refresh/all.rb +2 -2
  21. data/lib/textus/application/refresh/worker.rb +3 -3
  22. data/lib/textus/application/writes/accept.rb +7 -7
  23. data/lib/textus/application/writes/build.rb +10 -47
  24. data/lib/textus/application/writes/mv.rb +144 -0
  25. data/lib/textus/application/writes/publish.rb +41 -9
  26. data/lib/textus/application/writes/reject.rb +37 -0
  27. data/lib/textus/cli/verb/accept.rb +1 -2
  28. data/lib/textus/cli/verb/audit.rb +3 -3
  29. data/lib/textus/cli/verb/blame.rb +1 -2
  30. data/lib/textus/cli/verb/build.rb +6 -2
  31. data/lib/textus/cli/verb/delete.rb +1 -2
  32. data/lib/textus/cli/verb/deps.rb +1 -1
  33. data/lib/textus/cli/verb/freshness.rb +1 -2
  34. data/lib/textus/cli/verb/get.rb +2 -3
  35. data/lib/textus/cli/verb/list.rb +1 -1
  36. data/lib/textus/cli/verb/mv.rb +1 -1
  37. data/lib/textus/cli/verb/published.rb +1 -1
  38. data/lib/textus/cli/verb/put.rb +2 -2
  39. data/lib/textus/cli/verb/rdeps.rb +1 -1
  40. data/lib/textus/cli/verb/refresh.rb +1 -2
  41. data/lib/textus/cli/verb/reject.rb +1 -1
  42. data/lib/textus/cli/verb/rule_explain.rb +1 -2
  43. data/lib/textus/cli/verb/schema.rb +1 -1
  44. data/lib/textus/cli/verb/uid.rb +1 -1
  45. data/lib/textus/cli/verb/where.rb +1 -1
  46. data/lib/textus/cli/verb.rb +6 -1
  47. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  48. data/lib/textus/doctor.rb +1 -1
  49. data/lib/textus/domain/freshness/evaluator.rb +1 -1
  50. data/lib/textus/domain/policy/predicates/schema_valid.rb +3 -3
  51. data/lib/textus/entry/base.rb +28 -0
  52. data/lib/textus/entry/json.rb +59 -0
  53. data/lib/textus/entry/markdown.rb +46 -0
  54. data/lib/textus/entry/text.rb +35 -0
  55. data/lib/textus/entry/yaml.rb +59 -0
  56. data/lib/textus/entry.rb +16 -0
  57. data/lib/textus/envelope.rb +44 -14
  58. data/lib/textus/intro.rb +56 -0
  59. data/lib/textus/manifest/entry/parser.rb +84 -0
  60. data/lib/textus/manifest/entry/validators/events.rb +21 -0
  61. data/lib/textus/manifest/entry/validators/format_matrix.rb +26 -0
  62. data/lib/textus/manifest/entry/validators/index_filename.rb +45 -0
  63. data/lib/textus/manifest/entry/validators/inject_intro.rb +18 -0
  64. data/lib/textus/manifest/entry/validators/publish_each.rb +37 -0
  65. data/lib/textus/manifest/entry/validators.rb +20 -0
  66. data/lib/textus/manifest/entry.rb +35 -213
  67. data/lib/textus/manifest.rb +6 -16
  68. data/lib/textus/operations/reads.rb +39 -0
  69. data/lib/textus/operations/refresh.rb +27 -0
  70. data/lib/textus/operations/writes.rb +21 -0
  71. data/lib/textus/operations.rb +44 -0
  72. data/lib/textus/projection.rb +5 -4
  73. data/lib/textus/refresh.rb +3 -4
  74. data/lib/textus/schema/tools.rb +8 -7
  75. data/lib/textus/store/reader.rb +1 -1
  76. data/lib/textus/store/validator.rb +3 -3
  77. data/lib/textus/store/writer.rb +5 -74
  78. data/lib/textus/store.rb +1 -55
  79. data/lib/textus/version.rb +1 -1
  80. metadata +23 -4
  81. data/lib/textus/composition.rb +0 -72
  82. data/lib/textus/proposal.rb +0 -10
  83. data/lib/textus/store/mover.rb +0 -167
@@ -0,0 +1,45 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ module Validators
5
+ module IndexFilename
6
+ def self.call(entry)
7
+ return if entry.index_filename.nil?
8
+
9
+ check_shape!(entry)
10
+ check_extension!(entry)
11
+ end
12
+
13
+ def self.check_shape!(entry)
14
+ raise UsageError.new("entry '#{entry.key}': index_filename requires nested: true") unless entry.nested
15
+
16
+ unless entry.index_filename.is_a?(String) && !entry.index_filename.empty?
17
+ raise UsageError.new("entry '#{entry.key}': index_filename must be a non-empty string")
18
+ end
19
+
20
+ return unless entry.index_filename.include?("/") || File.basename(entry.index_filename) != entry.index_filename
21
+
22
+ raise UsageError.new("entry '#{entry.key}': index_filename must be a bare basename (no slashes)")
23
+ end
24
+
25
+ def self.check_extension!(entry)
26
+ ext = File.extname(entry.index_filename)
27
+ inferred = Textus::Entry.infer_from_extension(ext)
28
+
29
+ if inferred.nil?
30
+ raise UsageError.new(
31
+ "entry '#{entry.key}': index_filename #{entry.index_filename.inspect} has unknown extension #{ext.inspect}",
32
+ )
33
+ end
34
+ return if inferred == entry.format
35
+
36
+ raise UsageError.new(
37
+ "entry '#{entry.key}': index_filename extension #{ext.inspect} implies format #{inferred.inspect}, " \
38
+ "but entry format is #{entry.format.inspect}",
39
+ )
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,18 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ module Validators
5
+ module InjectIntro
6
+ def self.call(entry)
7
+ return unless entry.inject_intro
8
+
9
+ raise UsageError.new("entry '#{entry.key}': inject_intro: is only valid on derived entries") unless entry.in_generator_zone?
10
+ return unless entry.template.nil?
11
+
12
+ raise UsageError.new("entry '#{entry.key}': inject_intro: requires a template:")
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,37 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ module Validators
5
+ module PublishEach
6
+ KNOWN_VARS = %w[leaf basename key ext].freeze
7
+ VAR_RE = /\{([a-z]+)\}/
8
+ REQUIRED_DISCRIMINATOR_VARS = %w[leaf basename key].freeze
9
+
10
+ def self.call(entry)
11
+ return if entry.publish_each.nil?
12
+
13
+ raise UsageError.new("entry '#{entry.key}': publish_each requires nested: true") unless entry.nested
14
+ raise UsageError.new("entry '#{entry.key}': publish_to and publish_each are mutually exclusive") unless entry.publish_to.empty?
15
+ raise UsageError.new("entry '#{entry.key}': publish_each must be a string") unless entry.publish_each.is_a?(String)
16
+
17
+ used_vars = entry.publish_each.scan(VAR_RE).flatten
18
+ unknown = used_vars - KNOWN_VARS
19
+ unless unknown.empty?
20
+ raise UsageError.new(
21
+ "entry '#{entry.key}': publish_each uses unknown template variable(s) " \
22
+ "#{unknown.map { |v| "{#{v}}" }.join(", ")}. Known: #{KNOWN_VARS.map { |v| "{#{v}}" }.join(", ")}.",
23
+ )
24
+ end
25
+
26
+ return if used_vars.any? { |v| REQUIRED_DISCRIMINATOR_VARS.include?(v) }
27
+
28
+ raise UsageError.new(
29
+ "entry '#{entry.key}': publish_each must reference at least one of {leaf}, {basename}, or {key} " \
30
+ "(else every leaf would clobber the same target).",
31
+ )
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,20 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ module Validators
5
+ REGISTERED = [
6
+ Events,
7
+ PublishEach,
8
+ InjectIntro,
9
+ IndexFilename,
10
+ FormatMatrix,
11
+ ].freeze
12
+
13
+ def self.run_all(entry)
14
+ REGISTERED.each { |v| v.call(entry) }
15
+ nil
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -1,41 +1,44 @@
1
1
  module Textus
2
2
  class Manifest
3
3
  class Entry
4
- PUBLISH_EACH_VARS = %w[leaf basename key ext].freeze
5
- PUBLISH_EACH_VAR_RE = /\{([a-z]+)\}/
6
-
7
- COMPUTE_KINDS = %w[projection external].freeze
8
-
9
- attr_reader :key, :path, :zone, :schema, :owner, :nested, :generator, :raw, :format,
10
- :projection, :template, :publish_to, :publish_each,
11
- :intake_handler, :intake_config,
12
- :events, :inject_intro, :index_filename, :compute
13
-
14
- def initialize(manifest, raw)
4
+ # Re-exported for backward compatibility with callers that referenced these
5
+ # constants on Entry. Canonical source is the PublishEach validator.
6
+ PUBLISH_EACH_VARS = Validators::PublishEach::KNOWN_VARS
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:)
15
20
  @manifest = manifest
16
21
  @raw = raw
17
- @key = raw["key"] or raise UsageError.new("manifest entry missing key")
18
- @path = raw["path"] or raise UsageError.new("manifest entry '#{@key}' missing path")
19
- @zone = raw["zone"] or raise UsageError.new("manifest entry '#{@key}' missing zone")
20
- @schema = raw["schema"]
21
- @owner = raw["owner"]
22
- @nested = raw["nested"] == true
23
- parse_compute!(raw)
24
- @template = raw["template"]
25
- @publish_to = Array(raw["publish_to"])
26
- @publish_each = raw["publish_each"]
27
- @events = raw["events"] || {}
28
- @inject_intro = raw["inject_intro"] == true
29
- @index_filename = raw["index_filename"]
30
- @format = resolve_format!(raw["format"])
31
-
32
- validate_events!
33
- parse_intake!(raw["intake"])
34
- validate_format_matrix!
35
- validate_publish_each!
36
- validate_inject_intro!
37
- validate_index_filename!
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
38
40
  end
41
+ # rubocop:enable Metrics/ParameterLists
39
42
 
40
43
  # Resolves the per-leaf target path (relative to repo root) for a full
41
44
  # dotted key under this entry's prefix. Returns nil if this entry has no
@@ -69,192 +72,11 @@ module Textus
69
72
 
70
73
  private
71
74
 
72
- # `index_filename:` makes a nested entry treat a fixed basename (e.g.
73
- # `SKILL.md`) as the per-directory row. The directory path becomes the
74
- # key suffix; sibling files are not enumerated. Allows projecting
75
- # spec-mandated filenames that would otherwise be rejected by the
76
- # lowercase-only key segment grammar.
77
- def validate_index_filename!
78
- return if @index_filename.nil?
79
-
80
- raise UsageError.new("entry '#{@key}': index_filename requires nested: true") unless @nested
81
- unless @index_filename.is_a?(String) && !@index_filename.empty?
82
- raise UsageError.new("entry '#{@key}': index_filename must be a non-empty string")
83
- end
84
- if @index_filename.include?("/") || File.basename(@index_filename) != @index_filename
85
- raise UsageError.new("entry '#{@key}': index_filename must be a bare basename (no slashes)")
86
- end
87
-
88
- ext = File.extname(@index_filename)
89
- inferred = Manifest::EXT_TO_FORMAT[ext]
90
- if inferred.nil?
91
- raise UsageError.new(
92
- "entry '#{@key}': index_filename #{@index_filename.inspect} has unknown extension #{ext.inspect}",
93
- )
94
- end
95
- return if inferred == @format
96
-
97
- raise UsageError.new(
98
- "entry '#{@key}': index_filename extension #{ext.inspect} implies format #{inferred.inspect}, " \
99
- "but entry format is #{@format.inspect}",
100
- )
101
- end
102
-
103
75
  def zone_writers
104
76
  @manifest.zone_writers(@zone)
105
77
  rescue UsageError => e
106
78
  raise UsageError.new("entry '#{@key}': #{e.message}")
107
79
  end
108
-
109
- def validate_inject_intro!
110
- return unless @inject_intro
111
-
112
- unless in_generator_zone?
113
- raise UsageError.new(
114
- "entry '#{@key}': inject_intro: is only valid on derived entries",
115
- )
116
- end
117
- return unless @template.nil?
118
-
119
- raise UsageError.new(
120
- "entry '#{@key}': inject_intro: requires a template:",
121
- )
122
- end
123
-
124
- def validate_publish_each!
125
- return if @publish_each.nil?
126
-
127
- raise UsageError.new("entry '#{@key}': publish_each requires nested: true") unless @nested
128
- raise UsageError.new("entry '#{@key}': publish_to and publish_each are mutually exclusive") unless @publish_to.empty?
129
- raise UsageError.new("entry '#{@key}': publish_each must be a string") unless @publish_each.is_a?(String)
130
-
131
- used_vars = @publish_each.scan(PUBLISH_EACH_VAR_RE).flatten
132
- unknown = used_vars - PUBLISH_EACH_VARS
133
- unless unknown.empty?
134
- raise UsageError.new(
135
- "entry '#{@key}': publish_each uses unknown template variable(s) " \
136
- "#{unknown.map { |v| "{#{v}}" }.join(", ")}. Known: #{PUBLISH_EACH_VARS.map { |v| "{#{v}}" }.join(", ")}.",
137
- )
138
- end
139
-
140
- required = %w[leaf basename key]
141
- return if used_vars.any? { |v| required.include?(v) }
142
-
143
- raise UsageError.new(
144
- "entry '#{@key}': publish_each must reference at least one of {leaf}, {basename}, or {key} " \
145
- "(else every leaf would clobber the same target).",
146
- )
147
- end
148
-
149
- def resolve_format!(declared)
150
- ext = File.extname(@path)
151
- inferred = Manifest::EXT_TO_FORMAT[ext]
152
-
153
- if declared.nil?
154
- return inferred if inferred
155
- # No extension: nested defaults to markdown, leaf with no ext also markdown.
156
- return "markdown" if ext == "" && @nested
157
- return "markdown" if ext == ""
158
- else
159
- unless Manifest::EXT_TO_FORMAT.values.include?(declared)
160
- raise UsageError.new("entry '#{@key}': unknown format #{declared.inspect}")
161
- end
162
- # If the path has an extension, the declared format must match.
163
- if ext != "" && inferred && inferred != declared
164
- raise UsageError.new(
165
- "entry '#{@key}': path extension #{ext.inspect} does not match declared format #{declared.inspect}",
166
- )
167
- end
168
- return declared
169
- end
170
-
171
- "markdown"
172
- end
173
-
174
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
175
- def validate_format_matrix!
176
- ext = File.extname(@path)
177
-
178
- case @format
179
- when "markdown"
180
- # .md, or no extension (will be appended). Anything else is a mismatch caught above.
181
- raise UsageError.new("entry '#{@key}': markdown format requires '.md' path (got #{ext.inspect})") if ext != "" && ext != ".md"
182
- when "json"
183
- if @nested
184
- # nested json: path is a directory; ext must be empty.
185
- raise UsageError.new("entry '#{@key}': nested json path must not have an extension") if ext != ""
186
- elsif ext != ".json"
187
- raise UsageError.new("entry '#{@key}': json format requires '.json' path (got #{ext.inspect})")
188
- end
189
- when "yaml"
190
- if @nested
191
- raise UsageError.new("entry '#{@key}': nested yaml path must not have an extension") if ext != ""
192
- elsif ext != ".yaml" && ext != ".yml"
193
- raise UsageError.new("entry '#{@key}': yaml format requires '.yaml' or '.yml' path (got #{ext.inspect})")
194
- end
195
- when "text"
196
- if @nested
197
- raise UsageError.new("entry '#{@key}': nested text path must not have an extension") if ext != ""
198
- elsif ext != ".txt" && ext != ""
199
- raise UsageError.new("entry '#{@key}': text format requires '.txt' or no extension (got #{ext.inspect})")
200
- end
201
- end
202
-
203
- # Schema rules.
204
- raise UsageError.new("entry '#{@key}': text format must not declare a schema") if @format == "text" && !@schema.nil?
205
-
206
- # Template-required-for-derived rules. Skipped for entries materialized by an
207
- # external generator: command (those produce the bytes themselves).
208
- if in_generator_zone? && @template.nil? && @generator.nil? &&
209
- (@format == "markdown" || @format == "text") && !@nested
210
- raise UsageError.new("entry '#{@key}': derived #{@format} entries require a template")
211
- end
212
- end
213
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
214
-
215
- def parse_compute!(raw)
216
- src = raw["compute"]
217
- unless src
218
- @compute = nil
219
- @projection = nil
220
- @generator = nil
221
- return
222
- end
223
-
224
- kind = src["kind"]
225
- unless COMPUTE_KINDS.include?(kind)
226
- raise BadManifest.new(
227
- "entry '#{@key}': compute.kind must be one of #{COMPUTE_KINDS.join(", ")} (got #{kind.inspect})",
228
- )
229
- end
230
-
231
- @compute = src.freeze
232
- if kind == "projection"
233
- @projection = @compute
234
- @generator = nil
235
- else
236
- @generator = @compute
237
- @projection = nil
238
- end
239
- end
240
-
241
- def parse_intake!(src)
242
- src ||= {}
243
- @intake_handler = src["handler"]
244
- @intake_config = src["config"] || {}
245
- end
246
-
247
- def validate_events!
248
- pubsub_events = Hooks::Registry::EVENTS.select { |_, s| s[:mode] == :pubsub }.keys
249
- @events.each_key do |evt|
250
- next if pubsub_events.include?(evt.to_sym)
251
-
252
- raise UsageError.new(
253
- "entry '#{@key}': unknown event '#{evt}' in events: block. " \
254
- "Known events: #{pubsub_events.join(", ")}.",
255
- )
256
- end
257
- end
258
80
  end
259
81
  end
260
82
  end
@@ -3,14 +3,6 @@ require_relative "manifest/schema"
3
3
 
4
4
  module Textus
5
5
  class Manifest
6
- EXT_TO_FORMAT = {
7
- ".md" => "markdown",
8
- ".json" => "json",
9
- ".yaml" => "yaml",
10
- ".yml" => "yaml",
11
- ".txt" => "text",
12
- }.freeze
13
-
14
6
  TEXTUS_2_HINT = "Install textus 0.11.x to run the migrator, then upgrade to this version. " \
15
7
  "See https://github.com/patrick204nqh/textus/blob/main/CHANGELOG.md#0110".freeze
16
8
 
@@ -81,7 +73,11 @@ module Textus
81
73
 
82
74
  Schema.validate!(raw)
83
75
 
84
- @entries = Array(raw["entries"]).map { |e| Manifest::Entry.new(self, e) }
76
+ @entries = Array(raw["entries"]).map do |e|
77
+ entry = Manifest::Entry::Parser.call(self, e)
78
+ Manifest::Entry::Validators.run_all(entry)
79
+ entry
80
+ end
85
81
  validate_declared_keys!
86
82
  end
87
83
 
@@ -194,13 +190,7 @@ module Textus
194
190
  end
195
191
 
196
192
  def nested_glob(format)
197
- case format
198
- when "markdown" then "**/*.md"
199
- when "json" then "**/*.json"
200
- when "yaml" then "**/*.{yaml,yml}"
201
- when "text" then "**/*.txt"
202
- else raise UsageError.new("unknown format #{format.inspect} for nested glob")
203
- end
193
+ Textus::Entry.for_format(format).nested_glob
204
194
  end
205
195
  end
206
196
  end
@@ -0,0 +1,39 @@
1
+ module Textus
2
+ class Operations
3
+ class Reads
4
+ def initialize(ctx)
5
+ @ctx = ctx
6
+ end
7
+
8
+ def get
9
+ Application::Reads::Get.new(ctx: @ctx, orchestrator: orchestrator)
10
+ end
11
+
12
+ def freshness = Application::Reads::Freshness.new(ctx: @ctx)
13
+ def audit = Application::Reads::Audit.new(ctx: @ctx)
14
+ def blame = Application::Reads::Blame.new(ctx: @ctx)
15
+ def policy_explain = Application::Reads::PolicyExplain.new(ctx: @ctx)
16
+ def list = Application::Reads::List.new(ctx: @ctx)
17
+ def where = Application::Reads::Where.new(ctx: @ctx)
18
+ def uid = Application::Reads::Uid.new(ctx: @ctx)
19
+ def schema_envelope = Application::Reads::SchemaEnvelope.new(ctx: @ctx)
20
+ def deps = Application::Reads::Deps.new(ctx: @ctx)
21
+ def rdeps = Application::Reads::Rdeps.new(ctx: @ctx)
22
+ def published = Application::Reads::Published.new(ctx: @ctx)
23
+ def stale = Application::Reads::Stale.new(ctx: @ctx)
24
+ def validate_all = Application::Reads::ValidateAll.new(ctx: @ctx)
25
+
26
+ private
27
+
28
+ def orchestrator
29
+ Application::Refresh::Orchestrator.new(
30
+ worker: Application::Refresh::Worker.new(ctx: @ctx, bus: @ctx.store.bus),
31
+ bus: @ctx.store.bus,
32
+ store_root: @ctx.store.root,
33
+ store: @ctx.store,
34
+ role: @ctx.role,
35
+ )
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,27 @@
1
+ module Textus
2
+ class Operations
3
+ class Refresh
4
+ def initialize(ctx)
5
+ @ctx = ctx
6
+ end
7
+
8
+ def worker
9
+ Application::Refresh::Worker.new(ctx: @ctx, bus: @ctx.store.bus)
10
+ end
11
+
12
+ def orchestrator
13
+ Application::Refresh::Orchestrator.new(
14
+ worker: worker,
15
+ bus: @ctx.store.bus,
16
+ store_root: @ctx.store.root,
17
+ store: @ctx.store,
18
+ role: @ctx.role,
19
+ )
20
+ end
21
+
22
+ def all
23
+ Application::Refresh::All.new(ctx: @ctx, bus: @ctx.store.bus)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,21 @@
1
+ module Textus
2
+ class Operations
3
+ class Writes
4
+ def initialize(ctx)
5
+ @ctx = ctx
6
+ end
7
+
8
+ def put = Application::Writes::Put.new(ctx: @ctx, bus: bus)
9
+ def delete = Application::Writes::Delete.new(ctx: @ctx, bus: bus)
10
+ def mv = Application::Writes::Mv.new(ctx: @ctx, bus: bus)
11
+ def accept = Application::Writes::Accept.new(ctx: @ctx, bus: bus)
12
+ def build = Application::Writes::Build.new(ctx: @ctx, bus: bus)
13
+ def publish = Application::Writes::Publish.new(ctx: @ctx, bus: bus)
14
+ def reject = Application::Writes::Reject.new(ctx: @ctx, bus: bus)
15
+
16
+ private
17
+
18
+ def bus = @ctx.store.bus
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,44 @@
1
+ module Textus
2
+ # Single canonical entrypoint for invoking application use-cases against a
3
+ # store. Mirrors the directory structure under `lib/textus/application/`:
4
+ #
5
+ # ops = Textus::Operations.for(store, role: "agent")
6
+ # ops.writes.put.call(key, body: "...")
7
+ # ops.reads.get.call(key)
8
+ # ops.refresh.worker.call(key)
9
+ #
10
+ # Replaces the prior `Textus::Composition` module (deleted in v0.12.2).
11
+ class Operations
12
+ def self.for(store, role: Role::DEFAULT, correlation_id: nil, dry_run: false)
13
+ ctx = Application::Context.new(
14
+ store: store,
15
+ role: role,
16
+ correlation_id: correlation_id,
17
+ dry_run: dry_run,
18
+ )
19
+ new(ctx)
20
+ end
21
+
22
+ attr_reader :ctx
23
+
24
+ def initialize(ctx)
25
+ @ctx = ctx
26
+ end
27
+
28
+ def writes
29
+ @writes ||= Writes.new(@ctx)
30
+ end
31
+
32
+ def reads
33
+ @reads ||= Reads.new(@ctx)
34
+ end
35
+
36
+ def refresh
37
+ @refresh ||= Refresh.new(@ctx)
38
+ end
39
+
40
+ def with_role(role)
41
+ self.class.new(@ctx.with_role(role))
42
+ end
43
+ end
44
+ end
@@ -17,8 +17,8 @@ module Textus
17
17
  keys = collect_keys
18
18
  explicit_pluck = !@spec["pluck"].nil? && @spec["pluck"] != "*"
19
19
  rows = keys.map do |key|
20
- env = @store.get(key)
21
- row = pluck(env["_meta"], env["body"])
20
+ env = Operations.for(@store).reads.get.call(key)
21
+ row = pluck(env.meta, env.body)
22
22
  explicit_pluck ? row : row.merge("_key" => key)
23
23
  end
24
24
  reduced = apply_reducer(rows)
@@ -40,7 +40,7 @@ module Textus
40
40
  def apply_reducer(rows)
41
41
  name = @spec["transform"] or return rows
42
42
  callable = @store.registry.rpc_callable(:transform_rows, name)
43
- view = Application::Context.new(store: @store, role: "human")
43
+ view = Application::Context.system(@store)
44
44
  Timeout.timeout(REDUCER_TIMEOUT_SECONDS) do
45
45
  callable.call(store: view, rows: rows, config: @spec["transform_config"] || {})
46
46
  end
@@ -50,7 +50,8 @@ module Textus
50
50
 
51
51
  def collect_keys
52
52
  prefixes = Array(@spec["select"])
53
- prefixes.flat_map { |p| @store.list(prefix: p).map { |row| row["key"] } }.uniq
53
+ ops = Operations.for(@store)
54
+ prefixes.flat_map { |p| ops.reads.list.call(prefix: p).map { |row| row["key"] } }.uniq
54
55
  end
55
56
 
56
57
  def pluck(frontmatter, _body)
@@ -1,13 +1,12 @@
1
1
  module Textus
2
2
  module Refresh
3
3
  def self.call(store, key, as:)
4
- ctx = Textus::Composition.context(store, role: as)
5
- Textus::Composition.refresh_worker(ctx).run(key)
4
+ Textus::Operations.for(store, role: as).refresh.worker.run(key)
6
5
  end
7
6
 
8
7
  def self.refresh_stale(store, prefix: nil, zone: nil, as: "runner")
9
- ctx = Textus::Composition.context(store, role: as)
10
- Textus::Application::Refresh::All.call(ctx, prefix: prefix, zone: zone)
8
+ ops = Textus::Operations.for(store, role: as)
9
+ Textus::Application::Refresh::All.call(ops.ctx, prefix: prefix, zone: zone)
11
10
  end
12
11
 
13
12
  # Normalize the three accepted intake return shapes into the store's
@@ -6,8 +6,8 @@ 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 = store.get(from)
10
- meta = env["_meta"]
9
+ env = Textus::Operations.for(store).reads.get.call(from)
10
+ meta = env.meta
11
11
  schema = {
12
12
  "name" => name,
13
13
  "required" => meta.keys,
@@ -25,9 +25,9 @@ module Textus
25
25
  schema = load_schema(store, name)
26
26
  drift = []
27
27
  store.manifest.enumerate.each do |row|
28
- env = store.get(row[:key])
28
+ env = Textus::Operations.for(store).reads.get.call(row[:key])
29
29
  begin
30
- schema.validate!(env["_meta"])
30
+ schema.validate!(env.meta)
31
31
  rescue SchemaViolation => e
32
32
  drift << { "key" => row[:key], "details" => e.details }
33
33
  end
@@ -49,10 +49,11 @@ module Textus
49
49
  end
50
50
  raise UsageError.new("schema migrate needs --rename=OLD:NEW or schema.evolution.migrate_from") if renames.empty?
51
51
 
52
+ ops = Textus::Operations.for(store, role: "human")
52
53
  touched = []
53
54
  store.manifest.enumerate.each do |row|
54
- env = store.get(row[:key])
55
- meta = env["_meta"]
55
+ env = ops.reads.get.call(row[:key])
56
+ meta = env.meta.dup
56
57
  changed = false
57
58
  renames.each do |old, new|
58
59
  if meta.key?(old)
@@ -62,7 +63,7 @@ module Textus
62
63
  end
63
64
  next unless changed
64
65
 
65
- store.put(row[:key], meta: meta, body: env["body"], as: "human")
66
+ ops.writes.put.call(row[:key], meta: meta, body: env.body)
66
67
  touched << row[:key]
67
68
  end
68
69
  { "protocol" => PROTOCOL, "migrated" => touched, "renames" => renames }
@@ -46,7 +46,7 @@ module Textus
46
46
  # Returns the Textus UID for a key (or nil if the entry has none yet).
47
47
  # Raises UnknownKey if the key doesn't resolve to a real file.
48
48
  def uid(key)
49
- get(key)["uid"]
49
+ get(key).uid
50
50
  end
51
51
 
52
52
  def deps(key) = Dependencies.deps_of(@manifest, key)