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
@@ -7,7 +7,7 @@ module Textus
7
7
  end
8
8
 
9
9
  def call(key)
10
- @ctx.store.reader.deps(key)
10
+ Dependencies.deps_of(@ctx.manifest, key)
11
11
  end
12
12
  end
13
13
  end
@@ -15,7 +15,7 @@ module Textus
15
15
 
16
16
  def call(prefix: nil, zone: nil)
17
17
  rows = []
18
- @ctx.store.manifest.entries.each do |mentry|
18
+ @ctx.manifest.entries.each do |mentry|
19
19
  next if prefix && !mentry.key.start_with?(prefix)
20
20
  next if zone && mentry.zone != zone
21
21
 
@@ -27,7 +27,7 @@ module Textus
27
27
  private
28
28
 
29
29
  def row_for(mentry)
30
- set = @ctx.store.manifest.rules_for(mentry.key)
30
+ set = @ctx.manifest.rules_for(mentry.key)
31
31
  refresh = set.refresh
32
32
  envelope = safe_get(mentry.key)
33
33
  last = envelope&.meta&.dig("last_refreshed_at")
@@ -61,7 +61,16 @@ module Textus
61
61
  # Returns the raw envelope or nil. Nested entries (mentry.key is a
62
62
  # prefix, not a leaf) and missing files both resolve to nil.
63
63
  def safe_get(key)
64
- @ctx.store.reader.read_raw_envelope(key)
64
+ res = @ctx.manifest.resolve(key)
65
+ return nil unless @ctx.file_store.exists?(res.path)
66
+
67
+ raw = @ctx.file_store.read(res.path)
68
+ parsed = Entry.for_format(res.entry.format).parse(raw, path: res.path)
69
+ Envelope.build(
70
+ key: key, mentry: res.entry, path: res.path,
71
+ meta: parsed["_meta"], body: parsed["body"],
72
+ etag: Etag.for_bytes(raw), content: parsed["content"]
73
+ )
65
74
  rescue Textus::Error
66
75
  nil
67
76
  end
@@ -1,58 +1,63 @@
1
1
  module Textus
2
2
  module Application
3
3
  module Reads
4
+ # Pure read: returns the on-disk envelope annotated with a freshness
5
+ # verdict. Never triggers refresh; never invokes the orchestrator.
6
+ #
7
+ # For interactive reads that want refresh-on-stale, use
8
+ # `Reads::GetOrRefresh`, which composes this with the orchestrator.
4
9
  class Get
5
- def initialize(ctx:, orchestrator:, evaluator: Textus::Domain::Freshness::Evaluator)
6
- @ctx = ctx
7
- @orchestrator = orchestrator
8
- @evaluator = evaluator
10
+ def initialize(ctx:, evaluator: Textus::Domain::Freshness::Evaluator)
11
+ @ctx = ctx
12
+ @evaluator = evaluator
9
13
  end
10
14
 
11
15
  def call(key)
12
- envelope = @ctx.store.reader.read_raw_envelope(key)
16
+ envelope = read_raw_envelope(key)
13
17
  return nil if envelope.nil?
14
- return annotate_fresh(envelope) if @ctx.bypass_freshness?
15
18
 
16
- policy_set = @ctx.store.manifest.rules_for(key)
19
+ policy_set = @ctx.manifest.rules_for(key)
17
20
  refresh_policy = policy_set.refresh
18
21
  return annotate_fresh(envelope) if refresh_policy.nil?
19
22
 
20
23
  policy = refresh_policy.to_freshness_policy
21
24
  verdict = @evaluator.call(policy, envelope, now: @ctx.now)
22
25
 
