textus 0.15.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 (159) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +50 -55
  3. data/CHANGELOG.md +486 -0
  4. data/README.md +13 -9
  5. data/SPEC.md +13 -10
  6. data/docs/conventions.md +2 -2
  7. data/lib/textus/application/context.rb +20 -34
  8. data/lib/textus/application/policy/predicates/human_accept.rb +30 -0
  9. data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
  10. data/lib/textus/{domain → application}/policy/promotion.rb +20 -3
  11. data/lib/textus/application/projection.rb +91 -0
  12. data/lib/textus/application/reads/audit.rb +4 -4
  13. data/lib/textus/application/reads/blame.rb +11 -8
  14. data/lib/textus/application/reads/deps.rb +14 -3
  15. data/lib/textus/application/reads/freshness.rb +17 -6
  16. data/lib/textus/application/reads/get.rb +37 -11
  17. data/lib/textus/application/reads/get_or_refresh.rb +8 -8
  18. data/lib/textus/application/reads/list.rb +5 -3
  19. data/lib/textus/application/reads/policy_explain.rb +3 -3
  20. data/lib/textus/application/reads/published.rb +5 -3
  21. data/lib/textus/application/reads/rdeps.rb +15 -3
  22. data/lib/textus/application/reads/schema_envelope.rb +6 -3
  23. data/lib/textus/application/reads/stale.rb +3 -3
  24. data/lib/textus/application/reads/uid.rb +11 -3
  25. data/lib/textus/application/reads/validate_all.rb +12 -3
  26. data/lib/textus/application/reads/validator.rb +84 -0
  27. data/lib/textus/application/reads/where.rb +6 -3
  28. data/lib/textus/application/refresh/all.rb +16 -5
  29. data/lib/textus/application/refresh/orchestrator.rb +9 -9
  30. data/lib/textus/application/refresh/worker.rb +59 -32
  31. data/lib/textus/application/tools/migrate_keys.rb +191 -0
  32. data/lib/textus/application/tools/migrate_manifest_to_kinds.rb +31 -0
  33. data/lib/textus/application/writes/accept.rb +36 -13
  34. data/lib/textus/application/writes/delete.rb +13 -15
  35. data/lib/textus/application/writes/envelope_io.rb +166 -0
  36. data/lib/textus/application/writes/materializer.rb +50 -0
  37. data/lib/textus/application/writes/mv.rb +56 -95
  38. data/lib/textus/application/writes/publish.rb +132 -27
  39. data/lib/textus/application/writes/put.rb +17 -20
  40. data/lib/textus/application/writes/reject.rb +18 -9
  41. data/lib/textus/builder/pipeline.rb +21 -15
  42. data/lib/textus/builder/renderer/json.rb +4 -1
  43. data/lib/textus/builder/renderer/markdown.rb +7 -1
  44. data/lib/textus/builder/renderer/yaml.rb +4 -1
  45. data/lib/textus/cli/group/hook.rb +1 -3
  46. data/lib/textus/cli/group/key.rb +1 -4
  47. data/lib/textus/cli/group/refresh.rb +1 -2
  48. data/lib/textus/cli/group/rule.rb +1 -3
  49. data/lib/textus/cli/group/schema.rb +1 -5
  50. data/lib/textus/cli/group.rb +12 -16
  51. data/lib/textus/cli/verb/accept.rb +3 -1
  52. data/lib/textus/cli/verb/audit.rb +3 -1
  53. data/lib/textus/cli/verb/blame.rb +3 -1
  54. data/lib/textus/cli/verb/build.rb +4 -5
  55. data/lib/textus/cli/verb/delete.rb +3 -1
  56. data/lib/textus/cli/verb/deps.rb +3 -1
  57. data/lib/textus/cli/verb/doctor.rb +2 -0
  58. data/lib/textus/cli/verb/freshness.rb +3 -1
  59. data/lib/textus/cli/verb/get.rb +4 -2
  60. data/lib/textus/cli/verb/hook_run.rb +6 -4
  61. data/lib/textus/cli/verb/hooks.rb +8 -5
  62. data/lib/textus/cli/verb/init.rb +2 -0
  63. data/lib/textus/cli/verb/intro.rb +2 -0
  64. data/lib/textus/cli/verb/key_normalize.rb +35 -3
  65. data/lib/textus/cli/verb/list.rb +3 -1
  66. data/lib/textus/cli/verb/mv.rb +4 -1
  67. data/lib/textus/cli/verb/published.rb +3 -1
  68. data/lib/textus/cli/verb/put.rb +5 -4
  69. data/lib/textus/cli/verb/rdeps.rb +3 -1
  70. data/lib/textus/cli/verb/refresh.rb +1 -1
  71. data/lib/textus/cli/verb/refresh_stale.rb +4 -2
  72. data/lib/textus/cli/verb/reject.rb +3 -1
  73. data/lib/textus/cli/verb/rule_explain.rb +4 -1
  74. data/lib/textus/cli/verb/rule_list.rb +3 -0
  75. data/lib/textus/cli/verb/schema.rb +4 -1
  76. data/lib/textus/cli/verb/schema_diff.rb +3 -0
  77. data/lib/textus/cli/verb/schema_init.rb +3 -0
  78. data/lib/textus/cli/verb/schema_migrate.rb +3 -0
  79. data/lib/textus/cli/verb/uid.rb +4 -1
  80. data/lib/textus/cli/verb/where.rb +3 -1
  81. data/lib/textus/cli/verb.rb +30 -0
  82. data/lib/textus/cli.rb +18 -27
  83. data/lib/textus/doctor/check/audit_log.rb +1 -1
  84. data/lib/textus/doctor/check/handler_allowlist.rb +3 -2
  85. data/lib/textus/doctor/check/hooks.rb +4 -2
  86. data/lib/textus/doctor/check/illegal_keys.rb +6 -5
  87. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  88. data/lib/textus/doctor/check/manifest_files.rb +1 -1
  89. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  90. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  91. data/lib/textus/doctor/check/sentinels.rb +2 -2
  92. data/lib/textus/doctor/check/templates.rb +4 -3
  93. data/lib/textus/doctor.rb +3 -4
  94. data/lib/textus/domain/authorizer.rb +37 -0
  95. data/lib/textus/domain/freshness/evaluator.rb +1 -1
  96. data/lib/textus/domain/freshness/policy.rb +1 -1
  97. data/lib/textus/domain/freshness/verdict.rb +1 -1
  98. data/lib/textus/domain/freshness.rb +40 -0
  99. data/lib/textus/{store → domain}/sentinel.rb +1 -1
  100. data/lib/textus/{store → domain}/staleness/generator_check.rb +9 -8
  101. data/lib/textus/{store → domain}/staleness/intake_check.rb +3 -3
  102. data/lib/textus/{store → domain}/staleness.rb +1 -1
  103. data/lib/textus/entry/json.rb +1 -1
  104. data/lib/textus/entry/markdown.rb +1 -1
  105. data/lib/textus/entry/yaml.rb +1 -1
  106. data/lib/textus/envelope.rb +7 -3
  107. data/lib/textus/errors.rb +19 -0
  108. data/lib/textus/hooks/builtin.rb +6 -6
  109. data/lib/textus/hooks/bus.rb +155 -0
  110. data/lib/textus/hooks/context.rb +38 -0
  111. data/lib/textus/hooks/fire_report.rb +23 -0
  112. data/lib/textus/hooks/loader.rb +20 -17
  113. data/lib/textus/{store → infra}/audit_log.rb +1 -1
  114. data/lib/textus/infra/audit_subscriber.rb +43 -0
  115. data/lib/textus/infra/event_bus.rb +3 -3
  116. data/lib/textus/infra/publisher.rb +3 -3
  117. data/lib/textus/infra/refresh/detached.rb +1 -1
  118. data/lib/textus/infra/storage/file_store.rb +26 -0
  119. data/lib/textus/init.rb +14 -11
  120. data/lib/textus/intro.rb +7 -7
  121. data/lib/textus/manifest/entry/base.rb +38 -0
  122. data/lib/textus/manifest/entry/derived.rb +25 -0
  123. data/lib/textus/manifest/entry/intake.rb +19 -0
  124. data/lib/textus/manifest/entry/leaf.rb +16 -0
  125. data/lib/textus/manifest/entry/nested.rb +39 -0
  126. data/lib/textus/manifest/entry/parser.rb +64 -31
  127. data/lib/textus/manifest/entry/validators/events.rb +3 -2
  128. data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -3
  129. data/lib/textus/manifest/entry/validators/index_filename.rb +11 -10
  130. data/lib/textus/manifest/entry/validators/inject_intro.rb +5 -2
  131. data/lib/textus/manifest/entry/validators/publish_each.rb +9 -6
  132. data/lib/textus/manifest/entry.rb +0 -72
  133. data/lib/textus/manifest/resolution.rb +5 -0
  134. data/lib/textus/manifest/resolver.rb +109 -0
  135. data/lib/textus/manifest/schema.rb +1 -1
  136. data/lib/textus/manifest.rb +4 -100
  137. data/lib/textus/operations.rb +147 -23
  138. data/lib/textus/schema/tools.rb +7 -7
  139. data/lib/textus/schemas.rb +46 -0
  140. data/lib/textus/store.rb +12 -49
  141. data/lib/textus/uid.rb +18 -0
  142. data/lib/textus/version.rb +1 -1
  143. data/lib/textus.rb +17 -1
  144. metadata +31 -23
  145. data/lib/textus/application/writes/build.rb +0 -79
  146. data/lib/textus/dependencies.rb +0 -23
  147. data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
  148. data/lib/textus/hooks/dispatcher.rb +0 -63
  149. data/lib/textus/hooks/dsl.rb +0 -11
  150. data/lib/textus/hooks/registry.rb +0 -81
  151. data/lib/textus/migrate_keys.rb +0 -187
  152. data/lib/textus/operations/reads.rb +0 -56
  153. data/lib/textus/operations/refresh.rb +0 -27
  154. data/lib/textus/operations/writes.rb +0 -21
  155. data/lib/textus/projection.rb +0 -89
  156. data/lib/textus/refresh.rb +0 -39
  157. data/lib/textus/store/reader.rb +0 -69
  158. data/lib/textus/store/validator.rb +0 -82
  159. data/lib/textus/store/writer.rb +0 -102
