textus 0.5.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 (124) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -1
  3. data/README.md +29 -21
  4. data/SPEC.md +75 -142
  5. data/docs/architecture.md +42 -23
  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/verb/accept.rb +15 -0
  17. data/lib/textus/cli/verb/build.rb +13 -0
  18. data/lib/textus/cli/verb/delete.rb +16 -0
  19. data/lib/textus/cli/verb/deps.rb +12 -0
  20. data/lib/textus/cli/verb/doctor.rb +15 -0
  21. data/lib/textus/cli/verb/get.rb +12 -0
  22. data/lib/textus/cli/verb/hook_run.rb +48 -0
  23. data/lib/textus/cli/verb/hooks.rb +50 -0
  24. data/lib/textus/cli/verb/init.rb +14 -0
  25. data/lib/textus/cli/verb/intro.rb +11 -0
  26. data/lib/textus/cli/verb/list.rb +14 -0
  27. data/lib/textus/cli/verb/migrate_keys.rb +16 -0
  28. data/lib/textus/cli/verb/mv.rb +17 -0
  29. data/lib/textus/cli/verb/published.rb +11 -0
  30. data/lib/textus/cli/verb/put.rb +50 -0
  31. data/lib/textus/cli/verb/rdeps.rb +12 -0
  32. data/lib/textus/cli/verb/refresh.rb +15 -0
  33. data/lib/textus/cli/verb/schema.rb +12 -0
  34. data/lib/textus/cli/verb/schema_diff.rb +12 -0
  35. data/lib/textus/cli/verb/schema_init.rb +16 -0
  36. data/lib/textus/cli/verb/schema_migrate.rb +16 -0
  37. data/lib/textus/cli/verb/stale.rb +14 -0
  38. data/lib/textus/cli/verb/uid.rb +12 -0
  39. data/lib/textus/cli/verb/where.rb +12 -0
  40. data/lib/textus/cli.rb +23 -42
  41. data/lib/textus/doctor/check/audit_log.rb +50 -0
  42. data/lib/textus/doctor/check/hooks.rb +29 -0
  43. data/lib/textus/doctor/check/illegal_keys.rb +49 -0
  44. data/lib/textus/doctor/check/manifest_files.rb +38 -0
  45. data/lib/textus/doctor/check/schema_violations.rb +22 -0
  46. data/lib/textus/doctor/check/schemas.rb +26 -0
  47. data/lib/textus/doctor/check/sentinels.rb +57 -0
  48. data/lib/textus/doctor/check/templates.rb +26 -0
  49. data/lib/textus/doctor/check/unowned_schema_fields.rb +34 -0
  50. data/lib/textus/doctor/check.rb +30 -0
  51. data/lib/textus/doctor.rb +22 -288
  52. data/lib/textus/entry/base.rb +30 -0
  53. data/lib/textus/entry/json.rb +5 -1
  54. data/lib/textus/entry/markdown.rb +1 -1
  55. data/lib/textus/entry/text.rb +1 -1
  56. data/lib/textus/entry/yaml.rb +5 -1
  57. data/lib/textus/entry.rb +0 -5
  58. data/lib/textus/envelope.rb +30 -0
  59. data/lib/textus/hooks/builtin.rb +70 -0
  60. data/lib/textus/hooks/dispatcher.rb +49 -0
  61. data/lib/textus/hooks/loader.rb +26 -0
  62. data/lib/textus/hooks/registry.rb +73 -0
  63. data/lib/textus/init.rb +13 -10
  64. data/lib/textus/intro.rb +14 -16
  65. data/lib/textus/key/distance.rb +55 -0
  66. data/lib/textus/key/grammar.rb +33 -0
  67. data/lib/textus/key/path.rb +17 -0
  68. data/lib/textus/manifest/entry.rb +199 -0
  69. data/lib/textus/manifest.rb +10 -34
  70. data/lib/textus/migrate_keys.rb +1 -1
  71. data/lib/textus/projection.rb +5 -4
  72. data/lib/textus/proposal.rb +1 -1
  73. data/lib/textus/refresh.rb +11 -11
  74. data/lib/textus/schema/tools.rb +89 -0
  75. data/lib/textus/store/audit_log.rb +71 -0
  76. data/lib/textus/store/mover.rb +19 -16
  77. data/lib/textus/store/reader.rb +67 -0
  78. data/lib/textus/store/staleness.rb +10 -19
  79. data/lib/textus/store/validator.rb +11 -8
  80. data/lib/textus/store/view.rb +29 -0
  81. data/lib/textus/store/writer.rb +132 -0
  82. data/lib/textus/store.rb +25 -221
  83. data/lib/textus/version.rb +1 -1
  84. data/lib/textus.rb +14 -67
  85. metadata +73 -40
  86. data/lib/textus/audit_log.rb +0 -67
  87. data/lib/textus/builtin_actions.rb +0 -68
  88. data/lib/textus/cli/accept.rb +0 -13
  89. data/lib/textus/cli/action.rb +0 -51
  90. data/lib/textus/cli/build.rb +0 -11
  91. data/lib/textus/cli/delete.rb +0 -14
  92. data/lib/textus/cli/deprecated_alias.rb +0 -31
  93. data/lib/textus/cli/deps.rb +0 -10
  94. data/lib/textus/cli/doctor.rb +0 -13
  95. data/lib/textus/cli/extension_group.rb +0 -9
  96. data/lib/textus/cli/extensions.rb +0 -49
  97. data/lib/textus/cli/get.rb +0 -10
  98. data/lib/textus/cli/init.rb +0 -12
  99. data/lib/textus/cli/intro.rb +0 -9
  100. data/lib/textus/cli/key_group.rb +0 -10
  101. data/lib/textus/cli/list.rb +0 -12
  102. data/lib/textus/cli/migrate.rb +0 -41
  103. data/lib/textus/cli/migrate_keys.rb +0 -19
  104. data/lib/textus/cli/mv.rb +0 -20
  105. data/lib/textus/cli/published.rb +0 -9
  106. data/lib/textus/cli/put.rb +0 -48
  107. data/lib/textus/cli/rdeps.rb +0 -10
  108. data/lib/textus/cli/refresh.rb +0 -13
  109. data/lib/textus/cli/schema.rb +0 -10
  110. data/lib/textus/cli/schema_diff.rb +0 -15
  111. data/lib/textus/cli/schema_group.rb +0 -33
  112. data/lib/textus/cli/schema_init.rb +0 -19
  113. data/lib/textus/cli/schema_migrate.rb +0 -19
  114. data/lib/textus/cli/stale.rb +0 -12
  115. data/lib/textus/cli/uid.rb +0 -15
  116. data/lib/textus/cli/where.rb +0 -10
  117. data/lib/textus/extension_registry.rb +0 -61
  118. data/lib/textus/extensions.rb +0 -33
  119. data/lib/textus/key_distance.rb +0 -53
  120. data/lib/textus/manifest_entry.rb +0 -185
  121. data/lib/textus/migrate_v2.rb +0 -27
  122. data/lib/textus/schema_tools.rb +0 -87
  123. data/lib/textus/store/events.rb +0 -31
  124. data/lib/textus/store_view.rb +0 -27
