textus 0.3.0 → 0.5.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -1
  3. data/README.md +22 -18
  4. data/SPEC.md +49 -35
  5. data/docs/architecture.md +63 -28
  6. data/lib/textus/audit_log.rb +46 -11
  7. data/lib/textus/builder.rb +3 -3
  8. data/lib/textus/{builtin_fetchers.rb → builtin_actions.rb} +16 -11
  9. data/lib/textus/cli/accept.rb +13 -0
  10. data/lib/textus/cli/action.rb +51 -0
  11. data/lib/textus/cli/build.rb +11 -0
  12. data/lib/textus/cli/delete.rb +14 -0
  13. data/lib/textus/cli/deprecated_alias.rb +31 -0
  14. data/lib/textus/cli/deps.rb +10 -0
  15. data/lib/textus/cli/doctor.rb +13 -0
  16. data/lib/textus/cli/extension_group.rb +9 -0
  17. data/lib/textus/cli/extensions.rb +49 -0
  18. data/lib/textus/cli/get.rb +10 -0
  19. data/lib/textus/cli/group.rb +51 -0
  20. data/lib/textus/cli/init.rb +12 -0
  21. data/lib/textus/cli/intro.rb +9 -0
  22. data/lib/textus/cli/key_group.rb +10 -0
  23. data/lib/textus/cli/list.rb +12 -0
  24. data/lib/textus/cli/migrate.rb +41 -0
  25. data/lib/textus/cli/migrate_keys.rb +19 -0
  26. data/lib/textus/cli/mv.rb +20 -0
  27. data/lib/textus/cli/published.rb +9 -0
  28. data/lib/textus/cli/put.rb +48 -0
  29. data/lib/textus/cli/rdeps.rb +10 -0
  30. data/lib/textus/cli/refresh.rb +13 -0
  31. data/lib/textus/cli/schema.rb +10 -0
  32. data/lib/textus/cli/schema_diff.rb +15 -0
  33. data/lib/textus/cli/schema_group.rb +33 -0
  34. data/lib/textus/cli/schema_init.rb +19 -0
  35. data/lib/textus/cli/schema_migrate.rb +19 -0
  36. data/lib/textus/cli/stale.rb +12 -0
  37. data/lib/textus/cli/uid.rb +15 -0
  38. data/lib/textus/cli/verb.rb +62 -0
  39. data/lib/textus/cli/where.rb +10 -0
  40. data/lib/textus/cli.rb +65 -347
  41. data/lib/textus/doctor.rb +103 -32
  42. data/lib/textus/entry/json.rb +6 -4
  43. data/lib/textus/entry/markdown.rb +4 -4
  44. data/lib/textus/entry/text.rb +3 -3
  45. data/lib/textus/entry/yaml.rb +6 -4
  46. data/lib/textus/entry.rb +2 -2
  47. data/lib/textus/errors.rb +2 -2
  48. data/lib/textus/extension_registry.rb +22 -9
  49. data/lib/textus/extensions.rb +6 -2
  50. data/lib/textus/init.rb +6 -5
  51. data/lib/textus/intro.rb +11 -9
  52. data/lib/textus/manifest.rb +11 -215
  53. data/lib/textus/manifest_entry.rb +185 -0
  54. data/lib/textus/migrate_v2.rb +27 -0
  55. data/lib/textus/projection.rb +1 -1
  56. data/lib/textus/proposal.rb +3 -3
  57. data/lib/textus/refresh.rb +21 -20
  58. data/lib/textus/schema_tools.rb +8 -8
  59. data/lib/textus/store/events.rb +31 -0
  60. data/lib/textus/store/mover.rb +118 -0
  61. data/lib/textus/store/staleness.rb +142 -0
  62. data/lib/textus/store/validator.rb +53 -0
  63. data/lib/textus/store.rb +50 -355
  64. data/lib/textus/store_view.rb +11 -2
  65. data/lib/textus/version.rb +2 -2
  66. data/lib/textus.rb +39 -1
  67. metadata +39 -2
