textus 0.55.1 → 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 (172) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1 -1
  3. data/README.md +9 -9
  4. data/SPEC.md +14 -13
  5. data/docs/architecture/README.md +3 -3
  6. data/docs/reference/conventions.md +5 -2
  7. data/lib/textus/boot.rb +64 -85
  8. data/lib/textus/{gate → dispatch}/binder.rb +8 -10
  9. data/lib/textus/dispatch/contracts.rb +63 -0
  10. data/lib/textus/dispatch/handler_registry.rb +21 -0
  11. data/lib/textus/dispatch/middleware/audit_index.rb +51 -0
  12. data/lib/textus/dispatch/middleware/auth.rb +40 -0
  13. data/lib/textus/dispatch/middleware/base.rb +26 -0
  14. data/lib/textus/dispatch/middleware/binder.rb +20 -0
  15. data/lib/textus/dispatch/middleware/cascade.rb +53 -0
  16. data/lib/textus/dispatch/pipeline.rb +35 -0
  17. data/lib/textus/doctor/check/audit_log.rb +1 -1
  18. data/lib/textus/doctor/check/generator_drift.rb +2 -2
  19. data/lib/textus/doctor/check/orphaned_publish_targets.rb +1 -1
  20. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  21. data/lib/textus/doctor/check/{notebook_sources.rb → scratchpad_sources.rb} +10 -5
  22. data/lib/textus/doctor/check/sentinels.rb +1 -1
  23. data/lib/textus/doctor/check.rb +8 -6
  24. data/lib/textus/doctor.rb +1 -1
  25. data/lib/textus/errors.rb +2 -0
  26. data/lib/textus/format/base.rb +36 -8
  27. data/lib/textus/format/json.rb +0 -21
  28. data/lib/textus/format/markdown.rb +0 -21
  29. data/lib/textus/format/yaml.rb +0 -21
  30. data/lib/textus/format.rb +16 -1
  31. data/lib/textus/handlers/maintenance/boot_store.rb +15 -0
  32. data/lib/textus/handlers/maintenance/doctor_store.rb +15 -0
  33. data/lib/textus/handlers/maintenance/drain_store.rb +21 -0
  34. data/lib/textus/handlers/maintenance/ingest_entry.rb +159 -0
  35. data/lib/textus/handlers/maintenance/jobs_action.rb +21 -0
  36. data/lib/textus/handlers/maintenance/published_entries.rb +17 -0
  37. data/lib/textus/handlers/maintenance/rule_explain.rb +77 -0
  38. data/lib/textus/handlers/maintenance/rule_lint.rb +54 -0
  39. data/lib/textus/handlers/maintenance/rule_list.rb +32 -0
  40. data/lib/textus/handlers/maintenance/schema_envelope.rb +19 -0
  41. data/lib/textus/handlers/read/audit_entries.rb +48 -0
  42. data/lib/textus/handlers/read/blame_entry.rb +71 -0
  43. data/lib/textus/handlers/read/deps_entry.rb +17 -0
  44. data/lib/textus/handlers/read/get_entry.rb +68 -0
  45. data/lib/textus/handlers/read/list_keys.rb +36 -0
  46. data/lib/textus/handlers/read/pulse_entries.rb +66 -0
  47. data/lib/textus/handlers/read/rdeps_entry.rb +21 -0
  48. data/lib/textus/handlers/read/uid_entry.rb +18 -0
  49. data/lib/textus/handlers/read/where_entry.rb +18 -0
  50. data/lib/textus/handlers/write/accept_proposal.rb +39 -0
  51. data/lib/textus/handlers/write/data_mv.rb +55 -0
  52. data/lib/textus/handlers/write/delete_key.rb +17 -0
  53. data/lib/textus/handlers/write/enqueue_job.rb +27 -0
  54. data/lib/textus/handlers/write/key_delete_prefix.rb +32 -0
  55. data/lib/textus/handlers/write/key_mv_prefix.rb +45 -0
  56. data/lib/textus/handlers/write/move_key.rb +80 -0
  57. data/lib/textus/handlers/write/propose_entry.rb +29 -0
  58. data/lib/textus/handlers/write/put_entry.rb +29 -0
  59. data/lib/textus/handlers/write/reject_proposal.rb +29 -0
  60. data/lib/textus/init.rb +5 -5
  61. data/lib/textus/manifest/capabilities.rb +1 -1
  62. data/lib/textus/manifest/entry/base.rb +3 -3
  63. data/lib/textus/manifest/entry/publish/to_paths.rb +1 -1
  64. data/lib/textus/manifest/policy/predicates/author_held.rb +22 -0
  65. data/lib/textus/manifest/policy/predicates/etag_match.rb +18 -0
  66. data/lib/textus/manifest/policy/predicates/fresh_within.rb +13 -0
  67. data/lib/textus/manifest/policy/predicates/lane_deletable_by.rb +31 -0
  68. data/lib/textus/manifest/policy/predicates/lane_writable_by.rb +23 -0
  69. data/lib/textus/manifest/policy/predicates/raw_lane_ingest_only.rb +25 -0
  70. data/lib/textus/manifest/policy/predicates/raw_write_once.rb +24 -0
  71. data/lib/textus/manifest/policy/predicates/schema_valid.rb +41 -0
  72. data/lib/textus/manifest/policy/predicates/target_is_canon.rb +20 -0
  73. data/lib/textus/manifest/policy/predicates.rb +54 -0
  74. data/lib/textus/manifest/policy/retention.rb +1 -1
  75. data/lib/textus/orchestration.rb +55 -0
  76. data/lib/textus/port/audit_log.rb +6 -6
  77. data/lib/textus/port/build_lock.rb +1 -1
  78. data/lib/textus/{core → port}/sentinel.rb +1 -6
  79. data/lib/textus/port/sentinel_store.rb +3 -3
  80. data/lib/textus/port/storage/file_store.rb +23 -0
  81. data/lib/textus/port/storage/interface.rb +17 -0
  82. data/lib/textus/port/store.rb +58 -2
  83. data/lib/textus/port/watcher_lock.rb +2 -2
  84. data/lib/textus/produce/engine.rb +1 -11
  85. data/lib/textus/produce/publisher.rb +21 -0
  86. data/lib/textus/schema/registry.rb +42 -0
  87. data/lib/textus/schema/tools.rb +3 -10
  88. data/lib/textus/store/container.rb +140 -10
  89. data/lib/textus/store/cursor.rb +1 -1
  90. data/lib/textus/store/{envelope → entry}/reader.rb +8 -4
  91. data/lib/textus/store/{envelope → entry}/writer.rb +53 -29
  92. data/lib/textus/store/envelope/meta.rb +61 -0
  93. data/lib/textus/store/freshness/drift_detector.rb +93 -0
  94. data/lib/textus/store/freshness/evaluator.rb +20 -0
  95. data/lib/textus/store/freshness/ttl_evaluator.rb +57 -0
  96. data/lib/textus/{core → store}/freshness/verdict.rb +1 -11
  97. data/lib/textus/store/freshness.rb +8 -0
  98. data/lib/textus/store/index/builder.rb +5 -3
  99. data/lib/textus/store/jobs/planner.rb +27 -7
  100. data/lib/textus/store/jobs/queue.rb +9 -1
  101. data/lib/textus/store/jobs/retention/base.rb +52 -0
  102. data/lib/textus/store/jobs/retention/sweep.rb +55 -0
  103. data/lib/textus/store/jobs/retention.rb +1 -43
  104. data/lib/textus/store/jobs/sweep.rb +2 -2
  105. data/lib/textus/store/{geometry.rb → layout.rb} +19 -3
  106. data/lib/textus/store.rb +53 -30
  107. data/lib/textus/surface/cli/runner.rb +8 -9
  108. data/lib/textus/surface/cli/verb/doctor.rb +3 -2
  109. data/lib/textus/surface/cli/verb/get.rb +5 -3
  110. data/lib/textus/surface/cli/verb/put.rb +5 -3
  111. data/lib/textus/surface/mcp/catalog.rb +26 -62
  112. data/lib/textus/surface/mcp/errors.rb +0 -10
  113. data/lib/textus/surface/mcp/projector.rb +20 -0
  114. data/lib/textus/surface/mcp/server.rb +20 -31
  115. data/lib/textus/{core → value}/duration.rb +1 -4
  116. data/lib/textus/value/envelope.rb +5 -4
  117. data/lib/textus/value/etag.rb +1 -1
  118. data/lib/textus/value/payload.rb +7 -0
  119. data/lib/textus/value/result.rb +36 -16
  120. data/lib/textus/verb_registry.rb +417 -0
  121. data/lib/textus/version.rb +1 -1
  122. data/lib/textus/workflow/loader.rb +1 -1
  123. data/lib/textus/workflow/runner.rb +10 -18
  124. data/lib/textus.rb +0 -64
  125. metadata +70 -70
  126. data/lib/textus/action/accept.rb +0 -46
  127. data/lib/textus/action/audit.rb +0 -94
  128. data/lib/textus/action/base.rb +0 -42
  129. data/lib/textus/action/blame.rb +0 -79
  130. data/lib/textus/action/boot.rb +0 -15
  131. data/lib/textus/action/data_mv.rb +0 -58
  132. data/lib/textus/action/deps.rb +0 -19
  133. data/lib/textus/action/doctor.rb +0 -17
  134. data/lib/textus/action/drain.rb +0 -31
  135. data/lib/textus/action/enqueue.rb +0 -37
  136. data/lib/textus/action/get.rb +0 -34
  137. data/lib/textus/action/ingest.rb +0 -199
  138. data/lib/textus/action/jobs.rb +0 -27
  139. data/lib/textus/action/key_delete.rb +0 -26
  140. data/lib/textus/action/key_delete_prefix.rb +0 -35
  141. data/lib/textus/action/key_mv.rb +0 -122
  142. data/lib/textus/action/key_mv_prefix.rb +0 -48
  143. data/lib/textus/action/list.rb +0 -28
  144. data/lib/textus/action/propose.rb +0 -42
  145. data/lib/textus/action/published.rb +0 -22
  146. data/lib/textus/action/pulse.rb +0 -49
  147. data/lib/textus/action/put.rb +0 -38
  148. data/lib/textus/action/rdeps.rb +0 -24
  149. data/lib/textus/action/reject.rb +0 -28
  150. data/lib/textus/action/rule_explain.rb +0 -81
  151. data/lib/textus/action/rule_lint.rb +0 -62
  152. data/lib/textus/action/rule_list.rb +0 -38
  153. data/lib/textus/action/schema_envelope.rb +0 -22
  154. data/lib/textus/action/uid.rb +0 -19
  155. data/lib/textus/action/where.rb +0 -21
  156. data/lib/textus/contract/arg.rb +0 -10
  157. data/lib/textus/contract/dsl.rb +0 -88
  158. data/lib/textus/contract/spec.rb +0 -25
  159. data/lib/textus/contract.rb +0 -12
  160. data/lib/textus/core/freshness/evaluator.rb +0 -150
  161. data/lib/textus/core/freshness.rb +0 -11
  162. data/lib/textus/core/retention/sweep.rb +0 -57
  163. data/lib/textus/core/retention.rb +0 -11
  164. data/lib/textus/format/shared.rb +0 -17
  165. data/lib/textus/gate/auth.rb +0 -212
  166. data/lib/textus/gate.rb +0 -92
  167. data/lib/textus/meta.rb +0 -54
  168. data/lib/textus/schemas.rb +0 -54
  169. data/lib/textus/store/compositor.rb +0 -34
  170. data/lib/textus/store/session.rb +0 -37
  171. data/lib/textus/surface/projector.rb +0 -27
  172. data/lib/textus/surface/role_scope.rb +0 -34
