textus 0.22.0 → 0.26.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 (160) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +148 -45
  3. data/CHANGELOG.md +102 -0
  4. data/README.md +1 -1
  5. data/SPEC.md +12 -12
  6. data/docs/conventions.md +10 -0
  7. data/lib/textus/application/caps.rb +49 -0
  8. data/lib/textus/application/context.rb +2 -2
  9. data/lib/textus/application/envelope/reader.rb +44 -0
  10. data/lib/textus/application/{writes/envelope_io.rb → envelope/writer.rb} +24 -50
  11. data/lib/textus/application/maintenance/key_delete_prefix.rb +44 -0
  12. data/lib/textus/application/maintenance/key_mv_prefix.rb +57 -0
  13. data/lib/textus/application/maintenance/migrate.rb +59 -0
  14. data/lib/textus/application/maintenance/rule_lint.rb +65 -0
  15. data/lib/textus/application/maintenance/zone_mv.rb +60 -0
  16. data/lib/textus/application/maintenance.rb +17 -0
  17. data/lib/textus/application/projection.rb +12 -10
  18. data/lib/textus/application/read/audit.rb +106 -0
  19. data/lib/textus/application/read/blame.rb +91 -0
  20. data/lib/textus/application/read/deps.rb +34 -0
  21. data/lib/textus/application/read/freshness.rb +110 -0
  22. data/lib/textus/application/read/get.rb +75 -0
  23. data/lib/textus/application/read/get_or_refresh.rb +63 -0
  24. data/lib/textus/application/read/list.rb +25 -0
  25. data/lib/textus/application/read/policy_explain.rb +47 -0
  26. data/lib/textus/application/read/published.rb +25 -0
  27. data/lib/textus/application/read/pulse.rb +101 -0
  28. data/lib/textus/application/read/rdeps.rb +35 -0
  29. data/lib/textus/application/read/schema_envelope.rb +26 -0
  30. data/lib/textus/application/read/stale.rb +23 -0
  31. data/lib/textus/application/read/uid.rb +30 -0
  32. data/lib/textus/application/read/validate_all.rb +32 -0
  33. data/lib/textus/application/{reads → read}/validator.rb +2 -2
  34. data/lib/textus/application/read/where.rb +26 -0
  35. data/lib/textus/application/use_case.rb +22 -0
  36. data/lib/textus/application/write/accept.rb +102 -0
  37. data/lib/textus/application/{writes → write}/authority_gate.rb +3 -3
  38. data/lib/textus/application/write/delete.rb +45 -0
  39. data/lib/textus/application/{writes → write}/materializer.rb +14 -15
  40. data/lib/textus/application/write/mv.rb +118 -0
  41. data/lib/textus/application/write/publish.rb +96 -0
  42. data/lib/textus/application/write/put.rb +49 -0
  43. data/lib/textus/application/write/refresh_all.rb +63 -0
  44. data/lib/textus/application/{refresh/orchestrator.rb → write/refresh_orchestrator.rb} +32 -8
  45. data/lib/textus/application/write/refresh_worker.rb +134 -0
  46. data/lib/textus/application/write/reject.rb +62 -0
  47. data/lib/textus/boot.rb +27 -29
  48. data/lib/textus/builder/pipeline.rb +3 -3
  49. data/lib/textus/cli/group/mcp.rb +9 -0
  50. data/lib/textus/cli/group/zone.rb +9 -0
  51. data/lib/textus/cli/verb/accept.rb +1 -1
  52. data/lib/textus/cli/verb/audit.rb +2 -2
  53. data/lib/textus/cli/verb/blame.rb +1 -1
  54. data/lib/textus/cli/verb/boot.rb +1 -1
  55. data/lib/textus/cli/verb/build.rb +2 -2
  56. data/lib/textus/cli/verb/delete.rb +1 -1
  57. data/lib/textus/cli/verb/deps.rb +1 -1
  58. data/lib/textus/cli/verb/doctor.rb +1 -1
  59. data/lib/textus/cli/verb/freshness.rb +1 -1
  60. data/lib/textus/cli/verb/get.rb +1 -1
  61. data/lib/textus/cli/verb/hook_run.rb +3 -4
  62. data/lib/textus/cli/verb/hooks.rb +11 -14
  63. data/lib/textus/cli/verb/key_delete.rb +24 -0
  64. data/lib/textus/cli/verb/list.rb +1 -1
  65. data/lib/textus/cli/verb/mcp_serve.rb +17 -0
  66. data/lib/textus/cli/verb/migrate.rb +18 -0
  67. data/lib/textus/cli/verb/mv.rb +11 -3
  68. data/lib/textus/cli/verb/published.rb +1 -1
  69. data/lib/textus/cli/verb/pulse.rb +1 -1
  70. data/lib/textus/cli/verb/put.rb +8 -6
  71. data/lib/textus/cli/verb/rdeps.rb +1 -1
  72. data/lib/textus/cli/verb/refresh.rb +1 -1
  73. data/lib/textus/cli/verb/refresh_stale.rb +1 -1
  74. data/lib/textus/cli/verb/reject.rb +1 -1
  75. data/lib/textus/cli/verb/rule_explain.rb +1 -1
  76. data/lib/textus/cli/verb/rule_lint.rb +18 -0
  77. data/lib/textus/cli/verb/schema.rb +1 -1
  78. data/lib/textus/cli/verb/uid.rb +1 -1
  79. data/lib/textus/cli/verb/where.rb +1 -1
  80. data/lib/textus/cli/verb/zone_mv.rb +19 -0
  81. data/lib/textus/cli/verb.rb +4 -4
  82. data/lib/textus/doctor/check/audit_log.rb +2 -2
  83. data/lib/textus/doctor/check/handler_allowlist.rb +2 -2
  84. data/lib/textus/doctor/check/hooks.rb +4 -3
  85. data/lib/textus/doctor/check/illegal_keys.rb +2 -2
  86. data/lib/textus/doctor/check/intake_registration.rb +2 -2
  87. data/lib/textus/doctor/check/manifest_files.rb +2 -2
  88. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  89. data/lib/textus/doctor/check/refresh_locks.rb +2 -2
  90. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  91. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  92. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  93. data/lib/textus/doctor/check/schemas.rb +2 -2
  94. data/lib/textus/doctor/check/sentinels.rb +2 -2
  95. data/lib/textus/doctor/check/templates.rb +2 -2
  96. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  97. data/lib/textus/doctor/check.rb +5 -3
  98. data/lib/textus/doctor.rb +24 -27
  99. data/lib/textus/domain/authorizer.rb +4 -4
  100. data/lib/textus/{application → domain}/policy/predicates/accept_authority_signed.rb +2 -2
  101. data/lib/textus/{application → domain}/policy/predicates/schema_valid.rb +1 -1
  102. data/lib/textus/{application → domain}/policy/promotion.rb +1 -1
  103. data/lib/textus/domain/staleness/generator_check.rb +2 -2
  104. data/lib/textus/domain/staleness/intake_check.rb +2 -2
  105. data/lib/textus/domain/staleness.rb +1 -1
  106. data/lib/textus/hooks/builtin.rb +14 -14
  107. data/lib/textus/hooks/context.rb +13 -13
  108. data/lib/textus/hooks/error_log.rb +32 -0
  109. data/lib/textus/hooks/{bus.rb → event_bus.rb} +41 -53
  110. data/lib/textus/hooks/loader.rb +29 -3
  111. data/lib/textus/hooks/rpc_registry.rb +77 -0
  112. data/lib/textus/infra/audit_subscriber.rb +6 -7
  113. data/lib/textus/infra/refresh/detached.rb +1 -1
  114. data/lib/textus/key/path.rb +7 -3
  115. data/lib/textus/manifest/data.rb +78 -0
  116. data/lib/textus/manifest/entry/base.rb +4 -4
  117. data/lib/textus/manifest/entry/derived.rb +4 -5
  118. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  119. data/lib/textus/manifest/policy.rb +48 -0
  120. data/lib/textus/manifest/resolver.rb +14 -14
  121. data/lib/textus/manifest/rules.rb +1 -1
  122. data/lib/textus/manifest.rb +53 -111
  123. data/lib/textus/mcp/errors.rb +32 -0
  124. data/lib/textus/mcp/server.rb +127 -0
  125. data/lib/textus/mcp/session.rb +31 -0
  126. data/lib/textus/mcp/tool_schemas.rb +71 -0
  127. data/lib/textus/mcp/tools.rb +129 -0
  128. data/lib/textus/mcp.rb +6 -0
  129. data/lib/textus/schema/tools.rb +14 -10
  130. data/lib/textus/session.rb +84 -0
  131. data/lib/textus/store.rb +14 -9
  132. data/lib/textus/version.rb +1 -1
  133. data/lib/textus.rb +8 -1
  134. metadata +61 -36
  135. data/lib/textus/application/reads/audit.rb +0 -94
  136. data/lib/textus/application/reads/blame.rb +0 -82
  137. data/lib/textus/application/reads/deps.rb +0 -26
  138. data/lib/textus/application/reads/freshness.rb +0 -88
  139. data/lib/textus/application/reads/get.rb +0 -67
  140. data/lib/textus/application/reads/get_or_refresh.rb +0 -51
  141. data/lib/textus/application/reads/list.rb +0 -17
  142. data/lib/textus/application/reads/policy_explain.rb +0 -39
  143. data/lib/textus/application/reads/published.rb +0 -17
  144. data/lib/textus/application/reads/pulse.rb +0 -63
  145. data/lib/textus/application/reads/rdeps.rb +0 -27
  146. data/lib/textus/application/reads/schema_envelope.rb +0 -18
  147. data/lib/textus/application/reads/stale.rb +0 -15
  148. data/lib/textus/application/reads/uid.rb +0 -23
  149. data/lib/textus/application/reads/validate_all.rb +0 -24
  150. data/lib/textus/application/reads/where.rb +0 -18
  151. data/lib/textus/application/refresh/all.rb +0 -52
  152. data/lib/textus/application/refresh/worker.rb +0 -116
  153. data/lib/textus/application/writes/accept.rb +0 -89
  154. data/lib/textus/application/writes/delete.rb +0 -33
  155. data/lib/textus/application/writes/mv.rb +0 -105
  156. data/lib/textus/application/writes/publish.rb +0 -81
  157. data/lib/textus/application/writes/put.rb +0 -37
  158. data/lib/textus/application/writes/reject.rb +0 -50
  159. data/lib/textus/infra/event_bus.rb +0 -27
  160. data/lib/textus/operations.rb +0 -176