@@ -17,23 +17,8 @@ module Textus
17
17
 
18
18
  attr_reader :root, :entries, :raw
19
19
 
20
- LEGACY_ZONES = {
21
- "fixed" => ["human"],
22
- "state" => %w[human ai script],
23
- "derived" => ["build"],
24
- }.freeze
25
-
26
20
  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
21
+ @zones ||= Array(@raw["zones"]).to_h { |z| [z["name"], Array(z["writable_by"])] }
37
22
  end
38
23
 
39
24
  def zone_writers(zone_name)
@@ -45,7 +30,14 @@ module Textus
45
30
  raise IoError.new("manifest not found: #{manifest_path}") unless File.exist?(manifest_path)
46
31
 
47
32
  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
33
+ unless raw["version"] == PROTOCOL
34
+ msg = if raw["version"] == "textus/1"
35
+ "manifest is textus/1; run 'textus migrate v2' to upgrade. See SPEC §15."
36
+ else
37
+ "unsupported manifest version #{raw["version"].inspect}"
38
+ end
39
+ raise BadFrontmatter.new(manifest_path, msg)
40
+ end
49
41
 
50
42
  new(root, raw)
51
43
  end
@@ -53,6 +45,8 @@ module Textus
53
45
  def initialize(root, raw)
54
46
  @root = root
55
47
  @raw = raw
48
+ raise BadFrontmatter.new(File.join(root, "manifest.yaml"), "manifest must declare zones:") if Array(raw["zones"]).empty?
49
+
56
50
  @entries = Array(raw["entries"]).map { |e| ManifestEntry.new(self, e) }
57
51
  validate_declared_keys!
58
52
  end
@@ -193,202 +187,4 @@ module Textus
193
187
  end
194
188
  end
195
189
  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, :fetcher, :fetcher_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
- @fetcher = src["fetcher"]
365
- @fetcher_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.fetcher (+ source.config). See SPEC §5.4.",
375
- )
376
- end
377
- if raw.key?("hooks")
378
- raise UsageError.new(
379
- "entry '#{@key}': 'hooks:' renamed to 'events:' in 0.2; " \
380
- "remove on_ prefix from event names. See SPEC §5.10.",
381
- )
382
- end
383
-
384
- @events.each_key do |evt|
385
- next if ExtensionRegistry::EVENTS.include?(evt.to_sym)
386
-
387
- raise UsageError.new(
388
- "entry '#{@key}': unknown event '#{evt}' in events: block. " \
389
- "Known events: #{ExtensionRegistry::EVENTS.join(", ")}.",
390
- )
391
- end
392
- end
393
- end
394
190
  end
