textus 0.4.0 → 0.8.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +147 -2
  3. data/README.md +38 -28
  4. data/SPEC.md +84 -147
  5. data/docs/architecture.md +82 -28
  6. data/lib/textus/builder/pipeline.rb +56 -0
  7. data/lib/textus/builder/renderer/json.rb +42 -0
  8. data/lib/textus/builder/renderer/markdown.rb +22 -0
  9. data/lib/textus/builder/renderer/text.rb +14 -0
  10. data/lib/textus/builder/renderer/yaml.rb +42 -0
  11. data/lib/textus/builder/renderer.rb +17 -0
  12. data/lib/textus/builder.rb +9 -114
  13. data/lib/textus/cli/group/hook.rb +11 -0
  14. data/lib/textus/cli/group/key.rb +12 -0
  15. data/lib/textus/cli/group/schema.rb +13 -0
  16. data/lib/textus/cli/group.rb +51 -0
  17. data/lib/textus/cli/verb/accept.rb +15 -0
  18. data/lib/textus/cli/verb/build.rb +13 -0
  19. data/lib/textus/cli/verb/delete.rb +16 -0
  20. data/lib/textus/cli/verb/deps.rb +12 -0
  21. data/lib/textus/cli/verb/doctor.rb +15 -0
  22. data/lib/textus/cli/verb/get.rb +12 -0
  23. data/lib/textus/cli/verb/hook_run.rb +48 -0
  24. data/lib/textus/cli/verb/hooks.rb +50 -0
  25. data/lib/textus/cli/verb/init.rb +14 -0
  26. data/lib/textus/cli/verb/intro.rb +11 -0
  27. data/lib/textus/cli/verb/list.rb +14 -0
  28. data/lib/textus/cli/verb/migrate_keys.rb +16 -0
  29. data/lib/textus/cli/verb/mv.rb +17 -0
  30. data/lib/textus/cli/verb/published.rb +11 -0
  31. data/lib/textus/cli/verb/put.rb +50 -0
  32. data/lib/textus/cli/verb/rdeps.rb +12 -0
  33. data/lib/textus/cli/verb/refresh.rb +15 -0
  34. data/lib/textus/cli/verb/schema.rb +12 -0
  35. data/lib/textus/cli/verb/schema_diff.rb +12 -0
  36. data/lib/textus/cli/verb/schema_init.rb +16 -0
  37. data/lib/textus/cli/verb/schema_migrate.rb +16 -0
  38. data/lib/textus/cli/verb/stale.rb +14 -0
  39. data/lib/textus/cli/verb/uid.rb +12 -0
  40. data/lib/textus/cli/verb/where.rb +12 -0
  41. data/lib/textus/cli/verb.rb +62 -0
  42. data/lib/textus/cli.rb +44 -385
  43. data/lib/textus/doctor/check/audit_log.rb +50 -0
  44. data/lib/textus/doctor/check/hooks.rb +29 -0
  45. data/lib/textus/doctor/check/illegal_keys.rb +49 -0
  46. data/lib/textus/doctor/check/manifest_files.rb +38 -0
  47. data/lib/textus/doctor/check/schema_violations.rb +22 -0
  48. data/lib/textus/doctor/check/schemas.rb +26 -0
  49. data/lib/textus/doctor/check/sentinels.rb +57 -0
  50. data/lib/textus/doctor/check/templates.rb +26 -0
  51. data/lib/textus/doctor/check/unowned_schema_fields.rb +34 -0
  52. data/lib/textus/doctor/check.rb +30 -0
  53. data/lib/textus/doctor.rb +29 -264
  54. data/lib/textus/entry/base.rb +30 -0
  55. data/lib/textus/entry/json.rb +11 -5
  56. data/lib/textus/entry/markdown.rb +5 -5
  57. data/lib/textus/entry/text.rb +4 -4
  58. data/lib/textus/entry/yaml.rb +11 -5
  59. data/lib/textus/entry.rb +2 -7
  60. data/lib/textus/envelope.rb +30 -0
  61. data/lib/textus/errors.rb +2 -2
  62. data/lib/textus/hooks/builtin.rb +70 -0
  63. data/lib/textus/hooks/dispatcher.rb +49 -0
  64. data/lib/textus/hooks/loader.rb +26 -0
  65. data/lib/textus/hooks/registry.rb +73 -0
  66. data/lib/textus/init.rb +14 -11
  67. data/lib/textus/intro.rb +16 -18
  68. data/lib/textus/key/distance.rb +55 -0
  69. data/lib/textus/key/grammar.rb +33 -0
  70. data/lib/textus/key/path.rb +17 -0
  71. data/lib/textus/manifest/entry.rb +199 -0
  72. data/lib/textus/manifest.rb +20 -254
  73. data/lib/textus/migrate_keys.rb +1 -1
  74. data/lib/textus/projection.rb +6 -5
  75. data/lib/textus/proposal.rb +4 -4
  76. data/lib/textus/refresh.rb +17 -17
  77. data/lib/textus/schema/tools.rb +89 -0
  78. data/lib/textus/store/audit_log.rb +71 -0
  79. data/lib/textus/store/mover.rb +121 -0
  80. data/lib/textus/store/reader.rb +67 -0
  81. data/lib/textus/store/staleness.rb +133 -0
  82. data/lib/textus/store/validator.rb +56 -0
  83. data/lib/textus/store/view.rb +29 -0
  84. data/lib/textus/store/writer.rb +132 -0
  85. data/lib/textus/store.rb +26 -527
  86. data/lib/textus/version.rb +2 -2
  87. data/lib/textus.rb +14 -29
  88. metadata +78 -8
  89. data/lib/textus/audit_log.rb +0 -32
  90. data/lib/textus/builtin_actions.rb +0 -68
  91. data/lib/textus/extension_registry.rb +0 -61
  92. data/lib/textus/extensions.rb +0 -33
  93. data/lib/textus/key_distance.rb +0 -53
  94. data/lib/textus/schema_tools.rb +0 -87
  95. data/lib/textus/store_view.rb +0 -27
