textus 0.22.0 → 0.29.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 +195 -48
- data/CHANGELOG.md +178 -0
- data/README.md +55 -13
- data/SPEC.md +79 -42
- data/docs/conventions.md +10 -0
- data/lib/textus/boot.rb +31 -29
- data/lib/textus/builder/pipeline.rb +13 -12
- data/lib/textus/call.rb +28 -0
- 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 +3 -3
- 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 +7 -7
- data/lib/textus/cli.rb +0 -7
- data/lib/textus/container.rb +23 -0
- data/lib/textus/dispatcher.rb +49 -0
- 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 +11 -9
- 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 +12 -3
- data/lib/textus/doctor.rb +24 -27
- data/lib/textus/domain/authorizer.rb +6 -6
- 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/sentinel.rb +9 -65
- data/lib/textus/domain/staleness/generator_check.rb +46 -26
- data/lib/textus/domain/staleness/intake_check.rb +20 -12
- data/lib/textus/domain/staleness.rb +4 -4
- data/lib/textus/envelope/io/reader.rb +44 -0
- data/lib/textus/{application/writes/envelope_io.rb → envelope/io/writer.rb} +32 -58
- data/lib/textus/hooks/builtin.rb +14 -14
- data/lib/textus/hooks/context.rb +30 -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/key/path.rb +7 -3
- data/lib/textus/maintenance/key_delete_prefix.rb +36 -0
- data/lib/textus/maintenance/key_mv_prefix.rb +46 -0
- data/lib/textus/maintenance/migrate.rb +51 -0
- data/lib/textus/maintenance/rule_lint.rb +56 -0
- data/lib/textus/maintenance/zone_mv.rb +51 -0
- data/lib/textus/maintenance.rb +15 -0
- data/lib/textus/manifest/data.rb +79 -0
- data/lib/textus/manifest/entry/base.rb +38 -18
- data/lib/textus/manifest/entry/derived.rb +8 -9
- data/lib/textus/manifest/entry/nested.rb +7 -9
- data/lib/textus/manifest/entry/parser.rb +2 -2
- data/lib/textus/manifest/entry/validators/events.rb +2 -2
- data/lib/textus/manifest/entry/validators/format_matrix.rb +2 -2
- data/lib/textus/manifest/entry/validators/index_filename.rb +1 -1
- data/lib/textus/manifest/entry/validators/inject_boot.rb +4 -2
- data/lib/textus/manifest/entry/validators/publish_each.rb +1 -1
- data/lib/textus/manifest/entry/validators.rb +2 -2
- data/lib/textus/manifest/entry.rb +0 -5
- 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 +47 -110
- data/lib/textus/mcp/errors.rb +32 -0
- data/lib/textus/mcp/server.rb +126 -0
- data/lib/textus/mcp/session.rb +40 -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/{infra → ports}/audit_log.rb +1 -1
- data/lib/textus/{infra → ports}/audit_subscriber.rb +7 -8
- data/lib/textus/{infra → ports}/build_lock.rb +1 -1
- data/lib/textus/{infra → ports}/clock.rb +1 -1
- data/lib/textus/{infra → ports}/publisher.rb +6 -6
- data/lib/textus/{infra → ports}/refresh/detached.rb +3 -3
- data/lib/textus/{infra → ports}/refresh/lock.rb +1 -1
- data/lib/textus/ports/sentinel_store.rb +67 -0
- data/lib/textus/ports/storage/file_stat.rb +19 -0
- data/lib/textus/{infra → ports}/storage/file_store.rb +1 -1
- data/lib/textus/projection.rb +91 -0
- data/lib/textus/read/audit.rb +111 -0
- data/lib/textus/read/blame.rb +81 -0
- data/lib/textus/read/boot.rb +18 -0
- data/lib/textus/read/deps.rb +24 -0
- data/lib/textus/read/doctor.rb +19 -0
- data/lib/textus/read/freshness.rb +101 -0
- data/lib/textus/read/get.rb +66 -0
- data/lib/textus/read/get_or_refresh.rb +69 -0
- data/lib/textus/read/list.rb +15 -0
- data/lib/textus/read/policy_explain.rb +37 -0
- data/lib/textus/read/published.rb +15 -0
- data/lib/textus/read/pulse.rb +89 -0
- data/lib/textus/read/rdeps.rb +25 -0
- data/lib/textus/read/schema_envelope.rb +16 -0
- data/lib/textus/read/stale.rb +17 -0
- data/lib/textus/read/uid.rb +20 -0
- data/lib/textus/read/validate_all.rb +22 -0
- data/lib/textus/read/validator.rb +84 -0
- data/lib/textus/read/where.rb +16 -0
- data/lib/textus/role_scope.rb +49 -0
- data/lib/textus/schema/tools.rb +14 -10
- data/lib/textus/store.rb +25 -11
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +86 -0
- data/lib/textus/write/authority_gate.rb +24 -0
- data/lib/textus/write/delete.rb +54 -0
- data/lib/textus/write/materializer.rb +48 -0
- data/lib/textus/write/mv.rb +123 -0
- data/lib/textus/write/publish.rb +66 -0
- data/lib/textus/write/put.rb +59 -0
- data/lib/textus/write/refresh_all.rb +44 -0
- data/lib/textus/write/refresh_orchestrator.rb +102 -0
- data/lib/textus/write/refresh_worker.rb +138 -0
- data/lib/textus/write/reject.rb +54 -0
- data/lib/textus.rb +7 -1
- metadata +75 -46
- data/lib/textus/application/context.rb +0 -34
- data/lib/textus/application/projection.rb +0 -91
- 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/validator.rb +0 -86
- data/lib/textus/application/reads/where.rb +0 -18
- data/lib/textus/application/refresh/all.rb +0 -52
- data/lib/textus/application/refresh/orchestrator.rb +0 -78
- data/lib/textus/application/refresh/worker.rb +0 -116
- data/lib/textus/application/writes/accept.rb +0 -89
- data/lib/textus/application/writes/authority_gate.rb +0 -26
- data/lib/textus/application/writes/delete.rb +0 -33
- data/lib/textus/application/writes/materializer.rb +0 -50
- 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
|
@@ -1,56 +1,38 @@
|
|
|
1
1
|
require "fileutils"
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
|
-
|
|
5
|
-
module
|
|
6
|
-
# Owns the write pipeline (validate, serialize, etag-check, write, audit)
|
|
7
|
-
#
|
|
8
|
-
#
|
|
4
|
+
class Envelope
|
|
5
|
+
module IO
|
|
6
|
+
# Owns the write pipeline (validate, serialize, etag-check, write, audit).
|
|
7
|
+
# Talks to ports (FileStore, Schemas, AuditLog, Manifest) and an
|
|
8
|
+
# Reader for the existing-uid lookup.
|
|
9
|
+
#
|
|
10
|
+
# Invariant: every public method's final action is @audit_log.append(...).
|
|
9
11
|
#
|
|
10
12
|
# No permission check, no event firing — those belong to the caller
|
|
11
|
-
# (
|
|
12
|
-
class
|
|
13
|
+
# (Write::Put / ::Delete / ::Mv).
|
|
14
|
+
class Writer
|
|
13
15
|
Payload = Data.define(:meta, :body, :content)
|
|
14
16
|
|
|
15
|
-
def initialize(file_store:, manifest:, schemas:, audit_log:,
|
|
17
|
+
def initialize(file_store:, manifest:, schemas:, audit_log:, call:, reader:)
|
|
16
18
|
@file_store = file_store
|
|
17
19
|
@manifest = manifest
|
|
18
20
|
@schemas = schemas
|
|
19
21
|
@audit_log = audit_log
|
|
20
|
-
@
|
|
22
|
+
@call = call
|
|
23
|
+
@reader = reader
|
|
21
24
|
end
|
|
22
25
|
|
|
23
|
-
def
|
|
24
|
-
|
|
25
|
-
# Reads an envelope by key, returning nil when absent. Used by Mv
|
|
26
|
-
# to inspect pre-move state (UID presence, content surfacing) so
|
|
27
|
-
# the move pipeline can consolidate I/O in one place.
|
|
28
|
-
def read_envelope(key)
|
|
29
|
-
res = @manifest.resolver.resolve(key)
|
|
30
|
-
path = res.path
|
|
31
|
-
return nil unless @file_store.exists?(path)
|
|
32
|
-
|
|
33
|
-
mentry = res.entry
|
|
34
|
-
raw = @file_store.read(path)
|
|
35
|
-
parsed = Entry.for_format(mentry.format).parse(raw, path: path)
|
|
36
|
-
Envelope.build(
|
|
37
|
-
key: key, mentry: mentry, path: path,
|
|
38
|
-
meta: parsed["_meta"], body: parsed["body"],
|
|
39
|
-
etag: Etag.for_bytes(raw), content: parsed["content"]
|
|
40
|
-
)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def write(key, mentry:, payload:, if_etag: nil)
|
|
26
|
+
def put(key, mentry:, payload:, if_etag: nil)
|
|
44
27
|
path = @manifest.resolver.resolve(key).path
|
|
45
28
|
|
|
46
29
|
meta = payload.meta || {}
|
|
47
|
-
strategy = Entry.for_format(mentry.format)
|
|
48
30
|
|
|
49
|
-
existing_uid =
|
|
31
|
+
existing_uid = @reader.existing_uid(key)
|
|
50
32
|
meta, content = ensure_uid(mentry.format, meta, payload.content, existing_uid)
|
|
51
33
|
|
|
52
34
|
bytes, eff_meta, eff_body, eff_content = serialize_for_put(
|
|
53
|
-
mentry: mentry, path: path,
|
|
35
|
+
mentry: mentry, path: path,
|
|
54
36
|
meta: meta, body: payload.body, content: content
|
|
55
37
|
)
|
|
56
38
|
|
|
@@ -69,19 +51,22 @@ module Textus
|
|
|
69
51
|
|
|
70
52
|
@file_store.write(path, bytes)
|
|
71
53
|
etag_after = Etag.for_bytes(bytes)
|
|
72
|
-
|
|
73
|
-
role: @ctx.role, verb: "put", key: key,
|
|
74
|
-
etag_before: etag_before, etag_after: etag_after,
|
|
75
|
-
extras: @ctx.correlation_id ? { "correlation_id" => @ctx.correlation_id } : nil
|
|
76
|
-
)
|
|
77
|
-
Envelope.build(
|
|
54
|
+
envelope = Textus::Envelope.build(
|
|
78
55
|
key: key, mentry: mentry, path: path,
|
|
79
56
|
meta: eff_meta, body: eff_body, etag: etag_after, content: eff_content
|
|
80
57
|
)
|
|
58
|
+
@audit_log.append(
|
|
59
|
+
role: @call.role, verb: "put", key: key,
|
|
60
|
+
etag_before: etag_before, etag_after: etag_after,
|
|
61
|
+
extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
|
|
62
|
+
)
|
|
63
|
+
envelope
|
|
81
64
|
end
|
|
82
65
|
|
|
83
|
-
def delete(key, mentry
|
|
84
|
-
|
|
66
|
+
def delete(key, mentry: nil, if_etag: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
67
|
+
# `mentry:` is accepted for symmetry with `put` / `move` and to
|
|
68
|
+
# leave room for future format-specific delete hooks; no field
|
|
69
|
+
# on it is needed today.
|
|
85
70
|
path = @manifest.resolver.resolve(key).path
|
|
86
71
|
raise UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)) unless @file_store.exists?(path)
|
|
87
72
|
|
|
@@ -90,9 +75,9 @@ module Textus
|
|
|
90
75
|
|
|
91
76
|
@file_store.delete(path)
|
|
92
77
|
@audit_log.append(
|
|
93
|
-
role: @
|
|
78
|
+
role: @call.role, verb: "delete", key: key,
|
|
94
79
|
etag_before: etag_before, etag_after: nil,
|
|
95
|
-
extras: @
|
|
80
|
+
extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
|
|
96
81
|
)
|
|
97
82
|
end
|
|
98
83
|
|
|
@@ -112,7 +97,7 @@ module Textus
|
|
|
112
97
|
|
|
113
98
|
raw = @file_store.read(to_path)
|
|
114
99
|
parsed = Entry.for_format(new_mentry.format).parse(raw, path: to_path)
|
|
115
|
-
envelope = Envelope.build(
|
|
100
|
+
envelope = Textus::Envelope.build(
|
|
116
101
|
key: to_key, mentry: new_mentry, path: to_path,
|
|
117
102
|
meta: parsed["_meta"], body: parsed["body"],
|
|
118
103
|
etag: etag_after, content: parsed["content"]
|
|
@@ -123,10 +108,10 @@ module Textus
|
|
|
123
108
|
"from_path" => from_path, "to_path" => to_path,
|
|
124
109
|
"uid" => envelope.uid
|
|
125
110
|
}
|
|
126
|
-
extras["correlation_id"] = @
|
|
111
|
+
extras["correlation_id"] = @call.correlation_id if @call.correlation_id
|
|
127
112
|
|
|
128
113
|
@audit_log.append(
|
|
129
|
-
role: @
|
|
114
|
+
role: @call.role, verb: "mv", key: to_key,
|
|
130
115
|
etag_before: etag_before, etag_after: etag_after,
|
|
131
116
|
extras: extras
|
|
132
117
|
)
|
|
@@ -136,16 +121,6 @@ module Textus
|
|
|
136
121
|
|
|
137
122
|
private
|
|
138
123
|
|
|
139
|
-
def existing_uid_for(mentry, path)
|
|
140
|
-
return nil unless @file_store.exists?(path)
|
|
141
|
-
|
|
142
|
-
raw = @file_store.read(path)
|
|
143
|
-
parsed = Entry.for_format(mentry.format).parse(raw, path: path)
|
|
144
|
-
Envelope.extract_uid(parsed["_meta"])
|
|
145
|
-
rescue StandardError
|
|
146
|
-
nil
|
|
147
|
-
end
|
|
148
|
-
|
|
149
124
|
def ensure_uid(format, meta, content, existing_uid)
|
|
150
125
|
Textus::Entry.for_format(format).inject_uid(meta, content, existing_uid)
|
|
151
126
|
end
|
|
@@ -154,8 +129,7 @@ module Textus
|
|
|
154
129
|
Textus::Entry.for_format(format).enforce_name_match!(path, meta)
|
|
155
130
|
end
|
|
156
131
|
|
|
157
|
-
def serialize_for_put(mentry:, path:,
|
|
158
|
-
_ = strategy
|
|
132
|
+
def serialize_for_put(mentry:, path:, meta:, body:, content:)
|
|
159
133
|
Textus::Entry.for_format(mentry.format).serialize_for_put(
|
|
160
134
|
meta: meta, body: body, content: content, path: path,
|
|
161
135
|
)
|
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,48 @@
|
|
|
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 RoleScope 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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
def self.for(container:, call:)
|
|
12
|
+
scope = Textus::RoleScope.new(
|
|
13
|
+
container: container,
|
|
14
|
+
role: call.role,
|
|
15
|
+
correlation_id: call.correlation_id,
|
|
16
|
+
dry_run: call.dry_run,
|
|
17
|
+
)
|
|
18
|
+
new(scope: scope)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(scope:)
|
|
22
|
+
@scope = scope
|
|
23
|
+
@role = scope.role
|
|
24
|
+
@correlation_id = scope.correlation_id
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def backend
|
|
28
|
+
@scope
|
|
15
29
|
end
|
|
16
30
|
|
|
17
31
|
# read
|
|
18
|
-
def get(key) = @
|
|
19
|
-
def list(**) = @
|
|
20
|
-
def deps(key) = @
|
|
21
|
-
def freshness(key) = @
|
|
32
|
+
def get(key) = @scope.get(key)
|
|
33
|
+
def list(**) = @scope.list(**)
|
|
34
|
+
def deps(key) = @scope.deps(key)
|
|
35
|
+
def freshness(key) = @scope.freshness(key)
|
|
22
36
|
|
|
23
37
|
# write (authorized + audited)
|
|
24
|
-
def put(key, **) = @
|
|
25
|
-
def delete(key, **) = @
|
|
26
|
-
|
|
38
|
+
def put(key, **) = @scope.put(key, **)
|
|
39
|
+
def delete(key, **) = @scope.delete(key, **)
|
|
40
|
+
|
|
41
|
+
def audit(verb, key:, **)
|
|
42
|
+
@scope.container.audit_log.append(role: @role, verb: verb, key: key, **)
|
|
43
|
+
end
|
|
27
44
|
|
|
28
45
|
# fan-out
|
|
29
46
|
def publish_followup(event, **)
|
|
30
|
-
@
|
|
47
|
+
@scope.container.events.publish(event, ctx: self, **)
|
|
31
48
|
end
|
|
32
49
|
|
|
33
50
|
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::Container)",
|
|
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
|
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,36 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Maintenance
|
|
3
|
+
# Bulk-delete every leaf key under `prefix`.
|
|
4
|
+
class KeyDeletePrefix
|
|
5
|
+
def initialize(container:, call:)
|
|
6
|
+
@container = container
|
|
7
|
+
@call = call
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(prefix:, dry_run: false)
|
|
11
|
+
raise UsageError.new("prefix required") if prefix.nil? || prefix.empty?
|
|
12
|
+
|
|
13
|
+
leaves = Read::List.new(container: @container)
|
|
14
|
+
.call(prefix: prefix)
|
|
15
|
+
.map { |r| r.is_a?(Hash) ? (r["key"] || r[:key]) : r }
|
|
16
|
+
|
|
17
|
+
warnings = leaves.empty? ? ["no keys under #{prefix}"] : []
|
|
18
|
+
steps = leaves.map { |k| { "op" => "delete", "key" => k } }
|
|
19
|
+
|
|
20
|
+
plan = Plan.new(steps: steps, warnings: warnings)
|
|
21
|
+
return plan if dry_run
|
|
22
|
+
|
|
23
|
+
steps.each do |s|
|
|
24
|
+
delete.call(s["key"])
|
|
25
|
+
end
|
|
26
|
+
plan
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def delete
|
|
32
|
+
Write::Delete.new(container: @container, call: @call)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|