@@ -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:, bus:)
6
- @ctx = ctx
7
- @bus = bus
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 = @ctx.store.reader.get(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, bus: @bus).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, bus: @bus).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, bus: @bus).call(pending_key)
41
+ delete_op.call(pending_key)
34
42
 
35
43
  @bus.publish(:proposal_accepted,
36
- store: @ctx.with_role(@ctx.role),
44
+ ctx: @hook_context,
37
45
  key: pending_key,
38
- target_key: target,
39
- correlation_id: @ctx.correlation_id)
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.store.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,29 +2,27 @@ module Textus
2
2
  module Application
3
3
  module Writes
4
4
  class Delete
5
- def initialize(ctx:, bus:)
6
- @ctx = ctx
7
- @bus = bus
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.store.manifest.validate_key!(key)
12
- mentry, = @ctx.store.manifest.resolve(key)
15
+ @manifest.validate_key!(key)
16
+ mentry = @manifest.resolver.resolve(key).entry
13
17
 
14
- unless @ctx.can_write?(mentry.zone)
15
- raise WriteForbidden.new(key, mentry.zone,
16
- writers: @ctx.store.manifest.zone_writers(mentry.zone))
17
- end
18
+ @authorizer.authorize_write!(mentry, role: @ctx.role)
18
19
 