@@ -0,0 +1,73 @@
1
+ module Textus
2
+ module Hooks
3
+ class Registry
4
+ EVENTS = {
5
+ # RPC: exactly 1 handler per name; return value flows into store; failure aborts.
6
+ fetch: { mode: :rpc, args: %i[store config args] },
7
+ reduce: { mode: :rpc, args: %i[store rows config] },
8
+ check: { mode: :rpc, args: %i[store] },
9
+
10
+ # Pub-sub: 0..N handlers per event; return discarded; failure logged to audit.
11
+ put: { mode: :pubsub, args: %i[store key envelope] },
12
+ delete: { mode: :pubsub, args: %i[store key] },
13
+ refresh: { mode: :pubsub, args: %i[store key envelope change] },
14
+ build: { mode: :pubsub, args: %i[store key envelope sources] },
15
+ accept: { mode: :pubsub, args: %i[store key target_key] },
16
+ }.freeze
17
+
18
+ def initialize(dispatcher: nil)
19
+ @rpc = Hash.new { |h, k| h[k] = {} } # event => { name => callable }
20
+ @pubsub = Hash.new { |h, k| h[k] = [] } # event => [{name:, callable:, keys:}]
21
+ @dispatcher = dispatcher
22
+ end
23
+
24
+ def register(event, name, keys: nil, &blk)
25
+ spec = EVENTS[event.to_sym] or raise UsageError.new("unknown event: #{event}")
26
+ shape_check!(event, spec, blk)
27
+ name = name.to_sym
28
+
29
+ case spec[:mode]
30
+ when :rpc
31
+ raise UsageError.new("#{event} '#{name}' already registered") if @rpc[event.to_sym].key?(name)
32
+
33
+ @rpc[event.to_sym][name] = blk
34
+ when :pubsub
35
+ raise UsageError.new("#{event} hook '#{name}' already registered") if @pubsub[event.to_sym].any? { |h| h[:name] == name }
36
+
37
+ @pubsub[event.to_sym] << { name: name, callable: blk, keys: keys }
38
+ @dispatcher&.subscribe(event, name, keys: keys, &blk)
39
+ end
40
+ end
41
+
42
+ def rpc_callable(event, name)
43
+ @rpc[event.to_sym][name.to_sym] or
44
+ raise UsageError.new("unknown #{event}: #{name}")
45
+ end
46
+
47
+ def listeners(event, key:)
48
+ @pubsub[event.to_sym].select { |h| h[:keys].nil? || matches_any?(h[:keys], key) }
49
+ end
50
+
51
+ def rpc_names(event) = @rpc[event.to_sym].keys
52
+ def pubsub_handlers(event) = @pubsub[event.to_sym]
53
+
54
+ private
55
+
56
+ def shape_check!(event, spec, blk)
57
+ required = spec[:args]
58
+ provided = blk.parameters.select { |t, _| %i[keyreq key keyrest].include?(t) } # rubocop:disable Style/HashSlice
59
+ keyrest = provided.any? { |t, _| t == :keyrest }
60
+ missing = required - provided.map { |_, n| n }
61
+ return if keyrest || missing.empty?
62
+
63
+ raise UsageError.new(
64
+ "#{event} hooks must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})",
65
+ )
66
+ end
67
+
68
+ def matches_any?(globs, key)
69
+ Array(globs).any? { |g| File.fnmatch?(g, key.to_s, File::FNM_PATHNAME) }
70
+ end
71
+ end
72
+ end
73
+ end
data/lib/textus/init.rb CHANGED
@@ -22,27 +22,30 @@ module Textus
22
22
 
