textus 0.12.1 → 0.14.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +60 -40
  3. data/CHANGELOG.md +214 -0
  4. data/README.md +6 -12
  5. data/SPEC.md +4 -1
  6. data/docs/conventions.md +8 -8
  7. data/lib/textus/application/context.rb +4 -0
  8. data/lib/textus/application/reads/blame.rb +1 -1
  9. data/lib/textus/application/reads/deps.rb +15 -0
  10. data/lib/textus/application/reads/freshness.rb +2 -2
  11. data/lib/textus/application/reads/get.rb +8 -11
  12. data/lib/textus/application/reads/list.rb +15 -0
  13. data/lib/textus/application/reads/published.rb +15 -0
  14. data/lib/textus/application/reads/rdeps.rb +15 -0
  15. data/lib/textus/application/reads/schema_envelope.rb +15 -0
  16. data/lib/textus/application/reads/stale.rb +15 -0
  17. data/lib/textus/application/reads/uid.rb +15 -0
  18. data/lib/textus/application/reads/validate_all.rb +15 -0
  19. data/lib/textus/application/reads/where.rb +15 -0
  20. data/lib/textus/application/refresh/all.rb +2 -2
  21. data/lib/textus/application/refresh/worker.rb +3 -3
  22. data/lib/textus/application/writes/accept.rb +7 -7
  23. data/lib/textus/application/writes/build.rb +10 -47
  24. data/lib/textus/application/writes/mv.rb +144 -0
  25. data/lib/textus/application/writes/publish.rb +41 -9
  26. data/lib/textus/application/writes/reject.rb +37 -0
  27. data/lib/textus/cli/verb/accept.rb +1 -2
  28. data/lib/textus/cli/verb/audit.rb +3 -3
  29. data/lib/textus/cli/verb/blame.rb +1 -2
  30. data/lib/textus/cli/verb/build.rb +6 -2
  31. data/lib/textus/cli/verb/delete.rb +1 -2
  32. data/lib/textus/cli/verb/deps.rb +1 -1
  33. data/lib/textus/cli/verb/freshness.rb +1 -2
  34. data/lib/textus/cli/verb/get.rb +2 -3
  35. data/lib/textus/cli/verb/list.rb +1 -1
  36. data/lib/textus/cli/verb/mv.rb +1 -1
  37. data/lib/textus/cli/verb/published.rb +1 -1
  38. data/lib/textus/cli/verb/put.rb +2 -2
  39. data/lib/textus/cli/verb/rdeps.rb +1 -1
  40. data/lib/textus/cli/verb/refresh.rb +1 -2
  41. data/lib/textus/cli/verb/reject.rb +1 -1
  42. data/lib/textus/cli/verb/rule_explain.rb +1 -2
  43. data/lib/textus/cli/verb/schema.rb +1 -1
  44. data/lib/textus/cli/verb/uid.rb +1 -1
  45. data/lib/textus/cli/verb/where.rb +1 -1
  46. data/lib/textus/cli/verb.rb +6 -1
  47. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  48. data/lib/textus/doctor.rb +1 -1
  49. data/lib/textus/domain/freshness/evaluator.rb +1 -1
  50. data/lib/textus/domain/policy/predicates/schema_valid.rb +3 -3
  51. data/lib/textus/entry/base.rb +28 -0
  52. data/lib/textus/entry/json.rb +59 -0
  53. data/lib/textus/entry/markdown.rb +46 -0
  54. data/lib/textus/entry/text.rb +35 -0
  55. data/lib/textus/entry/yaml.rb +59 -0
  56. data/lib/textus/entry.rb +16 -0
  57. data/lib/textus/envelope.rb +44 -14
  58. data/lib/textus/intro.rb +56 -0
  59. data/lib/textus/manifest/entry/parser.rb +84 -0
  60. data/lib/textus/manifest/entry/validators/events.rb +21 -0
  61. data/lib/textus/manifest/entry/validators/format_matrix.rb +26 -0
  62. data/lib/textus/manifest/entry/validators/index_filename.rb +45 -0
  63. data/lib/textus/manifest/entry/validators/inject_intro.rb +18 -0
  64. data/lib/textus/manifest/entry/validators/publish_each.rb +37 -0
  65. data/lib/textus/manifest/entry/validators.rb +20 -0
  66. data/lib/textus/manifest/entry.rb +35 -213
  67. data/lib/textus/manifest.rb +6 -16
  68. data/lib/textus/operations/reads.rb +39 -0
  69. data/lib/textus/operations/refresh.rb +27 -0
  70. data/lib/textus/operations/writes.rb +21 -0
  71. data/lib/textus/operations.rb +44 -0
  72. data/lib/textus/projection.rb +5 -4
  73. data/lib/textus/refresh.rb +3 -4
  74. data/lib/textus/schema/tools.rb +8 -7
  75. data/lib/textus/store/reader.rb +1 -1
  76. data/lib/textus/store/validator.rb +3 -3
  77. data/lib/textus/store/writer.rb +5 -74
  78. data/lib/textus/store.rb +1 -55
  79. data/lib/textus/version.rb +1 -1
  80. metadata +23 -4
  81. data/lib/textus/composition.rb +0 -72
  82. data/lib/textus/proposal.rb +0 -10
  83. data/lib/textus/store/mover.rb +0 -167