@@ -0,0 +1,185 @@
1
+ module Textus
2
+ class ManifestEntry
3
+ PUBLISH_EACH_VARS = %w[leaf basename key ext].freeze
4
+ PUBLISH_EACH_VAR_RE = /\{([a-z]+)\}/
5
+
6
+ attr_reader :key, :path, :zone, :schema, :owner, :nested, :generator, :raw, :format,
7
+ :projection, :template, :publish_to, :publish_each, :action, :action_config, :ttl, :events,
8
+ :inject_intro
9
+
10
+ def initialize(manifest, raw)
11
+ @manifest = manifest
12
+ @raw = raw
13
+ @key = raw["key"] or raise UsageError.new("manifest entry missing key")
14
+ @path = raw["path"] or raise UsageError.new("manifest entry '#{@key}' missing path")
15
+ @zone = raw["zone"] or raise UsageError.new("manifest entry '#{@key}' missing zone")
16
+ @schema = raw["schema"]
17
+ @owner = raw["owner"]
18
+ @nested = raw["nested"] == true
19
+ @generator = raw["generator"]
20
+ @projection = raw["projection"]
21
+ @template = raw["template"]
22
+ @publish_to = Array(raw["publish_to"])
23
+ @publish_each = raw["publish_each"]
24
+ @events = raw["events"] || {}
25
+ @inject_intro = raw["inject_intro"] == true
26
+ @format = resolve_format!(raw["format"])
27
+
28
+ validate_events!
29
+ parse_source!(raw["source"])
30
+ validate_format_matrix!
31
+ validate_publish_each!
32
+ validate_inject_intro!
33
+ end
34
+
35
+ # Resolves the per-leaf target path (relative to repo root) for a full
36
+ # dotted key under this entry's prefix. Returns nil if this entry has no
37
+ # publish_each template.
38
+ def publish_target_for(full_key)
39
+ return nil if @publish_each.nil?
40
+
41
+ entry_segs = @key.split(".")
42
+ key_segs = full_key.split(".")
43
+ raise UsageError.new("key '#{full_key}' is not under entry '#{@key}'") unless key_segs[0, entry_segs.length] == entry_segs
44
+
45
+ remaining = key_segs[entry_segs.length..] || []
46
+ leaf = remaining.join("/")
47
+ basename = remaining.last || ""
48
+ ext = Entry.for_format(@format).extensions.first.to_s.sub(/^\./, "")
49
+
50
+ vars = { "leaf" => leaf, "basename" => basename, "key" => full_key, "ext" => ext }
51
+ @publish_each.gsub(PUBLISH_EACH_VAR_RE) { vars.fetch(::Regexp.last_match(1)) }
52
+ end
53
+
54
+ def derived?
55
+ writers = @manifest.zone_writers(@zone)
56
+ writers.include?("build")
57
+ rescue UsageError => e
58
+ raise UsageError.new("entry '#{@key}': #{e.message}")
59
+ end
60
+
61
+ private
62
+
63
+ def validate_inject_intro!
64
+ return unless @inject_intro
65
+
66
+ unless derived?
67
+ raise UsageError.new(
68
+ "entry '#{@key}': inject_intro: is only valid on derived entries",
69
+ )
70
+ end
71
+ return unless @template.nil?
72
+
73
+ raise UsageError.new(
74
+ "entry '#{@key}': inject_intro: requires a template:",
75
+ )
76
+ end
77
+
78
+ def validate_publish_each!
79
+ return if @publish_each.nil?
80
+
81
+ raise UsageError.new("entry '#{@key}': publish_each requires nested: true") unless @nested
82
+ raise UsageError.new("entry '#{@key}': publish_to and publish_each are mutually exclusive") unless @publish_to.empty?
83
+ raise UsageError.new("entry '#{@key}': publish_each must be a string") unless @publish_each.is_a?(String)
84
+
85
+ used_vars = @publish_each.scan(PUBLISH_EACH_VAR_RE).flatten
86
+ unknown = used_vars - PUBLISH_EACH_VARS
87
+ unless unknown.empty?
88
+ raise UsageError.new(
89
+ "entry '#{@key}': publish_each uses unknown template variable(s) " \
90
+ "#{unknown.map { |v| "{#{v}}" }.join(", ")}. Known: #{PUBLISH_EACH_VARS.map { |v| "{#{v}}" }.join(", ")}.",
91
+ )
92
+ end
93
+
94
+ required = %w[leaf basename key]
95
+ return if used_vars.any? { |v| required.include?(v) }
96
+
97
+ raise UsageError.new(
98
+ "entry '#{@key}': publish_each must reference at least one of {leaf}, {basename}, or {key} " \
99
+ "(else every leaf would clobber the same target).",
100
+ )
101
+ end
102
+
103
+ def resolve_format!(declared)
104
+ ext = File.extname(@path)
105
+ inferred = Manifest::EXT_TO_FORMAT[ext]
106
+
107
+ if declared.nil?
108
+ return inferred if inferred
109
+ # No extension: nested defaults to markdown, leaf with no ext also markdown.
110
+ return "markdown" if ext == "" && @nested
111
+ return "markdown" if ext == ""
112
+ else
113
+ raise UsageError.new("entry '#{@key}': unknown format #{declared.inspect}") unless Manifest::EXT_TO_FORMAT.values.include?(declared)
114
+ # If the path has an extension, the declared format must match.
115
+ if ext != "" && inferred && inferred != declared
116
+ raise UsageError.new(
117
+ "entry '#{@key}': path extension #{ext.inspect} does not match declared format #{declared.inspect}",
118
+ )
119
+ end
120
+ return declared
121
+ end
122
+
123
+ "markdown"
124
+ end
125
+
126
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
127
+ def validate_format_matrix!
128
+ ext = File.extname(@path)
129
+
130
+ case @format
131
+ when "markdown"
132
+ # .md, or no extension (will be appended). Anything else is a mismatch caught above.
133
+ raise UsageError.new("entry '#{@key}': markdown format requires '.md' path (got #{ext.inspect})") if ext != "" && ext != ".md"
134
+ when "json"
135
+ if @nested
136
+ # nested json: path is a directory; ext must be empty.
137
+ raise UsageError.new("entry '#{@key}': nested json path must not have an extension") if ext != ""
138
+ elsif ext != ".json"
139
+ raise UsageError.new("entry '#{@key}': json format requires '.json' path (got #{ext.inspect})")
140
+ end
141
+ when "yaml"
142
+ if @nested
143
+ raise UsageError.new("entry '#{@key}': nested yaml path must not have an extension") if ext != ""
144
+ elsif ext != ".yaml" && ext != ".yml"
145
+ raise UsageError.new("entry '#{@key}': yaml format requires '.yaml' or '.yml' path (got #{ext.inspect})")
146
+ end
147
+ when "text"
148
+ if @nested
149
+ raise UsageError.new("entry '#{@key}': nested text path must not have an extension") if ext != ""
150
+ elsif ext != ".txt" && ext != ""
151
+ raise UsageError.new("entry '#{@key}': text format requires '.txt' or no extension (got #{ext.inspect})")
152
+ end
153
+ end
154
+
155
+ # Schema rules.
156
+ raise UsageError.new("entry '#{@key}': text format must not declare a schema") if @format == "text" && !@schema.nil?
157
+
158
+ # Template-required-for-derived rules. Skipped for entries materialized by an
159
+ # external generator: command (those produce the bytes themselves).
160
+ if derived? && @template.nil? && @generator.nil? &&
161
+ (@format == "markdown" || @format == "text") && !@nested
162
+ raise UsageError.new("entry '#{@key}': derived #{@format} entries require a template")
163
+ end
164
+ end
165
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
166
+
167
+ def parse_source!(src)
168
+ src ||= {}
169
+ @action = src["action"]
170
+ @action_config = src["config"] || {}
171
+ @ttl = src["ttl"]
172
+ end
173
+
174
+ def validate_events!
175
+ @events.each_key do |evt|
176
+ next if ExtensionRegistry::EVENTS.include?(evt.to_sym)
177
+
178
+ raise UsageError.new(
179
+ "entry '#{@key}': unknown event '#{evt}' in events: block. " \
180
+ "Known events: #{ExtensionRegistry::EVENTS.join(", ")}.",
181
+ )
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,27 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ # One-shot migration: rewrites the manifest version string from textus/1
5
+ # to textus/2. On-disk entry file shapes are unchanged — the only change
6
+ # needed is the version: line in manifest.yaml.
7
+ module MigrateV2
8
+ def self.run(root)
9
+ manifest_path = File.join(root, "manifest.yaml")
10
+ raise IoError.new("manifest not found: #{manifest_path}") unless File.exist?(manifest_path)
11
+
12
+ content = File.read(manifest_path)
13
+ raw = YAML.safe_load(content, aliases: false)
14
+
15
+ case raw["version"]
16
+ when PROTOCOL
17
+ { "protocol" => PROTOCOL, "ok" => true, "no_op" => true, "message" => "already #{PROTOCOL}" }
18
+ when "textus/1"
19
+ new_content = content.sub(%r{^version:\s*textus/1\s*$}, "version: #{PROTOCOL}")
20
+ File.write(manifest_path, new_content)
21
+ { "protocol" => PROTOCOL, "ok" => true, "from" => "textus/1", "to" => PROTOCOL }
22
+ else
23
+ raise UsageError.new("cannot migrate from #{raw["version"].inspect}")
24
+ end
25
+ end
26
+ end
27
+ end
@@ -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)
@@ -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
@@ -2,30 +2,32 @@ require "timeout"
2
2
 
