textus 0.54.2 → 0.55.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 (242) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -1
  3. data/README.md +17 -10
  4. data/SPEC.md +39 -11
  5. data/docs/architecture/README.md +23 -11
  6. data/docs/reference/conventions.md +5 -2
  7. data/exe/textus +1 -1
  8. data/lib/textus/boot.rb +64 -93
  9. data/lib/textus/dispatch/binder.rb +48 -0
  10. data/lib/textus/dispatch/contracts.rb +63 -0
  11. data/lib/textus/dispatch/handler_registry.rb +21 -0
  12. data/lib/textus/dispatch/middleware/audit_index.rb +51 -0
  13. data/lib/textus/dispatch/middleware/auth.rb +40 -0
  14. data/lib/textus/dispatch/middleware/base.rb +26 -0
  15. data/lib/textus/dispatch/middleware/binder.rb +20 -0
  16. data/lib/textus/dispatch/middleware/cascade.rb +53 -0
  17. data/lib/textus/dispatch/pipeline.rb +35 -0
  18. data/lib/textus/doctor/check/audit_log.rb +2 -2
  19. data/lib/textus/doctor/check/generator_drift.rb +4 -4
  20. data/lib/textus/doctor/check/orphaned_publish_targets.rb +3 -3
  21. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  22. data/lib/textus/doctor/check/raw_asset_paths.rb +1 -1
  23. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  24. data/lib/textus/doctor/check/schema_violations.rb +2 -2
  25. data/lib/textus/doctor/check/schemas.rb +1 -1
  26. data/lib/textus/doctor/check/{notebook_sources.rb → scratchpad_sources.rb} +10 -5
  27. data/lib/textus/doctor/check/sentinels.rb +4 -4
  28. data/lib/textus/doctor/check/templates.rb +1 -1
  29. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  30. data/lib/textus/doctor/check.rb +9 -10
  31. data/lib/textus/doctor.rb +2 -2
  32. data/lib/textus/errors.rb +8 -0
  33. data/lib/textus/format/base.rb +34 -10
  34. data/lib/textus/format/json.rb +5 -27
  35. data/lib/textus/format/markdown.rb +5 -27
  36. data/lib/textus/format/text.rb +5 -4
  37. data/lib/textus/format/yaml.rb +30 -27
  38. data/lib/textus/format.rb +22 -1
  39. data/lib/textus/handlers/maintenance/boot_store.rb +15 -0
  40. data/lib/textus/handlers/maintenance/doctor_store.rb +15 -0
  41. data/lib/textus/handlers/maintenance/drain_store.rb +21 -0
  42. data/lib/textus/handlers/maintenance/ingest_entry.rb +159 -0
  43. data/lib/textus/handlers/maintenance/jobs_action.rb +21 -0
  44. data/lib/textus/handlers/maintenance/published_entries.rb +17 -0
  45. data/lib/textus/handlers/maintenance/rule_explain.rb +77 -0
  46. data/lib/textus/handlers/maintenance/rule_lint.rb +54 -0
  47. data/lib/textus/handlers/maintenance/rule_list.rb +32 -0
  48. data/lib/textus/handlers/maintenance/schema_envelope.rb +19 -0
  49. data/lib/textus/handlers/read/audit_entries.rb +48 -0
  50. data/lib/textus/handlers/read/blame_entry.rb +71 -0
  51. data/lib/textus/handlers/read/deps_entry.rb +17 -0
  52. data/lib/textus/handlers/read/get_entry.rb +68 -0
  53. data/lib/textus/handlers/read/list_keys.rb +36 -0
  54. data/lib/textus/handlers/read/pulse_entries.rb +66 -0
  55. data/lib/textus/handlers/read/rdeps_entry.rb +21 -0
  56. data/lib/textus/handlers/read/uid_entry.rb +18 -0
  57. data/lib/textus/handlers/read/where_entry.rb +18 -0
  58. data/lib/textus/handlers/write/accept_proposal.rb +39 -0
  59. data/lib/textus/handlers/write/data_mv.rb +55 -0
  60. data/lib/textus/handlers/write/delete_key.rb +17 -0
  61. data/lib/textus/handlers/write/enqueue_job.rb +27 -0
  62. data/lib/textus/handlers/write/key_delete_prefix.rb +32 -0
  63. data/lib/textus/handlers/write/key_mv_prefix.rb +45 -0
  64. data/lib/textus/handlers/write/move_key.rb +80 -0
  65. data/lib/textus/handlers/write/propose_entry.rb +29 -0
  66. data/lib/textus/handlers/write/put_entry.rb +29 -0
  67. data/lib/textus/handlers/write/reject_proposal.rb +29 -0
  68. data/lib/textus/init.rb +5 -7
  69. data/lib/textus/jobs.rb +3 -9
  70. data/lib/textus/manifest/capabilities.rb +4 -4
  71. data/lib/textus/manifest/entry/base.rb +3 -3
  72. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
  73. data/lib/textus/manifest/entry/publish/to_paths.rb +2 -2
  74. data/lib/textus/manifest/policy/predicates/author_held.rb +22 -0
  75. data/lib/textus/manifest/policy/predicates/etag_match.rb +18 -0
  76. data/lib/textus/manifest/policy/predicates/fresh_within.rb +13 -0
  77. data/lib/textus/manifest/policy/predicates/lane_deletable_by.rb +31 -0
  78. data/lib/textus/manifest/policy/predicates/lane_writable_by.rb +23 -0
  79. data/lib/textus/manifest/policy/predicates/raw_lane_ingest_only.rb +25 -0
  80. data/lib/textus/manifest/policy/predicates/raw_write_once.rb +24 -0
  81. data/lib/textus/manifest/policy/predicates/schema_valid.rb +41 -0
  82. data/lib/textus/manifest/policy/predicates/target_is_canon.rb +20 -0
  83. data/lib/textus/manifest/policy/predicates.rb +54 -0
  84. data/lib/textus/manifest/policy/retention.rb +1 -1
  85. data/lib/textus/manifest/schema/semantics/cross_field.rb +53 -0
  86. data/lib/textus/manifest/schema/semantics/invariants.rb +125 -0
  87. data/lib/textus/manifest/schema/semantics/migration.rb +83 -0
  88. data/lib/textus/manifest/schema/semantics.rb +11 -216
  89. data/lib/textus/orchestration.rb +55 -0
  90. data/lib/textus/{ports → port}/audit_log.rb +46 -6
  91. data/lib/textus/{ports → port}/build_lock.rb +2 -2
  92. data/lib/textus/{ports → port}/clock.rb +1 -1
  93. data/lib/textus/{ports → port}/publisher.rb +5 -5
  94. data/lib/textus/{core → port}/sentinel.rb +1 -6
  95. data/lib/textus/{ports → port}/sentinel_store.rb +4 -4
  96. data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
  97. data/lib/textus/port/storage/file_store.rb +49 -0
  98. data/lib/textus/port/storage/interface.rb +17 -0
  99. data/lib/textus/port/store.rb +149 -0
  100. data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
  101. data/lib/textus/produce/engine.rb +1 -11
  102. data/lib/textus/produce/publisher.rb +21 -0
  103. data/lib/textus/schema/registry.rb +42 -0
  104. data/lib/textus/schema/tools.rb +7 -10
  105. data/lib/textus/store/container.rb +173 -0
  106. data/lib/textus/store/cursor.rb +26 -0
  107. data/lib/textus/store/entry/reader.rb +47 -0
  108. data/lib/textus/store/entry/writer.rb +219 -0
  109. data/lib/textus/store/envelope/meta.rb +61 -0
  110. data/lib/textus/store/freshness/drift_detector.rb +93 -0
  111. data/lib/textus/store/freshness/evaluator.rb +20 -0
  112. data/lib/textus/store/freshness/ttl_evaluator.rb +57 -0
  113. data/lib/textus/{core → store}/freshness/verdict.rb +1 -11
  114. data/lib/textus/store/freshness.rb +8 -0
  115. data/lib/textus/store/index/builder.rb +76 -0
  116. data/lib/textus/store/index/lookup.rb +60 -0
  117. data/lib/textus/store/jobs/base.rb +13 -0
  118. data/lib/textus/store/jobs/index.rb +15 -0
  119. data/lib/textus/store/jobs/materialize.rb +15 -0
  120. data/lib/textus/store/jobs/plan.rb +11 -0
  121. data/lib/textus/store/jobs/planner.rb +124 -0
  122. data/lib/textus/store/jobs/queue.rb +162 -0
  123. data/lib/textus/store/jobs/registry.rb +19 -0
  124. data/lib/textus/store/jobs/retention/base.rb +52 -0
  125. data/lib/textus/store/jobs/retention/sweep.rb +55 -0
  126. data/lib/textus/store/jobs/retention.rb +8 -0
  127. data/lib/textus/store/jobs/sweep.rb +21 -0
  128. data/lib/textus/store/jobs/worker.rb +64 -0
  129. data/lib/textus/store/layout.rb +97 -0
  130. data/lib/textus/store.rb +63 -32
  131. data/lib/textus/{surfaces → surface}/cli/group/data.rb +1 -1
  132. data/lib/textus/{surfaces → surface}/cli/group/key.rb +1 -1
  133. data/lib/textus/{surfaces → surface}/cli/group/mcp.rb +1 -1
  134. data/lib/textus/{surfaces → surface}/cli/group/rule.rb +1 -1
  135. data/lib/textus/{surfaces → surface}/cli/group/schema.rb +1 -1
  136. data/lib/textus/{surfaces → surface}/cli/group.rb +2 -2
  137. data/lib/textus/{surfaces → surface}/cli/runner.rb +14 -68
  138. data/lib/textus/surface/cli/sources.rb +41 -0
  139. data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +5 -6
  140. data/lib/textus/{surfaces → surface}/cli/verb/get.rb +7 -5
  141. data/lib/textus/{surfaces → surface}/cli/verb/init.rb +1 -1
  142. data/lib/textus/{surfaces → surface}/cli/verb/mcp_serve.rb +3 -3
  143. data/lib/textus/surface/cli/verb/put.rb +27 -0
  144. data/lib/textus/{surfaces → surface}/cli/verb/schema_diff.rb +1 -1
  145. data/lib/textus/{surfaces → surface}/cli/verb/schema_init.rb +1 -1
  146. data/lib/textus/{surfaces → surface}/cli/verb/schema_migrate.rb +1 -1
  147. data/lib/textus/{surfaces → surface}/cli/verb/watch.rb +2 -2
  148. data/lib/textus/{surfaces → surface}/cli/verb.rb +3 -8
  149. data/lib/textus/{surfaces → surface}/cli.rb +1 -1
  150. data/lib/textus/surface/mcp/catalog.rb +58 -0
  151. data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -11
  152. data/lib/textus/surface/mcp/projector.rb +20 -0
  153. data/lib/textus/{surfaces → surface}/mcp/server.rb +24 -35
  154. data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
  155. data/lib/textus/{surfaces → surface}/watcher.rb +8 -8
  156. data/lib/textus/value/call.rb +30 -0
  157. data/lib/textus/value/command.rb +16 -0
  158. data/lib/textus/{core → value}/duration.rb +1 -4
  159. data/lib/textus/value/envelope.rb +90 -0
  160. data/lib/textus/value/etag.rb +39 -0
  161. data/lib/textus/value/payload.rb +7 -0
  162. data/lib/textus/value/result.rb +46 -0
  163. data/lib/textus/value/role.rb +38 -0
  164. data/lib/textus/value/types.rb +13 -0
  165. data/lib/textus/{uid.rb → value/uid.rb} +9 -7
  166. data/lib/textus/verb_registry.rb +417 -0
  167. data/lib/textus/version.rb +1 -1
  168. data/lib/textus/workflow/loader.rb +4 -4
  169. data/lib/textus/workflow/runner.rb +11 -33
  170. data/lib/textus.rb +4 -69
  171. metadata +142 -105
  172. data/lib/textus/action/accept.rb +0 -44
  173. data/lib/textus/action/audit.rb +0 -131
  174. data/lib/textus/action/base.rb +0 -42
  175. data/lib/textus/action/blame.rb +0 -97
  176. data/lib/textus/action/boot.rb +0 -17
  177. data/lib/textus/action/data_mv.rb +0 -69
  178. data/lib/textus/action/deps.rb +0 -34
  179. data/lib/textus/action/doctor.rb +0 -24
  180. data/lib/textus/action/drain.rb +0 -39
  181. data/lib/textus/action/enqueue.rb +0 -53
  182. data/lib/textus/action/get.rb +0 -78
  183. data/lib/textus/action/ingest.rb +0 -203
  184. data/lib/textus/action/jobs.rb +0 -36
  185. data/lib/textus/action/key_delete.rb +0 -36
  186. data/lib/textus/action/key_delete_prefix.rb +0 -44
  187. data/lib/textus/action/key_mv.rb +0 -129
  188. data/lib/textus/action/key_mv_prefix.rb +0 -57
  189. data/lib/textus/action/list.rb +0 -42
  190. data/lib/textus/action/propose.rb +0 -52
  191. data/lib/textus/action/published.rb +0 -24
  192. data/lib/textus/action/pulse.rb +0 -56
  193. data/lib/textus/action/put.rb +0 -52
  194. data/lib/textus/action/rdeps.rb +0 -39
  195. data/lib/textus/action/reject.rb +0 -37
  196. data/lib/textus/action/rule_explain.rb +0 -91
  197. data/lib/textus/action/rule_lint.rb +0 -68
  198. data/lib/textus/action/rule_list.rb +0 -42
  199. data/lib/textus/action/schema_envelope.rb +0 -29
  200. data/lib/textus/action/uid.rb +0 -33
  201. data/lib/textus/action/where.rb +0 -36
  202. data/lib/textus/action/write_verb.rb +0 -44
  203. data/lib/textus/call.rb +0 -28
  204. data/lib/textus/command.rb +0 -41
  205. data/lib/textus/container.rb +0 -26
  206. data/lib/textus/contract/around.rb +0 -29
  207. data/lib/textus/contract/binder.rb +0 -88
  208. data/lib/textus/contract/resources/build_lock.rb +0 -17
  209. data/lib/textus/contract/resources/cursor.rb +0 -26
  210. data/lib/textus/contract/sources.rb +0 -39
  211. data/lib/textus/contract/view.rb +0 -15
  212. data/lib/textus/contract.rb +0 -174
  213. data/lib/textus/core/freshness/evaluator.rb +0 -150
  214. data/lib/textus/core/freshness.rb +0 -11
  215. data/lib/textus/core/retention/sweep.rb +0 -57
  216. data/lib/textus/core/retention.rb +0 -11
  217. data/lib/textus/cursor_store.rb +0 -24
  218. data/lib/textus/envelope/reader.rb +0 -46
  219. data/lib/textus/envelope/writer.rb +0 -209
  220. data/lib/textus/envelope.rb +0 -79
  221. data/lib/textus/etag.rb +0 -36
  222. data/lib/textus/gate/auth.rb +0 -227
  223. data/lib/textus/gate.rb +0 -116
  224. data/lib/textus/jobs/base.rb +0 -23
  225. data/lib/textus/jobs/materialize.rb +0 -20
  226. data/lib/textus/jobs/plan.rb +0 -9
  227. data/lib/textus/jobs/planner.rb +0 -101
  228. data/lib/textus/jobs/retention.rb +0 -48
  229. data/lib/textus/jobs/sweep.rb +0 -27
  230. data/lib/textus/jobs/worker.rb +0 -67
  231. data/lib/textus/layout.rb +0 -91
  232. data/lib/textus/ports/job_store/job.rb +0 -65
  233. data/lib/textus/ports/job_store.rb +0 -123
  234. data/lib/textus/ports/raw_index.rb +0 -61
  235. data/lib/textus/ports/storage/file_store.rb +0 -26
  236. data/lib/textus/role.rb +0 -36
  237. data/lib/textus/schemas.rb +0 -54
  238. data/lib/textus/session.rb +0 -35
  239. data/lib/textus/surfaces/cli/verb/put.rb +0 -30
  240. data/lib/textus/surfaces/mcp/catalog.rb +0 -111
  241. data/lib/textus/surfaces/role_scope.rb +0 -34
  242. data/lib/textus/types.rb +0 -15