@@ -54,7 +54,7 @@ module Textus
54
54
  last_writer = @audit_log.last_writer_for(key)
55
55
  return if last_writer.nil?
56
56
 
57
- env["_meta"].each_key do |field|
57
+ env.meta.each_key do |field|
58
58
  owner = schema.maintained_by(field)
59
59
  next if owner.nil? || last_writer == owner || last_writer == "human"
60
60
 
@@ -72,8 +72,8 @@ module Textus
72
72
 
73
73
  def validate_schema!(schema, envelope, format)
74
74
  payload = case format
75
- when "json", "yaml" then envelope["content"] || {}
76
- else envelope["_meta"] || {}
75
+ when "json", "yaml" then envelope.content || {}
76
+ else envelope.meta || {}
77
77
  end
78
78
  schema.validate!(payload)
79
79
  end
@@ -11,16 +11,6 @@ module Textus
11
11
  @reader = store.reader
12
12
  end
13
13
 
14
- # Backward-compat shim — orchestration now lives in Application::Writes::Put.
15
- # rubocop:disable Metrics/ParameterLists
16
- def put(key, meta: nil, body: nil, content: nil, if_etag: nil, as: Role::DEFAULT, suppress_events: false)
17
- ctx = Textus::Application::Context.new(store: @store, role: as)
18
- Textus::Application::Writes::Put.new(ctx: ctx, bus: @store.bus).call(
19
- key, meta: meta, body: body, content: content, if_etag: if_etag, suppress_events: suppress_events
20
- )
21
- end
22
- # rubocop:enable Metrics/ParameterLists
23
-
24
14
  # Pure I/O: validate, serialize, etag-check, write to disk, audit. No
25
15
  # permission check and no event firing — those are handled by the caller
26
16
  # (Application::Writes::Put).
@@ -76,56 +66,17 @@ module Textus
76
66
  end
77
67
 
78
68
  def ensure_uid(format, meta, content, existing_uid)
79
- case format
80
- when "markdown", "json", "yaml"
81
- m = meta.is_a?(Hash) ? meta.dup : {}
82
- m["uid"] = existing_uid || Store.mint_uid unless m["uid"].is_a?(String) && !m["uid"].empty?
83
- [m, content]
84
- else
85
- [meta, content]
86
- end
69
+ Textus::Entry.for_format(format).inject_uid(meta, content, existing_uid)
87
70
  end
88
71
 
89
72
  def enforce_name_match!(path, meta, format)
90
- return unless %w[markdown json yaml].include?(format)
91
- return unless meta.is_a?(Hash) && meta["name"]
92
-
93
- ext = Entry.for_format(format).extensions.first
94
- basename = File.basename(path, ext)
95
- return if meta["name"] == basename
96
-
97
- raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
73
+ Textus::Entry.for_format(format).enforce_name_match!(path, meta)
98
74
  end
