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.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +43 -48
  3. data/CHANGELOG.md +173 -0
  4. data/lib/textus/application/context.rb +20 -58
  5. data/lib/textus/application/policy/predicates/human_accept.rb +30 -0
  6. data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
  7. data/lib/textus/{domain → application}/policy/promotion.rb +20 -3
  8. data/lib/textus/application/projection.rb +91 -0
  9. data/lib/textus/application/reads/audit.rb +4 -4
  10. data/lib/textus/application/reads/blame.rb +9 -8
  11. data/lib/textus/application/reads/deps.rb +14 -3
  12. data/lib/textus/application/reads/freshness.rb +10 -8
  13. data/lib/textus/application/reads/get.rb +10 -8
  14. data/lib/textus/application/reads/get_or_refresh.rb +3 -3
  15. data/lib/textus/application/reads/list.rb +3 -3
  16. data/lib/textus/application/reads/policy_explain.rb +3 -3
  17. data/lib/textus/application/reads/published.rb +5 -3
  18. data/lib/textus/application/reads/rdeps.rb +15 -3
  19. data/lib/textus/application/reads/schema_envelope.rb +5 -4
  20. data/lib/textus/application/reads/stale.rb +3 -3
  21. data/lib/textus/application/reads/uid.rb +11 -3
  22. data/lib/textus/application/reads/validate_all.rb +10 -6
  23. data/lib/textus/application/reads/validator.rb +2 -2
  24. data/lib/textus/application/reads/where.rb +3 -3
  25. data/lib/textus/application/refresh/all.rb +15 -11
  26. data/lib/textus/application/refresh/orchestrator.rb +9 -8
  27. data/lib/textus/application/refresh/worker.rb +56 -32
  28. data/lib/textus/application/tools/migrate_keys.rb +191 -0
  29. data/lib/textus/application/tools/migrate_manifest_to_kinds.rb +31 -0
  30. data/lib/textus/application/writes/accept.rb +38 -15
  31. data/lib/textus/application/writes/delete.rb +13 -10
  32. data/lib/textus/application/writes/envelope_io.rb +64 -4
  33. data/lib/textus/application/writes/materializer.rb +50 -0
  34. data/lib/textus/application/writes/mv.rb +57 -94
  35. data/lib/textus/application/writes/publish.rb +132 -26
  36. data/lib/textus/application/writes/put.rb +15 -14
  37. data/lib/textus/application/writes/reject.rb +20 -11
  38. data/lib/textus/builder/pipeline.rb +21 -15
  39. data/lib/textus/builder/renderer/json.rb +4 -1
  40. data/lib/textus/builder/renderer/markdown.rb +7 -1
  41. data/lib/textus/builder/renderer/yaml.rb +4 -1
  42. data/lib/textus/cli/verb/build.rb +2 -5
  43. data/lib/textus/cli/verb/get.rb +1 -1
  44. data/lib/textus/cli/verb/hook_run.rb +3 -4
  45. data/lib/textus/cli/verb/hooks.rb +5 -5
  46. data/lib/textus/cli/verb/key_normalize.rb +32 -3
  47. data/lib/textus/cli/verb/put.rb +2 -3
  48. data/lib/textus/cli/verb/refresh_stale.rb +1 -2
  49. data/lib/textus/doctor/check/handler_allowlist.rb +3 -2
  50. data/lib/textus/doctor/check/hooks.rb +2 -2
  51. data/lib/textus/doctor/check/illegal_keys.rb +6 -5
  52. data/lib/textus/doctor/check/intake_registration.rb +2 -2
  53. data/lib/textus/doctor/check/manifest_files.rb +1 -1
  54. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  55. data/lib/textus/doctor/check/templates.rb +4 -3
  56. data/lib/textus/doctor.rb +3 -4
  57. data/lib/textus/domain/authorizer.rb +37 -0
  58. data/lib/textus/domain/staleness/generator_check.rb +8 -7
  59. data/lib/textus/domain/staleness/intake_check.rb +2 -2
  60. data/lib/textus/hooks/builtin.rb +6 -6
  61. data/lib/textus/hooks/bus.rb +155 -0
  62. data/lib/textus/hooks/context.rb +38 -0
  63. data/lib/textus/hooks/fire_report.rb +23 -0
  64. data/lib/textus/hooks/loader.rb +3 -3
  65. data/lib/textus/infra/audit_subscriber.rb +4 -4
  66. data/lib/textus/infra/event_bus.rb +3 -3
  67. data/lib/textus/infra/refresh/detached.rb +1 -1
  68. data/lib/textus/init.rb +3 -2
  69. data/lib/textus/intro.rb +7 -7
  70. data/lib/textus/manifest/entry/base.rb +38 -0
  71. data/lib/textus/manifest/entry/derived.rb +25 -0
  72. data/lib/textus/manifest/entry/intake.rb +19 -0
  73. data/lib/textus/manifest/entry/leaf.rb +16 -0
  74. data/lib/textus/manifest/entry/nested.rb +39 -0
  75. data/lib/textus/manifest/entry/parser.rb +64 -31
  76. data/lib/textus/manifest/entry/validators/events.rb +3 -2
  77. data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -3
  78. data/lib/textus/manifest/entry/validators/index_filename.rb +11 -10
  79. data/lib/textus/manifest/entry/validators/inject_intro.rb +5 -2
  80. data/lib/textus/manifest/entry/validators/publish_each.rb +9 -6
  81. data/lib/textus/manifest/entry.rb +0 -72
  82. data/lib/textus/manifest/resolver.rb +109 -0
  83. data/lib/textus/manifest/schema.rb +1 -1
  84. data/lib/textus/manifest.rb +3 -100
  85. data/lib/textus/operations.rb +131 -74
  86. data/lib/textus/schema/tools.rb +2 -2
  87. data/lib/textus/store.rb +6 -6
  88. data/lib/textus/version.rb +1 -1
  89. metadata +18 -11
  90. data/lib/textus/application/writes/build.rb +0 -78
  91. data/lib/textus/dependencies.rb +0 -23
  92. data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
  93. data/lib/textus/hooks/dispatcher.rb +0 -71
  94. data/lib/textus/hooks/registry.rb +0 -85
  95. data/lib/textus/migrate_keys.rb +0 -187
  96. data/lib/textus/projection.rb +0 -89
  97. 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 = ctx