@@ -0,0 +1,22 @@
1
+ module Textus
2
+ module Application
3
+ # Registry mapping verb symbols to use-case modules. Each entry says
4
+ # which caps slice the use case needs (:read or :write); Session
5
+ # uses this to define one method per verb.
6
+ module UseCase
7
+ Entry = Data.define(:verb, :mod, :caps_kind)
8
+
9
+ @entries = []
10
+
11
+ class << self
12
+ attr_reader :entries
13
+
14
+ def register(verb, mod, caps:)
15
+ @entries << Entry.new(verb: verb.to_sym, mod: mod, caps_kind: caps.to_sym)
16
+ end
17
+
18
+ def each(&) = @entries.each(&)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,102 @@
1
+ require_relative "authority_gate"
2
+
3
+ module Textus
4
+ module Application
5
+ module Write
6
+ module Accept
7
+ def self.call(*, session:, ctx:, caps:, **)
8
+ Impl.new(
9
+ ctx: ctx, caps: caps,
10
+ writer: session.envelope_writer,
11
+ hook_context: session.hook_context
12
+ ).call(*, **)
13
+ end
14
+
15
+ class Impl
16
+ include AuthorityGate
17
+
18
+ def initialize(ctx:, caps:, writer:, hook_context:)
19
+ @ctx = ctx
20
+ @caps = caps
21
+ @manifest = caps.manifest
22
+ @file_store = caps.file_store
23
+ @schemas = caps.schemas
24
+ @writer = writer
25
+ @events = caps.events
26
+ @authorizer = caps.authorizer
27
+ @hook_context = hook_context
28
+ end
29
+
30
+ def call(pending_key)
31
+ assert_accept_authority!("accept")
32
+
33
+ env = Textus::Application::Read::Get::Impl.new(
34
+ ctx: @ctx, caps: @caps,
35
+ ).call(pending_key)
36
+ proposal = env.meta["proposal"] or raise ProposalError.new("entry has no proposal block: #{pending_key}")
37
+ target = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
38
+ action = proposal["action"] || "put"
39
+
40
+ evaluate_promotion!(env, target)
41
+
42
+ case action
43
+ when "put"
44
+ # Nested proposal "frontmatter" — the meta to write to the accepted
45
+ # target. Not related to the removed intake-handler legacy bridge.
46
+ target_meta = env.meta["frontmatter"] || {}
47
+ target_body = env.body
48
+ put_op.call(target, meta: target_meta, body: target_body)
49
+ when "delete"
50
+ delete_op.call(target)
51
+ else
52
+ raise ProposalError.new("unknown action: #{action}")
53
+ end
54
+
55
+ delete_op.call(pending_key)
56
+
57
+ @events.publish(:proposal_accepted,
58
+ ctx: @hook_context,
59
+ key: pending_key,
60
+ target_key: target)
61
+
62
+ { "protocol" => PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action }
63
+ end
64
+
65
+ private
66
+
67
+ def put_op
68
+ @put_op ||= Textus::Application::Write::Put::Impl.new(
69
+ ctx: @ctx, caps: @caps, writer: @writer,
70
+ hook_context: @hook_context
71
+ )
72
+ end
73
+
74
+ def delete_op
75
+ @delete_op ||= Textus::Application::Write::Delete::Impl.new(
76
+ ctx: @ctx, caps: @caps, writer: @writer,
77
+ hook_context: @hook_context
78
+ )
79
+ end
80
+
81
+ def evaluate_promotion!(env, target_key)
82
+ rules = @manifest.rules.for(target_key)
83
+ promote = rules.promote
84
+ return if promote.nil? || promote.requires.empty?
85
+
86
+ policy = Textus::Domain::Policy::Promotion.from_names(promote.requires)
87
+ result = policy.evaluate(
88
+ entry: env, schemas: @schemas, manifest: @manifest, role: @ctx.role,
89
+ )
90
+ return if result.ok?
91
+
92
+ raise ProposalError.new(
93
+ "promotion gate failed: #{result.reasons.join("; ")}",
94
+ )
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+
102
+ Textus::Application::UseCase.register(:accept, Textus::Application::Write::Accept, caps: :write)
@@ -1,15 +1,15 @@
1
1
  module Textus
