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
@@ -0,0 +1,31 @@
1
+ module Textus
2
+ class Manifest
3
+ class Policy
4
+ module Predicates
5
+ class LaneDeletableBy
6
+ def self.call(manifest:, actor:, action:, key:, schemas: nil, envelope: nil, extra: {})
7
+ return { pass: true } if key.nil?
8
+
9
+ mentry = manifest.resolver.resolve(key).entry
10
+ is_raw = manifest.policy.declared_kind(mentry.lane.to_s) == :raw
11
+ lane_verb = manifest.policy.verb_for_lane(mentry.lane.to_s)
12
+ caps = Set.new(manifest.data.role_caps.fetch(actor.to_s, []))
13
+
14
+ pass = if is_raw
15
+ caps.include?("author")
16
+ else
17
+ caps.include?(lane_verb.to_s) || caps.include?("author")
18
+ end
19
+ return { pass: true } if pass
20
+
21
+ extra_holders = is_raw ? ["author"] : [lane_verb.to_s, "author"]
22
+ holders = extra_holders.flat_map { |v| manifest.policy.roles_with_capability(v) }.uniq
23
+ { pass: false, error: Textus::WriteForbidden.new(mentry.key, mentry.lane, verb: lane_verb, holders:) }
24
+ rescue Textus::UnknownKey
25
+ { pass: true }
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,23 @@
1
+ module Textus
2
+ class Manifest
3
+ class Policy
4
+ module Predicates
5
+ class LaneWritableBy
6
+ def self.call(manifest:, actor:, action:, key:, schemas: nil, envelope: nil, extra: {})
7
+ return { pass: true } if key.nil?
8
+
9
+ mentry = manifest.resolver.resolve(key).entry
10
+ lane_verb = manifest.policy.verb_for_lane(mentry.lane.to_s)
11
+ caps = Set.new(manifest.data.role_caps.fetch(actor.to_s, []))
12
+ return { pass: true } if caps.include?(lane_verb.to_s)
13
+
14
+ holders = manifest.policy.roles_with_capability(lane_verb.to_s)
15
+ { pass: false, error: Textus::WriteForbidden.new(mentry.key, mentry.lane, verb: lane_verb, holders:) }
16
+ rescue Textus::UnknownKey
17
+ { pass: true }
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,25 @@
1
+ module Textus
2
+ class Manifest
3
+ class Policy
4
+ module Predicates
5
+ class RawLaneIngestOnly
6
+ def self.call(manifest:, actor:, action:, key:, schemas: nil, envelope: nil, extra: {})
7
+ return { pass: true } if key.nil?
8
+
9
+ mentry = manifest.resolver.resolve(key).entry
10
+ return { pass: true } unless manifest.policy.declared_kind(mentry.lane.to_s) == :raw
11
+ return { pass: true } if action == :ingest
12
+
13
+ { pass: false, error: Textus::Error.new(
14
+ :raw_lane_ingest_only,
15
+ "raw lane '#{mentry.lane}' only accepts `textus ingest` — " \
16
+ "use that verb instead of '#{action}'",
17
+ ) }
18
+ rescue Textus::UnknownKey
19
+ { pass: true }
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,24 @@
1
+ module Textus
2
+ class Manifest
3
+ class Policy
4
+ module Predicates
5
+ class RawWriteOnce
6
+ def self.call(manifest:, actor:, action:, key:, schemas: nil, envelope: nil, extra: {})
7
+ return { pass: true } if key.nil?
8
+
9
+ path = manifest.resolver.resolve(key).path
10
+ return { pass: true } unless File.exist?(path)
11
+
12
+ { pass: false, error: Textus::Error.new(
13
+ :raw_write_once,
14
+ "raw entry '#{key}' already exists; " \
15
+ "delete it first (`textus key-delete #{key}`), then re-ingest",
16
+ ) }
17
+ rescue Textus::UnknownKey
18
+ { pass: true }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,41 @@
1
+ module Textus
2
+ class Manifest
3
+ class Policy
4
+ module Predicates
5
+ class SchemaValid
6
+ def self.call(manifest:, actor:, action:, key:, schemas: nil, envelope: nil, extra: {})
7
+ return { pass: true } unless envelope
8
+ return { pass: true } if key.nil?
9
+
10
+ mentry = manifest.resolver.resolve(key).entry
11
+ schema_ref = mentry.schema
12
+ return { pass: true } unless schema_ref
13
+ return { pass: true } unless schemas
14
+
15
+ schema = schemas.fetch_or_nil(schema_ref)
16
+ return { pass: true } unless schema
17
+
18
+ frontmatter = envelope.meta&.dig("_meta") || envelope.meta || {}
19
+ begin
20
+ schema.validate!(frontmatter)
21
+ { pass: true }
22
+ rescue Textus::SchemaViolation => e
23
+ { pass: false, reason: schema_reason(e) }
24
+ end
25
+ rescue Textus::UnknownKey
26
+ { pass: true }
27
+ end
28
+
29
+ def self.schema_reason(err)
30
+ d = err.details
31
+ return err.message.dup unless d.is_a?(Hash)
32
+ return "missing required fields: #{Array(d["missing"]).join(", ")}" if d["missing"]
33
+ return "field '#{d["field"]}': #{d["reason"]}" if d["field"]
34
+
35
+ err.message.dup
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,20 @@
1
+ module Textus
2
+ class Manifest
3
+ class Policy
4
+ module Predicates
5
+ class TargetIsCanon
6
+ def self.call(manifest:, actor:, action:, key:, schemas: nil, envelope: nil, extra: {})
7
+ return { pass: true } if key.nil?
8
+
9
+ mentry = manifest.resolver.resolve(key).entry
10
+ kind = manifest.policy.declared_kind(mentry.lane.to_s)
11
+ pass = kind == :canon
12
+ { pass:, reason: pass ? nil : "target lane '#{mentry.lane}' is not canon (kind: #{kind})" }
13
+ rescue Textus::UnknownKey
14
+ { pass: true }
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,54 @@
1
+ module Textus
2
+ class Manifest
3
+ class Policy
4
+ module Predicates
5
+ FLOOR = {
6
+ put: %w[lane_writable_by raw_lane_ingest_only],
7
+ key_delete: %w[lane_deletable_by],
8
+ key_mv: %w[lane_writable_by raw_lane_ingest_only],
9
+ accept: %w[author_held],
10
+ reject: %w[author_held],
11
+ propose: %w[lane_writable_by raw_lane_ingest_only],
12
+ key_mv_prefix: %w[lane_writable_by raw_lane_ingest_only],
13
+ key_delete_prefix: %w[lane_writable_by raw_lane_ingest_only],
14
+ ingest: %w[lane_writable_by raw_write_once],
15
+ }.freeze
16
+
17
+ CLASSES = {
18
+ "lane_writable_by" => "LaneWritableBy",
19
+ "author_held" => "AuthorHeld",
20
+ "target_is_canon" => "TargetIsCanon",
21
+ "etag_match" => "EtagMatch",
22
+ "schema_valid" => "SchemaValid",
23
+ "fresh_within" => "FreshWithin",
24
+ "raw_lane_ingest_only" => "RawLaneIngestOnly",
25
+ "raw_write_once" => "RawWriteOnce",
26
+ "lane_deletable_by" => "LaneDeletableBy",
27
+ }.freeze
28
+
29
+ module_function
30
+
31
+ def by_name(name)
32
+ short = CLASSES.fetch(name.to_s) do
33
+ raise Textus::UsageError.new("unknown predicate '#{name}'")
34
+ end
35
+ const_get(short)
36
+ end
37
+
38
+ def evaluate(manifest:, action:, actor:, key:, schemas: nil, envelope: nil, extra: {}, rule_predicates: [])
39
+ failures = []
40
+ (FLOOR.fetch(action, []) + rule_predicates).uniq.each do |pred_name|
41
+ result = by_name(pred_name).call(
42
+ manifest:, schemas:, actor:, action:, key:, envelope:, extra:,
43
+ )
44
+ next if result[:pass]
45
+ raise result[:error] if result[:error]
46
+
47
+ failures << [pred_name, result[:reason]]
48
+ end
49
+ raise Textus::GuardFailed.new(failures) unless failures.empty?
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -21,7 +21,7 @@ module Textus
21
21
  raise Textus::BadManifest.new("retention action must be one of #{ACTIONS.join("|")}, got #{raw["action"].inspect}")