7
- @envelope_io = envelope_io
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(ctx: @ctx).call(pending_key)
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
- Textus::Application::Writes::Put.new(ctx: @ctx, envelope_io: @envelope_io).call(target, meta: target_meta, body: target_body)
34
+ put_op.call(target, meta: target_meta, body: target_body)
27
35
  when "delete"
28
- Textus::Application::Writes::Delete.new(ctx: @ctx, envelope_io: @envelope_io).call(target)
36
+ delete_op.call(target)
29
37
  else
30
38
  raise ProposalError.new("unknown action: #{action}")
31
39
  end
32
40
 
33
- Textus::Application::Writes::Delete.new(ctx: @ctx, envelope_io: @envelope_io).call(pending_key)
41
+ delete_op.call(pending_key)
34
42
 
35
- @ctx.bus.publish(:proposal_accepted,
36
- store: @ctx.with_role(@ctx.role),
37
- key: pending_key,
38
- target_key: target,
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 = @ctx.manifest.rules_for(target_key)
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::Domain::Policy::Promotion.from_names(promote.requires)
52
- result = policy.evaluate(entry: env, store: @ctx.store)
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 = ctx
7
- @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
8
12
  end
9
13
 
10
14
  def call(key, if_etag: nil, suppress_events: false)
11
- @ctx.manifest.validate_key!(key)
12
- mentry = @ctx.manifest.resolve(key).entry
15
+ @manifest.validate_key!(key)
16
+ mentry = @manifest.resolver.resolve(key).entry
13
17
 
14
- @ctx.authorize_write!(mentry)
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
- @ctx.bus.publish(:entry_deleted,
20
- store: @ctx.with_role(@ctx.role),
21
- key: key,
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