@@ -7,7 +7,7 @@ module Textus
7
7
  manifest.data.entries.each do |entry|
8
8
  next if entry.schema.nil?
9
9
 
10
- sp = File.join(root, "schemas", "#{entry.schema}.yaml")
10
+ sp = geometry.schema_path(entry.schema)
11
11
  next if File.exist?(sp)
12
12
 
13
13
  out << {
@@ -1,11 +1,11 @@
1
1
  module Textus
2
2
  module Doctor
3
3
  class Check
4
- class NotebookSources < Check
4
+ class ScratchpadSources < Check
5
5
  def call
6
6
  issues = []
7
7
  manifest.resolver.enumerate.each do |row|
8
- next unless row[:key].start_with?("notebook.notes.")
8
+ next unless row[:key].start_with?("scratchpad.notes.")
9
9
  next unless row[:path] && File.exist?(row[:path])
10
10
 
11
11
  sources = parse_sources(row[:path])
@@ -13,10 +13,10 @@ module Textus
13
13
  next if raw_entry_exists?(raw_key)
14
14
 
15
15
  issues << {
16
- "code" => "notebook.source_missing",
16
+ "code" => "scratchpad.source_missing",
17
17
  "level" => "warning",
18
18
  "subject" => row[:key],
19
- "message" => "notebook entry '#{row[:key]}' references raw key '#{raw_key}' " \
19
+ "message" => "scratchpad entry '#{row[:key]}' references raw key '#{raw_key}' " \
20
20
  "which does not exist in the store",
21
21
  "fix" => "re-ingest the source: textus ingest ..., or remove the stale sources: entry",
22
22
  }
@@ -33,7 +33,12 @@ module Textus
33
33
  return [] unless match
34
34
 
35
35
  front = YAML.safe_load(match[1])
36
- Array(front&.dig("sources"))
36
+ Array(front&.dig("sources")).filter_map do |s|
37
+ case s
38
+ when String then s
39
+ when Hash then s["key"]
40
+ end
41
+ end
37
42
  rescue StandardError
38
43
  []
39
44
  end
@@ -3,13 +3,13 @@ module Textus
3
3
  class Check
4
4
  class Sentinels < Check
5
5
  def call
6
- store = Textus::Ports::SentinelStore.new
7
- file_stat = Textus::Ports::Storage::FileStat.new
8
- dir = Textus::Layout.sentinels(root)
6
+ store = Textus::Port::SentinelStore.new
7
+ file_stat = Textus::Port::Storage::FileStat.new
8
+ dir = Textus::Store::Layout.new(root).sentinels_root
9
9
  return [] unless file_stat.directory?(dir)
10
10
 
11
11
  repo_root = File.dirname(root)
12
- file_stat.glob(File.join(dir, "**", "*#{Textus::Ports::SentinelStore::SUFFIX}")).flat_map do |sentinel_path|
12
+ file_stat.glob(File.join(dir, "**", "*#{Textus::Port::SentinelStore::SUFFIX}")).flat_map do |sentinel_path|
13
13
  inspect_sentinel(sentinel_path, repo_root, store, file_stat)
14
14
  end
15
15
  end
@@ -9,7 +9,7 @@ module Textus
9
9
  template = target.template
10
10
  next if template.nil?
11
11
 
12
- tp = File.join(root, "templates", template)
12
+ tp = geometry.template_path(template)
13
13
  next if File.exist?(tp)
14
14
 
15
15
  out << {
@@ -3,7 +3,7 @@ module Textus
3
3
  class Check
4
4
  class UnownedSchemaFields < Check
5
5
  def call
6
- dir = File.join(root, "schemas")
6
+ dir = geometry.schemas_dir
7
7
  return [] unless File.directory?(dir)
8
8
 
9
9
  Dir.glob(File.join(dir, "*.yaml")).flat_map do |path|
@@ -14,7 +14,7 @@ module Textus
14
14
  .downcase
15
15
  end
16
16
 
17
- def initialize(container, role: Textus::Role::DEFAULT)
17
+ def initialize(container, role: Textus::Value::Role::DEFAULT)
18
18
  @container = container
19
19
  @role = role
20
20
  end
@@ -26,18 +26,17 @@ module Textus
26
26
  protected
27
27
 
28
28
  def root = @container.root
29
+ def geometry = @container.layout
29
30
  def manifest = @container.manifest
30
31
 
31
- # Dispatch a verb through Gate.
32
32
  def dispatch(verb, *args, **kwargs)
33
- klass = Textus::Action::VERBS[verb]
34
- spec = klass.contract if klass.respond_to?(:contract?) && klass.contract?
35
- inputs = spec ? Textus::Contract::Binder.inputs_from_ordered(spec, args, kwargs) : kwargs
36
- cmd_class = Textus::Gate::VERB_COMMAND.fetch(verb)
37
- merged = inputs.merge(role: @role)
38
- filled = cmd_class.members.to_h { |m| [m, merged.key?(m) ? merged[m] : nil] }
39
- cmd = cmd_class.new(**filled)
40
- @container.gate.dispatch(cmd)
33
+ contract_class = Textus::VerbRegistry.contract_class_for(verb)
34
+ members = contract_class.members
35
+ command_kwargs = members.each_with_index.to_h { |m, i| [m, args[i] || kwargs[m]] }
36
+ command = contract_class.new(**command_kwargs)
37
+ call = Textus::Value::Call.build(role: @role)
38
+ result = @container.pipeline.dispatch(command, call: call)
39
+ Textus::Value::Result.extract(result)
41
40
  end
42
41
  end
43
42
  end
data/lib/textus/doctor.rb CHANGED
@@ -22,7 +22,7 @@ module Textus
22
22
  Check::ProposalTargets,
23
23
  Check::GeneratorDrift,
24
24
  Check::RawAssetPaths,
25
- Check::NotebookSources,
25
+ Check::ScratchpadSources,
26
26
  Check::StaleReviewedStamp,
27
27
  ].freeze
28
28
 
@@ -30,7 +30,7 @@ module Textus
30
30
 
31
31
  module_function
32
32
 
33
- def build(container:, checks: nil, role: Textus::Role::DEFAULT)
33
+ def build(container:, checks: nil, role: Textus::Value::Role::DEFAULT)
34
34
  selected_keys = checks ? Array(checks).map(&:to_s) : ALL_CHECKS
35
35
  unknown = selected_keys - ALL_CHECKS
36
36
  unless unknown.empty?
data/lib/textus/errors.rb CHANGED
@@ -225,7 +225,15 @@ module Textus
225
225
  end
226
226
  end
227
227
 
228
+ class ActionError < Error
229
+ def initialize(code, message, details: {})
230
+ super(code, message, details: details, exit_code: 1)
231
+ end
232
+ end
233
+
228
234
  class CursorExpired < Error
235
+ JSONRPC_CODE = -32_002
236
+
229
237
  attr_reader :requested, :min_available
230
238
 
231
239
  def initialize(requested:, min_available:)
@@ -25,18 +25,46 @@ module Textus
25
25
  raise NotImplementedError.new("#{name}.nested_glob not implemented")
26
26
  end
27
27
 
28
- def self.validate_path_extension(_path, _nested)
29
- raise NotImplementedError.new("#{name}.validate_path_extension not implemented")
28
+ def self.validate_path_extension(path, nested)
29
+ ext = File.extname(path)
30
+ if nested
31
+ return if ext == ""
32
+
33
+ raise UsageError.new("#{format_name} nested path must not have an extension")
34
+ end
35
+
36
+ return if extensions.include?(ext)
37
+
38
+ raise UsageError.new("#{format_name} format requires '#{extensions.join("' or '")}' path (got #{ext.inspect})")
30
39
  end
31
40
 
32
- def self.inject_uid(_meta, _content, _existing_uid)
33
- raise NotImplementedError.new("#{name}.inject_uid not implemented")
41
+ def self.enforce_name_match!(path, meta)
42
+ return unless meta.is_a?(Hash) && meta["name"]
43
+
44
+ ext = extensions.first
45
+ basename = File.basename(path, ext)
46
+ return if meta["name"] == basename
47
+
48
+ raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
34
49
  end
35
50
 
36
- def self.enforce_name_match!(_path, _meta)
37
- raise NotImplementedError.new("#{name}.enforce_name_match! not implemented")
51
+ def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
52
+ raw = File.binread(path)
53
+ parsed = parse(raw, path: path)
54
+ meta = parsed["_meta"] || {}
55
+ return false unless meta.is_a?(Hash) && meta["name"].is_a?(String) && meta["name"] != basename
56
+
57
+ new_meta = meta.merge("name" => basename)
58
+ File.binwrite(path, serialize(meta: new_meta, body: parsed["body"] || "", content: parsed["content"]))
59
+ true
38
60
  end
39
61
 
62
+ def self.format_name
63
+ name.split("::").last.downcase
64
+ end
65
+
66
+ def self.validate_raw_entry!(_parsed, _lane); end
67
+
40
68
  def self.serialize_for_put(meta:, body:, content:, path:)
41
69
  _ = meta
42
70
  _ = body
@@ -44,10 +72,6 @@ module Textus
44
72
  _ = path
45
73
  raise NotImplementedError.new("#{name}.serialize_for_put not implemented")
46
74
  end
47
-
48
- def self.rewrite_name(_path, _basename)
49
- raise NotImplementedError.new("#{name}.rewrite_name not implemented")
50
- end
51
75
  end
52
76
  end
53
77
  end
@@ -59,33 +59,6 @@ module Textus
59
59
  end
60
60
  end
61
61
 
62
- def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
63
- raw = File.binread(path)
64
- parsed = parse(raw, path: path)
65
- meta = parsed["_meta"]
66
- return false unless meta.is_a?(Hash) && meta["name"].is_a?(String) && meta["name"] != basename
67
-
68
- new_meta = meta.merge("name" => basename)
69
- File.binwrite(path, serialize(meta: new_meta, body: "", content: parsed["content"]))
70
- true
71
- end
72
-
73
- def self.enforce_name_match!(path, meta)
74
- return unless meta.is_a?(Hash) && meta["name"]
75
-
76
- ext = extensions.first
77
- basename = File.basename(path, ext)
78
- return if meta["name"] == basename
79
-
80
- raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
81
- end
82
-
83
- def self.inject_uid(meta, content, existing_uid)
84
- m = meta.is_a?(Hash) ? meta.dup : {}
85
- m["uid"] = existing_uid || Textus::Uid.mint unless m["uid"].is_a?(String) && !m["uid"].empty?
86
- [m, content]
87
- end
88
-
89
62
  def self.validate_path_extension(path, nested)
90
63
  ext = File.extname(path)
91
64
  if nested
@@ -98,6 +71,11 @@ module Textus
98
71
 
99
72
  raise UsageError.new("json format requires '.json' path (got #{ext.inspect})")
100
73
  end
74
+
75
+ def self.data_to_payload(data)
76
+ data = data.transform_keys(&:to_s) if data.is_a?(Hash)
77
+ { meta: data["_meta"] || {}, body: nil, content: data["content"] || data }
78
+ end
101
79
  end
102
80
  end
103
81
  end
@@ -43,39 +43,17 @@ module Textus
43
43
  [bytes, meta, body.to_s, nil]
44
44
  end
45
45
 
46
- def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
47
- raw = File.binread(path)
48
- parsed = parse(raw, path: path)
49
- meta = parsed["_meta"] || {}
50
- return false unless meta.is_a?(Hash) && meta["name"].is_a?(String) && meta["name"] != basename
51
-
52
- new_meta = meta.merge("name" => basename)
53
- File.binwrite(path, serialize(meta: new_meta, body: parsed["body"]))
54
- true
55
- end
56
-
57
- def self.enforce_name_match!(path, meta)
58
- return unless meta.is_a?(Hash) && meta["name"]
59
-
60
- ext = extensions.first
61
- basename = File.basename(path, ext)
62
- return if meta["name"] == basename
63
-
64
- raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
65
- end
66
-
67
- def self.inject_uid(meta, content, existing_uid)
68
- m = meta.is_a?(Hash) ? meta.dup : {}
69
- m["uid"] = existing_uid || Textus::Uid.mint unless m["uid"].is_a?(String) && !m["uid"].empty?
70
- [m, content]
71
- end
72
-
73
46
  def self.validate_path_extension(path, _nested)
74
47
  ext = File.extname(path)
75
48
  return if ["", ".md"].include?(ext)
76
49
 
77
50
  raise UsageError.new("markdown format requires '.md' path (got #{ext.inspect})")
78
51
  end
52
+
53
+ def self.data_to_payload(data)
54
+ data = data.transform_keys(&:to_s) if data.is_a?(Hash)
55
+ { meta: data["_meta"] || {}, body: (data["body"] || "").to_s, content: nil }
56
+ end
79
57
  end
80
58
  end
81
59
  end
@@ -20,10 +20,6 @@ module Textus
20
20
 
21
21
  def self.nested_glob = "**/*.txt"
22
22
 
23
- def self.inject_uid(meta, content, _existing_uid)
24
- [meta, content]
25
- end
26
-
27
23
  def self.enforce_name_match!(_path, _meta); end
28
24
 
29
25
  def self.serialize_for_put(meta:, body:, content:, path:)
@@ -49,6 +45,11 @@ module Textus
49
45
 
50
46
  raise UsageError.new("text format requires '.txt' or no extension (got #{ext.inspect})")
51
47
  end
48
+
49
+ def self.data_to_payload(data)
50
+ data = data.transform_keys(&:to_s) if data.is_a?(Hash)
51
+ { meta: data["_meta"] || {}, body: (data["body"] || "").to_s, content: nil }
52
+ end
52
53
  end
53
54
  end
54
55
  end
@@ -37,6 +37,31 @@ module Textus
37
37
  schema.validate!(parsed["content"] || {})
38
38
  end
39
39
 
40
+ RAW_REQUIRED = %w[ingested_at content_hash].freeze
41
+ RAW_SOURCE_KINDS = %w[url file asset].freeze
42
+
43
+ def self.validate_raw_entry!(parsed, lane)
44
+ return unless lane == "raw"
45
+
46
+ content = parsed["content"] || {}
47
+ return if content["superseded_by"]
48
+
49
+ missing = RAW_REQUIRED.reject { |f| content[f] }
50
+ raise Textus::BadContent.new(nil, "raw entry missing required field(s): #{missing.join(", ")}") if missing.any?
51
+
52
+ source = content["source"] || {}
53
+ kind = source["kind"]
54
+ unless RAW_SOURCE_KINDS.include?(kind)
55
+ raise Textus::BadContent.new(
56
+ nil, "raw entry source.kind must be #{RAW_SOURCE_KINDS.join("|")}, got #{kind.inspect}"
57
+ )
58
+ end
59
+
60
+ return unless kind == "url" && !source["url"]
61
+
62
+ raise Textus::BadContent.new(nil, "raw entry with source.kind=url must have source.url")
63
+ end
64
+
40
65
  def self.extensions = [".yaml", ".yml"]
41
66
 
42
67
  def self.nested_glob = "**/*.{yaml,yml}"
@@ -57,33 +82,6 @@ module Textus
57
82
  end
58
83
  end
59
84
 
60
- def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
61
- raw = File.binread(path)
62
- parsed = parse(raw, path: path)
63
- meta = parsed["_meta"]
64
- return false unless meta.is_a?(Hash) && meta["name"].is_a?(String) && meta["name"] != basename
65
-
66
- new_meta = meta.merge("name" => basename)
67
- File.binwrite(path, serialize(meta: new_meta, body: "", content: parsed["content"]))
68
- true
69
- end
70
-
71
- def self.enforce_name_match!(path, meta)
72
- return unless meta.is_a?(Hash) && meta["name"]
73
-
74
- ext = extensions.first
75
- basename = File.basename(path, ext)
76
- return if meta["name"] == basename
77
-
78
- raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
79
- end
80
-
81
- def self.inject_uid(meta, content, existing_uid)
82
- m = meta.is_a?(Hash) ? meta.dup : {}
83
- m["uid"] = existing_uid || Textus::Uid.mint unless m["uid"].is_a?(String) && !m["uid"].empty?
84
- [m, content]
85
- end
86
-
87
85
  def self.validate_path_extension(path, nested)
88
86
  ext = File.extname(path)
89
87
  if nested
@@ -96,6 +94,11 @@ module Textus
96
94
 
97
95
  raise UsageError.new("yaml format requires '.yaml' or '.yml' path (got #{ext.inspect})")
98
96
  end
97
+
98
+ def self.data_to_payload(data)
99
+ data = data.transform_keys(&:to_s) if data.is_a?(Hash)
100
+ { meta: data["_meta"] || {}, body: nil, content: data["content"] || data }
101
+ end
99
102
  end
100
103
  end
101
104
  end
data/lib/textus/format.rb CHANGED
@@ -9,6 +9,18 @@ module Textus
9
9
  "text" => -> { Format::Text },
10
10
  }.freeze
