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
@@ -3,7 +3,7 @@ require "json"
3
3
  require "time"
4
4
 
5
5
  module Textus
6
- module Ports
6
+ module Port
7
7
  # Append-only audit log adapter: writes and rotates the on-disk audit JSONL
8
8
  # under the store root. An instantiable class — it holds collaborators (the
9
9
  # root path + size/keep config), so each store binds its own instance. It
@@ -14,9 +14,9 @@ module Textus
14
14
  DEFAULT_MAX_SIZE = 10_485_760
15
15
  DEFAULT_KEEP = 5
16
16
 
17
- def initialize(root, max_size: DEFAULT_MAX_SIZE, keep: DEFAULT_KEEP)
18
- @root = root
19
- @path = Textus::Layout.audit_log(root)
17
+ def initialize(root = nil, layout: nil, max_size: DEFAULT_MAX_SIZE, keep: DEFAULT_KEEP)
18
+ @geometry = layout || Textus::Store::Layout.new(root)
19
+ @path = @geometry.audit_log_path
20
20
  @max_size = max_size
21
21
  @keep = keep
22
22
  end
@@ -72,6 +72,28 @@ module Textus
72
72
  end
73
73
  end
74
74
 
75
+ # Scan log files with optional filters. Returns parsed row hashes.
76
+ # Lane and timestamp filters are left to the caller (they need manifest
77
+ # resolution and Time parsing the port shouldn't know about).
78
+ def scan(seq_since: nil, key: nil, role: nil, verb: nil,
79
+ correlation_id: nil, limit: nil)
80
+ files = all_log_files
81
+ return [] if files.empty?
82
+
83
+ rows = []
84
+ files.each do |file|
85
+ File.foreach(file) do |line|
86
+ parsed = parse_row(line.chomp)
87
+ next unless parsed && matches?(parsed, seq_since:, key:, role:, verb:, correlation_id:)
88
+
89
+ rows << parsed
90
+ break if limit && rows.length >= limit
91
+ end
92
+ break if limit && rows.length >= limit
93
+ end
94
+ rows
95
+ end
96
+
75
97
  # Returns an array of integrity-violation descriptors for the on-disk log.
76
98
  # Each entry is { "lineno" => Integer, "reason" => String, "detail" => String }.
77
99
  # Empty array means the log is well-formed (or doesn't exist yet).
@@ -115,11 +137,11 @@ module Textus
115
137
  end
116
138
 
117
139
  def rotated(n)
118
- File.join(Textus::Layout.audit_dir(@root), "audit.log.#{n}")
140
+ @geometry.audit_rotated_log_path(n)
119
141
  end
120
142
 
121
143
  def rotated_meta(n)
122
- File.join(Textus::Layout.audit_dir(@root), "audit.log.#{n}.meta.json")
144
+ @geometry.audit_rotated_meta_path(n)
123
145
  end
124
146
 
125
147
  # Caller holds the flock. Returns the highest seq across the active log,
@@ -215,6 +237,24 @@ module Textus
215
237
  nil
216
238
  end
217
239
 
240
+ def matches?(row, seq_since: nil, key: nil, role: nil, verb: nil, correlation_id: nil)
241
+ return false if seq_since && row["seq"] <= seq_since
242
+ return false if key && row["key"] != key
243
+ return false if role && row["role"] != role
244
+ return false if verb && row["verb"] != verb
245
+ return false if correlation_id && row.dig("extras", "correlation_id") != correlation_id
246
+
247
+ true
248
+ end
249
+
250
+ def all_log_files
251
+ rotated = Dir.glob(@geometry.audit_log_glob)
252
+ .reject { |path| path.end_with?(".meta.json") }
253
+ .sort_by { |path| -path.scan(/\d+$/).first.to_i }
254
+ active_log = File.exist?(@path) ? [@path] : []
255
+ rotated + active_log
256
+ end
257
+
218
258
  def check_line(stripped, lineno)
219
259
  return nil if stripped.empty?
220
260
 
@@ -3,7 +3,7 @@ require "socket"
3
3
  require "time"
