textus 0.26.0 → 0.30.0

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 (157) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +118 -68
  3. data/CHANGELOG.md +132 -0
  4. data/README.md +61 -19
  5. data/SPEC.md +107 -46
  6. data/docs/conventions.md +4 -4
  7. data/lib/textus/boot.rb +18 -12
  8. data/lib/textus/builder/pipeline.rb +13 -12
  9. data/lib/textus/call.rb +28 -0
  10. data/lib/textus/cli/verb/audit.rb +1 -1
  11. data/lib/textus/cli/verb/boot.rb +1 -1
  12. data/lib/textus/cli/verb/build.rb +2 -2
  13. data/lib/textus/cli/verb/doctor.rb +1 -1
  14. data/lib/textus/cli/verb/hook_run.rb +2 -6
  15. data/lib/textus/cli/verb/put.rb +5 -14
  16. data/lib/textus/cli/verb/retain.rb +19 -0
  17. data/lib/textus/cli/verb/rule_list.rb +1 -1
  18. data/lib/textus/cli/verb.rb +6 -6
  19. data/lib/textus/cli.rb +19 -23
  20. data/lib/textus/container.rb +23 -0
  21. data/lib/textus/dispatcher.rb +57 -0
  22. data/lib/textus/doctor/check/audit_log.rb +1 -1
  23. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  24. data/lib/textus/doctor/check/sentinels.rb +10 -8
  25. data/lib/textus/doctor/check.rb +15 -5
  26. data/lib/textus/doctor.rb +7 -7
  27. data/lib/textus/domain/authorizer.rb +2 -2
  28. data/lib/textus/domain/duration.rb +22 -0
  29. data/lib/textus/domain/policy/refresh.rb +1 -15
  30. data/lib/textus/domain/policy/retention.rb +26 -0
  31. data/lib/textus/domain/retention.rb +44 -0
  32. data/lib/textus/domain/sentinel.rb +9 -65
  33. data/lib/textus/domain/staleness/generator_check.rb +46 -26
  34. data/lib/textus/domain/staleness/intake_check.rb +18 -10
  35. data/lib/textus/domain/staleness.rb +3 -3
  36. data/lib/textus/{application/envelope → envelope/io}/reader.rb +6 -2
  37. data/lib/textus/{application/envelope → envelope/io}/writer.rb +19 -11
  38. data/lib/textus/hooks/context.rb +30 -13
  39. data/lib/textus/hooks/event_bus.rb +8 -20
  40. data/lib/textus/hooks/rpc_registry.rb +9 -35
  41. data/lib/textus/hooks/signature.rb +31 -0
  42. data/lib/textus/init.rb +7 -6
  43. data/lib/textus/maintenance/key_delete_prefix.rb +36 -0
  44. data/lib/textus/maintenance/key_mv_prefix.rb +46 -0
  45. data/lib/textus/maintenance/migrate.rb +51 -0
  46. data/lib/textus/maintenance/rule_lint.rb +56 -0
  47. data/lib/textus/maintenance/zone_mv.rb +51 -0
  48. data/lib/textus/maintenance.rb +15 -0
  49. data/lib/textus/manifest/data.rb +9 -4
  50. data/lib/textus/manifest/entry/base.rb +38 -18
  51. data/lib/textus/manifest/entry/derived.rb +6 -6
  52. data/lib/textus/manifest/entry/nested.rb +7 -9
  53. data/lib/textus/manifest/entry/parser.rb +2 -2
  54. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  55. data/lib/textus/manifest/entry/validators/format_matrix.rb +2 -2
  56. data/lib/textus/manifest/entry/validators/index_filename.rb +1 -1
  57. data/lib/textus/manifest/entry/validators/inject_boot.rb +4 -2
  58. data/lib/textus/manifest/entry/validators/publish_each.rb +1 -1
  59. data/lib/textus/manifest/entry/validators.rb +2 -2
  60. data/lib/textus/manifest/entry.rb +0 -5
  61. data/lib/textus/manifest/policy.rb +34 -7
  62. data/lib/textus/manifest/rules.rb +10 -1
  63. data/lib/textus/manifest/schema.rb +54 -4
  64. data/lib/textus/manifest.rb +4 -8
  65. data/lib/textus/mcp/server.rb +2 -11
  66. data/lib/textus/mcp/session.rb +13 -20
  67. data/lib/textus/mcp/tools.rb +2 -2
  68. data/lib/textus/mcp.rb +1 -1
  69. data/lib/textus/{infra → ports}/audit_log.rb +1 -1
  70. data/lib/textus/{infra → ports}/audit_subscriber.rb +2 -2
  71. data/lib/textus/{infra → ports}/build_lock.rb +1 -1
  72. data/lib/textus/{infra → ports}/clock.rb +1 -1
  73. data/lib/textus/{infra → ports}/publisher.rb +6 -6
  74. data/lib/textus/{infra → ports}/refresh/detached.rb +3 -3
  75. data/lib/textus/{infra → ports}/refresh/lock.rb +1 -1
  76. data/lib/textus/ports/sentinel_store.rb +67 -0
  77. data/lib/textus/ports/storage/file_stat.rb +19 -0
  78. data/lib/textus/{infra → ports}/storage/file_store.rb +1 -1
  79. data/lib/textus/projection.rb +91 -0
  80. data/lib/textus/read/audit.rb +111 -0
  81. data/lib/textus/read/blame.rb +81 -0
  82. data/lib/textus/read/boot.rb +18 -0
  83. data/lib/textus/read/deps.rb +24 -0
  84. data/lib/textus/read/doctor.rb +19 -0
  85. data/lib/textus/read/freshness.rb +101 -0
  86. data/lib/textus/read/get.rb +66 -0
  87. data/lib/textus/read/get_or_refresh.rb +69 -0
  88. data/lib/textus/read/list.rb +15 -0
  89. data/lib/textus/read/policy_explain.rb +42 -0
  90. data/lib/textus/read/published.rb +15 -0
  91. data/lib/textus/read/pulse.rb +89 -0
  92. data/lib/textus/read/rdeps.rb +25 -0
  93. data/lib/textus/read/retainable.rb +17 -0
  94. data/lib/textus/read/schema_envelope.rb +16 -0
  95. data/lib/textus/read/stale.rb +17 -0
  96. data/lib/textus/read/uid.rb +20 -0
  97. data/lib/textus/read/validate_all.rb +22 -0
  98. data/lib/textus/read/validator.rb +84 -0
  99. data/lib/textus/read/where.rb +16 -0
  100. data/lib/textus/role_scope.rb +50 -0
  101. data/lib/textus/schema/tools.rb +3 -3
  102. data/lib/textus/store.rb +16 -7
  103. data/lib/textus/version.rb +1 -1
  104. data/lib/textus/write/accept.rb +86 -0
  105. data/lib/textus/write/authority_gate.rb +24 -0
  106. data/lib/textus/write/delete.rb +40 -0
  107. data/lib/textus/write/intake_fetch.rb +23 -0
  108. data/lib/textus/write/materializer.rb +48 -0
  109. data/lib/textus/write/mv.rb +113 -0
  110. data/lib/textus/write/publish.rb +66 -0
  111. data/lib/textus/write/put.rb +45 -0
  112. data/lib/textus/write/refresh_all.rb +44 -0
  113. data/lib/textus/write/refresh_orchestrator.rb +102 -0
  114. data/lib/textus/write/refresh_worker.rb +124 -0
  115. data/lib/textus/write/reject.rb +54 -0
  116. data/lib/textus/write/retention_sweep.rb +55 -0
  117. data/lib/textus.rb +1 -2
  118. metadata +62 -50
  119. data/lib/textus/application/caps.rb +0 -49
  120. data/lib/textus/application/context.rb +0 -34
  121. data/lib/textus/application/maintenance/key_delete_prefix.rb +0 -44
  122. data/lib/textus/application/maintenance/key_mv_prefix.rb +0 -57
  123. data/lib/textus/application/maintenance/migrate.rb +0 -59
  124. data/lib/textus/application/maintenance/rule_lint.rb +0 -65
  125. data/lib/textus/application/maintenance/zone_mv.rb +0 -60
  126. data/lib/textus/application/maintenance.rb +0 -17
  127. data/lib/textus/application/projection.rb +0 -93
  128. data/lib/textus/application/read/audit.rb +0 -106
  129. data/lib/textus/application/read/blame.rb +0 -91
  130. data/lib/textus/application/read/deps.rb +0 -34
  131. data/lib/textus/application/read/freshness.rb +0 -110
  132. data/lib/textus/application/read/get.rb +0 -75
  133. data/lib/textus/application/read/get_or_refresh.rb +0 -63
  134. data/lib/textus/application/read/list.rb +0 -25
  135. data/lib/textus/application/read/policy_explain.rb +0 -47
  136. data/lib/textus/application/read/published.rb +0 -25
  137. data/lib/textus/application/read/pulse.rb +0 -101
  138. data/lib/textus/application/read/rdeps.rb +0 -35
  139. data/lib/textus/application/read/schema_envelope.rb +0 -26
  140. data/lib/textus/application/read/stale.rb +0 -23
  141. data/lib/textus/application/read/uid.rb +0 -30
  142. data/lib/textus/application/read/validate_all.rb +0 -32
  143. data/lib/textus/application/read/validator.rb +0 -86
  144. data/lib/textus/application/read/where.rb +0 -26
  145. data/lib/textus/application/use_case.rb +0 -22
  146. data/lib/textus/application/write/accept.rb +0 -102
  147. data/lib/textus/application/write/authority_gate.rb +0 -26
  148. data/lib/textus/application/write/delete.rb +0 -45
  149. data/lib/textus/application/write/materializer.rb +0 -49
  150. data/lib/textus/application/write/mv.rb +0 -118
  151. data/lib/textus/application/write/publish.rb +0 -96
  152. data/lib/textus/application/write/put.rb +0 -49
  153. data/lib/textus/application/write/refresh_all.rb +0 -63
  154. data/lib/textus/application/write/refresh_orchestrator.rb +0 -102
  155. data/lib/textus/application/write/refresh_worker.rb +0 -134
  156. data/lib/textus/application/write/reject.rb +0 -62
  157. data/lib/textus/session.rb +0 -84