11
11
 
12
+ # Optional registry for injectable format strategies. Tests or app
13
+ # initializers can set Format.registry = { "custom" => -> { MyFormat }}
14
+ @registry = nil
15
+
16
+ def self.registry=(reg)
17
+ @registry = reg
18
+ end
19
+
20
+ def self.registry
21
+ @registry
22
+ end
23
+
12
24
  EXT_TO_FORMAT = {
13
25
  ".md" => "markdown",
14
26
  ".json" => "json",
@@ -18,7 +30,10 @@ module Textus
18
30
  }.freeze
19
31
 
20
32
  def self.for(format)
21
- STRATEGIES.fetch(format.to_s) { raise Textus::UsageError.new("unknown entry format: #{format.inspect}") }.call
33
+ key = format.to_s
34
+ return registry.fetch(key).call if registry&.key?(key)
35
+
36
+ STRATEGIES.fetch(key) { raise Textus::UsageError.new("unknown entry format: #{format.inspect}") }.call
22
37
  end
23
38
 
24
39
  def self.infer_from_extension(ext)
@@ -36,5 +51,11 @@ module Textus
36
51
  def self.serialize(meta: {}, body: "", content: nil, format: "markdown")
37
52
  Format.for(format).serialize(meta: meta, body: body, content: content)