99
75
 
100
76
  def serialize_for_put(mentry:, path:, strategy:, meta:, body:, content:)
101
- case mentry.format
102
- when "markdown", "text"
103
- bytes = strategy.serialize(meta: meta, body: body.to_s)
104
- [bytes, meta, body.to_s, nil]
105
- when "json", "yaml"
106
- raise UsageError.new("put for #{mentry.format} requires content: or body:") if content.nil? && (body.nil? || body.to_s.empty?)
107
-
108
- if content.nil?
109
- begin
110
- parsed = strategy.parse(body.to_s, path: path)
111
- rescue BadFrontmatter => e
112
- raise BadContent.new(path, "bad_content: #{e.message}")
113
- end
114
- [body.to_s, parsed["_meta"], body.to_s, parsed["content"]]
115
- else
116
- bytes = strategy.serialize(meta: meta, body: "", content: content)
117
- [bytes, meta, bytes, content]
118
- end
119
- else
120
- raise UsageError.new("unknown format #{mentry.format.inspect}")
121
- end
122
- end
123
-
124
- # Backward-compat shim — orchestration now lives in Application::Writes::Delete.
125
- def delete(key, if_etag: nil, as: Role::DEFAULT, suppress_events: false)
126
- ctx = Textus::Application::Context.new(store: @store, role: as)
127
- Textus::Application::Writes::Delete.new(ctx: ctx, bus: @store.bus).call(
128
- key, if_etag: if_etag, suppress_events: suppress_events
77
+ _ = strategy
78
+ Textus::Entry.for_format(mentry.format).serialize_for_put(
79
+ meta: meta, body: body, content: content, path: path,
129
80
  )
130
81
  end
131
82
 
@@ -146,26 +97,6 @@ module Textus
146
97
  extras: ctx.correlation_id ? { "correlation_id" => ctx.correlation_id } : nil
147
98
  )
148
99
  end
149
-
150
- def accept(key, as:)
151
- Proposal.accept(@store, key, as: as)
152
- end
153
-
154
- def reject(pending_key, as: Role::DEFAULT)
155
- raise ProposalError.new("only human role can reject proposals; got '#{as}'") unless as == "human"
156
-
157
- mentry, = @store.manifest.resolve(pending_key)
158
- raise ProposalError.new("reject: '#{pending_key}' is not in a proposal zone (zone=#{mentry.zone})") unless mentry.in_proposal_zone?
159
-
160
- env = @store.get(pending_key)
161
- proposal = env.dig("_meta", "proposal") or
162
- raise ProposalError.new("entry has no proposal block: #{pending_key}")
163
- target_key = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
164
-
165
- delete(pending_key, as: as)
166
- @store.fire_event(:proposal_rejected, key: pending_key, target_key: target_key)
167
- { "protocol" => PROTOCOL, "rejected" => pending_key, "target_key" => target_key }
168
- end
169
100
  end
170
101
  end
171
102
  end
data/lib/textus/store.rb CHANGED
@@ -45,7 +45,7 @@ module Textus
45
45
  load_hooks
46
46
  @reader = Reader.new(self)
47
47
  @writer = Writer.new(self)
48
- fire_event(:store_loaded)
48
+ @bus.publish(:store_loaded, store: Textus::Application::Context.system(self))
49
49
  end
50
50
 
51
51
  def load_hooks
@@ -75,60 +75,6 @@ module Textus
75
75
  end
76
76
  end
77
77
 
