textus 0.12.1 → 0.14.1

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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +60 -40
  3. data/CHANGELOG.md +231 -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/builder/pipeline.rb +46 -2
  28. data/lib/textus/cli/verb/accept.rb +1 -2
  29. data/lib/textus/cli/verb/audit.rb +3 -3
  30. data/lib/textus/cli/verb/blame.rb +1 -2
  31. data/lib/textus/cli/verb/build.rb +6 -2
  32. data/lib/textus/cli/verb/delete.rb +1 -2
  33. data/lib/textus/cli/verb/deps.rb +1 -1
  34. data/lib/textus/cli/verb/freshness.rb +1 -2
  35. data/lib/textus/cli/verb/get.rb +2 -3
  36. data/lib/textus/cli/verb/list.rb +1 -1
  37. data/lib/textus/cli/verb/mv.rb +1 -1
  38. data/lib/textus/cli/verb/published.rb +1 -1
  39. data/lib/textus/cli/verb/put.rb +2 -2
  40. data/lib/textus/cli/verb/rdeps.rb +1 -1
  41. data/lib/textus/cli/verb/refresh.rb +1 -2
  42. data/lib/textus/cli/verb/reject.rb +1 -1
  43. data/lib/textus/cli/verb/rule_explain.rb +1 -2
  44. data/lib/textus/cli/verb/schema.rb +1 -1
  45. data/lib/textus/cli/verb/uid.rb +1 -1
  46. data/lib/textus/cli/verb/where.rb +1 -1
  47. data/lib/textus/cli/verb.rb +6 -1
  48. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  49. data/lib/textus/doctor.rb +1 -1
  50. data/lib/textus/domain/freshness/evaluator.rb +1 -1
  51. data/lib/textus/domain/policy/predicates/schema_valid.rb +3 -3
  52. data/lib/textus/entry/base.rb +28 -0
  53. data/lib/textus/entry/json.rb +59 -0
  54. data/lib/textus/entry/markdown.rb +46 -0
  55. data/lib/textus/entry/text.rb +35 -0
  56. data/lib/textus/entry/yaml.rb +59 -0
  57. data/lib/textus/entry.rb +16 -0
  58. data/lib/textus/envelope.rb +44 -14
  59. data/lib/textus/intro.rb +56 -0
  60. data/lib/textus/manifest/entry/parser.rb +84 -0
  61. data/lib/textus/manifest/entry/validators/events.rb +21 -0
  62. data/lib/textus/manifest/entry/validators/format_matrix.rb +26 -0
  63. data/lib/textus/manifest/entry/validators/index_filename.rb +45 -0
  64. data/lib/textus/manifest/entry/validators/inject_intro.rb +18 -0
  65. data/lib/textus/manifest/entry/validators/publish_each.rb +37 -0
  66. data/lib/textus/manifest/entry/validators.rb +20 -0
  67. data/lib/textus/manifest/entry.rb +35 -213
  68. data/lib/textus/manifest.rb +19 -32
  69. data/lib/textus/operations/reads.rb +39 -0
  70. data/lib/textus/operations/refresh.rb +27 -0
  71. data/lib/textus/operations/writes.rb +21 -0
  72. data/lib/textus/operations.rb +44 -0
  73. data/lib/textus/projection.rb +5 -4
  74. data/lib/textus/refresh.rb +3 -4
  75. data/lib/textus/schema/tools.rb +8 -7
  76. data/lib/textus/store/reader.rb +1 -1
  77. data/lib/textus/store/validator.rb +3 -3
  78. data/lib/textus/store/writer.rb +5 -74
  79. data/lib/textus/store.rb +1 -55
  80. data/lib/textus/version.rb +1 -1
  81. metadata +23 -4
  82. data/lib/textus/composition.rb +0 -72
  83. data/lib/textus/proposal.rb +0 -10
  84. data/lib/textus/store/mover.rb +0 -167
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ module Application
3
+ module Reads
4
+ class Stale
5
+ def initialize(ctx:)
6
+ @ctx = ctx
7
+ end
8
+
9
+ def call(prefix: nil, zone: nil)
10
+ @ctx.store.reader.stale(prefix: prefix, zone: zone)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ module Application
3
+ module Reads
4
+ class Uid
5
+ def initialize(ctx:)
6
+ @ctx = ctx
7
+ end
8
+
9
+ def call(key)
10
+ @ctx.store.reader.uid(key)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ module Application
3
+ module Reads
4
+ class ValidateAll
5
+ def initialize(ctx:)
6
+ @ctx = ctx
7
+ end
8
+
9
+ def call
10
+ @ctx.store.reader.validate_all
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ module Application
3
+ module Reads
4
+ class Where
5
+ def initialize(ctx:)
6
+ @ctx = ctx
7
+ end
8
+
9
+ def call(key)
10
+ @ctx.store.reader.where(key)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -5,9 +5,9 @@ module Textus
5
5
  module_function
