textus 0.8.1 → 0.9.2

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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +224 -0
  3. data/README.md +50 -22
  4. data/SPEC.md +194 -63
  5. data/docs/architecture.md +22 -4
  6. data/docs/conventions.md +24 -17
  7. data/lib/textus/application/context.rb +68 -0
  8. data/lib/textus/application/reads/audit.rb +69 -0
  9. data/lib/textus/application/reads/blame.rb +79 -0
  10. data/lib/textus/application/reads/freshness.rb +77 -0
  11. data/lib/textus/application/reads/get.rb +62 -0
  12. data/lib/textus/application/reads/policy_explain.rb +39 -0
  13. data/lib/textus/application/refresh/all.rb +41 -0
  14. data/lib/textus/application/refresh/orchestrator.rb +68 -0
  15. data/lib/textus/application/refresh/worker.rb +79 -0
  16. data/lib/textus/application/writes/accept.rb +43 -0
  17. data/lib/textus/application/writes/build.rb +24 -0
  18. data/lib/textus/application/writes/delete.rb +37 -0
  19. data/lib/textus/application/writes/publish.rb +25 -0
  20. data/lib/textus/application/writes/put.rb +44 -0
  21. data/lib/textus/builder.rb +27 -14
  22. data/lib/textus/cli/group/policy.rb +11 -0
  23. data/lib/textus/cli/verb/accept.rb +2 -1
  24. data/lib/textus/cli/verb/audit.rb +31 -0
  25. data/lib/textus/cli/verb/blame.rb +17 -0
  26. data/lib/textus/cli/verb/build.rb +2 -1
  27. data/lib/textus/cli/verb/delete.rb +2 -1
  28. data/lib/textus/cli/verb/freshness.rb +17 -0
  29. data/lib/textus/cli/verb/get.rb +8 -1
  30. data/lib/textus/cli/verb/hook_run.rb +3 -3
  31. data/lib/textus/cli/verb/policy_explain.rb +15 -0
  32. data/lib/textus/cli/verb/policy_list.rb +25 -0
  33. data/lib/textus/cli/verb/put.rb +5 -4
  34. data/lib/textus/cli/verb/refresh.rb +2 -1
  35. data/lib/textus/cli/verb/refresh_stale.rb +19 -0
  36. data/lib/textus/cli/verb/reject.rb +15 -0
  37. data/lib/textus/cli.rb +16 -2
  38. data/lib/textus/composition.rb +71 -0
  39. data/lib/textus/doctor/check/handler_allowlist.rb +33 -0
  40. data/lib/textus/doctor/check/intake_registration.rb +46 -0
  41. data/lib/textus/doctor/check/legacy_intake_fields.rb +57 -0
  42. data/lib/textus/doctor/check/policy_ambiguity.rb +49 -0
  43. data/lib/textus/doctor.rb +4 -0
  44. data/lib/textus/domain/action.rb +9 -0
  45. data/lib/textus/domain/freshness/evaluator.rb +30 -0
  46. data/lib/textus/domain/freshness/policy.rb +18 -0
  47. data/lib/textus/domain/freshness/verdict.rb +12 -0
  48. data/lib/textus/domain/outcome.rb +10 -0
  49. data/lib/textus/domain/permission.rb +15 -0
  50. data/lib/textus/domain/policy/handler_allowlist.rb +17 -0
  51. data/lib/textus/domain/policy/matcher.rb +51 -0
  52. data/lib/textus/domain/policy/promote.rb +24 -0
  53. data/lib/textus/domain/policy/refresh.rb +48 -0
  54. data/lib/textus/domain/policy.rb +7 -0
  55. data/lib/textus/hooks/builtin.rb +5 -5
  56. data/lib/textus/hooks/dispatcher.rb +15 -1
  57. data/lib/textus/hooks/dsl.rb +18 -0
  58. data/lib/textus/hooks/registry.rb +12 -5
  59. data/lib/textus/infra/clock.rb +9 -0
  60. data/lib/textus/infra/event_bus.rb +27 -0
  61. data/lib/textus/infra/publisher.rb +73 -0
  62. data/lib/textus/infra/refresh/detached.rb +38 -0
  63. data/lib/textus/infra/refresh/lock.rb +44 -0
  64. data/lib/textus/init.rb +71 -28
  65. data/lib/textus/intro.rb +19 -11
  66. data/lib/textus/manifest/entry.rb +18 -9
  67. data/lib/textus/manifest/policies.rb +83 -0
  68. data/lib/textus/manifest.rb +30 -0
  69. data/lib/textus/proposal.rb +4 -21
  70. data/lib/textus/publisher.rb +4 -69
  71. data/lib/textus/refresh.rb +9 -44
  72. data/lib/textus/store/mover.rb +14 -9
  73. data/lib/textus/store/reader.rb +10 -8
  74. data/lib/textus/store/staleness.rb +4 -16
  75. data/lib/textus/store/validator.rb +46 -20
  76. data/lib/textus/store/view.rb +8 -19
  77. data/lib/textus/store/writer.rb +51 -14
  78. data/lib/textus/store.rb +29 -9
  79. data/lib/textus/version.rb +1 -1
  80. data/lib/textus.rb +1 -0
  81. metadata +46 -2
  82. data/lib/textus/cli/verb/stale.rb +0 -14