@@ -1,10 +1,8 @@
1
- # frozen_string_literal: true
2
-
3
1
  module Textus
4
2
  class Store
5
3
  class Container
6
- Infrastructure = Data.define(:file_store, :schemas, :audit_log, :job_store, :geometry)
7
- Coordination = Data.define(:manifest, :workflows, :gate, :compositor)
4
+ Infrastructure = Data.define(:file_store, :schemas, :audit_log, :job_store, :layout)
5
+ Coordination = Data.define(:manifest, :workflows, :pipeline)
8
6
 
9
7
  def self.attribute_names
10
8
  @attribute_names ||= [:root] + Infrastructure.members + Coordination.members
@@ -15,10 +13,10 @@ module Textus
15
13
  @coord = coord
16
14
  end
17
15
 
18
- attr_reader :infra, :coord
16
+ attr_reader :infra, :coord, :pipeline, :reader, :writer
19
17
 
20
18
  def root
21
- @infra.geometry.root
19
+ @infra.layout.root
22
20
  end
23
21
 
24
22
  Infrastructure.members.each do |name|
@@ -29,15 +27,147 @@ module Textus
29
27
  define_method(name) { @coord.public_send(name) }
30
28
  end
31
29
 
32
- def wire_gate!(gate, compositor)
33
- @coord = Coordination.new(
30
+ def wire!(pipeline:, reader:, writer:)
31
+ @pipeline = pipeline
32
+ @reader = reader
33
+ @writer = writer
34
+ @coord = Coordination.new(
34
35
  manifest: @coord.manifest,
35
36
  workflows: @coord.workflows,
36
- gate:,
37
- compositor:,
37
+ pipeline: pipeline,
38
38
  )
39
39
  self
40
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
41
171
  end
42
172
  end
43
173
  end
@@ -7,7 +7,7 @@ module Textus
7
7
  # losing it just re-emits recent deltas, never corrupts the store. ADR 0036/0038.
8
8
  class Cursor
9
9
  def initialize(root:, role:)
10
- @path = Store::Geometry.new(root).cursor_path(role)
10
+ @path = Store::Layout.new(root).cursor_path(role)
11
11
  end
12
12
 
13
13
  def read
@@ -1,6 +1,6 @@
1
1
  module Textus
2
2
  class Store
3
- module Envelope
3
+ module Entry
4
4
  # Read-only counterpart to EnvelopeWriter. Resolves a key, reads the
5
5
  # bytes, parses them via the format strategy, and hands back an
6
6
  # Envelope. Used by Mv (pre-move inspection) and by EnvelopeWriter
@@ -9,14 +9,18 @@ module Textus
9
9
  # No audit, no events, no permission checks — those live one layer up.
10
10
  class Reader
11
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
+
12
16
  new(file_store: container.file_store, manifest: container.manifest,
13
- geometry: container.geometry)
17
+ layout: container.layout)
14
18
  end