23
23
  FileUtils.mkdir_p(File.join(target_root, "schemas"))
24
24
  FileUtils.mkdir_p(File.join(target_root, "templates"))
25
- FileUtils.mkdir_p(File.join(target_root, "extensions"))
25
+ FileUtils.mkdir_p(File.join(target_root, "hooks"))
26
26
  ZONES.each do |z|
27
27
  dir = File.join(target_root, "zones", z)
28
28
  FileUtils.mkdir_p(dir)
29
29
  File.write(File.join(dir, ".gitkeep"), "")
30
30
  end
31
- File.write(File.join(target_root, "extensions", "README.md"), <<~MD)
32
- # Extensions
31
+ File.write(File.join(target_root, "hooks", "README.md"), <<~MD)
32
+ # Hooks
33
33
 
34
- Drop one Ruby file per extension. Four verbs are available:
34
+ Drop one Ruby file per hook. All extensions register through one DSL.
35
+ Every handler receives `store:` as its first kwarg, then event-specific args.
35
36
 
36
37
  ```ruby
37
- Textus.action(:name) { |config:, store:, args:| ... }
38
- Textus.reducer(:name) { |rows:, config:| ... }
39
- Textus.hook(:event, :name) { |key:, envelope:, **kw| ... }
40
- Textus.doctor_check(:name) { |store:| ... }
38
+ Textus.hook(:fetch, :name) { |store:, config:, args:| ... } # bring bytes in
39
+ Textus.hook(:reduce, :name) { |store:, rows:, config:| ... } # transform rows
40
+ Textus.hook(:check, :name) { |store:| ... } # doctor check
41
+ Textus.hook(:put, :name, keys: ["..."]) # lifecycle listener
42
+ { |store:, key:, envelope:| ... }
41
43
  ```