19
- @ctx.store.writer.delete_envelope_from_disk(
20
- key, ctx: @ctx, if_etag: if_etag
21
- )
20
+ @envelope_io.delete(key, mentry: mentry, if_etag: if_etag)
22
21
 
23
22
  unless suppress_events
24
23
  @bus.publish(:entry_deleted,
25
- store: @ctx.with_role(@ctx.role),
26
- key: key,
27
- correlation_id: @ctx.correlation_id)
24
+ ctx: @hook_context,
25
+ key: key)
28
26
  end
29
27
 
30
28
  { "protocol" => Textus::PROTOCOL, "ok" => true, "key" => key, "deleted" => true }
@@ -0,0 +1,166 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ module Application
5
+ module Writes
6
+ # Owns the write pipeline (validate, serialize, etag-check, write, audit)
7
+ # extracted from Store::Writer. Talks to ports (FileStore, Schemas,
8
+ # AuditLog, Manifest) instead of File/FileUtils and Store directly.
9
+ #
10
+ # No permission check, no event firing — those belong to the caller
11
+ # (Application::Writes::Put / ::Delete / ::Mv).
12
+ class EnvelopeIO
13
+ Payload = Data.define(:meta, :body, :content)
14
+
15
+ def initialize(file_store:, manifest:, schemas:, audit_log:, ctx:)
16
+ @file_store = file_store
17
+ @manifest = manifest
18
+ @schemas = schemas
19
+ @audit_log = audit_log
20
+ @ctx = ctx
21
+ end
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
+
43
+ def write(key, mentry:, payload:, if_etag: nil)
44
+ path = @manifest.resolver.resolve(key).path
45
+
46
+ meta = payload.meta || {}
47
+ strategy = Entry.for_format(mentry.format)
48
+
49
+ existing_uid = existing_uid_for(mentry, path)
50
+ meta, content = ensure_uid(mentry.format, meta, payload.content, existing_uid)
51
+
52
+ bytes, eff_meta, eff_body, eff_content = serialize_for_put(
53
+ mentry: mentry, path: path, strategy: strategy,
54
+ meta: meta, body: payload.body, content: content
55
+ )
56
+
57
+ enforce_name_match!(path, eff_meta, mentry.format)
58
+
59
+ schema = @schemas.fetch_or_nil(mentry.schema)
60
+ if schema
61
+ Entry.for_format(mentry.format).validate_against(
62
+ schema,
63
+ { "_meta" => eff_meta, "content" => eff_content },
64
+ )
65
+ end
66
+
67
+ etag_before = @file_store.exists?(path) ? @file_store.etag(path) : nil
68
+ raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && (etag_before != if_etag)
69
+
70
+ @file_store.write(path, bytes)
71
+ etag_after = Etag.for_bytes(bytes)
72
+ @audit_log.append(
73
+ role: @ctx.role, verb: "put", key: key,
74
+ etag_before: etag_before, etag_after: etag_after,
75
+ extras: @ctx.correlation_id ? { "correlation_id" => @ctx.correlation_id } : nil
76
+ )
77
+ Envelope.build(
78
+ key: key, mentry: mentry, path: path,
79
+ meta: eff_meta, body: eff_body, etag: etag_after, content: eff_content
80
+ )
81
+ end
82
+
83
+ def delete(key, mentry:, if_etag: nil)
84
+ _ = mentry
85
+ path = @manifest.resolver.resolve(key).path
86
+ raise UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)) unless @file_store.exists?(path)
87
+
88
+ etag_before = @file_store.etag(path)
89
+ raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before
90
+
91
+ @file_store.delete(path)
92
+ @audit_log.append(
93
+ role: @ctx.role, verb: "delete", key: key,
94
+ etag_before: etag_before, etag_after: nil,
95
+ extras: @ctx.correlation_id ? { "correlation_id" => @ctx.correlation_id } : nil
96
+ )
97
+ end
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
+
137
+ private
138
+
139
+ def existing_uid_for(mentry, path)
140
+ return nil unless @file_store.exists?(path)
141
+
142
+ raw = @file_store.read(path)
143
+ parsed = Entry.for_format(mentry.format).parse(raw, path: path)
144
+ Envelope.extract_uid(parsed["_meta"])
145
+ rescue StandardError
146
+ nil
147
+ end
148
+
149
+ def ensure_uid(format, meta, content, existing_uid)
150
+ Textus::Entry.for_format(format).inject_uid(meta, content, existing_uid)
151
+ end
152
+
153
+ def enforce_name_match!(path, meta, format)
154
+ Textus::Entry.for_format(format).enforce_name_match!(path, meta)
155
+ end
156
+
157
+ def serialize_for_put(mentry:, path:, strategy:, meta:, body:, content:)
158
+ _ = strategy
159
+ Textus::Entry.for_format(mentry.format).serialize_for_put(
160
+ meta: meta, body: body, content: content, path: path,
161
+ )
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -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,55 +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:, bus:)
13
- @ctx = ctx
14
- @bus = bus
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.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)
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_mentry, old_path, = manifest.resolve(old_key)
38
- raise UnknownKey.new(old_key) unless File.exist?(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)
39
37
 
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)
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)
44
42
 
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]
43
+ [old_res, new_res]
53
44
  end
54
45
 
55
46
  def validate_zone_and_format!(old_mentry, new_mentry)
@@ -64,80 +55,50 @@ module Textus
64
55
  raise UsageError.new("mv: format mismatch (#{old_mentry.format} → #{new_mentry.format}); refusing.")
65
56
  end
66
57
 
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,
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
+ )
83
71
  )
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
72
  end
93
73
 
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)
74
+ def publish_renamed(old_key, new_key, envelope)
108
75
  @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
76
+ ctx: @hook_context,
77
+ key: new_key,
78
+ from_key: old_key,
79
+ to_key: new_key,
80
+ envelope: envelope)
116
81
  end
117
82
 
118
- 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)
119
85
  {
120
86
  "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
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
124
90
  }
125
91
  end
126
92
 
127
- def success_result(plan, new_envelope:)
93
+ def success_result(old_key, new_key, old_res, new_res, envelope)
128
94
  {
129
95
  "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
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
134
100
  }
135
101
  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
102
  end
142
103
  end
143
104
  end