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
@@ -0,0 +1,173 @@
1
+ module Textus
2
+ class Store
3
+ class Container
4
+ Infrastructure = Data.define(:file_store, :schemas, :audit_log, :job_store, :layout)
5
+ Coordination = Data.define(:manifest, :workflows, :pipeline)
6
+
7
+ def self.attribute_names
8
+ @attribute_names ||= [:root] + Infrastructure.members + Coordination.members
9
+ end
10
+
11
+ def initialize(infra, coord)
12
+ @infra = infra
13
+ @coord = coord
14
+ end
15
+
16
+ attr_reader :infra, :coord, :pipeline, :reader, :writer
17
+
18
+ def root
19
+ @infra.layout.root
20
+ end
21
+
22
+ Infrastructure.members.each do |name|
23
+ define_method(name) { @infra.public_send(name) }
24
+ end
25
+
26
+ Coordination.members.each do |name|
27
+ define_method(name) { @coord.public_send(name) }
28
+ end
29
+
30
+ def wire!(pipeline:, reader:, writer:)
31
+ @pipeline = pipeline
32
+ @reader = reader
33
+ @writer = writer
34
+ @coord = Coordination.new(
35
+ manifest: @coord.manifest,
36
+ workflows: @coord.workflows,
37
+ pipeline: pipeline,
38
+ )
39
+ self
40
+ end
41
+
42
+ def self.build(infra, coord_seed)
43
+ coord = Coordination.new(
44
+ manifest: coord_seed.manifest,
45
+ workflows: coord_seed.workflows,
46
+ pipeline: nil,
47
+ )
48
+ container = new(infra, coord)
49
+ pipeline = build_pipeline(container)
50
+ reader = Textus::Store::Entry::Reader.from(container: container)
51
+ writer = create_writer_factory(container)
52
+ container.wire!(pipeline: pipeline, reader: reader, writer: writer)
53
+ end
54
+
55
+ def self.orchestration_for(container)
56
+ Orchestration.new(
57
+ list_keys: Handlers::Read::ListKeys.new(manifest: container.manifest, job_store: container.job_store),
58
+ move_key: Handlers::Write::MoveKey.new(container: container, manifest: container.manifest),
59
+ delete_key: Handlers::Write::DeleteKey.new(container: container),
60
+ audit_entries: Handlers::Read::AuditEntries.new(manifest: container.manifest, audit_log: container.audit_log),
61
+ )
62
+ end
63
+
64
+ def self.build_pipeline(container) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
65
+ registry = Dispatch::HandlerRegistry.new
66
+ fe = freshness_evaluator(container)
67
+ orch = orchestration_for(container)
68
+
69
+ registry.register(Dispatch::Contracts::GetEntry,
70
+ Handlers::Read::GetEntry.new(container: container, freshness_evaluator: fe))
71
+ registry.register(Dispatch::Contracts::PutEntry,
72
+ Handlers::Write::PutEntry.new(container: container))
73
+ registry.register(Dispatch::Contracts::ListKeys,
74
+ Handlers::Read::ListKeys.new(manifest: container.manifest, job_store: container.job_store))
75
+ registry.register(Dispatch::Contracts::DeleteKey,
76
+ Handlers::Write::DeleteKey.new(container: container))
77
+ registry.register(Dispatch::Contracts::MoveKey,
78
+ Handlers::Write::MoveKey.new(container: container, manifest: container.manifest))
79
+ registry.register(Dispatch::Contracts::ProposeEntry,
80
+ Handlers::Write::ProposeEntry.new(container: container))
81
+ registry.register(Dispatch::Contracts::AcceptProposal,
82
+ Handlers::Write::AcceptProposal.new(container: container))
83
+ registry.register(Dispatch::Contracts::RejectProposal,
84
+ Handlers::Write::RejectProposal.new(container: container))
85
+ registry.register(Dispatch::Contracts::EnqueueJob,
86
+ Handlers::Write::EnqueueJob.new(job_store: container.job_store))
87
+ registry.register(Dispatch::Contracts::WhereEntry,
88
+ Handlers::Read::WhereEntry.new(manifest: container.manifest))
89
+ registry.register(Dispatch::Contracts::UidEntry,
90
+ Handlers::Read::UidEntry.new(container: container))
91
+ registry.register(Dispatch::Contracts::DepsEntry,
92
+ Handlers::Read::DepsEntry.new(manifest: container.manifest))
93
+ registry.register(Dispatch::Contracts::RdepsEntry,
94
+ Handlers::Read::RdepsEntry.new(manifest: container.manifest))
95
+ registry.register(Dispatch::Contracts::BootStore,
96
+ Handlers::Maintenance::BootStore.new(container: container))
97
+ registry.register(Dispatch::Contracts::DoctorStore,
98
+ Handlers::Maintenance::DoctorStore.new(container: container))
99
+ registry.register(Dispatch::Contracts::PublishedEntries,
100
+ Handlers::Maintenance::PublishedEntries.new(manifest: container.manifest))
101
+ registry.register(Dispatch::Contracts::RuleExplain,
102
+ Handlers::Maintenance::RuleExplain.new(manifest: container.manifest))
103
+ registry.register(Dispatch::Contracts::RuleList,
104
+ Handlers::Maintenance::RuleList.new(manifest: container.manifest))
105
+ registry.register(Dispatch::Contracts::SchemaEnvelope,
106
+ Handlers::Maintenance::SchemaEnvelope.new(manifest: container.manifest, schemas: container.schemas))
107
+ registry.register(Dispatch::Contracts::DrainStore,
108
+ Handlers::Maintenance::DrainStore.new(container: container, job_store: container.job_store))
109
+ registry.register(Dispatch::Contracts::IngestEntry,
110
+ Handlers::Maintenance::IngestEntry.new(container: container))
111
+ registry.register(Dispatch::Contracts::JobsAction,
112
+ Handlers::Maintenance::JobsAction.new(job_store: container.job_store))
113
+ registry.register(Dispatch::Contracts::RuleLint,
114
+ Handlers::Maintenance::RuleLint.new(manifest: container.manifest))
115
+ registry.register(Dispatch::Contracts::DataMv,
116
+ Handlers::Write::DataMv.new(container: container))
117
+ registry.register(Dispatch::Contracts::AuditEntries,
118
+ Handlers::Read::AuditEntries.new(manifest: container.manifest, audit_log: container.audit_log))
119
+ registry.register(Dispatch::Contracts::PulseEntries,
120
+ Handlers::Read::PulseEntries.new(
121
+ manifest: container.manifest,
122
+ audit_log: container.audit_log,
123
+ file_store: container.file_store,
124
+ job_store: container.job_store,
125
+ orchestration: orch,
126
+ ))
127
+ registry.register(Dispatch::Contracts::BlameEntry,
128
+ Handlers::Read::BlameEntry.new(manifest: container.manifest, orchestration: orch))
129
+ registry.register(Dispatch::Contracts::KeyMvPrefix,
130
+ Handlers::Write::KeyMvPrefix.new(orchestration: orch))
131
+ registry.register(Dispatch::Contracts::KeyDeletePrefix,
132
+ Handlers::Write::KeyDeletePrefix.new(orchestration: orch))
133
+
134
+ Dispatch::Pipeline.new(
135
+ registry: registry,
136
+ container: container,
137
+ middleware: [
138
+ Dispatch::Middleware::Binder.new,
139
+ Dispatch::Middleware::Auth.new,
140
+ Dispatch::Middleware::AuditIndex.new(
141
+ job_store: container.job_store,
142
+ audit_log: container.audit_log,
143
+ ),
144
+ Dispatch::Middleware::Cascade.new,
145
+ ],
146
+ )
147
+ end
148
+ private_class_method :build_pipeline
149
+
150
+ def self.create_writer_factory(container)
151
+ lambda do |call|
152
+ Textus::Store::Entry::Writer.new(
153
+ file_store: container.file_store,
154
+ manifest: container.manifest,
155
+ schemas: container.schemas,
156
+ audit_log: container.audit_log,
157
+ call: call,
158
+ reader: container.reader,
159
+ layout: container.layout,
160
+ )
161
+ end
162
+ end
163
+
164
+ def self.freshness_evaluator(container)
165
+ Store::Freshness::TtlEvaluator.new(
166
+ manifest: container.manifest,
167
+ file_stat: Textus::Port::Storage::FileStat.new,
168
+ clock: Textus::Port::Clock.new,
169
+ )
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,26 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ class Store
5
+ # Per-role cursor cache under <root>/.state/cursors/<role>. A convenience so
6
+ # `textus pulse` (no --since) means "since I last looked". Gitignored;
7
+ # losing it just re-emits recent deltas, never corrupts the store. ADR 0036/0038.
8
+ class Cursor
9
+ def initialize(root:, role:)
10
+ @path = Store::Layout.new(root).cursor_path(role)
11
+ end
12
+
13
+ def read
14
+ Integer(File.read(@path).strip)
15
+ rescue Errno::ENOENT, ArgumentError
16
+ 0
17
+ end
18
+
19
+ def write(seq)
20
+ FileUtils.mkdir_p(File.dirname(@path))
21
+ File.write(@path, seq.to_s)
22
+ seq
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,47 @@
1
+ module Textus
2
+ class Store
3
+ module Entry
4
+ # Read-only counterpart to EnvelopeWriter. Resolves a key, reads the
5
+ # bytes, parses them via the format strategy, and hands back an
6
+ # Envelope. Used by Mv (pre-move inspection) and by EnvelopeWriter
7
+ # (existing-meta lookup for the uid/sources preservation step in #put).
8
+ #
9
+ # No audit, no events, no permission checks — those live one layer up.
10
+ class Reader
11
+ def self.from(container:)
12
+ # Prefer a cached reader on the container (injection point) for
13
+ # tests and alternative runtimes. Fall back to constructing one.
14
+ return container.reader if container.respond_to?(:reader) && container.reader
15
+
16
+ new(file_store: container.file_store, manifest: container.manifest,
17
+ layout: container.layout)
18
+ end
19
+
20
+ def initialize(file_store:, manifest:, layout:)
21
+ @file_store = file_store
22
+ @manifest = manifest
23
+ @layout = layout
24
+ end
25
+
26
+ def read(key)
27
+ res = @manifest.resolver.resolve(key)
28
+ path = res.path
29
+ return nil unless @file_store.exists?(path)
30
+
31
+ mentry = res.entry
32
+ raw = @file_store.read(path)
33
+ parsed = Format.for(mentry.format).parse(raw, path: path)
34
+ Textus::Value::Envelope.build(
35
+ key: key, mentry: mentry, path: path,
36
+ meta: parsed["_meta"], body: parsed["body"],
37
+ etag: Value::Etag.for_bytes(raw), content: parsed["content"]
38
+ )
39
+ end
40
+
41
+ def exists?(key)
42
+ @file_store.exists?(@manifest.resolver.resolve(key).path)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,219 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ class Store
5
+ module Entry
6
+ # Owns the write pipeline (validate, serialize, etag-check, write, audit).
7
+ # Talks to ports (FileStore, Schemas, AuditLog, Manifest) and an
8
+ # Reader for the existing-uid lookup.
9
+ #
10
+ # Invariant: every public method's final action is @audit_log.append(...).
11
+ #
12
+ # No permission check, no event firing — those belong to the caller
13
+ # (Write::Put / ::Delete / ::Mv).
14
+ class Writer
15
+ def self.from(container:, call:)
16
+ # If the container exposes a writer factory (in tests we set this),
17
+ # use it. Otherwise, construct a fresh Writer.
18
+ return container.writer.call(call) if container.respond_to?(:writer) && container.writer
19
+
20
+ new(
21
+ file_store: container.file_store, manifest: container.manifest,
22
+ schemas: container.schemas, audit_log: container.audit_log,
23
+ call: call, reader: Reader.from(container: container),
24
+ layout: container.layout
25
+ )
26
+ end
27
+
28
+ def initialize(file_store:, manifest:, schemas:, audit_log:, call:, reader:, layout:)
29
+ @file_store = file_store
30
+ @manifest = manifest
31
+ @schemas = schemas
32
+ @audit_log = audit_log
33
+ @call = call
34
+ @reader = reader
35
+ @layout = layout
36
+ end
37
+
38
+ def put(key, mentry:, payload:, if_etag: nil)
39
+ path = resolve_path(key)
40
+ meta = payload.meta || {}
41
+ content = payload.content
42
+
43
+ existing_env = read_existing(key)
44
+ existing_meta = existing_env ? existing_env.meta : {}
45
+ meta, content = inject_meta(meta, content, existing_meta, mentry.format)
46
+
47
+ bytes, eff_meta, eff_body, eff_content = serialize_entry(mentry, path, meta, payload, content)
48
+
49
+ enforce_name_match!(path, eff_meta, mentry.format)
50
+ validate_schema(mentry, eff_meta, eff_content)
51
+ validate_raw(eff_meta, eff_content, mentry.lane, mentry.format)
52
+
53
+ etag_before = check_etag!(path, key, if_etag)
54
+ write_bytes(path, bytes)
55
+
56
+ envelope = build_envelope(key, mentry, path, eff_meta, eff_body, eff_content, bytes)
57
+ audit_put(key, etag_before, envelope.etag)
58
+ envelope
59
+ end
60
+
61
+ def delete(key, mentry: nil, if_etag: nil) # rubocop:disable Lint/UnusedMethodArgument
62
+ # `mentry:` is accepted for symmetry with `put` / `move` and to
63
+ # leave room for future format-specific delete hooks; no field
64
+ # on it is needed today.
65
+ path = @manifest.resolver.resolve(key).path
66
+ raise UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)) unless @file_store.exists?(path)
67
+
68
+ etag_before = @file_store.etag(path)
69
+ raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before
70
+
71
+ @file_store.delete(path)
72
+ prune_empty_parents(path)
73
+ @audit_log.append(
74
+ role: @call.role, verb: "key_delete", key: key,
75
+ etag_before: etag_before, etag_after: nil,
76
+ extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
77
+ )
78
+ end
79
+
80
+ def move(from_key:, to_key:, new_mentry:, if_etag: nil)
81
+ from_path = @manifest.resolver.resolve(from_key).path
82
+ to_path = @manifest.resolver.resolve(to_key).path
83
+ raise UnknownKey.new(from_key, suggestions: @manifest.resolver.suggestions_for(from_key)) unless @file_store.exists?(from_path)
84
+
85
+ etag_before = @file_store.etag(from_path)
86
+ raise EtagMismatch.new(from_key, if_etag, etag_before) if if_etag && if_etag != etag_before
87
+
88
+ @file_store.mv(from_path, to_path)
89
+ prune_empty_parents(from_path)
90
+ basename = to_key.split(".").last
91
+ Format.for(new_mentry.format).rewrite_name(to_path, basename)
92
+ etag_after = Value::Etag.for_file(to_path)
93
+
94
+ envelope = @reader.read(to_key)
95
+
96
+ extras = {
97
+ "from_key" => from_key, "to_key" => to_key,
98
+ "from_path" => from_path, "to_path" => to_path,
99
+ "uid" => envelope.uid
100
+ }
101
+ extras["correlation_id"] = @call.correlation_id if @call.correlation_id
102
+
103
+ @audit_log.append(
104
+ role: @call.role, verb: "key_mv", key: to_key,
105
+ etag_before: etag_before, etag_after: etag_after,
106
+ extras: extras
107
+ )
108
+
109
+ envelope
110
+ end
111
+
112
+ private
113
+
114
+ # After a file leaves a directory (delete or move-source), remove any
115
+ # now-empty parent dirs so bulk move/delete doesn't accrue orphan dirs
116
+ # (F3 of #161). Floored at the entry's *zone directory* — a zone is a
117
+ # declared, first-class container, so its own dir is preserved even when
118
+ # momentarily empty; only the sub-dirs the bulk op carved out are
119
+ # pruned. Stops at the first non-empty ancestor, so a dir holding a
120
+ # `.gitkeep` or sibling entries survives. Best-effort: a lost race or a
121
+ # non-empty dir is silently fine, never fatal to the write.
122
+ def prune_empty_parents(path)
123
+ floor = @layout.lane_floor(path)
124
+ return unless floor
125
+
126
+ dir = File.dirname(path)
127
+ while dir.start_with?("#{floor}/") && @file_store.dir_empty?(dir)
128
+ @file_store.rmdir(dir)
129
+ dir = File.dirname(dir)
130
+ end
131
+ rescue SystemCallError
132
+ nil
133
+ end
134
+
135
+ def read_existing(key)
136
+ @reader.read(key)
137
+ end
138
+
139
+ def inject_meta(meta, content, existing_meta, format)
140
+ Envelope::Meta.inject_all(
141
+ meta, content, existing_meta,
142
+ format: format,
143
+ etag_for: method(:resolve_source_etag)
144
+ )
145
+ end
146
+
147
+ def resolve_source_etag(key)
148
+ path = @manifest.resolver.resolve(key).path
149
+ return nil unless @file_store.exists?(path)
150
+
151
+ Value::Etag.for_file(path)
152
+ rescue Textus::Error
153
+ nil
154
+ end
155
+
156
+ def resolve_path(key)
157
+ @manifest.resolver.resolve(key).path
158
+ end
159
+
160
+ def serialize_entry(mentry, path, meta, payload, content)
161
+ Textus::Format.for(mentry.format).serialize_for_put(
162
+ meta: meta, body: payload.body, content: content, path: path,
163
+ )
164
+ end
165
+
166
+ def enforce_name_match!(path, meta, format)
167
+ Textus::Format.for(format).enforce_name_match!(path, meta)
168
+ end
169
+
170
+ def validate_schema(mentry, eff_meta, eff_content)
171
+ schema = @schemas.fetch_or_nil(mentry.schema)
172
+ return unless schema
173
+
174
+ Format.for(mentry.format).validate_against(
175
+ schema,
176
+ { "_meta" => eff_meta, "content" => eff_content },
177
+ )
178
+ end
179
+
180
+ def validate_raw(eff_meta, eff_content, lane, format)
181
+ Textus::Format.for(format).validate_raw_entry!(
182
+ { "_meta" => eff_meta, "content" => eff_content },
183
+ lane,
184
+ )
185
+ end
186
+
187
+ def check_etag!(path, key, if_etag)
188
+ etag_before = @file_store.exists?(path) ? @file_store.etag(path) : nil
189
+ raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && (etag_before != if_etag)
190
+
191
+ etag_before
192
+ end
193
+
194
+ def write_bytes(path, bytes)
195
+ @file_store.write(path, bytes)
196
+ end
197
+
198
+ def build_envelope(key, mentry, path, eff_meta, eff_body, eff_content, bytes = nil)
199
+ raw = bytes || @file_store.read(path)
200
+ Textus::Value::Envelope.build(
201
+ key: key, mentry: mentry, path: path,
202
+ meta: eff_meta, body: eff_body,
203
+ etag: Value::Etag.for_bytes(raw),
204
+ content: eff_content
205
+ )
206
+ end
207
+
208
+ def audit_put(key, etag_before, etag_after)
209
+ extras = @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
210
+ @audit_log.append(
211
+ role: @call.role, verb: "put", key: key,
212
+ etag_before: etag_before, etag_after: etag_after,
213
+ extras: extras
214
+ )
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,61 @@
1
+ require "securerandom"
2
+
3
+ module Textus
4
+ class Store
5
+ module Envelope
6
+ module Meta
7
+ NO_META_FORMATS = %w[text].freeze
8
+
9
+ FIELDS = {
10
+ "uid" => {
11
+ inject: lambda { |meta, content, existing_meta, **_opts|
12
+ m = meta.is_a?(Hash) ? meta.dup : {}
13
+ existing = existing_meta.is_a?(Hash) ? existing_meta["uid"] : nil
14
+ m["uid"] = existing || Textus::Value::Uid.mint unless m["uid"].is_a?(String) && !m["uid"].empty?
15
+ [m, content]
16
+ },
17
+ },
18
+ "sources" => {
19
+ inject: lambda { |meta, content, existing_meta, etag_for: nil|
20
+ m = meta.is_a?(Hash) ? meta.dup : {}
21
+ existing = existing_meta.is_a?(Hash) ? existing_meta["sources"] : nil
22
+
23
+ if m.key?("sources")
24
+ raise Textus::BadContent.new(nil, "_meta.sources must be an array") unless m["sources"].is_a?(Array)
25
+
26
+ m["sources"] = m["sources"].map { |s| Meta.normalize_source!(s, etag_for) }
27
+ elsif existing.is_a?(Array) && !existing.empty?
28
+ m["sources"] = existing
29
+ end
30
+
31
+ [m, content]
32
+ },
33
+ },
34
+ }.freeze
35
+
36
+ def self.inject_all(meta, content, existing_meta = {}, format: nil, etag_for: nil)
37
+ return [meta, content] if NO_META_FORMATS.include?(format)
38
+
39
+ FIELDS.each_value do |field|
40
+ meta, content = field[:inject].call(meta, content, existing_meta, etag_for: etag_for)
41
+ end
42
+
43
+ [meta, content]
44
+ end
45
+
46
+ def self.normalize_source!(src, etag_for)
47
+ key = case src
48
+ when String then src
49
+ when Hash then src["key"]
50
+ end
51
+
52
+ raise Textus::BadContent.new(nil, "each source must be a string key or { key: } object") unless key.is_a?(String)
53
+ raise Textus::BadContent.new(nil, "each source key must be a non-empty string") if key.empty?
54
+
55
+ etag = etag_for&.call(key)
56
+ etag ? { "key" => key, "etag" => etag } : { "key" => key }
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Textus
6
+ class Store
7
+ module Freshness
8
+ class DriftDetector
9
+ def initialize(manifest:, file_stat:, clock:)
10
+ @manifest = manifest
11
+ @file_stat = file_stat
12
+ @clock = clock
13
+ end
14
+
15
+ def drift_rows(mentry)
16
+ return [] unless mentry.external?
17
+
18
+ path = Textus::Key::Path.resolve(@manifest.data, mentry)
19
+ reason = drift_reason(mentry, path)
20
+ reason ? [drift_row(mentry, path, reason)] : []
21
+ end
22
+
23
+ private
24
+
25
+ def drift_reason(mentry, path)
26
+ return "derived entry has never been generated" unless @file_stat.exists?(path)
27
+
28
+ generated_at = generated_at_of(mentry, path)
29
+ return "missing generated.at frontmatter" unless generated_at
30
+
31
+ gen_time = parse_time(generated_at)
32
+ return "unparseable generated.at: #{generated_at.inspect}" unless gen_time
33
+
34
+ offender = newest_source_after(mentry.source, gen_time)
35
+ "source '#{offender}' modified after generated.at" if offender
36
+ end
37
+
38
+ def generated_at_of(mentry, path)
39
+ Textus::Format.for(mentry.format).parse(@file_stat.read(path), path: path)["_meta"]
40
+ .dig("generated", "at")
41
+ end
42
+
43
+ def parse_time(str)
44
+ Time.parse(str.to_s)
45
+ rescue StandardError
46
+ nil
47
+ end
48
+
49
+ def newest_source_after(external_src, gen_time)
50
+ Array(external_src.sources).each do |src|
51
+ offender = check_source(src, gen_time)
52
+ return offender if offender
53
+ end
54
+ nil
55
+ end
56
+
57
+ def check_source(src, gen_time)
58
+ if src.match?(/\A[a-z0-9.][a-z0-9._-]*\z/) && !src.include?("/")
59
+ @manifest.resolver.enumerate(prefix: src).each do |row|
60
+ return src if @file_stat.mtime(row[:path]) > gen_time
61
+ end
62
+ nil
63
+ else
64
+ check_filesystem_source(src, gen_time)
65
+ end
66
+ end
67
+
68
+ def check_filesystem_source(src, gen_time)
69
+ abs = absolutize_source(src)
70
+ if @file_stat.directory?(abs)
71
+ dir_has_newer_file?(abs, gen_time) ? src : nil
72
+ elsif @file_stat.exists?(abs) && @file_stat.mtime(abs) > gen_time
73
+ src
74
+ end
75
+ end
76
+
77
+ def absolutize_source(src)
78
+ File.absolute_path?(src) ? src : File.join(File.dirname(@manifest.data.root), src)
79
+ end
80
+
81
+ def dir_has_newer_file?(abs, gen_time)
82
+ @file_stat.glob(File.join(abs, "**", "*")).any? do |fpath|
83
+ !@file_stat.directory?(fpath) && @file_stat.exists?(fpath) && @file_stat.mtime(fpath) > gen_time
84
+ end
85
+ end
86
+
87
+ def drift_row(mentry, path, reason)
88
+ { "key" => mentry.key, "path" => path, "generator" => mentry.source.command, "reason" => reason }
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ class Store
5
+ module Freshness
6
+ # Thin facade delegating to the focused TtlEvaluator and DriftDetector.
7
+ # Prefer using TtlEvaluator or DriftDetector directly.
8
+ class Evaluator
9
+ def initialize(manifest:, file_stat:, clock:)
10
+ @ttl = TtlEvaluator.new(manifest: manifest, file_stat: file_stat, clock: clock)
11
+ @drift = DriftDetector.new(manifest: manifest, file_stat: file_stat, clock: clock)
12
+ end
13
+
14
+ def verdict(mentry) = @ttl.verdict(mentry)
15
+ def stale_keys(**) = @ttl.stale_keys(**)
16
+ def drift_rows(mentry) = @drift.drift_rows(mentry)
17
+ end
18
+ end
19
+ end
20
+ end