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.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +43 -48
  3. data/CHANGELOG.md +238 -0
  4. data/SPEC.md +35 -2
  5. data/lib/textus/application/context.rb +20 -58
  6. data/lib/textus/application/policy/predicates/accept_authority_signed.rb +33 -0
  7. data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
  8. data/lib/textus/{domain → application}/policy/promotion.rb +18 -6
  9. data/lib/textus/application/projection.rb +91 -0
  10. data/lib/textus/application/reads/audit.rb +4 -4
  11. data/lib/textus/application/reads/blame.rb +9 -8
  12. data/lib/textus/application/reads/deps.rb +14 -3
  13. data/lib/textus/application/reads/freshness.rb +10 -8
  14. data/lib/textus/application/reads/get.rb +10 -8
  15. data/lib/textus/application/reads/get_or_refresh.rb +3 -3
  16. data/lib/textus/application/reads/list.rb +3 -3
  17. data/lib/textus/application/reads/policy_explain.rb +3 -3
  18. data/lib/textus/application/reads/published.rb +5 -3
  19. data/lib/textus/application/reads/rdeps.rb +15 -3
  20. data/lib/textus/application/reads/schema_envelope.rb +5 -4
  21. data/lib/textus/application/reads/stale.rb +3 -3
  22. data/lib/textus/application/reads/uid.rb +11 -3
  23. data/lib/textus/application/reads/validate_all.rb +10 -6
  24. data/lib/textus/application/reads/validator.rb +5 -3
  25. data/lib/textus/application/reads/where.rb +3 -3
  26. data/lib/textus/application/refresh/all.rb +15 -11
  27. data/lib/textus/application/refresh/orchestrator.rb +9 -8
  28. data/lib/textus/application/refresh/worker.rb +56 -32
  29. data/lib/textus/application/writes/accept.rb +43 -16
  30. data/lib/textus/application/writes/authority_gate.rb +26 -0
  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 +25 -12
  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 +4 -6
  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/put.rb +2 -3
  47. data/lib/textus/cli/verb/refresh_stale.rb +1 -2
  48. data/lib/textus/doctor/check/handler_allowlist.rb +3 -2
  49. data/lib/textus/doctor/check/hooks.rb +2 -2
  50. data/lib/textus/doctor/check/illegal_keys.rb +7 -7
  51. data/lib/textus/doctor/check/intake_registration.rb +2 -2
  52. data/lib/textus/doctor/check/manifest_files.rb +1 -1
  53. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  54. data/lib/textus/doctor/check/templates.rb +4 -3
  55. data/lib/textus/doctor.rb +3 -4
  56. data/lib/textus/domain/authorizer.rb +37 -0
  57. data/lib/textus/domain/policy/promote.rb +4 -2
  58. data/lib/textus/domain/policy/refresh.rb +2 -0
  59. data/lib/textus/domain/staleness/generator_check.rb +8 -7
  60. data/lib/textus/domain/staleness/intake_check.rb +2 -2
  61. data/lib/textus/hooks/builtin.rb +6 -6
  62. data/lib/textus/hooks/bus.rb +155 -0
  63. data/lib/textus/hooks/context.rb +38 -0
  64. data/lib/textus/hooks/fire_report.rb +23 -0
  65. data/lib/textus/hooks/loader.rb +3 -3
  66. data/lib/textus/infra/audit_subscriber.rb +4 -4
  67. data/lib/textus/infra/event_bus.rb +3 -3
  68. data/lib/textus/infra/refresh/detached.rb +1 -1
  69. data/lib/textus/init.rb +3 -2
  70. data/lib/textus/intro.rb +51 -27
  71. data/lib/textus/manifest/entry/base.rb +38 -0
  72. data/lib/textus/manifest/entry/derived.rb +25 -0
  73. data/lib/textus/manifest/entry/intake.rb +19 -0
  74. data/lib/textus/manifest/entry/leaf.rb +16 -0
  75. data/lib/textus/manifest/entry/nested.rb +39 -0
  76. data/lib/textus/manifest/entry/parser.rb +58 -31
  77. data/lib/textus/manifest/entry/validators/events.rb +3 -2
  78. data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -3
  79. data/lib/textus/manifest/entry/validators/index_filename.rb +11 -10
  80. data/lib/textus/manifest/entry/validators/inject_intro.rb +5 -2
  81. data/lib/textus/manifest/entry/validators/publish_each.rb +9 -6
  82. data/lib/textus/manifest/entry.rb +0 -72
  83. data/lib/textus/manifest/resolver.rb +112 -0
  84. data/lib/textus/manifest/role_kinds.rb +21 -0
  85. data/lib/textus/manifest/schema.rb +46 -2
  86. data/lib/textus/manifest.rb +24 -101
  87. data/lib/textus/operations.rb +131 -74
  88. data/lib/textus/schema/tools.rb +10 -3
  89. data/lib/textus/store.rb +6 -6
  90. data/lib/textus/version.rb +1 -1
  91. metadata +18 -14
  92. data/lib/textus/application/writes/build.rb +0 -78
  93. data/lib/textus/cli/verb/key_normalize.rb +0 -19
  94. data/lib/textus/dependencies.rb +0 -23
  95. data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
  96. data/lib/textus/domain/policy.rb +0 -7
  97. data/lib/textus/hooks/dispatcher.rb +0 -71
  98. data/lib/textus/hooks/registry.rb +0 -85
  99. data/lib/textus/manifest/resolution.rb +0 -5
  100. data/lib/textus/migrate_keys.rb +0 -187
  101. data/lib/textus/projection.rb +0 -89
  102. 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 = ctx
