textus 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +43 -48
  3. data/CHANGELOG.md +173 -0
  4. data/lib/textus/application/context.rb +20 -58
  5. data/lib/textus/application/policy/predicates/human_accept.rb +30 -0
  6. data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
  7. data/lib/textus/{domain → application}/policy/promotion.rb +20 -3
  8. data/lib/textus/application/projection.rb +91 -0
  9. data/lib/textus/application/reads/audit.rb +4 -4
  10. data/lib/textus/application/reads/blame.rb +9 -8
  11. data/lib/textus/application/reads/deps.rb +14 -3
  12. data/lib/textus/application/reads/freshness.rb +10 -8
  13. data/lib/textus/application/reads/get.rb +10 -8
  14. data/lib/textus/application/reads/get_or_refresh.rb +3 -3
  15. data/lib/textus/application/reads/list.rb +3 -3
  16. data/lib/textus/application/reads/policy_explain.rb +3 -3
  17. data/lib/textus/application/reads/published.rb +5 -3
  18. data/lib/textus/application/reads/rdeps.rb +15 -3
  19. data/lib/textus/application/reads/schema_envelope.rb +5 -4
  20. data/lib/textus/application/reads/stale.rb +3 -3
  21. data/lib/textus/application/reads/uid.rb +11 -3
  22. data/lib/textus/application/reads/validate_all.rb +10 -6
  23. data/lib/textus/application/reads/validator.rb +2 -2
  24. data/lib/textus/application/reads/where.rb +3 -3
  25. data/lib/textus/application/refresh/all.rb +15 -11
  26. data/lib/textus/application/refresh/orchestrator.rb +9 -8
  27. data/lib/textus/application/refresh/worker.rb +56 -32
  28. data/lib/textus/application/tools/migrate_keys.rb +191 -0
  29. data/lib/textus/application/tools/migrate_manifest_to_kinds.rb +31 -0
  30. data/lib/textus/application/writes/accept.rb +38 -15
  31. data/lib/textus/application/writes/delete.rb +13 -10
  32. data/lib/textus/application/writes/envelope_io.rb +64 -4
  33. data/lib/textus/application/writes/materializer.rb +50 -0
  34. data/lib/textus/application/writes/mv.rb +57 -94
  35. data/lib/textus/application/writes/publish.rb +132 -26
  36. data/lib/textus/application/writes/put.rb +15 -14
  37. data/lib/textus/application/writes/reject.rb +20 -11
  38. data/lib/textus/builder/pipeline.rb +21 -15
  39. data/lib/textus/builder/renderer/json.rb +4 -1
  40. data/lib/textus/builder/renderer/markdown.rb +7 -1
  41. data/lib/textus/builder/renderer/yaml.rb +4 -1
  42. data/lib/textus/cli/verb/build.rb +2 -5
  43. data/lib/textus/cli/verb/get.rb +1 -1
  44. data/lib/textus/cli/verb/hook_run.rb +3 -4
  45. data/lib/textus/cli/verb/hooks.rb +5 -5
  46. data/lib/textus/cli/verb/key_normalize.rb +32 -3
  47. data/lib/textus/cli/verb/put.rb +2 -3
  48. data/lib/textus/cli/verb/refresh_stale.rb +1 -2
  49. data/lib/textus/doctor/check/handler_allowlist.rb +3 -2
  50. data/lib/textus/doctor/check/hooks.rb +2 -2
  51. data/lib/textus/doctor/check/illegal_keys.rb +6 -5
  52. data/lib/textus/doctor/check/intake_registration.rb +2 -2
  53. data/lib/textus/doctor/check/manifest_files.rb +1 -1
  54. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  55. data/lib/textus/doctor/check/templates.rb +4 -3
  56. data/lib/textus/doctor.rb +3 -4
  57. data/lib/textus/domain/authorizer.rb +37 -0
  58. data/lib/textus/domain/staleness/generator_check.rb +8 -7
  59. data/lib/textus/domain/staleness/intake_check.rb +2 -2
  60. data/lib/textus/hooks/builtin.rb +6 -6
  61. data/lib/textus/hooks/bus.rb +155 -0
  62. data/lib/textus/hooks/context.rb +38 -0
  63. data/lib/textus/hooks/fire_report.rb +23 -0
  64. data/lib/textus/hooks/loader.rb +3 -3
  65. data/lib/textus/infra/audit_subscriber.rb +4 -4
  66. data/lib/textus/infra/event_bus.rb +3 -3
  67. data/lib/textus/infra/refresh/detached.rb +1 -1
  68. data/lib/textus/init.rb +3 -2
  69. data/lib/textus/intro.rb +7 -7
  70. data/lib/textus/manifest/entry/base.rb +38 -0
  71. data/lib/textus/manifest/entry/derived.rb +25 -0
  72. data/lib/textus/manifest/entry/intake.rb +19 -0
  73. data/lib/textus/manifest/entry/leaf.rb +16 -0
  74. data/lib/textus/manifest/entry/nested.rb +39 -0
  75. data/lib/textus/manifest/entry/parser.rb +64 -31
  76. data/lib/textus/manifest/entry/validators/events.rb +3 -2
  77. data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -3
  78. data/lib/textus/manifest/entry/validators/index_filename.rb +11 -10
  79. data/lib/textus/manifest/entry/validators/inject_intro.rb +5 -2
  80. data/lib/textus/manifest/entry/validators/publish_each.rb +9 -6
  81. data/lib/textus/manifest/entry.rb +0 -72
  82. data/lib/textus/manifest/resolver.rb +109 -0
  83. data/lib/textus/manifest/schema.rb +1 -1
  84. data/lib/textus/manifest.rb +3 -100
  85. data/lib/textus/operations.rb +131 -74
  86. data/lib/textus/schema/tools.rb +2 -2
  87. data/lib/textus/store.rb +6 -6
  88. data/lib/textus/version.rb +1 -1
  89. metadata +18 -11
  90. data/lib/textus/application/writes/build.rb +0 -78
  91. data/lib/textus/dependencies.rb +0 -23
  92. data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
  93. data/lib/textus/hooks/dispatcher.rb +0 -71
  94. data/lib/textus/hooks/registry.rb +0 -85
  95. data/lib/textus/migrate_keys.rb +0 -187
  96. data/lib/textus/projection.rb +0 -89
  97. data/lib/textus/refresh.rb +0 -39