22
22
  end
23
23
 
24
- def ttl_seconds = Textus::Core::Duration.seconds(@ttl)
24
+ def ttl_seconds = Textus::Value::Duration.seconds(@ttl)
25
25
  def destructive? = true
26
26
  end
27
27
  end
@@ -0,0 +1,55 @@
1
+ module Textus
2
+ class Orchestration
3
+ ListKeysQuery = Data.define(:prefix, :lane)
4
+ MoveKeyCommand = Data.define(:old_key, :new_key, :if_etag, :dry_run)
5
+ DeleteKeyCommand = Data.define(:key, :if_etag)
6
+ AuditQuery = Data.define(:seq_since, :key, :lane, :role, :verb, :since, :correlation_id, :limit)
7
+
8
+ def initialize(list_keys:, move_key:, delete_key:, audit_entries:)
9
+ @list_keys = list_keys
10
+ @move_key = move_key
11
+ @delete_key = delete_key
12
+ @audit_entries = audit_entries
13
+ end
14
+
15
+ def list_keys(prefix:, lane:, call:)
16
+ query = ListKeysQuery.new(prefix: prefix, lane: lane)
17
+ normalize(@list_keys.call(query, call), key: "rows")
18
+ end
19
+
20
+ def move_key(old_key:, new_key:, call:, if_etag: nil, dry_run: false)
21
+ command = MoveKeyCommand.new(old_key: old_key, new_key: new_key, if_etag: if_etag, dry_run: dry_run)
22
+ normalize(@move_key.call(command, call), key: "move")
23
+ end
24
+
25
+ def delete_key(key:, call:, if_etag: nil)
26
+ command = DeleteKeyCommand.new(key: key, if_etag: if_etag)
27
+ normalize(@delete_key.call(command, call), key: "delete")
28
+ end
29
+
30
+ # rubocop:disable Metrics/ParameterLists
31
+ def audit_entries(call:, seq_since: nil, key: nil, lane: nil, role: nil, verb: nil, since: nil, correlation_id: nil, limit: nil)
32
+ query = AuditQuery.new(
33
+ seq_since: seq_since,
34
+ key: key,
35
+ lane: lane,
36
+ role: role,
37
+ verb: verb,
38
+ since: since,
39
+ correlation_id: correlation_id,
40
+ limit: limit,
41
+ )
42
+ normalize(@audit_entries.call(query, call), key: "rows")
43
+ end
44
+ # rubocop:enable Metrics/ParameterLists
45
+
46
+ private
47
+
48
+ def normalize(result, key:)
49
+ return result unless result.is_a?(Value::Result)
50
+ return result if result.failure?
51
+
52
+ Value::Result.success({ key => result.value })
53
+ end
54
+ end
55
+ end
@@ -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::Store::Geometry.new(root).audit_log_path
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
@@ -137,11 +137,11 @@ module Textus
137
137
  end