23
- return annotate(envelope, verdict, refreshing: false) if verdict.fresh?
24
-
25
- action = policy.decide(verdict)
26
- outcome = @orchestrator.execute(action, key: key)
27
-
28
- case outcome
29
- when Textus::Domain::Outcome::Skipped
30
- annotate(envelope, verdict, refreshing: false)
31
- when Textus::Domain::Outcome::Refreshed
32
- fresh_verdict = @evaluator.call(policy, outcome.envelope, now: @ctx.now)
33
- annotate(outcome.envelope, fresh_verdict, refreshing: false)
34
- when Textus::Domain::Outcome::Detached
35
- annotate(envelope, verdict, refreshing: true)
36
- when Textus::Domain::Outcome::Failed
37
- annotate(envelope, verdict, refreshing: false, refresh_error: outcome.error.message)
38
- end
26
+ envelope.with(freshness: Textus::Domain::Freshness.build(
27
+ stale: verdict.stale?,
28
+ reason: verdict.reason,
29
+ refreshing: false,
30
+ ))
31
+ end
32
+
33
+ # Strict variant: raises UnknownKey when the entry is missing.
34
+ # Used by consumers (e.g. Validator) that need to distinguish absence
35
+ # from emptiness.
36
+ def get(key)
37
+ call(key) || raise(UnknownKey.new(key, suggestions: @ctx.manifest.suggestions_for(key)))
39
38
  end
40
39
 
41
40
  private
42
41
 
43
- def annotate(envelope, verdict, refreshing:, refresh_error: nil)
44
- fresh = {
45
- "stale" => verdict.stale?,
46
- "stale_reason" => verdict.reason,
47
- "refreshing" => refreshing,
48
- }
49
- fresh["refresh_error"] = refresh_error if refresh_error
50
- envelope.with(freshness: fresh)
42
+ def read_raw_envelope(key)
43
+ res = @ctx.manifest.resolve(key)
44
+ mentry = res.entry
45
+ path = res.path
46
+ return nil unless @ctx.file_store.exists?(path)
47
+
48
+ raw = @ctx.file_store.read(path)
49
+ parsed = Entry.for_format(mentry.format).parse(raw, path: path)
50
+ Envelope.build(
51
+ key: key, mentry: mentry, path: path,
52
+ meta: parsed["_meta"], body: parsed["body"],
53
+ etag: Etag.for_bytes(raw), content: parsed["content"]
54
+ )
51
55
  end
52
56
 
53
- # No refresh policy applies to this key — treat as fresh, skip evaluation/orchestration.
54
57
  def annotate_fresh(envelope)
55
- envelope.with(freshness: { "stale" => false, "stale_reason" => nil, "refreshing" => false })
58
+ envelope.with(freshness: Textus::Domain::Freshness.build(
59
+ stale: false, reason: nil, refreshing: false,
60
+ ))
56
61
  end
57
62
  end
58
63
  end
@@ -0,0 +1,51 @@
1
+ module Textus
2
+ module Application
3
+ module Reads
4
+ # Composes pure `Reads::Get` with the refresh orchestrator: runs Get
5
+ # to obtain the envelope and freshness verdict, then if the verdict
6
+ # is stale and the rule's `on_stale` policy demands action, hands
7
+ # off to the orchestrator. Use for interactive reads where the
8
+ # caller wants the freshest obtainable envelope.
9
+ #
10
+ # Pure reads (build, projection, schema tooling) should use
11
+ # `Reads::Get` directly; it has no orchestrator dependency.
12
+ class GetOrRefresh
13
+ def initialize(ctx:, get:, orchestrator:)
14
+ @ctx = ctx
15
+ @get = get
16
+ @orchestrator = orchestrator
17
+ end
18
+
19
+ def call(key)
20
+ envelope = @get.call(key)
21
+ return nil if envelope.nil?
22
+ return envelope unless envelope.freshness&.stale
23
+
24
+ policy_set = @ctx.store.manifest.rules_for(key)
25
+ refresh_policy = policy_set.refresh
26
+ return envelope if refresh_policy.nil?
27
+
28
+ policy = refresh_policy.to_freshness_policy
29
+ verdict = Textus::Domain::Freshness::Verdict.stale(envelope.freshness.reason)
30
+ action = policy.decide(verdict)
31
+ outcome = @orchestrator.execute(action, key: key)
32
+
33
+ case outcome
34
+ when Textus::Domain::Outcome::Skipped
35
+ envelope
36
+ when Textus::Domain::Outcome::Refreshed
37
+ outcome.envelope.with(
38
+ freshness: Textus::Domain::Freshness.build(stale: false, reason: nil, refreshing: false),
39
+ )
40
+ when Textus::Domain::Outcome::Detached
41
+ envelope.with(freshness: envelope.freshness.with(refreshing: true))
42
+ when Textus::Domain::Outcome::Failed
43
+ envelope.with(
44
+ freshness: envelope.freshness.with(refresh_error: outcome.error.message),
45
+ )
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -7,7 +7,9 @@ module Textus
7
7
  end
