textus 0.14.4 → 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 +378 -0
  4. data/README.md +13 -9
  5. data/SPEC.md +13 -10
  6. data/docs/conventions.md +11 -0
  7. data/lib/textus/application/context.rb +25 -7
  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 +38 -33
  13. data/lib/textus/application/reads/get_or_refresh.rb +51 -0
  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 +11 -3
  25. data/lib/textus/application/refresh/worker.rb +27 -20
  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 +8 -1
  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 +4 -1
  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 +40 -35
  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 +84 -17
  105. data/lib/textus/projection.rb +16 -11
  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 +15 -13
  114. data/lib/textus/hooks/dsl.rb +0 -11
  115. data/lib/textus/operations/reads.rb +0 -39
  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 List < Verb
5
+ command_name "list"
6
+
5
7
  option :prefix, "--prefix=KEY"
6
8
  option :zone, "--zone=Z"
7
9
 
8
10
  def call(store)
9
- emit({ "entries" => operations_for(store).reads.list.call(prefix: prefix, zone: zone) })
11
+ emit({ "entries" => operations_for(store).list(prefix: prefix, zone: zone) })
10
12
  end
11
13
  end
12
14
  end
@@ -2,13 +2,16 @@ module Textus
2
2
  class CLI
3
3
  class Verb
4
4
  class Mv < Verb
5
+ command_name "mv"
6
+ parent_group Group::Key
7
+
5
8
  option :as_flag, "--as=ROLE"
6
9
  option :dry_run, "--dry-run"
7
10
 
8
11
  def call(store)
9
12
  old_key = positional.shift or raise UsageError.new("mv requires <old-key> <new-key>")
10
13
  new_key = positional.shift or raise UsageError.new("mv requires <old-key> <new-key>")
11
- emit(operations_for(store).writes.mv.call(old_key, new_key, dry_run: dry_run || false))
14
+ emit(operations_for(store).mv(old_key, new_key, dry_run: dry_run || false))
12
15
  end
13
16
  end
14
17
  end
@@ -2,8 +2,10 @@ module Textus
2
2
  class CLI
3
3
  class Verb
4
4
  class Published < Verb
5
+ command_name "published"
6
+
5
7
  def call(store)
6
- emit({ "published" => operations_for(store).reads.published.call })
8
+ emit({ "published" => operations_for(store).published })
7
9
  end
8
10
  end
9
11
  end
@@ -2,6 +2,8 @@ module Textus
2
2
  class CLI
3
3
  class Verb
4
4
  class Put < Verb
5
+ command_name "put"
6
+
5
7
  option :as_flag, "--as=ROLE"
6
8
  option :use_stdin, "--stdin"
7
9
  option :fetch_name, "--fetch=NAME"
@@ -43,7 +45,7 @@ module Textus
43
45
  meta = payload["_meta"] || {}
44
46
  body = payload["body"] || ""
45
47
  if_etag = payload["if_etag"]
46
- result = Textus::Operations.for(store, role: role).writes.put.call(key, meta: meta, body: body, if_etag: if_etag)
48
+ result = Textus::Operations.for(store, role: role).put(key, meta: meta, body: body, if_etag: if_etag)
47
49
  emit(result.to_h_for_wire)
48
50
  end
49
51
  end
@@ -2,9 +2,11 @@ module Textus
2
2
  class CLI
3
3
  class Verb
4
4
  class Rdeps < Verb
5
+ command_name "rdeps"
6
+
5
7
  def call(store)
6
8
  key = positional.shift or raise UsageError.new("rdeps requires a key")
7
- emit({ "key" => key, "rdeps" => operations_for(store).reads.rdeps.call(key) })
9
+ emit({ "key" => key, "rdeps" => operations_for(store).rdeps(key) })
8
10
  end
9
11
  end
10
12
  end
@@ -6,7 +6,7 @@ module Textus
6
6
 
7
7
  def call(store)
8
8
  key = positional.shift or raise UsageError.new("refresh requires a key")
9
- emit(operations_for(store).refresh.worker.run(key).to_h_for_wire)
9
+ emit(operations_for(store).refresh(key).to_h_for_wire)
10
10
  end
11
11
  end
12
12
  end
@@ -2,6 +2,9 @@ module Textus
2
2
  class CLI
3
3
  class Verb
4
4
  class RefreshStale < Verb
5
+ command_name "stale"
6
+ parent_group Group::Refresh
7
+
5
8
  option :prefix, "--prefix=KEY"
6
9
  option :zone, "--zone=Z"
7
10
  option :as_flag, "--as=ROLE"
@@ -10,7 +13,7 @@ module Textus
10
13
  ctx = context_for(store)
11
14
  result = Textus::Application::Refresh::All.call(ctx, prefix: prefix, zone: zone)
12
15
  emit(result)
13
- exit(1) unless result["ok"]
16
+ result["ok"] ? 0 : 1
14
17
  end
15
18
  end
16
19
  end
@@ -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)
@@ -47,21 +38,35 @@ module Textus
47
38
  verb = argv.shift
48
39
  raise UsageError.new("missing verb") if verb.nil?
49
40
 
50
- case verb
51
- when "--version", "-v" then @stdout.puts(VERSION)
52
- 0
53
- when "--help", "-h" then print_help
54
- 0
55
- else
56
- klass = VERBS[verb] or raise UsageError.new("unknown verb: #{verb}")
57
- dispatch(klass, argv)
58
- end
41
+ result =
42
+ case verb
43
+ when "--version", "-v" then @stdout.puts(VERSION)
44
+ 0
45
+ when "--help", "-h" then print_help
46
+ 0
47
+ else
48
+ klass = self.class.verbs[verb] or raise UsageError.new("unknown verb: #{verb}")
49
+ dispatch(klass, argv)
50
+ end
51
+
52
+ coerce_exit_code(result)
59
53
  rescue Textus::Error => e
60
54
  emit_error(e)
61
55
  end
62
56
 
63
57
  private
64
58
 
59
+ def coerce_exit_code(value)
60
+ case value
61
+ when Integer then value
62
+ when true, nil then 0
63
+ when false then 1
64
+ else
65
+ @stderr.puts("warning: verb returned non-Integer #{value.class}; treating as 0")
66
+ 0
67
+ end
68
+ end
69
+
65
70
  def store
66
71
  @store ||= Store.discover(@cwd, root: @root_arg)
67
72
  end
@@ -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(