textus 0.20.2 → 0.26.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 (175) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +148 -45
  3. data/CHANGELOG.md +194 -0
  4. data/README.md +8 -5
  5. data/SPEC.md +54 -15
  6. data/docs/conventions.md +10 -0
  7. data/lib/textus/application/caps.rb +49 -0
  8. data/lib/textus/application/context.rb +2 -2
  9. data/lib/textus/application/envelope/reader.rb +44 -0
  10. data/lib/textus/application/{writes/envelope_io.rb → envelope/writer.rb} +24 -50
  11. data/lib/textus/application/maintenance/key_delete_prefix.rb +44 -0
  12. data/lib/textus/application/maintenance/key_mv_prefix.rb +57 -0
  13. data/lib/textus/application/maintenance/migrate.rb +59 -0
  14. data/lib/textus/application/maintenance/rule_lint.rb +65 -0
  15. data/lib/textus/application/maintenance/zone_mv.rb +60 -0
  16. data/lib/textus/application/maintenance.rb +17 -0
  17. data/lib/textus/application/projection.rb +12 -10
  18. data/lib/textus/application/read/audit.rb +106 -0
  19. data/lib/textus/application/read/blame.rb +91 -0
  20. data/lib/textus/application/read/deps.rb +34 -0
  21. data/lib/textus/application/read/freshness.rb +110 -0
  22. data/lib/textus/application/read/get.rb +75 -0
  23. data/lib/textus/application/read/get_or_refresh.rb +63 -0
  24. data/lib/textus/application/read/list.rb +25 -0
  25. data/lib/textus/application/read/policy_explain.rb +47 -0
  26. data/lib/textus/application/read/published.rb +25 -0
  27. data/lib/textus/application/read/pulse.rb +101 -0
  28. data/lib/textus/application/read/rdeps.rb +35 -0
  29. data/lib/textus/application/read/schema_envelope.rb +26 -0
  30. data/lib/textus/application/read/stale.rb +23 -0
  31. data/lib/textus/application/read/uid.rb +30 -0
  32. data/lib/textus/application/read/validate_all.rb +32 -0
  33. data/lib/textus/application/{reads → read}/validator.rb +2 -2
  34. data/lib/textus/application/read/where.rb +26 -0
  35. data/lib/textus/application/use_case.rb +22 -0
  36. data/lib/textus/application/write/accept.rb +102 -0
  37. data/lib/textus/application/{writes → write}/authority_gate.rb +3 -3
  38. data/lib/textus/application/write/delete.rb +45 -0
  39. data/lib/textus/application/{writes → write}/materializer.rb +14 -15
  40. data/lib/textus/application/write/mv.rb +118 -0
  41. data/lib/textus/application/write/publish.rb +96 -0
  42. data/lib/textus/application/write/put.rb +49 -0
  43. data/lib/textus/application/write/refresh_all.rb +63 -0
  44. data/lib/textus/application/{refresh/orchestrator.rb → write/refresh_orchestrator.rb} +32 -8
  45. data/lib/textus/application/write/refresh_worker.rb +134 -0
  46. data/lib/textus/application/write/reject.rb +62 -0
  47. data/lib/textus/{intro.rb → boot.rb} +49 -29
  48. data/lib/textus/builder/pipeline.rb +5 -5
  49. data/lib/textus/cli/group/mcp.rb +9 -0
  50. data/lib/textus/cli/group/zone.rb +9 -0
  51. data/lib/textus/cli/verb/accept.rb +1 -1
  52. data/lib/textus/cli/verb/audit.rb +4 -2
  53. data/lib/textus/cli/verb/blame.rb +1 -1
  54. data/lib/textus/cli/verb/boot.rb +13 -0
  55. data/lib/textus/cli/verb/build.rb +2 -2
  56. data/lib/textus/cli/verb/delete.rb +1 -1
  57. data/lib/textus/cli/verb/deps.rb +1 -1
  58. data/lib/textus/cli/verb/doctor.rb +1 -1
  59. data/lib/textus/cli/verb/freshness.rb +1 -1
  60. data/lib/textus/cli/verb/get.rb +1 -1
  61. data/lib/textus/cli/verb/hook_run.rb +3 -4
  62. data/lib/textus/cli/verb/hooks.rb +11 -14
  63. data/lib/textus/cli/verb/key_delete.rb +24 -0
  64. data/lib/textus/cli/verb/list.rb +1 -1
  65. data/lib/textus/cli/verb/mcp_serve.rb +17 -0
  66. data/lib/textus/cli/verb/migrate.rb +18 -0
  67. data/lib/textus/cli/verb/mv.rb +11 -3
  68. data/lib/textus/cli/verb/published.rb +1 -1
  69. data/lib/textus/cli/verb/pulse.rb +17 -0
  70. data/lib/textus/cli/verb/put.rb +8 -6
  71. data/lib/textus/cli/verb/rdeps.rb +1 -1
  72. data/lib/textus/cli/verb/refresh.rb +1 -1
  73. data/lib/textus/cli/verb/refresh_stale.rb +1 -1
  74. data/lib/textus/cli/verb/reject.rb +1 -1
  75. data/lib/textus/cli/verb/rule_explain.rb +1 -1
  76. data/lib/textus/cli/verb/rule_lint.rb +18 -0
  77. data/lib/textus/cli/verb/schema.rb +1 -1
  78. data/lib/textus/cli/verb/uid.rb +1 -1
  79. data/lib/textus/cli/verb/where.rb +1 -1
  80. data/lib/textus/cli/verb/zone_mv.rb +19 -0
  81. data/lib/textus/cli/verb.rb +4 -4
  82. data/lib/textus/cli.rb +1 -1
  83. data/lib/textus/doctor/check/audit_log.rb +2 -2
  84. data/lib/textus/doctor/check/handler_allowlist.rb +2 -2
  85. data/lib/textus/doctor/check/hooks.rb +4 -3
  86. data/lib/textus/doctor/check/illegal_keys.rb +2 -2
  87. data/lib/textus/doctor/check/intake_registration.rb +2 -2
  88. data/lib/textus/doctor/check/manifest_files.rb +2 -2
  89. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  90. data/lib/textus/doctor/check/refresh_locks.rb +2 -2
  91. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  92. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  93. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  94. data/lib/textus/doctor/check/schemas.rb +2 -2
  95. data/lib/textus/doctor/check/sentinels.rb +2 -2
  96. data/lib/textus/doctor/check/templates.rb +2 -2
  97. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  98. data/lib/textus/doctor/check.rb +5 -3
  99. data/lib/textus/doctor.rb +24 -27
  100. data/lib/textus/domain/authorizer.rb +4 -4
  101. data/lib/textus/{application → domain}/policy/predicates/accept_authority_signed.rb +2 -2
  102. data/lib/textus/{application → domain}/policy/predicates/schema_valid.rb +1 -1
  103. data/lib/textus/{application → domain}/policy/promotion.rb +1 -1
  104. data/lib/textus/domain/staleness/generator_check.rb +2 -2
  105. data/lib/textus/domain/staleness/intake_check.rb +2 -2
  106. data/lib/textus/domain/staleness.rb +1 -1
  107. data/lib/textus/errors.rb +16 -0
  108. data/lib/textus/hooks/builtin.rb +14 -14
  109. data/lib/textus/hooks/context.rb +13 -13
  110. data/lib/textus/hooks/error_log.rb +32 -0
  111. data/lib/textus/hooks/{bus.rb → event_bus.rb} +41 -53
  112. data/lib/textus/hooks/loader.rb +29 -3
  113. data/lib/textus/hooks/rpc_registry.rb +77 -0
  114. data/lib/textus/infra/audit_log.rb +126 -16
  115. data/lib/textus/infra/audit_subscriber.rb +6 -7
  116. data/lib/textus/infra/refresh/detached.rb +1 -1
  117. data/lib/textus/key/path.rb +7 -3
  118. data/lib/textus/manifest/data.rb +78 -0
  119. data/lib/textus/manifest/entry/base.rb +44 -7
  120. data/lib/textus/manifest/entry/derived.rb +41 -6
  121. data/lib/textus/manifest/entry/intake.rb +15 -3
  122. data/lib/textus/manifest/entry/leaf.rb +6 -5
  123. data/lib/textus/manifest/entry/nested.rb +42 -3
  124. data/lib/textus/manifest/entry/parser.rb +8 -44
  125. data/lib/textus/manifest/entry/validators/events.rb +2 -2
  126. data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -4
  127. data/lib/textus/manifest/entry/validators/index_filename.rb +2 -1
  128. data/lib/textus/manifest/entry/validators/inject_boot.rb +19 -0
  129. data/lib/textus/manifest/entry/validators/publish_each.rb +4 -3
  130. data/lib/textus/manifest/entry/validators.rb +1 -1
  131. data/lib/textus/manifest/entry.rb +3 -0
  132. data/lib/textus/manifest/policy.rb +48 -0
  133. data/lib/textus/manifest/resolver.rb +18 -18
  134. data/lib/textus/manifest/rules.rb +1 -1
  135. data/lib/textus/manifest/schema.rb +20 -6
  136. data/lib/textus/manifest.rb +53 -101
  137. data/lib/textus/mcp/errors.rb +32 -0
  138. data/lib/textus/mcp/server.rb +127 -0
  139. data/lib/textus/mcp/session.rb +31 -0
  140. data/lib/textus/mcp/tool_schemas.rb +71 -0
  141. data/lib/textus/mcp/tools.rb +129 -0
  142. data/lib/textus/mcp.rb +6 -0
  143. data/lib/textus/schema/tools.rb +14 -10
  144. data/lib/textus/session.rb +84 -0
  145. data/lib/textus/store.rb +17 -8
  146. data/lib/textus/version.rb +1 -1
  147. data/lib/textus.rb +8 -1
  148. metadata +65 -38
  149. data/lib/textus/application/reads/audit.rb +0 -69
  150. data/lib/textus/application/reads/blame.rb +0 -82
  151. data/lib/textus/application/reads/deps.rb +0 -26
  152. data/lib/textus/application/reads/freshness.rb +0 -88
  153. data/lib/textus/application/reads/get.rb +0 -67
  154. data/lib/textus/application/reads/get_or_refresh.rb +0 -51
  155. data/lib/textus/application/reads/list.rb +0 -17
  156. data/lib/textus/application/reads/policy_explain.rb +0 -39
  157. data/lib/textus/application/reads/published.rb +0 -17
  158. data/lib/textus/application/reads/rdeps.rb +0 -27
  159. data/lib/textus/application/reads/schema_envelope.rb +0 -18
  160. data/lib/textus/application/reads/stale.rb +0 -15
  161. data/lib/textus/application/reads/uid.rb +0 -23
  162. data/lib/textus/application/reads/validate_all.rb +0 -24
  163. data/lib/textus/application/reads/where.rb +0 -18
  164. data/lib/textus/application/refresh/all.rb +0 -52
  165. data/lib/textus/application/refresh/worker.rb +0 -116
  166. data/lib/textus/application/writes/accept.rb +0 -89
  167. data/lib/textus/application/writes/delete.rb +0 -33
  168. data/lib/textus/application/writes/mv.rb +0 -105
  169. data/lib/textus/application/writes/publish.rb +0 -162
  170. data/lib/textus/application/writes/put.rb +0 -37
  171. data/lib/textus/application/writes/reject.rb +0 -50
  172. data/lib/textus/cli/verb/intro.rb +0 -13
  173. data/lib/textus/infra/event_bus.rb +0 -27
  174. data/lib/textus/manifest/entry/validators/inject_intro.rb +0 -21
  175. data/lib/textus/operations.rb +0 -169
