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
@@ -13,27 +13,51 @@ module Textus
13
13
  end
14
14
 
15
15
  def call(key)
16
- envelope = @ctx.store.reader.read_raw_envelope(key)
16
+ envelope = read_raw_envelope(key)
17
17
  return nil if envelope.nil?
18
18
 
19
- policy_set = @ctx.store.manifest.rules_for(key)
19
+ policy_set = @ctx.manifest.rules_for(key)
20
20
  refresh_policy = policy_set.refresh
21
21
  return annotate_fresh(envelope) if refresh_policy.nil?
22
22
 
23
23
  policy = refresh_policy.to_freshness_policy
24
24
  verdict = @evaluator.call(policy, envelope, now: @ctx.now)
25
25
 
26
- envelope.with(freshness: {
27
- "stale" => verdict.stale?,
28
- "stale_reason" => verdict.reason,
29
- "refreshing" => false,
30
- })
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)))
31
38
  end
32
39
 
33
40
  private
34
41
 
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
+ )
55
+ end
56
+
35
57
  def annotate_fresh(envelope)
36
- 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
+ ))
37
61
  end
38
62
  end
39
63
  end
@@ -19,14 +19,14 @@ module Textus
19
19
  def call(key)
20
20
  envelope = @get.call(key)
21
21
  return nil if envelope.nil?
22
- return envelope unless envelope.freshness["stale"]
22
+ return envelope unless envelope.freshness&.stale
23
23
 
24
24
  policy_set = @ctx.store.manifest.rules_for(key)
25
25
  refresh_policy = policy_set.refresh
26
26
  return envelope if refresh_policy.nil?
27
27
 
28
28
  policy = refresh_policy.to_freshness_policy
29
- verdict = Textus::Domain::Freshness::Verdict.stale(envelope.freshness["stale_reason"])
29
+ verdict = Textus::Domain::Freshness::Verdict.stale(envelope.freshness.reason)
30
30
  action = policy.decide(verdict)
31
31
  outcome = @orchestrator.execute(action, key: key)
32
32
 
@@ -35,13 +35,13 @@ module Textus
35
35
  envelope
36
36
  when Textus::Domain::Outcome::Refreshed
37
37
  outcome.envelope.with(
38
- freshness: { "stale" => false, "stale_reason" => nil, "refreshing" => false },
38
+ freshness: Textus::Domain::Freshness.build(stale: false, reason: nil, refreshing: false),
39
39
  )
40
40
  when Textus::Domain::Outcome::Detached
41
- envelope.with(freshness: envelope.freshness.merge("refreshing" => true))
41
+ envelope.with(freshness: envelope.freshness.with(refreshing: true))
42
42
  when Textus::Domain::Outcome::Failed
43
43
  envelope.with(
44
- freshness: envelope.freshness.merge("refresh_error" => outcome.error.message),
44
+ freshness: envelope.freshness.with(refresh_error: outcome.error.message),
45
45
  )
46
46
  end
47
47
  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
@@ -59,7 +58,7 @@ module Textus
59
58
  store_view = @store ? Textus::Application::Context.new(store: @store, role: @role) : nil
60
59
  payload = { key: key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms }
61
60
  payload[:store] = store_view if store_view
62
- @bus.publish(:refresh_backgrounded, **payload)
61
+ @store&.bus&.publish(:refresh_backgrounded, **payload)
63
62
  @detached_spawner.call(store_root: @store_root, key: key)
64
63
  Textus::Domain::Outcome::Detached.new
65
64
  elsif result.is_a?(Textus::Error)
@@ -6,13 +6,16 @@ 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, remaining = @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
@@ -33,8 +36,8 @@ module Textus
33
36
 
34
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)
39
+ @ctx.bus.publish(:refresh_started, store: read_view, key: key, mode: :sync,
40
+ correlation_id: @ctx.correlation_id)
38
41
  call_intake(key, mentry, callable, remaining)
39
42
  end
40
43
 
@@ -48,31 +51,31 @@ module Textus
48
51
  )
49
52
  end
50
53
  rescue Timeout::Error
51
- @bus.publish(:refresh_failed, store: read_view, key: key, error_class: "Timeout::Error",
52
- error_message: "intake '#{mentry.intake_handler}' exceeded #{timeout}s",
53
- 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)
54
57
  raise UsageError.new("intake '#{mentry.intake_handler}' exceeded #{timeout}s timeout")
55
58
  rescue Textus::Error => 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)
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)
58
61
  raise
59
62
  rescue StandardError => e
60
- @bus.publish(:refresh_failed, store: read_view, key: key, error_class: e.class.name,
61
- 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)
62
65
  raise UsageError.new("intake '#{mentry.intake_handler}' raised: #{e.class}: #{e.message}")
63
66
  end
64
67
 
65
68
  def persist_and_notify(key, mentry, result, before_etag)