8
8
 
9
9
  def call(prefix: nil, zone: nil)
10
- @ctx.store.reader.list(prefix: prefix, zone: zone)
10
+ rows = @ctx.manifest.enumerate(prefix: prefix)
11
+ rows = rows.select { |r| r[:manifest_entry].zone == zone } if zone
12
+ rows.map { |row| { "key" => row[:key], "zone" => row[:manifest_entry].zone, "path" => row[:path] } }
11
13
  end
12
14
  end
13
15
  end
@@ -7,7 +7,7 @@ module Textus
7
7
  end
8
8
 
9
9
  def call
10
- @ctx.store.reader.published
10
+ Dependencies.published_of(@ctx.manifest)
11
11
  end
12
12
  end
13
13
  end
@@ -7,7 +7,7 @@ module Textus
7
7
  end
8
8
 
9
9
  def call(key)
10
- @ctx.store.reader.rdeps(key)
10
+ Dependencies.rdeps_of(@ctx.manifest, key)
11
11
  end
12
12
  end
13
13
  end
@@ -7,7 +7,9 @@ module Textus
7
7
  end
8
8
 
9
9
  def call(key)
10
- @ctx.store.reader.schema_envelope(key)
10
+ mentry = @ctx.manifest.resolve(key).entry
11
+ schema = @ctx.schemas.fetch_or_nil(mentry.schema)
12
+ { "protocol" => PROTOCOL, "key" => key, "schema_ref" => mentry.schema, "schema" => schema&.to_h }
11
13
  end
12
14
  end
13
15
  end
@@ -7,7 +7,7 @@ module Textus
7
7
  end
8
8
 
9
9
  def call(prefix: nil, zone: nil)
10
- @ctx.store.reader.stale(prefix: prefix, zone: zone)
10
+ Textus::Domain::Staleness.new(manifest: @ctx.manifest).call(prefix: prefix, zone: zone)
11
11
  end
12
12
  end
13
13
  end
@@ -7,7 +7,7 @@ module Textus
7
7
  end
8
8
 
9
9
  def call(key)
10
- @ctx.store.reader.uid(key)
10
+ Get.new(ctx: @ctx).get(key).uid
11
11
  end
12
12
  end
13
13
  end
@@ -7,7 +7,12 @@ module Textus
7
7
  end
8
8
 
9
9
  def call
10
- @ctx.store.reader.validate_all
10
+ Validator.new(
11
+ reader: Get.new(ctx: @ctx),
12
+ manifest: @ctx.manifest,
13
+ audit_log: @ctx.audit_log,
14
+ schema_for: ->(name) { @ctx.schemas.fetch_or_nil(name) },
15
+ ).call
11
16
  end
12
17
  end
13
18
  end