@@ -1,11 +1,18 @@
1
+ require "fileutils"
1
2
  require "json"
2
3
  require "time"
3
4
 
4
5
  module Textus
5
6
  module Infra
6
7
  class AuditLog
7
- def initialize(root)
8
- @path = File.join(root, "audit.log")
8
+ DEFAULT_MAX_SIZE = 10_485_760
9
+ DEFAULT_KEEP = 5
10
+
11
+ def initialize(root, max_size: DEFAULT_MAX_SIZE, keep: DEFAULT_KEEP)
12
+ @root = root
13
+ @path = File.join(root, "audit.log")
14
+ @max_size = max_size
15
+ @keep = keep
9
16
  end
10
17
 
11
18
  def last_writer_for(key)
@@ -23,27 +30,38 @@ module Textus
23
30
  last_role
24
31
  end
25
32
 
26
- def append(role:, verb:, key:, etag_before:, etag_after:, extras: nil)
27
- row = {
28
- "ts" => Time.now.utc.iso8601,
29
- "role" => role,
30
- "verb" => verb,
31
- "key" => key,
32
- "etag_before" => etag_before,
33
- "etag_after" => etag_after,
34
- }
33
+ def latest_seq
34
+ return scan_max_seq(@path) if File.exist?(@path) && File.size(@path).positive?
35
35
 
