textus 0.18.0 → 0.20.2
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 +43 -48
- data/CHANGELOG.md +238 -0
- data/SPEC.md +35 -2
- data/lib/textus/application/context.rb +20 -58
- data/lib/textus/application/policy/predicates/accept_authority_signed.rb +33 -0
- data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
- data/lib/textus/{domain → application}/policy/promotion.rb +18 -6
- data/lib/textus/application/projection.rb +91 -0
- data/lib/textus/application/reads/audit.rb +4 -4
- data/lib/textus/application/reads/blame.rb +9 -8
- data/lib/textus/application/reads/deps.rb +14 -3
- data/lib/textus/application/reads/freshness.rb +10 -8
- data/lib/textus/application/reads/get.rb +10 -8
- data/lib/textus/application/reads/get_or_refresh.rb +3 -3
- data/lib/textus/application/reads/list.rb +3 -3
- data/lib/textus/application/reads/policy_explain.rb +3 -3
- data/lib/textus/application/reads/published.rb +5 -3
- data/lib/textus/application/reads/rdeps.rb +15 -3
- data/lib/textus/application/reads/schema_envelope.rb +5 -4
- data/lib/textus/application/reads/stale.rb +3 -3
- data/lib/textus/application/reads/uid.rb +11 -3
- data/lib/textus/application/reads/validate_all.rb +10 -6
- data/lib/textus/application/reads/validator.rb +5 -3
- data/lib/textus/application/reads/where.rb +3 -3
- data/lib/textus/application/refresh/all.rb +15 -11
- data/lib/textus/application/refresh/orchestrator.rb +9 -8
- data/lib/textus/application/refresh/worker.rb +56 -32
- data/lib/textus/application/writes/accept.rb +43 -16
- data/lib/textus/application/writes/authority_gate.rb +26 -0
- data/lib/textus/application/writes/delete.rb +13 -10
- data/lib/textus/application/writes/envelope_io.rb +64 -4
- data/lib/textus/application/writes/materializer.rb +50 -0
- data/lib/textus/application/writes/mv.rb +57 -94
- data/lib/textus/application/writes/publish.rb +132 -26
- data/lib/textus/application/writes/put.rb +15 -14
- data/lib/textus/application/writes/reject.rb +25 -12
- data/lib/textus/builder/pipeline.rb +21 -15
- data/lib/textus/builder/renderer/json.rb +4 -1
- data/lib/textus/builder/renderer/markdown.rb +7 -1
- data/lib/textus/builder/renderer/yaml.rb +4 -1
- data/lib/textus/cli/verb/build.rb +4 -6
- 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 +5 -5
- data/lib/textus/cli/verb/put.rb +2 -3
- data/lib/textus/cli/verb/refresh_stale.rb +1 -2
- data/lib/textus/doctor/check/handler_allowlist.rb +3 -2
- data/lib/textus/doctor/check/hooks.rb +2 -2
- data/lib/textus/doctor/check/illegal_keys.rb +7 -7
- data/lib/textus/doctor/check/intake_registration.rb +2 -2
- data/lib/textus/doctor/check/manifest_files.rb +1 -1
- data/lib/textus/doctor/check/protocol_version.rb +2 -2
- data/lib/textus/doctor/check/templates.rb +4 -3
- data/lib/textus/doctor.rb +3 -4
- data/lib/textus/domain/authorizer.rb +37 -0
- data/lib/textus/domain/policy/promote.rb +4 -2
- data/lib/textus/domain/policy/refresh.rb +2 -0
- data/lib/textus/domain/staleness/generator_check.rb +8 -7
- data/lib/textus/domain/staleness/intake_check.rb +2 -2
- data/lib/textus/hooks/builtin.rb +6 -6
- data/lib/textus/hooks/bus.rb +155 -0
- data/lib/textus/hooks/context.rb +38 -0
- data/lib/textus/hooks/fire_report.rb +23 -0
- data/lib/textus/hooks/loader.rb +3 -3
- data/lib/textus/infra/audit_subscriber.rb +4 -4
- data/lib/textus/infra/event_bus.rb +3 -3
- data/lib/textus/infra/refresh/detached.rb +1 -1
- data/lib/textus/init.rb +3 -2
- data/lib/textus/intro.rb +51 -27
- data/lib/textus/manifest/entry/base.rb +38 -0
- data/lib/textus/manifest/entry/derived.rb +25 -0
- data/lib/textus/manifest/entry/intake.rb +19 -0
- data/lib/textus/manifest/entry/leaf.rb +16 -0
- data/lib/textus/manifest/entry/nested.rb +39 -0
- data/lib/textus/manifest/entry/parser.rb +58 -31
- data/lib/textus/manifest/entry/validators/events.rb +3 -2
- data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -3
- data/lib/textus/manifest/entry/validators/index_filename.rb +11 -10
- data/lib/textus/manifest/entry/validators/inject_intro.rb +5 -2
- data/lib/textus/manifest/entry/validators/publish_each.rb +9 -6
- data/lib/textus/manifest/entry.rb +0 -72
- data/lib/textus/manifest/resolver.rb +112 -0
- data/lib/textus/manifest/role_kinds.rb +21 -0
- data/lib/textus/manifest/schema.rb +46 -2
- data/lib/textus/manifest.rb +24 -101
- data/lib/textus/operations.rb +131 -74
- data/lib/textus/schema/tools.rb +10 -3
- data/lib/textus/store.rb +6 -6
- data/lib/textus/version.rb +1 -1
- metadata +18 -14
- data/lib/textus/application/writes/build.rb +0 -78
- data/lib/textus/cli/verb/key_normalize.rb +0 -19
- data/lib/textus/dependencies.rb +0 -23
- data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
- data/lib/textus/domain/policy.rb +0 -7
- data/lib/textus/hooks/dispatcher.rb +0 -71
- data/lib/textus/hooks/registry.rb +0 -85
- data/lib/textus/manifest/resolution.rb +0 -5
- data/lib/textus/migrate_keys.rb +0 -187
- data/lib/textus/projection.rb +0 -89
- data/lib/textus/refresh.rb +0 -39
|
@@ -6,17 +6,22 @@ module Textus
|
|
|
6
6
|
class Worker
|
|
7
7
|
FETCH_TIMEOUT_SECONDS = 30
|
|
8
8
|
|
|
9
|
-
def initialize(ctx:, envelope_io:)
|
|
10
|
-
@ctx
|
|
11
|
-
@
|
|
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
|
|
12
17
|
end
|
|
13
18
|
|
|
14
19
|
def run(key)
|
|
15
|
-
res = @
|
|
20
|
+
res = @manifest.resolver.resolve(key)
|
|
16
21
|
mentry = res.entry
|
|
17
22
|
path = res.path
|
|
18
23
|
remaining = res.remaining
|
|
19
|
-
raise UsageError.new("no intake declared for '#{key}'") unless mentry.
|
|
24
|
+
raise UsageError.new("no intake declared for '#{key}'") unless mentry.is_a?(Textus::Manifest::Entry::Intake)
|
|
20
25
|
|
|
21
26
|
before_etag = File.exist?(path) ? Etag.for_file(path) : nil
|
|
22
27
|
result = fetch_with_bus(key, mentry, remaining)
|
|
@@ -25,19 +30,14 @@ module Textus
|
|
|
25
30
|
|
|
26
31
|
private
|
|
27
32
|
|
|
28
|
-
def read_view
|
|
29
|
-
Application::Context.new(store: @ctx.store, role: @ctx.role)
|
|
30
|
-
end
|
|
31
|
-
|
|
32
33
|
def fetch_timeout_for(key)
|
|
33
|
-
rule = @
|
|
34
|
+
rule = @manifest.rules_for(key)
|
|
34
35
|
rule&.refresh&.fetch_timeout_seconds || FETCH_TIMEOUT_SECONDS
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
def fetch_with_bus(key, mentry, remaining)
|
|
38
|
-
callable = @
|
|
39
|
-
@
|
|
40
|
-
correlation_id: @ctx.correlation_id)
|
|
39
|
+
callable = @bus.rpc_callable(:resolve_intake, mentry.handler)
|
|
40
|
+
@bus.publish(:refresh_started, ctx: @hook_context, key: key, mode: :sync)
|
|
41
41
|
call_intake(key, mentry, callable, remaining)
|
|
42
42
|
end
|
|
43
43
|
|
|
@@ -45,38 +45,38 @@ module Textus
|
|
|
45
45
|
timeout = fetch_timeout_for(key)
|
|
46
46
|
Timeout.timeout(timeout) do
|
|
47
47
|
callable.call(
|
|
48
|
-
store: @
|
|
49
|
-
config: mentry.
|
|
48
|
+
store: @store,
|
|
49
|
+
config: mentry.config,
|
|
50
50
|
args: { trigger_key: key, leaf_segments: remaining || [] },
|
|
51
51
|
)
|
|
52
52
|
end
|
|
53
53
|
rescue Timeout::Error
|
|
54
|
-
@
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
raise UsageError.new("intake '#{mentry.
|
|
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
58
|
rescue Textus::Error => e
|
|
59
|
-
@
|
|
60
|
-
|
|
59
|
+
@bus.publish(:refresh_failed, ctx: @hook_context, key: key, error_class: e.class.name,
|
|
60
|
+
error_message: e.message)
|
|
61
61
|
raise
|
|
62
62
|
rescue StandardError => e
|
|
63
|
-
@
|
|
64
|
-
|
|
65
|
-
raise UsageError.new("intake '#{mentry.
|
|
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
66
|
end
|
|
67
67
|
|
|
68
68
|
def persist_and_notify(key, mentry, result, before_etag)
|
|
69
|
-
normalized =
|
|
70
|
-
|
|
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(
|
|
71
72
|
key,
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
mentry: mentry,
|
|
74
|
+
payload: Textus::Application::Writes::EnvelopeIO::Payload.new(
|
|
75
|
+
meta: normalized[:meta], body: normalized[:body], content: normalized[:content],
|
|
76
|
+
),
|
|
74
77
|
)
|
|
75
78
|
change = detect_change(before_etag, envelope)
|
|
76
|
-
unless change == :unchanged
|
|
77
|
-
@ctx.bus.publish(:entry_refreshed, store: read_view, key: key, envelope: envelope, change: change,
|
|
78
|
-
correlation_id: @ctx.correlation_id)
|
|
79
|
-
end
|
|
79
|
+
@bus.publish(:entry_refreshed, ctx: @hook_context, key: key, envelope: envelope, change: change) unless change == :unchanged
|
|
80
80
|
envelope
|
|
81
81
|
end
|
|
82
82
|
|
|
@@ -86,6 +86,30 @@ module Textus
|
|
|
86
86
|
else :updated
|
|
87
87
|
end
|
|
88
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
|
|
89
113
|
end
|
|
90
114
|
end
|
|
91
115
|
end
|
|
@@ -1,16 +1,28 @@
|
|
|
1
|
+
require_relative "authority_gate"
|
|
2
|
+
|
|
1
3
|
module Textus
|
|
2
4
|
module Application
|
|
3
5
|
module Writes
|
|
4
6
|
class Accept
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
8
18
|
end
|
|
9
19
|
|
|
10
20
|
def call(pending_key)
|
|
11
|
-
|
|
21
|
+
assert_accept_authority!("accept")
|
|
12
22
|
|
|
13
|
-
env = Textus::Application::Reads::Get.new(
|
|
23
|
+
env = Textus::Application::Reads::Get.new(
|
|
24
|
+
ctx: @ctx, manifest: @manifest, file_store: @file_store,
|
|
25
|
+
).call(pending_key)
|
|
14
26
|
proposal = env.meta["proposal"] or raise ProposalError.new("entry has no proposal block: #{pending_key}")
|
|
15
27
|
target = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
|
|
16
28
|
action = proposal["action"] || "put"
|
|
@@ -23,33 +35,48 @@ module Textus
|
|
|
23
35
|
# target. Not related to the removed intake-handler legacy bridge.
|
|
24
36
|
target_meta = env.meta["frontmatter"] || {}
|
|
25
37
|
target_body = env.body
|
|
26
|
-
|
|
38
|
+
put_op.call(target, meta: target_meta, body: target_body)
|
|
27
39
|
when "delete"
|
|
28
|
-
|
|
40
|
+
delete_op.call(target)
|
|
29
41
|
else
|
|
30
42
|
raise ProposalError.new("unknown action: #{action}")
|
|
31
43
|
end
|
|
32
44
|
|
|
33
|
-
|
|
45
|
+
delete_op.call(pending_key)
|
|
34
46
|
|
|
35
|
-
@
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
correlation_id: @ctx.correlation_id)
|
|
47
|
+
@bus.publish(:proposal_accepted,
|
|
48
|
+
ctx: @hook_context,
|
|
49
|
+
key: pending_key,
|
|
50
|
+
target_key: target)
|
|
40
51
|
|
|
41
52
|
{ "protocol" => PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action }
|
|
42
53
|
end
|
|
43
54
|
|
|
44
55
|
private
|
|
45
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
|
+
|
|
46
71
|
def evaluate_promotion!(env, target_key)
|
|
47
|
-
rules = @
|
|
72
|
+
rules = @manifest.rules_for(target_key)
|
|
48
73
|
promote = rules.promote
|
|
49
74
|
return if promote.nil? || promote.requires.empty?
|
|
50
75
|
|
|
51
|
-
policy = Textus::
|
|
52
|
-
result = policy.evaluate(
|
|
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
|
+
)
|
|
53
80
|
return if result.ok?
|
|
54
81
|
|
|
55
82
|
raise ProposalError.new(
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Writes
|
|
4
|
+
# Shared gate for write verbs that require the caller to hold the
|
|
5
|
+
# manifest's accept_authority role. Provides one method, expressed
|
|
6
|
+
# as two early-returns rather than a ternary, so each failure mode
|
|
7
|
+
# reads on its own line.
|
|
8
|
+
module AuthorityGate
|
|
9
|
+
def assert_accept_authority!(verb)
|
|
10
|
+
return if @manifest.role_kind(@ctx.role) == :accept_authority
|
|
11
|
+
|
|
12
|
+
authority = @manifest.roles_with_kind(:accept_authority).first
|
|
13
|
+
if authority.nil?
|
|
14
|
+
raise ProposalError.new(
|
|
15
|
+
"no role with accept_authority kind is declared in this manifest; #{verb} is disabled",
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
raise ProposalError.new(
|
|
20
|
+
"only #{authority} role can #{verb} proposals; got '#{@ctx.role}'",
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -2,24 +2,27 @@ module Textus
|
|
|
2
2
|
module Application
|
|
3
3
|
module Writes
|
|
4
4
|
class Delete
|
|
5
|
-
def initialize(ctx:, envelope_io:)
|
|
6
|
-
@ctx
|
|
7
|
-
@
|
|
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
|
|
8
12
|
end
|
|
9
13
|
|
|
10
14
|
def call(key, if_etag: nil, suppress_events: false)
|
|
11
|
-
@
|
|
12
|
-
mentry = @
|
|
15
|
+
@manifest.validate_key!(key)
|
|
16
|
+
mentry = @manifest.resolver.resolve(key).entry
|
|
13
17
|
|
|
14
|
-
@
|
|
18
|
+
@authorizer.authorize_write!(mentry, role: @ctx.role)
|
|
15
19
|
|
|
16
20
|
@envelope_io.delete(key, mentry: mentry, if_etag: if_etag)
|
|
17
21
|
|
|
18
22
|
unless suppress_events
|
|
19
|
-
@
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
correlation_id: @ctx.correlation_id)
|
|
23
|
+
@bus.publish(:entry_deleted,
|
|
24
|
+
ctx: @hook_context,
|
|
25
|
+
key: key)
|
|
23
26
|
end
|
|
24
27
|
|
|
25
28
|
{ "protocol" => Textus::PROTOCOL, "ok" => true, "key" => key, "deleted" => true }
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
1
3
|
module Textus
|
|
2
4
|
module Application
|
|
3
5
|
module Writes
|
|
@@ -6,7 +8,7 @@ module Textus
|
|
|
6
8
|
# AuditLog, Manifest) instead of File/FileUtils and Store directly.
|
|
7
9
|
#
|
|
8
10
|
# No permission check, no event firing — those belong to the caller
|
|
9
|
-
# (Application::Writes::Put / ::Delete).
|
|
11
|
+
# (Application::Writes::Put / ::Delete / ::Mv).
|
|
10
12
|
class EnvelopeIO
|
|
11
13
|
Payload = Data.define(:meta, :body, :content)
|
|
12
14
|
|
|
@@ -18,8 +20,28 @@ module Textus
|
|
|
18
20
|
@ctx = ctx
|
|
19
21
|
end
|
|
20
22
|
|
|
23
|
+
def exists?(path) = @file_store.exists?(path)
|
|
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
|
+
|
|
21
43
|
def write(key, mentry:, payload:, if_etag: nil)
|
|
22
|
-
path = @manifest.resolve(key).path
|
|
44
|
+
path = @manifest.resolver.resolve(key).path
|
|
23
45
|
|
|
24
46
|
meta = payload.meta || {}
|
|
25
47
|
strategy = Entry.for_format(mentry.format)
|
|
@@ -60,8 +82,8 @@ module Textus
|
|
|
60
82
|
|
|
61
83
|
def delete(key, mentry:, if_etag: nil)
|
|
62
84
|
_ = mentry
|
|
63
|
-
path = @manifest.resolve(key).path
|
|
64
|
-
raise UnknownKey.new(key, suggestions: @manifest.suggestions_for(key)) unless @file_store.exists?(path)
|
|
85
|
+
path = @manifest.resolver.resolve(key).path
|
|
86
|
+
raise UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)) unless @file_store.exists?(path)
|
|
65
87
|
|
|
66
88
|
etag_before = @file_store.etag(path)
|
|
67
89
|
raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before
|
|
@@ -74,6 +96,44 @@ module Textus
|
|
|
74
96
|
)
|
|
75
97
|
end
|
|
76
98
|
|
|
99
|
+
def move(from_key:, to_key:, new_mentry:, if_etag: nil)
|
|
100
|
+
from_path = @manifest.resolver.resolve(from_key).path
|
|
101
|
+
to_path = @manifest.resolver.resolve(to_key).path
|
|
102
|
+
raise UnknownKey.new(from_key, suggestions: @manifest.resolver.suggestions_for(from_key)) unless @file_store.exists?(from_path)
|
|
103
|
+
|
|
104
|
+
etag_before = @file_store.etag(from_path)
|
|
105
|
+
raise EtagMismatch.new(from_key, if_etag, etag_before) if if_etag && if_etag != etag_before
|
|
106
|
+
|
|
107
|
+
FileUtils.mkdir_p(File.dirname(to_path))
|
|
108
|
+
FileUtils.mv(from_path, to_path)
|
|
109
|
+
basename = to_key.split(".").last
|
|
110
|
+
Entry.for_format(new_mentry.format).rewrite_name(to_path, basename)
|
|
111
|
+
etag_after = Etag.for_file(to_path)
|
|
112
|
+
|
|
113
|
+
raw = @file_store.read(to_path)
|
|
114
|
+
parsed = Entry.for_format(new_mentry.format).parse(raw, path: to_path)
|
|
115
|
+
envelope = Envelope.build(
|
|
116
|
+
key: to_key, mentry: new_mentry, path: to_path,
|
|
117
|
+
meta: parsed["_meta"], body: parsed["body"],
|
|
118
|
+
etag: etag_after, content: parsed["content"]
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
extras = {
|
|
122
|
+
"from_key" => from_key, "to_key" => to_key,
|
|
123
|
+
"from_path" => from_path, "to_path" => to_path,
|
|
124
|
+
"uid" => envelope.uid
|
|
125
|
+
}
|
|
126
|
+
extras["correlation_id"] = @ctx.correlation_id if @ctx.correlation_id
|
|
127
|
+
|
|
128
|
+
@audit_log.append(
|
|
129
|
+
role: @ctx.role, verb: "mv", key: to_key,
|
|
130
|
+
etag_before: etag_before, etag_after: etag_after,
|
|
131
|
+
extras: extras
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
envelope
|
|
135
|
+
end
|
|
136
|
+
|
|
77
137
|
private
|
|
78
138
|
|
|
79
139
|
def existing_uid_for(mentry, path)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Application
|
|
5
|
+
module Writes
|
|
6
|
+
# Materializes a single Derived manifest entry onto disk by running
|
|
7
|
+
# the builder pipeline (template + projection + external runner).
|
|
8
|
+
# Extracted from Application::Writes::Build so that Publish can reuse
|
|
9
|
+
# it without creating a Build dependency.
|
|
10
|
+
class Materializer
|
|
11
|
+
def initialize(ctx:, manifest:, file_store:, bus:, root:, store:)
|
|
12
|
+
@ctx = ctx
|
|
13
|
+
@manifest = manifest
|
|
14
|
+
@file_store = file_store
|
|
15
|
+
@bus = bus
|
|
16
|
+
@root = root
|
|
17
|
+
@store = store
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Runs the builder pipeline for `mentry` and returns the on-disk
|
|
21
|
+
# target_path string.
|
|
22
|
+
def run(mentry)
|
|
23
|
+
reader = Textus::Application::Reads::Get.new(
|
|
24
|
+
ctx: @ctx, manifest: @manifest, file_store: @file_store,
|
|
25
|
+
)
|
|
26
|
+
lister = Textus::Application::Reads::List.new(manifest: @manifest)
|
|
27
|
+
Builder::Pipeline.run(
|
|
28
|
+
mentry: mentry,
|
|
29
|
+
manifest: @manifest,
|
|
30
|
+
reader: reader.method(:call),
|
|
31
|
+
lister: lister.method(:call),
|
|
32
|
+
transform_resolver: ->(name) { @bus.rpc_callable(:transform_rows, name) },
|
|
33
|
+
template_loader: ->(name) { read_template(name) },
|
|
34
|
+
transform_context: @store,
|
|
35
|
+
inject_intro: -> { Textus::Intro.run(@store) },
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def read_template(name)
|
|
42
|
+
tpl_path = File.join(@root, "templates", name)
|
|
43
|
+
raise TemplateError.new("template not found: #{tpl_path}", template_name: name) unless File.exist?(tpl_path)
|
|
44
|
+
|
|
45
|
+
File.read(tpl_path)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -1,60 +1,46 @@
|
|
|
1
|
-
require "fileutils"
|
|
2
|
-
|
|
3
1
|
module Textus
|
|
4
2
|
module Application
|
|
5
3
|
module Writes
|
|
6
4
|
class Mv
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
@
|
|
14
|
-
@envelope_io = envelope_io
|
|
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
|
|
15
12
|
end
|
|
16
13
|
|
|
17
14
|
def call(old_key, new_key, dry_run: false)
|
|
18
|
-
|
|
19
|
-
return dry_run_result(
|
|
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
|
|
20
17
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
25
|
end
|
|
26
26
|
|
|
27
27
|
private
|
|
28
28
|
|
|
29
|
-
def
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def prepare_plan(old_key, new_key)
|
|
33
|
-
manifest.validate_key!(old_key)
|
|
34
|
-
manifest.validate_key!(new_key)
|
|
29
|
+
def prepare(old_key, new_key)
|
|
30
|
+
@manifest.validate_key!(old_key)
|
|
31
|
+
@manifest.validate_key!(new_key)
|
|
35
32
|
raise UsageError.new("mv: old and new keys are identical") if old_key == new_key
|
|
36
33
|
|
|
37
|
-
old_res = manifest.resolve(old_key)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
raise UnknownKey.new(old_key) unless @ctx.file_store.exists?(old_path)
|
|
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)
|
|
41
37
|
|
|
42
|
-
new_res
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
@ctx.authorize_write!(old_mentry)
|
|
47
|
-
@ctx.authorize_write!(new_mentry)
|
|
48
|
-
raise UsageError.new("mv: target '#{new_key}' already exists at #{new_path}") if @ctx.file_store.exists?(new_path)
|
|
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)
|
|
49
42
|
|
|
50
|
-
|
|
51
|
-
plan = MovePlan.new(
|
|
52
|
-
old_key: old_key, new_key: new_key,
|
|
53
|
-
old_path: old_path, new_path: new_path,
|
|
54
|
-
new_mentry: new_mentry,
|
|
55
|
-
uid: pre_env.uid, etag_before: pre_env.etag
|
|
56
|
-
)
|
|
57
|
-
[plan, pre_env]
|
|
43
|
+
[old_res, new_res]
|
|
58
44
|
end
|
|
59
45
|
|
|
60
46
|
def validate_zone_and_format!(old_mentry, new_mentry)
|
|
@@ -69,73 +55,50 @@ module Textus
|
|
|
69
55
|
raise UsageError.new("mv: format mismatch (#{old_mentry.format} → #{new_mentry.format}); refusing.")
|
|
70
56
|
end
|
|
71
57
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
+
)
|
|
81
71
|
)
|
|
82
|
-
plan.with(uid: env.uid, etag_before: env.etag)
|
|
83
72
|
end
|
|
84
73
|
|
|
85
|
-
def
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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)
|
|
90
81
|
end
|
|
91
82
|
|
|
92
|
-
def
|
|
93
|
-
|
|
94
|
-
"from_key" => plan.old_key, "to_key" => plan.new_key,
|
|
95
|
-
"from_path" => plan.old_path, "to_path" => plan.new_path,
|
|
96
|
-
"uid" => plan.uid
|
|
97
|
-
}
|
|
98
|
-
extras["correlation_id"] = @ctx.correlation_id if @ctx.correlation_id
|
|
99
|
-
|
|
100
|
-
@ctx.audit_log.append(
|
|
101
|
-
role: @ctx.role, verb: "mv", key: plan.new_key,
|
|
102
|
-
etag_before: plan.etag_before, etag_after: etag_after,
|
|
103
|
-
extras: extras
|
|
104
|
-
)
|
|
105
|
-
new_envelope = reader_get(plan.new_key)
|
|
106
|
-
@ctx.bus.publish(:entry_renamed,
|
|
107
|
-
store: @ctx.with_role(@ctx.role),
|
|
108
|
-
key: plan.new_key,
|
|
109
|
-
from_key: plan.old_key,
|
|
110
|
-
to_key: plan.new_key,
|
|
111
|
-
envelope: new_envelope,
|
|
112
|
-
correlation_id: @ctx.correlation_id)
|
|
113
|
-
new_envelope
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def dry_run_result(plan)
|
|
83
|
+
def dry_run_result(old_key, new_key, old_res, new_res)
|
|
84
|
+
pre_env = @envelope_io.read_envelope(old_key)
|
|
117
85
|
{
|
|
118
86
|
"protocol" => PROTOCOL, "ok" => true, "dry_run" => true,
|
|
119
|
-
"from_key" =>
|
|
120
|
-
"from_path" =>
|
|
121
|
-
"uid" =>
|
|
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
|
|
122
90
|
}
|
|
123
91
|
end
|
|
124
92
|
|
|
125
|
-
def success_result(
|
|
93
|
+
def success_result(old_key, new_key, old_res, new_res, envelope)
|
|
126
94
|
{
|
|
127
95
|
"protocol" => PROTOCOL, "ok" => true,
|
|
128
|
-
"from_key" =>
|
|
129
|
-
"from_path" =>
|
|
130
|
-
"uid" =>
|
|
131
|
-
"envelope" =>
|
|
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
|
|
132
100
|
}
|
|
133
101
|
end
|
|
134
|
-
|
|
135
|
-
def rewrite_name_for_mv!(mentry, new_path, new_key)
|
|
136
|
-
basename = new_key.split(".").last
|
|
137
|
-
Entry.for_format(mentry.format).rewrite_name(new_path, basename)
|
|
138
|
-
end
|
|
139
102
|
end
|
|
140
103
|
end
|
|
141
104
|
end
|