@@ -1,6 +1,6 @@
1
1
  module Textus
2
- module Application
3
- module Envelope
2
+ class Envelope
3
+ module IO
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
@@ -8,6 +8,10 @@ module Textus
8
8
  #
9
9
  # No audit, no events, no permission checks — those live one layer up.
10
10
  class Reader
11
+ def self.from(container:)
12
+ new(file_store: container.file_store, manifest: container.manifest)
13
+ end
14
+
11
15
  def initialize(file_store:, manifest:)
12
16
  @file_store = file_store
13
17
  @manifest = manifest
@@ -1,8 +1,8 @@
1
1
  require "fileutils"
2
2
 
3
3
  module Textus
4
- module Application
5
- module Envelope
4
+ class Envelope
5
+ module IO
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.
@@ -10,16 +10,24 @@ module Textus
10
10
  # Invariant: every public method's final action is @audit_log.append(...).
11
11
  #
12
12
  # No permission check, no event firing — those belong to the caller
13
- # (Application::Write::Put / ::Delete / ::Mv).
13
+ # (Write::Put / ::Delete / ::Mv).
14
14
  class Writer
15
15
  Payload = Data.define(:meta, :body, :content)
16
16
 
17
- def initialize(file_store:, manifest:, schemas:, audit_log:, ctx:, reader:)
17
+ def self.from(container:, call:)
18
+ new(
19
+ file_store: container.file_store, manifest: container.manifest,
20
+ schemas: container.schemas, audit_log: container.audit_log,
21
+ call: call, reader: Reader.from(container: container)
22
+ )
23
+ end
24
+
25
+ def initialize(file_store:, manifest:, schemas:, audit_log:, call:, reader:)
18
26
  @file_store = file_store