3
3
  module Textus
4
4
  module Refresh
5
- FETCHER_TIMEOUT_SECONDS = 2
5
+ ACTION_TIMEOUT_SECONDS = 2
6
6
 
7
7
  def self.call(store, key, as:)
8
8
  mentry, path, = store.manifest.resolve(key)
9
- raise UsageError.new("no fetcher declared for '#{key}'") unless mentry.fetcher
9
+ raise UsageError.new("no action declared for '#{key}'") unless mentry.action
10
10
 
11
11
  before_etag = File.exist?(path) ? Etag.for_file(path) : nil
12
- callable = store.registry.fetcher(mentry.fetcher)
13
- view = StoreView.new(store)
12
+ callable = store.registry.action(mentry.action)
13
+ view = StoreView.new(store, writable: true, as: as)
14
14
  result =
15
15
  begin
16
- Timeout.timeout(FETCHER_TIMEOUT_SECONDS) { callable.call(config: mentry.fetcher_config, store: view) }
16
+ Timeout.timeout(ACTION_TIMEOUT_SECONDS) do
17
+ callable.call(config: mentry.action_config, store: view, args: {})
18
+ end
17
19
  rescue Timeout::Error
18
- raise UsageError.new("fetcher '#{mentry.fetcher}' exceeded #{FETCHER_TIMEOUT_SECONDS}s timeout")
20
+ raise UsageError.new("action '#{mentry.action}' exceeded #{ACTION_TIMEOUT_SECONDS}s timeout")
19
21
  rescue Textus::Error