38
53
  end
54
+
55
+ def self.data_to_payload(data, format)
56
+ return { meta: {}, body: "", content: nil } if data.nil?
57
+
58
+ Format.for(format).data_to_payload(data)
59
+ end
39
60
  end
40
61
  end
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ module Handlers
3
+ module Maintenance
4
+ class BootStore
5
+ def initialize(container:)
6
+ @container = container
7
+ end
8
+
9
+ def call(_command, _call)
10
+ Value::Result.success(Textus::Boot.build(container: @container))
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ module Handlers
3
+ module Maintenance
4
+ class DoctorStore
5
+ def initialize(container:)
6
+ @container = container
7
+ end
8
+
9
+ def call(command, call)
10
+ Value::Result.success(Textus::Doctor.build(container: @container, checks: command.checks, role: call.role))
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,21 @@
1
+ module Textus
2
+ module Handlers
3
+ module Maintenance
4
+ class DrainStore
5
+ def initialize(container:, job_store:)
6
+ @container = container
7
+ @job_store = job_store
8
+ end
9
+
10
+ def call(_command, call)
11
+ queue = Textus::Store::Jobs::Queue.new(store: @job_store)
12
+ Textus::Store::Jobs::Planner.seed(container: @container, queue: queue, role: call.role)
13
+ queue.reclaim(now: Textus::Port::Clock.new.now)
14
+ summary = Textus::Store::Jobs::Worker.for(container: @container, queue: queue).drain
15
+ Value::Result.success("protocol" => Textus::PROTOCOL, "ok" => summary.failed.zero?,
16
+ "completed" => summary.completed, "failed" => summary.failed)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,159 @@
1
+ require "fileutils"
2
+ require "date"
3
+ require "digest"
4
+
5
+ module Textus
6
+ module Handlers
7
+ module Maintenance
8
+ class IngestEntry
9
+ SOURCE_KINDS = %w[url file asset].freeze
10
+ CONTENT_HASH_ALGO = "sha256"
11
+
12
+ def initialize(container:)
13
+ @container = container
14
+ end
15
+
16
+ def call(command, call)
17
+ unless SOURCE_KINDS.include?(command.kind)
18
+ return Value::Result.failure(:usage_error,
19
+ "ingest kind must be one of #{SOURCE_KINDS.join("|")}")
20
+ end
21
+
22
+ case command.kind
23
+ when "url" then return Value::Result.failure(:usage_error, "ingest url requires url") unless command.url
24
+ when "file" then return Value::Result.failure(:usage_error, "ingest file requires path") unless command.path
25
+ when "asset"
26
+ return Value::Result.failure(:usage_error, "ingest asset requires path") unless command.path
27
+ return Value::Result.failure(:usage_error, "ingest asset requires lane") unless command.lane
28
+ end
29
+
30
+ now = Time.now.utc
31
+ key = derive_key(now, command.kind, command.slug)
32
+ content_hash = compute_content_hash(command)
33
+ mentry = @container.manifest.resolver.resolve(key).entry
34
+ ts = now.iso8601
35
+
36
+ structured = build_structured(ts, now, content_hash, command)
37
+ store = @container.job_store
38
+ index = Textus::Store::Index::Lookup.new(store:)
39
+
40
+ duplicate_key = find_duplicate(index, content_hash, command)
41
+
42
+ env = if duplicate_key && duplicate_key != key
43
+ supersede_entry(duplicate_key, key, structured, call, store, command)
44
+ else
45
+ write_entry(key, structured, mentry, call)
46
+ end
47
+
48
+ rebuild_index(store)
49
+ Value::Result.success(env)
50
+ end
51
+
52
+ private
53
+
54
+ def derive_key(now, kind, slug)
55
+ date = now.strftime("%Y.%m.%d")
56
+ "raw.#{date}.#{kind}-#{slug}"
57
+ end
58
+
59
+ def compute_content_hash(command)
60
+ digest = Digest::SHA256.new
61
+ case command.kind
62
+ when "url" then digest.update(command.url)
63
+ when "file", "asset" then digest.file(command.path)
64
+ end
65
+ "#{CONTENT_HASH_ALGO}:#{digest.hexdigest}"
66
+ end
67
+
68
+ def build_structured(timestamp, now, content_hash, command)
69
+ base = { "ingested_at" => timestamp, "content_hash" => content_hash }
70
+ case command.kind
71
+ when "url"
72
+ base.merge("source" => { "kind" => "url", "url" => command.url,
73
+ "label" => command.label || command.url }, "body" => nil)
74
+ when "file"
75
+ base.merge("source" => { "kind" => "file", "path" => command.path,
76
+ "label" => command.label || File.basename(command.path) },
77
+ "body" => File.read(command.path))
78
+ when "asset"
79
+ asset_rel = copy_asset(now, command.path, command.lane)
80
+ base.merge("source" => { "kind" => "asset",
81
+ "label" => command.label || File.basename(command.path) },
82
+ "asset" => asset_rel, "body" => nil)
83
+ end
84
+ end
85
+
86
+ def copy_asset(now, path, lane)
87
+ date_path = now.strftime("%Y/%m/%d")
88
+ filename = File.basename(path)
89
+ assets_dir = @container.layout.asset_raw_dir(date_path, lane)
90
+ FileUtils.mkdir_p(assets_dir)
91
+ FileUtils.cp(path, File.join(assets_dir, filename))
92
+ sentinel = @container.layout.asset_sentinel_path
93
+ File.write(sentinel, "*\n") unless File.exist?(sentinel)
94
+ "raw/#{date_path}/#{lane}/#{filename}"
95
+ end
96
+
97
+ def write_entry(key, structured, mentry, call)
98
+ writer = Store::Entry::Writer.from(container: @container, call: call)
99
+ writer.put(key, mentry: mentry,
100
+ payload: Textus::Value::Payload.new(meta: nil, body: nil, content: structured))
101
+ end
102
+
103
+ def find_duplicate(index, content_hash, command)
104
+ dup = index.find_by_hash(content_hash)
105
+ return dup if dup
106
+ return unless command.kind == "url"
107
+
108
+ index.find_by_url(command.url)
109
+ end
110
+
111
+ def supersede_entry(old_key, new_key, structured, call, store, command)
112
+ old_mentry = @container.manifest.resolver.resolve(old_key).entry
113
+ reader = Store::Entry::Reader.from(container: @container)
114
+ old_env = reader.read(old_key)
115
+ old_content = old_env&.content || {}
116
+ tombstone = {}
117
+ %w[ingested_at].each { |k| tombstone[k] = old_content[k] if old_content.key?(k) }
118
+ source_kind = old_content.dig("source", "kind")
119
+ tombstone["source"] = { "kind" => source_kind } if source_kind
120
+ tombstone["superseded_by"] = new_key
121
+
122
+ writer = Store::Entry::Writer.from(container: @container, call: call)
123
+ writer.put(old_key, mentry: old_mentry,
124
+ payload: Textus::Value::Payload.new(meta: nil, body: nil, content: tombstone))
125
+
126
+ structured["supersedes"] = old_key
127
+ env = write_entry(new_key, structured,
128
+ @container.manifest.resolver.resolve(new_key).entry, call)
129
+
130
+ move_asset(old_content["asset"], command.lane) if command.kind == "asset" && old_content["asset"]
131
+
132
+ rebuild_index(store)
133
+ env
134
+ end
135
+
136
+ def move_asset(old_rel, lane)
137
+ old_path = @container.layout.asset_resolve(old_rel)
138
+ return unless File.exist?(old_path)
139
+
140
+ now = Time.now.utc
141
+ date_path = now.strftime("%Y/%m/%d")
142
+ filename = File.basename(old_path)
143
+ new_dir = @container.layout.asset_raw_dir(date_path, lane)
144
+ new_path = File.join(new_dir, filename)
145
+ return if old_path == new_path
146
+
147
+ FileUtils.mkdir_p(new_dir)
148
+ FileUtils.mv(old_path, new_path)
149
+ rescue Errno::ENOENT, Errno::EACCES => e
150
+ warn "[textus ingest] could not move asset #{old_rel}: #{e.message}"
151
+ end
152
+
153
+ def rebuild_index(store)
154
+ Textus::Store::Index::Builder.new(store:).rebuild!(resolver: @container.manifest.resolver)
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end