36
- if extras.is_a?(Hash) && !extras.empty?
37
- extras = extras.dup
38
- %w[from_key to_key uid].each do |k|
39
- row[k] = extras.delete(k) if extras.key?(k)
36
+ # Active log is empty/missing — consult the most recent rotated file's sidecar.
37
+ meta = read_meta(1)
38
+ return meta["max_seq"] if meta
39
+
40
+ 0
41
+ end
42
+
43
+ def min_available_seq
44
+ rotated_metas = (1..@keep).map { |n| read_meta(n) }.compact
45
+ if rotated_metas.any?
46
+ rotated_metas.map { |m| m["min_seq"] }.min
47
+ elsif File.exist?(@path)
48
+ File.foreach(@path) do |line|
49
+ parsed = parse_row(line.chomp)
50
+ return parsed["seq"] if parsed && parsed["seq"]
40
51
  end
41
- row["extras"] = extras unless extras.empty?
52
+ nil
42
53
  end
54
+ end
43
55
 
56
+ def append(role:, verb:, key:, etag_before:, etag_after:, extras: nil)
44
57
  File.open(@path, File::WRONLY | File::APPEND | File::CREAT, 0o644) do |f|
45
58
  f.flock(File::LOCK_EX)
59
+ next_seq = current_max_seq_unlocked + 1
60
+ row = assemble_row(next_seq, { role: role, verb: verb, key: key,
61
+ etag_before: etag_before, etag_after: etag_after }, extras)
46
62
  f.write(JSON.generate(row) + "\n")
63
+ f.flush
64
+ rotate!(f) if f.size > @max_size
47
65
  end
48
66
  end
49
67
 
@@ -63,6 +81,98 @@ module Textus
63
81
 
64
82
  private
65
83
 
