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.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +50 -55
- data/CHANGELOG.md +486 -0
- data/README.md +13 -9
- data/SPEC.md +13 -10
- data/docs/conventions.md +2 -2
- data/lib/textus/application/context.rb +20 -34
- data/lib/textus/application/policy/predicates/human_accept.rb +30 -0
- data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
- data/lib/textus/{domain → application}/policy/promotion.rb +20 -3
- data/lib/textus/application/projection.rb +91 -0
- data/lib/textus/application/reads/audit.rb +4 -4
- data/lib/textus/application/reads/blame.rb +11 -8
- data/lib/textus/application/reads/deps.rb +14 -3
- data/lib/textus/application/reads/freshness.rb +17 -6
- data/lib/textus/application/reads/get.rb +37 -11
- data/lib/textus/application/reads/get_or_refresh.rb +8 -8
- data/lib/textus/application/reads/list.rb +5 -3
- data/lib/textus/application/reads/policy_explain.rb +3 -3
- data/lib/textus/application/reads/published.rb +5 -3
- data/lib/textus/application/reads/rdeps.rb +15 -3
- data/lib/textus/application/reads/schema_envelope.rb +6 -3
- data/lib/textus/application/reads/stale.rb +3 -3
- data/lib/textus/application/reads/uid.rb +11 -3
- data/lib/textus/application/reads/validate_all.rb +12 -3
- data/lib/textus/application/reads/validator.rb +84 -0
- data/lib/textus/application/reads/where.rb +6 -3
- data/lib/textus/application/refresh/all.rb +16 -5
- data/lib/textus/application/refresh/orchestrator.rb +9 -9
- data/lib/textus/application/refresh/worker.rb +59 -32
- data/lib/textus/application/tools/migrate_keys.rb +191 -0
- data/lib/textus/application/tools/migrate_manifest_to_kinds.rb +31 -0
- data/lib/textus/application/writes/accept.rb +36 -13
- data/lib/textus/application/writes/delete.rb +13 -15
- data/lib/textus/application/writes/envelope_io.rb +166 -0
- data/lib/textus/application/writes/materializer.rb +50 -0
- data/lib/textus/application/writes/mv.rb +56 -95
- data/lib/textus/application/writes/publish.rb +132 -27
- data/lib/textus/application/writes/put.rb +17 -20
- data/lib/textus/application/writes/reject.rb +18 -9
- data/lib/textus/builder/pipeline.rb +21 -15
- data/lib/textus/builder/renderer/json.rb +4 -1
- data/lib/textus/builder/renderer/markdown.rb +7 -1
- data/lib/textus/builder/renderer/yaml.rb +4 -1
- data/lib/textus/cli/group/hook.rb +1 -3
- data/lib/textus/cli/group/key.rb +1 -4
- data/lib/textus/cli/group/refresh.rb +1 -2
- data/lib/textus/cli/group/rule.rb +1 -3
- data/lib/textus/cli/group/schema.rb +1 -5
- data/lib/textus/cli/group.rb +12 -16
- data/lib/textus/cli/verb/accept.rb +3 -1
- data/lib/textus/cli/verb/audit.rb +3 -1
- data/lib/textus/cli/verb/blame.rb +3 -1
- data/lib/textus/cli/verb/build.rb +4 -5
- data/lib/textus/cli/verb/delete.rb +3 -1
- data/lib/textus/cli/verb/deps.rb +3 -1
- data/lib/textus/cli/verb/doctor.rb +2 -0
- data/lib/textus/cli/verb/freshness.rb +3 -1
- data/lib/textus/cli/verb/get.rb +4 -2
- data/lib/textus/cli/verb/hook_run.rb +6 -4
- data/lib/textus/cli/verb/hooks.rb +8 -5
- data/lib/textus/cli/verb/init.rb +2 -0
- data/lib/textus/cli/verb/intro.rb +2 -0
- data/lib/textus/cli/verb/key_normalize.rb +35 -3
- data/lib/textus/cli/verb/list.rb +3 -1
- data/lib/textus/cli/verb/mv.rb +4 -1
- data/lib/textus/cli/verb/published.rb +3 -1
- data/lib/textus/cli/verb/put.rb +5 -4
- data/lib/textus/cli/verb/rdeps.rb +3 -1
- data/lib/textus/cli/verb/refresh.rb +1 -1
- data/lib/textus/cli/verb/refresh_stale.rb +4 -2
- data/lib/textus/cli/verb/reject.rb +3 -1
- data/lib/textus/cli/verb/rule_explain.rb +4 -1
- data/lib/textus/cli/verb/rule_list.rb +3 -0
- data/lib/textus/cli/verb/schema.rb +4 -1
- data/lib/textus/cli/verb/schema_diff.rb +3 -0
- data/lib/textus/cli/verb/schema_init.rb +3 -0
- data/lib/textus/cli/verb/schema_migrate.rb +3 -0
- data/lib/textus/cli/verb/uid.rb +4 -1
- data/lib/textus/cli/verb/where.rb +3 -1
- data/lib/textus/cli/verb.rb +30 -0
- data/lib/textus/cli.rb +18 -27
- data/lib/textus/doctor/check/audit_log.rb +1 -1
- data/lib/textus/doctor/check/handler_allowlist.rb +3 -2
- data/lib/textus/doctor/check/hooks.rb +4 -2
- data/lib/textus/doctor/check/illegal_keys.rb +6 -5
- data/lib/textus/doctor/check/intake_registration.rb +5 -5
- data/lib/textus/doctor/check/manifest_files.rb +1 -1
- data/lib/textus/doctor/check/protocol_version.rb +2 -2
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/sentinels.rb +2 -2
- data/lib/textus/doctor/check/templates.rb +4 -3
- data/lib/textus/doctor.rb +3 -4
- data/lib/textus/domain/authorizer.rb +37 -0
- data/lib/textus/domain/freshness/evaluator.rb +1 -1
- data/lib/textus/domain/freshness/policy.rb +1 -1
- data/lib/textus/domain/freshness/verdict.rb +1 -1
- data/lib/textus/domain/freshness.rb +40 -0
- data/lib/textus/{store → domain}/sentinel.rb +1 -1
- data/lib/textus/{store → domain}/staleness/generator_check.rb +9 -8
- data/lib/textus/{store → domain}/staleness/intake_check.rb +3 -3
- data/lib/textus/{store → domain}/staleness.rb +1 -1
- data/lib/textus/entry/json.rb +1 -1
- data/lib/textus/entry/markdown.rb +1 -1
- data/lib/textus/entry/yaml.rb +1 -1
- data/lib/textus/envelope.rb +7 -3
- data/lib/textus/errors.rb +19 -0
- data/lib/textus/hooks/builtin.rb +6 -6
- data/lib/textus/hooks/bus.rb +155 -0
- data/lib/textus/hooks/context.rb +38 -0
- data/lib/textus/hooks/fire_report.rb +23 -0
- data/lib/textus/hooks/loader.rb +20 -17
- data/lib/textus/{store → infra}/audit_log.rb +1 -1
- data/lib/textus/infra/audit_subscriber.rb +43 -0
- data/lib/textus/infra/event_bus.rb +3 -3
- data/lib/textus/infra/publisher.rb +3 -3
- data/lib/textus/infra/refresh/detached.rb +1 -1
- data/lib/textus/infra/storage/file_store.rb +26 -0
- data/lib/textus/init.rb +14 -11
- data/lib/textus/intro.rb +7 -7
- data/lib/textus/manifest/entry/base.rb +38 -0
- data/lib/textus/manifest/entry/derived.rb +25 -0
- data/lib/textus/manifest/entry/intake.rb +19 -0
- data/lib/textus/manifest/entry/leaf.rb +16 -0
- data/lib/textus/manifest/entry/nested.rb +39 -0
- data/lib/textus/manifest/entry/parser.rb +64 -31
- data/lib/textus/manifest/entry/validators/events.rb +3 -2
- data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -3
- data/lib/textus/manifest/entry/validators/index_filename.rb +11 -10
- data/lib/textus/manifest/entry/validators/inject_intro.rb +5 -2
- data/lib/textus/manifest/entry/validators/publish_each.rb +9 -6
- data/lib/textus/manifest/entry.rb +0 -72
- data/lib/textus/manifest/resolution.rb +5 -0
- data/lib/textus/manifest/resolver.rb +109 -0
- data/lib/textus/manifest/schema.rb +1 -1
- data/lib/textus/manifest.rb +4 -100
- data/lib/textus/operations.rb +147 -23
- data/lib/textus/schema/tools.rb +7 -7
- data/lib/textus/schemas.rb +46 -0
- data/lib/textus/store.rb +12 -49
- data/lib/textus/uid.rb +18 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +17 -1
- metadata +31 -23
- data/lib/textus/application/writes/build.rb +0 -79
- data/lib/textus/dependencies.rb +0 -23
- data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
- data/lib/textus/hooks/dispatcher.rb +0 -63
- data/lib/textus/hooks/dsl.rb +0 -11
- data/lib/textus/hooks/registry.rb +0 -81
- data/lib/textus/migrate_keys.rb +0 -187
- data/lib/textus/operations/reads.rb +0 -56
- data/lib/textus/operations/refresh.rb +0 -27
- data/lib/textus/operations/writes.rb +0 -21
- data/lib/textus/projection.rb +0 -89
- data/lib/textus/refresh.rb +0 -39
- data/lib/textus/store/reader.rb +0 -69
- data/lib/textus/store/validator.rb +0 -82
- 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,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
|
data/lib/textus/manifest.rb
CHANGED
|
@@ -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
|
-
|
|
90
|
-
|
|
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
|
data/lib/textus/operations.rb
CHANGED
|
@@ -1,45 +1,169 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
# Single canonical entrypoint for invoking application use-cases against a
|
|
3
|
-
# store.
|
|
3
|
+
# store. Public surface is flat — one method per use case:
|
|
4
4
|
#
|
|
5
5
|
# ops = Textus::Operations.for(store, role: "agent")
|
|
6
|
-
# ops.
|
|
7
|
-
# ops.
|
|
8
|
-
# ops.
|
|
9
|
-
# ops.refresh
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
|
30
|
-
|
|
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
|
|
34
|
-
@
|
|
50
|
+
def hook_context
|
|
51
|
+
@hook_context ||= Textus::Hooks::Context.new(ops: self)
|
|
35
52
|
end
|
|
36
53
|
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
42
|
-
|
|
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
|
data/lib/textus/schema/tools.rb
CHANGED
|
@@ -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).
|
|
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).
|
|
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.
|
|
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.
|
|
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.
|
|
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
|