15
19
 
16
- def initialize(file_store:, manifest:, geometry:)
20
+ def initialize(file_store:, manifest:, layout:)
17
21
  @file_store = file_store
18
22
  @manifest = manifest
19
- @geometry = geometry
23
+ @layout = layout
20
24
  end
21
25
 
22
26
  def read(key)
@@ -2,7 +2,7 @@ require "fileutils"
2
2
 
3
3
  module Textus
4
4
  class Store
5
- module Envelope
5
+ module Entry
6
6
  # Owns the write pipeline (validate, serialize, etag-check, write, audit).
7
7
  # Talks to ports (FileStore, Schemas, AuditLog, Manifest) and an
8
8
  # Reader for the existing-uid lookup.
@@ -12,43 +12,47 @@ module Textus
12
12
  # No permission check, no event firing — those belong to the caller
13
13
  # (Write::Put / ::Delete / ::Mv).
14
14
  class Writer
15
- Payload = Data.define(:meta, :body, :content)
16
-
17
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
+
18
20
  new(
19
21
  file_store: container.file_store, manifest: container.manifest,
20
22
  schemas: container.schemas, audit_log: container.audit_log,
21
23
  call: call, reader: Reader.from(container: container),
22
- geometry: container.geometry
24
+ layout: container.layout
23
25
  )
