textus 0.18.0 → 0.20.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 +43 -48
- data/CHANGELOG.md +173 -0
- data/lib/textus/application/context.rb +20 -58
- data/lib/textus/application/policy/predicates/human_accept.rb +30 -0
- data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
- data/lib/textus/{domain → application}/policy/promotion.rb +20 -3
- 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 +2 -2
- 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/tools/migrate_keys.rb +191 -0
- data/lib/textus/application/tools/migrate_manifest_to_kinds.rb +31 -0
- data/lib/textus/application/writes/accept.rb +38 -15
- 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 +20 -11
- 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 +2 -5
- 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/key_normalize.rb +32 -3
- 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 +6 -5
- 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/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 +7 -7
- 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 +64 -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 +109 -0
- data/lib/textus/manifest/schema.rb +1 -1
- data/lib/textus/manifest.rb +3 -100
- data/lib/textus/operations.rb +131 -74
- data/lib/textus/schema/tools.rb +2 -2
- data/lib/textus/store.rb +6 -6
- data/lib/textus/version.rb +1 -1
- metadata +18 -11
- data/lib/textus/application/writes/build.rb +0 -78
- data/lib/textus/dependencies.rb +0 -23
- data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
- data/lib/textus/hooks/dispatcher.rb +0 -71
- data/lib/textus/hooks/registry.rb +0 -85
- data/lib/textus/migrate_keys.rb +0 -187
- data/lib/textus/projection.rb +0 -89
- data/lib/textus/refresh.rb +0 -39
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Tools
|
|
4
|
+
# Run-once helper that renames files/directories whose basenames don't
|
|
5
|
+
# conform to the strict key grammar (§3 of plan-1.2). Only walks
|
|
6
|
+
# nested: true manifest entries — leaf entries with illegal declared
|
|
7
|
+
# keys are caught by Manifest load and must be fixed by hand.
|
|
8
|
+
module MigrateKeys
|
|
9
|
+
SEGMENT = /\A[a-z0-9][a-z0-9-]*\z/
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Returns the envelope hash described in plan-1.2 §3.
|
|
14
|
+
def run(store, write: false)
|
|
15
|
+
plan = build_plan(store)
|
|
16
|
+
collisions = plan[:collisions]
|
|
17
|
+
renames = plan[:renames]
|
|
18
|
+
|
|
19
|
+
ok = collisions.empty?
|
|
20
|
+
apply!(store, renames) if write && ok
|
|
21
|
+
|
|
22
|
+
{
|
|
23
|
+
"protocol" => Textus::PROTOCOL,
|
|
24
|
+
"mode" => write ? "write" : "dry-run",
|
|
25
|
+
"renames" => renames.map { |r| envelope_rename(r) },
|
|
26
|
+
"collisions" => collisions.map { |c| envelope_collision(c) },
|
|
27
|
+
"ok" => ok,
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# ------------------------------------------------------------------
|
|
32
|
+
# Plan construction
|
|
33
|
+
# ------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
# Returns { renames: [...], collisions: [...] }
|
|
36
|
+
# Each rename: { from:, to:, old_key:, new_key:, kind: :file|:dir }
|
|
37
|
+
# Each collision: { target:, sources: [...] }
|
|
38
|
+
def build_plan(store) # rubocop:disable Metrics/AbcSize
|
|
39
|
+
renames = []
|
|
40
|
+
target_buckets = Hash.new { |h, k| h[k] = [] } # target_path => [source_path, ...]
|
|
41
|
+
|
|
42
|
+
store.manifest.entries.each do |entry|
|
|
43
|
+
next unless entry.nested?
|
|
44
|
+
|
|
45
|
+
base = File.join(store.root, "zones", entry.path)
|
|
46
|
+
next unless File.directory?(base)
|
|
47
|
+
|
|
48
|
+
# Walk depth-first. Order matters when computing the "new key"
|
|
49
|
+
# for files inside a renamed directory: we record renames bottom-up,
|
|
50
|
+
# so children are renamed before their parents on apply.
|
|
51
|
+
walk(base) do |abs_path, is_dir|
|
|
52
|
+
next if abs_path == base
|
|
53
|
+
|
|
54
|
+
basename = File.basename(abs_path)
|
|
55
|
+
stem = is_dir ? basename : basename.sub(/#{Regexp.escape(File.extname(basename))}\z/, "")
|
|
56
|
+
next if stem.match?(SEGMENT)
|
|
57
|
+
|
|
58
|
+
new_stem = normalize(stem)
|
|
59
|
+
# Skip if normalization yields the same stem (e.g. already-legal
|
|
60
|
+
# under a different lens). In practice match?(SEGMENT) catches that
|
|
61
|
+
# above; this is a safety net.
|
|
62
|
+
next if new_stem == stem
|
|
63
|
+
|
|
64
|
+
new_basename = is_dir ? new_stem : new_stem + File.extname(basename)
|
|
65
|
+
target = File.join(File.dirname(abs_path), new_basename)
|
|
66
|
+
target_buckets[target] << abs_path
|
|
67
|
+
|
|
68
|
+
renames << {
|
|
69
|
+
from: abs_path,
|
|
70
|
+
to: target,
|
|
71
|
+
kind: is_dir ? :dir : :file,
|
|
72
|
+
entry: entry,
|
|
73
|
+
base: base,
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
collisions = target_buckets.select { |_, srcs| srcs.length > 1 }
|
|
79
|
+
.map { |t, srcs| { target: t, sources: srcs.sort } }
|
|
80
|
+
|
|
81
|
+
# Drop colliding entries from renames (we won't apply any of them)
|
|
82
|
+
colliding_targets = collisions.to_set { |c| c[:target] }
|
|
83
|
+
renames.reject! { |r| colliding_targets.include?(r[:to]) }
|
|
84
|
+
|
|
85
|
+
# Sort renames bottom-up (deepest path first) so children move before parents.
|
|
86
|
+
renames.sort_by! { |r| -r[:from].count("/") }
|
|
87
|
+
|
|
88
|
+
{ renames: renames, collisions: collisions }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Yields [absolute_path, is_dir] for every entry under root. Depth-first.
|
|
92
|
+
def walk(root, &block)
|
|
93
|
+
Dir.each_child(root) do |name|
|
|
94
|
+
abs = File.join(root, name)
|
|
95
|
+
if File.directory?(abs)
|
|
96
|
+
walk(abs, &block)
|
|
97
|
+
yield abs, true
|
|
98
|
+
else
|
|
99
|
+
yield abs, false
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Deterministic transform per plan §3.
|
|
105
|
+
def normalize(s)
|
|
106
|
+
s = s.downcase
|
|
107
|
+
s = s.gsub(/[^a-z0-9-]/, "-") # ., _, and anything else become -
|
|
108
|
+
s = s.gsub(/-+/, "-")
|
|
109
|
+
s.sub(/\A-+/, "").sub(/-+\z/, "")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
# Apply
|
|
114
|
+
# ------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def apply!(store, renames)
|
|
117
|
+
audit = Textus::Infra::AuditLog.new(store.root)
|
|
118
|
+
renames.each do |r|
|
|
119
|
+
# Bottom-up order means a child's ancestors haven't moved yet, so
|
|
120
|
+
# `from`/`to` are valid as-recorded. The audit `key` reflects the
|
|
121
|
+
# eventual full key once every rename in this batch has applied.
|
|
122
|
+
from = r[:from]
|
|
123
|
+
to = r[:to]
|
|
124
|
+
File.rename(from, to)
|
|
125
|
+
new_key = compute_new_key(r, renames)
|
|
126
|
+
audit.append(
|
|
127
|
+
role: "runner",
|
|
128
|
+
verb: "migrate-keys",
|
|
129
|
+
key: new_key,
|
|
130
|
+
etag_before: nil,
|
|
131
|
+
etag_after: nil,
|
|
132
|
+
extras: { "from" => from, "to" => to },
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# If an ancestor of `path` was renamed earlier in this batch, rewrite the path.
|
|
138
|
+
def resolve_current_path(path, renames)
|
|
139
|
+
out = path
|
|
140
|
+
renames.each do |r|
|
|
141
|
+
prefix = r[:from] + "/"
|
|
142
|
+
out = r[:to] + out[r[:from].length..] if out.start_with?(prefix)
|
|
143
|
+
end
|
|
144
|
+
out
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# New full key after applying all renames up through this one.
|
|
148
|
+
def compute_new_key(rename, renames)
|
|
149
|
+
base = rename[:base]
|
|
150
|
+
entry = rename[:entry]
|
|
151
|
+
new_to = resolve_current_path(rename[:to], renames)
|
|
152
|
+
|
|
153
|
+
rel = new_to.sub(%r{\A#{Regexp.escape(base)}/?}, "")
|
|
154
|
+
stripped = rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "") unless rename[:kind] == :dir
|
|
155
|
+
stripped ||= rel
|
|
156
|
+
segs = stripped.split("/").reject(&:empty?)
|
|
157
|
+
(entry.key.split(".") + segs).join(".")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# ------------------------------------------------------------------
|
|
161
|
+
# Envelope helpers
|
|
162
|
+
# ------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
def envelope_rename(r)
|
|
165
|
+
{
|
|
166
|
+
"from" => r[:from],
|
|
167
|
+
"to" => r[:to],
|
|
168
|
+
"old_key" => path_to_key(r[:from], r[:base], r[:entry], r[:kind]),
|
|
169
|
+
"new_key" => path_to_key(r[:to], r[:base], r[:entry], r[:kind]),
|
|
170
|
+
}
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def envelope_collision(col)
|
|
174
|
+
{ "target" => col[:target], "sources" => col[:sources] }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def path_to_key(path, base, entry, kind)
|
|
178
|
+
rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
|
|
179
|
+
stripped =
|
|
180
|
+
if kind == :dir
|
|
181
|
+
rel
|
|
182
|
+
else
|
|
183
|
+
rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
|
|
184
|
+
end
|
|
185
|
+
segs = stripped.split("/").reject(&:empty?)
|
|
186
|
+
(entry.key.split(".") + segs).join(".")
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Application
|
|
5
|
+
module Tools
|
|
6
|
+
module MigrateManifestToKinds
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def upgrade_yaml(yaml_text)
|
|
10
|
+
raw = YAML.safe_load(yaml_text, aliases: false)
|
|
11
|
+
raw["entries"] = Array(raw["entries"]).map { |row| upgrade_row(row) }
|
|
12
|
+
YAML.dump(raw)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def upgrade_row(row)
|
|
16
|
+
return row if row["kind"]
|
|
17
|
+
|
|
18
|
+
row.merge("kind" => infer_kind(row))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def infer_kind(row)
|
|
22
|
+
return "intake" if row["intake"].is_a?(Hash) || row["intake_handler"]
|
|
23
|
+
return "derived" if row["template"] || row["compute"] || row["generator"] || row["projection"]
|
|
24
|
+
return "nested" if row["nested"] == true
|
|
25
|
+
|
|
26
|
+
"leaf"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -2,15 +2,23 @@ module Textus
|
|
|
2
2
|
module Application
|
|
3
3
|
module Writes
|
|
4
4
|
class Accept
|
|
5
|
-
def initialize(ctx:, envelope_io:)
|
|
6
|
-
@ctx
|
|
7
|
-
@
|
|
5
|
+
def initialize(ctx:, manifest:, file_store:, schemas:, envelope_io:, bus:, authorizer:, hook_context:) # rubocop:disable Metrics/ParameterLists
|
|
6
|
+
@ctx = ctx
|
|
7
|
+
@manifest = manifest
|
|
8
|
+
@file_store = file_store
|
|
9
|
+
@schemas = schemas
|
|
10
|
+
@envelope_io = envelope_io
|
|
11
|
+
@bus = bus
|
|
12
|
+
@authorizer = authorizer
|
|
13
|
+
@hook_context = hook_context
|
|
8
14
|
end
|
|
9
15
|
|
|
10
16
|
def call(pending_key)
|
|
11
17
|
raise ProposalError.new("only human role can accept proposals; got '#{@ctx.role}'") unless @ctx.role == "human"
|
|
12
18
|
|
|
13
|
-
env = Textus::Application::Reads::Get.new(
|
|
19
|
+
env = Textus::Application::Reads::Get.new(
|
|
20
|
+
ctx: @ctx, manifest: @manifest, file_store: @file_store,
|
|
21
|
+
).call(pending_key)
|
|
14
22
|
proposal = env.meta["proposal"] or raise ProposalError.new("entry has no proposal block: #{pending_key}")
|
|
15
23
|
target = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
|
|
16
24
|
action = proposal["action"] || "put"
|
|
@@ -23,33 +31,48 @@ module Textus
|
|
|
23
31
|
# target. Not related to the removed intake-handler legacy bridge.
|
|
24
32
|
target_meta = env.meta["frontmatter"] || {}
|
|
25
33
|
target_body = env.body
|
|
26
|
-
|
|
34
|
+
put_op.call(target, meta: target_meta, body: target_body)
|
|
27
35
|
when "delete"
|
|
28
|
-
|
|
36
|
+
delete_op.call(target)
|
|
29
37
|
else
|
|
30
38
|
raise ProposalError.new("unknown action: #{action}")
|
|
31
39
|
end
|
|
32
40
|
|
|
33
|
-
|
|
41
|
+
delete_op.call(pending_key)
|
|
34
42
|
|
|
35
|
-
@
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
correlation_id: @ctx.correlation_id)
|
|
43
|
+
@bus.publish(:proposal_accepted,
|
|
44
|
+
ctx: @hook_context,
|
|
45
|
+
key: pending_key,
|
|
46
|
+
target_key: target)
|
|
40
47
|
|
|
41
48
|
{ "protocol" => PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action }
|
|
42
49
|
end
|
|
43
50
|
|
|
44
51
|
private
|
|
45
52
|
|
|
53
|
+
def put_op
|
|
54
|
+
@put_op ||= Textus::Application::Writes::Put.new(
|
|
55
|
+
ctx: @ctx, manifest: @manifest, envelope_io: @envelope_io,
|
|
56
|
+
bus: @bus, authorizer: @authorizer, hook_context: @hook_context
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def delete_op
|
|
61
|
+
@delete_op ||= Textus::Application::Writes::Delete.new(
|
|
62
|
+
ctx: @ctx, manifest: @manifest, envelope_io: @envelope_io,
|
|
63
|
+
bus: @bus, authorizer: @authorizer, hook_context: @hook_context
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
46
67
|
def evaluate_promotion!(env, target_key)
|
|
47
|
-
rules = @
|
|
68
|
+
rules = @manifest.rules_for(target_key)
|
|
48
69
|
promote = rules.promote
|
|
49
70
|
return if promote.nil? || promote.requires.empty?
|
|
50
71
|
|
|
51
|
-
policy = Textus::
|
|
52
|
-
result = policy.evaluate(
|
|
72
|
+
policy = Textus::Application::Policy::Promotion.from_names(promote.requires)
|
|
73
|
+
result = policy.evaluate(
|
|
74
|
+
entry: env, schemas: @schemas, manifest: @manifest, role: @ctx.role,
|
|
75
|
+
)
|
|
53
76
|
return if result.ok?
|
|
54
77
|
|
|
55
78
|
raise ProposalError.new(
|
|
@@ -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
|