138
138
 
139
139
  def rotated(n)
140
- File.join(Textus::Store::Geometry.new(@root).audit_dir_path, "audit.log.#{n}")
140
+ @geometry.audit_rotated_log_path(n)
141
141
  end
142
142
 
143
143
  def rotated_meta(n)
144
- File.join(Textus::Store::Geometry.new(@root).audit_dir_path, "audit.log.#{n}.meta.json")
144
+ @geometry.audit_rotated_meta_path(n)
145
145
  end
146
146
 
147
147
  # Caller holds the flock. Returns the highest seq across the active log,
@@ -248,7 +248,7 @@ module Textus
248
248
  end
249
249
 
250
250
  def all_log_files
251
- rotated = Dir.glob(File.join(Textus::Store::Geometry.new(@root).audit_dir_path, "audit.log.*"))
251
+ rotated = Dir.glob(@geometry.audit_log_glob)
252
252
  .reject { |path| path.end_with?(".meta.json") }
253
253
  .sort_by { |path| -path.scan(/\d+$/).first.to_i }
254
254
  active_log = File.exist?(@path) ? [@path] : []
@@ -18,7 +18,7 @@ module Textus
18
18
  end
19
19
 
20
20
  def initialize(root:)
21
- @path = Textus::Store::Geometry.new(root).lock_path("build")
21
+ @path = Textus::Store::Layout.new(root).lock_path("build")
22
22
  @file = nil
