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
|
@@ -7,7 +7,7 @@ module Textus
|
|
|
7
7
|
return [] unless File.directory?(dir)
|
|
8
8
|
|
|
9
9
|
repo_root = File.dirname(store.root)
|
|
10
|
-
Dir.glob(File.join(dir, "**", "*#{Textus::
|
|
10
|
+
Dir.glob(File.join(dir, "**", "*#{Textus::Domain::Sentinel::SUFFIX}")).flat_map do |sentinel_path|
|
|
11
11
|
inspect_sentinel(sentinel_path, repo_root)
|
|
12
12
|
end
|
|
13
13
|
end
|
|
@@ -15,7 +15,7 @@ module Textus
|
|
|
15
15
|
private
|
|
16
16
|
|
|
17
17
|
def inspect_sentinel(sentinel_path, repo_root)
|
|
18
|
-
sentinel = Textus::
|
|
18
|
+
sentinel = Textus::Domain::Sentinel.load(sentinel_path, repo_root)
|
|
19
19
|
return [parse_error_issue(sentinel_path)] if sentinel.nil?
|
|
20
20
|
return [orphan_issue(sentinel_path, sentinel)] if sentinel.orphan?
|
|
21
21
|
return [drift_issue(sentinel)] if sentinel.drift?
|
|
@@ -5,16 +5,17 @@ module Textus
|
|
|
5
5
|
def call
|
|
6
6
|
out = []
|
|
7
7
|
store.manifest.entries.each do |entry|
|
|
8
|
-
|
|
8
|
+
template = entry.respond_to?(:template) ? entry.template : nil
|
|
9
|
+
next if template.nil?
|
|
9
10
|
|
|
10
|
-
tp = File.join(store.root, "templates",
|
|
11
|
+
tp = File.join(store.root, "templates", template)
|
|
11
12
|
next if File.exist?(tp)
|
|
12
13
|
|
|
13
14
|
out << {
|
|
14
15
|
"code" => "template.missing",
|
|
15
16
|
"level" => "error",
|
|
16
17
|
"subject" => entry.key,
|
|
17
|
-
"message" => "template '#{
|
|
18
|
+
"message" => "template '#{template}' not found at #{tp}",
|
|
18
19
|
"fix" => "create the file at #{tp} or update the entry's template: field",
|
|
19
20
|
}
|
|
20
21
|
end
|
data/lib/textus/doctor.rb
CHANGED
|
@@ -54,11 +54,10 @@ module Textus
|
|
|
54
54
|
|
|
55
55
|
def run_registered_checks(store)
|
|
56
56
|
out = []
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
callable = store.registry.rpc_callable(:validate, name)
|
|
57
|
+
store.bus.rpc_names(:validate).each do |name|
|
|
58
|
+
callable = store.bus.rpc_callable(:validate, name)
|
|
60
59
|
begin
|
|
61
|
-
result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) { callable.call(store:
|
|
60
|
+
result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) { callable.call(store: store) }
|
|
62
61
|
if result.is_a?(Array)
|
|
63
62
|
out.concat(result.map { |h| h.transform_keys(&:to_s) })
|
|
64
63
|
else
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Domain
|
|
5
|
+
# Authorization service. Single source of truth for "given a manifest
|
|
6
|
+
# entry and a role, may this caller read/write?". Extracted from
|
|
7
|
+
# Application::Context so the rule lives in Domain alongside Permission.
|
|
8
|
+
class Authorizer
|
|
9
|
+
def initialize(manifest:)
|
|
10
|
+
@manifest = manifest
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def can_write?(zone, role:)
|
|
14
|
+
@manifest.permission_for(zone.to_s).allows_write?(role)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def can_read?(zone, role:)
|
|
18
|
+
@manifest.permission_for(zone.to_s).allows_read?(role)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def authorize_write!(mentry, role:)
|
|
22
|
+
return if can_write?(mentry.zone, role: role)
|
|
23
|
+
|
|
24
|
+
writers = @manifest.zone_writers(mentry.zone)
|
|
25
|
+
raise WriteForbidden.new(mentry.key, mentry.zone, writers: writers)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def authorize_read!(mentry, role:)
|
|
29
|
+
return if can_read?(mentry.zone, role: role)
|
|
30
|
+
|
|
31
|
+
readers = @manifest.zone_readers[mentry.zone]
|
|
32
|
+
readers = nil if readers == :all
|
|
33
|
+
raise ReadForbidden.new(mentry.key, mentry.zone, readers: readers)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Domain
|
|
5
|
+
# Value object describing the freshness annotation attached to an Envelope
|
|
6
|
+
# after a freshness evaluation. Replaces the loose Hash that used to live
|
|
7
|
+
# on `Envelope#freshness`.
|
|
8
|
+
#
|
|
9
|
+
# Note on wire format: `#to_h_for_wire` is intentionally narrower than the
|
|
10
|
+
# full field set. It emits the legacy keys ("stale", "stale_reason",
|
|
11
|
+
# "refreshing", and "refresh_error" when present) so the CLI JSON wire
|
|
12
|
+
# stays byte-identical with textus/3. The gem-side fields `checked_at`
|
|
13
|
+
# and `ttl_remaining_ms` are NOT emitted on the wire in this phase.
|
|
14
|
+
Freshness = Data.define(
|
|
15
|
+
:stale, :refreshing, :reason, :refresh_error, :checked_at, :ttl_remaining_ms
|
|
16
|
+
) do
|
|
17
|
+
def self.build(stale:, refreshing: false, reason: nil, refresh_error: nil,
|
|
18
|
+
checked_at: nil, ttl_remaining_ms: nil)
|
|
19
|
+
new(
|
|
20
|
+
stale: stale,
|
|
21
|
+
refreshing: refreshing,
|
|
22
|
+
reason: reason,
|
|
23
|
+
refresh_error: refresh_error,
|
|
24
|
+
checked_at: checked_at,
|
|
25
|
+
ttl_remaining_ms: ttl_remaining_ms,
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_h_for_wire
|
|
30
|
+
h = {
|
|
31
|
+
"stale" => stale,
|
|
32
|
+
"stale_reason" => reason,
|
|
33
|
+
"refreshing" => refreshing,
|
|
34
|
+
}
|
|
35
|
+
h["refresh_error"] = refresh_error unless refresh_error.nil?
|
|
36
|
+
h
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -3,7 +3,7 @@ require "digest"
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
|
|
5
5
|
module Textus
|
|
6
|
-
|
|
6
|
+
module Domain
|
|
7
7
|
# Value object for sentinel files written by Infra::Publisher and inspected
|
|
8
8
|
# by Doctor::Check::Sentinels. Owns the JSON shape ({source, target,
|
|
9
9
|
# sha256, mode}) and the on-disk path layout (<store_root>/sentinels/
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
require "time"
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
|
-
|
|
4
|
+
module Domain
|
|
5
5
|
class Staleness
|
|
6
6
|
# Reports staleness for generator-zone entries — derived files whose
|
|
7
7
|
# generator's listed sources have been modified more recently than the
|
|
@@ -14,9 +14,10 @@ module Textus
|
|
|
14
14
|
|
|
15
15
|
def rows_for(mentry)
|
|
16
16
|
return [] unless mentry.in_generator_zone?
|
|
17
|
+
return [] unless mentry.is_a?(Textus::Manifest::Entry::Derived)
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
return [] unless
|
|
19
|
+
src = mentry.source
|
|
20
|
+
return [] unless src.is_a?(Textus::Manifest::Entry::Derived::External)
|
|
20
21
|
|
|
21
22
|
path = Textus::Key::Path.resolve(@manifest, mentry)
|
|
22
23
|
return [stale_row(mentry, path, "derived entry has never been generated")] unless File.exist?(path)
|
|
@@ -28,7 +29,7 @@ module Textus
|
|
|
28
29
|
gen_time = parse_time(generated_at)
|
|
29
30
|
return [stale_row(mentry, path, "unparseable generated.at: #{generated_at.inspect}")] unless gen_time
|
|
30
31
|
|
|
31
|
-
offender = newest_source_after(
|
|
32
|
+
offender = newest_source_after(src, gen_time)
|
|
32
33
|
return [stale_row(mentry, path, "source '#{offender}' modified after generated.at")] if offender
|
|
33
34
|
|
|
34
35
|
[]
|
|
@@ -42,8 +43,8 @@ module Textus
|
|
|
42
43
|
nil
|
|
43
44
|
end
|
|
44
45
|
|
|
45
|
-
def newest_source_after(
|
|
46
|
-
Array(
|
|
46
|
+
def newest_source_after(external_src, gen_time)
|
|
47
|
+
Array(external_src.sources).each do |src|
|
|
47
48
|
offender = check_source(src, gen_time)
|
|
48
49
|
return offender if offender
|
|
49
50
|
end
|
|
@@ -52,7 +53,7 @@ module Textus
|
|
|
52
53
|
|
|
53
54
|
def check_source(src, gen_time)
|
|
54
55
|
if src.match?(/\A[a-z0-9.][a-z0-9._-]*\z/) && !src.include?("/")
|
|
55
|
-
@manifest.enumerate(prefix: src).each do |row|
|
|
56
|
+
@manifest.resolver.enumerate(prefix: src).each do |row|
|
|
56
57
|
return src if File.mtime(row[:path]) > gen_time
|
|
57
58
|
end
|
|
58
59
|
nil
|
|
@@ -78,7 +79,7 @@ module Textus
|
|
|
78
79
|
{
|
|
79
80
|
"key" => mentry.key,
|
|
80
81
|
"path" => path,
|
|
81
|
-
"generator" => mentry.
|
|
82
|
+
"generator" => mentry.raw["compute"],
|
|
82
83
|
"reason" => reason,
|
|
83
84
|
}
|
|
84
85
|
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
require "time"
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
|
-
|
|
4
|
+
module Domain
|
|
5
5
|
class Staleness
|
|
6
6
|
# Reports TTL-exceeded staleness for intake-handler entries. Returns an
|
|
7
7
|
# Array of row hashes (possibly empty) per entry.
|
|
@@ -11,7 +11,7 @@ module Textus
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def rows_for(mentry)
|
|
14
|
-
return [] unless mentry.
|
|
14
|
+
return [] unless mentry.is_a?(Textus::Manifest::Entry::Intake)
|
|
15
15
|
|
|
16
16
|
ttl = @manifest.rules_for(mentry.key).refresh&.ttl_seconds
|
|
17
17
|
return [] unless ttl
|
|
@@ -38,7 +38,7 @@ module Textus
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def row(mentry, path, reason)
|
|
41
|
-
{ "key" => mentry.key, "path" => path, "handler" => mentry.
|
|
41
|
+
{ "key" => mentry.key, "path" => path, "handler" => mentry.handler, "reason" => reason }
|
|
42
42
|
end
|
|
43
43
|
end
|
|
44
44
|
end
|
data/lib/textus/entry/json.rb
CHANGED
|
@@ -85,7 +85,7 @@ module Textus
|
|
|
85
85
|
|
|
86
86
|
def self.inject_uid(meta, content, existing_uid)
|
|
87
87
|
m = meta.is_a?(Hash) ? meta.dup : {}
|
|
88
|
-
m["uid"] = existing_uid || Textus::
|
|
88
|
+
m["uid"] = existing_uid || Textus::Uid.mint unless m["uid"].is_a?(String) && !m["uid"].empty?
|
|
89
89
|
[m, content]
|
|
90
90
|
end
|
|
91
91
|
|
|
@@ -70,7 +70,7 @@ module Textus
|
|
|
70
70
|
|
|
71
71
|
def self.inject_uid(meta, content, existing_uid)
|
|
72
72
|
m = meta.is_a?(Hash) ? meta.dup : {}
|
|
73
|
-
m["uid"] = existing_uid || Textus::
|
|
73
|
+
m["uid"] = existing_uid || Textus::Uid.mint unless m["uid"].is_a?(String) && !m["uid"].empty?
|
|
74
74
|
[m, content]
|
|
75
75
|
end
|
|
76
76
|
|
data/lib/textus/entry/yaml.rb
CHANGED
|
@@ -83,7 +83,7 @@ module Textus
|
|
|
83
83
|
|
|
84
84
|
def self.inject_uid(meta, content, existing_uid)
|
|
85
85
|
m = meta.is_a?(Hash) ? meta.dup : {}
|
|
86
|
-
m["uid"] = existing_uid || Textus::
|
|
86
|
+
m["uid"] = existing_uid || Textus::Uid.mint unless m["uid"].is_a?(String) && !m["uid"].empty?
|
|
87
87
|
[m, content]
|
|
88
88
|
end
|
|
89
89
|
|
data/lib/textus/envelope.rb
CHANGED
|
@@ -45,16 +45,20 @@ module Textus
|
|
|
45
45
|
"uid" => uid,
|
|
46
46
|
}
|
|
47
47
|
h["content"] = content unless content.nil?
|
|
48
|
-
freshness
|
|
48
|
+
freshness&.to_h_for_wire&.each { |k, v| h[k] = v }
|
|
49
49
|
h
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
def stale?
|
|
53
|
-
|
|
53
|
+
return false if freshness.nil?
|
|
54
|
+
|
|
55
|
+
freshness.stale == true
|
|
54
56
|
end
|
|
55
57
|
|
|
56
58
|
def refreshing?
|
|
57
|
-
|
|
59
|
+
return false if freshness.nil?
|
|
60
|
+
|
|
61
|
+
freshness.refreshing == true
|
|
58
62
|
end
|
|
59
63
|
end
|
|
60
64
|
end
|
data/lib/textus/errors.rb
CHANGED
|
@@ -108,6 +108,25 @@ module Textus
|
|
|
108
108
|
end
|
|
109
109
|
end
|
|
110
110
|
|
|
111
|
+
class ReadForbidden < Error
|
|
112
|
+
def initialize(k, z, readers: nil)
|
|
113
|
+
readers_str =
|
|
114
|
+
if readers && !readers.empty?
|
|
115
|
+
readers.join(", ")
|
|
116
|
+
else
|
|
117
|
+
"the role(s) listed in the manifest 'read_policy:'"
|
|
118
|
+
end
|
|
119
|
+
details = { "key" => k, "zone" => z }
|
|
120
|
+
details["readers"] = readers if readers
|
|
121
|
+
super(
|
|
122
|
+
"read_forbidden",
|
|
123
|
+
"zone '#{z}' is not readable by role for key '#{k}'",
|
|
124
|
+
details: details,
|
|
125
|
+
hint: "this zone is readable by #{readers_str}; pass --as=<role>",
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
111
130
|
class EtagMismatch < Error
|
|
112
131
|
def initialize(k, w, g)
|
|
113
132
|
super(
|
data/lib/textus/hooks/builtin.rb
CHANGED
|
@@ -7,22 +7,22 @@ module Textus
|
|
|
7
7
|
module Hooks
|
|
8
8
|
module Builtin
|
|
9
9
|
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
10
|
-
def self.register_all
|
|
11
|
-
|
|
10
|
+
def self.register_all(bus)
|
|
11
|
+
bus.on(:resolve_intake, :json) do |store:, config:, args:|
|
|
12
12
|
_ = store
|
|
13
13
|
_ = args
|
|
14
14
|
data = JSON.parse(config["bytes"].to_s)
|
|
15
15
|
{ _meta: {}, body: YAML.dump(data) }
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
bus.on(:resolve_intake, :csv) do |store:, config:, args:|
|
|
19
19
|
_ = store
|
|
20
20
|
_ = args
|
|
21
21
|
rows = CSV.parse(config["bytes"].to_s, headers: true).map(&:to_h)
|
|
22
22
|
{ _meta: {}, body: YAML.dump(rows) }
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
bus.on(:resolve_intake, :"markdown-links") do |store:, config:, args:|
|
|
26
26
|
_ = store
|
|
27
27
|
_ = args
|
|
28
28
|
links = config["bytes"].to_s.scan(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}).map do |text, href|
|
|
@@ -31,7 +31,7 @@ module Textus
|
|
|
31
31
|
{ _meta: {}, body: YAML.dump(links) }
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
bus.on(:resolve_intake, :"ical-events") do |store:, config:, args:|
|
|
35
35
|
_ = store
|
|
36
36
|
_ = args
|
|
37
37
|
events = []
|
|
@@ -50,7 +50,7 @@ module Textus
|
|
|
50
50
|
{ _meta: {}, body: YAML.dump(events) }
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
bus.on(:resolve_intake, :rss) do |store:, config:, args:|
|
|
54
54
|
_ = store
|
|
55
55
|
_ = args
|
|
56
56
|
doc = REXML::Document.new(config["bytes"].to_s)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Hooks
|
|
5
|
+
class Bus
|
|
6
|
+
HOOK_TIMEOUT_SECONDS = 2
|
|
7
|
+
|
|
8
|
+
class HookTimeout < StandardError; end
|
|
9
|
+
|
|
10
|
+
EVENTS = {
|
|
11
|
+
# RPC events — gem-internal, keep :store
|
|
12
|
+
resolve_intake: { mode: :rpc, args: %i[store config args] },
|
|
13
|
+
transform_rows: { mode: :rpc, args: %i[store rows config] },
|
|
14
|
+
validate: { mode: :rpc, args: %i[store] },
|
|
15
|
+
|
|
16
|
+
# Pubsub events — ship :ctx (Hooks::Context) instead of raw store
|
|
17
|
+
entry_put: { mode: :pubsub, args: %i[ctx key envelope] },
|
|
18
|
+
entry_deleted: { mode: :pubsub, args: %i[ctx key] },
|
|
19
|
+
entry_refreshed: { mode: :pubsub, args: %i[ctx key envelope change] },
|
|
20
|
+
entry_renamed: { mode: :pubsub, args: %i[ctx key from_key to_key envelope] },
|
|
21
|
+
build_completed: { mode: :pubsub, args: %i[ctx key envelope sources] },
|
|
22
|
+
proposal_accepted: { mode: :pubsub, args: %i[ctx key target_key] },
|
|
23
|
+
proposal_rejected: { mode: :pubsub, args: %i[ctx key target_key] },
|
|
24
|
+
file_published: { mode: :pubsub, args: %i[ctx key envelope source target] },
|
|
25
|
+
store_loaded: { mode: :pubsub, args: %i[ctx] },
|
|
26
|
+
refresh_started: { mode: :pubsub, args: %i[ctx key mode] },
|
|
27
|
+
refresh_failed: { mode: :pubsub, args: %i[ctx key error_class error_message] },
|
|
28
|
+
refresh_backgrounded: { mode: :pubsub, args: %i[ctx key started_at budget_ms] },
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
def initialize
|
|
32
|
+
@rpc = Hash.new { |h, k| h[k] = {} }
|
|
33
|
+
@pubsub = Hash.new { |h, k| h[k] = [] }
|
|
34
|
+
@error_handlers = []
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def on(event, name, keys: nil, &) = register(event, name, keys: keys, &)
|
|
38
|
+
|
|
39
|
+
def register(event, name, keys: nil, &blk)
|
|
40
|
+
event_sym = event.to_sym
|
|
41
|
+
spec = EVENTS[event_sym] or raise UsageError.new("unknown event: #{event}")
|
|
42
|
+
shape_check!(event_sym, spec, blk)
|
|
43
|
+
name = name.to_sym
|
|
44
|
+
|
|
45
|
+
case spec[:mode]
|
|
46
|
+
when :rpc
|
|
47
|
+
raise UsageError.new("#{event_sym} '#{name}' already registered") if @rpc[event_sym].key?(name)
|
|
48
|
+
|
|
49
|
+
@rpc[event_sym][name] = blk
|
|
50
|
+
when :pubsub
|
|
51
|
+
raise UsageError.new("#{event_sym} hook '#{name}' already registered") if @pubsub[event_sym].any? { |h| h[:name] == name }
|
|
52
|
+
|
|
53
|
+
@pubsub[event_sym] << { name: name, callable: blk, keys: keys }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def on_error(&block) = @error_handlers << block
|
|
58
|
+
|
|
59
|
+
def rpc_callable(event, name)
|
|
60
|
+
@rpc[event.to_sym][name.to_sym] or raise UsageError.new("unknown #{event}: #{name}")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def rpc_names(event) = @rpc[event.to_sym].keys
|
|
64
|
+
def pubsub_handlers(event) = @pubsub[event.to_sym]
|
|
65
|
+
def listeners(event, key:) = @pubsub[event.to_sym].select { |h| h[:keys].nil? || matches_any?(h[:keys], key) }
|
|
66
|
+
|
|
67
|
+
def publish(event, strict: false, **kwargs)
|
|
68
|
+
key = kwargs[:key] || "-"
|
|
69
|
+
fired = []
|
|
70
|
+
errored = []
|
|
71
|
+
timed_out = []
|
|
72
|
+
raised = nil
|
|
73
|
+
|
|
74
|
+
@pubsub[event.to_sym].each do |sub|
|
|
75
|
+
next unless match?(sub[:keys], key)
|
|
76
|
+
|
|
77
|
+
outcome, err = invoke(event, sub, key, kwargs)
|
|
78
|
+
case outcome
|
|
79
|
+
when :ok then fired << sub[:name]
|
|
80
|
+
when :errored then errored << sub[:name]
|
|
81
|
+
when :timed_out then timed_out << sub[:name]
|
|
82
|
+
end
|
|
83
|
+
raised ||= err if strict && err
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
raise raised if strict && raised
|
|
87
|
+
|
|
88
|
+
FireReport.new(fired: fired, errored: errored, timed_out: timed_out)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def invoke(event, sub, key, kwargs)
|
|
94
|
+
accepted = filter_kwargs(sub[:callable], kwargs)
|
|
95
|
+
error = nil
|
|
96
|
+
|
|
97
|
+
thread = Thread.new do
|
|
98
|
+
sub[:callable].call(**accepted)
|
|
99
|
+
rescue StandardError => e
|
|
100
|
+
error = e
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
if thread.join(HOOK_TIMEOUT_SECONDS).nil?
|
|
104
|
+
thread.kill
|
|
105
|
+
err = HookTimeout.new("hook #{sub[:name]} exceeded #{HOOK_TIMEOUT_SECONDS}s on event #{event}")
|
|
106
|
+
notify_error(event, sub, key, kwargs, err)
|
|
107
|
+
return [:timed_out, err]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
if error
|
|
111
|
+
notify_error(event, sub, key, kwargs, error)
|
|
112
|
+
return [:errored, error]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
[:ok, nil]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def notify_error(event, sub, key, kwargs, error)
|
|
119
|
+
@error_handlers.each do |handler|
|
|
120
|
+
handler.call(event: event, hook: sub[:name], key: key, kwargs: kwargs, error: error)
|
|
121
|
+
rescue StandardError => e
|
|
122
|
+
warn "[textus] error handler failed: #{e.class}: #{e.message}"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def filter_kwargs(callable, kwargs)
|
|
127
|
+
params = callable.parameters
|
|
128
|
+
return kwargs if params.any? { |type, _| type == :keyrest }
|
|
129
|
+
|
|
130
|
+
accepted = params.each_with_object([]) do |(type, name), acc|
|
|
131
|
+
acc << name if %i[key keyreq].include?(type)
|
|
132
|
+
end
|
|
133
|
+
kwargs.slice(*accepted)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def shape_check!(event, spec, blk)
|
|
137
|
+
required = spec[:args]
|
|
138
|
+
provided = blk.parameters.select { |t, _| %i[keyreq key keyrest].include?(t) } # rubocop:disable Style/HashSlice
|
|
139
|
+
keyrest = provided.any? { |t, _| t == :keyrest }
|
|
140
|
+
missing = required - provided.map { |_, n| n }
|
|
141
|
+
return if keyrest || missing.empty?
|
|
142
|
+
|
|
143
|
+
raise UsageError.new("#{event} hooks must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def match?(globs, key)
|
|
147
|
+
return true if globs.nil?
|
|
148
|
+
|
|
149
|
+
Array(globs).any? { |g| File.fnmatch?(g, key.to_s, File::FNM_PATHNAME) }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def matches_any?(globs, key) = match?(globs, key)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Hooks
|
|
5
|
+
# A narrow handle passed to user hooks in place of the raw Store.
|
|
6
|
+
# All writes route back through Operations so authorization, audit
|
|
7
|
+
# logging, and schema validation always fire.
|
|
8
|
+
class Context
|
|
9
|
+
attr_reader :role, :correlation_id
|
|
10
|
+
|
|
11
|
+
def initialize(ops:)
|
|
12
|
+
@ops = ops
|
|
13
|
+
@role = ops.ctx.role
|
|
14
|
+
@correlation_id = ops.ctx.correlation_id
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# read
|
|
18
|
+
def get(key) = @ops.get(key)
|
|
19
|
+
def list(**) = @ops.list(**)
|
|
20
|
+
def deps(key) = @ops.deps(key)
|
|
21
|
+
def freshness(key) = @ops.freshness(key)
|
|
22
|
+
|
|
23
|
+
# write (authorized + audited)
|
|
24
|
+
def put(key, **) = @ops.put(key, **)
|
|
25
|
+
def delete(key, **) = @ops.delete(key, **)
|
|
26
|
+
def audit(verb, key:, **) = @ops.store.audit_log.append(role: @role, verb: verb, key: key, **)
|
|
27
|
+
|
|
28
|
+
# fan-out
|
|
29
|
+
def publish_followup(event, **)
|
|
30
|
+
@ops.store.bus.publish(event, ctx: self, **)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def inspect
|
|
34
|
+
"#<Textus::Hooks::Context role=#{@role} correlation_id=#{@correlation_id}>"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Hooks
|
|
5
|
+
# Outcome of a single Dispatcher#publish call.
|
|
6
|
+
#
|
|
7
|
+
# fired — hook names that ran to completion
|
|
8
|
+
# errored — hook names that raised
|
|
9
|
+
# timed_out — hook names whose worker thread exceeded the deadline
|
|
10
|
+
#
|
|
11
|
+
# Callers that care about hook health (tests, strict embedders) can
|
|
12
|
+
# check #ok? or inspect #failures. The dispatcher itself never raises
|
|
13
|
+
# on a hook failure unless strict: true was passed to #publish.
|
|
14
|
+
FireReport = Data.define(:fired, :errored, :timed_out) do
|
|
15
|
+
def initialize(fired:, errored:, timed_out:)
|
|
16
|
+
super(fired: fired.dup.freeze, errored: errored.dup.freeze, timed_out: timed_out.dup.freeze)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def ok? = errored.empty? && timed_out.empty?
|
|
20
|
+
def failures = errored + timed_out
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/textus/hooks/loader.rb
CHANGED
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Hooks
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def self.with_registry(registry)
|
|
8
|
-
prev = Thread.current[THREAD_REGISTRY_KEY]
|
|
9
|
-
Thread.current[THREAD_REGISTRY_KEY] = registry
|
|
10
|
-
yield
|
|
11
|
-
ensure
|
|
12
|
-
Thread.current[THREAD_REGISTRY_KEY] = prev
|
|
3
|
+
class Loader
|
|
4
|
+
def initialize(bus:)
|
|
5
|
+
@bus = bus
|
|
13
6
|
end
|
|
14
7
|
|
|
15
|
-
def
|
|
16
|
-
|
|
17
|
-
|
|
8
|
+
def load_dir(dir)
|
|
9
|
+
return unless File.directory?(dir)
|
|
10
|
+
|
|
11
|
+
# Discard any leftover blocks from a prior partial load.
|
|
12
|
+
Textus.drain_hook_blocks
|
|
13
|
+
|
|
14
|
+
Dir.glob(File.join(dir, "**/*.rb")).sort.each do |f| # rubocop:disable Lint/RedundantDirGlobSort
|
|
15
|
+
load(f)
|
|
16
|
+
rescue StandardError, ScriptError => e
|
|
17
|
+
raise UsageError.new("failed loading hook #{File.basename(f)}: #{e.class}: #{e.message}")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
Textus.drain_hook_blocks.each do |blk|
|
|
21
|
+
blk.call(@bus)
|
|
22
|
+
rescue StandardError, ScriptError => e
|
|
23
|
+
raise UsageError.new("failed registering hook: #{e.class}: #{e.message}")
|
|
24
|
+
end
|
|
18
25
|
end
|
|
19
26
|
end
|
|
20
27
|
end
|
|
21
|
-
|
|
22
|
-
# Public DSL
|
|
23
|
-
def self.with_registry(registry, &) = Hooks::Loader.with_registry(registry, &)
|
|
24
|
-
def self.current_registry = Hooks::Loader.current_registry
|
|
25
28
|
end
|