@@ -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
@@ -1,37 +1,102 @@
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
+ # Single-pass publish use case: materializes Derived entries (template +
5
+ # projection + external runner) AND copies Leaf/Nested entries to their
6
+ # publish targets. Replaces the former two-step Build + Publish split.
7
+ #
8
+ # Return shape: { "protocol", "built", "published_leaves" }
9
+ # — wire-compatible with what the `textus build` CLI verb previously
10
+ # assembled by merging Build + old Publish results.
7
11
  class Publish
8
- def initialize(ctx:)
9
- @ctx = ctx
12
+ def initialize(ctx:, manifest:, file_store:, bus:, root:, store:, hook_context:) # rubocop:disable Metrics/ParameterLists
13
+ @ctx = ctx
14
+ @manifest = manifest
15
+ @file_store = file_store
16
+ @bus = bus
17
+ @root = root
18
+ @store = store
19
+ @hook_context = hook_context
10
20
  end
11
21
 
12
22
  def call(prefix: nil)
13
- repo_root = File.dirname(store.root)
14
- out = []
15
- manifest.entries.each do |mentry|
16
- next unless mentry.nested && mentry.publish_each
17
- next if prefix && !mentry.key.start_with?(prefix) && !prefix.start_with?("#{mentry.key}.")
23
+ built = []
24
+ leaves = []
25
+ repo_root = File.dirname(@root)
18
26
 
19
- manifest.enumerate(prefix: mentry.key).each do |row|
20
- next unless row[:manifest_entry].equal?(mentry)
21
- next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
27
+ @manifest.entries.each do |mentry|
28
+ next if prefix && !entry_matches_prefix?(mentry, prefix)
22
29
 