84
+ # Caller holds the flock. Returns the highest seq across the active log,
85
+ # OR the most-recent rotated file's max_seq if the active log is empty.
86
+ def current_max_seq_unlocked
87
+ return scan_max_seq(@path) if File.exist?(@path) && File.size(@path).positive?
88
+
89
+ meta = read_meta(1)
90
+ meta ? meta["max_seq"] : 0
91
+ end
92
+
93
+ def scan_max_seq(file)
94
+ last = 0
95
+ File.foreach(file) do |line|
96
+ parsed = parse_row(line.chomp)
97
+ last = parsed["seq"] if parsed && parsed["seq"].is_a?(Integer)
98
+ end
99
+ last
100
+ end
101
+
102
+ def scan_seq_range(file)
103
+ min = nil
104
+ max = 0
105
+ File.foreach(file) do |line|
106
+ parsed = parse_row(line.chomp)
107
+ next unless parsed && parsed["seq"]
108
+
109
+ min ||= parsed["seq"]
110
+ max = parsed["seq"]
111
+ end
112
+ [min, max]
113
+ end
114
+
115
+ def read_meta(n)
116
+ path = File.join(@root, "audit.log.#{n}.meta.json")
117
+ return nil unless File.exist?(path)
118
+
119
+ JSON.parse(File.read(path))
120
+ rescue JSON::ParserError
121
+ nil
122
+ end
123
+
124
+ def assemble_row(seq, fields, extras = nil)
125
+ row = {
126
+ "seq" => seq,
127
+ "ts" => Time.now.utc.iso8601,
128
+ "role" => fields[:role],
129
+ "verb" => fields[:verb],
130
+ "key" => fields[:key],
131
+ "etag_before" => fields[:etag_before],
132
+ "etag_after" => fields[:etag_after],
133
+ }
134
+
135
+ if extras.is_a?(Hash) && !extras.empty?
136
+ extras = extras.dup
137
+ %w[from_key to_key uid].each do |k|
138
+ row[k] = extras.delete(k) if extras.key?(k)
139
+ end
140
+ row["extras"] = extras unless extras.empty?
141
+ end
142
+
143
+ row
144
+ end
145
+
146
+ # Called inside the flock, after a successful write that pushed size over max.
147
+ # Renames audit.log → audit.log.1 (shifting older files), writes sidecar meta.
148
+ def rotate!(open_file)
149
+ open_file.flush
150
+ min_seq, max_seq = scan_seq_range(@path)
151
+ meta = { "min_seq" => min_seq, "max_seq" => max_seq, "rotated_at" => Time.now.utc.iso8601 }
152
+
153
+ # Drop the file that would be shifted past @keep.
154
+ oldest = File.join(@root, "audit.log.#{@keep}")
155
+ oldest_meta = File.join(@root, "audit.log.#{@keep}.meta.json")
156
+ FileUtils.rm_f(oldest)
157
+ FileUtils.rm_f(oldest_meta)
158
+
159
+ # Shift .N → .(N+1) for N = keep-1 down to 1.
160
+ (@keep - 1).downto(1) do |n|
161
+ src = File.join(@root, "audit.log.#{n}")
162
+ dst = File.join(@root, "audit.log.#{n + 1}")
163
+ File.rename(src, dst) if File.exist?(src)
164
+
165
+ src_meta = File.join(@root, "audit.log.#{n}.meta.json")
166
+ dst_meta = File.join(@root, "audit.log.#{n + 1}.meta.json")
167
+ File.rename(src_meta, dst_meta) if File.exist?(src_meta)
168
+ end
169
+
170
+ # Active log → .1
171
+ File.rename(@path, File.join(@root, "audit.log.1"))
172
+ File.write(File.join(@root, "audit.log.1.meta.json"), JSON.generate(meta) + "\n")
173
+ # Next append will create a fresh audit.log via File::CREAT.
174
+ end
175
+
66
176
  def parse_row(line)
67
177
  return nil if line.empty?
68
178
 
@@ -3,18 +3,17 @@
3
3
  module Textus
4
4
  module Infra
5
5
  # Writes an "event_error" audit row when a user hook raises during
6
- # Hooks::Bus publish. Attached at Store boot.
6
+ # Hooks::EventBus publish. Attached at Store boot.
7
7
  #
8
- # Integration: uses Hooks::Bus#on_error callback (chosen over a
8
+ # Integration: uses Hooks::EventBus#on_error callback (chosen over a
9
9
  # synthetic :hook_error event because the bus already owns the
10
10
  # rescue and the failure is a bus-internal concern, not a domain
11
11
  # event subscribers should be able to filter by key glob).
12
12
  #
13
- # NOTE (0.16 scope): lifecycle audit rows for verb: "put" / "delete" /
14
- # "rename" are still written directly by Store::Writer and
15
- # Application::Writes::Mv. Moving those into this subscriber requires
16
- # event payloads to carry etag_before/etag_after across many write paths;
17
- # that is properly a 0.18 port-extraction concern.
13
+ # Lifecycle audit rows for verb: "put" / "delete" / "rename" are written
14
+ # by Application::Envelope::Writer directly (it owns the
15
+ # audit-append-as-final-step invariant); this subscriber covers the
16
+ # hook-failure case the writer never sees.
18
17
  class AuditSubscriber
