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.
Files changed (120) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +14 -14
  3. data/CHANGELOG.md +313 -0
  4. data/README.md +13 -9
  5. data/SPEC.md +13 -10
  6. data/docs/conventions.md +2 -2
  7. data/lib/textus/application/context.rb +24 -0
  8. data/lib/textus/application/reads/audit.rb +1 -1
  9. data/lib/textus/application/reads/blame.rb +3 -1
  10. data/lib/textus/application/reads/deps.rb +1 -1
  11. data/lib/textus/application/reads/freshness.rb +12 -3
  12. data/lib/textus/application/reads/get.rb +32 -8
  13. data/lib/textus/application/reads/get_or_refresh.rb +5 -5
  14. data/lib/textus/application/reads/list.rb +3 -1
  15. data/lib/textus/application/reads/published.rb +1 -1
  16. data/lib/textus/application/reads/rdeps.rb +1 -1
  17. data/lib/textus/application/reads/schema_envelope.rb +3 -1
  18. data/lib/textus/application/reads/stale.rb +1 -1
  19. data/lib/textus/application/reads/uid.rb +1 -1
  20. data/lib/textus/application/reads/validate_all.rb +6 -1
  21. data/lib/textus/application/reads/validator.rb +84 -0
  22. data/lib/textus/application/reads/where.rb +4 -1
  23. data/lib/textus/application/refresh/all.rb +8 -1
  24. data/lib/textus/application/refresh/orchestrator.rb +2 -3
  25. data/lib/textus/application/refresh/worker.rb +18 -15
  26. data/lib/textus/application/writes/accept.rb +12 -12
  27. data/lib/textus/application/writes/build.rb +3 -4
  28. data/lib/textus/application/writes/delete.rb +10 -15
  29. data/lib/textus/application/writes/envelope_io.rb +106 -0
  30. data/lib/textus/application/writes/mv.rb +25 -27
  31. data/lib/textus/application/writes/publish.rb +8 -9
  32. data/lib/textus/application/writes/put.rb +12 -16
  33. data/lib/textus/application/writes/reject.rb +10 -10
  34. data/lib/textus/builder/pipeline.rb +2 -2
  35. data/lib/textus/cli/group/hook.rb +1 -3
  36. data/lib/textus/cli/group/key.rb +1 -4
  37. data/lib/textus/cli/group/refresh.rb +1 -2
  38. data/lib/textus/cli/group/rule.rb +1 -3
  39. data/lib/textus/cli/group/schema.rb +1 -5
  40. data/lib/textus/cli/group.rb +12 -16
  41. data/lib/textus/cli/verb/accept.rb +3 -1
  42. data/lib/textus/cli/verb/audit.rb +3 -1
  43. data/lib/textus/cli/verb/blame.rb +3 -1
  44. data/lib/textus/cli/verb/build.rb +4 -2
  45. data/lib/textus/cli/verb/delete.rb +3 -1
  46. data/lib/textus/cli/verb/deps.rb +3 -1
  47. data/lib/textus/cli/verb/doctor.rb +2 -0
  48. data/lib/textus/cli/verb/freshness.rb +3 -1
  49. data/lib/textus/cli/verb/get.rb +3 -1
  50. data/lib/textus/cli/verb/hook_run.rb +3 -0
  51. data/lib/textus/cli/verb/hooks.rb +3 -0
  52. data/lib/textus/cli/verb/init.rb +2 -0
  53. data/lib/textus/cli/verb/intro.rb +2 -0
  54. data/lib/textus/cli/verb/key_normalize.rb +3 -0
  55. data/lib/textus/cli/verb/list.rb +3 -1
  56. data/lib/textus/cli/verb/mv.rb +4 -1
  57. data/lib/textus/cli/verb/published.rb +3 -1
  58. data/lib/textus/cli/verb/put.rb +3 -1
  59. data/lib/textus/cli/verb/rdeps.rb +3 -1
  60. data/lib/textus/cli/verb/refresh.rb +1 -1
  61. data/lib/textus/cli/verb/refresh_stale.rb +3 -0
  62. data/lib/textus/cli/verb/reject.rb +3 -1
  63. data/lib/textus/cli/verb/rule_explain.rb +4 -1
  64. data/lib/textus/cli/verb/rule_list.rb +3 -0
  65. data/lib/textus/cli/verb/schema.rb +4 -1
  66. data/lib/textus/cli/verb/schema_diff.rb +3 -0
  67. data/lib/textus/cli/verb/schema_init.rb +3 -0
  68. data/lib/textus/cli/verb/schema_migrate.rb +3 -0
  69. data/lib/textus/cli/verb/uid.rb +4 -1
  70. data/lib/textus/cli/verb/where.rb +3 -1
  71. data/lib/textus/cli/verb.rb +30 -0
  72. data/lib/textus/cli.rb +18 -27
  73. data/lib/textus/doctor/check/audit_log.rb +1 -1
  74. data/lib/textus/doctor/check/hooks.rb +3 -1
  75. data/lib/textus/doctor/check/intake_registration.rb +3 -3
  76. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  77. data/lib/textus/doctor/check/sentinels.rb +2 -2
  78. data/lib/textus/domain/freshness/evaluator.rb +1 -1
  79. data/lib/textus/domain/freshness/policy.rb +1 -1
  80. data/lib/textus/domain/freshness/verdict.rb +1 -1
  81. data/lib/textus/domain/freshness.rb +40 -0
  82. data/lib/textus/domain/policy/predicates/schema_valid.rb +2 -2
  83. data/lib/textus/{store → domain}/sentinel.rb +1 -1
  84. data/lib/textus/{store → domain}/staleness/generator_check.rb +1 -1
  85. data/lib/textus/{store → domain}/staleness/intake_check.rb +1 -1
  86. data/lib/textus/{store → domain}/staleness.rb +1 -1
  87. data/lib/textus/entry/json.rb +1 -1
  88. data/lib/textus/entry/markdown.rb +1 -1
  89. data/lib/textus/entry/yaml.rb +1 -1
  90. data/lib/textus/envelope.rb +7 -3
  91. data/lib/textus/errors.rb +19 -0
  92. data/lib/textus/hooks/builtin.rb +6 -6
  93. data/lib/textus/hooks/dispatcher.rb +17 -9
  94. data/lib/textus/hooks/loader.rb +20 -17
  95. data/lib/textus/hooks/registry.rb +4 -0
  96. data/lib/textus/{store → infra}/audit_log.rb +1 -1
  97. data/lib/textus/infra/audit_subscriber.rb +43 -0
  98. data/lib/textus/infra/publisher.rb +3 -3
  99. data/lib/textus/infra/storage/file_store.rb +26 -0
  100. data/lib/textus/init.rb +11 -9
  101. data/lib/textus/manifest/resolution.rb +5 -0
  102. data/lib/textus/manifest.rb +4 -3
  103. data/lib/textus/migrate_keys.rb +1 -1
  104. data/lib/textus/operations.rb +83 -16
  105. data/lib/textus/projection.rb +2 -2
  106. data/lib/textus/refresh.rb +1 -1
  107. data/lib/textus/schema/tools.rb +5 -5
  108. data/lib/textus/schemas.rb +46 -0
  109. data/lib/textus/store.rb +12 -49
  110. data/lib/textus/uid.rb +18 -0
  111. data/lib/textus/version.rb +1 -1
  112. data/lib/textus.rb +17 -1
  113. metadata +14 -13
  114. data/lib/textus/hooks/dsl.rb +0 -11
  115. data/lib/textus/operations/reads.rb +0 -56
  116. data/lib/textus/operations/refresh.rb +0 -27
  117. data/lib/textus/operations/writes.rb +0 -21
  118. data/lib/textus/store/reader.rb +0 -69
  119. data/lib/textus/store/validator.rb +0 -82
  120. 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).writes.reject.call(key))
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).reads.policy_explain.call(key: key)
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,6 +2,9 @@ module Textus
2
2
  class CLI