4
4
 
5
5
  module Textus
6
- module Ports
6
+ module Port
7
7
  # Cross-process build lock: a pid/host-stamped lockfile under the store root
8
8
  # that serializes converge's produce/sweep. An instantiable class — it holds
9
9
  # the root and lock state; `self.with(root:)` is a convenience that constructs
@@ -18,7 +18,7 @@ module Textus
18
18
  end
19
19
 
20
20
  def initialize(root:)
21
- @path = Textus::Layout.build_lock(root)
21
+ @path = Textus::Store::Layout.new(root).lock_path("build")
22
22
  @file = nil
23
23
  end
24
24
 
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Ports
2
+ module Port
3
3
  # The wall clock. An instantiable class (ADR 0109) — uniform with the other
4
4
  # ports; `now` reads the system time. Callers that need a fixed time still
5
5
  # pass it as data via `Call#now`.
@@ -1,12 +1,12 @@
1
1
  require "fileutils"
2
2
 
3
3
  module Textus
4
- module Ports
4
+ module Port
5
5
  # Publishes built artifacts from the store to repo-relative consumer paths.
6
6
  # Publish = copy + sentinel. The in-store file is already the consumer-shaped
7
7
  # artifact; no parsing or stripping.
8
8
  #
9
- # Sentinel I/O is delegated to Textus::Ports::SentinelStore. Sentinels live
9
+ # Sentinel I/O is delegated to Textus::Port::SentinelStore. Sentinels live
10
10
  # under `<store_root>/.run/sentinels/` (runtime, git-ignored — ADR 0070) and
11
11
  # mirror the target's repo-relative layout so consumer directories aren't
12
12
  # polluted with `.textus-managed.json` siblings.
@@ -18,7 +18,7 @@ module Textus
18
18
  guard_clobber(source, target, store_root)
19
19
  File.delete(target) if File.symlink?(target)
20
20
  FileUtils.cp(source, target)
21
- Textus::Ports::SentinelStore.new.write!(target: target, source: provenance_source, store_root: store_root)
21
+ Textus::Port::SentinelStore.new.write!(target: target, source: provenance_source, store_root: store_root)
22
22
  end
23
23
 
24
24
  # Removes a previously-published file and its sentinel. No-op unless the
@@ -27,7 +27,7 @@ module Textus
27
27
  return unless managed?(target, store_root)
28
28
 
29
29
  FileUtils.rm_f(target)
30
- sentinel = Textus::Ports::SentinelStore.new.sentinel_path(target, store_root)
30
+ sentinel = Textus::Port::SentinelStore.new.sentinel_path(target, store_root)
31
31
  FileUtils.rm_f(sentinel)
32
32
  end
33
33
 
@@ -53,7 +53,7 @@ module Textus
53
53
  end
54
54
 
55
55
  def managed?(target, store_root)
56
- File.exist?(Textus::Ports::SentinelStore.new.sentinel_path(target, store_root))
56
+ File.exist?(Textus::Port::SentinelStore.new.sentinel_path(target, store_root))
57
57
  end
58
58
  end
59
59
  end
@@ -1,12 +1,7 @@
1
1
  require "digest"
2
2
 
3
3
  module Textus
4
- module Core
5
- # Pure value object representing a published-file sentinel. Holds the
6
- # recorded target path, source path, sha256 checksum, and publish mode.
7
- # Has no filesystem I/O — path layout and persistence live in
8
- # Ports::SentinelStore; predicate methods accept a FileStat port for
9
- # existence and content checks.
4
+ module Port
10
5
  class Sentinel
11
6
  attr_reader :target, :source, :sha256, :mode
12
7
 
@@ -3,7 +3,7 @@ require "digest"
3
3
  require "fileutils"
4
4
 
5
5
  module Textus
6
- module Ports
6
+ module Port
7
7
  # Persistence adapter for sentinel files. Owns the on-disk JSON shape, the
8
8
  # path layout (<store_root>/.run/sentinels/<target-rel-to-repo>.textus-managed.json
9
9
  # — runtime, git-ignored, ADR 0070), and all File/FileUtils I/O.