19
27
  @manifest = manifest
20
28
  @schemas = schemas
21
29
  @audit_log = audit_log
22
- @ctx = ctx
30
+ @call = call
23
31
  @reader = reader
24
32
  end
25
33
 
@@ -56,9 +64,9 @@ module Textus
56
64
  meta: eff_meta, body: eff_body, etag: etag_after, content: eff_content
57
65
  )
58
66
  @audit_log.append(
59
- role: @ctx.role, verb: "put", key: key,
67
+ role: @call.role, verb: "put", key: key,
60
68
  etag_before: etag_before, etag_after: etag_after,
61
- extras: @ctx.correlation_id ? { "correlation_id" => @ctx.correlation_id } : nil
69
+ extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
62
70
  )
63
71
  envelope
64
72
  end
@@ -75,9 +83,9 @@ module Textus
75
83
 
76
84
  @file_store.delete(path)
77
85
  @audit_log.append(
78
- role: @ctx.role, verb: "delete", key: key,
86
+ role: @call.role, verb: "delete", key: key,
79
87
  etag_before: etag_before, etag_after: nil,
80
- extras: @ctx.correlation_id ? { "correlation_id" => @ctx.correlation_id } : nil
88
+ extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
81
89
  )
82
90
  end
83
91
 
@@ -108,10 +116,10 @@ module Textus
108
116
  "from_path" => from_path, "to_path" => to_path,
109
117
  "uid" => envelope.uid
110
118
  }