42
44
 
43
- Events: :put, :delete, :refresh, :build, :accept.
45
+ Events: :fetch, :reduce, :check (rpc — return value used)
46
+ :put, :delete, :refresh, :build, :accept (pub-sub — return discarded)
44
47
 
45
- See SPEC.md §5.11 for the full contract.
48
+ See SPEC.md §5.10 for the full table.
46
49
  MD
47
50
 
48
51
  File.write(File.join(target_root, "manifest.yaml"), DEFAULT_MANIFEST)
data/lib/textus/intro.rb CHANGED
@@ -37,14 +37,14 @@ module Textus
37
37
  { "name" => "schema", "summary" => "field shape for a key family" },
38
38
  { "name" => "put", "summary" => "write an entry; --as=<role>, --stdin payload" },
39
39
  { "name" => "accept", "summary" => "apply a pending.* proposal; --as=human only" },
40
- { "name" => "mv", "summary" => "rename a key in place; uid preserved, audit row written" },
40
+ { "name" => "key", "summary" => "key operations: 'key mv', 'key uid', 'key migrate'" },
41
41
  { "name" => "delete", "summary" => "delete an entry; --as=<role>" },
42
42
  { "name" => "build", "summary" => "materialize derived entries; publish_to and publish_each fan out copies" },
43
43
  { "name" => "refresh", "summary" => "run an action for an intake entry" },
44
44
  { "name" => "stale", "summary" => "list derived/intake entries past their freshness check" },
45
45
  { "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
46
- { "name" => "migrate-keys", "summary" => "rename files whose basenames violate the strict key grammar" },
47
- { "name" => "extensions", "summary" => "list registered actions, reducers, doctor_checks, declared hooks" },
46
+ { "name" => "hook",
47
+ "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
48
48
  ].freeze
49
49
 
50
50
  def self.run(store)
@@ -80,7 +80,7 @@ module Textus
80
80
  "owner" => e.owner,
81
81
  "format" => e.format,
82
82
  "derived" => derived,
83
- "intake" => !e.action.nil?,
83
+ "intake" => !e.fetch.nil?,
84
84
  "publish_to" => Array(e.publish_to),
85
85
  "publish_each" => e.publish_each,
86
86
  }
@@ -89,18 +89,16 @@ module Textus
89
89
 
90
90
  def self.extensions_for(store)
91
91
  reg = store.registry
92
- reducers = reg.reducer_names.map(&:to_s).sort
93
- actions = reg.action_names.map(&:to_s).sort
94
- doctor_checks = reg.doctor_check_names.map(&:to_s).sort
95
- hooks = reg.hook_events.flat_map do |evt|
96
- reg.hooks(evt).map { |h| { "event" => evt.to_s, "name" => h[:name].to_s } }
97
- end.sort_by { |h| [h["event"], h["name"]] }
98
- {
99
- "reducers" => reducers,
100
- "actions" => actions,
101
- "doctor_checks" => doctor_checks,
102
- "hooks" => hooks,
103
- }
92
+ sections = {}
93
+ Hooks::Registry::EVENTS.each do |event, spec|
94
+ case spec[:mode]
95
+ when :rpc
96
+ sections[event.to_s] = reg.rpc_names(event).map(&:to_s).sort
97
+ when :pubsub
98
+ sections[event.to_s] = reg.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
99
+ end
100
+ end
101
+ sections
104
102
  end
105
103
  end
106
104
  end