23
- out << publish_leaf(mentry, row, repo_root)
30
+ case mentry
31
+ when Textus::Manifest::Entry::Derived
32
+ next unless mentry.in_generator_zone?
33
+
34
+ result = materialize_derived(mentry, repo_root)
35
+ built << result if result
36
+ when Textus::Manifest::Entry::Nested
37
+ next unless mentry.publish_each
38
+
39
+ publish_nested(mentry, repo_root, prefix, leaves)
40
+ when Textus::Manifest::Entry::Leaf
41
+ next if Array(mentry.publish_to).empty?
42
+
43
+ result = publish_leaf_entry(mentry, repo_root)
44
+ built << result if result
24
45
  end
25
46
  end
26
- { "protocol" => Textus::PROTOCOL, "published_leaves" => out }
47
+
48
+ { "protocol" => Textus::PROTOCOL, "built" => built, "published_leaves" => leaves }
27
49
  end
28
50
 
29
51
  private
30
52
 
31
- def store = @ctx.store
32
- def manifest = store.manifest
53
+ # Materialize a Derived entry and copy to publish_to targets.
54
+ def materialize_derived(mentry, repo_root)
55
+ target_path = Materializer.new(
56
+ ctx: @ctx, manifest: @manifest, file_store: @file_store,
57
+ bus: @bus, root: @root, store: @store
58
+ ).run(mentry)
33
59
 
34
- def publish_leaf(mentry, row, repo_root)
60
+ publish_derived_copies(mentry, target_path, repo_root)
61
+ fire_build_completed(mentry, target_path)
62
+
63
+ { "key" => mentry.key, "path" => target_path, "published_to" => mentry.publish_to }
64
+ end
65
+
66
+ def publish_derived_copies(mentry, target_path, repo_root)
67
+ envelope = reader.call(mentry.key)
68
+ mentry.publish_to.each do |rel|
69
+ target_abs = File.join(repo_root, rel)
70
+ Textus::Infra::Publisher.publish(source: target_path, target: target_abs, store_root: @root)
71
+ publish_event(:file_published,
72
+ key: mentry.key,
73
+ envelope: envelope,
74
+ source: target_path,
75
+ target: target_abs)
76
+ end
77
+ end
78
+
79
+ def fire_build_completed(mentry, target_path) # rubocop:disable Lint/UnusedMethodArgument
80
+ envelope = reader.call(mentry.key)
81
+ src = mentry.source
82
+ selects = src.is_a?(Textus::Manifest::Entry::Derived::Projection) ? Array(src.select).compact : []
83
+ publish_event(:build_completed,
84
+ key: mentry.key,
85
+ envelope: envelope,
86
+ sources: selects)
87
+ end
88
+
89
+ # Publish each leaf under a Nested entry's publish_each pattern.
90
+ def publish_nested(mentry, repo_root, prefix, accumulator)
91
+ @manifest.resolver.enumerate(prefix: mentry.key).each do |row|
92
+ next unless row[:manifest_entry].equal?(mentry)
93
+ next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
94
+
95
+ accumulator << publish_nested_leaf(mentry, row, repo_root)
96
+ end
97
+ end
98
+
99
+ def publish_nested_leaf(mentry, row, repo_root)
35
100
  target_rel = mentry.publish_target_for(row[:key])
36
101
  target_abs = File.expand_path(File.join(repo_root, target_rel))
37
102
  unless target_abs.start_with?(File.expand_path(repo_root) + File::SEPARATOR)
@@ -40,16 +105,57 @@ module Textus
40
105
  )
41
106
  end
42
107
 
43
- Textus::Infra::Publisher.publish(source: row[:path], target: target_abs, store_root: store.root)
44
- @ctx.bus.publish(:file_published,
45
- store: @ctx.with_role(@ctx.role),
46
- key: row[:key],
47
- envelope: Textus::Application::Reads::Get.new(ctx: @ctx).call(row[:key]),
48
- source: row[:path],
49
- target: target_abs,
50
- correlation_id: @ctx.correlation_id)
108
+ Textus::Infra::Publisher.publish(source: row[:path], target: target_abs, store_root: @root)
109
+ publish_event(:file_published,
110
+ key: row[:key],
111
+ envelope: reader.call(row[:key]),
112
+ source: row[:path],
113
+ target: target_abs)
51
114
  { "key" => row[:key], "source" => row[:path], "target" => target_abs }
52
115
  end