19
18
  def initialize(audit_log)
20
19
  @audit_log = audit_log
@@ -21,7 +21,7 @@ module Textus
21
21
 
22
22
  begin
23
23
  store = Textus::Store.new(store_root)
24
- Textus::Operations.for(store, role: "runner").refresh(key)
24
+ store.session(role: "runner").refresh(key)
25
25
  rescue StandardError
26
26
  # Already logged via :refresh_failed; exit cleanly.
27
27
  ensure
@@ -4,12 +4,16 @@ module Textus
4
4
  # Returns the absolute filesystem path for a manifest entry (the leaf file,
5
5
  # not a nested directory). Adds the format's primary extension when the
6
6
  # manifest entry's `path:` is extensionless.
7
- def self.resolve(manifest, mentry)
7
+ #
8
+ # The first argument is a Manifest::Data (or anything responding to .root);
9
+ # callers historically passed the whole Manifest but should now pass
10
+ # `manifest.data`.
11
+ def self.resolve(data, mentry)
8
12
  primary_ext = Entry.for_format(mentry.format).extensions.first
9
13
  if File.extname(mentry.path) == ""
10
- File.join(manifest.root, "zones", mentry.path + primary_ext)
14
+ File.join(data.root, "zones", mentry.path + primary_ext)
11
15
  else
12
- File.join(manifest.root, "zones", mentry.path)
16
+ File.join(data.root, "zones", mentry.path)
13
17
  end
14
18
  end
15
19
  end
@@ -0,0 +1,78 @@
1
+ require_relative "schema"
2
+ require_relative "role_kinds"
3
+
4
+ module Textus
5
+ class Manifest
6
+ # Immutable, parsed view of a manifest YAML document.
7
+ #
8
+ # Holds raw structural data (zones, entries, audit_config, role_mapping)
9
+ # but no behaviour beyond accessors. Behaviour (zone authority, key
10
+ # resolution, rules) lives on Manifest::Policy / Resolver / Rules.
11
+ class Data
12
+ AUDIT_DEFAULTS = { max_size: 10_485_760, keep: 5 }.freeze
13
+
14
+ attr_reader :raw, :root, :entries, :zones, :zone_readers, :audit_config, :role_mapping, :policy
15
+
16
+ def self.validate_key!(key)
17
+ raise UsageError.new("empty key") if key.nil? || key.empty?
18
+
19
+ Key::Grammar.validate!(key)
20
+ end
21
+
22
+ # Forwarder used by Resolver and Entry classes that received a Data
23
+ # but were written against the historical Manifest API.
24
+ def validate_key!(key) = self.class.validate_key!(key)
25
+
26
+ def self.parse(raw, root:)
27
+ raise BadFrontmatter.new(File.join(root.to_s, "manifest.yaml"), "manifest must declare zones:") if Array(raw["zones"]).empty?
28
+
29
+ Schema.validate!(raw)
30
+ new(raw: raw, root: root)
31
+ end
32
+
33
+ def initialize(raw:, root:)
34
+ @raw = raw
35
+ @root = root
36
+ @zones = Array(raw["zones"]).to_h { |z| [z["name"], Array(z["write_policy"])] }
37
+ @zone_readers = Array(raw["zones"]).to_h do |z|
38
+ rp = z["read_policy"]
39
+ [z["name"], rp.nil? ? :all : Array(rp)]
40
+ end
41
+ @audit_config = build_audit_config(raw)
42
+ @role_mapping = RoleKinds.resolve(raw["roles"])
43
+ # Policy is constructed before entries because Entry validators
44
+ # call `entry.in_generator_zone?` which routes through Policy.
45
+ @policy = Policy.new(self)
46
+ @entries = build_entries(raw)
47
+ validate_declared_keys!
48
+ freeze
49
+ end
50
+
51
+ private
52
+
53
+ def build_audit_config(raw)
54
+ a = raw["audit"] || {}
55
+ {
56
+ max_size: a["max_size"] || AUDIT_DEFAULTS[:max_size],
57
+ keep: a["keep"] || AUDIT_DEFAULTS[:keep],
58
+ }.freeze
59
+ end
60
+
61
+ def build_entries(raw)
62
+ Array(raw["entries"]).map do |e|
63
+ entry = Manifest::Entry::Parser.call(self, e)
64
+ Manifest::Entry::Validators.run_all(entry)
65
+ entry
66
+ end.freeze
67
+ end
68
+
69
+ def validate_declared_keys!
70
+ @entries.each do |e|
71
+ raise UsageError.new("empty key") if e.key.nil? || e.key.empty?
72
+
73
+ Key::Grammar.validate!(e.key)
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -2,10 +2,10 @@ module Textus
2
2
  class Manifest
3
3
  class Entry
4
4
  class Base < Entry