@@ -0,0 +1,84 @@
1
+ module Textus
2
+ module Application
3
+ module Reads
4
+ class Validator
5
+ def initialize(reader:, manifest:, audit_log:, schema_for:)
6
+ @reader = reader
7
+ @manifest = manifest
8
+ @audit_log = audit_log
9
+ @schema_for = schema_for
10
+ end
11
+
12
+ def call
13
+ violations = []
14
+ check_content_violations(violations)
15
+ check_role_authority_violations(violations)
16
+ { "protocol" => PROTOCOL, "ok" => violations.empty?, "violations" => violations }
17
+ end
18
+
19
+ private
20
+
21
+ def check_content_violations(violations)
22
+ @manifest.enumerate.each do |row|
23
+ key = row[:key]
24
+ mentry = row[:manifest_entry]
25
+ env = fetch_envelope(key, violations) or next
26
+ schema = mentry.schema && @schema_for.call(mentry.schema)
27
+ next unless schema
28
+
29
+ begin
30
+ validate_schema!(schema, env, mentry.format)
31
+ rescue Textus::Error => e
32
+ violations << { "key" => key, "code" => e.code, "message" => e.message }
33
+ end
34
+ end
35
+ end
36
+
37
+ def check_role_authority_violations(violations)
38
+ @manifest.enumerate.each do |row|
39
+ mentry = row[:manifest_entry]
40
+ next unless mentry.schema
41
+
42
+ schema = @schema_for.call(mentry.schema)
43
+ next unless schema
44
+
45
+ env = begin
46
+ @reader.get(row[:key])
47
+ rescue StandardError
48
+ next
49
+ end
50
+ append_authority_violations(violations, row[:key], env, schema)
51
+ end
52
+ end
53
+
54
+ def append_authority_violations(violations, key, env, schema)
55
+ last_writer = @audit_log.last_writer_for(key)
56
+ return if last_writer.nil?
57
+
58
+ env.meta.each_key do |field|
59
+ owner = schema.maintained_by(field)
60
+ next if owner.nil? || last_writer == owner || last_writer == "human"
61
+
62
+ violations << { "key" => key, "code" => "role_authority",
63
+ "field" => field, "expected" => owner, "last_writer" => last_writer }
64
+ end
65
+ end
66
+
67
+ def fetch_envelope(key, violations)
68
+ @reader.get(key)
69
+ rescue Textus::Error => e
70
+ violations << { "key" => key, "code" => e.code, "message" => e.message }
71
+ nil
72
+ end
73
+
74
+ def validate_schema!(schema, envelope, format)
75
+ payload = case format
76
+ when "json", "yaml" then envelope.content || {}
77
+ else envelope.meta || {}
78
+ end
79
+ schema.validate!(payload)
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -7,7 +7,10 @@ module Textus
7
7
  end
8
8
 
9
9
  def call(key)
10
- @ctx.store.reader.where(key)
10
+ res = @ctx.manifest.resolve(key)
11
+ mentry = res.entry
12
+ path = res.path
13
+ { "protocol" => PROTOCOL, "key" => key, "zone" => mentry.zone, "owner" => mentry.owner, "path" => path }
11
14
  end
12
15
  end
13
16
  end
@@ -5,7 +5,14 @@ module Textus
5
5
  module_function
6
6
 
7
7
  def call(ctx, prefix: nil, zone: nil)
8
- worker = Textus::Application::Refresh::Worker.new(ctx: ctx, bus: ctx.store.bus)
8
+ envelope_io = Textus::Application::Writes::EnvelopeIO.new(
9
+ file_store: ctx.file_store,
10
+ manifest: ctx.manifest,
11
+ schemas: ctx.schemas,
12
+ audit_log: ctx.audit_log,
13
+ ctx: ctx,
14
+ )
15
+ worker = Textus::Application::Refresh::Worker.new(ctx: ctx, envelope_io: envelope_io)
9
16
 
10
17
  stale_rows = Textus::Application::Reads::Stale.new(ctx: ctx).call(prefix: prefix, zone: zone)
11
18
  refreshed = []
@@ -2,9 +2,8 @@ module Textus
2
2
  module Application
3
3
  module Refresh
4
4
  class Orchestrator
5
- def initialize(worker:, bus:, store_root:, store: nil, role: "human", detached_spawner: nil)
5
+ def initialize(worker:, store_root:, store: nil, role: "human", detached_spawner: nil)
6
6
  @worker = worker
7
- @bus = bus
8
7
  @store_root = store_root
9
8
  @store = store