111
- extras["correlation_id"] = @ctx.correlation_id if @ctx.correlation_id
119
+ extras["correlation_id"] = @call.correlation_id if @call.correlation_id
112
120
 
113
121
  @audit_log.append(
114
- role: @ctx.role, verb: "mv", key: to_key,
122
+ role: @call.role, verb: "mv", key: to_key,
115
123
  etag_before: etag_before, etag_after: etag_after,
116
124
  extras: extras
117
125
  )
@@ -3,31 +3,48 @@
3
3
  module Textus
4
4
  module Hooks
5
5
  # A narrow handle passed to user hooks in place of the raw Store.
6
- # All writes route back through the Session so authorization, audit
6
+ # All writes route back through the RoleScope so authorization, audit
7
7
  # logging, and schema validation always fire.
8
8
  class Context
9
9
  attr_reader :role, :correlation_id
10
10
 
11
- def initialize(session:)
12
- @session = session
13
- @role = session.ctx.role
14
- @correlation_id = session.ctx.correlation_id
11
+ def self.for(container:, call:)
12
+ scope = Textus::RoleScope.new(
13
+ container: container,
14
+ role: call.role,
15
+ correlation_id: call.correlation_id,
16
+ dry_run: call.dry_run,
17
+ )
18
+ new(scope: scope)
19
+ end
20
+
21
+ def initialize(scope:)
22
+ @scope = scope
23
+ @role = scope.role
24
+ @correlation_id = scope.correlation_id
25
+ end
26
+
27
+ def backend
28
+ @scope
15
29
  end
16
30
 
17
31
  # read
18
- def get(key) = @session.get(key)
19
- def list(**) = @session.list(**)
20
- def deps(key) = @session.deps(key)
21
- def freshness(key) = @session.freshness(key)
32
+ def get(key) = @scope.get(key)
33
+ def list(**) = @scope.list(**)
34
+ def deps(key) = @scope.deps(key)
35
+ def freshness(key) = @scope.freshness(key)
22
36
 
23
37
  # write (authorized + audited)
24
- def put(key, **) = @session.put(key, **)
25
- def delete(key, **) = @session.delete(key, **)
26
- def audit(verb, key:, **) = @session.write_caps.audit_log.append(role: @role, verb: verb, key: key, **)
38
+ def put(key, **) = @scope.put(key, **)
39
+ def delete(key, **) = @scope.delete(key, **)
40
+
41
+ def audit(verb, key:, **)
42
+ @scope.container.audit_log.append(role: @role, verb: verb, key: key, **)
43
+ end
27
44
 
28
45
  # fan-out
29
46
  def publish_followup(event, **)
30
- @session.write_caps.events.publish(event, ctx: self, **)
47
+ @scope.container.events.publish(event, ctx: self, **)
31
48
  end
32
49
 
33
50
  def inspect
@@ -39,7 +39,12 @@ module Textus
39
39
  raise UsageError.new("#{event_sym} is an RPC event; register on RpcRegistry") if RPC_EVENTS.include?(event_sym)
40
40
 
41
41
  required = EVENTS[event_sym] or raise UsageError.new("unknown event: #{event}")
