textus 0.22.0 → 0.26.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 +148 -45
- data/CHANGELOG.md +102 -0
- data/README.md +1 -1
- data/SPEC.md +12 -12
- data/docs/conventions.md +10 -0
- data/lib/textus/application/caps.rb +49 -0
- data/lib/textus/application/context.rb +2 -2
- data/lib/textus/application/envelope/reader.rb +44 -0
- data/lib/textus/application/{writes/envelope_io.rb → envelope/writer.rb} +24 -50
- data/lib/textus/application/maintenance/key_delete_prefix.rb +44 -0
- data/lib/textus/application/maintenance/key_mv_prefix.rb +57 -0
- data/lib/textus/application/maintenance/migrate.rb +59 -0
- data/lib/textus/application/maintenance/rule_lint.rb +65 -0
- data/lib/textus/application/maintenance/zone_mv.rb +60 -0
- data/lib/textus/application/maintenance.rb +17 -0
- data/lib/textus/application/projection.rb +12 -10
- data/lib/textus/application/read/audit.rb +106 -0
- data/lib/textus/application/read/blame.rb +91 -0
- data/lib/textus/application/read/deps.rb +34 -0
- data/lib/textus/application/read/freshness.rb +110 -0
- data/lib/textus/application/read/get.rb +75 -0
- data/lib/textus/application/read/get_or_refresh.rb +63 -0
- data/lib/textus/application/read/list.rb +25 -0
- data/lib/textus/application/read/policy_explain.rb +47 -0
- data/lib/textus/application/read/published.rb +25 -0
- data/lib/textus/application/read/pulse.rb +101 -0
- data/lib/textus/application/read/rdeps.rb +35 -0
- data/lib/textus/application/read/schema_envelope.rb +26 -0
- data/lib/textus/application/read/stale.rb +23 -0
- data/lib/textus/application/read/uid.rb +30 -0
- data/lib/textus/application/read/validate_all.rb +32 -0
- data/lib/textus/application/{reads → read}/validator.rb +2 -2
- data/lib/textus/application/read/where.rb +26 -0
- data/lib/textus/application/use_case.rb +22 -0
- data/lib/textus/application/write/accept.rb +102 -0
- data/lib/textus/application/{writes → write}/authority_gate.rb +3 -3
- data/lib/textus/application/write/delete.rb +45 -0
- data/lib/textus/application/{writes → write}/materializer.rb +14 -15
- data/lib/textus/application/write/mv.rb +118 -0
- data/lib/textus/application/write/publish.rb +96 -0
- data/lib/textus/application/write/put.rb +49 -0
- data/lib/textus/application/write/refresh_all.rb +63 -0
- data/lib/textus/application/{refresh/orchestrator.rb → write/refresh_orchestrator.rb} +32 -8
- data/lib/textus/application/write/refresh_worker.rb +134 -0
- data/lib/textus/application/write/reject.rb +62 -0
- data/lib/textus/boot.rb +27 -29
- data/lib/textus/builder/pipeline.rb +3 -3
- data/lib/textus/cli/group/mcp.rb +9 -0
- data/lib/textus/cli/group/zone.rb +9 -0
- data/lib/textus/cli/verb/accept.rb +1 -1
- data/lib/textus/cli/verb/audit.rb +2 -2
- data/lib/textus/cli/verb/blame.rb +1 -1
- data/lib/textus/cli/verb/boot.rb +1 -1
- data/lib/textus/cli/verb/build.rb +2 -2
- data/lib/textus/cli/verb/delete.rb +1 -1
- data/lib/textus/cli/verb/deps.rb +1 -1
- data/lib/textus/cli/verb/doctor.rb +1 -1
- data/lib/textus/cli/verb/freshness.rb +1 -1
- data/lib/textus/cli/verb/get.rb +1 -1
- data/lib/textus/cli/verb/hook_run.rb +3 -4
- data/lib/textus/cli/verb/hooks.rb +11 -14
- data/lib/textus/cli/verb/key_delete.rb +24 -0
- data/lib/textus/cli/verb/list.rb +1 -1
- data/lib/textus/cli/verb/mcp_serve.rb +17 -0
- data/lib/textus/cli/verb/migrate.rb +18 -0
- data/lib/textus/cli/verb/mv.rb +11 -3
- data/lib/textus/cli/verb/published.rb +1 -1
- data/lib/textus/cli/verb/pulse.rb +1 -1
- data/lib/textus/cli/verb/put.rb +8 -6
- data/lib/textus/cli/verb/rdeps.rb +1 -1
- data/lib/textus/cli/verb/refresh.rb +1 -1
- data/lib/textus/cli/verb/refresh_stale.rb +1 -1
- data/lib/textus/cli/verb/reject.rb +1 -1
- data/lib/textus/cli/verb/rule_explain.rb +1 -1
- data/lib/textus/cli/verb/rule_lint.rb +18 -0
- data/lib/textus/cli/verb/schema.rb +1 -1
- data/lib/textus/cli/verb/uid.rb +1 -1
- data/lib/textus/cli/verb/where.rb +1 -1
- data/lib/textus/cli/verb/zone_mv.rb +19 -0
- data/lib/textus/cli/verb.rb +4 -4
- data/lib/textus/doctor/check/audit_log.rb +2 -2
- data/lib/textus/doctor/check/handler_allowlist.rb +2 -2
- data/lib/textus/doctor/check/hooks.rb +4 -3
- data/lib/textus/doctor/check/illegal_keys.rb +2 -2
- data/lib/textus/doctor/check/intake_registration.rb +2 -2
- data/lib/textus/doctor/check/manifest_files.rb +2 -2
- data/lib/textus/doctor/check/protocol_version.rb +2 -2
- data/lib/textus/doctor/check/refresh_locks.rb +2 -2
- data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
- data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/schemas.rb +2 -2
- data/lib/textus/doctor/check/sentinels.rb +2 -2
- data/lib/textus/doctor/check/templates.rb +2 -2
- data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
- data/lib/textus/doctor/check.rb +5 -3
- data/lib/textus/doctor.rb +24 -27
- data/lib/textus/domain/authorizer.rb +4 -4
- data/lib/textus/{application → domain}/policy/predicates/accept_authority_signed.rb +2 -2
- data/lib/textus/{application → domain}/policy/predicates/schema_valid.rb +1 -1
- data/lib/textus/{application → domain}/policy/promotion.rb +1 -1
- data/lib/textus/domain/staleness/generator_check.rb +2 -2
- data/lib/textus/domain/staleness/intake_check.rb +2 -2
- data/lib/textus/domain/staleness.rb +1 -1
- data/lib/textus/hooks/builtin.rb +14 -14
- data/lib/textus/hooks/context.rb +13 -13
- data/lib/textus/hooks/error_log.rb +32 -0
- data/lib/textus/hooks/{bus.rb → event_bus.rb} +41 -53
- data/lib/textus/hooks/loader.rb +29 -3
- data/lib/textus/hooks/rpc_registry.rb +77 -0
- data/lib/textus/infra/audit_subscriber.rb +6 -7
- data/lib/textus/infra/refresh/detached.rb +1 -1
- data/lib/textus/key/path.rb +7 -3
- data/lib/textus/manifest/data.rb +78 -0
- data/lib/textus/manifest/entry/base.rb +4 -4
- data/lib/textus/manifest/entry/derived.rb +4 -5
- data/lib/textus/manifest/entry/validators/events.rb +1 -1
- data/lib/textus/manifest/policy.rb +48 -0
- data/lib/textus/manifest/resolver.rb +14 -14
- data/lib/textus/manifest/rules.rb +1 -1
- data/lib/textus/manifest.rb +53 -111
- data/lib/textus/mcp/errors.rb +32 -0
- data/lib/textus/mcp/server.rb +127 -0
- data/lib/textus/mcp/session.rb +31 -0
- data/lib/textus/mcp/tool_schemas.rb +71 -0
- data/lib/textus/mcp/tools.rb +129 -0
- data/lib/textus/mcp.rb +6 -0
- data/lib/textus/schema/tools.rb +14 -10
- data/lib/textus/session.rb +84 -0
- data/lib/textus/store.rb +14 -9
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +8 -1
- metadata +61 -36
- data/lib/textus/application/reads/audit.rb +0 -94
- data/lib/textus/application/reads/blame.rb +0 -82
- data/lib/textus/application/reads/deps.rb +0 -26
- data/lib/textus/application/reads/freshness.rb +0 -88
- data/lib/textus/application/reads/get.rb +0 -67
- data/lib/textus/application/reads/get_or_refresh.rb +0 -51
- data/lib/textus/application/reads/list.rb +0 -17
- data/lib/textus/application/reads/policy_explain.rb +0 -39
- data/lib/textus/application/reads/published.rb +0 -17
- data/lib/textus/application/reads/pulse.rb +0 -63
- data/lib/textus/application/reads/rdeps.rb +0 -27
- data/lib/textus/application/reads/schema_envelope.rb +0 -18
- data/lib/textus/application/reads/stale.rb +0 -15
- data/lib/textus/application/reads/uid.rb +0 -23
- data/lib/textus/application/reads/validate_all.rb +0 -24
- data/lib/textus/application/reads/where.rb +0 -18
- data/lib/textus/application/refresh/all.rb +0 -52
- data/lib/textus/application/refresh/worker.rb +0 -116
- data/lib/textus/application/writes/accept.rb +0 -89
- data/lib/textus/application/writes/delete.rb +0 -33
- data/lib/textus/application/writes/mv.rb +0 -105
- data/lib/textus/application/writes/publish.rb +0 -81
- data/lib/textus/application/writes/put.rb +0 -37
- data/lib/textus/application/writes/reject.rb +0 -50
- data/lib/textus/infra/event_bus.rb +0 -27
- data/lib/textus/operations.rb +0 -176
data/lib/textus/hooks/builtin.rb
CHANGED
|
@@ -7,23 +7,23 @@ module Textus
|
|
|
7
7
|
module Hooks
|
|
8
8
|
module Builtin
|
|
9
9
|
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
10
|
-
def self.register_all(
|
|
11
|
-
|
|
12
|
-
_ =
|
|
10
|
+
def self.register_all(events:, rpc:) # rubocop:disable Lint/UnusedMethodArgument
|
|
11
|
+
rpc.register(:resolve_intake, :json) do |caps:, config:, args:|
|
|
12
|
+
_ = caps
|
|
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
|
-
|
|
19
|
-
_ =
|
|
18
|
+
rpc.register(:resolve_intake, :csv) do |caps:, config:, args:|
|
|
19
|
+
_ = caps
|
|
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
|
-
|
|
26
|
-
_ =
|
|
25
|
+
rpc.register(:resolve_intake, :"markdown-links") do |caps:, config:, args:|
|
|
26
|
+
_ = caps
|
|
27
27
|
_ = args
|
|
28
28
|
links = config["bytes"].to_s.scan(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}).map do |text, href|
|
|
29
29
|
{ "text" => text, "href" => href }
|
|
@@ -31,27 +31,27 @@ module Textus
|
|
|
31
31
|
{ _meta: {}, body: YAML.dump(links) }
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
_ =
|
|
34
|
+
rpc.register(:resolve_intake, :"ical-events") do |caps:, config:, args:|
|
|
35
|
+
_ = caps
|
|
36
36
|
_ = args
|
|
37
|
-
|
|
37
|
+
events_list = []
|
|
38
38
|
current = nil
|
|
39
39
|
config["bytes"].to_s.each_line do |line|
|
|
40
40
|
line = line.strip
|
|
41
41
|
case line
|
|
42
42
|
when "BEGIN:VEVENT" then current = {}
|
|
43
43
|
when "END:VEVENT"
|
|
44
|
-
|
|
44
|
+
events_list << current if current
|
|
45
45
|
current = nil
|
|
46
46
|
when /\A(SUMMARY|DTSTART|DTEND|UID|LOCATION|DESCRIPTION):(.*)\z/
|
|
47
47
|
current[Regexp.last_match(1).downcase] = Regexp.last_match(2) if current
|
|
48
48
|
end
|
|
49
49
|
end
|
|
50
|
-
{ _meta: {}, body: YAML.dump(
|
|
50
|
+
{ _meta: {}, body: YAML.dump(events_list) }
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
_ =
|
|
53
|
+
rpc.register(:resolve_intake, :rss) do |caps:, config:, args:|
|
|
54
|
+
_ = caps
|
|
55
55
|
_ = args
|
|
56
56
|
doc = REXML::Document.new(config["bytes"].to_s)
|
|
57
57
|
items = doc.elements.to_a("//item").map do |item|
|
data/lib/textus/hooks/context.rb
CHANGED
|
@@ -3,31 +3,31 @@
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Hooks
|
|
5
5
|
# A narrow handle passed to user hooks in place of the raw Store.
|
|
6
|
-
# All writes route back through
|
|
6
|
+
# All writes route back through the Session so authorization, audit
|
|
7
7
|
# logging, and schema validation always fire.
|
|
8
8
|
class Context
|
|
9
9
|
attr_reader :role, :correlation_id
|
|
10
10
|
|
|
11
|
-
def initialize(
|
|
12
|
-
@
|
|
13
|
-
@role =
|
|
14
|
-
@correlation_id =
|
|
11
|
+
def initialize(session:)
|
|
12
|
+
@session = session
|
|
13
|
+
@role = session.ctx.role
|
|
14
|
+
@correlation_id = session.ctx.correlation_id
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
# read
|
|
18
|
-
def get(key) = @
|
|
19
|
-
def list(**) = @
|
|
20
|
-
def deps(key) = @
|
|
21
|
-
def freshness(key) = @
|
|
18
|
+
def get(key) = @session.get(key)
|
|
19
|
+
def list(**) = @session.list(**)
|
|
20
|
+
def deps(key) = @session.deps(key)
|
|
21
|
+
def freshness(key) = @session.freshness(key)
|
|
22
22
|
|
|
23
23
|
# write (authorized + audited)
|
|
24
|
-
def put(key, **) = @
|
|
25
|
-
def delete(key, **) = @
|
|
26
|
-
def audit(verb, key:, **) = @
|
|
24
|
+
def put(key, **) = @session.put(key, **)
|
|
25
|
+
def delete(key, **) = @session.delete(key, **)
|
|
26
|
+
def audit(verb, key:, **) = @session.write_caps.audit_log.append(role: @role, verb: verb, key: key, **)
|
|
27
27
|
|
|
28
28
|
# fan-out
|
|
29
29
|
def publish_followup(event, **)
|
|
30
|
-
@
|
|
30
|
+
@session.write_caps.events.publish(event, ctx: self, **)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def inspect
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Hooks
|
|
3
|
+
# Bounded in-memory ring buffer of recent hook failures (errored and
|
|
4
|
+
# timed_out). Each row carries the audit `seq` observed at the time of
|
|
5
|
+
# failure so pulse can filter "errors since cursor".
|
|
6
|
+
class ErrorLog
|
|
7
|
+
DEFAULT_CAPACITY = 256
|
|
8
|
+
|
|
9
|
+
def initialize(capacity: DEFAULT_CAPACITY)
|
|
10
|
+
@capacity = capacity
|
|
11
|
+
@rows = []
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def record(seq:, event:, hook:, key:, error_class:, error_message:)
|
|
16
|
+
row = {
|
|
17
|
+
seq: seq, event: event, hook: hook, key: key,
|
|
18
|
+
error_class: error_class, error_message: error_message,
|
|
19
|
+
at: Time.now.utc.iso8601
|
|
20
|
+
}
|
|
21
|
+
@mutex.synchronize do
|
|
22
|
+
@rows << row
|
|
23
|
+
@rows.shift while @rows.size > @capacity
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def since(seq)
|
|
28
|
+
@mutex.synchronize { @rows.select { |r| r[:seq] > seq }.dup }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -2,67 +2,55 @@
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Hooks
|
|
5
|
-
class
|
|
5
|
+
class EventBus
|
|
6
6
|
HOOK_TIMEOUT_SECONDS = 2
|
|
7
7
|
|
|
8
8
|
class HookTimeout < StandardError; end
|
|
9
9
|
|
|
10
10
|
EVENTS = {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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] },
|
|
11
|
+
entry_put: %i[ctx key envelope],
|
|
12
|
+
entry_deleted: %i[ctx key],
|
|
13
|
+
entry_refreshed: %i[ctx key envelope change],
|
|
14
|
+
entry_renamed: %i[ctx key from_key to_key envelope],
|
|
15
|
+
build_completed: %i[ctx key envelope sources],
|
|
16
|
+
proposal_accepted: %i[ctx key target_key],
|
|
17
|
+
proposal_rejected: %i[ctx key target_key],
|
|
18
|
+
file_published: %i[ctx key envelope source target],
|
|
19
|
+
store_loaded: %i[ctx],
|
|
20
|
+
refresh_started: %i[ctx key mode],
|
|
21
|
+
refresh_failed: %i[ctx key error_class error_message],
|
|
22
|
+
refresh_backgrounded: %i[ctx key started_at budget_ms],
|
|
29
23
|
}.freeze
|
|
30
24
|
|
|
31
|
-
|
|
32
|
-
|
|
25
|
+
RPC_EVENTS = %i[resolve_intake transform_rows validate].freeze
|
|
26
|
+
|
|
27
|
+
def initialize(error_log: ErrorLog.new)
|
|
33
28
|
@pubsub = Hash.new { |h, k| h[k] = [] }
|
|
34
29
|
@error_handlers = []
|
|
30
|
+
@error_log = error_log
|
|
35
31
|
end
|
|
36
32
|
|
|
33
|
+
attr_reader :error_log
|
|
34
|
+
|
|
37
35
|
def on(event, name, keys: nil, &) = register(event, name, keys: keys, &)
|
|
38
36
|
|
|
39
37
|
def register(event, name, keys: nil, &blk)
|
|
40
38
|
event_sym = event.to_sym
|
|
41
|
-
|
|
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)
|
|
39
|
+
raise UsageError.new("#{event_sym} is an RPC event; register on RpcRegistry") if RPC_EVENTS.include?(event_sym)
|
|
48
40
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
41
|
+
required = EVENTS[event_sym] or raise UsageError.new("unknown event: #{event}")
|
|
42
|
+
shape_check!(event_sym, required, blk)
|
|
43
|
+
name = name.to_sym
|
|
44
|
+
raise UsageError.new("#{event_sym} hook '#{name}' already registered") if @pubsub[event_sym].any? { |h| h[:name] == name }
|
|
52
45
|
|
|
53
|
-
|
|
54
|
-
end
|
|
46
|
+
@pubsub[event_sym] << { name: name, callable: blk, keys: keys }
|
|
55
47
|
end
|
|
56
48
|
|
|
57
49
|
def on_error(&block) = @error_handlers << block
|
|
58
50
|
|
|
59
|
-
def
|
|
60
|
-
@rpc[event.to_sym][name.to_sym] or raise UsageError.new("unknown #{event}: #{name}")
|
|
61
|
-
end
|
|
51
|
+
def listeners(event, key:) = @pubsub[event.to_sym].select { |h| match?(h[:keys], key) }
|
|
62
52
|
|
|
63
|
-
def
|
|
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) }
|
|
53
|
+
def pubsub_handlers(event) = @pubsub[event.to_sym]
|
|
66
54
|
|
|
67
55
|
def publish(event, strict: false, **kwargs)
|
|
68
56
|
key = kwargs[:key] || "-"
|
|
@@ -93,29 +81,33 @@ module Textus
|
|
|
93
81
|
def invoke(event, sub, key, kwargs)
|
|
94
82
|
accepted = filter_kwargs(sub[:callable], kwargs)
|
|
95
83
|
error = nil
|
|
96
|
-
|
|
97
84
|
thread = Thread.new do
|
|
98
85
|
sub[:callable].call(**accepted)
|
|
99
86
|
rescue StandardError => e
|
|
100
87
|
error = e
|
|
101
88
|
end
|
|
102
|
-
|
|
103
89
|
if thread.join(HOOK_TIMEOUT_SECONDS).nil?
|
|
104
90
|
thread.kill
|
|
105
91
|
err = HookTimeout.new("hook #{sub[:name]} exceeded #{HOOK_TIMEOUT_SECONDS}s on event #{event}")
|
|
106
92
|
notify_error(event, sub, key, kwargs, err)
|
|
107
93
|
return [:timed_out, err]
|
|
108
94
|
end
|
|
109
|
-
|
|
110
95
|
if error
|
|
111
96
|
notify_error(event, sub, key, kwargs, error)
|
|
112
97
|
return [:errored, error]
|
|
113
98
|
end
|
|
114
|
-
|
|
115
99
|
[:ok, nil]
|
|
116
100
|
end
|
|
117
101
|
|
|
118
102
|
def notify_error(event, sub, key, kwargs, error)
|
|
103
|
+
@error_log.record(
|
|
104
|
+
seq: kwargs[:_audit_seq] || -1,
|
|
105
|
+
event: event,
|
|
106
|
+
hook: sub[:name],
|
|
107
|
+
key: key,
|
|
108
|
+
error_class: error.class.name,
|
|
109
|
+
error_message: error.message,
|
|
110
|
+
)
|
|
119
111
|
@error_handlers.each do |handler|
|
|
120
112
|
handler.call(event: event, hook: sub[:name], key: key, kwargs: kwargs, error: error)
|
|
121
113
|
rescue StandardError => e
|
|
@@ -127,18 +119,16 @@ module Textus
|
|
|
127
119
|
params = callable.parameters
|
|
128
120
|
return kwargs if params.any? { |type, _| type == :keyrest }
|
|
129
121
|
|
|
130
|
-
accepted = params.each_with_object([])
|
|
131
|
-
acc << name if %i[key keyreq].include?(type)
|
|
132
|
-
end
|
|
122
|
+
accepted = params.each_with_object([]) { |(t, n), acc| acc << n if %i[key keyreq].include?(t) }
|
|
133
123
|
kwargs.slice(*accepted)
|
|
134
124
|
end
|
|
135
125
|
|
|
136
|
-
def shape_check!(event,
|
|
137
|
-
required = spec[:args]
|
|
126
|
+
def shape_check!(event, required, blk)
|
|
138
127
|
provided = blk.parameters.select { |t, _| %i[keyreq key keyrest].include?(t) } # rubocop:disable Style/HashSlice
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
128
|
+
return if provided.any? { |t, _| t == :keyrest }
|
|
129
|
+
|
|
130
|
+
missing = required - provided.map { |_, n| n }
|
|
131
|
+
return if missing.empty?
|
|
142
132
|
|
|
143
133
|
raise UsageError.new("#{event} hooks must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
|
|
144
134
|
end
|
|
@@ -148,8 +138,6 @@ module Textus
|
|
|
148
138
|
|
|
149
139
|
Array(globs).any? { |g| File.fnmatch?(g, key.to_s, File::FNM_PATHNAME) }
|
|
150
140
|
end
|
|
151
|
-
|
|
152
|
-
def matches_any?(globs, key) = match?(globs, key)
|
|
153
141
|
end
|
|
154
142
|
end
|
|
155
143
|
end
|
data/lib/textus/hooks/loader.rb
CHANGED
|
@@ -1,8 +1,34 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Hooks
|
|
3
3
|
class Loader
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
# A small DSL object passed to user hook blocks. Routes `.on(...)` to the
|
|
5
|
+
# EventBus and `.rpc(...)` / `.register(...)` to the RpcRegistry.
|
|
6
|
+
class Dsl
|
|
7
|
+
def initialize(events:, rpc:)
|
|
8
|
+
@events = events
|
|
9
|
+
@rpc = rpc
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Pubsub registration — delegates to EventBus.
|
|
13
|
+
# Also handles RPC event names by delegating to RpcRegistry.
|
|
14
|
+
def on(event, name, keys: nil, &)
|
|
15
|
+
if Hooks::RpcRegistry::EVENTS.key?(event.to_sym)
|
|
16
|
+
@rpc.register(event, name, &)
|
|
17
|
+
else
|
|
18
|
+
@events.register(event, name, keys: keys, &)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Explicit RPC registration.
|
|
23
|
+
def register(event, name, &)
|
|
24
|
+
@rpc.register(event, name, &)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(events:, rpc:)
|
|
29
|
+
@events = events
|
|
30
|
+
@rpc = rpc
|
|
31
|
+
@dsl = Dsl.new(events: @events, rpc: @rpc)
|
|
6
32
|
end
|
|
7
33
|
|
|
8
34
|
def load_dir(dir)
|
|
@@ -18,7 +44,7 @@ module Textus
|
|
|
18
44
|
end
|
|
19
45
|
|
|
20
46
|
Textus.drain_hook_blocks.each do |blk|
|
|
21
|
-
blk.call(@
|
|
47
|
+
blk.call(@dsl)
|
|
22
48
|
rescue StandardError, ScriptError => e
|
|
23
49
|
raise UsageError.new("failed registering hook: #{e.class}: #{e.message}")
|
|
24
50
|
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Hooks
|
|
5
|
+
class RpcRegistry
|
|
6
|
+
EVENTS = {
|
|
7
|
+
resolve_intake: %i[caps config args],
|
|
8
|
+
transform_rows: %i[caps rows config],
|
|
9
|
+
validate: %i[caps],
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
PUBSUB_EVENTS = EventBus::EVENTS.keys.freeze
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@table = Hash.new { |h, k| h[k] = {} }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def register(event, name, &blk)
|
|
19
|
+
event_sym = event.to_sym
|
|
20
|
+
raise UsageError.new("#{event_sym} is a pubsub event; register on EventBus") if PUBSUB_EVENTS.include?(event_sym)
|
|
21
|
+
|
|
22
|
+
required = EVENTS[event_sym] or raise UsageError.new("unknown RPC event: #{event}")
|
|
23
|
+
shape_check!(event_sym, required, blk)
|
|
24
|
+
name = name.to_sym
|
|
25
|
+
raise UsageError.new("#{event_sym} '#{name}' already registered") if @table[event_sym].key?(name)
|
|
26
|
+
|
|
27
|
+
@table[event_sym][name] = blk
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def names(event) = @table[event.to_sym].keys
|
|
31
|
+
|
|
32
|
+
def callable(event, name)
|
|
33
|
+
@table[event.to_sym][name.to_sym] or raise UsageError.new("unknown #{event}: #{name}")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Invoke a registered callable, injecting `caps:` under the kwarg name
|
|
37
|
+
# the callable declares. Legacy `store:` is rejected (no shim).
|
|
38
|
+
def invoke(event, name, caps:, **other)
|
|
39
|
+
blk = callable(event, name)
|
|
40
|
+
params = blk.parameters
|
|
41
|
+
accepts_keyrest = params.any? { |t, _| t == :keyrest }
|
|
42
|
+
declared = params.each_with_object([]) { |(t, n), acc| acc << n if %i[key keyreq].include?(t) }
|
|
43
|
+
|
|
44
|
+
if declared.include?(:store)
|
|
45
|
+
raise UsageError.new(
|
|
46
|
+
"RPC callable for #{event} '#{name}' declares legacy `store:`; rename to `caps:` " \
|
|
47
|
+
"(Textus::Application::ReadCaps / WriteCaps)",
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
kwargs = other.dup
|
|
52
|
+
kwargs[:caps] = caps if accepts_keyrest || declared.include?(:caps)
|
|
53
|
+
blk.call(**kwargs)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def shape_check!(event, required, blk)
|
|
59
|
+
provided = blk.parameters.select { |t, _| %i[keyreq key keyrest].include?(t) } # rubocop:disable Style/HashSlice
|
|
60
|
+
return if provided.any? { |t, _| t == :keyrest }
|
|
61
|
+
|
|
62
|
+
param_names = provided.map { |_, n| n }
|
|
63
|
+
# Allow `store:` as a stand-in for `caps:` so registration succeeds;
|
|
64
|
+
# invoke will raise UsageError when the callable is actually called.
|
|
65
|
+
effective_required = if param_names.include?(:store)
|
|
66
|
+
required.map { |r| r == :caps ? :store : r }
|
|
67
|
+
else
|
|
68
|
+
required
|
|
69
|
+
end
|
|
70
|
+
missing = effective_required - param_names
|
|
71
|
+
return if missing.empty?
|
|
72
|
+
|
|
73
|
+
raise UsageError.new("#{event} RPC must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -3,18 +3,17 @@
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Infra
|
|
5
5
|
# Writes an "event_error" audit row when a user hook raises during
|
|
6
|
-
# Hooks::
|
|
6
|
+
# Hooks::EventBus publish. Attached at Store boot.
|
|
7
7
|
#
|
|
8
|
-
# Integration: uses Hooks::
|
|
8
|
+
# Integration: uses Hooks::EventBus#on_error callback (chosen over a
|
|
9
9
|
# synthetic :hook_error event because the bus already owns the
|
|
10
10
|
# rescue and the failure is a bus-internal concern, not a domain
|
|
11
11
|
# event subscribers should be able to filter by key glob).
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# that is properly a 0.18 port-extraction concern.
|
|
13
|
+
# Lifecycle audit rows for verb: "put" / "delete" / "rename" are written
|
|
14
|
+
# by Application::Envelope::Writer directly (it owns the
|
|
15
|
+
# audit-append-as-final-step invariant); this subscriber covers the
|
|
16
|
+
# hook-failure case the writer never sees.
|
|
18
17
|
class AuditSubscriber
|
|
19
18
|
def initialize(audit_log)
|
|
20
19
|
@audit_log = audit_log
|
|
@@ -21,7 +21,7 @@ module Textus
|
|
|
21
21
|
|
|
22
22
|
begin
|
|
23
23
|
store = Textus::Store.new(store_root)
|
|
24
|
-
|
|
24
|
+
store.session(role: "runner").refresh(key)
|
|
25
25
|
rescue StandardError
|
|
26
26
|
# Already logged via :refresh_failed; exit cleanly.
|
|
27
27
|
ensure
|
data/lib/textus/key/path.rb
CHANGED
|
@@ -4,12 +4,16 @@ module Textus
|
|
|
4
4
|
# Returns the absolute filesystem path for a manifest entry (the leaf file,
|
|
5
5
|
# not a nested directory). Adds the format's primary extension when the
|
|
6
6
|
# manifest entry's `path:` is extensionless.
|
|
7
|
-
|
|
7
|
+
#
|
|
8
|
+
# The first argument is a Manifest::Data (or anything responding to .root);
|
|
9
|
+
# callers historically passed the whole Manifest but should now pass
|
|
10
|
+
# `manifest.data`.
|
|
11
|
+
def self.resolve(data, mentry)
|
|
8
12
|
primary_ext = Entry.for_format(mentry.format).extensions.first
|
|
9
13
|
if File.extname(mentry.path) == ""
|
|
10
|
-
File.join(
|
|
14
|
+
File.join(data.root, "zones", mentry.path + primary_ext)
|
|
11
15
|
else
|
|
12
|
-
File.join(
|
|
16
|
+
File.join(data.root, "zones", mentry.path)
|
|
13
17
|
end
|
|
14
18
|
end
|
|
15
19
|
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
require_relative "schema"
|
|
2
|
+
require_relative "role_kinds"
|
|
3
|
+
|
|
4
|
+
module Textus
|
|
5
|
+
class Manifest
|
|
6
|
+
# Immutable, parsed view of a manifest YAML document.
|
|
7
|
+
#
|
|
8
|
+
# Holds raw structural data (zones, entries, audit_config, role_mapping)
|
|
9
|
+
# but no behaviour beyond accessors. Behaviour (zone authority, key
|
|
10
|
+
# resolution, rules) lives on Manifest::Policy / Resolver / Rules.
|
|
11
|
+
class Data
|
|
12
|
+
AUDIT_DEFAULTS = { max_size: 10_485_760, keep: 5 }.freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :raw, :root, :entries, :zones, :zone_readers, :audit_config, :role_mapping, :policy
|
|
15
|
+
|
|
16
|
+
def self.validate_key!(key)
|
|
17
|
+
raise UsageError.new("empty key") if key.nil? || key.empty?
|
|
18
|
+
|
|
19
|
+
Key::Grammar.validate!(key)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Forwarder used by Resolver and Entry classes that received a Data
|
|
23
|
+
# but were written against the historical Manifest API.
|
|
24
|
+
def validate_key!(key) = self.class.validate_key!(key)
|
|
25
|
+
|
|
26
|
+
def self.parse(raw, root:)
|
|
27
|
+
raise BadFrontmatter.new(File.join(root.to_s, "manifest.yaml"), "manifest must declare zones:") if Array(raw["zones"]).empty?
|
|
28
|
+
|
|
29
|
+
Schema.validate!(raw)
|
|
30
|
+
new(raw: raw, root: root)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(raw:, root:)
|
|
34
|
+
@raw = raw
|
|
35
|
+
@root = root
|
|
36
|
+
@zones = Array(raw["zones"]).to_h { |z| [z["name"], Array(z["write_policy"])] }
|
|
37
|
+
@zone_readers = Array(raw["zones"]).to_h do |z|
|
|
38
|
+
rp = z["read_policy"]
|
|
39
|
+
[z["name"], rp.nil? ? :all : Array(rp)]
|
|
40
|
+
end
|
|
41
|
+
@audit_config = build_audit_config(raw)
|
|
42
|
+
@role_mapping = RoleKinds.resolve(raw["roles"])
|
|
43
|
+
# Policy is constructed before entries because Entry validators
|
|
44
|
+
# call `entry.in_generator_zone?` which routes through Policy.
|
|
45
|
+
@policy = Policy.new(self)
|
|
46
|
+
@entries = build_entries(raw)
|
|
47
|
+
validate_declared_keys!
|
|
48
|
+
freeze
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def build_audit_config(raw)
|
|
54
|
+
a = raw["audit"] || {}
|
|
55
|
+
{
|
|
56
|
+
max_size: a["max_size"] || AUDIT_DEFAULTS[:max_size],
|
|
57
|
+
keep: a["keep"] || AUDIT_DEFAULTS[:keep],
|
|
58
|
+
}.freeze
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def build_entries(raw)
|
|
62
|
+
Array(raw["entries"]).map do |e|
|
|
63
|
+
entry = Manifest::Entry::Parser.call(self, e)
|
|
64
|
+
Manifest::Entry::Validators.run_all(entry)
|
|
65
|
+
entry
|
|
66
|
+
end.freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def validate_declared_keys!
|
|
70
|
+
@entries.each do |e|
|
|
71
|
+
raise UsageError.new("empty key") if e.key.nil? || e.key.empty?
|
|
72
|
+
|
|
73
|
+
Key::Grammar.validate!(e.key)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -19,13 +19,13 @@ module Textus
|
|
|
19
19
|
# rubocop:enable Metrics/ParameterLists, Lint/MissingSuper
|
|
20
20
|
|
|
21
21
|
def zone_writers
|
|
22
|
-
@manifest.zone_writers(@zone)
|
|
22
|
+
@manifest.policy.zone_writers(@zone)
|
|
23
23
|
rescue UsageError => e
|
|
24
24
|
raise UsageError.new("entry '#{@key}': #{e.message}")
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
def in_generator_zone? = @manifest.zone_kinds(@zone).include?(:generator)
|
|
28
|
-
def in_proposal_zone? = @manifest.zone_kinds(@zone).include?(:proposer)
|
|
27
|
+
def in_generator_zone? = @manifest.policy.zone_kinds(@zone).include?(:generator)
|
|
28
|
+
def in_proposal_zone? = @manifest.policy.zone_kinds(@zone).include?(:proposer)
|
|
29
29
|
|
|
30
30
|
def nested? = false
|
|
31
31
|
def derived? = false
|
|
@@ -42,7 +42,7 @@ module Textus
|
|
|
42
42
|
def index_filename = nil
|
|
43
43
|
|
|
44
44
|
PublishContext = Struct.new(
|
|
45
|
-
:repo_root, :manifest, :file_store, :root, :
|
|
45
|
+
:repo_root, :manifest, :file_store, :root, :caps, :rpc, :session, :ctx, :bus, :hook_context,
|
|
46
46
|
:reader, :emit, # callables: reader.call(key) → envelope; emit.call(event, **payload)
|
|
47
47
|
keyword_init: true
|
|
48
48
|
)
|
|
@@ -2,8 +2,8 @@ module Textus
|
|
|
2
2
|
class Manifest
|
|
3
3
|
class Entry
|
|
4
4
|
class Derived < Base
|
|
5
|
-
Projection = Data.define(:select, :pluck, :sort_by, :transform)
|
|
6
|
-
External = Data.define(:sources, :runner)
|
|
5
|
+
Projection = ::Data.define(:select, :pluck, :sort_by, :transform)
|
|
6
|
+
External = ::Data.define(:sources, :runner)
|
|
7
7
|
|
|
8
8
|
attr_reader :source, :template, :inject_boot, :events
|
|
9
9
|
|
|
@@ -22,9 +22,8 @@ module Textus
|
|
|
22
22
|
def publish_via(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
23
23
|
return nil unless in_generator_zone?
|
|
24
24
|
|
|
25
|
-
target_path = Textus::Application::
|
|
26
|
-
ctx: pctx.ctx,
|
|
27
|
-
bus: pctx.bus, root: pctx.root, store: pctx.store
|
|
25
|
+
target_path = Textus::Application::Write::Materializer.new(
|
|
26
|
+
ctx: pctx.ctx, caps: pctx.caps, rpc: pctx.rpc, session: pctx.session,
|
|
28
27
|
).run(self)
|
|
29
28
|
|
|
30
29
|
envelope = pctx.reader.call(@key)
|
|
@@ -4,7 +4,7 @@ module Textus
|
|
|
4
4
|
module Validators
|
|
5
5
|
module Events
|
|
6
6
|
def self.call(entry)
|
|
7
|
-
pubsub_events = Textus::Hooks::
|
|
7
|
+
pubsub_events = Textus::Hooks::EventBus::EVENTS.keys
|
|
8
8
|
events = entry.events
|
|
9
9
|
events.each_key do |evt|
|
|
10
10
|
next if pubsub_events.include?(evt.to_sym)
|