116
+
117
+ # Publish a standalone Leaf entry that has publish_to targets.
118
+ def publish_leaf_entry(mentry, repo_root)
119
+ source_path = @manifest.resolver.resolve(mentry.key).path
120
+ envelope = reader.call(mentry.key)
121
+
122
+ mentry.publish_to.each do |rel|
123
+ target_abs = File.join(repo_root, rel)
124
+ Textus::Infra::Publisher.publish(source: source_path, target: target_abs, store_root: @root)
125
+ publish_event(:file_published,
126
+ key: mentry.key,
127
+ envelope: envelope,
128
+ source: source_path,
129
+ target: target_abs)
130
+ end
131
+
132
+ { "key" => mentry.key, "path" => source_path, "published_to" => mentry.publish_to }
133
+ end
134
+
135
+ # Whether the entry should be processed for the given prefix filter.
136
+ def entry_matches_prefix?(mentry, prefix)
137
+ return true unless prefix
138
+
139
+ case mentry
140
+ when Textus::Manifest::Entry::Nested
141
+ # Nested: process if the entry key is a prefix of `prefix` or
142
+ # `prefix` is a prefix of the entry key (a leaf under it).
143
+ mentry.key.start_with?(prefix) ||
144
+ prefix.start_with?("#{mentry.key}.")
145
+ else
146
+ mentry.key.start_with?(prefix)
147
+ end
148
+ end
149
+
150
+ def reader
151
+ @reader ||= Textus::Application::Reads::Get.new(
152
+ ctx: @ctx, manifest: @manifest, file_store: @file_store,
153
+ )
154
+ end
155
+
156
+ def publish_event(event, **payload)
157
+ @bus.publish(event, ctx: @hook_context, **payload)
158
+ end
53
159
  end
54
160
  end
55
161
  end
@@ -2,16 +2,20 @@ module Textus
2
2
  module Application
3
3
  module Writes
4
4
  class Put
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
- def call(key, meta: nil, body: nil, content: nil, if_etag: nil, suppress_events: false)
11
- @ctx.manifest.validate_key!(key)
12
- mentry = @ctx.manifest.resolve(key).entry
14
+ def call(key, meta: nil, body: nil, content: nil, if_etag: nil)
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 = @envelope_io.write(
17
21
  key,
@@ -20,13 +24,10 @@ module Textus
20
24
  if_etag: if_etag,
21
25
  )
22
26
 
23
- unless suppress_events
24
- @ctx.bus.publish(:entry_put,
25
- store: @ctx.with_role(@ctx.role),
26
- key: key,
27
- envelope: envelope,
28
- correlation_id: @ctx.correlation_id)
29
- end
27
+ @bus.publish(:entry_put,
28
+ ctx: @hook_context,
29
+ key: key,
30
+ envelope: envelope)
30
31
 
31
32
  envelope
32
33
  end
@@ -2,32 +2,41 @@ module Textus
2
2
  module Application
3
3
  module Writes
4
4
  class Reject
5
- def initialize(ctx:, envelope_io:)
6
- @ctx = ctx
7
- @envelope_io = envelope_io
5
+ def initialize(ctx:, manifest:, file_store:, envelope_io:, bus:, authorizer:, hook_context:) # rubocop:disable Metrics/ParameterLists
6
+ @ctx = ctx
7
+ @manifest = manifest
8
+ @file_store = file_store
9
+ @envelope_io = envelope_io
10
+ @bus = bus
11
+ @authorizer = authorizer
12
+ @hook_context = hook_context
8
13
  end
9
14
 
10
15
  def call(pending_key)
11
16
  raise ProposalError.new("only human role can reject proposals; got '#{@ctx.role}'") unless @ctx.role == "human"
12
17
 
13
- mentry = @ctx.manifest.resolve(pending_key).entry
18
+ mentry = @manifest.resolver.resolve(pending_key).entry
14
19
  unless mentry.in_proposal_zone?
15
20
  raise ProposalError.new("reject: '#{pending_key}' is not in a proposal zone (zone=#{mentry.zone})")
16
21
  end
17
22
 
18
- 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)
19
26
  proposal = env.meta&.dig("proposal") or
20
27
  raise ProposalError.new("entry has no proposal block: #{pending_key}")
21
28
  target_key = proposal["target_key"] or
22
29
  raise ProposalError.new("proposal missing target_key")
23
30
 