24
26
  end
25
27
 
26
- def initialize(file_store:, manifest:, schemas:, audit_log:, call:, reader:, geometry:) # rubocop:disable Metrics/ParameterLists
28
+ def initialize(file_store:, manifest:, schemas:, audit_log:, call:, reader:, layout:)
27
29
  @file_store = file_store
28
30
  @manifest = manifest
29
31
  @schemas = schemas
30
32
  @audit_log = audit_log
31
33
  @call = call
32
34
  @reader = reader
33
- @geometry = geometry
35
+ @layout = layout
34
36
  end
35
37
 
36
38
  def put(key, mentry:, payload:, if_etag: nil)
37
- path = resolve_path(key)
38
- meta = payload.meta || {}
39
+ path = resolve_path(key)
40
+ meta = payload.meta || {}
39
41
  content = payload.content
40
- existing_env = @reader.read(key)
41
- existing_meta = existing_env ? existing_env.meta : {}
42
- meta, content = Textus::Meta.inject_all(meta, content, existing_meta, format: mentry.format)
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
+
43
47
  bytes, eff_meta, eff_body, eff_content = serialize_entry(mentry, path, meta, payload, content)
48
+
44
49
  enforce_name_match!(path, eff_meta, mentry.format)
45
50
  validate_schema(mentry, eff_meta, eff_content)