3
3
  class Verb
4
4
  class RuleList < Verb
5
+ command_name "list"
6
+ parent_group Group::Rule
7
+
5
8
  def call(store)
6
9
  policies = store.manifest.rules.blocks.map do |b|
7
10
  row = { "match" => b.match }
@@ -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).reads.schema_envelope.call(key))
10
+ emit(operations_for(store).schema_envelope(key))
8
11
  end
9
12
  end
10
13
  end
@@ -2,6 +2,9 @@ module Textus
2
2
  class CLI
3
3
  class Verb
4
4
  class SchemaDiff < Verb
5
+ command_name "diff"
6
+ parent_group Group::Schema
7
+
5
8
  def call(store)
6
9
  name = positional.shift or raise UsageError.new("schema diff NAME")
7
10
  emit(Textus::Schema::Tools.diff(store, name: name))
@@ -2,6 +2,9 @@ module Textus
2
2
  class CLI
3
3
  class Verb
4
4
  class SchemaInit < Verb
5
+ command_name "init"
6
+ parent_group Group::Schema
7
+
5
8
  option :from_key, "--from=KEY"
6
9
 
7
10
  def call(store)
@@ -2,6 +2,9 @@ module Textus
2
2
  class CLI
3
3
  class Verb
4
4
  class SchemaMigrate < Verb
5
+ command_name "migrate"
6
+ parent_group Group::Schema
7
+
5
8
  option :rename, "--rename=O:N"
6
9
 
7
10
  def call(store)
@@ -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).reads.uid.call(key) })
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).reads.where.call(key))
9
+ emit(operations_for(store).where(key))
8
10
  end
9
11
  end
10
12
  end
