textus 0.47.1 → 0.49.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/README.md +9 -7
  4. data/SPEC.md +40 -46
  5. data/docs/reference/conventions.md +11 -10
  6. data/lib/textus/boot.rb +2 -2
  7. data/lib/textus/cli/runner.rb +5 -4
  8. data/lib/textus/dispatcher.rb +3 -8
  9. data/lib/textus/doctor/check/generator_drift.rb +28 -0
  10. data/lib/textus/doctor/check/lifecycle_action_invalid.rb +39 -0
  11. data/lib/textus/doctor/check/rule_ambiguity.rb +1 -1
  12. data/lib/textus/doctor.rb +2 -0
  13. data/lib/textus/domain/lifecycle.rb +83 -0
  14. data/lib/textus/domain/policy/base_guards.rb +2 -2
  15. data/lib/textus/domain/policy/lifecycle.rb +35 -0
  16. data/lib/textus/domain/staleness.rb +6 -3
  17. data/lib/textus/envelope/io/writer.rb +2 -2
  18. data/lib/textus/hooks/context.rb +1 -1
  19. data/lib/textus/init.rb +4 -4
  20. data/lib/textus/maintenance/key_delete_prefix.rb +1 -1
  21. data/lib/textus/maintenance/key_mv_prefix.rb +2 -2
  22. data/lib/textus/maintenance/tend.rb +110 -0
  23. data/lib/textus/manifest/rules.rb +11 -23
  24. data/lib/textus/manifest/schema.rb +3 -18
  25. data/lib/textus/ports/audit_log.rb +1 -1
  26. data/lib/textus/ports/audit_subscriber.rb +1 -1
  27. data/lib/textus/ports/fetch/detached.rb +5 -1
  28. data/lib/textus/read/freshness.rb +29 -22
  29. data/lib/textus/read/get.rb +47 -32
  30. data/lib/textus/read/pulse.rb +1 -1
  31. data/lib/textus/read/rule_explain.rb +10 -16
  32. data/lib/textus/read/rule_list.rb +5 -7
  33. data/lib/textus/version.rb +1 -1
  34. data/lib/textus/write/accept.rb +1 -1
  35. data/lib/textus/write/fetch_worker.rb +8 -12
  36. data/lib/textus/write/{delete.rb → key_delete.rb} +3 -3
  37. data/lib/textus/write/{mv.rb → key_mv.rb} +4 -4
  38. data/lib/textus/write/reject.rb +1 -1
  39. metadata +8 -15
  40. data/lib/textus/cli/group/fetch.rb +0 -20
  41. data/lib/textus/cli/verb/fetch.rb +0 -14
  42. data/lib/textus/cli/verb/fetch_all.rb +0 -20
  43. data/lib/textus/domain/policy/fetch.rb +0 -37
  44. data/lib/textus/domain/policy/retention.rb +0 -26
  45. data/lib/textus/domain/retention.rb +0 -44
  46. data/lib/textus/domain/staleness/intake_check.rb +0 -54
  47. data/lib/textus/maintenance/migrate.rb +0 -65
  48. data/lib/textus/read/retainable.rb +0 -17
  49. data/lib/textus/read/stale.rb +0 -17
  50. data/lib/textus/write/fetch_all.rb +0 -53
  51. data/lib/textus/write/retention_sweep.rb +0 -64
@@ -2,42 +2,39 @@ module Textus
2
2
  module Read
3
3
  # The one read path. `fetch:` controls behavior:
4
4
  # fetch: false (default) — pure read: the on-disk envelope annotated with
5
- # a freshness verdict. NEVER builds the orchestrator (no threads/forks/
6
- # locks/events). This is the safe default for direct (in-process)
7
- # callers — accept/reject/publish, materializer, uid, validate_all/
8
- # validator, schema tooling, and the hook context that must read
9
- # persisted truth without triggering a fetch.
10
- # fetch: trueread-through: after a stale verdict, hands off to the
11
- # fetch orchestrator per the entry's fetch rule (degrades to the pure
12
- # result when the key has no rule).
5
+ # a lifecycle freshness verdict. NEVER builds the orchestrator and NEVER
6
+ # mutates. Safe for direct callers (accept/reject/publish, materializer,
7
+ # uid, validators, hooks).
8
+ # fetch: true read-through: after a stale verdict on a `refresh` policy,
9
+ # hands off to the fetch orchestrator. A read NEVER performs a
10
+ # destructive action (drop/archive) those belong to the `tend` sweep
11
+ # (ADR 0079).
13
12
  #