46
- Textus::Format::Yaml.validate_raw_entry!(
47
- { "_meta" => eff_meta, "content" => eff_content },
48
- mentry.lane,
49
- )
51
+ validate_raw(eff_meta, eff_content, mentry.lane, mentry.format)
52
+
50
53
  etag_before = check_etag!(path, key, if_etag)
51
54
  write_bytes(path, bytes)
55
+
52
56
  envelope = build_envelope(key, mentry, path, eff_meta, eff_body, eff_content, bytes)
53
57
  audit_put(key, etag_before, envelope.etag)
54
58
  envelope
@@ -81,8 +85,7 @@ module Textus
81
85
  etag_before = @file_store.etag(from_path)
82
86
  raise EtagMismatch.new(from_key, if_etag, etag_before) if if_etag && if_etag != etag_before
83
87
 
84
- FileUtils.mkdir_p(File.dirname(to_path))
85
- FileUtils.mv(from_path, to_path)
88
+ @file_store.mv(from_path, to_path)
86
89
  prune_empty_parents(from_path)
87
90
  basename = to_key.split(".").last
88
91
  Format.for(new_mentry.format).rewrite_name(to_path, basename)
@@ -117,39 +120,53 @@ module Textus
117
120
  # `.gitkeep` or sibling entries survives. Best-effort: a lost race or a
118
121
  # non-empty dir is silently fine, never fatal to the write.
119
122
  def prune_empty_parents(path)
120
- floor = @geometry.lane_floor(path)
123
+ floor = @layout.lane_floor(path)
121
124
  return unless floor
122
125
 
123
126
  dir = File.dirname(path)
124
- while dir.start_with?("#{floor}/") && Dir.empty?(dir)
125
- Dir.rmdir(dir)
127
+ while dir.start_with?("#{floor}/") && @file_store.dir_empty?(dir)
128
+ @file_store.rmdir(dir)
126
129
  dir = File.dirname(dir)
127
130
  end
128
131
  rescue SystemCallError
129
132
  nil
130
133
  end
131
134
 
132
- def enforce_name_match!(path, meta, format)
133
- Textus::Format.for(format).enforce_name_match!(path, meta)
135
+ def read_existing(key)
136
+ @reader.read(key)
134
137
  end
135
138
 
136
- def serialize_for_put(mentry:, path:, meta:, body:, content:)
137
- Textus::Format.for(mentry.format).serialize_for_put(
138
- meta: meta, body: body, content: content, path: path,
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)
139
144
  )
140
145
  end
141
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
+
142
156
  def resolve_path(key)
143
157
  @manifest.resolver.resolve(key).path
144
158
  end
145
159
 
146
160
  def serialize_entry(mentry, path, meta, payload, content)
147
- serialize_for_put(
148
- mentry: mentry, path: path,
149
- meta: meta, body: payload.body, content: content
161
+ Textus::Format.for(mentry.format).serialize_for_put(
162
+ meta: meta, body: payload.body, content: content, path: path,
150
163
  )
151
164
  end
152
165
 
166
+ def enforce_name_match!(path, meta, format)
167
+ Textus::Format.for(format).enforce_name_match!(path, meta)
168
+ end
169
+
153
170
  def validate_schema(mentry, eff_meta, eff_content)
154
171
  schema = @schemas.fetch_or_nil(mentry.schema)
155
172
  return unless schema
@@ -160,6 +177,13 @@ module Textus
160
177
  )
161
178
  end
162
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
+
163
187
  def check_etag!(path, key, if_etag)
164
188
  etag_before = @file_store.exists?(path) ? @file_store.etag(path) : nil
165
189
  raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && (etag_before != if_etag)
@@ -171,7 +195,7 @@ module Textus
171
195
  @file_store.write(path, bytes)
172
196
  end
173
197
 
