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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +83 -1
- data/README.md +29 -21
- data/SPEC.md +75 -142
- data/docs/architecture.md +42 -23
- data/lib/textus/builder/pipeline.rb +56 -0
- data/lib/textus/builder/renderer/json.rb +42 -0
- data/lib/textus/builder/renderer/markdown.rb +22 -0
- data/lib/textus/builder/renderer/text.rb +14 -0
- data/lib/textus/builder/renderer/yaml.rb +42 -0
- data/lib/textus/builder/renderer.rb +17 -0
- data/lib/textus/builder.rb +9 -114
- data/lib/textus/cli/group/hook.rb +11 -0
- data/lib/textus/cli/group/key.rb +12 -0
- data/lib/textus/cli/group/schema.rb +13 -0
- data/lib/textus/cli/verb/accept.rb +15 -0
- data/lib/textus/cli/verb/build.rb +13 -0
- data/lib/textus/cli/verb/delete.rb +16 -0
- data/lib/textus/cli/verb/deps.rb +12 -0
- data/lib/textus/cli/verb/doctor.rb +15 -0
- data/lib/textus/cli/verb/get.rb +12 -0
- data/lib/textus/cli/verb/hook_run.rb +48 -0
- data/lib/textus/cli/verb/hooks.rb +50 -0
- data/lib/textus/cli/verb/init.rb +14 -0
- data/lib/textus/cli/verb/intro.rb +11 -0
- data/lib/textus/cli/verb/list.rb +14 -0
- data/lib/textus/cli/verb/migrate_keys.rb +16 -0
- data/lib/textus/cli/verb/mv.rb +17 -0
- data/lib/textus/cli/verb/published.rb +11 -0
- data/lib/textus/cli/verb/put.rb +50 -0
- data/lib/textus/cli/verb/rdeps.rb +12 -0
- data/lib/textus/cli/verb/refresh.rb +15 -0
- data/lib/textus/cli/verb/schema.rb +12 -0
- data/lib/textus/cli/verb/schema_diff.rb +12 -0
- data/lib/textus/cli/verb/schema_init.rb +16 -0
- data/lib/textus/cli/verb/schema_migrate.rb +16 -0
- data/lib/textus/cli/verb/stale.rb +14 -0
- data/lib/textus/cli/verb/uid.rb +12 -0
- data/lib/textus/cli/verb/where.rb +12 -0
- data/lib/textus/cli.rb +23 -42
- data/lib/textus/doctor/check/audit_log.rb +50 -0
- data/lib/textus/doctor/check/hooks.rb +29 -0
- data/lib/textus/doctor/check/illegal_keys.rb +49 -0
- data/lib/textus/doctor/check/manifest_files.rb +38 -0
- data/lib/textus/doctor/check/schema_violations.rb +22 -0
- data/lib/textus/doctor/check/schemas.rb +26 -0
- data/lib/textus/doctor/check/sentinels.rb +57 -0
- data/lib/textus/doctor/check/templates.rb +26 -0
- data/lib/textus/doctor/check/unowned_schema_fields.rb +34 -0
- data/lib/textus/doctor/check.rb +30 -0
- data/lib/textus/doctor.rb +22 -288
- data/lib/textus/entry/base.rb +30 -0
- data/lib/textus/entry/json.rb +5 -1
- data/lib/textus/entry/markdown.rb +1 -1
- data/lib/textus/entry/text.rb +1 -1
- data/lib/textus/entry/yaml.rb +5 -1
- data/lib/textus/entry.rb +0 -5
- data/lib/textus/envelope.rb +30 -0
- data/lib/textus/hooks/builtin.rb +70 -0
- data/lib/textus/hooks/dispatcher.rb +49 -0
- data/lib/textus/hooks/loader.rb +26 -0
- data/lib/textus/hooks/registry.rb +73 -0
- data/lib/textus/init.rb +13 -10
- data/lib/textus/intro.rb +14 -16
- data/lib/textus/key/distance.rb +55 -0
- data/lib/textus/key/grammar.rb +33 -0
- data/lib/textus/key/path.rb +17 -0
- data/lib/textus/manifest/entry.rb +199 -0
- data/lib/textus/manifest.rb +10 -34
- data/lib/textus/migrate_keys.rb +1 -1
- data/lib/textus/projection.rb +5 -4
- data/lib/textus/proposal.rb +1 -1
- data/lib/textus/refresh.rb +11 -11
- data/lib/textus/schema/tools.rb +89 -0
- data/lib/textus/store/audit_log.rb +71 -0
- data/lib/textus/store/mover.rb +19 -16
- data/lib/textus/store/reader.rb +67 -0
- data/lib/textus/store/staleness.rb +10 -19
- data/lib/textus/store/validator.rb +11 -8
- data/lib/textus/store/view.rb +29 -0
- data/lib/textus/store/writer.rb +132 -0
- data/lib/textus/store.rb +25 -221
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +14 -67
- metadata +73 -40
- data/lib/textus/audit_log.rb +0 -67
- data/lib/textus/builtin_actions.rb +0 -68
- data/lib/textus/cli/accept.rb +0 -13
- data/lib/textus/cli/action.rb +0 -51
- data/lib/textus/cli/build.rb +0 -11
- data/lib/textus/cli/delete.rb +0 -14
- data/lib/textus/cli/deprecated_alias.rb +0 -31
- data/lib/textus/cli/deps.rb +0 -10
- data/lib/textus/cli/doctor.rb +0 -13
- data/lib/textus/cli/extension_group.rb +0 -9
- data/lib/textus/cli/extensions.rb +0 -49
- data/lib/textus/cli/get.rb +0 -10
- data/lib/textus/cli/init.rb +0 -12
- data/lib/textus/cli/intro.rb +0 -9
- data/lib/textus/cli/key_group.rb +0 -10
- data/lib/textus/cli/list.rb +0 -12
- data/lib/textus/cli/migrate.rb +0 -41
- data/lib/textus/cli/migrate_keys.rb +0 -19
- data/lib/textus/cli/mv.rb +0 -20
- data/lib/textus/cli/published.rb +0 -9
- data/lib/textus/cli/put.rb +0 -48
- data/lib/textus/cli/rdeps.rb +0 -10
- data/lib/textus/cli/refresh.rb +0 -13
- data/lib/textus/cli/schema.rb +0 -10
- data/lib/textus/cli/schema_diff.rb +0 -15
- data/lib/textus/cli/schema_group.rb +0 -33
- data/lib/textus/cli/schema_init.rb +0 -19
- data/lib/textus/cli/schema_migrate.rb +0 -19
- data/lib/textus/cli/stale.rb +0 -12
- data/lib/textus/cli/uid.rb +0 -15
- data/lib/textus/cli/where.rb +0 -10
- data/lib/textus/extension_registry.rb +0 -61
- data/lib/textus/extensions.rb +0 -33
- data/lib/textus/key_distance.rb +0 -53
- data/lib/textus/manifest_entry.rb +0 -185
- data/lib/textus/migrate_v2.rb +0 -27
- data/lib/textus/schema_tools.rb +0 -87
- data/lib/textus/store/events.rb +0 -31
- 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, "
|
|
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, "
|
|
32
|
-
#
|
|
31
|
+
File.write(File.join(target_root, "hooks", "README.md"), <<~MD)
|
|
32
|
+
# Hooks
|
|
33
33
|
|
|
34
|
-
Drop one Ruby file per
|
|
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.
|
|
38
|
-
Textus.
|
|
39
|
-
Textus.hook(:
|
|
40
|
-
Textus.
|
|
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: :
|
|
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.
|
|
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" => "
|
|
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" => "
|
|
47
|
-
|
|
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.
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
data/lib/textus/manifest.rb
CHANGED
|
@@ -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;
|
|
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|
|
|
45
|
+
@entries = Array(raw["entries"]).map { |e| Manifest::Entry.new(self, e) }
|
|
51
46
|
validate_declared_keys!
|
|
52
47
|
end
|
|
53
48
|
|
|
54
|
-
# Returns [
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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?(
|
|
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
|
-
|
|
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)
|
data/lib/textus/migrate_keys.rb
CHANGED
|
@@ -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
|
data/lib/textus/projection.rb
CHANGED
|
@@ -38,13 +38,14 @@ module Textus
|
|
|
38
38
|
private
|
|
39
39
|
|
|
40
40
|
def apply_reducer(rows)
|
|
41
|
-
name = @spec["
|
|
42
|
-
callable = @store.registry.
|
|
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["
|
|
45
|
+
callable.call(store: view, rows: rows, config: @spec["reduce_config"] || {})
|
|
45
46
|
end
|
|
46
47
|
rescue Timeout::Error
|
|
47
|
-
raise UsageError.new("
|
|
48
|
+
raise UsageError.new("reduce '#{name}' exceeded #{REDUCER_TIMEOUT_SECONDS}s timeout")
|
|
48
49
|
end
|
|
49
50
|
|
|
50
51
|
def collect_keys
|
data/lib/textus/proposal.rb
CHANGED
|
@@ -20,7 +20,7 @@ module Textus
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
store.delete(pending_key, as: "human")
|
|
23
|
-
store.fire_event(:accept,
|
|
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
|