10
9
  @role = role
@@ -47,10 +46,19 @@ module Textus
47
46
 
48
47
  if thread.alive?
49
48
  thread.kill
49
+
50
+ # Single-flight: if a sibling process / earlier fork holds the
51
+ # per-leaf lock, don't fork another worker — they're already
52
+ # doing this work.
53
+ probe = Textus::Infra::Refresh::Lock.new(root: @store_root, key: key)
54
+ return Textus::Domain::Outcome::Detached.new unless probe.try_acquire
55
+
56
+ probe.release
57
+
50
58
  store_view = @store ? Textus::Application::Context.new(store: @store, role: @role) : nil
51
59
  payload = { key: key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms }
52
60
  payload[:store] = store_view if store_view
53
- @bus.publish(:refresh_backgrounded, **payload)
61
+ @store&.bus&.publish(:refresh_backgrounded, **payload)
54
62
  @detached_spawner.call(store_root: @store_root, key: key)
55
63
  Textus::Domain::Outcome::Detached.new
56
64
  elsif result.is_a?(Textus::Error)
@@ -6,17 +6,20 @@ module Textus
6
6
  class Worker
7
7
  FETCH_TIMEOUT_SECONDS = 30
8
8
 
9
- def initialize(ctx:, bus:)
9
+ def initialize(ctx:, envelope_io:)
10
10
  @ctx = ctx
11
- @bus = bus
11
+ @envelope_io = envelope_io
12
12
  end
13
13
 
14
14
  def run(key)
15
- mentry, path, = @ctx.store.manifest.resolve(key)
15
+ res = @ctx.store.manifest.resolve(key)
16
+ mentry = res.entry
17
+ path = res.path
18
+ remaining = res.remaining
16
19
  raise UsageError.new("no intake declared for '#{key}'") unless mentry.intake_handler
17
20
 
18
21
  before_etag = File.exist?(path) ? Etag.for_file(path) : nil
19
- result = fetch_with_bus(key, mentry)
22
+ result = fetch_with_bus(key, mentry, remaining)
20
23
  persist_and_notify(key, mentry, result, before_etag)
21
24
  end
22
25
 
@@ -31,44 +34,48 @@ module Textus
31
34
  rule&.refresh&.fetch_timeout_seconds || FETCH_TIMEOUT_SECONDS
32
35
  end
33
36
 
34
- def fetch_with_bus(key, mentry)
37
+ def fetch_with_bus(key, mentry, remaining)
35
38
  callable = @ctx.store.registry.rpc_callable(:resolve_intake, mentry.intake_handler)
36
- @bus.publish(:refresh_started, store: read_view, key: key, mode: :sync,
37
- correlation_id: @ctx.correlation_id)
38
- call_intake(key, mentry, callable)
39
+ @ctx.bus.publish(:refresh_started, store: read_view, key: key, mode: :sync,
40
+ correlation_id: @ctx.correlation_id)
41
+ call_intake(key, mentry, callable, remaining)
39
42
  end
40
43
 
41
- def call_intake(key, mentry, callable)
44
+ def call_intake(key, mentry, callable, remaining)
42
45
  timeout = fetch_timeout_for(key)
43
46
  Timeout.timeout(timeout) do
44
- callable.call(store: @ctx, config: mentry.intake_config, args: {})
47
+ callable.call(
48
+ store: @ctx,
49
+ config: mentry.intake_config,
50
+ args: { trigger_key: key, leaf_segments: remaining || [] },
51
+ )
45
52
  end
46
53
  rescue Timeout::Error
47
- @bus.publish(:refresh_failed, store: read_view, key: key, error_class: "Timeout::Error",
48
- error_message: "intake '#{mentry.intake_handler}' exceeded #{timeout}s",
49
- correlation_id: @ctx.correlation_id)
54
+ @ctx.bus.publish(:refresh_failed, store: read_view, key: key, error_class: "Timeout::Error",
55
+ error_message: "intake '#{mentry.intake_handler}' exceeded #{timeout}s",
56
+ correlation_id: @ctx.correlation_id)
50
57
  raise UsageError.new("intake '#{mentry.intake_handler}' exceeded #{timeout}s timeout")
