textus 0.47.1 → 0.50.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/README.md +9 -7
  4. data/SPEC.md +44 -69
  5. data/docs/reference/conventions.md +13 -12
  6. data/lib/textus/boot.rb +47 -32
  7. data/lib/textus/builder/renderer/json.rb +1 -1
  8. data/lib/textus/cli/runner.rb +5 -4
  9. data/lib/textus/cli/verb/boot.rb +2 -1
  10. data/lib/textus/cli.rb +0 -1
  11. data/lib/textus/dispatcher.rb +3 -8
  12. data/lib/textus/doctor/check/generator_drift.rb +28 -0
  13. data/lib/textus/doctor/check/lifecycle_action_invalid.rb +39 -0
  14. data/lib/textus/doctor/check/rule_ambiguity.rb +1 -1
  15. data/lib/textus/doctor.rb +2 -0
  16. data/lib/textus/domain/lifecycle.rb +83 -0
  17. data/lib/textus/domain/policy/base_guards.rb +2 -2
  18. data/lib/textus/domain/policy/lifecycle.rb +35 -0
  19. data/lib/textus/domain/staleness.rb +6 -3
  20. data/lib/textus/envelope/io/writer.rb +2 -2
  21. data/lib/textus/hooks/context.rb +1 -1
  22. data/lib/textus/init.rb +4 -4
  23. data/lib/textus/maintenance/key_delete_prefix.rb +1 -1
  24. data/lib/textus/maintenance/key_mv_prefix.rb +2 -2
  25. data/lib/textus/maintenance/tend.rb +110 -0
  26. data/lib/textus/manifest/entry/base.rb +1 -0
  27. data/lib/textus/manifest/entry/derived.rb +4 -2
  28. data/lib/textus/manifest/rules.rb +11 -23
  29. data/lib/textus/manifest/schema.rb +4 -19
  30. data/lib/textus/mcp/server.rb +9 -2
  31. data/lib/textus/ports/audit_log.rb +1 -1
  32. data/lib/textus/ports/audit_subscriber.rb +1 -1
  33. data/lib/textus/ports/fetch/detached.rb +5 -1
  34. data/lib/textus/read/boot.rb +4 -2
  35. data/lib/textus/read/freshness.rb +37 -26
  36. data/lib/textus/read/get.rb +47 -32
  37. data/lib/textus/read/pulse.rb +1 -1
  38. data/lib/textus/read/rule_explain.rb +10 -16
  39. data/lib/textus/read/rule_list.rb +5 -7
  40. data/lib/textus/version.rb +1 -1
  41. data/lib/textus/write/accept.rb +1 -1
  42. data/lib/textus/write/fetch_worker.rb +8 -12
  43. data/lib/textus/write/{delete.rb → key_delete.rb} +3 -3
  44. data/lib/textus/write/{mv.rb → key_mv.rb} +4 -4
  45. data/lib/textus/write/reject.rb +1 -1
  46. metadata +8 -15
  47. data/lib/textus/cli/group/fetch.rb +0 -20
  48. data/lib/textus/cli/verb/fetch.rb +0 -14
  49. data/lib/textus/cli/verb/fetch_all.rb +0 -20
  50. data/lib/textus/domain/policy/fetch.rb +0 -37
  51. data/lib/textus/domain/policy/retention.rb +0 -26
  52. data/lib/textus/domain/retention.rb +0 -44
  53. data/lib/textus/domain/staleness/intake_check.rb +0 -54
  54. data/lib/textus/maintenance/migrate.rb +0 -65
  55. data/lib/textus/read/retainable.rb +0 -17
  56. data/lib/textus/read/stale.rb +0 -17
  57. data/lib/textus/write/fetch_all.rb +0 -53
  58. data/lib/textus/write/retention_sweep.rb +0 -64
@@ -1,9 +1,9 @@
1
1
  module Textus
2
2
  module Write
3
- class Mv
3
+ class KeyMv
4
4
  extend Textus::Contract::DSL
5
5
 
6
- verb :mv
6
+ verb :key_mv
7
7
  summary "Rename one entry (same zone + format). Refuses if the target exists. Single-key, lower blast radius than key_mv_prefix."
8
8
  surfaces :cli, :mcp
9
9
  cli "key mv"
@@ -53,8 +53,8 @@ module Textus
53
53
  raise UnknownKey.new(old_key) unless reader.exists?(old_key)
54
54
 