6
6
 
7
7
  def call(ctx, prefix: nil, zone: nil)
8
- worker = Textus::Composition.refresh_worker(ctx)
8
+ worker = Textus::Application::Refresh::Worker.new(ctx: ctx, bus: ctx.store.bus)
9
9
 
10
- stale_rows = ctx.store.stale(prefix: prefix, zone: zone)
10
+ stale_rows = Textus::Application::Reads::Stale.new(ctx: ctx).call(prefix: prefix, zone: zone)
11
11
  refreshed = []
12
12
  failed = []
13
13
  skipped = []
@@ -54,10 +54,10 @@ module Textus
54
54
 
55
55
  def persist_and_notify(key, mentry, result, before_etag)
56
56
  normalized = Textus::Refresh.normalize_action_result(result, format: mentry.format)
57
- envelope = @ctx.store.put(
57
+ envelope = Textus::Application::Writes::Put.new(ctx: @ctx, bus: @bus).call(
58
58
  key,
59
59
  meta: normalized[:meta], body: normalized[:body], content: normalized[:content],
60
- as: @ctx.role, suppress_events: true
60
+ suppress_events: true
61
61
  )
62
62
  change = detect_change(before_etag, envelope)
63
63
  unless change == :unchanged
@@ -69,7 +69,7 @@ module Textus
69
69
 
70
70
  def detect_change(before_etag, envelope)
71
71
  if before_etag.nil? then :created
72
- elsif envelope["etag"] == before_etag then :unchanged
72
+ elsif envelope.etag == before_etag then :unchanged
73
73
  else :updated
74
74
  end
75
75
  end
@@ -10,8 +10,8 @@ module Textus
10
10
  def call(pending_key)
11
11
  raise ProposalError.new("only human role can accept proposals; got '#{@ctx.role}'") unless @ctx.role == "human"
12
12
 
13
- env = @ctx.store.get(pending_key)
14
- proposal = env["_meta"]["proposal"] or raise ProposalError.new("entry has no proposal block: #{pending_key}")
13
+ env = @ctx.store.reader.get(pending_key)
14
+ proposal = env.meta["proposal"] or raise ProposalError.new("entry has no proposal block: #{pending_key}")
15
15
  target = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
16
16
  action = proposal["action"] || "put"
17
17
 
@@ -21,16 +21,16 @@ module Textus
21
21
  when "put"
22
22
  # Nested proposal "frontmatter" — the meta to write to the accepted
23
23
  # target. Not related to the removed intake-handler legacy bridge.
24
- target_meta = env["_meta"]["frontmatter"] || {}
25
- target_body = env["body"]
26
- Composition.writes_put(@ctx).call(target, meta: target_meta, body: target_body)
24
+ target_meta = env.meta["frontmatter"] || {}
25
+ target_body = env.body
26
+ Textus::Application::Writes::Put.new(ctx: @ctx, bus: @bus).call(target, meta: target_meta, body: target_body)
27
27
  when "delete"
28
- Composition.writes_delete(@ctx).call(target)
28
+ Textus::Application::Writes::Delete.new(ctx: @ctx, bus: @bus).call(target)
29
29
  else
30
30
  raise ProposalError.new("unknown action: #{action}")
31
31
  end
32
32
 
33
- Composition.writes_delete(@ctx).call(pending_key)
33
+ Textus::Application::Writes::Delete.new(ctx: @ctx, bus: @bus).call(pending_key)
34
34
 
35
35
  @bus.publish(:proposal_accepted,
36
36
  store: @ctx.with_role(@ctx.role),
@@ -4,9 +4,12 @@ module Textus
4
4
  module Application
5
5
  module Writes
6
6
  # Materializes generator-zone entries (template + projection) onto disk
7
- # and copies the result to any configured `publish_to` / `publish_each`
8
- # targets. Fires `:build_completed` and `:file_published` events on the bus,
9
- # tagged with the request's correlation_id for traceability.
7
+ # and copies the result to any configured `publish_to:` targets. Fires
8
+ # `:build_completed` and `:file_published` events.
9
+ #
10
+ # For `publish_each:` (per-leaf publishing of nested entries), see
11
+ # `Application::Writes::Publish`. The CLI verb `textus build` calls
12
+ # both classes and merges the results.
10
13
  class Build
11
14
  def initialize(ctx:, bus:)
12
15
  @ctx = ctx
@@ -14,16 +17,14 @@ module Textus
14
17
  end
15
18
 
16
19
  def call(prefix: nil)
17
- built = []
18
- manifest.entries.each do |mentry|
20
+ built = manifest.entries.filter_map do |mentry|
19
21
  next unless mentry.in_generator_zone?
20
22
  next unless mentry.projection || mentry.template
21
23
  next if prefix && !mentry.key.start_with?(prefix)
22
24
 
23
- built << materialize(mentry)
25
+ materialize(mentry)
24
26
  end
25
- published_leaves = publish_leaves(prefix: prefix)
26
- { "protocol" => Textus::PROTOCOL, "built" => built, "published_leaves" => published_leaves }
27
+ { "protocol" => Textus::PROTOCOL, "built" => built }
27
28
  end
28
29
 
29
30
  private
@@ -32,41 +33,6 @@ module Textus
32
33
  def manifest = store.manifest
33
34
  def root = store.root
34
35
 
35
- def publish_leaves(prefix: nil)
36
- repo_root = File.dirname(root)
37
- out = []
38
- manifest.entries.each do |mentry|
39
- next unless mentry.nested && mentry.publish_each
40
- next if prefix && !mentry.key.start_with?(prefix) && !prefix.start_with?("#{mentry.key}.")
41
-
42
- manifest.enumerate(prefix: mentry.key).each do |row|
43
- next unless row[:manifest_entry].equal?(mentry)
44
- next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
45
-
46
- out << publish_leaf(mentry, row, repo_root)
47
- end
48
- end
49
- out
50
- end
51
-
52
- def publish_leaf(mentry, row, repo_root)
53
- target_rel = mentry.publish_target_for(row[:key])
54
- target_abs = File.expand_path(File.join(repo_root, target_rel))
55
- unless target_abs.start_with?(File.expand_path(repo_root) + File::SEPARATOR)
56
- raise PublishError.new(
57
- "entry '#{mentry.key}': publish_each target '#{target_rel}' for key '#{row[:key]}' escapes repo root",
58
- )
59
- end
60
-
61
- Textus::Infra::Publisher.publish(source: row[:path], target: target_abs, store_root: root)
62
- publish_event(:file_published,
63
- key: row[:key],
64
- envelope: store.get(row[:key]),
65
- source: row[:path],
66
- target: target_abs)
67
- { "key" => row[:key], "source" => row[:path], "target" => target_abs }
68
- end
69
-
70
36
  def materialize(mentry)
71
37
  target_path = Builder::Pipeline.run(
72
38
  store: store,
@@ -85,7 +51,7 @@ module Textus
85
51
  end
86
52
 
87
53
  def publish_and_fire(mentry, target_path)
88
- envelope = store.get(mentry.key)
54
+ envelope = store.reader.get(mentry.key)
89
55
  repo_root = File.dirname(root)
90
56
 
91
57
  mentry.publish_to.each do |rel|
@@ -105,9 +71,6 @@ module Textus
105
71
  end
106
72
 
107
73
  def publish_event(event, **payload)
108
- # `with_role` returns a Context that preserves the original
109
- # correlation_id, so hooks reading `store.correlation_id` see the
110
- # same value as the event's top-level correlation_id key.
111
74
  @bus.publish(event, store: @ctx.with_role(@ctx.role), correlation_id: @ctx.correlation_id, **payload)
112
75
  end
113
76
  end
@@ -0,0 +1,144 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ module Application
5
+ module Writes
6
+ 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
15
+ end
16
+
17
+ 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
20
+
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)
25
+ end
26
+
27
+ private
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)
35
+ raise UsageError.new("mv: old and new keys are identical") if old_key == new_key
36
+
37
+ old_mentry, old_path, = manifest.resolve(old_key)
38
+ raise UnknownKey.new(old_key) unless File.exist?(old_path)
39
+
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)
44
+
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]
53
+ end
54
+
55
+ def validate_zone_and_format!(old_mentry, new_mentry)
56
+ if old_mentry.zone != new_mentry.zone
57
+ raise UsageError.new(
58
+ "mv: cross-zone move refused (#{old_mentry.zone} → #{new_mentry.zone}). " \
59
+ "Use put+delete for cross-zone moves.",
60
+ )
61
+ end
62
+ return if old_mentry.format == new_mentry.format
63
+
64
+ raise UsageError.new("mv: format mismatch (#{old_mentry.format} → #{new_mentry.format}); refusing.")
65
+ end
66
+
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,
83
+ )
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
+ end
93
+
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)
108
+ @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
116
+ end
117
+
118
+ def dry_run_result(plan)
119
+ {
120
+ "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
124
+ }
125
+ end
126
+
127
+ def success_result(plan, new_envelope:)
128
+ {
129
+ "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
134
+ }
135
+ 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
+ end
142
+ end
143
+ end
144
+ end
@@ -1,23 +1,55 @@
1
1
  module Textus