78
- def get(key, as: Textus::Role::DEFAULT)
79
- ctx = Textus::Composition.context(self, role: as)
80
- result = Textus::Composition.reads_get(ctx).call(key)
81
- raise UnknownKey.new(key, suggestions: manifest.suggestions_for(key)) if result.nil?
82
-
83
- result
84
- end
85
-
86
- def where(key) = @reader.where(key)
87
- def list(**) = @reader.list(**)
88
- def schema_envelope(key) = @reader.schema_envelope(key)
89
-
90
- # rubocop:disable Metrics/ParameterLists
91
- def put(key, meta: nil, body: nil, content: nil, if_etag: nil, as: Role::DEFAULT, suppress_events: false)
92
- ctx = Textus::Composition.context(self, role: as)
93
- Textus::Composition.writes_put(ctx).call(
94
- key, meta: meta, body: body, content: content, if_etag: if_etag, suppress_events: suppress_events
95
- )
96
- end
97
- # rubocop:enable Metrics/ParameterLists
98
-
99
- def delete(key, if_etag: nil, as: Role::DEFAULT, suppress_events: false)
100
- ctx = Textus::Composition.context(self, role: as)
101
- Textus::Composition.writes_delete(ctx).call(key, if_etag: if_etag, suppress_events: suppress_events)
102
- end
103
-
104
- def fire_event(event, **)
105
- view = Textus::Application::Context.new(store: self, role: "human")
106
- @bus.publish(event, store: view, **)
107
- end
108
-
109
- def accept(key, as: Role::DEFAULT)
110
- ctx = Textus::Composition.context(self, role: as)
111
- Textus::Composition.writes_accept(ctx).call(key)
112
- end
113
-
114
- def reject(...) = @writer.reject(...)
115
-
116
- def deps(key) = @reader.deps(key)
117
- def rdeps(key) = @reader.rdeps(key)
118
- def published = @reader.published
119
- def stale(**) = @reader.stale(**)
120
- def validate_all = @reader.validate_all
121
-
122
- def uid(key) = @reader.uid(key)
123
-
124
- # Move an entry from old_key to new_key within the same zone. Preserves
125
- # uid (minting one first if absent), validates both keys against the
126
- # manifest, refuses to clobber, and writes one mv audit row.
127
- def mv(old_key, new_key, as: Role::DEFAULT, dry_run: false, correlation_id: nil)
128
- Mover.new(store: self, reader: @reader, writer: @writer, manifest: @manifest, audit_log: audit_log)
129
- .call(old_key, new_key, as: as, dry_run: dry_run, correlation_id: correlation_id)
130
- end
131
-
132
78
  def audit_log
133
79
  @audit_log ||= Store::AuditLog.new(@root)
134
80
  end
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.12.1"
2
+ VERSION = "0.14.0"
3
3
  PROTOCOL = "textus/3"
4
4
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: textus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.1
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -112,17 +112,28 @@ files:
112
112
  - lib/textus/application/context.rb
113
113
  - lib/textus/application/reads/audit.rb
114
114
  - lib/textus/application/reads/blame.rb
115
+ - lib/textus/application/reads/deps.rb
115
116
  - lib/textus/application/reads/freshness.rb
116
117
  - lib/textus/application/reads/get.rb
118
+ - lib/textus/application/reads/list.rb
117
119
  - lib/textus/application/reads/policy_explain.rb
120
+ - lib/textus/application/reads/published.rb
121
+ - lib/textus/application/reads/rdeps.rb
122
+ - lib/textus/application/reads/schema_envelope.rb
123
+ - lib/textus/application/reads/stale.rb
124
+ - lib/textus/application/reads/uid.rb
125
+ - lib/textus/application/reads/validate_all.rb
126
+ - lib/textus/application/reads/where.rb
118
127
  - lib/textus/application/refresh/all.rb
119
128
  - lib/textus/application/refresh/orchestrator.rb
120
129
  - lib/textus/application/refresh/worker.rb
121
130
  - lib/textus/application/writes/accept.rb
122
131
  - lib/textus/application/writes/build.rb
123
132
  - lib/textus/application/writes/delete.rb
133
+ - lib/textus/application/writes/mv.rb
124
134
  - lib/textus/application/writes/publish.rb
125
135
  - lib/textus/application/writes/put.rb
136
+ - lib/textus/application/writes/reject.rb
126
137
  - lib/textus/builder/pipeline.rb
127
138
  - lib/textus/builder/renderer.rb
128
139
  - lib/textus/builder/renderer/json.rb