174
- def build_envelope(key, mentry, path, eff_meta, eff_body, eff_content, bytes = nil) # rubocop:disable Metrics/ParameterLists
198
+ def build_envelope(key, mentry, path, eff_meta, eff_body, eff_content, bytes = nil)
175
199
  raw = bytes || @file_store.read(path)
176
200
  Textus::Value::Envelope.build(
177
201
  key: key, mentry: mentry, path: path,
@@ -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
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ class Store
5
+ module Freshness
6
+ class TtlEvaluator
7
+ def initialize(manifest:, file_stat:, clock:)
8
+ @manifest = manifest
9
+ @file_stat = file_stat
10
+ @clock = clock
11
+ end
12
+
13
+ def verdict(mentry)
14
+ ttl = @manifest.rules.for(mentry.key).retention&.ttl_seconds
15
+ return fresh if ttl.nil?
16
+
17
+ stale = age_stale?(file_basis(mentry), ttl)
18
+ Verdict.build(stale: stale, reason: stale ? "ttl exceeded" : nil, fetching: false)
19
+ end
20
+
21
+ def stale_keys(prefix: nil, lane: nil)
22
+ @manifest.data.entries.select { |m| due?(m, prefix: prefix, lane: lane) }.map(&:key)
23
+ end
24
+
25
+ private
26
+
27
+ def fresh = Verdict.build(stale: false, reason: nil, fetching: false)
28
+
29
+ def file_basis(mentry)
30
+ path = @manifest.resolver.resolve(mentry.key).path
31
+ return nil unless @file_stat.exists?(path)
32
+
33
+ @file_stat.mtime(path)
34
+ end
35
+
36
+ def due?(mentry, prefix:, lane:)
37
+ return false if lane && mentry.lane != lane
38
+ return false if prefix && !mentry.key.start_with?(prefix)
39
+
40
+ ttl = @manifest.rules.for(mentry.key).retention&.ttl_seconds
41
+ return false if ttl.nil?
42
+
43
+ path = @manifest.resolver.resolve(mentry.key).path
44
+ return true unless @file_stat.exists?(path)
45
+
46
+ age_stale?(file_basis(mentry), ttl)
47
+ end
48
+
49
+ def age_stale?(basis, ttl)
50
+ return true if basis.nil?
51
+
52
+ (@clock.now - basis).to_i > ttl
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -1,16 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  module Textus
4
- module Core
2
+ class Store
5
3
  module Freshness
6
- # Value object describing the freshness annotation attached to an Envelope
7
- # after a currency evaluation (ADR 0099 — was Core::Freshness).
8
- #
9
- # Note on wire format: `#to_h_for_wire` is intentionally narrower than the
10
- # full field set. It emits the legacy keys ("stale", "stale_reason",
11
- # "fetching", and "fetch_error" when present) so the CLI JSON wire stays
12
- # byte-identical with textus/4. The gem-side fields `checked_at` and
13
- # `ttl_remaining_ms` are NOT emitted on the wire.
14
4
  Verdict = Data.define(
15
5
  :stale, :fetching, :reason, :fetch_error, :checked_at, :ttl_remaining_ms
16
6
  ) do
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ class Store
5
+ module Freshness
6
+ end
7
+ end
8
+ end
@@ -19,9 +19,10 @@ module Textus
19
19
  @store.execute("DELETE FROM entries")
20
20
  rows.each do |data|
21
21
  @store.execute(
22
- "INSERT INTO entries (key, lane, format, etag, content, extra, indexed_at)
23
- VALUES (?, ?, ?, ?, ?, ?, ?)",
24
- [data[:key], data[:lane], data[:format], data[:etag], data[:content], data[:extra], now_iso],
22
+ "INSERT INTO entries (key, lane, format, etag, content, extra, indexed_at, schema_ref)
23
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
24
+ [data[:key], data[:lane], data[:format], data[:etag],
25
+ data[:content], data[:extra], now_iso, data[:schema_ref]],
25
26
  )
26
27
  end
27
28
  @store.execute("INSERT INTO entries_fts(entries_fts) VALUES('rebuild')")
@@ -46,6 +47,7 @@ module Textus
46
47
  etag: Textus::Value::Etag.for_bytes(raw),
47
48
  content: content_text(parsed),
48
49
  extra: extra_json(parsed),
50
+ schema_ref: entry.schema,
49
51
  }
50
52
  end
51
53