2
2
  module Application
3
3
  module Writes
4
+ # Copies nested-leaf entries to their `publish_each:` targets. Fires
5
+ # `:file_published` for each copy. Mirror of `Build` for the publish
6
+ # half — split out from the old Build per ADR 0007.
4
7
  class Publish
5
8
  def initialize(ctx:, bus:)
6
9
  @ctx = ctx
7
10
  @bus = bus
8
11
  end
9
12
 
10
- def call(source:, target:, key:)
11
- Textus::Infra::Publisher.publish(
12
- source: source,
13
- target: target,
14
- store_root: @ctx.store.root,
15
- )
13
+ def call(prefix: nil)
14
+ repo_root = File.dirname(store.root)
15
+ out = []
16
+ manifest.entries.each do |mentry|
17
+ next unless mentry.nested && mentry.publish_each
18
+ next if prefix && !mentry.key.start_with?(prefix) && !prefix.start_with?("#{mentry.key}.")
19
+
20
+ manifest.enumerate(prefix: mentry.key).each do |row|
21
+ next unless row[:manifest_entry].equal?(mentry)
22
+ next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
23
+
24
+ out << publish_leaf(mentry, row, repo_root)
25
+ end
26
+ end
27
+ { "protocol" => Textus::PROTOCOL, "published_leaves" => out }
28
+ end
29
+
30
+ private
31
+
32
+ def store = @ctx.store
33
+ def manifest = store.manifest
34
+
35
+ def publish_leaf(mentry, row, repo_root)
36
+ target_rel = mentry.publish_target_for(row[:key])
37
+ target_abs = File.expand_path(File.join(repo_root, target_rel))
38
+ unless target_abs.start_with?(File.expand_path(repo_root) + File::SEPARATOR)
39
+ raise PublishError.new(
40
+ "entry '#{mentry.key}': publish_each target '#{target_rel}' for key '#{row[:key]}' escapes repo root",
41
+ )
42
+ end
43
+
44
+ Textus::Infra::Publisher.publish(source: row[:path], target: target_abs, store_root: store.root)
16
45
  @bus.publish(:file_published,
17
- key: key,
18
- source: source,
19
- target: target,
46
+ store: @ctx.with_role(@ctx.role),
47
+ key: row[:key],
48
+ envelope: store.reader.get(row[:key]),
49
+ source: row[:path],
50
+ target: target_abs,
20
51
  correlation_id: @ctx.correlation_id)