55
55
  validate_zone_and_format!(old_res.entry, new_res.entry)
56
- guard_for(:mv, old_key).check!(eval_for(:mv, target_key: old_key))
57
- guard_for(:mv, new_key).check!(eval_for(:mv, target_key: new_key))
56
+ guard_for(:key_mv, old_key).check!(eval_for(:key_mv, target_key: old_key))
57
+ guard_for(:key_mv, new_key).check!(eval_for(:key_mv, target_key: new_key))
58
58
  raise UsageError.new("mv: target '#{new_key}' already exists at #{new_res.path}") if reader.exists?(new_key)
59
59
 
60
60
  [old_res, new_res]
@@ -59,7 +59,7 @@ module Textus
59
59
  end
60
60
 
61
61
  def delete_op
62
- @delete_op ||= Textus::Write::Delete.new(
62
+ @delete_op ||= Textus::Write::KeyDelete.new(
63
63
  container: @container, call: @call,
64
64
  )
65
65
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: textus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.47.1
4
+ version: 0.50.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -120,7 +120,6 @@ files:
120
120
  - lib/textus/call.rb
121
121
  - lib/textus/cli.rb
122
122
  - lib/textus/cli/group.rb
123
- - lib/textus/cli/group/fetch.rb
124
123
  - lib/textus/cli/group/hook.rb
125
124
  - lib/textus/cli/group/key.rb
126
125
  - lib/textus/cli/group/mcp.rb
@@ -132,8 +131,6 @@ files:
132
131
  - lib/textus/cli/verb/boot.rb
133
132
  - lib/textus/cli/verb/build.rb
134
133
  - lib/textus/cli/verb/doctor.rb
135
- - lib/textus/cli/verb/fetch.rb
136
- - lib/textus/cli/verb/fetch_all.rb
137
134
  - lib/textus/cli/verb/get.rb
138
135
  - lib/textus/cli/verb/hook_run.rb
139
136
  - lib/textus/cli/verb/hooks.rb
@@ -157,10 +154,12 @@ files:
157
154
  - lib/textus/doctor/check.rb
158
155
  - lib/textus/doctor/check/audit_log.rb
159
156
  - lib/textus/doctor/check/fetch_locks.rb
157
+ - lib/textus/doctor/check/generator_drift.rb
160
158
  - lib/textus/doctor/check/handler_allowlist.rb
161
159
  - lib/textus/doctor/check/hooks.rb
162
160
  - lib/textus/doctor/check/illegal_keys.rb
163
161
  - lib/textus/doctor/check/intake_registration.rb
162
+ - lib/textus/doctor/check/lifecycle_action_invalid.rb
164
163
  - lib/textus/doctor/check/manifest_files.rb
165
164
  - lib/textus/doctor/check/orphaned_publish_targets.rb
166
165
  - lib/textus/doctor/check/proposal_targets.rb
@@ -179,14 +178,15 @@ files:
179
178
  - lib/textus/domain/freshness/evaluator.rb
180
179
  - lib/textus/domain/freshness/policy.rb
181
180
  - lib/textus/domain/freshness/verdict.rb
181
+ - lib/textus/domain/lifecycle.rb
182
182
  - lib/textus/domain/outcome.rb
183
183
  - lib/textus/domain/permission.rb
184
184
  - lib/textus/domain/policy/base_guards.rb
185
185
  - lib/textus/domain/policy/evaluation.rb
186
- - lib/textus/domain/policy/fetch.rb
187
186
  - lib/textus/domain/policy/guard.rb
188
187
  - lib/textus/domain/policy/guard_factory.rb
189
188
  - lib/textus/domain/policy/handler_allowlist.rb
189
+ - lib/textus/domain/policy/lifecycle.rb
190
190
  - lib/textus/domain/policy/matcher.rb
191
191
  - lib/textus/domain/policy/predicates/author_held.rb
192
192
  - lib/textus/domain/policy/predicates/etag_match.rb
@@ -195,12 +195,9 @@ files:
195
195
  - lib/textus/domain/policy/predicates/schema_valid.rb
196
196
  - lib/textus/domain/policy/predicates/target_is_canon.rb
197
197
  - lib/textus/domain/policy/predicates/zone_writable_by.rb
198
- - lib/textus/domain/policy/retention.rb
199
- - lib/textus/domain/retention.rb
200
198
  - lib/textus/domain/sentinel.rb
201
199
  - lib/textus/domain/staleness.rb
202
200
  - lib/textus/domain/staleness/generator_check.rb
203
- - lib/textus/domain/staleness/intake_check.rb
204
201
  - lib/textus/entry.rb
205
202
  - lib/textus/entry/base.rb
206
203
  - lib/textus/entry/json.rb
@@ -231,8 +228,8 @@ files:
231
228
  - lib/textus/maintenance.rb
232
229
  - lib/textus/maintenance/key_delete_prefix.rb
233
230
  - lib/textus/maintenance/key_mv_prefix.rb
234
- - lib/textus/maintenance/migrate.rb
235
231
  - lib/textus/maintenance/rule_lint.rb
232
+ - lib/textus/maintenance/tend.rb
236
233
  - lib/textus/maintenance/zone_mv.rb
237
234
  - lib/textus/manifest.rb
238
235
  - lib/textus/manifest/capabilities.rb
@@ -293,11 +290,9 @@ files:
293
290
  - lib/textus/read/published.rb
294
291
  - lib/textus/read/pulse.rb
295
292
  - lib/textus/read/rdeps.rb
296
- - lib/textus/read/retainable.rb
297
293
  - lib/textus/read/rule_explain.rb
298
294
  - lib/textus/read/rule_list.rb
299
295
  - lib/textus/read/schema_envelope.rb
300
- - lib/textus/read/stale.rb
301
296
  - lib/textus/read/uid.rb
302
297
  - lib/textus/read/validate_all.rb
303
298
  - lib/textus/read/validator.rb
@@ -313,18 +308,16 @@ files:
313
308
  - lib/textus/version.rb
314
309
  - lib/textus/write/accept.rb
315
310
  - lib/textus/write/build.rb
316
- - lib/textus/write/delete.rb
317
- - lib/textus/write/fetch_all.rb
318
311
  - lib/textus/write/fetch_events.rb
319
312
  - lib/textus/write/fetch_orchestrator.rb
320
313
  - lib/textus/write/fetch_worker.rb
321
314
  - lib/textus/write/intake_fetch.rb
315
+ - lib/textus/write/key_delete.rb
316
+ - lib/textus/write/key_mv.rb
322
317
  - lib/textus/write/materializer.rb
323
- - lib/textus/write/mv.rb
324
318
  - lib/textus/write/propose.rb
325
319
  - lib/textus/write/put.rb
326
320
  - lib/textus/write/reject.rb
327
- - lib/textus/write/retention_sweep.rb
328
321
  homepage: https://github.com/patrick204nqh/textus
329
322
  licenses:
330
323
  - MIT
@@ -1,20 +0,0 @@
1
- module Textus
2
- class CLI
3
- class Group
4
- class Fetch < Group
5
- command_name "fetch"
6
-
7
- def parse(argv)
8
- if argv.first == "all"
9
- argv.shift
10
- @sub_klass = Verb::FetchAll
11
- else
12
- @sub_klass = Verb::Fetch
13
- end
14
- @sub = @sub_klass.new(stdin: @stdin, stdout: @stdout, stderr: @stderr, cwd: @cwd)
15
- @sub.parse(argv)
16
- end
17
- end
18
- end
19
- end
20
- end
@@ -1,14 +0,0 @@
1
- module Textus
2
- class CLI
3
- class Verb
4
- class Fetch < Verb
5
- option :as_flag, "--as=ROLE"
6
-
7
- def call(store)
8
- key = positional.shift or raise UsageError.new("fetch requires a key")
9
- emit(session_for(store).fetch(key).to_h_for_wire)
10
- end
11
- end
12
- end
13
- end
14
- end
@@ -1,20 +0,0 @@
1
- module Textus
2
- class CLI
3
- class Verb
4
- class FetchAll < Verb
5
- command_name "all"
6
- parent_group Group::Fetch
7
-
8
- option :prefix, "--prefix=KEY"
9
- option :zone, "--zone=Z"
10
- option :as_flag, "--as=ROLE"
11
-
12
- def call(store)
13
- result = session_for(store).fetch_all(prefix: prefix, zone: zone)
14
- emit(result)
15
- result["ok"] ? 0 : 1
16
- end
17
- end
18
- end
19
- end
20
- end
@@ -1,37 +0,0 @@
1
- module Textus
2
- module Domain
3
- module Policy
4
- class Fetch
5
- ALLOWED_ON_STALE = %i[warn sync timed_sync].freeze
6
-
7
- attr_reader :ttl, :on_stale, :sync_budget_ms, :fetch_timeout_seconds
8
-
9
- def initialize(ttl:, on_stale:, sync_budget_ms:, fetch_timeout_seconds: nil)
10
- on_stale_sym = on_stale.is_a?(Symbol) ? on_stale : on_stale.to_s.to_sym
11
- unless ALLOWED_ON_STALE.include?(on_stale_sym)
12
- raise Textus::UsageError.new(
13
- "on_stale must be one of #{ALLOWED_ON_STALE.join(", ")} (got #{on_stale.inspect})",
14
- )
15
- end
16
-
17
- @ttl = ttl
18
- @on_stale = on_stale_sym
19
- @sync_budget_ms = sync_budget_ms
20
- @fetch_timeout_seconds = fetch_timeout_seconds
21
- end
22
-
23
- def ttl_seconds
24
- Textus::Domain::Duration.seconds(@ttl)
25
- end
26
-
27
- def to_freshness_policy
28
- Textus::Domain::Freshness::Policy.new(
29
- ttl_seconds: ttl_seconds,
30
- on_stale: @on_stale,
31
- sync_budget_ms: @sync_budget_ms,
32
- )
33
- end
34
- end
35
- end
36
- end
37
- end
@@ -1,26 +0,0 @@
1
- module Textus
2
- module Domain
3
- module Policy
4
- # Lifetime policy for queue/quarantine leaves. Both windows are optional
5
- # durations (see Domain::Duration). `expire_after` deletes; `archive_after`
6
- # moves the leaf aside. When both are set, expire wins once its (longer)
7
- # window is exceeded.
8
- class Retention
9
- attr_reader :expire_after, :archive_after
10
-
11
- def initialize(expire_after: nil, archive_after: nil)
12
- @expire_after = Textus::Domain::Duration.seconds(expire_after)
13
- @archive_after = Textus::Domain::Duration.seconds(archive_after)
14
- end
15
-
16
- # :expire | :archive | nil for a leaf of the given age (seconds).
17
- def action_for(age_seconds)
18
- return :expire if @expire_after && age_seconds > @expire_after
19
- return :archive if @archive_after && age_seconds > @archive_after
20
-
21
- nil
22
- end
23
- end
24
- end
25
- end
26
- end
@@ -1,44 +0,0 @@
1
- module Textus
2
- module Domain
3
- # Reports leaves whose age (now - file mtime) exceeds a retention window.
4
- # Each row is { "key", "path", "action" => "expire"|"archive", "age_seconds" }.
5
- class Retention
6
- def initialize(manifest:, file_stat:, clock:)
7
- @manifest = manifest
8
- @file_stat = file_stat
9
- @clock = clock
10
- end
11
-
12
- def call(prefix: nil, zone: nil)
13
- @manifest.data.entries
14
- .select { |m| entry_matches?(m, prefix: prefix, zone: zone) }
15
- .flat_map { |m| rows_for(m) }
16
- end
17
-
18
- private
19
-
20
- def rows_for(mentry)
21
- policy = @manifest.rules.for(mentry.key).retention
22
- return [] if policy.nil?
23
-
24
- @manifest.resolver.enumerate(prefix: mentry.key).filter_map do |row|
25
- path = row[:path]
26
- next unless @file_stat.exists?(path)
27
-
28
- age = (@clock.now - @file_stat.mtime(path)).to_i
29
- action = policy.action_for(age)
30
- next if action.nil?
31
-
32
- { "key" => row[:key], "path" => path, "action" => action.to_s, "age_seconds" => age }
33
- end
34
- end
35
-
36
- def entry_matches?(mentry, prefix:, zone:)
37
- return false if zone && mentry.zone != zone
38
- return false if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
39
-
40
- true
41
- end
42
- end
43
- end
44
- end
@@ -1,54 +0,0 @@
1
- require "time"
2
-
3
- module Textus
4
- module Domain
5
- class Staleness
6
- # Reports TTL-exceeded staleness for intake-handler entries. Returns an
7
- # Array of row hashes (possibly empty) per entry.
8
- class IntakeCheck
9
- def initialize(manifest:, file_stat:, clock:)
10
- @manifest = manifest
11
- @file_stat = file_stat
12
- @clock = clock
13
- end
14
-
15
- def rows_for(mentry)
16
- return [] unless mentry.is_a?(Textus::Manifest::Entry::Intake)
17
-
18
- ttl = @manifest.rules.for(mentry.key).fetch&.ttl_seconds
19
- return [] unless ttl
20
-
21
- path = Textus::Key::Path.resolve(@manifest.data, mentry)
22
- reason = ttl_reason(mentry, path, ttl)
23
- reason ? [row(mentry, path, reason)] : []
24
- end
25
-
26
- private
27
-
28
- def ttl_reason(mentry, path, ttl)
29
- return "never fetched" unless @file_stat.exists?(path)
30
-
31
- last_str = last_fetched_of(mentry, path)
32
- return "never fetched (no last_fetched_at)" if last_str.nil?
33
-
34
- last = parse_time(last_str)
35
- "ttl exceeded (#{ttl}s)" if last.nil? || (@clock.now - last) > ttl
36
- end
37
-
38
- def last_fetched_of(mentry, path)
39
- Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"]["last_fetched_at"]
40
- end
41
-
42
- def parse_time(str)
43
- Time.parse(str.to_s)
44
- rescue StandardError
45
- nil
46
- end
47
-
48
- def row(mentry, path, reason)
49
- { "key" => mentry.key, "path" => path, "handler" => mentry.handler, "reason" => reason }
50
- end
51
- end
52
- end
53
- end
54
- end
@@ -1,65 +0,0 @@
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
- extend Textus::Contract::DSL
9
-
10
- verb :migrate
11
- summary "Run a YAML migration plan (multi-op)."
12
- surfaces :cli, :mcp
13
- arg :plan_yaml, String, required: true, positional: true, source: :file,
14
- description: "path to the YAML migration plan (zone_mv, key_mv_prefix, key_delete_prefix ops run in order)"
15
- arg :dry_run, :boolean, default: false,
16
- description: "when true, returns the planned ops without applying them; " \
17
- "defaults to false, so omitting it runs the migration immediately"
18
- view { |v, _i| v.to_h }
19
-
20
- def initialize(container:, call:)
21
- @container = container
22
- @call = call
23
- end
24
-
25
- def call(plan_yaml, dry_run: false)
26
- raw = YAML.safe_load(plan_yaml, permitted_classes: [Symbol], aliases: false)
27
- raise UsageError.new("migration plan must be a YAML mapping") unless raw.is_a?(Hash)
28
-
29
- ops = Array(raw["operations"])
30
- all_steps = []
31
- warnings = []
32
-
33
- ops.each do |op_hash|
34
- op_name = op_hash["op"]
35
- sub_plan = invoke_op(op_name, op_hash, dry_run: dry_run)
36
- all_steps.concat(sub_plan.steps)
37
- warnings.concat(sub_plan.warnings)
38
- end
39
-
40
- Plan.new(steps: all_steps, warnings: warnings)
41
- end
42
-
43
- private
44
-
45
- def invoke_op(op_name, op_hash, dry_run:)
46
- klass = op_class(op_name)
47
- inputs = op_hash.except("op").transform_keys(&:to_sym).merge(dry_run: dry_run)
48
- # Each op now carries positional args (from/to, from_prefix/to_prefix,
49
- # prefix); split the YAML fields into (positional, keyword) via the op's
50
- # own contract so we call its #call signature correctly (ADR 0066/0068).
51
- args, kwargs = Textus::Contract::Binder.bind(klass.contract, inputs)
52
- klass.new(container: @container, call: @call).call(*args, **kwargs)
53
- end
54
-
55
- def op_class(op_name)
56
- case op_name
57
- when "key_mv_prefix" then KeyMvPrefix
58
- when "key_delete_prefix" then KeyDeletePrefix
59
- when "zone_mv" then ZoneMv
60
- else raise UsageError.new("unknown op: #{op_name}")
61
- end
62
- end
63
- end
64
- end
65
- end
@@ -1,17 +0,0 @@
1
- module Textus
2
- module Read
3
- class Retainable
4
- def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
5
- @manifest = container.manifest
6
- end
7
-
8
- def call(prefix: nil, zone: nil)
9
- Textus::Domain::Retention.new(
10
- manifest: @manifest,
11
- file_stat: Textus::Ports::Storage::FileStat.new,
12
- clock: Textus::Ports::Clock,
13
- ).call(prefix: prefix, zone: zone)
14
- end
15
- end
16
- end
17
- end
@@ -1,17 +0,0 @@
1
- module Textus
2
- module Read
3
- class Stale
4
- def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
5
- @manifest = container.manifest
6
- end
7
-
8
- def call(prefix: nil, zone: nil)
9
- Textus::Domain::Staleness.new(
10
- manifest: @manifest,
11
- file_stat: Textus::Ports::Storage::FileStat.new,
12
- clock: Textus::Ports::Clock,
13
- ).call(prefix: prefix, zone: zone)
14
- end
15
- end
16
- end
17
- end
@@ -1,53 +0,0 @@
1
- module Textus
2
- module Write
3
- class FetchAll
4
- extend Textus::Contract::DSL
5
-
6
- verb :fetch_all
7
- summary "Fetch all stale quarantine entries, optionally scoped by zone/prefix."
8
- surfaces :cli, :mcp
9
- cli "fetch all"
10
- arg :prefix, String, description: "only refresh stale entries whose key starts with this dotted prefix"
11
- arg :zone, String, description: "only refresh stale entries in this quarantine zone (see `pulse` stale list)"
12
-
13
- def initialize(container:, call:)
14
- @container = container
15
- @call = call
16
- end
17
-
18
- def call(prefix: nil, zone: nil)
19
- worker = Textus::Write::FetchWorker.new(
20
- container: @container, call: @call,
21
- )
22
-
23
- stale_rows = Textus::Read::Stale.new(container: @container, call: @call).call(prefix: prefix, zone: zone)
24
- fetched = []
25
- failed = []
26
- skipped = []
27
-
28
- stale_rows.each do |row|
29
- key = row["key"] || row[:key]
30
- reason = row["reason"] || row[:reason]
31
- if reason.to_s.match?(/ttl exceeded|never fetched/)
32
- begin
33
- worker.run(key)
34
- fetched << key
35
- rescue Textus::Error => e
36
- failed << { "key" => key, "error" => e.message }
37
- end
38
- else
39
- skipped << { "key" => key, "reason" => reason }
40
- end
41
- end
42
-
43
- {
44
- "protocol" => Textus::PROTOCOL,
45
- "ok" => failed.empty?,
46
- "fetched" => fetched,
47
- "failed" => failed,
48
- "skipped" => skipped,
49
- }
50
- end
51
- end
52
- end
53
- end
@@ -1,64 +0,0 @@
1
- require "fileutils"
2
-
3
- module Textus
4
- module Write
5
- # Applies retention actions reported by Read::Retainable. `expire` deletes
6
- # the leaf through the role gate; `archive` copies it to
7
- # <root>/archive/<relative-path> first, then deletes. Rows whose zone the
8
- # caller's role cannot write surface in `failed` rather than aborting.
9
- class RetentionSweep
10
- extend Textus::Contract::DSL
11
-
12
- verb :retain
13
- summary "Apply each entry's retention policy; prune expired versions."
14
- surfaces :cli
15
- cli "retain"
16
- arg :prefix, String, description: "restrict to keys starting with this dotted prefix"
17
- arg :zone, String, description: "restrict to entries in this zone"
18
-
19
- def initialize(container:, call:)
20
- @container = container
21
- @call = call
22
- end
23
-
24
- def call(prefix: nil, zone: nil)
25
- rows = Textus::Read::Retainable.new(container: @container, call: @call)
26
- .call(prefix: prefix, zone: zone)
27
- delete_op = Textus::Write::Delete.new(container: @container, call: @call)
28
- expired = []
29
- archived = []
30
- failed = []
31
-
32
- rows.each do |row|
33
- key = row["key"]
34
- begin
35
- archive_leaf(row) if row["action"] == "archive"
36
- delete_op.call(key)
37
- (row["action"] == "archive" ? archived : expired) << key
38
- rescue Textus::Error => e
39
- failed << { "key" => key, "error" => e.message }
40
- end
41
- end
42
-
43
- {
44
- "protocol" => Textus::PROTOCOL,
45
- "ok" => failed.empty?,
46
- "expired" => expired,
47
- "archived" => archived,
48
- "failed" => failed,
49
- }
50
- end
51
-
52
- private
53
-
54
- def archive_leaf(row)
55
- src = row["path"]
56
- root = @container.root.to_s
57
- rel = src.delete_prefix("#{root}/")
58
- dest = File.join(root, "archive", rel)
59
- FileUtils.mkdir_p(File.dirname(dest))
60
- FileUtils.cp(src, dest)
61
- end
62
- end
63
- end
64
- end