@@ -0,0 +1,199 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ PUBLISH_EACH_VARS = %w[leaf basename key ext].freeze
5
+ PUBLISH_EACH_VAR_RE = /\{([a-z]+)\}/
6
+
7
+ attr_reader :key, :path, :zone, :schema, :owner, :nested, :generator, :raw, :format,
8
+ :projection, :template, :publish_to, :publish_each, :fetch, :fetch_config, :ttl, :events,
9
+ :inject_intro
10
+
11
+ def initialize(manifest, raw)
12
+ @manifest = manifest
13
+ @raw = raw
14
+ @key = raw["key"] or raise UsageError.new("manifest entry missing key")
15
+ @path = raw["path"] or raise UsageError.new("manifest entry '#{@key}' missing path")
16
+ @zone = raw["zone"] or raise UsageError.new("manifest entry '#{@key}' missing zone")
17
+ @schema = raw["schema"]
18
+ @owner = raw["owner"]
19
+ @nested = raw["nested"] == true
20
+ @generator = raw["generator"]
21
+ @projection = raw["projection"]
22
+ @template = raw["template"]
23
+ @publish_to = Array(raw["publish_to"])
24
+ @publish_each = raw["publish_each"]
25
+ @events = raw["events"] || {}
26
+ @inject_intro = raw["inject_intro"] == true
27
+ @format = resolve_format!(raw["format"])
28
+
29
+ validate_events!
30
+ parse_source!(raw["source"])
31
+ reject_legacy_projection_keys!
32
+ validate_format_matrix!
33
+ validate_publish_each!
34
+ validate_inject_intro!
35
+ end
36
+
37
+ # Resolves the per-leaf target path (relative to repo root) for a full
38
+ # dotted key under this entry's prefix. Returns nil if this entry has no
39
+ # publish_each template.
40
+ def publish_target_for(full_key)
41
+ return nil if @publish_each.nil?
42
+
43
+ entry_segs = @key.split(".")
44
+ key_segs = full_key.split(".")
45
+ raise UsageError.new("key '#{full_key}' is not under entry '#{@key}'") unless key_segs[0, entry_segs.length] == entry_segs
46
+
47
+ remaining = key_segs[entry_segs.length..] || []
48
+ leaf = remaining.join("/")
49
+ basename = remaining.last || ""
50
+ ext = Textus::Entry.for_format(@format).extensions.first.to_s.sub(/^\./, "")
51
+
52
+ vars = { "leaf" => leaf, "basename" => basename, "key" => full_key, "ext" => ext }
53
+ @publish_each.gsub(PUBLISH_EACH_VAR_RE) { vars.fetch(::Regexp.last_match(1)) }
54
+ end
55
+
56
+ def derived?
57
+ writers = @manifest.zone_writers(@zone)
58
+ writers.include?("build")
59
+ rescue UsageError => e
60
+ raise UsageError.new("entry '#{@key}': #{e.message}")
61
+ end
62
+
63
+ private
64
+
65
+ def validate_inject_intro!
66
+ return unless @inject_intro
67
+
68
+ unless derived?
69
+ raise UsageError.new(
70
+ "entry '#{@key}': inject_intro: is only valid on derived entries",
71
+ )
72
+ end
73
+ return unless @template.nil?
74
+
75
+ raise UsageError.new(
76
+ "entry '#{@key}': inject_intro: requires a template:",
77
+ )
78
+ end
79
+
80
+ def validate_publish_each!
81
+ return if @publish_each.nil?
82
+
83
+ raise UsageError.new("entry '#{@key}': publish_each requires nested: true") unless @nested
84
+ raise UsageError.new("entry '#{@key}': publish_to and publish_each are mutually exclusive") unless @publish_to.empty?
85
+ raise UsageError.new("entry '#{@key}': publish_each must be a string") unless @publish_each.is_a?(String)
86
+
87
+ used_vars = @publish_each.scan(PUBLISH_EACH_VAR_RE).flatten
88
+ unknown = used_vars - PUBLISH_EACH_VARS
89
+ unless unknown.empty?
90
+ raise UsageError.new(
91
+ "entry '#{@key}': publish_each uses unknown template variable(s) " \
92
+ "#{unknown.map { |v| "{#{v}}" }.join(", ")}. Known: #{PUBLISH_EACH_VARS.map { |v| "{#{v}}" }.join(", ")}.",
93
+ )
94
+ end
95
+
96
+ required = %w[leaf basename key]
97
+ return if used_vars.any? { |v| required.include?(v) }
98
+
99
+ raise UsageError.new(
100
+ "entry '#{@key}': publish_each must reference at least one of {leaf}, {basename}, or {key} " \
101
+ "(else every leaf would clobber the same target).",
102
+ )
103
+ end
104
+
105
+ def resolve_format!(declared)
106
+ ext = File.extname(@path)
107
+ inferred = Manifest::EXT_TO_FORMAT[ext]
108
+
109
+ if declared.nil?
110
+ return inferred if inferred
111
+ # No extension: nested defaults to markdown, leaf with no ext also markdown.
112
+ return "markdown" if ext == "" && @nested
113
+ return "markdown" if ext == ""
114
+ else
115
+ unless Manifest::EXT_TO_FORMAT.values.include?(declared)
116
+ raise UsageError.new("entry '#{@key}': unknown format #{declared.inspect}")
117
+ end
118
+ # If the path has an extension, the declared format must match.
119
+ if ext != "" && inferred && inferred != declared
120
+ raise UsageError.new(
121
+ "entry '#{@key}': path extension #{ext.inspect} does not match declared format #{declared.inspect}",
122
+ )
123
+ end
124
+ return declared
125
+ end
126
+
127
+ "markdown"
128
+ end
129
+
130
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
131
+ def validate_format_matrix!
132
+ ext = File.extname(@path)
133
+
134
+ case @format
135
+ when "markdown"
136
+ # .md, or no extension (will be appended). Anything else is a mismatch caught above.
137
+ raise UsageError.new("entry '#{@key}': markdown format requires '.md' path (got #{ext.inspect})") if ext != "" && ext != ".md"
138
+ when "json"
139
+ if @nested
140
+ # nested json: path is a directory; ext must be empty.
141
+ raise UsageError.new("entry '#{@key}': nested json path must not have an extension") if ext != ""
142
+ elsif ext != ".json"
143
+ raise UsageError.new("entry '#{@key}': json format requires '.json' path (got #{ext.inspect})")
144
+ end
145
+ when "yaml"
146
+ if @nested
147
+ raise UsageError.new("entry '#{@key}': nested yaml path must not have an extension") if ext != ""
148
+ elsif ext != ".yaml" && ext != ".yml"
149
+ raise UsageError.new("entry '#{@key}': yaml format requires '.yaml' or '.yml' path (got #{ext.inspect})")
150
+ end
151
+ when "text"
152
+ if @nested
153
+ raise UsageError.new("entry '#{@key}': nested text path must not have an extension") if ext != ""
154
+ elsif ext != ".txt" && ext != ""
155
+ raise UsageError.new("entry '#{@key}': text format requires '.txt' or no extension (got #{ext.inspect})")
156
+ end
157
+ end
158
+
159
+ # Schema rules.
160
+ raise UsageError.new("entry '#{@key}': text format must not declare a schema") if @format == "text" && !@schema.nil?
161
+
162
+ # Template-required-for-derived rules. Skipped for entries materialized by an
163
+ # external generator: command (those produce the bytes themselves).
164
+ if derived? && @template.nil? && @generator.nil? &&
165
+ (@format == "markdown" || @format == "text") && !@nested
166
+ raise UsageError.new("entry '#{@key}': derived #{@format} entries require a template")
167
+ end
168
+ end
169
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
170
+
171
+ def parse_source!(src)
172
+ src ||= {}
173
+ raise UsageError.new("entry '#{@key}': source.action renamed to source.fetch in 0.6") if src.key?("action")
174
+
175
+ @fetch = src["fetch"]
176
+ @fetch_config = src["config"] || {}
177
+ @ttl = src["ttl"]
178
+ end
179
+
180
+ def reject_legacy_projection_keys!
181
+ return unless @projection.is_a?(Hash) && @projection.key?("reducer")
182
+
183
+ raise UsageError.new("entry '#{@key}': projection.reducer renamed to projection.reduce in 0.6")
184
+ end
185
+
186
+ def validate_events!
187
+ pubsub_events = Hooks::Registry::EVENTS.select { |_, s| s[:mode] == :pubsub }.keys
188
+ @events.each_key do |evt|
189
+ next if pubsub_events.include?(evt.to_sym)
190
+
191
+ raise UsageError.new(
192
+ "entry '#{@key}': unknown event '#{evt}' in events: block. " \
193
+ "Known events: #{pubsub_events.join(", ")}.",
194
+ )
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
@@ -2,11 +2,6 @@ require "yaml"
2
2
 
3
3
  module Textus
4
4
  class Manifest
5
- # New stricter grammar: lowercase + digits + internal hyphens. No underscores.
6
- KEY_SEGMENT = /\A[a-z0-9][a-z0-9-]*\z/
7
- MAX_SEGMENTS = 8
8
- MAX_SEGMENT_LEN = 64
9
-
10
5
  EXT_TO_FORMAT = {
11
6
  ".md" => "markdown",
12
7
  ".json" => "json",
@@ -17,23 +12,8 @@ module Textus
17
12
 
18
13
  attr_reader :root, :entries, :raw
19
14
 
20
- LEGACY_ZONES = {
21
- "fixed" => ["human"],
22
- "state" => %w[human ai script],
23
- "derived" => ["build"],
24
- }.freeze
25
-
26
15
  def zones
27
- @zones ||= begin
28
- declared = Array(@raw["zones"])
29
- if declared.empty?
30
- LEGACY_ZONES.transform_values(&:dup)
31
- else
32
- declared.to_h do |z|
33
- [z["name"], Array(z["writable_by"])]
34
- end
35
- end
36
- end
16
+ @zones ||= Array(@raw["zones"]).to_h { |z| [z["name"], Array(z["writable_by"])] }
37
17
  end
38
18
 
39
19
  def zone_writers(zone_name)
@@ -45,7 +25,14 @@ module Textus
45
25
  raise IoError.new("manifest not found: #{manifest_path}") unless File.exist?(manifest_path)
46
26
 
47
27
  raw = YAML.safe_load_file(manifest_path, aliases: false)
48
- raise BadFrontmatter.new(manifest_path, "unsupported manifest version #{raw["version"].inspect}") unless raw["version"] == PROTOCOL
28
+ unless raw["version"] == PROTOCOL
29
+ msg = if raw["version"] == "textus/1"
30
+ "manifest is textus/1; edit manifest.yaml: change 'version: textus/1' to 'version: #{PROTOCOL}'"
31
+ else
32
+ "unsupported manifest version #{raw["version"].inspect}"
33
+ end
34
+ raise BadFrontmatter.new(manifest_path, msg)
35
+ end
49
36
 
50
37
  new(root, raw)
51
38
  end
@@ -53,11 +40,13 @@ module Textus
53
40
  def initialize(root, raw)
54
41
  @root = root
55
42
  @raw = raw
56
- @entries = Array(raw["entries"]).map { |e| ManifestEntry.new(self, e) }
43
+ raise BadFrontmatter.new(File.join(root, "manifest.yaml"), "manifest must declare zones:") if Array(raw["zones"]).empty?
44
+
45
+ @entries = Array(raw["entries"]).map { |e| Manifest::Entry.new(self, e) }
57
46
  validate_declared_keys!
58
47
  end
59
48
 
60
- # Returns [ManifestEntry, resolved_path, remaining_segments]
49
+ # Returns [Manifest::Entry, resolved_path, remaining_segments]
61
50
  def resolve(key)
62
51
  validate_key!(key)
63
52
  segments = key.split(".")
@@ -76,7 +65,7 @@ module Textus
76
65
  else
77
66
  raise UnknownKey.new(key, suggestions: suggestions_for(key)) unless entry.nested
78
67
 
79
- primary_ext = Entry.for_format(entry.format).extensions.first
68
+ primary_ext = Textus::Entry.for_format(entry.format).extensions.first
80
69
  path = File.join(@root, "zones", entry.path, *remaining) + primary_ext
81
70
  [entry, path, remaining]
82
71
  end
@@ -89,7 +78,7 @@ module Textus
89
78
  # Include declared (non-nested) entry keys even if file is missing.
90
79
  candidates.concat(@entries.reject(&:nested).map(&:key))
91
80
  candidates.uniq!
92
- KeyDistance.suggest(key, candidates, limit: 5)
81
+ Key::Distance.suggest(key, candidates, limit: 5)
93
82
  rescue StandardError
94
83
  []
95
84
  end
@@ -113,7 +102,7 @@ module Textus
113
102
 
114
103
  illegal = segs.find { |s| !valid_segment?(s) }
115
104
  if illegal
116
- warn("textus: skipping illegal key segment '#{illegal}' at #{fp} — run 'textus migrate-keys --dry-run'")
105
+ warn("textus: skipping illegal key segment '#{illegal}' at #{fp} — run 'textus key migrate --dry-run'")
117
106
  next
118
107
  end
119
108
 
@@ -144,30 +133,16 @@ module Textus
144
133
  def validate_key!(key)
145
134
  raise UsageError.new("empty key") if key.nil? || key.empty?
146
135
 
147
- segs = key.split(".")
148
- raise UsageError.new("key '#{key}' has #{segs.length} segments (max #{MAX_SEGMENTS})") if segs.length > MAX_SEGMENTS
149
-
150
- segs.each do |seg|
151
- if seg.empty?
152
- raise UsageError.new("empty segment in key '#{key}'")
153
- elsif seg.length > MAX_SEGMENT_LEN
154
- raise UsageError.new("segment '#{seg}' in key '#{key}' exceeds #{MAX_SEGMENT_LEN} chars")
155
- elsif !seg.match?(KEY_SEGMENT)
156
- raise UsageError.new(
157
- "invalid key segment '#{seg}' in '#{key}': must match [a-z0-9][a-z0-9-]* " \
158
- "(lowercase, digits, hyphens; no underscores or uppercase)",
159
- )
160
- end
161
- end
136
+ Key::Grammar.validate!(key)
162
137
  end
163
138
 
164
139
  private
165
140
 
166
141
  def valid_segment?(seg)
167
142
  return false if seg.nil? || seg.empty?
168
- return false if seg.length > MAX_SEGMENT_LEN
143
+ return false if seg.length > Key::Grammar::MAX_SEGMENT_LEN
169
144
 
170
- seg.match?(KEY_SEGMENT)
145
+ seg.match?(Key::Grammar::SEGMENT)
171
146
  end
172
147
 
173
148
  def validate_declared_keys!
@@ -175,12 +150,7 @@ module Textus
175
150
  end
176
151
 
177
152
  def resolve_leaf_path(entry)
178
- primary_ext = Entry.for_format(entry.format).extensions.first
179
- if File.extname(entry.path) == ""
180
- File.join(@root, "zones", entry.path + primary_ext)
181
- else
182
- File.join(@root, "zones", entry.path)
183
- end
153
+ Textus::Key::Path.resolve(self, entry)
184
154
  end
185
155
 
186
156
  def nested_glob(format)
@@ -193,208 +163,4 @@ module Textus
193
163
  end
194
164
  end
195
165
  end
196
-
197
- class ManifestEntry
198
- PUBLISH_EACH_VARS = %w[leaf basename key ext].freeze
199
- PUBLISH_EACH_VAR_RE = /\{([a-z]+)\}/
200
-
201
- attr_reader :key, :path, :zone, :schema, :owner, :nested, :generator, :raw, :format,
202
- :projection, :template, :publish_to, :publish_each, :action, :action_config, :ttl, :events,
203
- :inject_intro
204
-
205
- def initialize(manifest, raw)
206
- @manifest = manifest
207
- @raw = raw
208
- @key = raw["key"] or raise UsageError.new("manifest entry missing key")
209
- @path = raw["path"] or raise UsageError.new("manifest entry '#{@key}' missing path")
210
- @zone = raw["zone"] or raise UsageError.new("manifest entry '#{@key}' missing zone")
211
- @schema = raw["schema"]
212
- @owner = raw["owner"]
213
- @nested = raw["nested"] == true
214
- @generator = raw["generator"]
215
- @projection = raw["projection"]
216
- @template = raw["template"]
217
- @publish_to = Array(raw["publish_to"])
218
- @publish_each = raw["publish_each"]
219
- @events = raw["events"] || {}
220
- @inject_intro = raw["inject_intro"] == true
221
- @format = resolve_format!(raw["format"])
222
-
223
- reject_legacy!(raw)
224
- parse_source!(raw["source"])
225
- validate_format_matrix!
226
- validate_publish_each!
227
- validate_inject_intro!
228
- end
229
-
230
- # Resolves the per-leaf target path (relative to repo root) for a full
231
- # dotted key under this entry's prefix. Returns nil if this entry has no
232
- # publish_each template.
233
- def publish_target_for(full_key)
234
- return nil if @publish_each.nil?
235
-
236
- entry_segs = @key.split(".")
237
- key_segs = full_key.split(".")
238
- raise UsageError.new("key '#{full_key}' is not under entry '#{@key}'") unless key_segs[0, entry_segs.length] == entry_segs
239
-
240
- remaining = key_segs[entry_segs.length..] || []
241
- leaf = remaining.join("/")
242
- basename = remaining.last || ""
243
- ext = Entry.for_format(@format).extensions.first.to_s.sub(/^\./, "")
244
-
245
- vars = { "leaf" => leaf, "basename" => basename, "key" => full_key, "ext" => ext }
246
- @publish_each.gsub(PUBLISH_EACH_VAR_RE) { vars.fetch(::Regexp.last_match(1)) }
247
- end
248
-
249
- def derived?
250
- writers = @manifest.zone_writers(@zone)
251
- writers.include?("build")
252
- rescue UsageError => e
253
- raise UsageError.new("entry '#{@key}': #{e.message}")
254
- end
255
-
256
- private
257
-
258
- def validate_inject_intro!
259
- return unless @inject_intro
260
-
261
- unless derived?
262
- raise UsageError.new(
263
- "entry '#{@key}': inject_intro: is only valid on derived entries",
264
- )
265
- end
266
- return unless @template.nil?
267
-
268
- raise UsageError.new(
269
- "entry '#{@key}': inject_intro: requires a template:",
270
- )
271
- end
272
-
273
- def validate_publish_each!
274
- return if @publish_each.nil?
275
-
276
- raise UsageError.new("entry '#{@key}': publish_each requires nested: true") unless @nested
277
- raise UsageError.new("entry '#{@key}': publish_to and publish_each are mutually exclusive") unless @publish_to.empty?
278
- raise UsageError.new("entry '#{@key}': publish_each must be a string") unless @publish_each.is_a?(String)
279
-
280
- used_vars = @publish_each.scan(PUBLISH_EACH_VAR_RE).flatten
281
- unknown = used_vars - PUBLISH_EACH_VARS
282
- unless unknown.empty?
283
- raise UsageError.new(
284
- "entry '#{@key}': publish_each uses unknown template variable(s) " \
285
- "#{unknown.map { |v| "{#{v}}" }.join(", ")}. Known: #{PUBLISH_EACH_VARS.map { |v| "{#{v}}" }.join(", ")}.",
286
- )
287
- end
288
-
289
- required = %w[leaf basename key]
290
- return if used_vars.any? { |v| required.include?(v) }
291
-
292
- raise UsageError.new(
293
- "entry '#{@key}': publish_each must reference at least one of {leaf}, {basename}, or {key} " \
294
- "(else every leaf would clobber the same target).",
295
- )
296
- end
297
-
298
- def resolve_format!(declared)
299
- ext = File.extname(@path)
300
- inferred = Manifest::EXT_TO_FORMAT[ext]
301
-
302
- if declared.nil?
303
- return inferred if inferred
304
- # No extension: nested defaults to markdown, leaf with no ext also markdown.
305
- return "markdown" if ext == "" && @nested
306
- return "markdown" if ext == ""
307
- else
308
- raise UsageError.new("entry '#{@key}': unknown format #{declared.inspect}") unless Manifest::EXT_TO_FORMAT.values.include?(declared)
309
- # If the path has an extension, the declared format must match.
310
- if ext != "" && inferred && inferred != declared
311
- raise UsageError.new(
312
- "entry '#{@key}': path extension #{ext.inspect} does not match declared format #{declared.inspect}",
313
- )
314
- end
315
- return declared
316
- end
317
-
318
- "markdown"
319
- end
320
-
321
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
322
- def validate_format_matrix!
323
- ext = File.extname(@path)
324
-
325
- case @format
326
- when "markdown"
327
- # .md, or no extension (will be appended). Anything else is a mismatch caught above.
328
- raise UsageError.new("entry '#{@key}': markdown format requires '.md' path (got #{ext.inspect})") if ext != "" && ext != ".md"
329
- when "json"
330
- if @nested
331
- # nested json: path is a directory; ext must be empty.
332
- raise UsageError.new("entry '#{@key}': nested json path must not have an extension") if ext != ""
333
- elsif ext != ".json"
334
- raise UsageError.new("entry '#{@key}': json format requires '.json' path (got #{ext.inspect})")
335
- end
336
- when "yaml"
337
- if @nested
338
- raise UsageError.new("entry '#{@key}': nested yaml path must not have an extension") if ext != ""
339
- elsif ext != ".yaml" && ext != ".yml"
340
- raise UsageError.new("entry '#{@key}': yaml format requires '.yaml' or '.yml' path (got #{ext.inspect})")
341
- end
342
- when "text"
343
- if @nested
344
- raise UsageError.new("entry '#{@key}': nested text path must not have an extension") if ext != ""
345
- elsif ext != ".txt" && ext != ""
346
- raise UsageError.new("entry '#{@key}': text format requires '.txt' or no extension (got #{ext.inspect})")
347
- end
348
- end
349
-
350
- # Schema rules.
351
- raise UsageError.new("entry '#{@key}': text format must not declare a schema") if @format == "text" && !@schema.nil?
352
-
353
- # Template-required-for-derived rules. Skipped for entries materialized by an
354
- # external generator: command (those produce the bytes themselves).
355
- if derived? && @template.nil? && @generator.nil? &&
356
- (@format == "markdown" || @format == "text") && !@nested
357
- raise UsageError.new("entry '#{@key}': derived #{@format} entries require a template")
358
- end
359
- end
360
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
361
-
362
- def parse_source!(src)
363
- src ||= {}
364
- @action = src["action"]
365
- @action_config = src["config"] || {}
366
- @ttl = src["ttl"]
367
- end
368
-
369
- def reject_legacy!(raw)
370
- src = raw["source"] || {}
371
- if src.key?("parse") || src.key?("from")
372
- raise UsageError.new(
373
- "entry '#{@key}': source.parse/source.from removed in 0.2; " \
374
- "use source.action (+ source.config). See SPEC §5.4.",
375
- )
376
- end
377
- if src.key?("fetcher")
378
- raise UsageError.new(
379
- "entry '#{@key}': source.fetcher renamed to source.action in 0.4; " \
380
- "rename the key. See SPEC §5.4.",
381
- )
382
- end
383
- if raw.key?("hooks")
384
- raise UsageError.new(
385
- "entry '#{@key}': 'hooks:' renamed to 'events:' in 0.2; " \
386
- "remove on_ prefix from event names. See SPEC §5.10.",
387
- )
388
- end
389
-
390
- @events.each_key do |evt|
391
- next if ExtensionRegistry::EVENTS.include?(evt.to_sym)
392
-
393
- raise UsageError.new(
394
- "entry '#{@key}': unknown event '#{evt}' in events: block. " \
395
- "Known events: #{ExtensionRegistry::EVENTS.join(", ")}.",
396
- )
397
- end
398
- end
399
- end
400
166
  end
@@ -112,7 +112,7 @@ module Textus
112
112
  # ------------------------------------------------------------------
113
113
 
114
114
  def apply!(store, renames)
115
- audit = AuditLog.new(store.root)
115
+ audit = Store::AuditLog.new(store.root)
116
116
  renames.each do |r|
117
117
  # Bottom-up order means a child's ancestors haven't moved yet, so
118
118
  # `from`/`to` are valid as-recorded. The audit `key` reflects the
@@ -18,7 +18,7 @@ module Textus
18
18
  explicit_pluck = !@spec["pluck"].nil? && @spec["pluck"] != "*"
19
19
  rows = keys.map do |key|
20
20
  env = @store.get(key)
21
- row = pluck(env["frontmatter"], env["body"])
21
+ row = pluck(env["_meta"], env["body"])
22
22
  explicit_pluck ? row : row.merge("_key" => key)
23
23
  end
24
24
  reduced = apply_reducer(rows)
@@ -38,13 +38,14 @@ module Textus
38
38
  private
39
39
 
40
40
  def apply_reducer(rows)
41
- name = @spec["reducer"] or return rows
42
- callable = @store.registry.reducer(name)
41
+ name = @spec["reduce"] or return rows
42
+ callable = @store.registry.rpc_callable(:reduce, name)
43
+ view = Store::View.new(@store)
43
44
  Timeout.timeout(REDUCER_TIMEOUT_SECONDS) do
44
- callable.call(rows: rows, config: @spec["reducer_config"] || {})
45
+ callable.call(store: view, rows: rows, config: @spec["reduce_config"] || {})
45
46
  end
46
47
  rescue Timeout::Error
47
- raise UsageError.new("reducer '#{name}' exceeded #{REDUCER_TIMEOUT_SECONDS}s timeout")
48
+ raise UsageError.new("reduce '#{name}' exceeded #{REDUCER_TIMEOUT_SECONDS}s timeout")
48
49
  end
49
50
 
50
51
  def collect_keys
@@ -4,15 +4,15 @@ module Textus
4
4
  raise ProposalError.new("only human role can accept proposals; got '#{as}'") unless as == "human"
5
5
 
6
6
  env = store.get(pending_key)
7
- proposal = env["frontmatter"]["proposal"] or raise ProposalError.new("entry has no proposal block: #{pending_key}")
7
+ proposal = env["_meta"]["proposal"] or raise ProposalError.new("entry has no proposal block: #{pending_key}")
8
8
  target = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
9
9
  action = proposal["action"] || "put"
10
10
 
11
11
  case action
12
12
  when "put"
13
- target_fm = env["frontmatter"]["frontmatter"] || {}
13
+ target_meta = env["_meta"]["frontmatter"] || {}
14
14
  target_body = env["body"]
15
- store.put(target, frontmatter: target_fm, body: target_body, as: "human")
15
+ store.put(target, meta: target_meta, body: target_body, as: "human")
16
16
  when "delete"
17
17
  store.delete(target, as: "human")
18
18
  else
@@ -20,7 +20,7 @@ module Textus
20
20
  end
21
21
 
22
22
  store.delete(pending_key, as: "human")
23
- store.fire_event(:accept, pending_key: pending_key, target_key: target)
23
+ store.fire_event(:accept, key: pending_key, target_key: target)
24
24
  { "protocol" => PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action }
25
25
  end
26
26
  end