42
- shape_check!(event_sym, required, blk)
42
+ sig = Signature.new(blk)
43
+ missing = sig.missing(required)
44
+ if missing.any?
45
+ raise UsageError.new("#{event_sym} hooks must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
46
+ end
47
+
43
48
  name = name.to_sym
44
49
  raise UsageError.new("#{event_sym} hook '#{name}' already registered") if @pubsub[event_sym].any? { |h| h[:name] == name }
45
50
 
@@ -79,8 +84,9 @@ module Textus
79
84
  private
80
85
 
81
86
  def invoke(event, sub, key, kwargs)
82
- accepted = filter_kwargs(sub[:callable], kwargs)
87
+ accepted = Signature.new(sub[:callable]).filter(kwargs)
83
88
  error = nil
89
+ # Thread#kill is unsafe in general but bounded here: post-commit, isolated, only a runaway user hook is affected.
84
90
  thread = Thread.new do
85
91
  sub[:callable].call(**accepted)
86
92
  rescue StandardError => e
@@ -115,24 +121,6 @@ module Textus
115
121
  end
116
122
  end
117
123
 
118
- def filter_kwargs(callable, kwargs)
119
- params = callable.parameters
120
- return kwargs if params.any? { |type, _| type == :keyrest }
121
-
122
- accepted = params.each_with_object([]) { |(t, n), acc| acc << n if %i[key keyreq].include?(t) }
123
- kwargs.slice(*accepted)
124
- end
125
-
126
- def shape_check!(event, required, blk)
127
- provided = blk.parameters.select { |t, _| %i[keyreq key keyrest].include?(t) } # rubocop:disable Style/HashSlice
128
- return if provided.any? { |t, _| t == :keyrest }
129
-
130
- missing = required - provided.map { |_, n| n }
131
- return if missing.empty?
132
-
133
- raise UsageError.new("#{event} hooks must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
134
- end
135
-
136
124
  def match?(globs, key)
137
125
  return true if globs.nil?
138
126
 
@@ -20,7 +20,10 @@ module Textus
20
20
  raise UsageError.new("#{event_sym} is a pubsub event; register on EventBus") if PUBSUB_EVENTS.include?(event_sym)
21
21
 
22
22
  required = EVENTS[event_sym] or raise UsageError.new("unknown RPC event: #{event}")
23
- shape_check!(event_sym, required, blk)
23
+ sig = Signature.new(blk)
24
+ missing = sig.missing(required)
25
+ raise UsageError.new("#{event_sym} RPC must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})") if missing.any?
26
+
24
27
  name = name.to_sym
25
28
  raise UsageError.new("#{event_sym} '#{name}' already registered") if @table[event_sym].key?(name)
26
29
 
@@ -33,45 +36,16 @@ module Textus
33
36
  @table[event.to_sym][name.to_sym] or raise UsageError.new("unknown #{event}: #{name}")
34
37
  end
35
38
 
36
- # Invoke a registered callable, injecting `caps:` under the kwarg name
37
- # the callable declares. Legacy `store:` is rejected (no shim).
39
+ # Invoke a registered callable, injecting `caps:` only if the callable
40
+ # declares it (or accepts keyrest). Mis-named kwargs (e.g. the legacy
41
+ # `caps:`-alternative) are rejected at registration time, not here.
38
42
  def invoke(event, name, caps:, **other)
39
43
  blk = callable(event, name)
40
- params = blk.parameters
41
- accepts_keyrest = params.any? { |t, _| t == :keyrest }
42
- declared = params.each_with_object([]) { |(t, n), acc| acc << n if %i[key keyreq].include?(t) }
43
-
44
- if declared.include?(:store)
45
- raise UsageError.new(
46
- "RPC callable for #{event} '#{name}' declares legacy `store:`; rename to `caps:` " \
47
- "(Textus::Application::ReadCaps / WriteCaps)",
48
- )
49
- end
50
-
44
+ sig = Signature.new(blk)
51
45
  kwargs = other.dup
52
- kwargs[:caps] = caps if accepts_keyrest || declared.include?(:caps)
46
+ kwargs[:caps] = caps if sig.accepts_keyrest? || sig.declared_keys.include?(:caps)
53
47
  blk.call(**kwargs)
54
48
  end
55
-
56
- private
57
-
58
- def shape_check!(event, required, blk)
59
- provided = blk.parameters.select { |t, _| %i[keyreq key keyrest].include?(t) } # rubocop:disable Style/HashSlice
60
- return if provided.any? { |t, _| t == :keyrest }
61
-
62
- param_names = provided.map { |_, n| n }
63
- # Allow `store:` as a stand-in for `caps:` so registration succeeds;
64
- # invoke will raise UsageError when the callable is actually called.
65
- effective_required = if param_names.include?(:store)
66
- required.map { |r| r == :caps ? :store : r }
67
- else
68
- required
69
- end
70
- missing = effective_required - param_names
71
- return if missing.empty?
72
-
73
- raise UsageError.new("#{event} RPC must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
74
- end
75
49
  end
76
50
  end
77
51
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Hooks
5
+ class Signature
6
+ def initialize(callable)
7
+ @params = callable.parameters
8
+ end
9
+
10
+ def accepts_keyrest?
11
+ @params.any? { |type, _| type == :keyrest }
12
+ end
13
+
14
+ def declared_keys
15
+ @params.each_with_object([]) { |(t, n), acc| acc << n if %i[keyreq key].include?(t) }
16
+ end
17
+
18
+ def missing(required)
19
+ return [] if accepts_keyrest?
20
+
21
+ required - declared_keys
22
+ end
23
+
24
+ def filter(kwargs)
25
+ return kwargs if accepts_keyrest?
26
+
27
+ kwargs.slice(*declared_keys)
28
+ end
29
+ end
30
+ end
31
+ end
data/lib/textus/init.rb CHANGED
@@ -7,14 +7,15 @@ module Textus
7
7
  DEFAULT_MANIFEST = <<~YAML
8
8
  version: textus/3
9
9
  zones:
10
- - { name: identity, write_policy: [human], read_policy: [all] }
11
- - { name: working, write_policy: [human, agent, runner], read_policy: [all] }
12
- - { name: intake, write_policy: [runner], read_policy: [all] }
13
- - { name: review, write_policy: [agent, human], read_policy: [all] }
14
- - { name: output, write_policy: [builder], read_policy: [all] }
10
+ - { name: identity, kind: origin, write_policy: [human], read_policy: [all] }
11
+ - { name: working, kind: origin, write_policy: [human], read_policy: [all] }
12
+ - { name: intake, kind: quarantine, write_policy: [runner], read_policy: [all] }
13
+ - { name: review, kind: queue, write_policy: [agent, human], read_policy: [all] }
14
+ - { name: output, kind: derived, write_policy: [builder], read_policy: [all] }
15
15
  entries:
16
16
  - { key: identity.self, path: identity/self.md, zone: identity, schema: null, owner: human:self, kind: leaf }
17
17
  - { key: working.notes, path: working/notes, zone: working, schema: null, owner: human:self, nested: true, kind: nested }
18
+ - { key: review.notes, path: review/notes, zone: review, schema: null, owner: agent:self, nested: true, kind: nested }
18
19
  YAML
19
20
 
20
21
  HOOKS_README = <<~MD
@@ -34,7 +35,7 @@ module Textus
34
35
  end
35
36
 
36
37
  reg.on(:transform_rows, :my_source) { |rows:, **| rows.map { |r| r.merge(processed: true) } }
37
- reg.on(:validate, :my_check) { |store:, **| [] }
38
+ reg.on(:validate, :my_check) { |caps:, **| [] }
38
39
  reg.on(:entry_put, :my_listener, keys: ["working.*"]) { |key:, envelope:, **| }
39
40
 
40
41
  # Run a side-effect every time textus writes a file to your repo:
@@ -0,0 +1,36 @@
1
+ module Textus
2
+ module Maintenance
3
+ # Bulk-delete every leaf key under `prefix`.
4
+ class KeyDeletePrefix
5
+ def initialize(container:, call:)
6
+ @container = container
7
+ @call = call
8
+ end
9
+
10
+ def call(prefix:, dry_run: false)
11
+ raise UsageError.new("prefix required") if prefix.nil? || prefix.empty?
12
+
13
+ leaves = Read::List.new(container: @container)
14
+ .call(prefix: prefix)
15
+ .map { |r| r.is_a?(Hash) ? (r["key"] || r[:key]) : r }
16
+
17
+ warnings = leaves.empty? ? ["no keys under #{prefix}"] : []
18
+ steps = leaves.map { |k| { "op" => "delete", "key" => k } }
19
+
20
+ plan = Plan.new(steps: steps, warnings: warnings)
21
+ return plan if dry_run
22
+
23
+ steps.each do |s|
24
+ delete.call(s["key"])
25
+ end
26
+ plan
27
+ end
28
+
29
+ private
30
+
31
+ def delete
32
+ Write::Delete.new(container: @container, call: @call)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,46 @@
1
+ module Textus
2
+ module Maintenance
3
+ # Bulk-rename every leaf key under `from_prefix` to `to_prefix`.
4
+ # Calls Write::Mv directly for each entry — emits one audit row per file moved.
5
+ class KeyMvPrefix
6
+ def initialize(container:, call:)
7
+ @container = container
8
+ @call = call
9
+ end
10
+
11
+ def call(from_prefix:, to_prefix:, dry_run: false)
12
+ raise UsageError.new("from_prefix and to_prefix required") if from_prefix.nil? || to_prefix.nil?
13
+
14
+ leaves = list_leaves_under(from_prefix)
15
+ warnings = []
16
+ warnings << "no keys under #{from_prefix}" if leaves.empty?
17
+
18
+ steps = leaves.map do |old_key|
19
+ tail = old_key.delete_prefix("#{from_prefix}.")
20
+ new_key = "#{to_prefix}.#{tail}"
21
+ { "op" => "mv", "from" => old_key, "to" => new_key }
22
+ end
23
+
24
+ plan = Plan.new(steps: steps, warnings: warnings)
25
+ return plan if dry_run
26
+
27
+ steps.each do |s|
28
+ mv.call(s["from"], s["to"], dry_run: false)
29
+ end
30
+ plan
31
+ end
32
+
33
+ private
34
+
35
+ def list_leaves_under(prefix)
36
+ Read::List.new(container: @container)
37
+ .call(prefix: prefix)
38
+ .map { |row| row.is_a?(Hash) ? (row["key"] || row[:key]) : row }
39
+ end
40
+
41
+ def mv
42
+ Write::Mv.new(container: @container, call: @call)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,51 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ module Maintenance
5
+ # Loads a YAML migration plan and dispatches each op to the
6
+ # appropriate Maintenance use case. Concatenates resulting Plans.
7
+ class Migrate
8
+ def initialize(container:, call:)
9
+ @container = container
10
+ @call = call
11
+ end
12
+
13
+ def call(plan_yaml:, dry_run: false)
14
+ raw = YAML.safe_load(plan_yaml, permitted_classes: [Symbol], aliases: false)
15
+ raise UsageError.new("migration plan must be a YAML mapping") unless raw.is_a?(Hash)
16
+
17
+ ops = Array(raw["operations"])
18
+ all_steps = []
19
+ warnings = []
20
+
21
+ ops.each do |op_hash|
22
+ op_name = op_hash["op"]
23
+ sub_plan = invoke_op(op_name, op_hash, dry_run: dry_run)
24
+ all_steps.concat(sub_plan.steps)
25
+ warnings.concat(sub_plan.warnings)
26
+ end
27
+
28
+ Plan.new(steps: all_steps, warnings: warnings)
29
+ end
30
+
31
+ private
32
+
33
+ def invoke_op(op_name, op_hash, dry_run:)
34
+ kwargs = op_hash.except("op").transform_keys(&:to_sym).merge(dry_run: dry_run)
35
+ klass = op_class(op_name)
36
+ klass.new(
37
+ container: @container, call: @call,
38
+ ).call(**kwargs)
39
+ end
40
+
41
+ def op_class(op_name)
42
+ case op_name
43
+ when "key_mv_prefix" then KeyMvPrefix
44
+ when "key_delete_prefix" then KeyDeletePrefix
45
+ when "zone_mv" then ZoneMv
46
+ else raise UsageError.new("unknown op: #{op_name}")
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,56 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ module Maintenance
5
+ # Compare the live manifest's `rules:` block against a candidate
6
+ # YAML string. Returns a Plan describing rule additions/removals/
7
+ # changes. Does NOT write anything.
8
+ class RuleLint
9
+ def initialize(container:, call:)
10
+ @container = container
11
+ @call = call
12
+ @root = container.root
13
+ end
14
+
15
+ def call(candidate_yaml:)
16
+ live_rules = current_rules
17
+ candidate_rules = parse_candidate(candidate_yaml)
18
+
19
+ live_by_match = live_rules.to_h { |r| [r["match"], r] }
20
+ candidate_by_match = candidate_rules.to_h { |r| [r["match"], r] }
21
+
22
+ steps = (candidate_by_match.keys - live_by_match.keys).map do |m|
23
+ { "op" => "add_rule", "match" => m, "rule" => candidate_by_match[m] }
24
+ end
25
+ (live_by_match.keys - candidate_by_match.keys).each do |m|
26
+ steps << { "op" => "remove_rule", "match" => m }
27
+ end
28
+ (live_by_match.keys & candidate_by_match.keys).each do |m|
29
+ next if live_by_match[m] == candidate_by_match[m]
30
+
31
+ steps << { "op" => "change_rule", "match" => m,
32
+ "from" => live_by_match[m], "to" => candidate_by_match[m] }
33
+ end
34
+
35
+ Plan.new(steps: steps, warnings: [])
36
+ end
37
+
38
+ private
39
+
40
+ def current_rules
41
+ raw = YAML.safe_load_file(File.join(@root, "manifest.yaml"),
42
+ permitted_classes: [Symbol], aliases: false)
43
+ Array(raw["rules"])
44
+ end
45
+
46
+ def parse_candidate(yaml_text)
47
+ raw = YAML.safe_load(yaml_text, permitted_classes: [Symbol], aliases: false)
48
+ raise UsageError.new("candidate is not a YAML mapping") unless raw.is_a?(Hash)
49
+
50
+ Array(raw["rules"])
51
+ rescue Psych::Exception => e
52
+ raise UsageError.new("candidate YAML parse error: #{e.message}")
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,51 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ module Maintenance
5
+ # Rename a zone — rewrites the manifest's zones[] entry, rewrites
6
+ # the `zone:` field on every entry under the old zone, and moves
7
+ # every file from zones/<old>/ to zones/<new>/.
8
+ class ZoneMv
9
+ def initialize(container:, call:)
10
+ @container = container
11
+ @call = call
12
+ @manifest = container.manifest
13
+ @root = container.root
14
+ end
15
+
16
+ def call(from:, to:, dry_run: false)
17
+ raise UsageError.new("from and to required") if from.nil? || to.nil? || from.empty? || to.empty?
18
+ raise UsageError.new("zone '#{from}' not declared") unless @manifest.data.zones.key?(from)
19
+
20
+ dest_dir = File.join(@root, "zones", to)
21
+ raise UsageError.new("destination 'zones/#{to}' already exists") if File.exist?(dest_dir)
22
+
23
+ affected_keys = @manifest.data.entries.select { |e| e.zone == from }.map(&:key)
24
+
25
+ steps = [{ "op" => "rename_zone", "from" => from, "to" => to }]
26
+ steps += affected_keys.map { |k| { "op" => "mv", "from" => k, "to" => "#{to}#{k[from.length..]}" } }
27
+
28
+ plan = Plan.new(steps: steps, warnings: [])
29
+ return plan if dry_run
30
+
31
+ rewrite_manifest!(from, to)
32
+ FileUtils.mv(File.join(@root, "zones", from), dest_dir)
33
+ plan
34
+ end
35
+
36
+ private
37
+
38
+ def rewrite_manifest!(from, to)
39
+ path = File.join(@root, "manifest.yaml")
40
+ raw = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false)
41
+ raw["zones"].each { |z| z["name"] = to if z["name"] == from }
42
+ raw["entries"].each do |e|
43
+ e["zone"] = to if e["zone"] == from
44
+ e["key"] = e["key"].sub(/\A#{Regexp.escape(from)}(\.|\z)/, "#{to}\\1")
45
+ e["path"] = e["path"].sub(%r{\A#{Regexp.escape(from)}(/|\z)}, "#{to}\\1")
46
+ end
47
+ File.write(path, YAML.dump(raw))
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ # Bulk and structural changes to a textus store. Each use case returns
3
+ # a Plan when called with dry_run: true, and applies the plan when
4
+ # called with dry_run: false.
5
+ module Maintenance
6
+ # A Plan is a JSON-shaped preview. Steps are op-tagged hashes the
7
+ # use case knows how to apply. Warnings are strings surfaced to
8
+ # the operator (skipped keys, ambiguities).
9
+ Plan = Data.define(:steps, :warnings) do
10
+ def to_h
11
+ { "steps" => steps, "warnings" => warnings }
12
+ end
13
+ end
14
+ end
15
+ end
@@ -11,7 +11,8 @@ module Textus
11
11
  class Data
12
12
  AUDIT_DEFAULTS = { max_size: 10_485_760, keep: 5 }.freeze
13
13
 
14
- attr_reader :raw, :root, :entries, :zones, :zone_readers, :audit_config, :role_mapping, :policy
14
+ attr_reader :raw, :root, :entries, :zones, :zone_readers, :declared_zone_kinds,
15
+ :audit_config, :role_mapping, :policy
15
16
 
16
17
  def self.validate_key!(key)
17
18
  raise UsageError.new("empty key") if key.nil? || key.empty?
@@ -38,10 +39,14 @@ module Textus
38
39
  rp = z["read_policy"]
39
40
  [z["name"], rp.nil? ? :all : Array(rp)]
40
41
  end
42
+ @declared_zone_kinds = Array(raw["zones"]).to_h do |z|
43
+ [z["name"], z["kind"]&.to_sym]
44
+ end
41
45
  @audit_config = build_audit_config(raw)
42
46
  @role_mapping = RoleKinds.resolve(raw["roles"])
43
47
  # Policy is constructed before entries because Entry validators
44
- # call `entry.in_generator_zone?` which routes through Policy.
48
+ # call `entry.in_generator_zone?(policy)` and similar helpers
49
+ # that take Policy as an argument.
45
50
  @policy = Policy.new(self)
46
51
  @entries = build_entries(raw)
47
52
  validate_declared_keys!
@@ -60,8 +65,8 @@ module Textus
60
65
 
61
66
  def build_entries(raw)
62
67
  Array(raw["entries"]).map do |e|
63
- entry = Manifest::Entry::Parser.call(self, e)
64
- Manifest::Entry::Validators.run_all(entry)
68
+ entry = Manifest::Entry::Parser.call(e)
69
+ Manifest::Entry::Validators.run_all(entry, policy: @policy)
65
70
  entry
66
71
  end.freeze
67
72
  end