51
58
  rescue Textus::Error => e
52
- @bus.publish(:refresh_failed, store: read_view, key: key, error_class: e.class.name,
53
- error_message: e.message, correlation_id: @ctx.correlation_id)
59
+ @ctx.bus.publish(:refresh_failed, store: read_view, key: key, error_class: e.class.name,
60
+ error_message: e.message, correlation_id: @ctx.correlation_id)
54
61
  raise
55
62
  rescue StandardError => e
56
- @bus.publish(:refresh_failed, store: read_view, key: key, error_class: e.class.name,
57
- error_message: e.message, correlation_id: @ctx.correlation_id)
63
+ @ctx.bus.publish(:refresh_failed, store: read_view, key: key, error_class: e.class.name,
64
+ error_message: e.message, correlation_id: @ctx.correlation_id)
58
65
  raise UsageError.new("intake '#{mentry.intake_handler}' raised: #{e.class}: #{e.message}")
59
66
  end
60
67
 
61
68
  def persist_and_notify(key, mentry, result, before_etag)
62
69
  normalized = Textus::Refresh.normalize_action_result(result, format: mentry.format)
63
- envelope = Textus::Application::Writes::Put.new(ctx: @ctx, bus: @bus).call(
70
+ envelope = Textus::Application::Writes::Put.new(ctx: @ctx, envelope_io: @envelope_io).call(
64
71
  key,
65
72
  meta: normalized[:meta], body: normalized[:body], content: normalized[:content],
66
73
  suppress_events: true
67
74
  )
68
75
  change = detect_change(before_etag, envelope)
69
76
  unless change == :unchanged
70
- @bus.publish(:entry_refreshed, store: read_view, key: key, envelope: envelope, change: change,
71
- correlation_id: @ctx.correlation_id)
77
+ @ctx.bus.publish(:entry_refreshed, store: read_view, key: key, envelope: envelope, change: change,
78
+ correlation_id: @ctx.correlation_id)
72
79
  end
73
80
  envelope
74
81
  end
@@ -2,15 +2,15 @@ module Textus
2
2
  module Application
3
3
  module Writes
4
4
  class Accept
5
- def initialize(ctx:, bus:)
5
+ def initialize(ctx:, envelope_io:)
6
6
  @ctx = ctx
7
- @bus = bus
7
+ @envelope_io = envelope_io
8
8
  end
9
9
 
10
10
  def call(pending_key)
11
11
  raise ProposalError.new("only human role can accept proposals; got '#{@ctx.role}'") unless @ctx.role == "human"
12
12
 
13
- env = @ctx.store.reader.get(pending_key)
13
+ env = Textus::Application::Reads::Get.new(ctx: @ctx).call(pending_key)
14
14
  proposal = env.meta["proposal"] or raise ProposalError.new("entry has no proposal block: #{pending_key}")
15
15
  target = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
16
16
  action = proposal["action"] || "put"
@@ -23,20 +23,20 @@ module Textus
23
23
  # target. Not related to the removed intake-handler legacy bridge.
24
24
  target_meta = env.meta["frontmatter"] || {}
25
25
  target_body = env.body
26
- Textus::Application::Writes::Put.new(ctx: @ctx, bus: @bus).call(target, meta: target_meta, body: target_body)
26
+ Textus::Application::Writes::Put.new(ctx: @ctx, envelope_io: @envelope_io).call(target, meta: target_meta, body: target_body)
27
27
  when "delete"
28
- Textus::Application::Writes::Delete.new(ctx: @ctx, bus: @bus).call(target)
28
+ Textus::Application::Writes::Delete.new(ctx: @ctx, envelope_io: @envelope_io).call(target)
29
29
  else
30
30
  raise ProposalError.new("unknown action: #{action}")
31
31
  end
32
32
 
33
- Textus::Application::Writes::Delete.new(ctx: @ctx, bus: @bus).call(pending_key)
33
+ Textus::Application::Writes::Delete.new(ctx: @ctx, envelope_io: @envelope_io).call(pending_key)
34
34
 
35
- @bus.publish(:proposal_accepted,
36
- store: @ctx.with_role(@ctx.role),
37
- key: pending_key,
38
- target_key: target,
39
- correlation_id: @ctx.correlation_id)
35
+ @ctx.bus.publish(:proposal_accepted,
36
+ store: @ctx.with_role(@ctx.role),
37
+ key: pending_key,
38
+ target_key: target,
39
+ correlation_id: @ctx.correlation_id)
40
40
 
41
41
  { "protocol" => PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action }
42
42
  end
@@ -44,7 +44,7 @@ module Textus
44
44
  private
45
45
 
46
46
  def evaluate_promotion!(env, target_key)
47
- rules = @ctx.store.manifest.rules_for(target_key)
47
+ rules = @ctx.manifest.rules_for(target_key)
48
48
  promote = rules.promote
49
49
  return if promote.nil? || promote.requires.empty?
50
50
 
@@ -11,9 +11,8 @@ module Textus
11
11
  # `Application::Writes::Publish`. The CLI verb `textus build` calls
12
12
  # both classes and merges the results.
13
13
  class Build
14
- def initialize(ctx:, bus:)
14
+ def initialize(ctx:)
15
15
  @ctx = ctx
16
- @bus = bus
17
16
  end
18
17
 
19
18
  def call(prefix: nil)
@@ -51,7 +50,7 @@ module Textus
51
50
  end
52
51
 
53
52
  def publish_and_fire(mentry, target_path)
54
- envelope = store.reader.get(mentry.key)
53
+ envelope = Textus::Application::Reads::Get.new(ctx: @ctx).call(mentry.key)
55
54
  repo_root = File.dirname(root)
56
55
 
57
56
  mentry.publish_to.each do |rel|
@@ -71,7 +70,7 @@ module Textus
71
70
  end
72
71
 
73
72
  def publish_event(event, **payload)
74
- @bus.publish(event, store: @ctx.with_role(@ctx.role), correlation_id: @ctx.correlation_id, **payload)
73
+ @ctx.bus.publish(event, store: @ctx.with_role(@ctx.role), correlation_id: @ctx.correlation_id, **payload)
75
74
  end
76
75
  end
77
76
  end
@@ -2,29 +2,24 @@ module Textus
2
2
  module Application
3
3
  module Writes
4
4
  class Delete
5
- def initialize(ctx:, bus:)
5
+ def initialize(ctx:, envelope_io:)
6
6
  @ctx = ctx
7
- @bus = bus
7
+ @envelope_io = envelope_io
8
8
  end
9
9
 
10
10
  def call(key, if_etag: nil, suppress_events: false)
11
- @ctx.store.manifest.validate_key!(key)
12
- mentry, = @ctx.store.manifest.resolve(key)
11
+ @ctx.manifest.validate_key!(key)
12
+ mentry = @ctx.manifest.resolve(key).entry
13
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
14
+ @ctx.authorize_write!(mentry)
18
15
 
19
- @ctx.store.writer.delete_envelope_from_disk(
20
- key, ctx: @ctx, if_etag: if_etag
21
- )
16
+ @envelope_io.delete(key, mentry: mentry, if_etag: if_etag)
22
17
 
23
18
  unless suppress_events
24
- @bus.publish(:entry_deleted,
25
- store: @ctx.with_role(@ctx.role),
26
- key: key,
27
- correlation_id: @ctx.correlation_id)
19
+ @ctx.bus.publish(:entry_deleted,
20
+ store: @ctx.with_role(@ctx.role),
21
+ key: key,
22
+ correlation_id: @ctx.correlation_id)
28
23
  end
29
24
 
30
25
  { "protocol" => Textus::PROTOCOL, "ok" => true, "key" => key, "deleted" => true }