52
+ { "key" => row[:key], "source" => row[:path], "target" => target_abs }
21
53
  end
22
54
  end
23
55
  end
@@ -0,0 +1,37 @@
1
+ module Textus
2
+ module Application
3
+ module Writes
4
+ class Reject
5
+ def initialize(ctx:, bus:)
6
+ @ctx = ctx
7
+ @bus = bus
8
+ end
9
+
10
+ def call(pending_key)
11
+ raise ProposalError.new("only human role can reject proposals; got '#{@ctx.role}'") unless @ctx.role == "human"
12
+
13
+ mentry, = @ctx.store.manifest.resolve(pending_key)
14
+ unless mentry.in_proposal_zone?
15
+ raise ProposalError.new("reject: '#{pending_key}' is not in a proposal zone (zone=#{mentry.zone})")
16
+ end
17
+
18
+ env = @ctx.store.reader.get(pending_key)
19
+ proposal = env.meta&.dig("proposal") or
20
+ raise ProposalError.new("entry has no proposal block: #{pending_key}")
21
+ target_key = proposal["target_key"] or
22
+ raise ProposalError.new("proposal missing target_key")
23
+
24
+ Textus::Application::Writes::Delete.new(ctx: @ctx, bus: @bus).call(pending_key, suppress_events: true)
25
+
26
+ @bus.publish(:proposal_rejected,
27
+ store: @ctx.with_role(@ctx.role),
28
+ key: pending_key,
29
+ target_key: target_key,
30
+ correlation_id: @ctx.correlation_id)
31
+
32
+ { "protocol" => PROTOCOL, "rejected" => pending_key, "target_key" => target_key }
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -19,6 +19,35 @@ module Textus
19
19
  end