@@ -0,0 +1,55 @@
1
+ module Textus
2
+ module Key
3
+ # Small utilities for ranking key suggestions. Bounded inputs only —
4
+ # Levenshtein is O(n*m) so we refuse to compute on long strings.
5
+ class Distance
6
+ MAX_LEN = 200
7
+
8
+ # Length of the shared dot-separated prefix between two dotted keys.
9
+ def self.shared_prefix_segments(left, right)
10
+ asegs = left.split(".")
11
+ bsegs = right.split(".")
12
+ n = [asegs.length, bsegs.length].min
13
+ i = 0
14
+ i += 1 while i < n && asegs[i] == bsegs[i]
15
+ i
16
+ end
17
+
18
+ # Classic iterative Levenshtein with two rows. Bounded to MAX_LEN.
19
+ def self.levenshtein(left, right)
20
+ return nil if left.length > MAX_LEN || right.length > MAX_LEN
21
+ return right.length if left.empty?
22
+ return left.length if right.empty?
23
+
24
+ prev = (0..right.length).to_a
25
+ curr = Array.new(right.length + 1, 0)
26
+ (1..left.length).each do |i|
27
+ curr[0] = i
28
+ (1..right.length).each do |j|
29
+ cost = left[i - 1] == right[j - 1] ? 0 : 1
30
+ curr[j] = [
31
+ curr[j - 1] + 1, # insertion
32
+ prev[j] + 1, # deletion
33
+ prev[j - 1] + cost, # substitution
34
+ ].min
35
+ end
36
+ prev, curr = curr, prev
37
+ end
38
+ prev[right.length]
39
+ end
40
+
41
+ # Rank candidate keys against requested. Returns up to `limit` keys.
42
+ # Sort: longer shared prefix first; then smaller Levenshtein distance.
43
+ def self.suggest(requested, candidates, limit: 5)
44
+ return [] if requested.nil? || requested.empty?
45
+
46
+ scored = candidates.first(200).map do |k|
47
+ prefix = shared_prefix_segments(requested, k)
48
+ dist = levenshtein(requested, k) || Float::INFINITY
49
+ [k, prefix, dist]
50
+ end
51
+ scored.sort_by { |(_, prefix, dist)| [-prefix, dist] }.first(limit).map(&:first)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,33 @@
1
+ module Textus
2
+ module Key
3
+ module Grammar
4
+ SEGMENT = /\A[a-z0-9][a-z0-9-]*\z/
5
+ MAX_SEGMENTS = 8
6
+ MAX_SEGMENT_LEN = 64
7
+
8
+ module_function
9
+
10
+ def validate!(key) # rubocop:disable Naming/PredicateMethod
11
+ raise UsageError.new("key must be a String") unless key.is_a?(String)
12
+ raise UsageError.new("empty key") if key.empty?
13
+
14
+ segs = key.split(".")
15
+ raise UsageError.new("key '#{key}' has #{segs.length} segments (max #{MAX_SEGMENTS})") if segs.length > MAX_SEGMENTS
16
+
17
+ segs.each do |seg|
18
+ if seg.empty?
19
+ raise UsageError.new("empty segment in key '#{key}'")
20
+ elsif seg.length > MAX_SEGMENT_LEN
21
+ raise UsageError.new("segment '#{seg}' in key '#{key}' exceeds #{MAX_SEGMENT_LEN} chars")
22
+ elsif !seg.match?(SEGMENT)
23
+ raise UsageError.new(
24
+ "invalid key segment '#{seg}' in '#{key}': must match [a-z0-9][a-z0-9-]* " \
25
+ "(lowercase, digits, hyphens; no underscores or uppercase)",
26
+ )
27
+ end
28
+ end
29
+ true
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,17 @@
1
+ module Textus
2
+ module Key
3
+ module Path
4
+ # Returns the absolute filesystem path for a manifest entry (the leaf file,
5
+ # not a nested directory). Adds the format's primary extension when the
6
+ # manifest entry's `path:` is extensionless.
7
+ def self.resolve(manifest, mentry)
8
+ primary_ext = Entry.for_format(mentry.format).extensions.first
9
+ if File.extname(mentry.path) == ""
10
+ File.join(manifest.root, "zones", mentry.path + primary_ext)
11
+ else
12
+ File.join(manifest.root, "zones", mentry.path)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -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",
@@ -32,7 +27,7 @@ module Textus
32
27
  raw = YAML.safe_load_file(manifest_path, aliases: false)
33
28
  unless raw["version"] == PROTOCOL
34
29
  msg = if raw["version"] == "textus/1"
35
- "manifest is textus/1; run 'textus migrate v2' to upgrade. See SPEC §15."
30
+ "manifest is textus/1; edit manifest.yaml: change 'version: textus/1' to 'version: #{PROTOCOL}'"
36
31
  else
37
32
  "unsupported manifest version #{raw["version"].inspect}"