14
- # The public `get` verb is read-through because the contract declares
15
- # `arg :fetch, default: true`, injected on every verb surface (RoleScope +
16
- # MCP map_args, ADR 0062 amendment). Direct construction bypasses that
17
- # injection and so gets the safe `fetch: false` method default.
13
+ # Lifecycle policy comes from the unified `lifecycle:` rule slot (ADR 0079).
18
14
  class Get
19
15
  extend Textus::Contract::DSL
20
16
 
21
17
  verb :get
22
- summary "Read one entry. Read-through by default — fetches on stale per " \
23
- "the entry's fetch rule, degrading to a pure read when the key " \
24
- "has no rule. Pass fetch:false for a guaranteed pure on-disk " \
25
- "read. Returns the envelope (uid, etag, _meta, body, freshness)."
18
+ summary "Read one entry. Read-through by default — refreshes on stale per " \
19
+ "the entry's lifecycle rule (on_expire: refresh), degrading to a " \
20
+ "pure read when the key has no rule. Pass fetch:false for a " \
21
+ "guaranteed pure on-disk read. Returns the envelope (uid, etag, " \
22
+ "_meta, body, freshness)."
26
23
  surfaces :cli, :mcp
27
24
  arg :key, String, required: true, positional: true,
28
25
  description: "dotted entry key to read, e.g. 'knowledge.project'"
29
26
  arg :fetch, :boolean, default: true,
30
- description: "read-through (fetch on stale per the " \
31
- "entry's fetch rule) when true, the default; " \
27
+ description: "read-through (refresh on stale per the " \
28
+ "entry's lifecycle rule) when true, the default; " \
32
29
  "false returns the on-disk envelope without ever fetching"
33
30
  view { |v, _i| v.to_h_for_wire }
34
31
 
35
- def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator, orchestrator: nil)
32
+ def initialize(container:, call:, orchestrator: nil, file_stat: Textus::Ports::Storage::FileStat.new)
36
33
  @container = container
37
34
  @call = call
38
35
  @manifest = container.manifest
39
36
  @file_store = container.file_store
40
- @evaluator = evaluator
37
+ @file_stat = file_stat
41
38
  @orchestrator = orchestrator # nil → built lazily on first fetch only
42
39
  end
43
40
 
@@ -46,12 +43,11 @@ module Textus
46
43
  return envelope if envelope.nil?
47
44
  return envelope unless fetch && envelope.freshness&.stale
48
45
 
49
- fetch_policy = fetch_policy_for(key)
50
- return envelope if fetch_policy.nil?
46
+ policy = lifecycle_for(key)
47
+ return envelope unless policy&.on_expire == :refresh # only refresh acts on a read
51
48
 
52
- policy = fetch_policy.to_freshness_policy
53
49
  verdict = Textus::Domain::Freshness::Verdict.stale(envelope.freshness.reason)
54
- outcome = orchestrator.execute(policy.decide(verdict), key: key)
50
+ outcome = orchestrator.execute(refresh_policy(policy).decide(verdict), key: key)
55
51
  resolve(outcome, envelope)
56
52
  end
57
53
 
@@ -64,23 +60,42 @@ module Textus
64
60
 
65
61
  private
66
62
 
67
- # Pure read + freshness verdict; no orchestrator dependency.
63
+ # Pure read + unified lifecycle verdict; no orchestrator dependency.
68
64
  def annotated_envelope(key)
69
65
  envelope = read_raw_envelope(key)
70
66
  return nil if envelope.nil?
71
67
 
72
- fetch_policy = fetch_policy_for(key)
73
- return annotate_fresh(envelope) if fetch_policy.nil?
68
+ policy = lifecycle_for(key)
69
+ return annotate_fresh(envelope) if policy.nil?
74
70
 
75
- policy = fetch_policy.to_freshness_policy
76
- verdict = @evaluator.call(policy, envelope, now: @call.now)
71
+ expired, reason = Textus::Domain::Lifecycle.verdict(
72
+ policy: policy,
73
+ last_fetched_at: envelope.meta&.dig("last_fetched_at"),
74
+ mtime: mtime_for(key),
75
+ now: @call.now,
76
+ )
77
77
  envelope.with(freshness: Textus::Domain::Freshness.build(
78
- stale: verdict.stale?, reason: verdict.reason, fetching: false,
78
+ stale: expired, reason: reason, fetching: false,
79
79
  ))
80
80
  end
81
81
 
