textus 0.15.0 → 0.18.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/ARCHITECTURE.md +14 -14
- data/CHANGELOG.md +313 -0
- data/README.md +13 -9
- data/SPEC.md +13 -10
- data/docs/conventions.md +2 -2
- data/lib/textus/application/context.rb +24 -0
- data/lib/textus/application/reads/audit.rb +1 -1
- data/lib/textus/application/reads/blame.rb +3 -1
- data/lib/textus/application/reads/deps.rb +1 -1
- data/lib/textus/application/reads/freshness.rb +12 -3
- data/lib/textus/application/reads/get.rb +32 -8
- data/lib/textus/application/reads/get_or_refresh.rb +5 -5
- data/lib/textus/application/reads/list.rb +3 -1
- data/lib/textus/application/reads/published.rb +1 -1
- data/lib/textus/application/reads/rdeps.rb +1 -1
- data/lib/textus/application/reads/schema_envelope.rb +3 -1
- data/lib/textus/application/reads/stale.rb +1 -1
- data/lib/textus/application/reads/uid.rb +1 -1
- data/lib/textus/application/reads/validate_all.rb +6 -1
- data/lib/textus/application/reads/validator.rb +84 -0
- data/lib/textus/application/reads/where.rb +4 -1
- data/lib/textus/application/refresh/all.rb +8 -1
- data/lib/textus/application/refresh/orchestrator.rb +2 -3
- data/lib/textus/application/refresh/worker.rb +18 -15
- data/lib/textus/application/writes/accept.rb +12 -12
- data/lib/textus/application/writes/build.rb +3 -4
- data/lib/textus/application/writes/delete.rb +10 -15
- data/lib/textus/application/writes/envelope_io.rb +106 -0
- data/lib/textus/application/writes/mv.rb +25 -27
- data/lib/textus/application/writes/publish.rb +8 -9
- data/lib/textus/application/writes/put.rb +12 -16
- data/lib/textus/application/writes/reject.rb +10 -10
- data/lib/textus/builder/pipeline.rb +2 -2
- data/lib/textus/cli/group/hook.rb +1 -3
- data/lib/textus/cli/group/key.rb +1 -4
- data/lib/textus/cli/group/refresh.rb +1 -2
- data/lib/textus/cli/group/rule.rb +1 -3
- data/lib/textus/cli/group/schema.rb +1 -5
- data/lib/textus/cli/group.rb +12 -16
- data/lib/textus/cli/verb/accept.rb +3 -1
- data/lib/textus/cli/verb/audit.rb +3 -1
- data/lib/textus/cli/verb/blame.rb +3 -1
- data/lib/textus/cli/verb/build.rb +4 -2
- data/lib/textus/cli/verb/delete.rb +3 -1
- data/lib/textus/cli/verb/deps.rb +3 -1
- data/lib/textus/cli/verb/doctor.rb +2 -0
- data/lib/textus/cli/verb/freshness.rb +3 -1
- data/lib/textus/cli/verb/get.rb +3 -1
- data/lib/textus/cli/verb/hook_run.rb +3 -0
- data/lib/textus/cli/verb/hooks.rb +3 -0
- data/lib/textus/cli/verb/init.rb +2 -0
- data/lib/textus/cli/verb/intro.rb +2 -0
- data/lib/textus/cli/verb/key_normalize.rb +3 -0
- data/lib/textus/cli/verb/list.rb +3 -1
- data/lib/textus/cli/verb/mv.rb +4 -1
- data/lib/textus/cli/verb/published.rb +3 -1
- data/lib/textus/cli/verb/put.rb +3 -1
- data/lib/textus/cli/verb/rdeps.rb +3 -1
- data/lib/textus/cli/verb/refresh.rb +1 -1
- data/lib/textus/cli/verb/refresh_stale.rb +3 -0
- data/lib/textus/cli/verb/reject.rb +3 -1
- data/lib/textus/cli/verb/rule_explain.rb +4 -1
- data/lib/textus/cli/verb/rule_list.rb +3 -0
- data/lib/textus/cli/verb/schema.rb +4 -1
- data/lib/textus/cli/verb/schema_diff.rb +3 -0
- data/lib/textus/cli/verb/schema_init.rb +3 -0
- data/lib/textus/cli/verb/schema_migrate.rb +3 -0
- data/lib/textus/cli/verb/uid.rb +4 -1
- data/lib/textus/cli/verb/where.rb +3 -1
- data/lib/textus/cli/verb.rb +30 -0
- data/lib/textus/cli.rb +18 -27
- data/lib/textus/doctor/check/audit_log.rb +1 -1
- data/lib/textus/doctor/check/hooks.rb +3 -1
- data/lib/textus/doctor/check/intake_registration.rb +3 -3
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/sentinels.rb +2 -2
- data/lib/textus/domain/freshness/evaluator.rb +1 -1
- data/lib/textus/domain/freshness/policy.rb +1 -1
- data/lib/textus/domain/freshness/verdict.rb +1 -1
- data/lib/textus/domain/freshness.rb +40 -0
- data/lib/textus/domain/policy/predicates/schema_valid.rb +2 -2
- data/lib/textus/{store → domain}/sentinel.rb +1 -1
- data/lib/textus/{store → domain}/staleness/generator_check.rb +1 -1
- data/lib/textus/{store → domain}/staleness/intake_check.rb +1 -1
- data/lib/textus/{store → domain}/staleness.rb +1 -1
- data/lib/textus/entry/json.rb +1 -1
- data/lib/textus/entry/markdown.rb +1 -1
- data/lib/textus/entry/yaml.rb +1 -1
- data/lib/textus/envelope.rb +7 -3
- data/lib/textus/errors.rb +19 -0
- data/lib/textus/hooks/builtin.rb +6 -6
- data/lib/textus/hooks/dispatcher.rb +17 -9
- data/lib/textus/hooks/loader.rb +20 -17
- data/lib/textus/hooks/registry.rb +4 -0
- data/lib/textus/{store → infra}/audit_log.rb +1 -1
- data/lib/textus/infra/audit_subscriber.rb +43 -0
- data/lib/textus/infra/publisher.rb +3 -3
- data/lib/textus/infra/storage/file_store.rb +26 -0
- data/lib/textus/init.rb +11 -9
- data/lib/textus/manifest/resolution.rb +5 -0
- data/lib/textus/manifest.rb +4 -3
- data/lib/textus/migrate_keys.rb +1 -1
- data/lib/textus/operations.rb +83 -16
- data/lib/textus/projection.rb +2 -2
- data/lib/textus/refresh.rb +1 -1
- data/lib/textus/schema/tools.rb +5 -5
- data/lib/textus/schemas.rb +46 -0
- data/lib/textus/store.rb +12 -49
- data/lib/textus/uid.rb +18 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +17 -1
- metadata +14 -13
- data/lib/textus/hooks/dsl.rb +0 -11
- data/lib/textus/operations/reads.rb +0 -56
- data/lib/textus/operations/refresh.rb +0 -27
- data/lib/textus/operations/writes.rb +0 -21
- data/lib/textus/store/reader.rb +0 -69
- data/lib/textus/store/validator.rb +0 -82
- data/lib/textus/store/writer.rb +0 -102
|
@@ -2,11 +2,13 @@ module Textus
|
|
|
2
2
|
class CLI
|
|
3
3
|
class Verb
|
|
4
4
|
class Reject < Verb
|
|
5
|
+
command_name "reject"
|
|
6
|
+
|
|
5
7
|
option :as_flag, "--as=ROLE"
|
|
6
8
|
|
|
7
9
|
def call(store)
|
|
8
10
|
key = positional.shift or raise UsageError.new("reject requires a key")
|
|
9
|
-
emit(operations_for(store).
|
|
11
|
+
emit(operations_for(store).reject(key))
|
|
10
12
|
end
|
|
11
13
|
end
|
|
12
14
|
end
|
|
@@ -2,9 +2,12 @@ module Textus
|
|
|
2
2
|
class CLI
|
|
3
3
|
class Verb
|
|
4
4
|
class RuleExplain < Verb
|
|
5
|
+
command_name "explain"
|
|
6
|
+
parent_group Group::Rule
|
|
7
|
+
|
|
5
8
|
def call(store)
|
|
6
9
|
key = positional.shift or raise UsageError.new("policy explain requires a KEY")
|
|
7
|
-
result = operations_for(store).
|
|
10
|
+
result = operations_for(store).policy_explain(key: key)
|
|
8
11
|
emit({ "verb" => "policy_explain" }.merge(result.transform_keys(&:to_s)))
|
|
9
12
|
end
|
|
10
13
|
end
|
|
@@ -2,9 +2,12 @@ module Textus
|
|
|
2
2
|
class CLI
|
|
3
3
|
class Verb
|
|
4
4
|
class Schema < Verb
|
|
5
|
+
command_name "show"
|
|
6
|
+
parent_group Group::Schema
|
|
7
|
+
|
|
5
8
|
def call(store)
|
|
6
9
|
key = positional.shift or raise UsageError.new("schema requires a key")
|
|
7
|
-
emit(operations_for(store).
|
|
10
|
+
emit(operations_for(store).schema_envelope(key))
|
|
8
11
|
end
|
|
9
12
|
end
|
|
10
13
|
end
|
data/lib/textus/cli/verb/uid.rb
CHANGED
|
@@ -2,9 +2,12 @@ module Textus
|
|
|
2
2
|
class CLI
|
|
3
3
|
class Verb
|
|
4
4
|
class Uid < Verb
|
|
5
|
+
command_name "uid"
|
|
6
|
+
parent_group Group::Key
|
|
7
|
+
|
|
5
8
|
def call(store)
|
|
6
9
|
key = positional.shift or raise UsageError.new("uid requires a key")
|
|
7
|
-
emit({ "key" => key, "uid" => operations_for(store).
|
|
10
|
+
emit({ "key" => key, "uid" => operations_for(store).uid(key) })
|
|
8
11
|
end
|
|
9
12
|
end
|
|
10
13
|
end
|
|
@@ -2,9 +2,11 @@ module Textus
|
|
|
2
2
|
class CLI
|
|
3
3
|
class Verb
|
|
4
4
|
class Where < Verb
|
|
5
|
+
command_name "where"
|
|
6
|
+
|
|
5
7
|
def call(store)
|
|
6
8
|
key = positional.shift or raise UsageError.new("where requires a key")
|
|
7
|
-
emit(operations_for(store).
|
|
9
|
+
emit(operations_for(store).where(key))
|
|
8
10
|
end
|
|
9
11
|
end
|
|
10
12
|
end
|
data/lib/textus/cli/verb.rb
CHANGED
|
@@ -22,9 +22,39 @@ module Textus
|
|
|
22
22
|
true
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# Declarative CLI name. Reader returns the registered name (or nil
|
|
26
|
+
# for verbs that aren't directly invokable, like the abstract
|
|
27
|
+
# Verb/Group base classes). Writer registers it.
|
|
28
|
+
def command_name(name = nil)
|
|
29
|
+
if name.nil?
|
|
30
|
+
@command_name
|
|
31
|
+
else
|
|
32
|
+
@command_name = name.to_s
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Declares that this verb is a subcommand of `group_klass`. When
|
|
37
|
+
# set, the verb is NOT a top-level CLI verb — it's listed under
|
|
38
|
+
# the group's subcommands instead.
|
|
39
|
+
def parent_group(group_klass = nil)
|
|
40
|
+
if group_klass.nil?
|
|
41
|
+
@parent_group
|
|
42
|
+
else
|
|
43
|
+
@parent_group = group_klass
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
25
47
|
def inherited(subclass)
|
|
26
48
|
super
|
|
27
49
|
subclass.instance_variable_set(:@options, [])
|
|
50
|
+
subclass.instance_variable_set(:@command_name, nil)
|
|
51
|
+
subclass.instance_variable_set(:@parent_group, nil)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Recursive subclass enumeration. Ruby 3.1 ships Class#subclasses
|
|
55
|
+
# but not Class#descendants, so we expand it ourselves.
|
|
56
|
+
def descendants
|
|
57
|
+
subclasses.flat_map { |k| [k] + k.descendants }
|
|
28
58
|
end
|
|
29
59
|
end
|
|
30
60
|
|
data/lib/textus/cli.rb
CHANGED
|
@@ -3,32 +3,23 @@ require "optparse"
|
|
|
3
3
|
|
|
4
4
|
module Textus
|
|
5
5
|
class CLI
|
|
6
|
-
# verb
|
|
7
|
-
#
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
"list" => Verb::List,
|
|
24
|
-
"published" => Verb::Published,
|
|
25
|
-
"put" => Verb::Put,
|
|
26
|
-
"rdeps" => Verb::Rdeps,
|
|
27
|
-
"refresh" => Group::Refresh,
|
|
28
|
-
"rule" => Group::Rule,
|
|
29
|
-
"schema" => Group::Schema,
|
|
30
|
-
"where" => Verb::Where,
|
|
31
|
-
}.freeze
|
|
6
|
+
# Auto-derived verb table. Every CLI::Verb (or Group) subclass that
|
|
7
|
+
# declares `command_name "X"` and has no `parent_group` is a top-level
|
|
8
|
+
# verb. Sorted alphabetically for stable help output. Adding a new
|
|
9
|
+
# verb requires only a new file declaring its `command_name`.
|
|
10
|
+
def self.verbs
|
|
11
|
+
Verb.descendants
|
|
12
|
+
.select { |k| k.command_name && k.parent_group.nil? }
|
|
13
|
+
.sort_by(&:command_name)
|
|
14
|
+
.to_h { |k| [k.command_name, k] }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Backward-compat constant; callers should prefer `CLI.verbs`.
|
|
18
|
+
def self.const_missing(name)
|
|
19
|
+
return verbs.freeze if name == :VERBS
|
|
20
|
+
|
|
21
|
+
super
|
|
22
|
+
end
|
|
32
23
|
|
|
33
24
|
def self.run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, cwd: Dir.pwd)
|
|
34
25
|
new(stdin: stdin, stdout: stdout, stderr: stderr, cwd: cwd).run(argv)
|
|
@@ -54,7 +45,7 @@ module Textus
|
|
|
54
45
|
when "--help", "-h" then print_help
|
|
55
46
|
0
|
|
56
47
|
else
|
|
57
|
-
klass =
|
|
48
|
+
klass = self.class.verbs[verb] or raise UsageError.new("unknown verb: #{verb}")
|
|
58
49
|
dispatch(klass, argv)
|
|
59
50
|
end
|
|
60
51
|
|
|
@@ -4,7 +4,7 @@ module Textus
|
|
|
4
4
|
class AuditLog < Check
|
|
5
5
|
def call
|
|
6
6
|
path = File.join(store.root, "audit.log")
|
|
7
|
-
Textus::
|
|
7
|
+
Textus::Infra::AuditLog.new(store.root).verify_integrity.map do |v|
|
|
8
8
|
{
|
|
9
9
|
"code" => "audit.parse_error",
|
|
10
10
|
"level" => "warning",
|
|
@@ -9,8 +9,10 @@ module Textus
|
|
|
9
9
|
|
|
10
10
|
Dir.glob(File.join(dir, "*.rb")).sort.each do |f| # rubocop:disable Lint/RedundantDirGlobSort
|
|
11
11
|
registry = Textus::Hooks::Registry.new
|
|
12
|
-
Textus.
|
|
12
|
+
Textus.drain_hook_blocks
|
|
13
|
+
begin
|
|
13
14
|
load(f)
|
|
15
|
+
Textus.drain_hook_blocks.each { |b| b.call(registry) }
|
|
14
16
|
end
|
|
15
17
|
rescue StandardError, ScriptError => e
|
|
16
18
|
out << {
|
|
@@ -13,8 +13,8 @@ module Textus
|
|
|
13
13
|
"code" => "intake.handler_missing",
|
|
14
14
|
"level" => "error",
|
|
15
15
|
"subject" => name.to_s,
|
|
16
|
-
"message" => "manifest references intake handler '#{name}' but no
|
|
17
|
-
"fix" => "create .textus/hooks/#{name}.rb with `Textus.on(:resolve_intake, :#{name}) { ... }`",
|
|
16
|
+
"message" => "manifest references intake handler '#{name}' but no resolve_intake hook for '#{name}' is registered",
|
|
17
|
+
"fix" => "create .textus/hooks/#{name}.rb with `Textus.hook { |reg| reg.on(:resolve_intake, :#{name}) { ... } }`",
|
|
18
18
|
}
|
|
19
19
|
end
|
|
20
20
|
|
|
@@ -23,7 +23,7 @@ module Textus
|
|
|
23
23
|
"code" => "intake.handler_orphan",
|
|
24
24
|
"level" => "warning",
|
|
25
25
|
"subject" => name.to_s,
|
|
26
|
-
"message" => "
|
|
26
|
+
"message" => "resolve_intake hook '#{name}' is registered but no manifest entry references it",
|
|
27
27
|
"fix" => "remove the unused handler, or add an entry with `intake.handler: #{name}`",
|
|
28
28
|
}
|
|
29
29
|
end
|
|
@@ -3,7 +3,7 @@ module Textus
|
|
|
3
3
|
class Check
|
|
4
4
|
class SchemaViolations < Check
|
|
5
5
|
def call
|
|
6
|
-
res = Textus::Operations.for(store).
|
|
6
|
+
res = Textus::Operations.for(store).validate_all
|
|
7
7
|
res["violations"].map do |v|
|
|
8
8
|
fix = v["expected"] &&
|
|
9
9
|
"field '#{v["field"]}' should be written by '#{v["expected"]}' (last writer: #{v["last_writer"]})"
|
|
@@ -7,7 +7,7 @@ module Textus
|
|
|
7
7
|
return [] unless File.directory?(dir)
|
|
8
8
|
|
|
9
9
|
repo_root = File.dirname(store.root)
|
|
10
|
-
Dir.glob(File.join(dir, "**", "*#{Textus::
|
|
10
|
+
Dir.glob(File.join(dir, "**", "*#{Textus::Domain::Sentinel::SUFFIX}")).flat_map do |sentinel_path|
|
|
11
11
|
inspect_sentinel(sentinel_path, repo_root)
|
|
12
12
|
end
|
|
13
13
|
end
|
|
@@ -15,7 +15,7 @@ module Textus
|
|
|
15
15
|
private
|
|
16
16
|
|
|
17
17
|
def inspect_sentinel(sentinel_path, repo_root)
|
|
18
|
-
sentinel = Textus::
|
|
18
|
+
sentinel = Textus::Domain::Sentinel.load(sentinel_path, repo_root)
|
|
19
19
|
return [parse_error_issue(sentinel_path)] if sentinel.nil?
|
|
20
20
|
return [orphan_issue(sentinel_path, sentinel)] if sentinel.orphan?
|
|
21
21
|
return [drift_issue(sentinel)] if sentinel.drift?
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Domain
|
|
5
|
+
# Value object describing the freshness annotation attached to an Envelope
|
|
6
|
+
# after a freshness evaluation. Replaces the loose Hash that used to live
|
|
7
|
+
# on `Envelope#freshness`.
|
|
8
|
+
#
|
|
9
|
+
# Note on wire format: `#to_h_for_wire` is intentionally narrower than the
|
|
10
|
+
# full field set. It emits the legacy keys ("stale", "stale_reason",
|
|
11
|
+
# "refreshing", and "refresh_error" when present) so the CLI JSON wire
|
|
12
|
+
# stays byte-identical with textus/3. The gem-side fields `checked_at`
|
|
13
|
+
# and `ttl_remaining_ms` are NOT emitted on the wire in this phase.
|
|
14
|
+
Freshness = Data.define(
|
|
15
|
+
:stale, :refreshing, :reason, :refresh_error, :checked_at, :ttl_remaining_ms
|
|
16
|
+
) do
|
|
17
|
+
def self.build(stale:, refreshing: false, reason: nil, refresh_error: nil,
|
|
18
|
+
checked_at: nil, ttl_remaining_ms: nil)
|
|
19
|
+
new(
|
|
20
|
+
stale: stale,
|
|
21
|
+
refreshing: refreshing,
|
|
22
|
+
reason: reason,
|
|
23
|
+
refresh_error: refresh_error,
|
|
24
|
+
checked_at: checked_at,
|
|
25
|
+
ttl_remaining_ms: ttl_remaining_ms,
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_h_for_wire
|
|
30
|
+
h = {
|
|
31
|
+
"stale" => stale,
|
|
32
|
+
"stale_reason" => reason,
|
|
33
|
+
"refreshing" => refreshing,
|
|
34
|
+
}
|
|
35
|
+
h["refresh_error"] = refresh_error unless refresh_error.nil?
|
|
36
|
+
h
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -15,11 +15,11 @@ module Textus
|
|
|
15
15
|
target_key = entry.meta&.dig("proposal", "target_key")
|
|
16
16
|
return true unless target_key
|
|
17
17
|
|
|
18
|
-
mentry
|
|
18
|
+
mentry = store.manifest.resolve(target_key).entry
|
|
19
19
|
schema_ref = mentry&.schema
|
|
20
20
|
return true unless schema_ref
|
|
21
21
|
|
|
22
|
-
schema = store.
|
|
22
|
+
schema = store.schemas.fetch_or_nil(schema_ref)
|
|
23
23
|
return true unless schema
|
|
24
24
|
|
|
25
25
|
frontmatter = entry.meta&.dig("frontmatter") || {}
|
|
@@ -3,7 +3,7 @@ require "digest"
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
|
|
5
5
|
module Textus
|
|
6
|
-
|
|
6
|
+
module Domain
|
|
7
7
|
# Value object for sentinel files written by Infra::Publisher and inspected
|
|
8
8
|
# by Doctor::Check::Sentinels. Owns the JSON shape ({source, target,
|
|
9
9
|
# sha256, mode}) and the on-disk path layout (<store_root>/sentinels/
|
data/lib/textus/entry/json.rb
CHANGED
|
@@ -85,7 +85,7 @@ module Textus
|
|
|
85
85
|
|
|
86
86
|
def self.inject_uid(meta, content, existing_uid)
|
|
87
87
|
m = meta.is_a?(Hash) ? meta.dup : {}
|
|
88
|
-
m["uid"] = existing_uid || Textus::
|
|
88
|
+
m["uid"] = existing_uid || Textus::Uid.mint unless m["uid"].is_a?(String) && !m["uid"].empty?
|
|
89
89
|
[m, content]
|
|
90
90
|
end
|
|
91
91
|
|
|
@@ -70,7 +70,7 @@ module Textus
|
|
|
70
70
|
|
|
71
71
|
def self.inject_uid(meta, content, existing_uid)
|
|
72
72
|
m = meta.is_a?(Hash) ? meta.dup : {}
|
|
73
|
-
m["uid"] = existing_uid || Textus::
|
|
73
|
+
m["uid"] = existing_uid || Textus::Uid.mint unless m["uid"].is_a?(String) && !m["uid"].empty?
|
|
74
74
|
[m, content]
|
|
75
75
|
end
|
|
76
76
|
|
data/lib/textus/entry/yaml.rb
CHANGED
|
@@ -83,7 +83,7 @@ module Textus
|
|
|
83
83
|
|
|
84
84
|
def self.inject_uid(meta, content, existing_uid)
|
|
85
85
|
m = meta.is_a?(Hash) ? meta.dup : {}
|
|
86
|
-
m["uid"] = existing_uid || Textus::
|
|
86
|
+
m["uid"] = existing_uid || Textus::Uid.mint unless m["uid"].is_a?(String) && !m["uid"].empty?
|
|
87
87
|
[m, content]
|
|
88
88
|
end
|
|
89
89
|
|
data/lib/textus/envelope.rb
CHANGED
|
@@ -45,16 +45,20 @@ module Textus
|
|
|
45
45
|
"uid" => uid,
|
|
46
46
|
}
|
|
47
47
|
h["content"] = content unless content.nil?
|
|
48
|
-
freshness
|
|
48
|
+
freshness&.to_h_for_wire&.each { |k, v| h[k] = v }
|
|
49
49
|
h
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
def stale?
|
|
53
|
-
|
|
53
|
+
return false if freshness.nil?
|
|
54
|
+
|
|
55
|
+
freshness.stale == true
|
|
54
56
|
end
|
|
55
57
|
|
|
56
58
|
def refreshing?
|
|
57
|
-
|
|
59
|
+
return false if freshness.nil?
|
|
60
|
+
|
|
61
|
+
freshness.refreshing == true
|
|
58
62
|
end
|
|
59
63
|
end
|
|
60
64
|
end
|
data/lib/textus/errors.rb
CHANGED
|
@@ -108,6 +108,25 @@ module Textus
|
|
|
108
108
|
end
|
|
109
109
|
end
|
|
110
110
|
|
|
111
|
+
class ReadForbidden < Error
|
|
112
|
+
def initialize(k, z, readers: nil)
|
|
113
|
+
readers_str =
|
|
114
|
+
if readers && !readers.empty?
|
|
115
|
+
readers.join(", ")
|
|
116
|
+
else
|
|
117
|
+
"the role(s) listed in the manifest 'read_policy:'"
|
|
118
|
+
end
|
|
119
|
+
details = { "key" => k, "zone" => z }
|
|
120
|
+
details["readers"] = readers if readers
|
|
121
|
+
super(
|
|
122
|
+
"read_forbidden",
|
|
123
|
+
"zone '#{z}' is not readable by role for key '#{k}'",
|
|
124
|
+
details: details,
|
|
125
|
+
hint: "this zone is readable by #{readers_str}; pass --as=<role>",
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
111
130
|
class EtagMismatch < Error
|
|
112
131
|
def initialize(k, w, g)
|
|
113
132
|
super(
|
data/lib/textus/hooks/builtin.rb
CHANGED
|
@@ -7,22 +7,22 @@ module Textus
|
|
|
7
7
|
module Hooks
|
|
8
8
|
module Builtin
|
|
9
9
|
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
10
|
-
def self.register_all
|
|
11
|
-
|
|
10
|
+
def self.register_all(registry)
|
|
11
|
+
registry.on(:resolve_intake, :json) do |store:, config:, args:|
|
|
12
12
|
_ = store
|
|
13
13
|
_ = args
|
|
14
14
|
data = JSON.parse(config["bytes"].to_s)
|
|
15
15
|
{ _meta: {}, body: YAML.dump(data) }
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
registry.on(:resolve_intake, :csv) do |store:, config:, args:|
|
|
19
19
|
_ = store
|
|
20
20
|
_ = args
|
|
21
21
|
rows = CSV.parse(config["bytes"].to_s, headers: true).map(&:to_h)
|
|
22
22
|
{ _meta: {}, body: YAML.dump(rows) }
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
registry.on(:resolve_intake, :"markdown-links") do |store:, config:, args:|
|
|
26
26
|
_ = store
|
|
27
27
|
_ = args
|
|
28
28
|
links = config["bytes"].to_s.scan(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}).map do |text, href|
|
|
@@ -31,7 +31,7 @@ module Textus
|
|
|
31
31
|
{ _meta: {}, body: YAML.dump(links) }
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
registry.on(:resolve_intake, :"ical-events") do |store:, config:, args:|
|
|
35
35
|
_ = store
|
|
36
36
|
_ = args
|
|
37
37
|
events = []
|
|
@@ -50,7 +50,7 @@ module Textus
|
|
|
50
50
|
{ _meta: {}, body: YAML.dump(events) }
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
registry.on(:resolve_intake, :rss) do |store:, config:, args:|
|
|
54
54
|
_ = store
|
|
55
55
|
_ = args
|
|
56
56
|
doc = REXML::Document.new(config["bytes"].to_s)
|
|
@@ -7,9 +7,15 @@ module Textus
|
|
|
7
7
|
class Dispatcher
|
|
8
8
|
HOOK_TIMEOUT_SECONDS = 2
|
|
9
9
|
|
|
10
|
-
def initialize
|
|
11
|
-
@audit_log = audit_log
|
|
10
|
+
def initialize
|
|
12
11
|
@subscribers = Hash.new { |h, k| h[k] = [] }
|
|
12
|
+
@error_handlers = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Register an error callback invoked when a user hook raises.
|
|
16
|
+
# Used by Infra::AuditSubscriber to record an "event_error" audit row.
|
|
17
|
+
def on_error(&block)
|
|
18
|
+
@error_handlers << block
|
|
13
19
|
end
|
|
14
20
|
|
|
15
21
|
def subscribe(event, name, keys: nil, &block)
|
|
@@ -31,13 +37,15 @@ module Textus
|
|
|
31
37
|
accepted = filter_kwargs(sub[:callable], kwargs)
|
|
32
38
|
Timeout.timeout(HOOK_TIMEOUT_SECONDS) { sub[:callable].call(**accepted) }
|
|
33
39
|
rescue StandardError => e
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
notify_error(event, sub, key, kwargs, e)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def notify_error(event, sub, key, kwargs, error)
|
|
44
|
+
@error_handlers.each do |handler|
|
|
45
|
+
handler.call(event: event, hook: sub[:name], key: key, kwargs: kwargs, error: error)
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
warn "[textus] error handler failed: #{e.class}: #{e.message}"
|
|
48
|
+
end
|
|
41
49
|
end
|
|
42
50
|
|
|
43
51
|
# Passes only the kwargs a hook block declares. Lets us extend event
|
data/lib/textus/hooks/loader.rb
CHANGED
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Hooks
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def self.with_registry(registry)
|
|
8
|
-
prev = Thread.current[THREAD_REGISTRY_KEY]
|
|
9
|
-
Thread.current[THREAD_REGISTRY_KEY] = registry
|
|
10
|
-
yield
|
|
11
|
-
ensure
|
|
12
|
-
Thread.current[THREAD_REGISTRY_KEY] = prev
|
|
3
|
+
class Loader
|
|
4
|
+
def initialize(registry:)
|
|
5
|
+
@registry = registry
|
|
13
6
|
end
|
|
14
7
|
|
|
15
|
-
def
|
|
16
|
-
|
|
17
|
-
|
|
8
|
+
def load_dir(dir)
|
|
9
|
+
return unless File.directory?(dir)
|
|
10
|
+
|
|
11
|
+
# Discard any leftover blocks from a prior partial load.
|
|
12
|
+
Textus.drain_hook_blocks
|
|
13
|
+
|
|
14
|
+
Dir.glob(File.join(dir, "**/*.rb")).sort.each do |f| # rubocop:disable Lint/RedundantDirGlobSort
|
|
15
|
+
load(f)
|
|
16
|
+
rescue StandardError, ScriptError => e
|
|
17
|
+
raise UsageError.new("failed loading hook #{File.basename(f)}: #{e.class}: #{e.message}")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
Textus.drain_hook_blocks.each do |blk|
|
|
21
|
+
blk.call(@registry)
|
|
22
|
+
rescue StandardError, ScriptError => e
|
|
23
|
+
raise UsageError.new("failed registering hook: #{e.class}: #{e.message}")
|
|
24
|
+
end
|
|
18
25
|
end
|
|
19
26
|
end
|
|
20
27
|
end
|
|
21
|
-
|
|
22
|
-
# Public DSL
|
|
23
|
-
def self.with_registry(registry, &) = Hooks::Loader.with_registry(registry, &)
|
|
24
|
-
def self.current_registry = Hooks::Loader.current_registry
|
|
25
28
|
end
|
|
@@ -28,6 +28,10 @@ module Textus
|
|
|
28
28
|
@dispatcher = dispatcher
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
def on(event, name, keys: nil, &)
|
|
32
|
+
register(event, name, keys: keys, &)
|
|
33
|
+
end
|
|
34
|
+
|
|
31
35
|
def register(event, name, keys: nil, &blk)
|
|
32
36
|
event_sym = event.to_sym
|
|
33
37
|
spec = EVENTS[event_sym] or raise UsageError.new("unknown event: #{event}")
|