38
33
  end
@@ -47,11 +42,11 @@ module Textus
47
42
  @raw = raw
48
43
  raise BadFrontmatter.new(File.join(root, "manifest.yaml"), "manifest must declare zones:") if Array(raw["zones"]).empty?
49
44
 
50
- @entries = Array(raw["entries"]).map { |e| ManifestEntry.new(self, e) }
45
+ @entries = Array(raw["entries"]).map { |e| Manifest::Entry.new(self, e) }
51
46
  validate_declared_keys!
52
47
  end
53
48
 
54
- # Returns [ManifestEntry, resolved_path, remaining_segments]
49
+ # Returns [Manifest::Entry, resolved_path, remaining_segments]
55
50
  def resolve(key)
56
51
  validate_key!(key)
57
52
  segments = key.split(".")
@@ -70,7 +65,7 @@ module Textus
70
65
  else
71
66
  raise UnknownKey.new(key, suggestions: suggestions_for(key)) unless entry.nested
72
67
 
73
- primary_ext = Entry.for_format(entry.format).extensions.first
68
+ primary_ext = Textus::Entry.for_format(entry.format).extensions.first
74
69
  path = File.join(@root, "zones", entry.path, *remaining) + primary_ext
75
70
  [entry, path, remaining]
76
71
  end
@@ -83,7 +78,7 @@ module Textus
83
78
  # Include declared (non-nested) entry keys even if file is missing.
84
79
  candidates.concat(@entries.reject(&:nested).map(&:key))
85
80
  candidates.uniq!
86
- KeyDistance.suggest(key, candidates, limit: 5)
81
+ Key::Distance.suggest(key, candidates, limit: 5)
87
82
  rescue StandardError
88
83
  []
89
84
  end
@@ -107,7 +102,7 @@ module Textus
107
102
 
108
103
  illegal = segs.find { |s| !valid_segment?(s) }
109
104
  if illegal
110
- 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'")
111
106
  next
112
107
  end
113
108
 
@@ -138,30 +133,16 @@ module Textus
138
133
  def validate_key!(key)
139
134
  raise UsageError.new("empty key") if key.nil? || key.empty?
140
135
 
141
- segs = key.split(".")
142
- raise UsageError.new("key '#{key}' has #{segs.length} segments (max #{MAX_SEGMENTS})") if segs.length > MAX_SEGMENTS
143
-
144
- segs.each do |seg|
145
- if seg.empty?
146
- raise UsageError.new("empty segment in key '#{key}'")
147
- elsif seg.length > MAX_SEGMENT_LEN
148
- raise UsageError.new("segment '#{seg}' in key '#{key}' exceeds #{MAX_SEGMENT_LEN} chars")
149
- elsif !seg.match?(KEY_SEGMENT)
150
- raise UsageError.new(
151
- "invalid key segment '#{seg}' in '#{key}': must match [a-z0-9][a-z0-9-]* " \
152
- "(lowercase, digits, hyphens; no underscores or uppercase)",
153
- )
154
- end
155
- end
136
+ Key::Grammar.validate!(key)
156
137
  end
157
138
 
158
139
  private
159
140
 
160
141
  def valid_segment?(seg)
161
142
  return false if seg.nil? || seg.empty?
162
- return false if seg.length > MAX_SEGMENT_LEN
143
+ return false if seg.length > Key::Grammar::MAX_SEGMENT_LEN
163
144
 
164
- seg.match?(KEY_SEGMENT)
145
+ seg.match?(Key::Grammar::SEGMENT)
165
146
  end
166
147
 
167
148
  def validate_declared_keys!
@@ -169,12 +150,7 @@ module Textus
169
150
  end
170
151
 
171
152
  def resolve_leaf_path(entry)
172
- primary_ext = Entry.for_format(entry.format).extensions.first
173
- if File.extname(entry.path) == ""
174
- File.join(@root, "zones", entry.path + primary_ext)
175
- else
176
- File.join(@root, "zones", entry.path)
177
- end
153
+ Textus::Key::Path.resolve(self, entry)
178
154
  end
179
155
 
180
156
  def nested_glob(format)
@@ -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
@@ -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
@@ -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