82
- def fetch_policy_for(key)
83
- @manifest.rules.for(key).fetch
82
+ def lifecycle_for(key)
83
+ @manifest.rules.for(key).lifecycle
84
+ end
85
+
86
+ def refresh_policy(policy)
87
+ Textus::Domain::Freshness::Policy.new(
88
+ ttl_seconds: policy.ttl_seconds,
89
+ on_stale: policy.budget_ms ? :timed_sync : :sync,
90
+ sync_budget_ms: policy.budget_ms,
91
+ )
92
+ end
93
+
94
+ def mtime_for(key)
95
+ path = @manifest.resolver.resolve(key).path
96
+ @file_stat.exists?(path) ? @file_stat.mtime(path) : nil
97
+ rescue Textus::Error
98
+ nil
84
99
  end
85
100
 
86
101
  def resolve(outcome, envelope)
@@ -30,7 +30,7 @@ module Textus
30
30
  {
31
31
  "cursor" => @audit_log.latest_seq,
32
32
  "changed" => audit_changes_since(since),
33
- "stale" => freshness_rows.select { |r| r[:status] == :stale }.map { |r| r[:key] },
33
+ "stale" => freshness_rows.select { |r| r[:status] == :expired }.map { |r| r[:key] },
34
34
  "pending_review" => review_keys,
35
35
  "doctor" => doctor_summary,
36
36
  "contract_etag" => contract_etag,
@@ -1,7 +1,7 @@
1
1
  module Textus
2
2
  module Read
3
3
  # Effective rules for a key, at two depths (ADR 0059). Lean by default —
4
- # `{ fetch, guard }`, the agent-cheap read that was the `rules` verb. With
4
+ # `{ lifecycle, guard }`, the agent-cheap read that was the `rules` verb. With
5
5
  # `detail: true` it returns the verbose explanation — every matching policy
6
6
  # block plus the per-transition guard predicate names — that was
7
7
  # `policy_explain`. One verb, one name across CLI/MCP/method; the audience
@@ -10,7 +10,7 @@ module Textus
10
10
  extend Textus::Contract::DSL
11
11
 
12
12
  verb :rule_explain
13
- summary "Effective rules for a key. Lean {fetch, guard} by default; detail: true adds matched blocks + guard predicates."
13
+ summary "Effective rules for a key. Lean {lifecycle, guard} by default; detail: true adds matched blocks + guard predicates."
14
14
  surfaces :cli, :mcp
15
15
  cli "rule explain"
16
16
  arg :key, String, required: true, positional: true,
@@ -34,11 +34,10 @@ module Textus
34
34
  def effective(key)
35
35
  set = @manifest.rules.for(key)
36
36
  {
37
- "fetch" => set.fetch && {
38
- "ttl_seconds" => set.fetch.ttl_seconds,
39
- "on_stale" => set.fetch.on_stale,
40
- "sync_budget_ms" => set.fetch.sync_budget_ms,
41
- "fetch_timeout_seconds" => set.fetch.fetch_timeout_seconds,
37
+ "lifecycle" => set.lifecycle && {
38
+ "ttl_seconds" => set.lifecycle.ttl_seconds,
39
+ "on_expire" => set.lifecycle.on_expire,
40
+ "budget_ms" => set.lifecycle.budget_ms,
42
41
  },
43
42
  "guard" => set.guard,
44
43
  }.compact
@@ -57,22 +56,17 @@ module Textus
57
56
  matched_blocks: matching.map do |b|
58
57
  {
59
58
  match: b.match,
60
- fetch: !b.fetch.nil?,
59
+ lifecycle: !b.lifecycle.nil?,
61
60
  handler_allowlist: !b.handler_allowlist.nil?,
62
61
  guard: !b.guard.nil?,
63
- retention: !b.retention.nil?,
64
62
  }
65
63
  end,
66
64
  effective: {
67
- fetch: winners.fetch && {
68
- ttl_seconds: winners.fetch.ttl_seconds,
69
- on_stale: winners.fetch.on_stale,
65
+ lifecycle: winners.lifecycle && {
66
+ ttl_seconds: winners.lifecycle.ttl_seconds,
67
+ on_expire: winners.lifecycle.on_expire,
70
68
  },
71
69
  handler_allowlist: winners.handler_allowlist&.handlers,
72
- retention: winners.retention && {
73
- expire_after: winners.retention.expire_after,
74
- archive_after: winners.retention.archive_after,
75
- },
76
70
  },
77
71
  guards: Textus::Domain::Policy::BaseGuards::BASE.keys.to_h do |transition|
78
72
  [transition, factory.for(transition, key).predicates.map(&:name)]
@@ -20,17 +20,15 @@ module Textus
20
20
  def call
21
21
  @manifest.rules.blocks.map do |b|
22
22
  row = { "match" => b.match }
23
- if b.fetch
24
- row["fetch"] = {
25
- "ttl_seconds" => b.fetch.ttl_seconds,
26
- "on_stale" => b.fetch.on_stale,
27
- "sync_budget_ms" => b.fetch.sync_budget_ms,
28
- "fetch_timeout_seconds" => b.fetch.fetch_timeout_seconds,
23
+ if b.lifecycle
24
+ row["lifecycle"] = {
25
+ "ttl_seconds" => b.lifecycle.ttl_seconds,
26
+ "on_expire" => b.lifecycle.on_expire,
27
+ "budget_ms" => b.lifecycle.budget_ms,
29
28
  }
30
29
  end
31
30
  row["handler_allowlist"] = b.handler_allowlist.handlers if b.handler_allowlist
32
31
  row["guard"] = b.guard if b.guard
33
- row["retention"] = { "expire_after" => b.retention.expire_after, "archive_after" => b.retention.archive_after } if b.retention
34
32
  row
35
33
  end
36
34
  end
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.47.1"
2
+ VERSION = "0.49.0"
3
3
  PROTOCOL = "textus/3"
4
4
  end
@@ -52,7 +52,7 @@ module Textus
52
52
 
53
53
  def hook_context = @hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
54
54
  def put_op = @put_op ||= Textus::Write::Put.new(container: @container, call: @call)
55
- def delete_op = @delete_op ||= Textus::Write::Delete.new(container: @container, call: @call)
55
+ def delete_op = @delete_op ||= Textus::Write::KeyDelete.new(container: @container, call: @call)
56
56
  end
57
57
  end
58
58
  end
@@ -2,16 +2,10 @@ require "timeout"
2
2
 
3
3
  module Textus
4
4
  module Write
5
+ # Internal fetch executor for one quarantine/intake entry. No longer a
6
+ # public verb (ADR 0079 collapsed the `fetch` surface): used by `get`'s
7
+ # orchestrator (read-through refresh) and by the `tend` sweep.
5
8
  class FetchWorker
6
- extend Textus::Contract::DSL
7
-
8
- verb :fetch
9
- summary "Run a fetch action for one quarantine entry."
10
- surfaces :cli, :mcp
11
- arg :key, String, required: true, positional: true,
12
- description: "quarantine-zone entry key to refresh using its declared intake action"
13
- view { |outcome| { "outcome" => outcome.class.name.split("::").last.downcase } }
14
-
15
9
  FETCH_TIMEOUT_SECONDS = IntakeFetch::FETCH_TIMEOUT_SECONDS
16
10
 
17
11
  def initialize(container:, call:)
@@ -69,9 +63,11 @@ module Textus
69
63
  @fetch_events ||= FetchEvents.from(container: @container, call: @call)
70
64
  end
71
65
 
72
- def fetch_timeout_for(key)
73
- rule = @manifest.rules.for(key)
74
- rule&.fetch&.fetch_timeout_seconds || FETCH_TIMEOUT_SECONDS
66
+ # ADR 0079: a per-rule fetch_timeout_seconds override was an accepted loss
67
+ # in the fetch:/retention: → lifecycle: collapse; the constant ceiling
68
+ # applies to every intake.
69
+ def fetch_timeout_for(_key)
70
+ FETCH_TIMEOUT_SECONDS
75
71
  end
76
72
 
77
73
  def fetch_with_events(key, mentry, remaining)
@@ -1,9 +1,9 @@
1
1
  module Textus
2
2
  module Write
3
- class Delete
3
+ class KeyDelete
4
4
  extend Textus::Contract::DSL
5
5
 
6
- verb :delete
6
+ verb :key_delete
7
7
  summary "Delete one entry by key. Single-key, lower blast radius than " \
8
8
  "key_delete_prefix; guarded by an optional optimistic-concurrency etag. Returns {ok, key, deleted}."
9
9
  surfaces :cli, :mcp
@@ -25,7 +25,7 @@ module Textus
25
25
  Textus::Manifest::Data.validate_key!(key)
26
26
  mentry = @manifest.resolver.resolve(key).entry
27
27
 
28
- guard_for(:delete, key, if_etag: if_etag).check!(eval_for(:delete, target_key: key))
28
+ guard_for(:key_delete, key, if_etag: if_etag).check!(eval_for(:key_delete, target_key: key))
29
29
 
30
30
  writer.delete(key, mentry: mentry, if_etag: if_etag)
31
31
 
@@ -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.49.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