@@ -167,7 +178,6 @@ files:
167
178
  - lib/textus/cli/verb/schema_migrate.rb
168
179
  - lib/textus/cli/verb/uid.rb
169
180
  - lib/textus/cli/verb/where.rb
170
- - lib/textus/composition.rb
171
181
  - lib/textus/dependencies.rb
172
182
  - lib/textus/doctor.rb
173
183
  - lib/textus/doctor/check.rb
@@ -225,19 +235,28 @@ files:
225
235
  - lib/textus/key/path.rb
226
236
  - lib/textus/manifest.rb
227
237
  - lib/textus/manifest/entry.rb
238
+ - lib/textus/manifest/entry/parser.rb
239
+ - lib/textus/manifest/entry/validators.rb
240
+ - lib/textus/manifest/entry/validators/events.rb
241
+ - lib/textus/manifest/entry/validators/format_matrix.rb
242
+ - lib/textus/manifest/entry/validators/index_filename.rb
243
+ - lib/textus/manifest/entry/validators/inject_intro.rb
244
+ - lib/textus/manifest/entry/validators/publish_each.rb
228
245
  - lib/textus/manifest/rules.rb
229
246
  - lib/textus/manifest/schema.rb
230
247
  - lib/textus/migrate_keys.rb
231
248
  - lib/textus/mustache.rb
249
+ - lib/textus/operations.rb
250
+ - lib/textus/operations/reads.rb
251
+ - lib/textus/operations/refresh.rb
252
+ - lib/textus/operations/writes.rb
232
253
  - lib/textus/projection.rb
233
- - lib/textus/proposal.rb
234
254
  - lib/textus/refresh.rb
235
255
  - lib/textus/role.rb
236
256
  - lib/textus/schema.rb
237
257
  - lib/textus/schema/tools.rb
238
258
  - lib/textus/store.rb
239
259
  - lib/textus/store/audit_log.rb
240
- - lib/textus/store/mover.rb
241
260
  - lib/textus/store/reader.rb
242
261
  - lib/textus/store/sentinel.rb
243
262
  - lib/textus/store/staleness.rb