5
- attr_reader :raw, :key, :path, :zone, :schema, :owner, :format, :manifest
5
+ attr_reader :raw, :key, :path, :zone, :schema, :owner, :format, :manifest, :publish_to
6
6
 
7
7
  # rubocop:disable Metrics/ParameterLists, Lint/MissingSuper
8
- def initialize(manifest:, raw:, key:, path:, zone:, schema:, owner:, format:)
8
+ def initialize(manifest:, raw:, key:, path:, zone:, schema:, owner:, format:, publish_to: [])
9
9
  @manifest = manifest
10
10
  @raw = raw
11
11
  @key = key
@@ -14,24 +14,61 @@ module Textus
14
14
  @schema = schema
15
15
  @owner = owner
16
16
  @format = format
17
+ @publish_to = Array(publish_to)
17
18
  end
18
19
  # rubocop:enable Metrics/ParameterLists, Lint/MissingSuper
19
20
 
20
- def kind = self.class.name.split("::").last.downcase.to_sym
21
-
22
21
  def zone_writers
23
- @manifest.zone_writers(@zone)
22
+ @manifest.policy.zone_writers(@zone)
24
23
  rescue UsageError => e
25
24
  raise UsageError.new("entry '#{@key}': #{e.message}")
26
25
  end
27
26
 
28
- def in_generator_zone? = @manifest.zone_kinds(@zone).include?(:generator)
29
- def in_proposal_zone? = @manifest.zone_kinds(@zone).include?(:proposer)
27
+ def in_generator_zone? = @manifest.policy.zone_kinds(@zone).include?(:generator)
28
+ def in_proposal_zone? = @manifest.policy.zone_kinds(@zone).include?(:proposer)
30
29
 
31
30
  def nested? = false
32
31
  def derived? = false
33
32
  def intake? = false
34
33
  def leaf? = false