20
20
  end
21
21
 
22
+ # Replaces the freshly-stamped timestamp inside `new_bytes` with the
23
+ # timestamp pulled from `old_bytes` (same format). Returns the rewritten
24
+ # bytes, or nil if either side lacks a parseable timestamp.
25
+ module IdempotentWrite
26
+ def self.rewrite_with_prior_timestamp(new_bytes:, old_bytes:, format:)
27
+ prior = extract_timestamp(old_bytes, format)
28
+ fresh = extract_timestamp(new_bytes, format)
29
+ return nil unless prior && fresh
30
+ return new_bytes if prior == fresh
31
+
32
+ new_bytes.sub(fresh, prior)
33
+ end
34
+
35
+ def self.extract_timestamp(bytes, format)
36
+ case format
37
+ when "markdown"
38
+ parsed = Entry.for_format("markdown").parse(bytes)
39
+ parsed.dig("_meta", "generated", "at")
40
+ when "json", "yaml"
41
+ parsed = Entry.for_format(format).parse(bytes)
42
+ parsed.dig("_meta", "generated_at")
43
+ else # rubocop:disable Style/EmptyElse
44
+ nil
45
+ end
46
+ rescue Textus::BadFrontmatter
47
+ nil
48
+ end
49
+ end
50
+
22
51
  module Pipeline
23
52
  def self.renderers
