textus 0.12.1 → 0.14.1
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 +60 -40
- data/CHANGELOG.md +231 -0
- data/README.md +6 -12
- data/SPEC.md +4 -1
- data/docs/conventions.md +8 -8
- data/lib/textus/application/context.rb +4 -0
- data/lib/textus/application/reads/blame.rb +1 -1
- data/lib/textus/application/reads/deps.rb +15 -0
- data/lib/textus/application/reads/freshness.rb +2 -2
- data/lib/textus/application/reads/get.rb +8 -11
- data/lib/textus/application/reads/list.rb +15 -0
- data/lib/textus/application/reads/published.rb +15 -0
- data/lib/textus/application/reads/rdeps.rb +15 -0
- data/lib/textus/application/reads/schema_envelope.rb +15 -0
- data/lib/textus/application/reads/stale.rb +15 -0
- data/lib/textus/application/reads/uid.rb +15 -0
- data/lib/textus/application/reads/validate_all.rb +15 -0
- data/lib/textus/application/reads/where.rb +15 -0
- data/lib/textus/application/refresh/all.rb +2 -2
- data/lib/textus/application/refresh/worker.rb +3 -3
- data/lib/textus/application/writes/accept.rb +7 -7
- data/lib/textus/application/writes/build.rb +10 -47
- data/lib/textus/application/writes/mv.rb +144 -0
- data/lib/textus/application/writes/publish.rb +41 -9
- data/lib/textus/application/writes/reject.rb +37 -0
- data/lib/textus/builder/pipeline.rb +46 -2
- data/lib/textus/cli/verb/accept.rb +1 -2
- data/lib/textus/cli/verb/audit.rb +3 -3
- data/lib/textus/cli/verb/blame.rb +1 -2
- data/lib/textus/cli/verb/build.rb +6 -2
- data/lib/textus/cli/verb/delete.rb +1 -2
- data/lib/textus/cli/verb/deps.rb +1 -1
- data/lib/textus/cli/verb/freshness.rb +1 -2
- data/lib/textus/cli/verb/get.rb +2 -3
- data/lib/textus/cli/verb/list.rb +1 -1
- data/lib/textus/cli/verb/mv.rb +1 -1
- data/lib/textus/cli/verb/published.rb +1 -1
- data/lib/textus/cli/verb/put.rb +2 -2
- data/lib/textus/cli/verb/rdeps.rb +1 -1
- data/lib/textus/cli/verb/refresh.rb +1 -2
- data/lib/textus/cli/verb/reject.rb +1 -1
- data/lib/textus/cli/verb/rule_explain.rb +1 -2
- 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.rb +6 -1
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor.rb +1 -1
- data/lib/textus/domain/freshness/evaluator.rb +1 -1
- data/lib/textus/domain/policy/predicates/schema_valid.rb +3 -3
- data/lib/textus/entry/base.rb +28 -0
- data/lib/textus/entry/json.rb +59 -0
- data/lib/textus/entry/markdown.rb +46 -0
- data/lib/textus/entry/text.rb +35 -0
- data/lib/textus/entry/yaml.rb +59 -0
- data/lib/textus/entry.rb +16 -0
- data/lib/textus/envelope.rb +44 -14
- data/lib/textus/intro.rb +56 -0
- data/lib/textus/manifest/entry/parser.rb +84 -0
- data/lib/textus/manifest/entry/validators/events.rb +21 -0
- data/lib/textus/manifest/entry/validators/format_matrix.rb +26 -0
- data/lib/textus/manifest/entry/validators/index_filename.rb +45 -0
- data/lib/textus/manifest/entry/validators/inject_intro.rb +18 -0
- data/lib/textus/manifest/entry/validators/publish_each.rb +37 -0
- data/lib/textus/manifest/entry/validators.rb +20 -0
- data/lib/textus/manifest/entry.rb +35 -213
- data/lib/textus/manifest.rb +19 -32
- data/lib/textus/operations/reads.rb +39 -0
- data/lib/textus/operations/refresh.rb +27 -0
- data/lib/textus/operations/writes.rb +21 -0
- data/lib/textus/operations.rb +44 -0
- data/lib/textus/projection.rb +5 -4
- data/lib/textus/refresh.rb +3 -4
- data/lib/textus/schema/tools.rb +8 -7
- data/lib/textus/store/reader.rb +1 -1
- data/lib/textus/store/validator.rb +3 -3
- data/lib/textus/store/writer.rb +5 -74
- data/lib/textus/store.rb +1 -55
- data/lib/textus/version.rb +1 -1
- metadata +23 -4
- data/lib/textus/composition.rb +0 -72
- data/lib/textus/proposal.rb +0 -10
- data/lib/textus/store/mover.rb +0 -167
|
@@ -5,9 +5,9 @@ module Textus
|
|
|
5
5
|
module_function
|
|
6
6
|
|
|
7
7
|
def call(ctx, prefix: nil, zone: nil)
|
|
8
|
-
worker = Textus::
|
|
8
|
+
worker = Textus::Application::Refresh::Worker.new(ctx: ctx, bus: ctx.store.bus)
|
|
9
9
|
|
|
10
|
-
stale_rows = ctx.
|
|
10
|
+
stale_rows = Textus::Application::Reads::Stale.new(ctx: ctx).call(prefix: prefix, zone: zone)
|
|
11
11
|
refreshed = []
|
|
12
12
|
failed = []
|
|
13
13
|
skipped = []
|
|
@@ -54,10 +54,10 @@ module Textus
|
|
|
54
54
|
|
|
55
55
|
def persist_and_notify(key, mentry, result, before_etag)
|
|
56
56
|
normalized = Textus::Refresh.normalize_action_result(result, format: mentry.format)
|
|
57
|
-
envelope = @ctx.
|
|
57
|
+
envelope = Textus::Application::Writes::Put.new(ctx: @ctx, bus: @bus).call(
|
|
58
58
|
key,
|
|
59
59
|
meta: normalized[:meta], body: normalized[:body], content: normalized[:content],
|
|
60
|
-
|
|
60
|
+
suppress_events: true
|
|
61
61
|
)
|
|
62
62
|
change = detect_change(before_etag, envelope)
|
|
63
63
|
unless change == :unchanged
|
|
@@ -69,7 +69,7 @@ module Textus
|
|
|
69
69
|
|
|
70
70
|
def detect_change(before_etag, envelope)
|
|
71
71
|
if before_etag.nil? then :created
|
|
72
|
-
elsif envelope
|
|
72
|
+
elsif envelope.etag == before_etag then :unchanged
|
|
73
73
|
else :updated
|
|
74
74
|
end
|
|
75
75
|
end
|
|
@@ -10,8 +10,8 @@ module Textus
|
|
|
10
10
|
def call(pending_key)
|
|
11
11
|
raise ProposalError.new("only human role can accept proposals; got '#{@ctx.role}'") unless @ctx.role == "human"
|
|
12
12
|
|
|
13
|
-
env = @ctx.store.get(pending_key)
|
|
14
|
-
proposal = env["
|
|
13
|
+
env = @ctx.store.reader.get(pending_key)
|
|
14
|
+
proposal = env.meta["proposal"] or raise ProposalError.new("entry has no proposal block: #{pending_key}")
|
|
15
15
|
target = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
|
|
16
16
|
action = proposal["action"] || "put"
|
|
17
17
|
|
|
@@ -21,16 +21,16 @@ module Textus
|
|
|
21
21
|
when "put"
|
|
22
22
|
# Nested proposal "frontmatter" — the meta to write to the accepted
|
|
23
23
|
# target. Not related to the removed intake-handler legacy bridge.
|
|
24
|
-
target_meta = env["
|
|
25
|
-
target_body = env
|
|
26
|
-
|
|
24
|
+
target_meta = env.meta["frontmatter"] || {}
|
|
25
|
+
target_body = env.body
|
|
26
|
+
Textus::Application::Writes::Put.new(ctx: @ctx, bus: @bus).call(target, meta: target_meta, body: target_body)
|
|
27
27
|
when "delete"
|
|
28
|
-
|
|
28
|
+
Textus::Application::Writes::Delete.new(ctx: @ctx, bus: @bus).call(target)
|
|
29
29
|
else
|
|
30
30
|
raise ProposalError.new("unknown action: #{action}")
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
Textus::Application::Writes::Delete.new(ctx: @ctx, bus: @bus).call(pending_key)
|
|
34
34
|
|
|
35
35
|
@bus.publish(:proposal_accepted,
|
|
36
36
|
store: @ctx.with_role(@ctx.role),
|
|
@@ -4,9 +4,12 @@ module Textus
|
|
|
4
4
|
module Application
|
|
5
5
|
module Writes
|
|
6
6
|
# Materializes generator-zone entries (template + projection) onto disk
|
|
7
|
-
# and copies the result to any configured `publish_to
|
|
8
|
-
#
|
|
9
|
-
#
|
|
7
|
+
# and copies the result to any configured `publish_to:` targets. Fires
|
|
8
|
+
# `:build_completed` and `:file_published` events.
|
|
9
|
+
#
|
|
10
|
+
# For `publish_each:` (per-leaf publishing of nested entries), see
|
|
11
|
+
# `Application::Writes::Publish`. The CLI verb `textus build` calls
|
|
12
|
+
# both classes and merges the results.
|
|
10
13
|
class Build
|
|
11
14
|
def initialize(ctx:, bus:)
|
|
12
15
|
@ctx = ctx
|
|
@@ -14,16 +17,14 @@ module Textus
|
|
|
14
17
|
end
|
|
15
18
|
|
|
16
19
|
def call(prefix: nil)
|
|
17
|
-
built =
|
|
18
|
-
manifest.entries.each do |mentry|
|
|
20
|
+
built = manifest.entries.filter_map do |mentry|
|
|
19
21
|
next unless mentry.in_generator_zone?
|
|
20
22
|
next unless mentry.projection || mentry.template
|
|
21
23
|
next if prefix && !mentry.key.start_with?(prefix)
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
materialize(mentry)
|
|
24
26
|
end
|
|
25
|
-
|
|
26
|
-
{ "protocol" => Textus::PROTOCOL, "built" => built, "published_leaves" => published_leaves }
|
|
27
|
+
{ "protocol" => Textus::PROTOCOL, "built" => built }
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
private
|
|
@@ -32,41 +33,6 @@ module Textus
|
|
|
32
33
|
def manifest = store.manifest
|
|
33
34
|
def root = store.root
|
|
34
35
|
|
|
35
|
-
def publish_leaves(prefix: nil)
|
|
36
|
-
repo_root = File.dirname(root)
|
|
37
|
-
out = []
|
|
38
|
-
manifest.entries.each do |mentry|
|
|
39
|
-
next unless mentry.nested && mentry.publish_each
|
|
40
|
-
next if prefix && !mentry.key.start_with?(prefix) && !prefix.start_with?("#{mentry.key}.")
|
|
41
|
-
|
|
42
|
-
manifest.enumerate(prefix: mentry.key).each do |row|
|
|
43
|
-
next unless row[:manifest_entry].equal?(mentry)
|
|
44
|
-
next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
|
|
45
|
-
|
|
46
|
-
out << publish_leaf(mentry, row, repo_root)
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
out
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def publish_leaf(mentry, row, repo_root)
|
|
53
|
-
target_rel = mentry.publish_target_for(row[:key])
|
|
54
|
-
target_abs = File.expand_path(File.join(repo_root, target_rel))
|
|
55
|
-
unless target_abs.start_with?(File.expand_path(repo_root) + File::SEPARATOR)
|
|
56
|
-
raise PublishError.new(
|
|
57
|
-
"entry '#{mentry.key}': publish_each target '#{target_rel}' for key '#{row[:key]}' escapes repo root",
|
|
58
|
-
)
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
Textus::Infra::Publisher.publish(source: row[:path], target: target_abs, store_root: root)
|
|
62
|
-
publish_event(:file_published,
|
|
63
|
-
key: row[:key],
|
|
64
|
-
envelope: store.get(row[:key]),
|
|
65
|
-
source: row[:path],
|
|
66
|
-
target: target_abs)
|
|
67
|
-
{ "key" => row[:key], "source" => row[:path], "target" => target_abs }
|
|
68
|
-
end
|
|
69
|
-
|
|
70
36
|
def materialize(mentry)
|
|
71
37
|
target_path = Builder::Pipeline.run(
|
|
72
38
|
store: store,
|
|
@@ -85,7 +51,7 @@ module Textus
|
|
|
85
51
|
end
|
|
86
52
|
|
|
87
53
|
def publish_and_fire(mentry, target_path)
|
|
88
|
-
envelope = store.get(mentry.key)
|
|
54
|
+
envelope = store.reader.get(mentry.key)
|
|
89
55
|
repo_root = File.dirname(root)
|
|
90
56
|
|
|
91
57
|
mentry.publish_to.each do |rel|
|
|
@@ -105,9 +71,6 @@ module Textus
|
|
|
105
71
|
end
|
|
106
72
|
|
|
107
73
|
def publish_event(event, **payload)
|
|
108
|
-
# `with_role` returns a Context that preserves the original
|
|
109
|
-
# correlation_id, so hooks reading `store.correlation_id` see the
|
|
110
|
-
# same value as the event's top-level correlation_id key.
|
|
111
74
|
@bus.publish(event, store: @ctx.with_role(@ctx.role), correlation_id: @ctx.correlation_id, **payload)
|
|
112
75
|
end
|
|
113
76
|
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Application
|
|
5
|
+
module Writes
|
|
6
|
+
class Mv
|
|
7
|
+
MovePlan = Data.define(
|
|
8
|
+
:old_key, :new_key, :old_path, :new_path,
|
|
9
|
+
:new_mentry, :uid, :etag_before
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
def initialize(ctx:, bus:)
|
|
13
|
+
@ctx = ctx
|
|
14
|
+
@bus = bus
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(old_key, new_key, dry_run: false)
|
|
18
|
+
plan, pre_env = prepare_plan(old_key, new_key)
|
|
19
|
+
return dry_run_result(plan) if dry_run
|
|
20
|
+
|
|
21
|
+
plan = ensure_uid!(plan, pre_env: pre_env)
|
|
22
|
+
etag_after = perform_move!(plan)
|
|
23
|
+
new_envelope = record_move(plan, etag_after: etag_after)
|
|
24
|
+
success_result(plan, new_envelope: new_envelope)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def manifest = @ctx.store.manifest
|
|
30
|
+
def reader = @ctx.store.reader
|
|
31
|
+
|
|
32
|
+
def prepare_plan(old_key, new_key)
|
|
33
|
+
manifest.validate_key!(old_key)
|
|
34
|
+
manifest.validate_key!(new_key)
|
|
35
|
+
raise UsageError.new("mv: old and new keys are identical") if old_key == new_key
|
|
36
|
+
|
|
37
|
+
old_mentry, old_path, = manifest.resolve(old_key)
|
|
38
|
+
raise UnknownKey.new(old_key) unless File.exist?(old_path)
|
|
39
|
+
|
|
40
|
+
new_mentry, new_path, = manifest.resolve(new_key)
|
|
41
|
+
validate_zone_and_format!(old_mentry, new_mentry)
|
|
42
|
+
validate_writer!(old_mentry, old_key)
|
|
43
|
+
raise UsageError.new("mv: target '#{new_key}' already exists at #{new_path}") if File.exist?(new_path)
|
|
44
|
+
|
|
45
|
+
pre_env = reader.get(old_key)
|
|
46
|
+
plan = MovePlan.new(
|
|
47
|
+
old_key: old_key, new_key: new_key,
|
|
48
|
+
old_path: old_path, new_path: new_path,
|
|
49
|
+
new_mentry: new_mentry,
|
|
50
|
+
uid: pre_env.uid, etag_before: pre_env.etag
|
|
51
|
+
)
|
|
52
|
+
[plan, pre_env]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def validate_zone_and_format!(old_mentry, new_mentry)
|
|
56
|
+
if old_mentry.zone != new_mentry.zone
|
|
57
|
+
raise UsageError.new(
|
|
58
|
+
"mv: cross-zone move refused (#{old_mentry.zone} → #{new_mentry.zone}). " \
|
|
59
|
+
"Use put+delete for cross-zone moves.",
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
return if old_mentry.format == new_mentry.format
|
|
63
|
+
|
|
64
|
+
raise UsageError.new("mv: format mismatch (#{old_mentry.format} → #{new_mentry.format}); refusing.")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def validate_writer!(mentry, key)
|
|
68
|
+
writers = manifest.zone_writers(mentry.zone)
|
|
69
|
+
return if writers.include?(@ctx.role)
|
|
70
|
+
|
|
71
|
+
raise WriteForbidden.new(key, mentry.zone, writers: writers)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def ensure_uid!(plan, pre_env:)
|
|
75
|
+
return plan if plan.uid
|
|
76
|
+
|
|
77
|
+
env = Textus::Application::Writes::Put.new(ctx: @ctx, bus: @bus).call(
|
|
78
|
+
plan.old_key,
|
|
79
|
+
meta: pre_env.meta,
|
|
80
|
+
body: pre_env.body,
|
|
81
|
+
content: pre_env.content,
|
|
82
|
+
suppress_events: true,
|
|
83
|
+
)
|
|
84
|
+
plan.with(uid: env.uid, etag_before: env.etag)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def perform_move!(plan)
|
|
88
|
+
FileUtils.mkdir_p(File.dirname(plan.new_path))
|
|
89
|
+
FileUtils.mv(plan.old_path, plan.new_path)
|
|
90
|
+
rewrite_name_for_mv!(plan.new_mentry, plan.new_path, plan.new_key)
|
|
91
|
+
Etag.for_file(plan.new_path)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def record_move(plan, etag_after:)
|
|
95
|
+
extras = {
|
|
96
|
+
"from_key" => plan.old_key, "to_key" => plan.new_key,
|
|
97
|
+
"from_path" => plan.old_path, "to_path" => plan.new_path,
|
|
98
|
+
"uid" => plan.uid
|
|
99
|
+
}
|
|
100
|
+
extras["correlation_id"] = @ctx.correlation_id if @ctx.correlation_id
|
|
101
|
+
|
|
102
|
+
@ctx.store.audit_log.append(
|
|
103
|
+
role: @ctx.role, verb: "mv", key: plan.new_key,
|
|
104
|
+
etag_before: plan.etag_before, etag_after: etag_after,
|
|
105
|
+
extras: extras
|
|
106
|
+
)
|
|
107
|
+
new_envelope = reader.get(plan.new_key)
|
|
108
|
+
@bus.publish(:entry_renamed,
|
|
109
|
+
store: @ctx.with_role(@ctx.role),
|
|
110
|
+
key: plan.new_key,
|
|
111
|
+
from_key: plan.old_key,
|
|
112
|
+
to_key: plan.new_key,
|
|
113
|
+
envelope: new_envelope,
|
|
114
|
+
correlation_id: @ctx.correlation_id)
|
|
115
|
+
new_envelope
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def dry_run_result(plan)
|
|
119
|
+
{
|
|
120
|
+
"protocol" => PROTOCOL, "ok" => true, "dry_run" => true,
|
|
121
|
+
"from_key" => plan.old_key, "to_key" => plan.new_key,
|
|
122
|
+
"from_path" => plan.old_path, "to_path" => plan.new_path,
|
|
123
|
+
"uid" => plan.uid
|
|
124
|
+
}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def success_result(plan, new_envelope:)
|
|
128
|
+
{
|
|
129
|
+
"protocol" => PROTOCOL, "ok" => true,
|
|
130
|
+
"from_key" => plan.old_key, "to_key" => plan.new_key,
|
|
131
|
+
"from_path" => plan.old_path, "to_path" => plan.new_path,
|
|
132
|
+
"uid" => plan.uid,
|
|
133
|
+
"envelope" => new_envelope.to_h_for_wire
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def rewrite_name_for_mv!(mentry, new_path, new_key)
|
|
138
|
+
basename = new_key.split(".").last
|
|
139
|
+
Entry.for_format(mentry.format).rewrite_name(new_path, basename)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -1,23 +1,55 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Application
|
|
3
3
|
module Writes
|
|
4
|
+
# Copies nested-leaf entries to their `publish_each:` targets. Fires
|
|
5
|
+
# `:file_published` for each copy. Mirror of `Build` for the publish
|
|
6
|
+
# half — split out from the old Build per ADR 0007.
|
|
4
7
|
class Publish
|
|
5
8
|
def initialize(ctx:, bus:)
|
|
6
9
|
@ctx = ctx
|
|
7
10
|
@bus = bus
|
|
8
11
|
end
|
|
9
12
|
|
|
10
|
-
def call(
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
def call(prefix: nil)
|
|
14
|
+
repo_root = File.dirname(store.root)
|
|
15
|
+
out = []
|
|
16
|
+
manifest.entries.each do |mentry|
|
|
17
|
+
next unless mentry.nested && mentry.publish_each
|
|
18
|
+
next if prefix && !mentry.key.start_with?(prefix) && !prefix.start_with?("#{mentry.key}.")
|
|
19
|
+
|
|
20
|
+
manifest.enumerate(prefix: mentry.key).each do |row|
|
|
21
|
+
next unless row[:manifest_entry].equal?(mentry)
|
|
22
|
+
next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
|
|
23
|
+
|
|
24
|
+
out << publish_leaf(mentry, row, repo_root)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
{ "protocol" => Textus::PROTOCOL, "published_leaves" => out }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def store = @ctx.store
|
|
33
|
+
def manifest = store.manifest
|
|
34
|
+
|
|
35
|
+
def publish_leaf(mentry, row, repo_root)
|
|
36
|
+
target_rel = mentry.publish_target_for(row[:key])
|
|
37
|
+
target_abs = File.expand_path(File.join(repo_root, target_rel))
|
|
38
|
+
unless target_abs.start_with?(File.expand_path(repo_root) + File::SEPARATOR)
|
|
39
|
+
raise PublishError.new(
|
|
40
|
+
"entry '#{mentry.key}': publish_each target '#{target_rel}' for key '#{row[:key]}' escapes repo root",
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
Textus::Infra::Publisher.publish(source: row[:path], target: target_abs, store_root: store.root)
|
|
16
45
|
@bus.publish(:file_published,
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
46
|
+
store: @ctx.with_role(@ctx.role),
|
|
47
|
+
key: row[:key],
|
|
48
|
+
envelope: store.reader.get(row[:key]),
|
|
49
|
+
source: row[:path],
|
|
50
|
+
target: target_abs,
|
|
20
51
|
correlation_id: @ctx.correlation_id)
|
|
52
|
+
{ "key" => row[:key], "source" => row[:path], "target" => target_abs }
|
|
21
53
|
end
|
|
22
54
|
end
|
|
23
55
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Writes
|
|
4
|
+
class Reject
|
|
5
|
+
def initialize(ctx:, bus:)
|
|
6
|
+
@ctx = ctx
|
|
7
|
+
@bus = bus
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(pending_key)
|
|
11
|
+
raise ProposalError.new("only human role can reject proposals; got '#{@ctx.role}'") unless @ctx.role == "human"
|
|
12
|
+
|
|
13
|
+
mentry, = @ctx.store.manifest.resolve(pending_key)
|
|
14
|
+
unless mentry.in_proposal_zone?
|
|
15
|
+
raise ProposalError.new("reject: '#{pending_key}' is not in a proposal zone (zone=#{mentry.zone})")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
env = @ctx.store.reader.get(pending_key)
|
|
19
|
+
proposal = env.meta&.dig("proposal") or
|
|
20
|
+
raise ProposalError.new("entry has no proposal block: #{pending_key}")
|
|
21
|
+
target_key = proposal["target_key"] or
|
|
22
|
+
raise ProposalError.new("proposal missing target_key")
|
|
23
|
+
|
|
24
|
+
Textus::Application::Writes::Delete.new(ctx: @ctx, bus: @bus).call(pending_key, suppress_events: true)
|
|
25
|
+
|
|
26
|
+
@bus.publish(:proposal_rejected,
|
|
27
|
+
store: @ctx.with_role(@ctx.role),
|
|
28
|
+
key: pending_key,
|
|
29
|
+
target_key: target_key,
|
|
30
|
+
correlation_id: @ctx.correlation_id)
|
|
31
|
+
|
|
32
|
+
{ "protocol" => PROTOCOL, "rejected" => pending_key, "target_key" => target_key }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -19,6 +19,35 @@ module Textus
|
|
|
19
19
|
end
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
# Replaces the freshly-stamped timestamp inside `new_bytes` with the
|
|
23
|
+
# timestamp pulled from `old_bytes` (same format). Returns the rewritten
|
|
24
|
+
# bytes, or nil if either side lacks a parseable timestamp.
|
|
25
|
+
module IdempotentWrite
|
|
26
|
+
def self.rewrite_with_prior_timestamp(new_bytes:, old_bytes:, format:)
|
|
27
|
+
prior = extract_timestamp(old_bytes, format)
|
|
28
|
+
fresh = extract_timestamp(new_bytes, format)
|
|
29
|
+
return nil unless prior && fresh
|
|
30
|
+
return new_bytes if prior == fresh
|
|
31
|
+
|
|
32
|
+
new_bytes.sub(fresh, prior)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.extract_timestamp(bytes, format)
|
|
36
|
+
case format
|
|
37
|
+
when "markdown"
|
|
38
|
+
parsed = Entry.for_format("markdown").parse(bytes)
|
|
39
|
+
parsed.dig("_meta", "generated", "at")
|
|
40
|
+
when "json", "yaml"
|
|
41
|
+
parsed = Entry.for_format(format).parse(bytes)
|
|
42
|
+
parsed.dig("_meta", "generated_at")
|
|
43
|
+
else # rubocop:disable Style/EmptyElse
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
rescue Textus::BadFrontmatter
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
22
51
|
module Pipeline
|
|
23
52
|
def self.renderers
|
|
24
53
|
@renderers ||= {
|
|
@@ -44,13 +73,28 @@ module Textus
|
|
|
44
73
|
raise UsageError.new("builder: unsupported format #{mentry.format.inspect} for '#{mentry.key}'")
|
|
45
74
|
bytes = klass.new(template_loader: template_loader).call(mentry: mentry, data: data)
|
|
46
75
|
|
|
47
|
-
# 3. Write
|
|
76
|
+
# 3. Write (idempotent: skip if only generated_at would differ)
|
|
48
77
|
target_path = Key::Path.resolve(store.manifest, mentry)
|
|
49
78
|
FileUtils.mkdir_p(File.dirname(target_path))
|
|
50
|
-
|
|
79
|
+
write_if_changed(target_path, bytes, mentry.format)
|
|
51
80
|
|
|
52
81
|
target_path
|
|
53
82
|
end
|
|
83
|
+
|
|
84
|
+
def self.write_if_changed(target_path, bytes, format)
|
|
85
|
+
if File.exist?(target_path)
|
|
86
|
+
old_bytes = File.binread(target_path)
|
|
87
|
+
if format == "text"
|
|
88
|
+
return if old_bytes == bytes
|
|
89
|
+
else
|
|
90
|
+
rewritten = IdempotentWrite.rewrite_with_prior_timestamp(
|
|
91
|
+
new_bytes: bytes, old_bytes: old_bytes, format: format,
|
|
92
|
+
)
|
|
93
|
+
return if rewritten && rewritten == old_bytes
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
File.binwrite(target_path, bytes)
|
|
97
|
+
end
|
|
54
98
|
end
|
|
55
99
|
end
|
|
56
100
|
end
|
|
@@ -6,8 +6,7 @@ module Textus
|
|
|
6
6
|
|
|
7
7
|
def call(store)
|
|
8
8
|
key = positional.shift or raise UsageError.new("accept requires a key")
|
|
9
|
-
|
|
10
|
-
emit(Textus::Composition.writes_accept(ctx).call(key))
|
|
9
|
+
emit(operations_for(store).writes.accept.call(key))
|
|
11
10
|
end
|
|
12
11
|
end
|
|
13
12
|
end
|
|
@@ -11,9 +11,9 @@ module Textus
|
|
|
11
11
|
option :limit, "--limit=N"
|
|
12
12
|
|
|
13
13
|
def call(store)
|
|
14
|
-
|
|
15
|
-
since_time = since && Textus::Application::Reads::Audit.parse_since(since, now: ctx.now)
|
|
16
|
-
rows =
|
|
14
|
+
ops = operations_for(store)
|
|
15
|
+
since_time = since && Textus::Application::Reads::Audit.parse_since(since, now: ops.ctx.now)
|
|
16
|
+
rows = ops.reads.audit.call(
|
|
17
17
|
key: key_filter,
|
|
18
18
|
zone: zone,
|
|
19
19
|
role: role_filter,
|
|
@@ -6,8 +6,7 @@ module Textus
|
|
|
6
6
|
|
|
7
7
|
def call(store)
|
|
8
8
|
key = positional.shift or raise UsageError.new("blame requires a key")
|
|
9
|
-
|
|
10
|
-
rows = Textus::Composition.blame(ctx).call(key: key, limit: limit&.to_i)
|
|
9
|
+
rows = operations_for(store).reads.blame.call(key: key, limit: limit&.to_i)
|
|
11
10
|
emit({ "verb" => "blame", "key" => key, "rows" => rows })
|
|
12
11
|
end
|
|
13
12
|
end
|
|
@@ -5,8 +5,12 @@ module Textus
|
|
|
5
5
|
option :prefix, "--prefix=K"
|
|
6
6
|
|
|
7
7
|
def call(store)
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
ops = Textus::Operations.for(store, role: "builder")
|
|
9
|
+
build_res = ops.writes.build.call(prefix: prefix)
|
|
10
|
+
publish_res = ops.writes.publish.call(prefix: prefix)
|
|
11
|
+
emit({ "protocol" => Textus::PROTOCOL,
|
|
12
|
+
"built" => build_res["built"],
|
|
13
|
+
"published_leaves" => publish_res["published_leaves"] })
|
|
10
14
|
end
|
|
11
15
|
end
|
|
12
16
|
end
|
|
@@ -7,8 +7,7 @@ module Textus
|
|
|
7
7
|
|
|
8
8
|
def call(store)
|
|
9
9
|
key = positional.shift or raise UsageError.new("delete requires a key")
|
|
10
|
-
|
|
11
|
-
emit(Textus::Composition.writes_delete(ctx).call(key, if_etag: if_etag))
|
|
10
|
+
emit(operations_for(store).writes.delete.call(key, if_etag: if_etag))
|
|
12
11
|
end
|
|
13
12
|
end
|
|
14
13
|
end
|
data/lib/textus/cli/verb/deps.rb
CHANGED
|
@@ -4,7 +4,7 @@ module Textus
|
|
|
4
4
|
class Deps < Verb
|
|
5
5
|
def call(store)
|
|
6
6
|
key = positional.shift or raise UsageError.new("deps requires a key")
|
|
7
|
-
emit({ "key" => key, "deps" => store.deps(key) })
|
|
7
|
+
emit({ "key" => key, "deps" => operations_for(store).reads.deps.call(key) })
|
|
8
8
|
end
|
|
9
9
|
end
|
|
10
10
|
end
|
|
@@ -6,8 +6,7 @@ module Textus
|
|
|
6
6
|
option :zone, "--zone=Z"
|
|
7
7
|
|
|
8
8
|
def call(store)
|
|
9
|
-
|
|
10
|
-
rows = Textus::Composition.freshness(ctx).call(prefix: prefix, zone: zone)
|
|
9
|
+
rows = operations_for(store).reads.freshness.call(prefix: prefix, zone: zone)
|
|
11
10
|
emit({ "verb" => "freshness", "rows" => rows })
|
|
12
11
|
end
|
|
13
12
|
end
|
data/lib/textus/cli/verb/get.rb
CHANGED
|
@@ -6,11 +6,10 @@ module Textus
|
|
|
6
6
|
|
|
7
7
|
def call(store)
|
|
8
8
|
key = positional.shift or raise UsageError.new("get requires a key")
|
|
9
|
-
|
|
10
|
-
result = Textus::Composition.reads_get(ctx).call(key)
|
|
9
|
+
result = operations_for(store).reads.get.call(key)
|
|
11
10
|
raise Textus::UnknownKey.new(key, suggestions: store.manifest.suggestions_for(key)) if result.nil?
|
|
12
11
|
|
|
13
|
-
emit(result)
|
|
12
|
+
emit(result.to_h_for_wire)
|
|
14
13
|
end
|
|
15
14
|
end
|
|
16
15
|
end
|
data/lib/textus/cli/verb/list.rb
CHANGED
data/lib/textus/cli/verb/mv.rb
CHANGED
|
@@ -8,7 +8,7 @@ module Textus
|
|
|
8
8
|
def call(store)
|
|
9
9
|
old_key = positional.shift or raise UsageError.new("mv requires <old-key> <new-key>")
|
|
10
10
|
new_key = positional.shift or raise UsageError.new("mv requires <old-key> <new-key>")
|
|
11
|
-
emit(store.mv(old_key, new_key,
|
|
11
|
+
emit(operations_for(store).writes.mv.call(old_key, new_key, dry_run: dry_run || false))
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
14
|
end
|