@@ -1,72 +0,0 @@
1
- module Textus
2
- module Composition
3
- module_function
4
-
5
- def context(store, role:, correlation_id: nil, dry_run: false)
6
- Textus::Application::Context.new(
7
- store: store,
8
- role: role,
9
- correlation_id: correlation_id,
10
- dry_run: dry_run,
11
- )
12
- end
13
-
14
- def reads_get(ctx)
15
- Textus::Application::Reads::Get.new(ctx: ctx, orchestrator: refresh_orchestrator(ctx))
16
- end
17
-
18
- def freshness(ctx)
19
- Textus::Application::Reads::Freshness.new(ctx: ctx)
20
- end
21
-
22
- def audit(ctx)
23
- Textus::Application::Reads::Audit.new(ctx: ctx)
24
- end
25
-
26
- def blame(ctx)
27
- Textus::Application::Reads::Blame.new(ctx: ctx)
28
- end
29
-
30
- def policy_explain(ctx)
31
- Textus::Application::Reads::PolicyExplain.new(ctx: ctx)
32
- end
33
-
34
- def refresh_worker(ctx)
35
- Textus::Application::Refresh::Worker.new(ctx: ctx, bus: ctx.store.bus)
36
- end
37
-
38
- def refresh_orchestrator(ctx)
39
- Textus::Application::Refresh::Orchestrator.new(
40
- worker: refresh_worker(ctx),
41
- bus: ctx.store.bus,
42
- store_root: ctx.store.root,
43
- store: ctx.store,
44
- role: ctx.role,
45
- )
46
- end
47
-
48
- def writes_put(ctx)
49
- Textus::Application::Writes::Put.new(ctx: ctx, bus: ctx.store.bus)
50
- end
51
-
52
- def writes_delete(ctx)
53
- Textus::Application::Writes::Delete.new(ctx: ctx, bus: ctx.store.bus)
54
- end
55
-
56
- def writes_build(ctx)
57
- Textus::Application::Writes::Build.new(ctx: ctx, bus: ctx.store.bus)
58
- end
59
-
60
- def writes_accept(ctx)
61
- Textus::Application::Writes::Accept.new(ctx: ctx, bus: ctx.store.bus)
62
- end
63
-
64
- def writes_publish(ctx)
65
- Textus::Application::Writes::Publish.new(ctx: ctx, bus: ctx.store.bus)
66
- end
67
-
68
- def event_bus(ctx)
69
- Textus::Infra::EventBus.new(registry: ctx.store.registry)
70
- end
71
- end
72
- end
@@ -1,10 +0,0 @@
1
- module Textus
2
- module Proposal
3
- # Deprecated as of 0.9.1: use Textus::Application::Writes::Accept (via
4
- # Textus::Composition.writes_accept).
5
- def self.accept(store, pending_key, as:)
6
- ctx = Textus::Composition.context(store, role: as)
7
- Textus::Application::Writes::Accept.new(ctx: ctx, bus: store.bus).call(pending_key)
8
- end
9
- end
10
- end
@@ -1,167 +0,0 @@
1
- require "fileutils"
2
-
3
- module Textus
4
- class Store
5
- class Mover
6
- MovePlan = Data.define(
7
- :old_key, :new_key, :old_path, :new_path,
8
- :new_mentry, :uid, :etag_before, :as
9
- )
10
-
11
- def initialize(store:, reader:, writer:, manifest:, audit_log:)
12
- @store = store
13
- @reader = reader
14
- @writer = writer
15
- @manifest = manifest
16
- @audit_log = audit_log
17
- end
18
-
19
- def call(old_key, new_key, as: Role::DEFAULT, dry_run: false, correlation_id: nil)
20
- plan, pre_env = prepare_plan(old_key, new_key, as: as)
21
- return dry_run_result(plan) if dry_run
22
-
23
- plan = ensure_uid!(plan, pre_env: pre_env)
24
- etag_after = perform_move!(plan)
25
- new_envelope = record_move(plan, etag_after: etag_after, correlation_id: correlation_id)
26
- success_result(plan, new_envelope: new_envelope)
27
- end
28
-
29
- private
30
-
31
- # Validates inputs, resolves manifest entries, and reads the source
32
- # envelope. Returns [MovePlan, pre_envelope]; the pre_envelope is only
33
- # needed by ensure_uid! and is threaded separately to keep MovePlan
34
- # focused on the planned operation.
35
- def prepare_plan(old_key, new_key, as:)
36
- @manifest.validate_key!(old_key)
37
- @manifest.validate_key!(new_key)
38
- raise UsageError.new("mv: old and new keys are identical") if old_key == new_key
39
-
40
- old_mentry, old_path, = @manifest.resolve(old_key)
41
- raise UnknownKey.new(old_key) unless File.exist?(old_path)
42
-
43
- new_mentry, new_path, = @manifest.resolve(new_key)
44
- validate_zone_and_format!(old_mentry, new_mentry)
45
- validate_writer!(old_mentry, old_key, as)
46
- raise UsageError.new("mv: target '#{new_key}' already exists at #{new_path}") if File.exist?(new_path)
47
-
48
- pre_env = @reader.get(old_key)
49
- plan = MovePlan.new(
50
- old_key: old_key, new_key: new_key,
51
- old_path: old_path, new_path: new_path,
52
- new_mentry: new_mentry,
53
- uid: pre_env["uid"], etag_before: pre_env["etag"], as: as
54
- )
55
- [plan, pre_env]
56
- end
57
-
58
- def validate_zone_and_format!(old_mentry, new_mentry)
59
- if old_mentry.zone != new_mentry.zone
60
- raise UsageError.new(
61
- "mv: cross-zone move refused (#{old_mentry.zone} → #{new_mentry.zone}). " \
62
- "Use put+delete for cross-zone moves.",
63
- )
64
- end
65
- return if old_mentry.format == new_mentry.format
66
-
67
- raise UsageError.new(
68
- "mv: format mismatch (#{old_mentry.format} → #{new_mentry.format}); refusing.",
69
- )
70
- end
71
-
72
- def validate_writer!(mentry, key, as)
73
- writers = @manifest.zone_writers(mentry.zone)
74
- return if writers.include?(as)
75
-
76
- raise WriteForbidden.new(key, mentry.zone, writers: writers)
77
- end
78
-
79
- def ensure_uid!(plan, pre_env:)
80
- return plan if plan.uid
81
-
82
- env = @writer.put(
83
- plan.old_key,
84
- meta: pre_env["_meta"],
85
- body: pre_env["body"],
86
- content: pre_env["content"],
87
- as: plan.as,
88
- suppress_events: true,
89
- )
90
- plan.with(uid: env["uid"], etag_before: env["etag"])
91
- end
92
-
93
- def perform_move!(plan)
94
- FileUtils.mkdir_p(File.dirname(plan.new_path))
95
- FileUtils.mv(plan.old_path, plan.new_path)
96
- rewrite_name_for_mv!(plan.new_mentry, plan.new_path, plan.new_key)
97
- Etag.for_file(plan.new_path)
98
- end
99
-
100
- def record_move(plan, etag_after:, correlation_id:)
101
- extras = {
102
- "from_key" => plan.old_key, "to_key" => plan.new_key,
103
- "from_path" => plan.old_path, "to_path" => plan.new_path,
104
- "uid" => plan.uid
105
- }
106
- extras["correlation_id"] = correlation_id if correlation_id
107
-
108
- @audit_log.append(
109
- role: plan.as, verb: "mv", key: plan.new_key,
110
- etag_before: plan.etag_before, etag_after: etag_after,
111
- extras: extras
112
- )
113
- new_envelope = @reader.get(plan.new_key)
114
- @store.fire_event(
115
- :entry_renamed,
116
- key: plan.new_key, from_key: plan.old_key, to_key: plan.new_key,
117
- envelope: new_envelope
118
- )
119
- new_envelope
120
- end
121
-
122
- def dry_run_result(plan)
123
- {
124
- "protocol" => PROTOCOL, "ok" => true, "dry_run" => true,
125
- "from_key" => plan.old_key, "to_key" => plan.new_key,
126
- "from_path" => plan.old_path, "to_path" => plan.new_path,
127
- "uid" => plan.uid
128
- }
129
- end
130
-
131
- def success_result(plan, new_envelope:)
132
- {
133
- "protocol" => PROTOCOL, "ok" => true,
134
- "from_key" => plan.old_key, "to_key" => plan.new_key,
135
- "from_path" => plan.old_path, "to_path" => plan.new_path,
136
- "uid" => plan.uid,
137
- "envelope" => new_envelope
138
- }
139
- end
140
-
141
- # If the moved file carries a `name:` field (markdown) or `_meta.name`
142
- # (json/yaml), rewrite it to the new basename so enforce_name_match! stays
143
- # happy on the next read. Only touches the bytes when name actually changes.
144
- def rewrite_name_for_mv!(mentry, new_path, new_key)
145
- strategy = Entry.for_format(mentry.format)
146
- raw = File.binread(new_path)
147
- parsed = strategy.parse(raw, path: new_path)
148
- basename = new_key.split(".").last
149
-
150
- case mentry.format
151
- when "markdown"
152
- meta = parsed["_meta"] || {}
153
- return unless meta.is_a?(Hash) && meta["name"].is_a?(String) && meta["name"] != basename
154
-
155
- meta = meta.merge("name" => basename)
156
- File.binwrite(new_path, strategy.serialize(meta: meta, body: parsed["body"]))
157
- when "json", "yaml"
158
- meta = parsed["_meta"]
159
- return unless meta.is_a?(Hash) && meta["name"].is_a?(String) && meta["name"] != basename
160
-
161
- new_meta = meta.merge("name" => basename)
162
- File.binwrite(new_path, strategy.serialize(meta: new_meta, body: "", content: parsed["content"]))
163
- end
164
- end
165
- end
166
- end
167
- end