20
22
  raise
21
23
  rescue StandardError => e
22
- raise UsageError.new("fetcher '#{mentry.fetcher}' raised: #{e.class}: #{e.message}")
24
+ raise UsageError.new("action '#{mentry.action}' raised: #{e.class}: #{e.message}")
23
25
  end
24
26
 
25
- normalized = normalize_fetcher_result(result, format: mentry.format)
27
+ normalized = normalize_action_result(result, format: mentry.format)
26
28
  envelope = store.put(
27
29
  key,
28
- frontmatter: normalized[:frontmatter],
30
+ meta: normalized[:meta],
29
31
  body: normalized[:body],
30
32
  content: normalized[:content],
31
33
  as: as,
@@ -43,29 +45,28 @@ module Textus
43
45
  envelope
44
46
  end
45
47
 
46
- # Normalize the three accepted fetcher return shapes into the store's
47
- # internal {frontmatter, body, content} representation. See plan-1.2 §7.
48
- def self.normalize_fetcher_result(res, format:)
48
+ # Normalize the three accepted action return shapes into the store's
49
+ # internal {frontmatter, body, content} representation.
50
+ def self.normalize_action_result(res, format:)
49
51
  res = res.transform_keys(&:to_s) if res.is_a?(Hash)
50
52
  res ||= {}
51
- fm = res["frontmatter"]
53
+ # Accept both legacy :frontmatter/:_meta key names from actions.
54
+ meta_val = res["_meta"] || res["frontmatter"]
52
55
  body = res["body"]
53
56
  content = res["content"]
54
57
 
55
58
  case format
56
59
  when "markdown"
57
- { frontmatter: fm || {}, body: body.to_s, content: nil }
60
+ { meta: meta_val || {}, body: body.to_s, content: nil }
58
61
  when "text"
59
- { frontmatter: {}, body: body.to_s, content: nil }
62
+ { meta: {}, body: body.to_s, content: nil }
60
63
  when "json", "yaml"
61
64
  if !content.nil?
62
- meta = content.is_a?(Hash) && content["_meta"].is_a?(Hash) ? content["_meta"] : {}
63
- { frontmatter: meta, body: nil, content: content }
65
+ { meta: meta_val || {}, body: nil, content: content }
64
66
  elsif !body.nil?
65
- # Store#put will re-parse and validate the bytes.
66
- { frontmatter: {}, body: body.to_s, content: nil }
67
+ { meta: {}, body: body.to_s, content: nil }
67
68
  else
68
- raise UsageError.new("fetcher for #{format} returned neither content nor body")
69
+ raise UsageError.new("action for #{format} returned neither content nor body")
69
70
  end
70
71
  else
71
72
  raise UsageError.new("unknown format #{format.inspect}")
@@ -6,12 +6,12 @@ module Textus
6
6
  # textus schema-init NAME --from=KEY → infer YAML schema from an entry's frontmatter
7
7
  def self.init(store, name:, from:)
8
8
  env = store.get(from)
9
- fm = env["frontmatter"]
9
+ meta = env["_meta"]
10
10
  schema = {
11
11
  "name" => name,
12
- "required" => fm.keys,
12
+ "required" => meta.keys,
13
13
  "optional" => [],
14
- "fields" => fm.each_with_object({}) { |(k, v), h| h[k] = { "type" => infer_type(v) } },
14
+ "fields" => meta.each_with_object({}) { |(k, v), h| h[k] = { "type" => infer_type(v) } },
15
15
  }
16
16
  FileUtils.mkdir_p(File.join(store.root, "schemas"))
17
17
  target = File.join(store.root, "schemas", "#{name}.yaml")
@@ -26,7 +26,7 @@ module Textus
26
26
  store.manifest.enumerate.each do |row|
27
27
  env = store.get(row[:key])
28
28
  begin
29
- schema.validate!(env["frontmatter"])
29
+ schema.validate!(env["_meta"])
30
30
  rescue SchemaViolation => e
31
31
  drift << { "key" => row[:key], "details" => e.details }
32
32
  end
@@ -51,17 +51,17 @@ module Textus
51
51
  touched = []
52
52
  store.manifest.enumerate.each do |row|
53
53
  env = store.get(row[:key])
54
- fm = env["frontmatter"]
54
+ meta = env["_meta"]
55
55
  changed = false
56
56
  renames.each do |old, new|
57
- if fm.key?(old)
58
- fm[new] = fm.delete(old)
57
+ if meta.key?(old)
58
+ meta[new] = meta.delete(old)
59
59
  changed = true
60
60
  end
61
61
  end
62
62
  next unless changed
63
63
 
64
- store.put(row[:key], frontmatter: fm, body: env["body"], as: "human")
64
+ store.put(row[:key], meta: meta, body: env["body"], as: "human")
65
65
  touched << row[:key]
66
66
  end
67
67
  { "protocol" => PROTOCOL, "migrated" => touched, "renames" => renames }
@@ -0,0 +1,31 @@
1
+ require "timeout"
2
+
3
+ module Textus
4
+ class Store
5
+ class Events
6
+ HOOK_TIMEOUT_SECONDS = 2
7
+
8
+ def initialize(store)
9
+ @store = store
10
+ end
11
+
12
+ def call(event, **kwargs)
13
+ view = StoreView.new(@store)
14
+ @store.registry.hooks(event).each do |entry|
15
+ name = entry[:name]
16
+ Timeout.timeout(HOOK_TIMEOUT_SECONDS) { entry[:callable].call(store: view, **kwargs) }
17
+ rescue StandardError => e
18
+ extras = { "event" => event.to_s, "hook" => name.to_s, "error" => "#{e.class}: #{e.message}" }
19
+ extras["target_key"] = kwargs[:target_key] if kwargs.key?(:target_key)
20
+ extras["pending_key"] = kwargs[:pending_key] if kwargs.key?(:pending_key)
21
+ @store.audit_log.append(
22
+ role: "script", verb: "event_error",
23
+ key: kwargs[:key] || kwargs[:target_key] || kwargs[:pending_key] || "-",
24
+ etag_before: nil, etag_after: nil,
25
+ extras: extras
26
+ )
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end