@@ -0,0 +1,68 @@
1
+ require "securerandom"
2
+
3
+ module Textus
4
+ module Application
5
+ class Context
6
+ attr_reader :store, :role, :correlation_id
7
+
8
+ def initialize(store:, role:, correlation_id: nil, clock: Time, dry_run: false)
9
+ @store = store
10
+ @role = role.to_s
11
+ @correlation_id = correlation_id || SecureRandom.uuid
12
+ @clock = clock
13
+ @dry_run = dry_run
14
+ @now = nil
15
+ end
16
+
17
+ def now
18
+ @now ||= @clock.now
19
+ end
20
+
21
+ def dry_run?
22
+ @dry_run
23
+ end
24
+
25
+ def can_write?(zone)
26
+ store.manifest.permission_for(zone.to_s).allows_write?(role)
27
+ end
28
+
29
+ def can_read?(zone)
30
+ store.manifest.permission_for(zone.to_s).allows_read?(role)
31
+ end
32
+
33
+ def with_role(new_role)
34
+ self.class.new(
35
+ store: @store,
36
+ role: new_role,
37
+ correlation_id: @correlation_id,
38
+ clock: @clock,
39
+ dry_run: @dry_run,
40
+ )
41
+ end
42
+
43
+ # Backward-compat for intake handlers receiving a Context (was Store::View)
44
+ # that call store.put/get/delete on it. Slated for removal in 0.10.0.
45
+ def put(key, **opts)
46
+ opts[:as] ||= role
47
+ store.put(key, **opts)
48
+ end
49
+
50
+ def delete(key, **opts)
51
+ opts[:as] ||= role
52
+ store.delete(key, **opts)
53
+ end
54
+
55
+ def get(key, **)
56
+ store.get(key, **)
57
+ end
58
+
59
+ def list(*, **)
60
+ store.list(*, **)
61
+ end
62
+
63
+ def where(*, **)
64
+ store.where(*, **)
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,69 @@
1
+ require "json"
2
+ require "time"
3
+
4
+ module Textus
5
+ module Application
6
+ module Reads
7
+ # Queries .textus/audit.log. Filters: key, zone, role, verb, since,
8
+ # correlation_id, limit. Reads the log file as JSON-Lines (legacy TSV
9
+ # rows produce nil and are skipped).
10
+ class Audit
11
+ def initialize(ctx:)
12
+ @ctx = ctx
13
+ @log_path = File.join(@ctx.store.root, "audit.log")
14
+ end
15
+
16
+ # rubocop:disable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
17
+ def call(key: nil, zone: nil, role: nil, verb: nil, since: nil, correlation_id: nil, limit: nil)
18
+ return [] unless File.exist?(@log_path)
19
+
20
+ rows = []
21
+ File.foreach(@log_path) do |line|
22
+ parsed = parse_row(line.chomp)
23
+ next unless parsed
24
+ next if key && parsed["key"] != key
25
+ next if role && parsed["role"] != role
26
+ next if verb && parsed["verb"] != verb
27
+ next if zone && !key_in_zone?(parsed["key"], zone)
28
+ next if since && (parsed["ts"].nil? || Time.parse(parsed["ts"]) < since)
29
+ next if correlation_id && parsed.dig("extras", "correlation_id") != correlation_id
30
+
31
+ rows << parsed
32
+ break if limit && rows.length >= limit
33
+ end
34
+ rows
35
+ end
36
+ # rubocop:enable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
37
+
38
+ # Accepts ISO8601 ("2026-01-15", "2026-01-15T10:00:00Z") or a relative
39
+ # offset matching /\A(\d+)([smhd])\z/. Returns nil for unparseable input.
40
+ def self.parse_since(str, now: Time.now.utc)
41
+ return nil if str.nil? || str.empty?
42
+ return Time.parse(str) if str =~ /\A\d{4}-\d{2}-\d{2}/
43
+
44
+ m = str.match(/\A(\d+)([smhd])\z/) or return nil
45
+ mult = { "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }[m[2]]
46
+ now - (m[1].to_i * mult)
47
+ end
48
+
49
+ private
50
+
51
+ def parse_row(line)
52
+ return nil if line.empty?
53
+ return nil unless line.start_with?("{")
54
+
55
+ JSON.parse(line)
56
+ rescue JSON::ParserError
57
+ nil
58
+ end
59
+
60
+ def key_in_zone?(key, zone)
61
+ mentry, = @ctx.store.manifest.resolve(key)
62
+ mentry && mentry.zone == zone
63
+ rescue Textus::Error
64
+ false
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,79 @@
1
+ require "open3"
2
+
3
+ module Textus
4
+ module Application
5
+ module Reads
6
+ # For one key, joins every audit-log row with the git commit (sha,
7
+ # author, date, subject) that introduced the file state at that audit
8
+ # row. Falls back to `git => nil` when not in a git repo or when the
9
+ # file is untracked.
10
+ class Blame
11
+ def initialize(ctx:)
12
+ @ctx = ctx
13
+ end
14
+
15
+ def call(key:, limit: nil)
16
+ audit_rows = Textus::Composition.audit(@ctx).call(key: key, limit: limit)
17
+ path = resolve_path(key)
18
+ return audit_rows.map { |r| r.merge("git" => nil) } unless git_tracked?(path)
19
+
20
+ audit_rows.map { |r| r.merge("git" => git_commit_at(path, timestamp: r["ts"])) }
21
+ end
22
+
23
+ private
24
+
25
+ def resolve_path(key)
26
+ mentry, path, = @ctx.store.manifest.resolve(key)
27
+ # Nested entries resolve to a file under the entry path; leaf entries
28
+ # already have a fully-resolved path. Either way `path` is what git
29
+ # needs to know about.
30
+ path || Textus::Key::Path.resolve(@ctx.store.manifest, mentry)
31
+ rescue Textus::Error
32
+ nil
33
+ end
34
+
35
+ def git_tracked?(path)
36
+ return false if path.nil?
37
+ return false unless File.exist?(path)
38
+ return false unless git_repo?
39
+
40
+ _out, _err, status = Open3.capture3(
41
+ "git", "ls-files", "--error-unmatch", path,
42
+ chdir: @ctx.store.root
43
+ )
44
+ status.success?
45
+ rescue Errno::ENOENT
46
+ false
47
+ end
48
+
49
+ def git_repo?
50
+ # Walk up from store root to find a .git directory.
51
+ dir = @ctx.store.root
52
+ loop do
53
+ return true if File.directory?(File.join(dir, ".git"))
54
+
55
+ parent = File.dirname(dir)
56
+ return false if parent == dir
57
+
58
+ dir = parent
59
+ end
60
+ end
61
+
62
+ def git_commit_at(path, timestamp:)
63
+ args = ["git", "log", "-1"]
64
+ args << "--before=#{timestamp}" if timestamp
65
+ args += ["--format=%H%x09%an%x09%aI%x09%s", "--", path]
66
+ out, _err, status = Open3.capture3(*args, chdir: @ctx.store.root)
67
+ return nil unless status.success?
68
+
69
+ sha, author, date, subject = out.strip.split("\t", 4)
70
+ return nil if sha.nil? || sha.empty?
71
+
72
+ { "sha" => sha, "author" => author, "date" => date, "subject" => subject }
73
+ rescue Errno::ENOENT
74
+ nil
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,77 @@
1
+ require "time"
2
+
3
+ module Textus
4
+ module Application
5
+ module Reads
6
+ # Per-entry freshness report. Walks every entry declared in the manifest,
7
+ # consults `policies_for(key)` for a refresh policy, and reports the
8
+ # current status. Status is one of :fresh, :stale, :never_refreshed, or
9
+ # :no_policy.
10
+ class Freshness
11
+ def initialize(ctx:, evaluator: Textus::Domain::Freshness::Evaluator)
12
+ @ctx = ctx
13
+ @evaluator = evaluator
14
+ end
15
+
16
+ def call(prefix: nil, zone: nil)
17
+ rows = []
18
+ @ctx.store.manifest.entries.each do |mentry|
19
+ next if prefix && !mentry.key.start_with?(prefix)
20
+ next if zone && mentry.zone != zone
21
+
22
+ rows << row_for(mentry)
23
+ end
24
+ rows
25
+ end
26
+
27
+ private
28
+
29
+ def row_for(mentry)
30
+ set = @ctx.store.manifest.policies_for(mentry.key)
31
+ refresh = set.refresh
32
+ envelope = safe_get(mentry.key)
33
+ last = envelope&.dig("_meta", "last_refreshed_at")
34
+
35
+ return base_row(mentry, last).merge(status: :no_policy) if refresh.nil?
36
+
37
+ fp = refresh.to_freshness_policy
38
+ verdict = @evaluator.call(fp, envelope || {}, now: @ctx.now)
39
+ status = if verdict.fresh? then :fresh
40
+ elsif last.nil? then :never_refreshed
41
+ else :stale
42
+ end
43
+
44
+ base_row(mentry, last).merge(
45
+ ttl_seconds: fp.ttl_seconds,
46
+ on_stale: fp.on_stale,
47
+ status: status,
48
+ next_due_at: next_due(last, fp.ttl_seconds),
49
+ )
50
+ end
51
+
52
+ def base_row(mentry, last)
53
+ {
54
+ key: mentry.key,
55
+ zone: mentry.zone,
56
+ last_refreshed_at: last,
57
+ age_seconds: last ? (@ctx.now - Time.parse(last)).to_i : nil,
58
+ }
59
+ end
60
+
61
+ # Returns the raw envelope or nil. Nested entries (mentry.key is a
62
+ # prefix, not a leaf) and missing files both resolve to nil.
63
+ def safe_get(key)
64
+ @ctx.store.reader.read_raw_envelope(key)
65
+ rescue Textus::Error
66
+ nil
67
+ end
68
+
69
+ def next_due(last, ttl)
70
+ return nil if last.nil? || ttl.nil?
71
+
72
+ (Time.parse(last) + ttl).utc.iso8601
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,62 @@
1
+ module Textus
2
+ module Application
3
+ module Reads
4
+ class Get
5
+ def initialize(ctx:, orchestrator:, evaluator: Textus::Domain::Freshness::Evaluator)
6
+ @ctx = ctx
7
+ @orchestrator = orchestrator
8
+ @evaluator = evaluator
9
+ end
10
+
11
+ def call(key)
12
+ envelope = @ctx.store.reader.read_raw_envelope(key)
13
+ return nil if envelope.nil?
14
+
15
+ policy_set = @ctx.store.manifest.policies_for(key)
16
+ refresh_policy = policy_set.refresh
17
+ return annotate_fresh(envelope) if refresh_policy.nil?
18
+
19
+ policy = refresh_policy.to_freshness_policy
20
+ verdict = @evaluator.call(policy, envelope, now: @ctx.now)
21
+
22
+ return annotate(envelope, verdict, refreshing: false) if verdict.fresh?
23
+
24
+ action = policy.decide(verdict)
25
+ outcome = @orchestrator.execute(action, key: key)
26
+
27
+ case outcome
28
+ when Textus::Domain::Outcome::Skipped
29
+ annotate(envelope, verdict, refreshing: false)
30
+ when Textus::Domain::Outcome::Refreshed
31
+ fresh_verdict = @evaluator.call(policy, outcome.envelope, now: @ctx.now)
32
+ annotate(outcome.envelope, fresh_verdict, refreshing: false)
33
+ when Textus::Domain::Outcome::Detached
34
+ annotate(envelope, verdict, refreshing: true)
35
+ when Textus::Domain::Outcome::Failed
36
+ annotate(envelope, verdict, refreshing: false, refresh_error: outcome.error.message)
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def annotate(envelope, verdict, refreshing:, refresh_error: nil)
43
+ envelope = envelope.dup
44
+ envelope["stale"] = verdict.stale?
45
+ envelope["stale_reason"] = verdict.reason
46
+ envelope["refreshing"] = refreshing
47
+ envelope["refresh_error"] = refresh_error if refresh_error
48
+ envelope
49
+ end
50
+
51
+ # No refresh policy applies to this key — treat as fresh, skip evaluation/orchestration.
52
+ def annotate_fresh(envelope)
53
+ envelope = envelope.dup
54
+ envelope["stale"] = false
55
+ envelope["stale_reason"] = nil
56
+ envelope["refreshing"] = false
57
+ envelope
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,39 @@
1
+ module Textus
2
+ module Application
3
+ module Reads
4
+ # For one key, surface every matching policy block along with the
5
+ # per-slot effective value (which loses ties win-by-specificity).
6
+ class PolicyExplain
7
+ def initialize(ctx:)
8
+ @ctx = ctx
9
+ end
10
+
11
+ def call(key:)
12
+ policies = @ctx.store.manifest.policies
13
+ matching = policies.explain(key)
14
+ winners = policies.for(key)
15
+
16
+ {
17
+ key: key,
18
+ matched_blocks: matching.map do |b|
19
+ {
20
+ match: b.match,
21
+ refresh: !b.refresh.nil?,
22
+ handler_allowlist: !b.handler_allowlist.nil?,
23
+ promote: !b.promote.nil?,
24
+ }
25
+ end,
26
+ effective: {
27
+ refresh: winners.refresh && {
28
+ ttl_seconds: winners.refresh.ttl_seconds,
29
+ on_stale: winners.refresh.on_stale,
30
+ },
31
+ handler_allowlist: winners.handler_allowlist&.handlers,
32
+ promote_requires: winners.promote&.requires,
33
+ },
34
+ }
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,41 @@
1
+ module Textus
2
+ module Application
3
+ module Refresh
4
+ module All
5
+ module_function
6
+
7
+ def call(ctx, prefix: nil, zone: nil)
8
+ worker = Textus::Composition.refresh_worker(ctx)
9
+
10
+ stale_rows = ctx.store.stale(prefix: prefix, zone: zone)
11
+ refreshed = []
12
+ failed = []
13
+ skipped = []
14
+
15
+ stale_rows.each do |row|
16
+ key = row["key"] || row[:key]
17
+ reason = row["reason"] || row[:reason]
18
+ if reason.to_s.match?(/ttl exceeded|never refreshed/)
19
+ begin
20
+ worker.run(key)
21
+ refreshed << key
22
+ rescue Textus::Error => e
23
+ failed << { "key" => key, "error" => e.message }
24
+ end
25
+ else
26
+ skipped << { "key" => key, "reason" => reason }
27
+ end
28
+ end
29
+
30
+ {
31
+ "protocol" => Textus::PROTOCOL,
32
+ "ok" => failed.empty?,
33
+ "refreshed" => refreshed,
34
+ "failed" => failed,
35
+ "skipped" => skipped,
36
+ }
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,68 @@
1
+ module Textus
2
+ module Application
3
+ module Refresh
4
+ class Orchestrator
5
+ def initialize(worker:, bus:, store_root:, store: nil, detached_spawner: nil)
6
+ @worker = worker
7
+ @bus = bus
8
+ @store_root = store_root
9
+ @store = store
10
+ @detached_spawner = detached_spawner || default_spawner
11
+ end
12
+
13
+ def execute(action, key:)
14
+ case action
15
+ when Textus::Domain::Action::Return then Textus::Domain::Outcome::Skipped.new
16
+ when Textus::Domain::Action::RefreshSync then run_sync(key)
17
+ when Textus::Domain::Action::RefreshTimed then run_timed(action.budget_ms, key)
18
+ else raise ArgumentError.new("unknown action: #{action.inspect}")
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def run_sync(key)
25
+ envelope = @worker.run(key)
26
+ Textus::Domain::Outcome::Refreshed.new(envelope: envelope)
27
+ rescue Textus::Error => e
28
+ Textus::Domain::Outcome::Failed.new(error: e)
29
+ end
30
+
31
+ def run_timed(budget_ms, key)
32
+ unless Textus::Infra::Refresh::Detached.supported?
33
+ return Textus::Domain::Outcome::Failed.new(
34
+ error: Textus::UsageError.new("timed_sync requires fork (Unix only)"),
35
+ )
36
+ end
37
+
38
+ result = nil
39
+ thread = Thread.new do
40
+ result = @worker.run(key)
41
+ rescue Textus::Error => e
42
+ result = e
43
+ end
44
+
45
+ thread.join(budget_ms / 1000.0)
46
+
47
+ if thread.alive?
48
+ thread.kill
49
+ store_view = @store ? Textus::Store::View.new(@store) : nil
50
+ payload = { key: key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms }
51
+ payload[:store] = store_view if store_view
52
+ @bus.publish(:refresh_detached, **payload)
53
+ @detached_spawner.call(store_root: @store_root, key: key)
54
+ Textus::Domain::Outcome::Detached.new
55
+ elsif result.is_a?(Textus::Error)
56
+ Textus::Domain::Outcome::Failed.new(error: result)
57
+ else
58
+ Textus::Domain::Outcome::Refreshed.new(envelope: result)
59
+ end
60
+ end
61
+
62
+ def default_spawner
63
+ Textus::Infra::Refresh::Detached.method(:spawn)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,79 @@
1
+ require "timeout"
2
+
3
+ module Textus
4
+ module Application
5
+ module Refresh
6
+ class Worker
7
+ FETCH_TIMEOUT_SECONDS = 30
8
+
9
+ def initialize(ctx:, bus:)
10
+ @ctx = ctx
11
+ @bus = bus
12
+ end
13
+
14
+ def run(key)
15
+ mentry, path, = @ctx.store.manifest.resolve(key)
16
+ raise UsageError.new("no intake declared for '#{key}'") unless mentry.intake_handler
17
+
18
+ before_etag = File.exist?(path) ? Etag.for_file(path) : nil
19
+ result = fetch_with_bus(key, mentry)
20
+ persist_and_notify(key, mentry, result, before_etag)
21
+ end
22
+
23
+ private
24
+
25
+ def read_view
26
+ Store::View.new(@ctx.store)
27
+ end
28
+
29
+ def fetch_with_bus(key, mentry)
30
+ callable = @ctx.store.registry.rpc_callable(:intake, mentry.intake_handler)
31
+ @bus.publish(:refresh_began, store: read_view, key: key, mode: :sync,
32
+ correlation_id: @ctx.correlation_id)
33
+ call_intake(key, mentry, callable)
34
+ end
35
+
36
+ def call_intake(key, mentry, callable)
37
+ Timeout.timeout(FETCH_TIMEOUT_SECONDS) do
38
+ callable.call(store: @ctx, config: mentry.intake_config, args: {})
39
+ end
40
+ rescue Timeout::Error
41
+ @bus.publish(:refresh_failed, store: read_view, key: key, error_class: "Timeout::Error",
42
+ error_message: "intake '#{mentry.intake_handler}' exceeded #{FETCH_TIMEOUT_SECONDS}s",
43
+ correlation_id: @ctx.correlation_id)
44
+ raise UsageError.new("intake '#{mentry.intake_handler}' exceeded #{FETCH_TIMEOUT_SECONDS}s timeout")
45
+ rescue Textus::Error => e
46
+ @bus.publish(:refresh_failed, store: read_view, key: key, error_class: e.class.name,
47
+ error_message: e.message, correlation_id: @ctx.correlation_id)
48
+ raise
49
+ rescue StandardError => e
50
+ @bus.publish(:refresh_failed, store: read_view, key: key, error_class: e.class.name,
51
+ error_message: e.message, correlation_id: @ctx.correlation_id)
52
+ raise UsageError.new("intake '#{mentry.intake_handler}' raised: #{e.class}: #{e.message}")
53
+ end
54
+
55
+ def persist_and_notify(key, mentry, result, before_etag)
56
+ normalized = Textus::Refresh.normalize_action_result(result, format: mentry.format)
57
+ envelope = @ctx.store.put(
58
+ key,
59
+ meta: normalized[:meta], body: normalized[:body], content: normalized[:content],
60
+ as: @ctx.role, suppress_events: true
61
+ )
62
+ change = detect_change(before_etag, envelope)
63
+ unless change == :unchanged
64
+ @bus.publish(:refreshed, store: read_view, key: key, envelope: envelope, change: change,
65
+ correlation_id: @ctx.correlation_id)
66
+ end
67
+ envelope
68
+ end
69
+
70
+ def detect_change(before_etag, envelope)
71
+ if before_etag.nil? then :created
72
+ elsif envelope["etag"] == before_etag then :unchanged
73
+ else :updated
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,43 @@
1
+ module Textus
2
+ module Application
3
+ module Writes
4
+ class Accept
5
+ def initialize(ctx:, bus:)
6
+ @ctx = ctx
7
+ @bus = bus
8
+ end
9
+
10
+ def call(pending_key)
11
+ raise ProposalError.new("only human role can accept proposals; got '#{@ctx.role}'") unless @ctx.role == "human"
12
+
13
+ env = @ctx.store.get(pending_key)
14
+ proposal = env["_meta"]["proposal"] or raise ProposalError.new("entry has no proposal block: #{pending_key}")
15
+ target = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
16
+ action = proposal["action"] || "put"
17
+
18
+ case action
19
+ when "put"
20
+ target_meta = env["_meta"]["frontmatter"] || {}
21
+ target_body = env["body"]
22
+ Composition.writes_put(@ctx).call(target, meta: target_meta, body: target_body)
23
+ when "delete"
24
+ Composition.writes_delete(@ctx).call(target)
25
+ else
26
+ raise ProposalError.new("unknown action: #{action}")
27
+ end
28
+
29
+ Composition.writes_delete(@ctx).call(pending_key)
30
+
31
+ store_view = Store::View.new(@ctx.store)
32
+ @bus.publish(:accepted,
33
+ store: store_view,
34
+ key: pending_key,
35
+ target_key: target,
36
+ correlation_id: @ctx.correlation_id)
37
+
38
+ { "protocol" => PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action }
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,24 @@
1
+ module Textus
2
+ module Application
3
+ module Writes
4
+ class Build
5
+ def initialize(ctx:, bus:)
6
+ @ctx = ctx
7
+ @bus = bus
8
+ end
9
+
10
+ def call(prefix: nil)
11
+ # Delegate to legacy Builder for the materialization/projection logic.
12
+ # Builder fires its own events through @store.fire_event; we do NOT
13
+ # double-fire from here. Full extraction of Builder internals into
14
+ # Writes::Build is deferred to 0.10.0.
15
+ #
16
+ # TODO(0.10.0): propagate @ctx.correlation_id through :built/:published
17
+ # events once Builder internals are extracted into this use case.
18
+ legacy = Textus::Builder.new(@ctx.store)
19
+ legacy.build(prefix: prefix)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,37 @@
1
+ module Textus
2
+ module Application
3
+ module Writes
4
+ class Delete
5
+ def initialize(ctx:, bus:)
6
+ @ctx = ctx
7
+ @bus = bus
8
+ end
9
+
10
+ def call(key, if_etag: nil, suppress_events: false)
11
+ @ctx.store.manifest.validate_key!(key)
12
+ mentry, = @ctx.store.manifest.resolve(key)
13
+
14
+ unless @ctx.can_write?(mentry.zone)
15
+ raise WriteForbidden.new(key, mentry.zone,
16
+ writers: @ctx.store.manifest.zone_writers(mentry.zone))
17
+ end
18
+
19
+ @ctx.store.writer.delete_envelope_from_disk(
20
+ key, if_etag: if_etag, as: @ctx.role,
21
+ correlation_id: @ctx.correlation_id
22
+ )
23
+
24
+ unless suppress_events
25
+ store_view = Store::View.new(@ctx.store)
26
+ @bus.publish(:deleted,
27
+ store: store_view,
28
+ key: key,
29
+ correlation_id: @ctx.correlation_id)
30
+ end
31
+
32
+ { "protocol" => Textus::PROTOCOL, "ok" => true, "key" => key, "deleted" => true }
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end