@@ -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 name Verb subclass. Adding a new verb is a one-line entry here
7
- # plus a new file under lib/textus/cli/.
8
- VERBS = {
9
- "accept" => Verb::Accept,
10
- "audit" => Verb::Audit,
11
- "blame" => Verb::Blame,
12
- "reject" => Verb::Reject,
13
- "build" => Verb::Build,
14
- "delete" => Verb::Delete,
15
- "deps" => Verb::Deps,
16
- "doctor" => Verb::Doctor,
17
- "freshness" => Verb::Freshness,
18
- "get" => Verb::Get,
19
- "hook" => Group::Hook,
20
- "init" => Verb::Init,
21
- "intro" => Verb::Intro,
22
- "key" => Group::Key,
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 = VERBS[verb] or raise UsageError.new("unknown verb: #{verb}")
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::Store::AuditLog.new(store.root).verify_integrity.map do |v|
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.with_registry(registry) do
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 Textus.on(:resolve_intake, :#{name}) is registered",
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" => "Textus.on(:resolve_intake, :#{name}) is registered but no manifest entry references it",
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).reads.validate_all.call
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::Store::Sentinel::SUFFIX}")).flat_map do |sentinel_path|
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::Store::Sentinel.load(sentinel_path, repo_root)
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?
@@ -2,7 +2,7 @@ require "time"
2
2
 
3
3
  module Textus
4
4
  module Domain
5
- module Freshness
5
+ class Freshness
6
6
  module Evaluator
7
7
  module_function
8
8
 
@@ -1,6 +1,6 @@
1
1
  module Textus
2
2
  module Domain
3
- module Freshness
3
+ class Freshness
4
4
  Policy = Data.define(:ttl_seconds, :on_stale, :sync_budget_ms) do
5
5
  def decide(verdict)
6
6
  return Action::Return.new if verdict.fresh?
@@ -1,6 +1,6 @@
1
1
  module Textus
2
2
  module Domain
3
- module Freshness
3
+ class Freshness
4
4
  Verdict = Data.define(:fresh, :reason) do
5
5
  def self.fresh = new(fresh: true, reason: nil)
6
6
  def self.stale(reason) = new(fresh: false, reason: reason)
@@ -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, = store.manifest.resolve(target_key)
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.schema_for(schema_ref)
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
- class Store
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/
@@ -1,7 +1,7 @@
1
1
  require "time"
2
2
 
3
3
  module Textus
4
- class Store
4
+ module Domain
5
5
  class Staleness
6
6
  # Reports staleness for generator-zone entries — derived files whose
7
7
  # generator's listed sources have been modified more recently than the
@@ -1,7 +1,7 @@
1
1
  require "time"
2
2
 
3
3
  module Textus
4
- class Store
4
+ module Domain
5
5
  class Staleness
6
6
  # Reports TTL-exceeded staleness for intake-handler entries. Returns an
7
7
  # Array of row hashes (possibly empty) per entry.
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- class Store
2
+ module Domain
3
3
  class Staleness
4
4
  def initialize(manifest:)
5
5
  @manifest = manifest
@@ -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::Store.mint_uid unless m["uid"].is_a?(String) && !m["uid"].empty?
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::Store.mint_uid unless m["uid"].is_a?(String) && !m["uid"].empty?
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
 
@@ -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::Store.mint_uid unless m["uid"].is_a?(String) && !m["uid"].empty?
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
 
@@ -45,16 +45,20 @@ module Textus
45
45
  "uid" => uid,
46
46
  }
47
47
  h["content"] = content unless content.nil?
48
- freshness.each { |k, v| h[k.to_s] = v } if freshness.is_a?(Hash)
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
- freshness.is_a?(Hash) && (freshness["stale"] == true || freshness[:stale] == true)
53
+ return false if freshness.nil?
54
+
55
+ freshness.stale == true
54
56
  end
55
57
 
56
58
  def refreshing?
57
- freshness.is_a?(Hash) && (freshness["refreshing"] == true || freshness[:refreshing] == true)
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(
@@ -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
- Textus.on(:resolve_intake, :json) do |store:, config:, args:|
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
- Textus.on(:resolve_intake, :csv) do |store:, config:, args:|
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
- Textus.on(:resolve_intake, :"markdown-links") do |store:, config:, args:|
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
- Textus.on(:resolve_intake, :"ical-events") do |store:, config:, args:|
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
- Textus.on(:resolve_intake, :rss) do |store:, config:, args:|
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(audit_log:)
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
- extras = { "event" => event.to_s, "hook" => sub[:name].to_s, "error" => "#{e.class}: #{e.message}" }
35
- extras["target_key"] = kwargs[:target_key] if kwargs.key?(:target_key)
36
- extras["pending_key"] = kwargs[:pending_key] if kwargs.key?(:pending_key)
37
- @audit_log.append(
38
- role: "runner", verb: "event_error", key: key,
39
- etag_before: nil, etag_after: nil, extras: extras
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
@@ -1,25 +1,28 @@
1
1
  module Textus
2
2
  module Hooks
3
- module Loader
4
- THREAD_REGISTRY_KEY = :__textus_active_registry__
5
- private_constant :THREAD_REGISTRY_KEY
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 self.current_registry
16
- Thread.current[THREAD_REGISTRY_KEY] or
17
- raise UsageError.new("no active registry; hook code must be loaded by a Store")
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}")
@@ -2,7 +2,7 @@ require "json"
2
2
  require "time"
3
3
 
4
4
  module Textus
5
- class Store
5
+ module Infra
6
6
  class AuditLog
7
7
  def initialize(root)
8
8
  @path = File.join(root, "audit.log")