textus 0.20.2 → 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 +194 -0
- data/README.md +8 -5
- data/SPEC.md +54 -15
- 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/{intro.rb → boot.rb} +49 -29
- data/lib/textus/builder/pipeline.rb +5 -5
- 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 +4 -2
- data/lib/textus/cli/verb/blame.rb +1 -1
- data/lib/textus/cli/verb/boot.rb +13 -0
- 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 +17 -0
- 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/cli.rb +1 -1
- 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/errors.rb +16 -0
- 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_log.rb +126 -16
- 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 +44 -7
- data/lib/textus/manifest/entry/derived.rb +41 -6
- data/lib/textus/manifest/entry/intake.rb +15 -3
- data/lib/textus/manifest/entry/leaf.rb +6 -5
- data/lib/textus/manifest/entry/nested.rb +42 -3
- data/lib/textus/manifest/entry/parser.rb +8 -44
- data/lib/textus/manifest/entry/validators/events.rb +2 -2
- data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -4
- data/lib/textus/manifest/entry/validators/index_filename.rb +2 -1
- data/lib/textus/manifest/entry/validators/inject_boot.rb +19 -0
- data/lib/textus/manifest/entry/validators/publish_each.rb +4 -3
- data/lib/textus/manifest/entry/validators.rb +1 -1
- data/lib/textus/manifest/entry.rb +3 -0
- data/lib/textus/manifest/policy.rb +48 -0
- data/lib/textus/manifest/resolver.rb +18 -18
- data/lib/textus/manifest/rules.rb +1 -1
- data/lib/textus/manifest/schema.rb +20 -6
- data/lib/textus/manifest.rb +53 -101
- 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 +17 -8
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +8 -1
- metadata +65 -38
- data/lib/textus/application/reads/audit.rb +0 -69
- 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/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 -162
- data/lib/textus/application/writes/put.rb +0 -37
- data/lib/textus/application/writes/reject.rb +0 -50
- data/lib/textus/cli/verb/intro.rb +0 -13
- data/lib/textus/infra/event_bus.rb +0 -27
- data/lib/textus/manifest/entry/validators/inject_intro.rb +0 -21
- data/lib/textus/operations.rb +0 -169
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
require "timeout"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Application
|
|
5
|
-
module Refresh
|
|
6
|
-
class Worker
|
|
7
|
-
FETCH_TIMEOUT_SECONDS = 30
|
|
8
|
-
|
|
9
|
-
def initialize(ctx:, manifest:, envelope_io:, bus:, store:, authorizer:, hook_context:) # rubocop:disable Metrics/ParameterLists
|
|
10
|
-
@ctx = ctx
|
|
11
|
-
@manifest = manifest
|
|
12
|
-
@envelope_io = envelope_io
|
|
13
|
-
@bus = bus
|
|
14
|
-
@store = store
|
|
15
|
-
@authorizer = authorizer
|
|
16
|
-
@hook_context = hook_context
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def run(key)
|
|
20
|
-
res = @manifest.resolver.resolve(key)
|
|
21
|
-
mentry = res.entry
|
|
22
|
-
path = res.path
|
|
23
|
-
remaining = res.remaining
|
|
24
|
-
raise UsageError.new("no intake declared for '#{key}'") unless mentry.is_a?(Textus::Manifest::Entry::Intake)
|
|
25
|
-
|
|
26
|
-
before_etag = File.exist?(path) ? Etag.for_file(path) : nil
|
|
27
|
-
result = fetch_with_bus(key, mentry, remaining)
|
|
28
|
-
persist_and_notify(key, mentry, result, before_etag)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
private
|
|
32
|
-
|
|
33
|
-
def fetch_timeout_for(key)
|
|
34
|
-
rule = @manifest.rules_for(key)
|
|
35
|
-
rule&.refresh&.fetch_timeout_seconds || FETCH_TIMEOUT_SECONDS
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def fetch_with_bus(key, mentry, remaining)
|
|
39
|
-
callable = @bus.rpc_callable(:resolve_intake, mentry.handler)
|
|
40
|
-
@bus.publish(:refresh_started, ctx: @hook_context, key: key, mode: :sync)
|
|
41
|
-
call_intake(key, mentry, callable, remaining)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def call_intake(key, mentry, callable, remaining)
|
|
45
|
-
timeout = fetch_timeout_for(key)
|
|
46
|
-
Timeout.timeout(timeout) do
|
|
47
|
-
callable.call(
|
|
48
|
-
store: @store,
|
|
49
|
-
config: mentry.config,
|
|
50
|
-
args: { trigger_key: key, leaf_segments: remaining || [] },
|
|
51
|
-
)
|
|
52
|
-
end
|
|
53
|
-
rescue Timeout::Error
|
|
54
|
-
@bus.publish(:refresh_failed, ctx: @hook_context, key: key,
|
|
55
|
-
error_class: "Timeout::Error",
|
|
56
|
-
error_message: "intake '#{mentry.handler}' exceeded #{timeout}s")
|
|
57
|
-
raise UsageError.new("intake '#{mentry.handler}' exceeded #{timeout}s timeout")
|
|
58
|
-
rescue Textus::Error => e
|
|
59
|
-
@bus.publish(:refresh_failed, ctx: @hook_context, key: key, error_class: e.class.name,
|
|
60
|
-
error_message: e.message)
|
|
61
|
-
raise
|
|
62
|
-
rescue StandardError => e
|
|
63
|
-
@bus.publish(:refresh_failed, ctx: @hook_context, key: key, error_class: e.class.name,
|
|
64
|
-
error_message: e.message)
|
|
65
|
-
raise UsageError.new("intake '#{mentry.handler}' raised: #{e.class}: #{e.message}")
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def persist_and_notify(key, mentry, result, before_etag)
|
|
69
|
-
normalized = self.class.send(:normalize_action_result, result, format: mentry.format)
|
|
70
|
-
@authorizer.authorize_write!(mentry, role: @ctx.role)
|
|
71
|
-
envelope = @envelope_io.write(
|
|
72
|
-
key,
|
|
73
|
-
mentry: mentry,
|
|
74
|
-
payload: Textus::Application::Writes::EnvelopeIO::Payload.new(
|
|
75
|
-
meta: normalized[:meta], body: normalized[:body], content: normalized[:content],
|
|
76
|
-
),
|
|
77
|
-
)
|
|
78
|
-
change = detect_change(before_etag, envelope)
|
|
79
|
-
@bus.publish(:entry_refreshed, ctx: @hook_context, key: key, envelope: envelope, change: change) unless change == :unchanged
|
|
80
|
-
envelope
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def detect_change(before_etag, envelope)
|
|
84
|
-
if before_etag.nil? then :created
|
|
85
|
-
elsif envelope.etag == before_etag then :unchanged
|
|
86
|
-
else :updated
|
|
87
|
-
end
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def self.normalize_action_result(res, format:)
|
|
91
|
-
res = res.transform_keys(&:to_s) if res.is_a?(Hash)
|
|
92
|
-
res ||= {}
|
|
93
|
-
meta_val = res["_meta"]
|
|
94
|
-
body = res["body"]
|
|
95
|
-
content = res["content"]
|
|
96
|
-
|
|
97
|
-
case format
|
|
98
|
-
when "markdown" then { meta: meta_val || {}, body: body.to_s, content: nil }
|
|
99
|
-
when "text" then { meta: {}, body: body.to_s, content: nil }
|
|
100
|
-
when "json", "yaml"
|
|
101
|
-
if !content.nil?
|
|
102
|
-
{ meta: meta_val || {}, body: nil, content: content }
|
|
103
|
-
elsif !body.nil?
|
|
104
|
-
{ meta: {}, body: body.to_s, content: nil }
|
|
105
|
-
else
|
|
106
|
-
raise Textus::UsageError.new("intake for #{format} returned neither content nor body")
|
|
107
|
-
end
|
|
108
|
-
else
|
|
109
|
-
raise Textus::UsageError.new("unknown format #{format.inspect}")
|
|
110
|
-
end
|
|
111
|
-
end
|
|
112
|
-
private_class_method :normalize_action_result
|
|
113
|
-
end
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
end
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
require_relative "authority_gate"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Application
|
|
5
|
-
module Writes
|
|
6
|
-
class Accept
|
|
7
|
-
include AuthorityGate
|
|
8
|
-
|
|
9
|
-
def initialize(ctx:, manifest:, file_store:, schemas:, envelope_io:, bus:, authorizer:, hook_context:) # rubocop:disable Metrics/ParameterLists
|
|
10
|
-
@ctx = ctx
|
|
11
|
-
@manifest = manifest
|
|
12
|
-
@file_store = file_store
|
|
13
|
-
@schemas = schemas
|
|
14
|
-
@envelope_io = envelope_io
|
|
15
|
-
@bus = bus
|
|
16
|
-
@authorizer = authorizer
|
|
17
|
-
@hook_context = hook_context
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def call(pending_key)
|
|
21
|
-
assert_accept_authority!("accept")
|
|
22
|
-
|
|
23
|
-
env = Textus::Application::Reads::Get.new(
|
|
24
|
-
ctx: @ctx, manifest: @manifest, file_store: @file_store,
|
|
25
|
-
).call(pending_key)
|
|
26
|
-
proposal = env.meta["proposal"] or raise ProposalError.new("entry has no proposal block: #{pending_key}")
|
|
27
|
-
target = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
|
|
28
|
-
action = proposal["action"] || "put"
|
|
29
|
-
|
|
30
|
-
evaluate_promotion!(env, target)
|
|
31
|
-
|
|
32
|
-
case action
|
|
33
|
-
when "put"
|
|
34
|
-
# Nested proposal "frontmatter" — the meta to write to the accepted
|
|
35
|
-
# target. Not related to the removed intake-handler legacy bridge.
|
|
36
|
-
target_meta = env.meta["frontmatter"] || {}
|
|
37
|
-
target_body = env.body
|
|
38
|
-
put_op.call(target, meta: target_meta, body: target_body)
|
|
39
|
-
when "delete"
|
|
40
|
-
delete_op.call(target)
|
|
41
|
-
else
|
|
42
|
-
raise ProposalError.new("unknown action: #{action}")
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
delete_op.call(pending_key)
|
|
46
|
-
|
|
47
|
-
@bus.publish(:proposal_accepted,
|
|
48
|
-
ctx: @hook_context,
|
|
49
|
-
key: pending_key,
|
|
50
|
-
target_key: target)
|
|
51
|
-
|
|
52
|
-
{ "protocol" => PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action }
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
private
|
|
56
|
-
|
|
57
|
-
def put_op
|
|
58
|
-
@put_op ||= Textus::Application::Writes::Put.new(
|
|
59
|
-
ctx: @ctx, manifest: @manifest, envelope_io: @envelope_io,
|
|
60
|
-
bus: @bus, authorizer: @authorizer, hook_context: @hook_context
|
|
61
|
-
)
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def delete_op
|
|
65
|
-
@delete_op ||= Textus::Application::Writes::Delete.new(
|
|
66
|
-
ctx: @ctx, manifest: @manifest, envelope_io: @envelope_io,
|
|
67
|
-
bus: @bus, authorizer: @authorizer, hook_context: @hook_context
|
|
68
|
-
)
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def evaluate_promotion!(env, target_key)
|
|
72
|
-
rules = @manifest.rules_for(target_key)
|
|
73
|
-
promote = rules.promote
|
|
74
|
-
return if promote.nil? || promote.requires.empty?
|
|
75
|
-
|
|
76
|
-
policy = Textus::Application::Policy::Promotion.from_names(promote.requires)
|
|
77
|
-
result = policy.evaluate(
|
|
78
|
-
entry: env, schemas: @schemas, manifest: @manifest, role: @ctx.role,
|
|
79
|
-
)
|
|
80
|
-
return if result.ok?
|
|
81
|
-
|
|
82
|
-
raise ProposalError.new(
|
|
83
|
-
"promotion gate failed: #{result.reasons.join("; ")}",
|
|
84
|
-
)
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
end
|
|
89
|
-
end
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Application
|
|
3
|
-
module Writes
|
|
4
|
-
class Delete
|
|
5
|
-
def initialize(ctx:, manifest:, envelope_io:, bus:, authorizer:, hook_context:)
|
|
6
|
-
@ctx = ctx
|
|
7
|
-
@manifest = manifest
|
|
8
|
-
@envelope_io = envelope_io
|
|
9
|
-
@bus = bus
|
|
10
|
-
@authorizer = authorizer
|
|
11
|
-
@hook_context = hook_context
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def call(key, if_etag: nil, suppress_events: false)
|
|
15
|
-
@manifest.validate_key!(key)
|
|
16
|
-
mentry = @manifest.resolver.resolve(key).entry
|
|
17
|
-
|
|
18
|
-
@authorizer.authorize_write!(mentry, role: @ctx.role)
|
|
19
|
-
|
|
20
|
-
@envelope_io.delete(key, mentry: mentry, if_etag: if_etag)
|
|
21
|
-
|
|
22
|
-
unless suppress_events
|
|
23
|
-
@bus.publish(:entry_deleted,
|
|
24
|
-
ctx: @hook_context,
|
|
25
|
-
key: key)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
{ "protocol" => Textus::PROTOCOL, "ok" => true, "key" => key, "deleted" => true }
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
end
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Application
|
|
3
|
-
module Writes
|
|
4
|
-
class Mv
|
|
5
|
-
def initialize(ctx:, manifest:, envelope_io:, bus:, authorizer:, hook_context:)
|
|
6
|
-
@ctx = ctx
|
|
7
|
-
@manifest = manifest
|
|
8
|
-
@envelope_io = envelope_io
|
|
9
|
-
@bus = bus
|
|
10
|
-
@authorizer = authorizer
|
|
11
|
-
@hook_context = hook_context
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def call(old_key, new_key, dry_run: false)
|
|
15
|
-
old_res, new_res = prepare(old_key, new_key)
|
|
16
|
-
return dry_run_result(old_key, new_key, old_res, new_res) if dry_run
|
|
17
|
-
|
|
18
|
-
ensure_uid!(old_key, old_res.entry)
|
|
19
|
-
envelope = @envelope_io.move(
|
|
20
|
-
from_key: old_key, to_key: new_key,
|
|
21
|
-
new_mentry: new_res.entry
|
|
22
|
-
)
|
|
23
|
-
publish_renamed(old_key, new_key, envelope)
|
|
24
|
-
success_result(old_key, new_key, old_res, new_res, envelope)
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
private
|
|
28
|
-
|
|
29
|
-
def prepare(old_key, new_key)
|
|
30
|
-
@manifest.validate_key!(old_key)
|
|
31
|
-
@manifest.validate_key!(new_key)
|
|
32
|
-
raise UsageError.new("mv: old and new keys are identical") if old_key == new_key
|
|
33
|
-
|
|
34
|
-
old_res = @manifest.resolver.resolve(old_key)
|
|
35
|
-
new_res = @manifest.resolver.resolve(new_key)
|
|
36
|
-
raise UnknownKey.new(old_key) unless @envelope_io.exists?(old_res.path)
|
|
37
|
-
|
|
38
|
-
validate_zone_and_format!(old_res.entry, new_res.entry)
|
|
39
|
-
@authorizer.authorize_write!(old_res.entry, role: @ctx.role)
|
|
40
|
-
@authorizer.authorize_write!(new_res.entry, role: @ctx.role)
|
|
41
|
-
raise UsageError.new("mv: target '#{new_key}' already exists at #{new_res.path}") if @envelope_io.exists?(new_res.path)
|
|
42
|
-
|
|
43
|
-
[old_res, new_res]
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def validate_zone_and_format!(old_mentry, new_mentry)
|
|
47
|
-
if old_mentry.zone != new_mentry.zone
|
|
48
|
-
raise UsageError.new(
|
|
49
|
-
"mv: cross-zone move refused (#{old_mentry.zone} → #{new_mentry.zone}). " \
|
|
50
|
-
"Use put+delete for cross-zone moves.",
|
|
51
|
-
)
|
|
52
|
-
end
|
|
53
|
-
return if old_mentry.format == new_mentry.format
|
|
54
|
-
|
|
55
|
-
raise UsageError.new("mv: format mismatch (#{old_mentry.format} → #{new_mentry.format}); refusing.")
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
# If the source file lacks a UID, rewrite it in-place via EnvelopeIO#write
|
|
59
|
-
# so a UID gets injected before the move. This replaces the previous
|
|
60
|
-
# Put(suppress_events: true) bypass with a direct EnvelopeIO call —
|
|
61
|
-
# producing one "put" audit row, then the "mv" row from EnvelopeIO#move.
|
|
62
|
-
def ensure_uid!(old_key, old_mentry)
|
|
63
|
-
pre_env = @envelope_io.read_envelope(old_key)
|
|
64
|
-
return if pre_env.uid
|
|
65
|
-
|
|
66
|
-
@envelope_io.write(
|
|
67
|
-
old_key, mentry: old_mentry,
|
|
68
|
-
payload: EnvelopeIO::Payload.new(
|
|
69
|
-
meta: pre_env.meta, body: pre_env.body, content: pre_env.content,
|
|
70
|
-
)
|
|
71
|
-
)
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def publish_renamed(old_key, new_key, envelope)
|
|
75
|
-
@bus.publish(:entry_renamed,
|
|
76
|
-
ctx: @hook_context,
|
|
77
|
-
key: new_key,
|
|
78
|
-
from_key: old_key,
|
|
79
|
-
to_key: new_key,
|
|
80
|
-
envelope: envelope)
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def dry_run_result(old_key, new_key, old_res, new_res)
|
|
84
|
-
pre_env = @envelope_io.read_envelope(old_key)
|
|
85
|
-
{
|
|
86
|
-
"protocol" => PROTOCOL, "ok" => true, "dry_run" => true,
|
|
87
|
-
"from_key" => old_key, "to_key" => new_key,
|
|
88
|
-
"from_path" => old_res.path, "to_path" => new_res.path,
|
|
89
|
-
"uid" => pre_env.uid
|
|
90
|
-
}
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def success_result(old_key, new_key, old_res, new_res, envelope)
|
|
94
|
-
{
|
|
95
|
-
"protocol" => PROTOCOL, "ok" => true,
|
|
96
|
-
"from_key" => old_key, "to_key" => new_key,
|
|
97
|
-
"from_path" => old_res.path, "to_path" => new_res.path,
|
|
98
|
-
"uid" => envelope.uid,
|
|
99
|
-
"envelope" => envelope.to_h_for_wire
|
|
100
|
-
}
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
end
|
|
105
|
-
end
|
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Application
|
|
3
|
-
module Writes
|
|
4
|
-
# Single-pass publish use case: materializes Derived entries (template +
|
|
5
|
-
# projection + external runner) AND copies Leaf/Nested entries to their
|
|
6
|
-
# publish targets. Replaces the former two-step Build + Publish split.
|
|
7
|
-
#
|
|
8
|
-
# Return shape: { "protocol", "built", "published_leaves" }
|
|
9
|
-
# — wire-compatible with what the `textus build` CLI verb previously
|
|
10
|
-
# assembled by merging Build + old Publish results.
|
|
11
|
-
class Publish
|
|
12
|
-
def initialize(ctx:, manifest:, file_store:, bus:, root:, store:, hook_context:) # rubocop:disable Metrics/ParameterLists
|
|
13
|
-
@ctx = ctx
|
|
14
|
-
@manifest = manifest
|
|
15
|
-
@file_store = file_store
|
|
16
|
-
@bus = bus
|
|
17
|
-
@root = root
|
|
18
|
-
@store = store
|
|
19
|
-
@hook_context = hook_context
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def call(prefix: nil)
|
|
23
|
-
built = []
|
|
24
|
-
leaves = []
|
|
25
|
-
repo_root = File.dirname(@root)
|
|
26
|
-
|
|
27
|
-
@manifest.entries.each do |mentry|
|
|
28
|
-
next if prefix && !entry_matches_prefix?(mentry, prefix)
|
|
29
|
-
|
|
30
|
-
case mentry
|
|
31
|
-
when Textus::Manifest::Entry::Derived
|
|
32
|
-
next unless mentry.in_generator_zone?
|
|
33
|
-
|
|
34
|
-
result = materialize_derived(mentry, repo_root)
|
|
35
|
-
built << result if result
|
|
36
|
-
when Textus::Manifest::Entry::Nested
|
|
37
|
-
next unless mentry.publish_each
|
|
38
|
-
|
|
39
|
-
publish_nested(mentry, repo_root, prefix, leaves)
|
|
40
|
-
when Textus::Manifest::Entry::Leaf
|
|
41
|
-
next if Array(mentry.publish_to).empty?
|
|
42
|
-
|
|
43
|
-
result = publish_leaf_entry(mentry, repo_root)
|
|
44
|
-
built << result if result
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
{ "protocol" => Textus::PROTOCOL, "built" => built, "published_leaves" => leaves }
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
private
|
|
52
|
-
|
|
53
|
-
# Materialize a Derived entry and copy to publish_to targets.
|
|
54
|
-
def materialize_derived(mentry, repo_root)
|
|
55
|
-
target_path = Materializer.new(
|
|
56
|
-
ctx: @ctx, manifest: @manifest, file_store: @file_store,
|
|
57
|
-
bus: @bus, root: @root, store: @store
|
|
58
|
-
).run(mentry)
|
|
59
|
-
|
|
60
|
-
publish_derived_copies(mentry, target_path, repo_root)
|
|
61
|
-
fire_build_completed(mentry)
|
|
62
|
-
|
|
63
|
-
{ "key" => mentry.key, "path" => target_path, "published_to" => mentry.publish_to }
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def publish_derived_copies(mentry, target_path, repo_root)
|
|
67
|
-
envelope = reader.call(mentry.key)
|
|
68
|
-
mentry.publish_to.each do |rel|
|
|
69
|
-
target_abs = File.join(repo_root, rel)
|
|
70
|
-
Textus::Infra::Publisher.publish(source: target_path, target: target_abs, store_root: @root)
|
|
71
|
-
publish_event(:file_published,
|
|
72
|
-
key: mentry.key,
|
|
73
|
-
envelope: envelope,
|
|
74
|
-
source: target_path,
|
|
75
|
-
target: target_abs)
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def fire_build_completed(mentry)
|
|
80
|
-
envelope = reader.call(mentry.key)
|
|
81
|
-
src = mentry.source
|
|
82
|
-
selects = src.is_a?(Textus::Manifest::Entry::Derived::Projection) ? Array(src.select).compact : []
|
|
83
|
-
publish_event(:build_completed,
|
|
84
|
-
key: mentry.key,
|
|
85
|
-
envelope: envelope,
|
|
86
|
-
sources: selects)
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
# Publish each leaf under a Nested entry's publish_each pattern.
|
|
90
|
-
def publish_nested(mentry, repo_root, prefix, accumulator)
|
|
91
|
-
@manifest.resolver.enumerate(prefix: mentry.key).each do |row|
|
|
92
|
-
next unless row[:manifest_entry].equal?(mentry)
|
|
93
|
-
next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
|
|
94
|
-
|
|
95
|
-
accumulator << publish_nested_leaf(mentry, row, repo_root)
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def publish_nested_leaf(mentry, row, repo_root)
|
|
100
|
-
target_rel = mentry.publish_target_for(row[:key])
|
|
101
|
-
target_abs = File.expand_path(File.join(repo_root, target_rel))
|
|
102
|
-
unless target_abs.start_with?(File.expand_path(repo_root) + File::SEPARATOR)
|
|
103
|
-
raise PublishError.new(
|
|
104
|
-
"entry '#{mentry.key}': publish_each target '#{target_rel}' for key '#{row[:key]}' escapes repo root",
|
|
105
|
-
)
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
Textus::Infra::Publisher.publish(source: row[:path], target: target_abs, store_root: @root)
|
|
109
|
-
publish_event(:file_published,
|
|
110
|
-
key: row[:key],
|
|
111
|
-
envelope: reader.call(row[:key]),
|
|
112
|
-
source: row[:path],
|
|
113
|
-
target: target_abs)
|
|
114
|
-
{ "key" => row[:key], "source" => row[:path], "target" => target_abs }
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
# Publish a standalone Leaf entry that has publish_to targets.
|
|
118
|
-
def publish_leaf_entry(mentry, repo_root)
|
|
119
|
-
source_path = @manifest.resolver.resolve(mentry.key).path
|
|
120
|
-
envelope = reader.call(mentry.key)
|
|
121
|
-
|
|
122
|
-
mentry.publish_to.each do |rel|
|
|
123
|
-
target_abs = File.join(repo_root, rel)
|
|
124
|
-
Textus::Infra::Publisher.publish(source: source_path, target: target_abs, store_root: @root)
|
|
125
|
-
publish_event(:file_published,
|
|
126
|
-
key: mentry.key,
|
|
127
|
-
envelope: envelope,
|
|
128
|
-
source: source_path,
|
|
129
|
-
target: target_abs)
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
{ "key" => mentry.key, "path" => source_path, "published_to" => mentry.publish_to }
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
# Whether the entry should be processed for the given prefix filter.
|
|
136
|
-
def entry_matches_prefix?(mentry, prefix)
|
|
137
|
-
return true unless prefix
|
|
138
|
-
|
|
139
|
-
case mentry
|
|
140
|
-
when Textus::Manifest::Entry::Nested
|
|
141
|
-
# Nested: process if the entry key is a prefix of `prefix` or
|
|
142
|
-
# `prefix` is a prefix of the entry key (a leaf under it).
|
|
143
|
-
mentry.key.start_with?(prefix) ||
|
|
144
|
-
prefix.start_with?("#{mentry.key}.")
|
|
145
|
-
else
|
|
146
|
-
mentry.key.start_with?(prefix)
|
|
147
|
-
end
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
def reader
|
|
151
|
-
@reader ||= Textus::Application::Reads::Get.new(
|
|
152
|
-
ctx: @ctx, manifest: @manifest, file_store: @file_store,
|
|
153
|
-
)
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
def publish_event(event, **payload)
|
|
157
|
-
@bus.publish(event, ctx: @hook_context, **payload)
|
|
158
|
-
end
|
|
159
|
-
end
|
|
160
|
-
end
|
|
161
|
-
end
|
|
162
|
-
end
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Application
|
|
3
|
-
module Writes
|
|
4
|
-
class Put
|
|
5
|
-
def initialize(ctx:, manifest:, envelope_io:, bus:, authorizer:, hook_context:)
|
|
6
|
-
@ctx = ctx
|
|
7
|
-
@manifest = manifest
|
|
8
|
-
@envelope_io = envelope_io
|
|
9
|
-
@bus = bus
|
|
10
|
-
@authorizer = authorizer
|
|
11
|
-
@hook_context = hook_context
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def call(key, meta: nil, body: nil, content: nil, if_etag: nil)
|
|
15
|
-
@manifest.validate_key!(key)
|
|
16
|
-
mentry = @manifest.resolver.resolve(key).entry
|
|
17
|
-
|
|
18
|
-
@authorizer.authorize_write!(mentry, role: @ctx.role)
|
|
19
|
-
|
|
20
|
-
envelope = @envelope_io.write(
|
|
21
|
-
key,
|
|
22
|
-
mentry: mentry,
|
|
23
|
-
payload: Textus::Application::Writes::EnvelopeIO::Payload.new(meta: meta, body: body, content: content),
|
|
24
|
-
if_etag: if_etag,
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
@bus.publish(:entry_put,
|
|
28
|
-
ctx: @hook_context,
|
|
29
|
-
key: key,
|
|
30
|
-
envelope: envelope)
|
|
31
|
-
|
|
32
|
-
envelope
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
end
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
require_relative "authority_gate"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Application
|
|
5
|
-
module Writes
|
|
6
|
-
class Reject
|
|
7
|
-
include AuthorityGate
|
|
8
|
-
|
|
9
|
-
def initialize(ctx:, manifest:, file_store:, envelope_io:, bus:, authorizer:, hook_context:) # rubocop:disable Metrics/ParameterLists
|
|
10
|
-
@ctx = ctx
|
|
11
|
-
@manifest = manifest
|
|
12
|
-
@file_store = file_store
|
|
13
|
-
@envelope_io = envelope_io
|
|
14
|
-
@bus = bus
|
|
15
|
-
@authorizer = authorizer
|
|
16
|
-
@hook_context = hook_context
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def call(pending_key)
|
|
20
|
-
assert_accept_authority!("reject")
|
|
21
|
-
|
|
22
|
-
mentry = @manifest.resolver.resolve(pending_key).entry
|
|
23
|
-
unless mentry.in_proposal_zone?
|
|
24
|
-
raise ProposalError.new("reject: '#{pending_key}' is not in a proposal zone (zone=#{mentry.zone})")
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
env = Textus::Application::Reads::Get.new(
|
|
28
|
-
ctx: @ctx, manifest: @manifest, file_store: @file_store,
|
|
29
|
-
).call(pending_key)
|
|
30
|
-
proposal = env.meta&.dig("proposal") or
|
|
31
|
-
raise ProposalError.new("entry has no proposal block: #{pending_key}")
|
|
32
|
-
target_key = proposal["target_key"] or
|
|
33
|
-
raise ProposalError.new("proposal missing target_key")
|
|
34
|
-
|
|
35
|
-
Textus::Application::Writes::Delete.new(
|
|
36
|
-
ctx: @ctx, manifest: @manifest, envelope_io: @envelope_io,
|
|
37
|
-
bus: @bus, authorizer: @authorizer, hook_context: @hook_context
|
|
38
|
-
).call(pending_key, suppress_events: true)
|
|
39
|
-
|
|
40
|
-
@bus.publish(:proposal_rejected,
|
|
41
|
-
ctx: @hook_context,
|
|
42
|
-
key: pending_key,
|
|
43
|
-
target_key: target_key)
|
|
44
|
-
|
|
45
|
-
{ "protocol" => PROTOCOL, "rejected" => pending_key, "target_key" => target_key }
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
end
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Infra
|
|
3
|
-
class EventBus
|
|
4
|
-
def initialize(bus:)
|
|
5
|
-
@bus = bus
|
|
6
|
-
end
|
|
7
|
-
|
|
8
|
-
def publish(event, **payload)
|
|
9
|
-
@bus.pubsub_handlers(event).each do |entry|
|
|
10
|
-
next unless entry[:keys].nil? || matches?(entry[:keys], payload[:key])
|
|
11
|
-
|
|
12
|
-
entry[:callable].call(**payload)
|
|
13
|
-
rescue StandardError => e
|
|
14
|
-
warn "[textus] pub-sub handler #{entry[:name].inspect} for #{event.inspect} failed: #{e.class}: #{e.message}"
|
|
15
|
-
end
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
private
|
|
19
|
-
|
|
20
|
-
def matches?(globs, key)
|
|
21
|
-
return true if key.nil?
|
|
22
|
-
|
|
23
|
-
Array(globs).any? { |g| File.fnmatch?(g, key.to_s, File::FNM_PATHNAME) }
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
end
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
class Manifest
|
|
3
|
-
class Entry
|
|
4
|
-
module Validators
|
|
5
|
-
module InjectIntro
|
|
6
|
-
def self.call(entry)
|
|
7
|
-
inject_intro = entry.respond_to?(:inject_intro) ? entry.inject_intro : false
|
|
8
|
-
return unless inject_intro
|
|
9
|
-
|
|
10
|
-
raise UsageError.new("entry '#{entry.key}': inject_intro: is only valid on derived entries") unless entry.in_generator_zone?
|
|
11
|
-
|
|
12
|
-
has_template = entry.respond_to?(:template) && !entry.template.nil?
|
|
13
|
-
return if has_template
|
|
14
|
-
|
|
15
|
-
raise UsageError.new("entry '#{entry.key}': inject_intro: requires a template:")
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
end
|