24
- Textus::Application::Writes::Delete.new(ctx: @ctx, envelope_io: @envelope_io).call(pending_key, suppress_events: true)
31
+ Textus::Application::Writes::Delete.new(
32
+ ctx: @ctx, manifest: @manifest, envelope_io: @envelope_io,
33
+ bus: @bus, authorizer: @authorizer, hook_context: @hook_context
34
+ ).call(pending_key, suppress_events: true)
25
35
 
26
- @ctx.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)
36
+ @bus.publish(:proposal_rejected,
37
+ ctx: @hook_context,
38
+ key: pending_key,
39
+ target_key: target_key)
31
40
 
32
41
  { "protocol" => PROTOCOL, "rejected" => pending_key, "target_key" => target_key }
33
42
  end
@@ -7,11 +7,15 @@ module Textus
7
7
  # Returns a new hash with _meta as the first key, per SPEC §6 ordering.
8
8
  def self.call(content_hash, mentry)
9
9
  meta = { "generated_at" => Time.now.utc.iso8601 }
10
- from = Array(mentry.projection&.fetch("select", nil)).compact
11
- meta["from"] = from unless from.empty?
10
+ if mentry.is_a?(Textus::Manifest::Entry::Derived)
11
+ src = mentry.source
12
+ if src.is_a?(Textus::Manifest::Entry::Derived::Projection)
13
+ from = Array(src.select).compact
14
+ meta["from"] = from unless from.empty?
15
+ meta["reduce"] = src.transform if src.transform
16
+ end
17
+ end
12
18
  meta["template"] = mentry.template if mentry.template
13
- reduce = mentry.projection&.dig("transform")
14
- meta["reduce"] = reduce if reduce
15
19
 
16
20
  out = { "_meta" => meta }
17
21
  content_hash.each { |k, v| out[k] = v unless k == "_meta" }
@@ -58,22 +62,23 @@ module Textus
58
62
  }
59
63
  end
60
64
 
61
- def self.run(store:, mentry:, template_loader:)
65
+ # rubocop:disable Metrics/ParameterLists
66
+ def self.run(mentry:, manifest:, reader:, lister:, transform_resolver:, template_loader:,
67
+ transform_context: nil, inject_intro: nil)
62
68
  # 1. Load sources + project + reduce
63
69
  data =