24
53
  @renderers ||= {
@@ -44,13 +73,28 @@ module Textus
44
73
  raise UsageError.new("builder: unsupported format #{mentry.format.inspect} for '#{mentry.key}'")
45
74
  bytes = klass.new(template_loader: template_loader).call(mentry: mentry, data: data)
46
75
 
47
- # 3. Write
76
+ # 3. Write (idempotent: skip if only generated_at would differ)
48
77
  target_path = Key::Path.resolve(store.manifest, mentry)
49
78
  FileUtils.mkdir_p(File.dirname(target_path))
50
- File.binwrite(target_path, bytes)
79
+ write_if_changed(target_path, bytes, mentry.format)
51
80
 
52
81
  target_path
53
82
  end
83
+
84
+ def self.write_if_changed(target_path, bytes, format)
85
+ if File.exist?(target_path)
86
+ old_bytes = File.binread(target_path)
87
+ if format == "text"
88
+ return if old_bytes == bytes
89
+ else
90
+ rewritten = IdempotentWrite.rewrite_with_prior_timestamp(
91
+ new_bytes: bytes, old_bytes: old_bytes, format: format,
92
+ )
93
+ return if rewritten && rewritten == old_bytes
94
+ end
95
+ end
96
+ File.binwrite(target_path, bytes)
97
+ end
54
98
  end
55
99
  end
56
100
  end
@@ -6,8 +6,7 @@ module Textus
6
6
 
7
7
  def call(store)
8
8
  key = positional.shift or raise UsageError.new("accept requires a key")
9
- ctx = context_for(store)
10
- emit(Textus::Composition.writes_accept(ctx).call(key))
9
+ emit(operations_for(store).writes.accept.call(key))
11
10
  end
12
11
  end
13
12
  end
@@ -11,9 +11,9 @@ module Textus
11
11
  option :limit, "--limit=N"
12
12
 
13
13
  def call(store)
14
- ctx = context_for(store)
15
- since_time = since && Textus::Application::Reads::Audit.parse_since(since, now: ctx.now)
16
- rows = Textus::Composition.audit(ctx).call(
14
+ ops = operations_for(store)
15
+ since_time = since && Textus::Application::Reads::Audit.parse_since(since, now: ops.ctx.now)
16
+ rows = ops.reads.audit.call(
17
17
  key: key_filter,
18
18
  zone: zone,
19
19
  role: role_filter,
@@ -6,8 +6,7 @@ module Textus
6
6
 
7
7
  def call(store)
8
8
  key = positional.shift or raise UsageError.new("blame requires a key")
9
- ctx = context_for(store)
10
- rows = Textus::Composition.blame(ctx).call(key: key, limit: limit&.to_i)
9
+ rows = operations_for(store).reads.blame.call(key: key, limit: limit&.to_i)
11
10
  emit({ "verb" => "blame", "key" => key, "rows" => rows })
12
11
  end
13
12
  end
@@ -5,8 +5,12 @@ module Textus
5
5
  option :prefix, "--prefix=K"
6
6
 
7
7
  def call(store)
8
- ctx = Textus::Composition.context(store, role: "builder")
9
- emit(Textus::Composition.writes_build(ctx).call(prefix: prefix))
8
+ ops = Textus::Operations.for(store, role: "builder")
9
+ build_res = ops.writes.build.call(prefix: prefix)
10
+ publish_res = ops.writes.publish.call(prefix: prefix)
11
+ emit({ "protocol" => Textus::PROTOCOL,
12
+ "built" => build_res["built"],
13
+ "published_leaves" => publish_res["published_leaves"] })
10
14
  end
11
15
  end
12
16
  end
@@ -7,8 +7,7 @@ module Textus
7
7
 
8
8
  def call(store)
9
9
  key = positional.shift or raise UsageError.new("delete requires a key")
10
- ctx = context_for(store)
11
- emit(Textus::Composition.writes_delete(ctx).call(key, if_etag: if_etag))
10
+ emit(operations_for(store).writes.delete.call(key, if_etag: if_etag))
12
11
  end
13
12
  end
14
13
  end
@@ -4,7 +4,7 @@ module Textus
4
4
  class Deps < Verb
5
5
  def call(store)
6
6
  key = positional.shift or raise UsageError.new("deps requires a key")
7
- emit({ "key" => key, "deps" => store.deps(key) })
7
+ emit({ "key" => key, "deps" => operations_for(store).reads.deps.call(key) })
8
8
  end
9
9
  end
10
10
  end
@@ -6,8 +6,7 @@ module Textus
6
6
  option :zone, "--zone=Z"
7
7
 
8
8
  def call(store)
9
- ctx = context_for(store)
10
- rows = Textus::Composition.freshness(ctx).call(prefix: prefix, zone: zone)
9
+ rows = operations_for(store).reads.freshness.call(prefix: prefix, zone: zone)
11
10
  emit({ "verb" => "freshness", "rows" => rows })
12
11
  end
13
12
  end
@@ -6,11 +6,10 @@ module Textus
6
6
 
7
7
  def call(store)
8
8
  key = positional.shift or raise UsageError.new("get requires a key")
9
- ctx = context_for(store)
10
- result = Textus::Composition.reads_get(ctx).call(key)
9
+ result = operations_for(store).reads.get.call(key)
11
10
  raise Textus::UnknownKey.new(key, suggestions: store.manifest.suggestions_for(key)) if result.nil?
12
11
 
13
- emit(result)
12
+ emit(result.to_h_for_wire)
14
13
  end
15
14
  end
16
15
  end
@@ -6,7 +6,7 @@ module Textus
6
6
  option :zone, "--zone=Z"
7
7
 
8
8
  def call(store)
9
- emit({ "entries" => store.list(prefix: prefix, zone: zone) })
9
+ emit({ "entries" => operations_for(store).reads.list.call(prefix: prefix, zone: zone) })
10
10
  end
11
11
  end
12
12
  end
@@ -8,7 +8,7 @@ module Textus
8
8
  def call(store)
9
9
  old_key = positional.shift or raise UsageError.new("mv requires <old-key> <new-key>")
10
10
  new_key = positional.shift or raise UsageError.new("mv requires <old-key> <new-key>")
11
- emit(store.mv(old_key, new_key, as: resolved_role(store), dry_run: dry_run || false))
11
+ emit(operations_for(store).writes.mv.call(old_key, new_key, dry_run: dry_run || false))
12
12
  end
13
13
  end
14
14
  end