66
69
  normalized = Textus::Refresh.normalize_action_result(result, format: mentry.format)
67
- 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(
68
71
  key,
69
72
  meta: normalized[:meta], body: normalized[:body], content: normalized[:content],
70
73
  suppress_events: true
71
74
  )
72
75
  change = detect_change(before_etag, envelope)
73
76
  unless change == :unchanged
74
- @bus.publish(:entry_refreshed, store: read_view, key: key, envelope: envelope, change: change,
75
- 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)
76
79
  end
77
80
  envelope
78
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 }
@@ -0,0 +1,106 @@
1
+ module Textus
2
+ module Application
3
+ module Writes
4
+ # Owns the write pipeline (validate, serialize, etag-check, write, audit)
5
+ # extracted from Store::Writer. Talks to ports (FileStore, Schemas,
6
+ # AuditLog, Manifest) instead of File/FileUtils and Store directly.
7
+ #
8
+ # No permission check, no event firing — those belong to the caller
9
+ # (Application::Writes::Put / ::Delete).
10
+ class EnvelopeIO
11
+ Payload = Data.define(:meta, :body, :content)
12
+
13
+ def initialize(file_store:, manifest:, schemas:, audit_log:, ctx:)
14
+ @file_store = file_store
15
+ @manifest = manifest
16
+ @schemas = schemas
17
+ @audit_log = audit_log
18
+ @ctx = ctx
19
+ end
20
+
21
+ def write(key, mentry:, payload:, if_etag: nil)
22
+ path = @manifest.resolve(key).path
23
+
24
+ meta = payload.meta || {}
25
+ strategy = Entry.for_format(mentry.format)
26
+
27
+ existing_uid = existing_uid_for(mentry, path)
28
+ meta, content = ensure_uid(mentry.format, meta, payload.content, existing_uid)
29
+
30
+ bytes, eff_meta, eff_body, eff_content = serialize_for_put(
31
+ mentry: mentry, path: path, strategy: strategy,
32
+ meta: meta, body: payload.body, content: content
33
+ )
34
+
35
+ enforce_name_match!(path, eff_meta, mentry.format)
36
+
37
+ schema = @schemas.fetch_or_nil(mentry.schema)
38
+ if schema
39
+ Entry.for_format(mentry.format).validate_against(
40
+ schema,
41
+ { "_meta" => eff_meta, "content" => eff_content },
42
+ )
43
+ end
44
+
45
+ etag_before = @file_store.exists?(path) ? @file_store.etag(path) : nil
46
+ raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && (etag_before != if_etag)
47
+
48
+ @file_store.write(path, bytes)
49
+ etag_after = Etag.for_bytes(bytes)
50
+ @audit_log.append(
51
+ role: @ctx.role, verb: "put", key: key,
52
+ etag_before: etag_before, etag_after: etag_after,
53
+ extras: @ctx.correlation_id ? { "correlation_id" => @ctx.correlation_id } : nil
54
+ )
55
+ Envelope.build(
56
+ key: key, mentry: mentry, path: path,
57
+ meta: eff_meta, body: eff_body, etag: etag_after, content: eff_content
58
+ )
59
+ end
60
+
61
+ def delete(key, mentry:, if_etag: nil)
62
+ _ = mentry
63
+ path = @manifest.resolve(key).path
64
+ raise UnknownKey.new(key, suggestions: @manifest.suggestions_for(key)) unless @file_store.exists?(path)
65
+
66
+ etag_before = @file_store.etag(path)
67
+ raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before
68
+
69
+ @file_store.delete(path)
70
+ @audit_log.append(
71
+ role: @ctx.role, verb: "delete", key: key,
72
+ etag_before: etag_before, etag_after: nil,
73
+ extras: @ctx.correlation_id ? { "correlation_id" => @ctx.correlation_id } : nil
74
+ )
75
+ end
76
+
77
+ private
78
+
79
+ def existing_uid_for(mentry, path)
80
+ return nil unless @file_store.exists?(path)
81
+
82
+ raw = @file_store.read(path)
83
+ parsed = Entry.for_format(mentry.format).parse(raw, path: path)
84
+ Envelope.extract_uid(parsed["_meta"])
85
+ rescue StandardError
86
+ nil
87
+ end
88
+
89
+ def ensure_uid(format, meta, content, existing_uid)
90
+ Textus::Entry.for_format(format).inject_uid(meta, content, existing_uid)
91
+ end
92
+
93
+ def enforce_name_match!(path, meta, format)
94
+ Textus::Entry.for_format(format).enforce_name_match!(path, meta)
95
+ end
96
+
97
+ def serialize_for_put(mentry:, path:, strategy:, meta:, body:, content:)
98
+ _ = strategy
99
+ Textus::Entry.for_format(mentry.format).serialize_for_put(
100
+ meta: meta, body: body, content: content, path: path,
101
+ )
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end