@@ -26,7 +26,7 @@ module Textus
26
26
 
27
27
  def load(path, repo_root)
28
28
  raw = JSON.parse(File.read(path))
29
- Textus::Core::Sentinel.new(
29
+ Textus::Port::Sentinel.new(
30
30
  target: absolutize(raw["target"], repo_root),
31
31
  source: absolutize(raw["source"], repo_root),
32
32
  sha256: raw["sha256"],
@@ -39,14 +39,14 @@ module Textus
39
39
  def sentinel_path(target, store_root)
40
40
  repo_root = File.dirname(store_root)
41
41
  rel = relative_to(target, repo_root) || File.basename(target)
42
- File.join(Textus::Layout.sentinels(store_root), rel + SUFFIX)
42
+ File.join(Textus::Store::Layout.new(store_root).sentinels_root, rel + SUFFIX)
43
43
  end
44
44
 
45
45
  # Absolute target paths of every sentinel recorded under `target_dir`.
46
46
  def targets_under(target_dir, store_root)
47
47
  repo_root = File.dirname(store_root)
48
48
  rel = relative_to(target_dir, repo_root) or return []
49
- root = Textus::Layout.sentinels(store_root)
49
+ root = Textus::Store::Layout.new(store_root).sentinels_root
50
50
  sdir = File.join(root, rel)
51
51
  return [] unless File.directory?(sdir)
52
52
 
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Ports
2
+ module Port
3
3
  module Storage
4
4
  # Read-only filesystem query port. The narrow interface that pure
5
5
  # domain logic (staleness checks, sentinel value) depends on, so the
@@ -0,0 +1,49 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ module Port
5
+ module Storage
6
+ # Pure filesystem I/O port. Wraps File/FileUtils/Etag with no knowledge
7
+ # of envelopes, entries, schemas, or audit.
8
+ class FileStore
9
+ include Interface
10
+
11
+ def read(path) = File.binread(path)
12
+
13
+ def write(path, bytes)
14
+ FileUtils.mkdir_p(File.dirname(path))
15
+ File.binwrite(path, bytes)
16
+ end
17
+
18
+ # Raises Errno::ENOENT if absent — mirrors File.delete and matches the
19
+ # semantics used by Store::Writer (which guards with File.exist? first).
20
+ def delete(path) = File.delete(path)
21
+
22
+ def exists?(path) = File.exist?(path)
23
+
24
+ def etag(path) = Value::Etag.for_file(path)
25
+
26
+ # Convenience filesystem ops so callers can go through the port
27
+ # instead of calling FileUtils/Dir directly. Keeps filesystem
28
+ # semantics in one place for easier testing and replacement.
29
+ def mkdir_p(path)
30
+ FileUtils.mkdir_p(path)
31
+ end
32
+
33
+ def mv(from_path, to_path)
34
+ FileUtils.mkdir_p(File.dirname(to_path))
35
+ FileUtils.mv(from_path, to_path)
36
+ end
37
+
38
+ def rmdir(path)
39
+ Dir.rmdir(path)
40
+ end
41
+
42
+ def dir_empty?(dir)
43
+ # Dir.empty? exists on modern Rubies; wrap for clarity
44
+ Dir.empty?(dir)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,17 @@
1
+ module Textus
2
+ module Port
3
+ module Storage
4
+ module Interface
5
+ def read(path) = raise NotImplementedError
6
+ def write(path, bytes) = raise NotImplementedError
7
+ def delete(path) = raise NotImplementedError
8
+ def exists?(path) = raise NotImplementedError
9
+ def etag(path) = raise NotImplementedError
10
+ def mkdir_p(path) = raise NotImplementedError
11
+ def mv(from_path, to_path) = raise NotImplementedError
12
+ def rmdir(path) = raise NotImplementedError
13
+ def dir_empty?(dir) = raise NotImplementedError
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "sqlite3"
5
+
6
+ module Textus
7
+ module Port
8
+ # SQLite-backed runtime store for textus state. Owns the connection,
9
+ # schema setup, WAL mode, and transaction boundary for the index and queue.
10
+ class Store
11
+ attr_reader :path, :connection
12
+
13
+ def initialize(root:)
14
+ @path = Textus::Store::Layout.new(root).store_db_path
15
+ FileUtils.mkdir_p(File.dirname(@path))
16
+ @connection = SQLite3::Database.new(@path)
17
+ @connection.results_as_hash = true
18
+ end
19
+
20
+ def execute(sql, params = [])
21
+ @connection.execute(sql, params)
22
+ end
23
+
24
+ def query_value(sql, params = [])
25
+ @connection.get_first_value(sql, params)
26
+ end
27
+
28
+ def setup!
29
+ execute("PRAGMA journal_mode=WAL")
30
+ execute("PRAGMA foreign_keys=ON")
31
+ connection.execute_batch(<<~SQL)
32
+ CREATE TABLE IF NOT EXISTS entries (
33
+ key TEXT PRIMARY KEY,
34
+ lane TEXT NOT NULL,
35
+ format TEXT NOT NULL,
36
+ etag TEXT,
37
+ content TEXT,
38
+ extra TEXT,
39
+ indexed_at TEXT NOT NULL
40
+ ) STRICT;
41
+
42
+ CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(
43
+ key, lane, content,
44
+ content=entries, content_rowid=rowid
45
+ );
46
+
47
+ CREATE TABLE IF NOT EXISTS jobs (
48
+ id TEXT PRIMARY KEY,
49
+ type TEXT NOT NULL,
50
+ args TEXT NOT NULL,
51
+ state TEXT NOT NULL DEFAULT 'ready',
52
+ role TEXT NOT NULL,
53
+ attempts INTEGER NOT NULL DEFAULT 0,
54
+ max_attempts INTEGER NOT NULL DEFAULT 3,
55
+ errors TEXT,
56
+ lease TEXT,
57
+ created_at TEXT NOT NULL,
58
+ updated_at TEXT NOT NULL
59
+ ) STRICT;
60
+
61
+ CREATE INDEX IF NOT EXISTS idx_jobs_state ON jobs(state);
62
+ CREATE INDEX IF NOT EXISTS idx_entries_lane ON entries(lane);
63
+
64
+ CREATE TABLE IF NOT EXISTS audit_events (
65
+ seq INTEGER PRIMARY KEY,
66
+ ts TEXT NOT NULL,
67
+ role TEXT NOT NULL,
68
+ verb TEXT NOT NULL,
69
+ key TEXT NOT NULL,
70
+ etag_before TEXT,
71
+ etag_after TEXT
72
+ ) STRICT;
73
+
74
+ CREATE INDEX IF NOT EXISTS idx_audit_events_seq ON audit_events(seq);
75
+ SQL
76
+ # Idempotent migration: add schema_ref column if missing (existing stores).
77
+ execute("ALTER TABLE entries ADD COLUMN schema_ref TEXT") rescue nil # rubocop:disable Style/RescueModifier
78
+ self
79
+ end
80
+
81
+ def search_entries(q: nil, schema: nil, lane: nil, prefix: nil) # rubocop:disable Naming/MethodParameterName
82
+ return nil if q.nil? && schema.nil?
83
+
84
+ if q
85
+ fts_search(q: q, schema: schema, lane: lane, prefix: prefix)
86
+ else
87
+ schema_search(schema: schema, lane: lane, prefix: prefix)
88
+ end
89
+ end
90
+
91
+ def insert_audit_event(seq:, ts:, role:, verb:, key:, etag_before:, etag_after:) # rubocop:disable Naming/MethodParameterName
92
+ execute(
93
+ "INSERT OR IGNORE INTO audit_events (seq, ts, role, verb, key, etag_before, etag_after) VALUES (?, ?, ?, ?, ?, ?, ?)",
94
+ [seq, ts, role, verb, key, etag_before, etag_after],
95
+ )
96
+ end
97
+
98
+ def audit_events_since(seq:)
99
+ execute(
100
+ "SELECT seq, ts, role, verb, key, etag_before, etag_after FROM audit_events WHERE seq > ? ORDER BY seq",
101
+ [seq],
102
+ )
103
+ end
104
+
105
+ def transaction
106
+ connection.transaction
107
+ yield
108
+ connection.commit
109
+ rescue StandardError
110
+ connection.rollback if connection.transaction_active?
111
+ raise
112
+ end
113
+
114
+ def close
115
+ connection.close unless connection.closed?
116
+ end
117
+
118
+ def self.open(root)
119
+ store = new(root: root)
120
+ store.setup!
121
+ return store unless block_given?
122
+
123
+ yield store
124
+ ensure
125
+ store&.close
126
+ end
127
+ private :connection
128
+
129
+ def fts_search(q:, schema:, lane:, prefix:) # rubocop:disable Naming/MethodParameterName
130
+ sql = "SELECT e.key, e.lane, e.schema_ref FROM entries e JOIN entries_fts fts ON e.rowid = fts.rowid WHERE entries_fts MATCH ?"
131
+ params = [q]
132
+ sql += " AND e.lane = ?" and params << lane if lane
133
+ sql += " AND e.schema_ref = ?" and params << schema if schema
134
+ sql += " AND (e.key = ? OR e.key LIKE ?)" and params.push(prefix, "#{prefix}.%") if prefix
135
+ execute(sql, params)
136
+ end
137
+ private :fts_search
138
+
139
+ def schema_search(schema:, lane:, prefix:)
140
+ sql = "SELECT key, lane, schema_ref FROM entries WHERE schema_ref = ?"
141
+ params = [schema]
142
+ sql += " AND lane = ?" and params << lane if lane
143
+ sql += " AND (key = ? OR key LIKE ?)" and params.push(prefix, "#{prefix}.%") if prefix
144
+ execute(sql, params)
145
+ end
146
+ private :schema_search
147
+ end
148
+ end
149
+ end
@@ -3,18 +3,18 @@
3
3
  require "socket"
4
4
 
5
5
  module Textus
6
- module Ports
6
+ module Port
7
7
  # Flock-based watcher presence lock. Held for the watcher's lifetime.
8
8
  # Process death releases the flock automatically.
9
9
  class WatcherLock
10
10
  def initialize(root)
11
- @path = Textus::Layout.watcher_lock(root)
11
+ @path = Textus::Store::Layout.new(root).lock_path("watcher")
12
12
  @file = nil
13
13
  FileUtils.mkdir_p(File.dirname(@path))
14
14
  end
15
15
 
16
16
  def self.running?(root)
17
- path = Textus::Layout.watcher_lock(root)
17
+ path = Textus::Store::Layout.new(root).lock_path("watcher")
18
18
  return false unless File.exist?(path)
19
19
 
20
20
  File.open(path, "r+") do |file|
@@ -33,17 +33,7 @@ module Textus
33
33
  end
34
34
 
35
35
  def publish_only(key)
36
- entry = @container.manifest.resolver.resolve(key).entry
37
- return unless entry.publish_tree || !Array(entry.publish_to).empty?
38
-
39
- reader = Textus::Envelope::Reader.from(container: @container)
40
- entry_path = @container.manifest.resolver.resolve(key).path
41
- return unless entry.publish_tree || File.exist?(entry_path)
42
-
43
- pctx = Textus::Manifest::Entry::Base::PublishContext.new(
44
- container: @container, call: @call, reader: reader.method(:read),
45
- )
46
- entry.publish_via(pctx)
36
+ Textus::Produce::Publisher.call(container: @container, call: @call, key: key)
47
37
  end
48
38
  end
49
39
  end
@@ -0,0 +1,21 @@
1
+ module Textus
2
+ module Produce
3
+ module Publisher
4
+ def self.call(container:, call:, key:)
5
+ entry = container.manifest.resolver.resolve(key).entry
6
+ return unless entry.publish_tree || !Array(entry.publish_to).empty?
7
+
8
+ entry_path = container.manifest.resolver.resolve(key).path
9
+ return unless entry.publish_tree || container.file_store.exists?(entry_path)
10
+
11
+ reader = Textus::Store::Entry::Reader.from(container: container)
12
+ pctx = Textus::Manifest::Entry::Base::PublishContext.new(
13
+ container: container,
14
+ call: call,
15
+ reader: reader.method(:read),
16
+ )
17
+ entry.publish_via(pctx)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,42 @@
1
+ module Textus
2
+ class Schema
3
+ class Registry
4
+ def initialize(dir)
5
+ @dir = dir
6
+ @schemas = {}
7
+ load_all
8
+ end
9
+
10
+ def fetch(name)
11
+ @schemas[name] || raise(IoError.new("schema not found: #{File.join(@dir, "#{name}.yaml")}"))
12
+ end
13
+
14
+ def fetch_or_nil(name)
15
+ return nil if name.nil?
16
+
17
+ fetch(name)
18
+ end
19
+
20
+ def all
21
+ @schemas.values
22
+ end
23
+
24
+ def by_name
25
+ @schemas.dup
26
+ end
27
+
28
+ private
29
+
30
+ def load_all
31
+ return unless File.directory?(@dir)
32
+
33
+ Dir.glob(File.join(@dir, "*.yaml")).each do |path|
34
+ name = File.basename(path, ".yaml")
35
+ @schemas[name] = Schema.load(path)
36
+ rescue StandardError => e
37
+ warn "textus: failed to load schema '#{name}' at #{path}: #{e.message}"
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -6,7 +6,7 @@ module Textus
6
6
  module Tools
7
7
  # textus schema init NAME --from=KEY → infer YAML schema from an entry's frontmatter
8
8
  def self.init(store, name:, from:)
9
- env = pure_get(store, Textus::Role::DEFAULT, from)
9
+ env = pure_get(store, Textus::Value::Role::DEFAULT, from)
10
10
  meta = env.meta
11
11
  schema = {
12
12
  "name" => name,
@@ -14,8 +14,9 @@ module Textus
14
14
  "optional" => [],
15
15
  "fields" => meta.each_with_object({}) { |(k, v), h| h[k] = { "type" => infer_type(v) } },
16
16
  }
17
- FileUtils.mkdir_p(File.join(store.root, "schemas"))
18
- target = File.join(store.root, "schemas", "#{name}.yaml")
17
+ geom = Textus::Store::Layout.new(store.root)
18
+ FileUtils.mkdir_p(geom.schemas_dir)
19
+ target = geom.schema_path(name)
19
20
  File.write(target, YAML.dump(schema))
20
21
  { "protocol" => PROTOCOL, "schema_name" => name, "path" => target }
21
22
  end
@@ -25,7 +26,7 @@ module Textus
25
26
  schema = load_schema(store, name)
26
27
  drift = []
27
28
  store.manifest.resolver.enumerate.each do |row|
28
- env = pure_get(store, Textus::Role::DEFAULT, row[:key])
29
+ env = pure_get(store, Textus::Value::Role::DEFAULT, row[:key])
29
30
  begin
30
31
  schema.validate!(env.meta)
31
32
  rescue SchemaViolation => e
@@ -50,7 +51,7 @@ module Textus
50
51
  raise UsageError.new("schema migrate needs --rename=OLD:NEW or schema.evolution.migrate_from") if renames.empty?
51
52
 
52
53
  authority = accept_role_for(store)
53
- ops = store.as(authority)
54
+ ops = store.with_role(authority)
54
55
  touched = []
55
56
  store.manifest.resolver.enumerate.each do |row|
56
57
  env = pure_get(store, authority, row[:key])
@@ -84,11 +85,7 @@ module Textus
84
85
  # Orchestrator-free read: schema tooling must never trigger a fetch
85
86
  # while inspecting/migrating entries (ADR 0062).
86
87
  def self.pure_get(store, role, key)
87
- scope = store.as(role)
88
- Textus::Action::Get.new(key: key).call(
89
- container: scope.container,
90
- call: Textus::Call.build(role: role),
91
- )
88
+ store.with_role(role).get(key)
92
89
  end
93
90
 
94
91
  def self.load_schema(store, name)