64
- if mentry.projection
65
- ops = Operations.for(store)
66
- Projection.new(
67
- reader: ops.method(:get),
68
- spec: mentry.projection,
69
- lister: ops.method(:list),
70
- transform_resolver: ->(name) { store.registry.rpc_callable(:transform_rows, name) },
71
- transform_context: Application::Context.system(store),
70
+ if mentry.is_a?(Textus::Manifest::Entry::Derived) && mentry.projection?
71
+ Application::Projection.new(
72
+ reader: reader,
73
+ spec: mentry.source.to_h.transform_keys(&:to_s),
74
+ lister: lister,
75
+ transform_resolver: transform_resolver,
76
+ transform_context: transform_context,
72
77
  ).run
73
78
  else
74
79
  { "entries" => [], "count" => 0, "generated_at" => Time.now.utc.iso8601 }
75
80
  end
76
- data = data.merge("intro" => Intro.run(store)) if mentry.inject_intro
81
+ data = data.merge("intro" => inject_intro.call) if mentry.inject_intro && inject_intro
77
82
 
78
83
  # 2. Render
79
84
  klass = renderers[mentry.format] or
@@ -81,12 +86,13 @@ module Textus
81
86
  bytes = klass.new(template_loader: template_loader).call(mentry: mentry, data: data)
82
87
 
83
88
  # 3. Write (idempotent: skip if only generated_at would differ)
84
- target_path = Key::Path.resolve(store.manifest, mentry)
89
+ target_path = Key::Path.resolve(manifest, mentry)
85
90
  FileUtils.mkdir_p(File.dirname(target_path))
86
91
  write_if_changed(target_path, bytes, mentry.format)
87
92
 
88
93
  target_path
89
94
  end
95
+ # rubocop:enable Metrics/ParameterLists
90
96
 
91
97
  def self.write_if_changed(target_path, bytes, format)
92
98
  if File.exist?(target_path)
@@ -28,7 +28,10 @@ module Textus
28
28
  end
29
29
 
30
30
  def default_shape(mentry, data)
31
- if mentry.projection && mentry.projection["transform"] && data.is_a?(Hash) && !data.key?("entries")
31
+ has_transform = mentry.is_a?(Textus::Manifest::Entry::Derived) &&
32
+ mentry.source.is_a?(Textus::Manifest::Entry::Derived::Projection) &&
33
+ mentry.source.transform
34
+ if has_transform && data.is_a?(Hash) && !data.key?("entries")
32
35
  data
33
36
  elsif data.is_a?(Hash) && data["entries"].is_a?(Array)
34
37
  { "entries" => data["entries"] }
@@ -8,10 +8,16 @@ module Textus
8
8
  raise TemplateError.new("entry '#{mentry.key}': markdown build requires a template") unless mentry.template
9
9
 
10
10
  body = Mustache.render(@template_loader.call(mentry.template), data)
11
+ from = if mentry.is_a?(Textus::Manifest::Entry::Derived) &&
12
+ mentry.source.is_a?(Textus::Manifest::Entry::Derived::Projection)
13
+ Array(mentry.source.select).compact
14
+ else
15
+ []
16
+ end
11
17
  frontmatter = {
12
18
  "generated" => {
13
19
  "at" => Time.now.utc.iso8601,
14
- "from" => Array(mentry.projection&.fetch("select", nil)).compact,
20
+ "from" => from,
15
21
  },
16
22
  }
17
23
  Entry.for_format("markdown").serialize(meta: frontmatter, body: body)
@@ -28,7 +28,10 @@ module Textus
28
28
  end
29
29
 
30
30
  def default_shape(mentry, data)
31
- if mentry.projection && mentry.projection["transform"] && data.is_a?(Hash) && !data.key?("entries")
31
+ has_transform = mentry.is_a?(Textus::Manifest::Entry::Derived) &&
32
+ mentry.source.is_a?(Textus::Manifest::Entry::Derived::Projection) &&
33
+ mentry.source.transform
34
+ if has_transform && data.is_a?(Hash) && !data.key?("entries")
32
35
  data
33
36
  elsif data.is_a?(Hash) && data["entries"].is_a?(Array)
34
37
  { "entries" => data["entries"] }
@@ -9,11 +9,8 @@ module Textus
9
9
  def call(store)
10
10
  Textus::Infra::BuildLock.with(root: store.root) do
11
11
  ops = Textus::Operations.for(store, role: "builder")
12
- build_res = ops.build(prefix: prefix)
13
- publish_res = ops.publish(prefix: prefix)
14
- emit({ "protocol" => Textus::PROTOCOL,
15
- "built" => build_res["built"],
16
- "published_leaves" => publish_res["published_leaves"] })
12
+ result = ops.publish(prefix: prefix)
13
+ emit(result)
17
14
  end
18
15
  end
19
16
  end
@@ -9,7 +9,7 @@ module Textus
9
9
  def call(store)
10
10
  key = positional.shift or raise UsageError.new("get requires a key")
11
11
  result = operations_for(store).get_or_refresh(key)
12
- raise Textus::UnknownKey.new(key, suggestions: store.manifest.suggestions_for(key)) if result.nil?
12
+ raise Textus::UnknownKey.new(key, suggestions: store.manifest.resolver.suggestions_for(key)) if result.nil?
13
13
 
14
14
  emit(result.to_h_for_wire)
15
15
  end
@@ -26,13 +26,12 @@ module Textus
26
26
  end
27
27
  end
28
28
 
29
- role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
30
- callable = store.registry.rpc_callable(:resolve_intake, name)
31
- view = Application::Context.new(store: store, role: role)
29
+ Role.resolve(flag: as_flag, env: ENV, root: store.root)
30
+ callable = store.bus.rpc_callable(:resolve_intake, name)
32
31
 
33
32
  begin
34
33
  Timeout.timeout(Textus::Application::Refresh::Worker::FETCH_TIMEOUT_SECONDS) do
35
- callable.call(config: {}, store: view, args: args)
34
+ callable.call(config: {}, store: store, args: args)
36
35
  end
37
36
  rescue Timeout::Error
38
37
  raise UsageError.new(