2
2
  module Application
3
- module Writes
3
+ module Write
4
4
  # Shared gate for write verbs that require the caller to hold the
5
5
  # manifest's accept_authority role. Provides one method, expressed
6
6
  # as two early-returns rather than a ternary, so each failure mode
7
7
  # reads on its own line.
8
8
  module AuthorityGate
9
9
  def assert_accept_authority!(verb)
10
- return if @manifest.role_kind(@ctx.role) == :accept_authority
10
+ return if @manifest.policy.role_kind(@ctx.role) == :accept_authority
11
11
 
12
- authority = @manifest.roles_with_kind(:accept_authority).first
12
+ authority = @manifest.policy.roles_with_kind(:accept_authority).first
13
13
  if authority.nil?
14
14
  raise ProposalError.new(
15
15
  "no role with accept_authority kind is declared in this manifest; #{verb} is disabled",
@@ -0,0 +1,45 @@
1
+ module Textus
2
+ module Application
3
+ module Write
4
+ module Delete
5
+ def self.call(*, session:, ctx:, caps:, **)
6
+ Impl.new(
7
+ ctx: ctx, caps: caps,
8
+ writer: session.envelope_writer,
9
+ hook_context: session.hook_context
10
+ ).call(*, **)
11
+ end
12
+
13
+ class Impl
14
+ def initialize(ctx:, caps:, writer:, hook_context:)
15
+ @ctx = ctx
16
+ @manifest = caps.manifest
17
+ @events = caps.events
18
+ @authorizer = caps.authorizer
19
+ @writer = writer
20
+ @hook_context = hook_context
21
+ end
22
+
23
+ def call(key, if_etag: nil, suppress_events: false)
24
+ Textus::Manifest::Data.validate_key!(key)
25
+ mentry = @manifest.resolver.resolve(key).entry
26
+
27
+ @authorizer.authorize_write!(mentry, role: @ctx.role)
28
+
29
+ @writer.delete(key, mentry: mentry, if_etag: if_etag)
30
+
31
+ unless suppress_events
32
+ @events.publish(:entry_deleted,
33
+ ctx: @hook_context,
34
+ key: key)
35
+ end
36
+
37
+ { "protocol" => Textus::PROTOCOL, "ok" => true, "key" => key, "deleted" => true }
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ Textus::Application::UseCase.register(:delete, Textus::Application::Write::Delete, caps: :write)
@@ -2,37 +2,36 @@ require "fileutils"
2
2
 
3
3
  module Textus
4
4
  module Application
5
- module Writes
5
+ module Write
6
6
  # Materializes a single Derived manifest entry onto disk by running
7
7
  # the builder pipeline (template + projection + external runner).
8
- # Extracted from Application::Writes::Build so that Publish can reuse
8
+ # Extracted from Application::Write::Build so that Publish can reuse
9
9
  # it without creating a Build dependency.
10
10
  class Materializer
11
- def initialize(ctx:, manifest:, file_store:, bus:, root:, store:)
11
+ def initialize(ctx:, caps:, rpc:, session:)
12
12
  @ctx = ctx
13
- @manifest = manifest
14
- @file_store = file_store
15
- @bus = bus
16
- @root = root
17
- @store = store
13
+ @caps = caps
14
+ @manifest = caps.manifest
15
+ @file_store = caps.file_store
16
+ @rpc = rpc
17
+ @root = caps.root
18
+ @session = session
18
19
  end
19
20
 
20
21
  # Runs the builder pipeline for `mentry` and returns the on-disk
21
22
  # target_path string.
22
23
  def run(mentry)
23
- reader = Textus::Application::Reads::Get.new(
24
- ctx: @ctx, manifest: @manifest, file_store: @file_store,
25
- )
26
- lister = Textus::Application::Reads::List.new(manifest: @manifest)
24
+ reader = Textus::Application::Read::Get::Impl.new(ctx: @ctx, caps: @caps)
25
+ lister = Textus::Application::Read::List::Impl.new(caps: @caps)
27
26
  Builder::Pipeline.run(
28
27
  mentry: mentry,
29
28
  manifest: @manifest,
30
29
  reader: reader.method(:call),
31
30
  lister: lister.method(:call),
32
- transform_resolver: ->(name) { @bus.rpc_callable(:transform_rows, name) },
31
+ rpc: @rpc,
33
32
  template_loader: ->(name) { read_template(name) },
34
- transform_context: @store,
35
- inject_boot: -> { Textus::Boot.run(@store) },
33
+ transform_context: @caps,
34
+ inject_boot: -> { Textus::Boot.run(@session) },
36
35
  )
37
36
  end
38
37
 
@@ -0,0 +1,118 @@
1
+ module Textus
2
+ module Application
3
+ module Write
4
+ module Mv
5
+ def self.call(*, session:, ctx:, caps:, **)
6
+ Impl.new(
7
+ ctx: ctx, caps: caps,
8
+ reader: session.envelope_reader,
9
+ writer: session.envelope_writer,
10
+ hook_context: session.hook_context
11
+ ).call(*, **)
12
+ end
13
+
14
+ class Impl
15
+ def initialize(ctx:, caps:, reader:, writer:, hook_context:)
16
+ @ctx = ctx
17
+ @manifest = caps.manifest
18
+ @events = caps.events
19
+ @authorizer = caps.authorizer
20
+ @reader = reader
21
+ @writer = writer
22
+ @hook_context = hook_context
23
+ end
24
+
25
+ def call(old_key, new_key, dry_run: false)
26
+ old_res, new_res = prepare(old_key, new_key)
27
+ return dry_run_result(old_key, new_key, old_res, new_res) if dry_run
28
+
29
+ ensure_uid!(old_key, old_res.entry)
30
+ envelope = @writer.move(
31
+ from_key: old_key, to_key: new_key,
32
+ new_mentry: new_res.entry
33
+ )
34
+ publish_renamed(old_key, new_key, envelope)
35
+ success_result(old_key, new_key, old_res, new_res, envelope)
36
+ end
37
+
38
+ private
39
+
40
+ def prepare(old_key, new_key)
41
+ Textus::Manifest::Data.validate_key!(old_key)
42
+ Textus::Manifest::Data.validate_key!(new_key)
43
+ raise UsageError.new("mv: old and new keys are identical") if old_key == new_key
44
+
45
+ old_res = @manifest.resolver.resolve(old_key)
46
+ new_res = @manifest.resolver.resolve(new_key)
47
+ raise UnknownKey.new(old_key) unless @reader.exists?(old_key)
48
+
49
+ validate_zone_and_format!(old_res.entry, new_res.entry)
50
+ @authorizer.authorize_write!(old_res.entry, role: @ctx.role)
51
+ @authorizer.authorize_write!(new_res.entry, role: @ctx.role)
52
+ raise UsageError.new("mv: target '#{new_key}' already exists at #{new_res.path}") if @reader.exists?(new_key)
53
+
54
+ [old_res, new_res]
55
+ end
56
+
57
+ def validate_zone_and_format!(old_mentry, new_mentry)
58
+ if old_mentry.zone != new_mentry.zone
59
+ raise UsageError.new(
60
+ "mv: cross-zone move refused (#{old_mentry.zone} → #{new_mentry.zone}). " \
61
+ "Use put+delete for cross-zone moves.",
62
+ )
63
+ end
64
+ return if old_mentry.format == new_mentry.format
65
+
66
+ raise UsageError.new("mv: format mismatch (#{old_mentry.format} → #{new_mentry.format}); refusing.")
67
+ end
68
+
69
+ # If the source file lacks a UID, rewrite it in-place via the writer
70
+ # so a UID gets injected before the move. This produces one "put"
71
+ # audit row, then the "mv" row from Writer#move.
72
+ def ensure_uid!(old_key, old_mentry)
73
+ pre_env = @reader.read(old_key)
74
+ return if pre_env.uid
75
+
76
+ @writer.put(
77
+ old_key, mentry: old_mentry,
78
+ payload: Textus::Application::Envelope::Writer::Payload.new(
79
+ meta: pre_env.meta, body: pre_env.body, content: pre_env.content,
80
+ )
81
+ )
82
+ end
83
+
84
+ def publish_renamed(old_key, new_key, envelope)
85
+ @events.publish(:entry_renamed,
86
+ ctx: @hook_context,
87
+ key: new_key,
88
+ from_key: old_key,
89
+ to_key: new_key,
90
+ envelope: envelope)
91
+ end
92
+
93
+ def dry_run_result(old_key, new_key, old_res, new_res)
94
+ pre_env = @reader.read(old_key)
95
+ {
96
+ "protocol" => PROTOCOL, "ok" => true, "dry_run" => true,
97
+ "from_key" => old_key, "to_key" => new_key,
98
+ "from_path" => old_res.path, "to_path" => new_res.path,
99
+ "uid" => pre_env.uid
100
+ }
101
+ end
102
+
103
+ def success_result(old_key, new_key, old_res, new_res, envelope)
104
+ {
105
+ "protocol" => PROTOCOL, "ok" => true,
106
+ "from_key" => old_key, "to_key" => new_key,
107
+ "from_path" => old_res.path, "to_path" => new_res.path,
108
+ "uid" => envelope.uid,
109
+ "envelope" => envelope.to_h_for_wire
110
+ }
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+
118
+ Textus::Application::UseCase.register(:mv, Textus::Application::Write::Mv, caps: :write)
@@ -0,0 +1,96 @@
1
+ module Textus
2
+ module Application
3
+ module Write
4
+ # Single-pass publish use case: dispatches polymorphically to each
5
+ # entry's `publish_via` method. Derived entries materialize their body
6
+ # via Materializer; Nested entries fan out via publish_each; Leaf and
7
+ # Intake entries copy their stored body to publish_to targets. The
8
+ # Publish layer owns wiring (context, accumulation) but not per-kind
9
+ # logic.
10
+ #
11
+ # Return shape: { "protocol", "built", "published_leaves" }
12
+ module Publish
13
+ def self.call(*, session:, ctx:, caps:, **)
14
+ Impl.new(
15
+ ctx: ctx, caps: caps,
16
+ rpc: session.rpc,
17
+ session: session,
18
+ hook_context: session.hook_context
19
+ ).call(*, **)
20
+ end
21
+
22
+ class Impl
23
+ def initialize(ctx:, caps:, rpc:, session:, hook_context:)
24
+ @ctx = ctx
25
+ @caps = caps
26
+ @manifest = caps.manifest
27
+ @file_store = caps.file_store
28
+ @events = caps.events
29
+ @root = caps.root
30
+ @rpc = rpc
31
+ @session = session
32
+ @hook_context = hook_context
33
+ end
34
+
35
+ def call(prefix: nil)
36
+ built = []
37
+ leaves = []
38
+ context = build_context
39
+
40
+ @manifest.data.entries.each do |mentry|
41
+ next if prefix && !entry_matches_prefix?(mentry, prefix)
42
+
43
+ result = mentry.publish_via(context, prefix: prefix)
44
+ next if result.nil?
45
+
46
+ case result[:kind]
47
+ when :built then built << result[:value]
48
+ when :leaves then leaves.concat(result[:value])
49
+ end
50
+ end
51
+
52
+ { "protocol" => Textus::PROTOCOL, "built" => built, "published_leaves" => leaves }
53
+ end
54
+
55
+ private
56
+
57
+ def build_context
58
+ Textus::Manifest::Entry::Base::PublishContext.new(
59
+ repo_root: File.dirname(@root),
60
+ manifest: @manifest,
61
+ file_store: @file_store,
62
+ root: @root,
63
+ caps: @caps,
64
+ rpc: @rpc,
65
+ session: @session,
66
+ ctx: @ctx,
67
+ bus: @events,
68
+ hook_context: @hook_context,
69
+ reader: reader,
70
+ emit: ->(event, **payload) { @events.publish(event, ctx: @hook_context, **payload) },
71
+ )
72
+ end
73
+
74
+ # Whether the entry should be processed for the given prefix filter.
75
+ def entry_matches_prefix?(mentry, prefix)
76
+ return true unless prefix
77
+
78
+ case mentry
79
+ when Textus::Manifest::Entry::Nested
80
+ mentry.key.start_with?(prefix) ||
81
+ prefix.start_with?("#{mentry.key}.")
82
+ else
83
+ mentry.key.start_with?(prefix)
84
+ end
85
+ end
86
+
87
+ def reader
88
+ @reader ||= Textus::Application::Read::Get::Impl.new(ctx: @ctx, caps: @caps)
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+
96
+ Textus::Application::UseCase.register(:publish, Textus::Application::Write::Publish, caps: :write)
@@ -0,0 +1,49 @@
1
+ module Textus
2
+ module Application
3
+ module Write
4
+ module Put
5
+ def self.call(*, session:, ctx:, caps:, **)
6
+ Impl.new(
7
+ ctx: ctx, caps: caps,
8
+ writer: session.envelope_writer,
9
+ hook_context: session.hook_context
10
+ ).call(*, **)
11
+ end
12
+
13
+ class Impl
14
+ def initialize(ctx:, caps:, writer:, hook_context:)
15
+ @ctx = ctx
16
+ @manifest = caps.manifest
17
+ @events = caps.events
18
+ @authorizer = caps.authorizer
19
+ @writer = writer
20
+ @hook_context = hook_context
21
+ end
22
+
23
+ def call(key, meta: nil, body: nil, content: nil, if_etag: nil)
24
+ Textus::Manifest::Data.validate_key!(key)
25
+ mentry = @manifest.resolver.resolve(key).entry
26
+
27
+ @authorizer.authorize_write!(mentry, role: @ctx.role)
28
+
29
+ envelope = @writer.put(
30
+ key,
31
+ mentry: mentry,
32
+ payload: Textus::Application::Envelope::Writer::Payload.new(meta: meta, body: body, content: content),
33
+ if_etag: if_etag,
34
+ )
35
+
36
+ @events.publish(:entry_put,
37
+ ctx: @hook_context,
38
+ key: key,
39
+ envelope: envelope)
40
+
41
+ envelope
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ Textus::Application::UseCase.register(:put, Textus::Application::Write::Put, caps: :write)
@@ -0,0 +1,63 @@
1
+ module Textus
2
+ module Application
3
+ module Write
4
+ module RefreshAll
5
+ def self.call(*, session:, ctx:, caps:, **)
6
+ Impl.new(
7
+ ctx: ctx, caps: caps,
8
+ rpc: session.rpc,
9
+ writer: session.envelope_writer,
10
+ hook_context: session.hook_context
11
+ ).call(*, **)
12
+ end
13
+
14
+ class Impl
15
+ def initialize(ctx:, caps:, rpc:, writer:, hook_context:)
16
+ @ctx = ctx
17
+ @caps = caps
18
+ @rpc = rpc
19
+ @writer = writer
20
+ @hook_context = hook_context
21
+ end
22
+
23
+ def call(prefix: nil, zone: nil)
24
+ worker = Textus::Application::Write::RefreshWorker::Impl.new(
25
+ ctx: @ctx, caps: @caps, rpc: @rpc, writer: @writer,
26
+ hook_context: @hook_context
27
+ )
28
+
29
+ stale_rows = Textus::Application::Read::Stale::Impl.new(caps: @caps).call(prefix: prefix, zone: zone)
30
+ refreshed = []
31
+ failed = []
32
+ skipped = []
33
+
34
+ stale_rows.each do |row|
35
+ key = row["key"] || row[:key]
36
+ reason = row["reason"] || row[:reason]
37
+ if reason.to_s.match?(/ttl exceeded|never refreshed/)
38
+ begin
39
+ worker.run(key)
40
+ refreshed << key
41
+ rescue Textus::Error => e
42
+ failed << { "key" => key, "error" => e.message }
43
+ end
44
+ else
45
+ skipped << { "key" => key, "reason" => reason }
46
+ end
47
+ end
48
+
49
+ {
50
+ "protocol" => Textus::PROTOCOL,
51
+ "ok" => failed.empty?,
52
+ "refreshed" => refreshed,
53
+ "failed" => failed,
54
+ "skipped" => skipped,
55
+ }
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ Textus::Application::UseCase.register(:refresh_all, Textus::Application::Write::RefreshAll, caps: :write)
@@ -1,12 +1,11 @@
1
1
  module Textus
2
2
  module Application
3
- module Refresh
4
- class Orchestrator
5
- def initialize(worker:, store_root:, bus: nil, store: nil, ctx: nil, hook_context: nil, detached_spawner: nil) # rubocop:disable Metrics/ParameterLists
3
+ module Write
4
+ class RefreshOrchestrator
5
+ def initialize(worker:, store_root:, events:, ctx: nil, hook_context: nil, detached_spawner: nil)
6
6
  @worker = worker
7
7
  @store_root = store_root
8
- @bus = bus
9
- @store = store
8
+ @events = events
10
9
  @ctx = ctx
11
10
  @hook_context = hook_context
12
11
  @detached_spawner = detached_spawner || default_spawner
@@ -31,12 +30,37 @@ module Textus
31
30
  end
32
31
 
33
32
  def run_timed(budget_ms, key)
34
- unless Textus::Infra::Refresh::Detached.supported?
33
+ return run_timed_with_fork(budget_ms, key) if Textus::Infra::Refresh::Detached.supported?
34
+
35
+ run_timed_cooperative(budget_ms, key)
36
+ end
37
+
38
+ def run_timed_cooperative(budget_ms, key)
39
+ result = nil
40
+ thread = Thread.new do
41
+ result = @worker.run(key)
42
+ rescue Textus::Error => e
43
+ result = e
44
+ end
45
+
46
+ thread.join(budget_ms / 1000.0)
47
+ if thread.alive?
48
+ thread.kill
35
49
  return Textus::Domain::Outcome::Failed.new(
36
- error: Textus::UsageError.new("timed_sync requires fork (Unix only)"),
50
+ error: Textus::UsageError.new(
51
+ "refresh exceeded budget #{budget_ms}ms (no fork available — cooperative cancel)",
52
+ ),
37
53
  )
38
54
  end
39
55
 
56
+ if result.is_a?(Textus::Error)
57
+ Textus::Domain::Outcome::Failed.new(error: result)
58
+ else
59
+ Textus::Domain::Outcome::Refreshed.new(envelope: result)
60
+ end
61
+ end
62
+
63
+ def run_timed_with_fork(budget_ms, key)
40
64
  result = nil
41
65
  thread = Thread.new do
42
66
  result = @worker.run(key)
@@ -59,7 +83,7 @@ module Textus
59
83
 
60
84
  payload = { key: key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms }
61
85
  payload[:ctx] = @hook_context if @hook_context
62
- @bus&.publish(:refresh_backgrounded, **payload)
86
+ @events.publish(:refresh_backgrounded, **payload)
63
87
  @detached_spawner.call(store_root: @store_root, key: key)
64
88
  Textus::Domain::Outcome::Detached.new
65
89
  elsif result.is_a?(Textus::Error)