23
23
  end
24
24
 
@@ -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
 
@@ -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::Store::Geometry.new(store_root).sentinels_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::Store::Geometry.new(store_root).sentinels_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
 
@@ -6,6 +6,8 @@ module Textus
6
6
  # Pure filesystem I/O port. Wraps File/FileUtils/Etag with no knowledge
7
7
  # of envelopes, entries, schemas, or audit.
8
8
  class FileStore
9
+ include Interface
10
+
9
11
  def read(path) = File.binread(path)
10
12
 
11
13
  def write(path, bytes)
@@ -20,6 +22,27 @@ module Textus
20
22
  def exists?(path) = File.exist?(path)
21
23
 
22
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
23
46
  end
24
47
  end
25
48
  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
@@ -11,8 +11,7 @@ module Textus
11
11
  attr_reader :path, :connection
12
12
 
13
13
  def initialize(root:)
14
- @root = root
15
- @path = Textus::Store::Geometry.new(root).store_db_path
14
+ @path = Textus::Store::Layout.new(root).store_db_path
16
15
  FileUtils.mkdir_p(File.dirname(@path))
17
16
  @connection = SQLite3::Database.new(@path)
18
17
  @connection.results_as_hash = true
@@ -61,10 +60,48 @@ module Textus
61
60
 
62
61
  CREATE INDEX IF NOT EXISTS idx_jobs_state ON jobs(state);
63
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);
64
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
65
78
  self
66
79
  end
67
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
+
68
105
  def transaction
69
106
  connection.transaction
70
107
  yield
@@ -88,6 +125,25 @@ module Textus
88
125
  store&.close
89
126
  end
90
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
91
147
  end
92
148
  end
93
149
  end
@@ -8,13 +8,13 @@ module Textus
8
8
  # Process death releases the flock automatically.
9
9
  class WatcherLock
10
10
  def initialize(root)
11
- @path = Textus::Store::Geometry.new(root).lock_path("watcher")
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::Store::Geometry.new(root).lock_path("watcher")
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::Store::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
@@ -14,7 +14,7 @@ module Textus
14
14
  "optional" => [],
15
15
  "fields" => meta.each_with_object({}) { |(k, v), h| h[k] = { "type" => infer_type(v) } },
16
16
  }
17
- geom = Textus::Store::Geometry.new(store.root)
17
+ geom = Textus::Store::Layout.new(store.root)
18
18
  FileUtils.mkdir_p(geom.schemas_dir)
19
19
  target = geom.schema_path(name)
20
20
  File.write(target, YAML.dump(schema))
@@ -51,7 +51,7 @@ module Textus
51
51
  raise UsageError.new("schema migrate needs --rename=OLD:NEW or schema.evolution.migrate_from") if renames.empty?
52
52
 
53
53
  authority = accept_role_for(store)
54
- ops = store.as(authority)
54
+ ops = store.with_role(authority)
55
55
  touched = []
56
56
  store.manifest.resolver.enumerate.each do |row|
57
57
  env = pure_get(store, authority, row[:key])
@@ -85,14 +85,7 @@ module Textus
85
85
  # Orchestrator-free read: schema tooling must never trigger a fetch
86
86
  # while inspecting/migrating entries (ADR 0062).
87
87
  def self.pure_get(store, role, key)
88
- scope = store.as(role)
89
- Value::Result.unwrap(
90
- Textus::Action::Get.call(
91
- container: scope.container,
92
- call: Textus::Value::Call.build(role: role),
93
- key: key,
94
- ),
95
- )
88
+ store.with_role(role).get(key)
96
89
  end
97
90
 
98
91
  def self.load_schema(store, name)