11
- @envelope_io = envelope_io
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 = @ctx.store.manifest.resolve(key)
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.intake_handler
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 = @ctx.store.manifest.rules_for(key)
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 = @ctx.store.registry.rpc_callable(:resolve_intake, mentry.intake_handler)
39
- @ctx.bus.publish(:refresh_started, store: read_view, key: key, mode: :sync,
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: @ctx,
49
- config: mentry.intake_config,
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
- @ctx.bus.publish(:refresh_failed, store: read_view, key: key, error_class: "Timeout::Error",
55
- error_message: "intake '#{mentry.intake_handler}' exceeded #{timeout}s",
56
- correlation_id: @ctx.correlation_id)
57
- raise UsageError.new("intake '#{mentry.intake_handler}' exceeded #{timeout}s timeout")
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
- @ctx.bus.publish(:refresh_failed, store: read_view, key: key, error_class: e.class.name,
60
- error_message: e.message, correlation_id: @ctx.correlation_id)
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
- @ctx.bus.publish(:refresh_failed, store: read_view, key: key, error_class: e.class.name,
64
- error_message: e.message, correlation_id: @ctx.correlation_id)
65
- raise UsageError.new("intake '#{mentry.intake_handler}' raised: #{e.class}: #{e.message}")
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 = Textus::Refresh.normalize_action_result(result, format: mentry.format)
70
- envelope = Textus::Application::Writes::Put.new(ctx: @ctx, envelope_io: @envelope_io).call(
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
- meta: normalized[:meta], body: normalized[:body], content: normalized[:content],
73
- suppress_events: true
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
- def initialize(ctx:, envelope_io:)
6
- @ctx = ctx
7
- @envelope_io = envelope_io
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
- raise ProposalError.new("only human role can accept proposals; got '#{@ctx.role}'") unless @ctx.role == "human"
21
+ assert_accept_authority!("accept")
12
22
 
13
- env = Textus::Application::Reads::Get.new(ctx: @ctx).call(pending_key)
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
- Textus::Application::Writes::Put.new(ctx: @ctx, envelope_io: @envelope_io).call(target, meta: target_meta, body: target_body)
38
+ put_op.call(target, meta: target_meta, body: target_body)
27
39
  when "delete"
28
- Textus::Application::Writes::Delete.new(ctx: @ctx, envelope_io: @envelope_io).call(target)
40
+ delete_op.call(target)
29
41
  else
30
42
  raise ProposalError.new("unknown action: #{action}")
31
43
  end
32
44
 
33
- Textus::Application::Writes::Delete.new(ctx: @ctx, envelope_io: @envelope_io).call(pending_key)
45
+ delete_op.call(pending_key)
34
46
 
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)
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 = @ctx.manifest.rules_for(target_key)
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::Domain::Policy::Promotion.from_names(promote.requires)
52
- result = policy.evaluate(entry: env, store: @ctx.store)
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 = 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
@@ -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
- MovePlan = Data.define(
8
- :old_key, :new_key, :old_path, :new_path,
9
- :new_mentry, :uid, :etag_before
10
- )
11
-
12
- def initialize(ctx:, envelope_io:)
13
- @ctx = ctx
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
- plan, pre_env = prepare_plan(old_key, new_key)
19
- return dry_run_result(plan) if dry_run
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
- 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)
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 manifest = @ctx.manifest
30
- def reader_get(key) = (@reader_get ||= Textus::Application::Reads::Get.new(ctx: @ctx)).call(key)
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
- old_mentry = old_res.entry
39
- old_path = old_res.path
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 = manifest.resolve(new_key)
43
- new_mentry = new_res.entry
44
- new_path = new_res.path
45
- validate_zone_and_format!(old_mentry, new_mentry)
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
- pre_env = reader_get(old_key)
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
- def ensure_uid!(plan, pre_env:)
73
- return plan if plan.uid
74
-
75
- env = Textus::Application::Writes::Put.new(ctx: @ctx, envelope_io: @envelope_io).call(
76
- plan.old_key,
77
- meta: pre_env.meta,
78
- body: pre_env.body,
79
- content: pre_env.content,
80
- suppress_events: true,
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 perform_move!(plan)
86
- FileUtils.mkdir_p(File.dirname(plan.new_path))
87
- FileUtils.mv(plan.old_path, plan.new_path)
88
- rewrite_name_for_mv!(plan.new_mentry, plan.new_path, plan.new_key)
89
- Etag.for_file(plan.new_path)
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 record_move(plan, etag_after:)
93
- extras = {
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" => plan.old_key, "to_key" => plan.new_key,
120
- "from_path" => plan.old_path, "to_path" => plan.new_path,
121
- "uid" => plan.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(plan, new_envelope:)
93
+ def success_result(old_key, new_key, old_res, new_res, envelope)
126
94
  {
127
95
  "protocol" => PROTOCOL, "ok" => true,
128
- "from_key" => plan.old_key, "to_key" => plan.new_key,
129
- "from_path" => plan.old_path, "to_path" => plan.new_path,
130
- "uid" => plan.uid,
131
- "envelope" => new_envelope.to_h_for_wire
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