34
+
35
+ # Nil stubs for cross-cutting optional attrs. Subclasses override the
36
+ # ones they own. Validators and serializers can call these directly
37
+ # without `respond_to?` guards.
38
+ def template = nil
39
+ def inject_boot = false # rubocop:disable Naming/PredicateMethod
40
+ def events = {}
41
+ def publish_each = nil
42
+ def index_filename = nil
43
+
44
+ PublishContext = Struct.new(
45
+ :repo_root, :manifest, :file_store, :root, :caps, :rpc, :session, :ctx, :bus, :hook_context,
46
+ :reader, :emit, # callables: reader.call(key) → envelope; emit.call(event, **payload)
47
+ keyword_init: true
48
+ )
49
+
50
+ # Subclasses override to customize publish behavior.
51
+ # Default: copy the stored file to each publish_to target.
52
+ # Returns: { kind: :built|:leaves, value: ... } to be accumulated by
53
+ # Publish#call, or nil to skip.
54
+ def publish_via(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
55
+ return nil if Array(publish_to).empty?
56
+
57
+ source_path = pctx.manifest.resolver.resolve(@key).path
58
+ envelope = pctx.reader.call(@key)
59
+
60
+ publish_to.each do |rel|
61
+ target_abs = File.join(pctx.repo_root, rel)
62
+ Textus::Infra::Publisher.publish(source: source_path, target: target_abs, store_root: pctx.root)
63
+ pctx.emit.call(:file_published,
64
+ key: @key,
65
+ envelope: envelope,
66
+ source: source_path,
67
+ target: target_abs)
68
+ end
69
+
70
+ { kind: :built, value: { "key" => @key, "path" => source_path, "published_to" => publish_to } }
71
+ end
35
72
  end
36
73
  end
37
74
  end
@@ -2,23 +2,58 @@ module Textus
2
2
  class Manifest
3
3
  class Entry
4
4
  class Derived < Base
5
- Projection = Data.define(:select, :pluck, :sort_by, :transform)
6
- External = Data.define(:sources, :runner)
5
+ Projection = ::Data.define(:select, :pluck, :sort_by, :transform)
6
+ External = ::Data.define(:sources, :runner)
7
7
 
8
- attr_reader :source, :template, :inject_intro, :publish_to, :events
8
+ attr_reader :source, :template, :inject_boot, :events
9
9
 
10
- def initialize(source:, template: nil, inject_intro: false, publish_to: [], events: {}, **rest)
10
+ def initialize(source:, template: nil, inject_boot: false, events: {}, **rest)
11
11
  super(**rest)
12
12
  @source = source
13
13
  @template = template
14
- @inject_intro = inject_intro
15
- @publish_to = Array(publish_to)
14
+ @inject_boot = inject_boot
16
15
  @events = events || {}
17
16
  end
18
17
 
19
18
  def derived? = true
20
19
  def projection? = @source.is_a?(Projection)
21
20
  def external? = @source.is_a?(External)
21
+
22
+ def publish_via(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
23
+ return nil unless in_generator_zone?
24
+
25
+ target_path = Textus::Application::Write::Materializer.new(
26
+ ctx: pctx.ctx, caps: pctx.caps, rpc: pctx.rpc, session: pctx.session,
27
+ ).run(self)
28
+
29
+ envelope = pctx.reader.call(@key)
30
+ Array(publish_to).each do |rel|
31
+ target_abs = File.join(pctx.repo_root, rel)
32
+ Textus::Infra::Publisher.publish(source: target_path, target: target_abs, store_root: pctx.root)
33
+ pctx.emit.call(:file_published, key: @key, envelope: envelope, source: target_path, target: target_abs)
34
+ end
35
+
36
+ src = @source
37
+ selects = src.is_a?(Projection) ? Array(src.select).compact : []
38
+ pctx.emit.call(:build_completed, key: @key, envelope: envelope, sources: selects)
39
+
40
+ { kind: :built, value: { "key" => @key, "path" => target_path, "published_to" => publish_to } }
41
+ end
42
+
43
+ KIND = :derived
44
+
45
+ def self.from_raw(common, raw)
46
+ source = Parser.parse_source(raw, common[:key])
47
+ new(
48
+ source: source,
49
+ template: raw["template"],
50
+ inject_boot: raw["inject_boot"] == true,
51
+ events: raw["events"] || {},
52
+ **common,
53
+ )
54
+ end
55
+
56
+ Entry::REGISTRY[KIND] = self
22
57
  end
23
58
  end
24
59
  end
@@ -2,17 +2,29 @@ module Textus
2
2
  class Manifest
3
3
  class Entry
4
4
  class Intake < Base
5
- attr_reader :handler, :config, :events, :publish_to
5
+ attr_reader :handler, :config, :events
6
6
 
7
7
  def initialize(handler:, config: {}, events: {}, **rest)
8
8
  super(**rest)
9
9
  @handler = handler
10
10
  @config = config || {}
11
11
  @events = events || {}
12
- @publish_to = []
13
12
  end
14
13
 
15
- def intake? = true
14
+ def intake? = true
15
+ def nested? = !!@raw["nested"]
16
+
17
+ KIND = :intake
18
+
19
+ def self.from_raw(common, raw)
20
+ intake = raw["intake"] || {}
21
+ handler = intake["handler"] || raw["intake_handler"] or
22
+ raise UsageError.new("intake entry '#{common[:key]}' missing handler")
23
+ config = intake["config"] || raw["intake_config"] || {}
24
+ new(handler: handler, config: config, events: raw["events"] || {}, **common)
25
+ end
26
+
27
+ Entry::REGISTRY[KIND] = self
16
28
  end
17
29
  end
18
30
  end
@@ -2,14 +2,15 @@ module Textus
2
2
  class Manifest
3
3
  class Entry
4
4
  class Leaf < Base
5
- attr_reader :publish_to
5
+ KIND = :leaf
6
6
 
7
- def initialize(publish_to: [], **rest)
8
- super(**rest)
9
- @publish_to = Array(publish_to)
7
+ def leaf? = true
8
+
9
+ def self.from_raw(common, _raw)
10
+ new(**common)
10
11
  end
11
12
 
12
- def leaf? = true
13
+ Entry::REGISTRY[KIND] = self
13
14
  end
14
15
  end
15
16
  end
@@ -7,13 +7,12 @@ module Textus
7
7
  PUBLISH_EACH_VARS = Validators::PublishEach::KNOWN_VARS
8
8
  PUBLISH_EACH_VAR_RE = Validators::PublishEach::VAR_RE
9
9
 
10
- attr_reader :index_filename, :publish_each, :publish_to
10
+ attr_reader :index_filename, :publish_each
11
11
 
12
- def initialize(index_filename: nil, publish_each: nil, publish_to: [], **rest)
12
+ def initialize(index_filename: nil, publish_each: nil, **rest)
13
13
  super(**rest)
14
14
  @index_filename = index_filename
15
15
  @publish_each = publish_each
16
- @publish_to = Array(publish_to)
17
16
  end
18
17
 
19
18
  def nested? = true
@@ -33,6 +32,46 @@ module Textus
33
32
  vars = { "leaf" => leaf, "basename" => basename, "key" => full_key, "ext" => ext }
34
33
  @publish_each.gsub(PUBLISH_EACH_VAR_RE) { vars.fetch(::Regexp.last_match(1)) }
35
34
  end
35
+
36
+ def publish_via(pctx, prefix: nil)
37
+ return nil if @publish_each.nil?
38
+
39
+ leaves = []
40
+ @manifest.resolver.enumerate(prefix: @key).each do |row|
41
+ next unless row[:manifest_entry].equal?(self)
42
+ next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
43
+
44
+ target_rel = publish_target_for(row[:key])
45
+ target_abs = File.expand_path(File.join(pctx.repo_root, target_rel))
46
+ unless target_abs.start_with?(File.expand_path(pctx.repo_root) + File::SEPARATOR)
47
+ raise Textus::PublishError.new(
48
+ "entry '#{@key}': publish_each target '#{target_rel}' for key '#{row[:key]}' escapes repo root",
49
+ )
50
+ end
51
+
52
+ Textus::Infra::Publisher.publish(source: row[:path], target: target_abs, store_root: pctx.root)
53
+ pctx.emit.call(:file_published,
54
+ key: row[:key],
55
+ envelope: pctx.reader.call(row[:key]),
56
+ source: row[:path],
57
+ target: target_abs)
58
+ leaves << { "key" => row[:key], "source" => row[:path], "target" => target_abs }
59
+ end
60
+
61
+ { kind: :leaves, value: leaves }
62
+ end
63
+
64
+ KIND = :nested
65
+
66
+ def self.from_raw(common, raw)
67
+ new(
68
+ index_filename: raw["index_filename"],
69
+ publish_each: raw["publish_each"],
70
+ **common,
71
+ )
72
+ end
73
+
74
+ Entry::REGISTRY[KIND] = self
36
75
  end
37
76
  end
38
77
  end
@@ -9,7 +9,7 @@ module Textus
9
9
  path = raw["path"] or raise UsageError.new("manifest entry '#{key}' missing path")
10
10
  zone = raw["zone"] or raise UsageError.new("manifest entry '#{key}' missing zone")
11
11
 
12
- raw_kind = raw["kind"] or raise BadManifest.new("entry '#{key}' missing required `kind:` (leaf|nested|derived|intake)")
12
+ raw_kind = raw["kind"] or raise BadManifest.new("entry '#{key}' missing required `kind:` (#{Entry::REGISTRY.keys.join("|")})")
13
13
  kind = raw_kind.to_sym
14
14
  format = resolve_format(raw, path)
15
15
 
@@ -17,49 +17,13 @@ module Textus
17
17
  manifest: manifest, raw: raw,
18
18
  key: key, path: path, zone: zone,
19
19
  schema: raw["schema"], owner: raw["owner"],
20
- format: format
20
+ format: format,
21
+ publish_to: raw["publish_to"]
21
22
  }
22
23
 
23
- case kind
24
- when :leaf then build_leaf(common, raw)
25
- when :nested then build_nested(common, raw)
26
- when :derived then build_derived(common, raw, key)
27
- when :intake then build_intake(common, raw, key)
28
- else raise BadManifest.new("entry '#{key}': unknown kind: #{kind.inspect}")
29
- end
30
- end
31
-
32
- def self.build_leaf(common, raw)
33
- Leaf.new(publish_to: raw["publish_to"], **common)
34
- end
35
-
36
- def self.build_nested(common, raw)
37
- Nested.new(
38
- index_filename: raw["index_filename"],
39
- publish_each: raw["publish_each"],
40
- publish_to: raw["publish_to"],
41
- **common,
42
- )
43
- end
44
-
45
- def self.build_derived(common, raw, key)
46
- source = parse_source(raw, key)
47
- Derived.new(
48
- source: source,
49
- template: raw["template"],
50
- inject_intro: raw["inject_intro"] == true,
51
- publish_to: raw["publish_to"],
52
- events: raw["events"] || {},
53
- **common,
54
- )
55
- end
56
-
57
- def self.build_intake(common, raw, key)
58
- intake = raw["intake"] || {}
59
- handler = intake["handler"] || raw["intake_handler"] or
60
- raise UsageError.new("intake entry '#{key}' missing handler")
61
- config = intake["config"] || raw["intake_config"] || {}
62
- Intake.new(handler: handler, config: config, events: raw["events"] || {}, **common)
24
+ klass = Entry::REGISTRY[kind] or
25
+ raise BadManifest.new("entry '#{key}': unknown kind: #{kind.inspect} (known: #{Entry::REGISTRY.keys.join(", ")})")
26
+ klass.from_raw(common, raw)
63
27
  end
64
28
 
65
29
  def self.parse_source(raw, key)
@@ -73,14 +37,14 @@ module Textus
73
37
  end
74
38
 
75
39
  if compute["kind"] == "projection"
76
- Derived::Projection.new(
40
+ Entry::Derived::Projection.new(
77
41
  select: compute["select"],
78
42
  pluck: compute["pluck"],
79
43
  sort_by: compute["sort_by"],
80
44
  transform: compute["transform"],
81
45
  )
82
46
  else
83
- Derived::External.new(sources: compute["sources"], runner: compute["runner"])
47
+ Entry::Derived::External.new(sources: compute["sources"], runner: compute["runner"])
84
48
  end
85
49
  end
86
50
 
@@ -4,8 +4,8 @@ module Textus
4
4
  module Validators
5
5
  module Events
6
6
  def self.call(entry)
7
- pubsub_events = Textus::Hooks::Bus::EVENTS.select { |_, s| s[:mode] == :pubsub }.keys
8
- events = entry.respond_to?(:events) ? entry.events : {}
7
+ pubsub_events = Textus::Hooks::EventBus::EVENTS.keys
8
+ events = entry.events
9
9
  events.each_key do |evt|
10
10
  next if pubsub_events.include?(evt.to_sym)
11
11