textus 0.29.0 → 0.35.1
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.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +2 -235
- data/CHANGELOG.md +169 -0
- data/README.md +85 -64
- data/SPEC.md +366 -201
- data/docs/conventions.md +42 -37
- data/lib/textus/boot.rb +93 -76
- data/lib/textus/cli/group/{refresh.rb → fetch.rb} +4 -4
- data/lib/textus/cli/verb/build.rb +1 -1
- data/lib/textus/cli/verb/fetch.rb +14 -0
- data/lib/textus/cli/verb/{refresh_stale.rb → fetch_stale.rb} +3 -3
- data/lib/textus/cli/verb/get.rb +1 -1
- data/lib/textus/cli/verb/hook_run.rb +2 -6
- data/lib/textus/cli/verb/hooks.rb +1 -1
- data/lib/textus/cli/verb/put.rb +5 -14
- data/lib/textus/cli/verb/retain.rb +19 -0
- data/lib/textus/cli/verb/rule_list.rb +8 -8
- data/lib/textus/cli.rb +21 -18
- data/lib/textus/container.rb +1 -2
- data/lib/textus/dispatcher.rb +11 -3
- data/lib/textus/doctor/check/{refresh_locks.rb → fetch_locks.rb} +7 -7
- data/lib/textus/doctor/check/proposal_targets.rb +45 -0
- data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
- data/lib/textus/doctor/check.rb +8 -5
- data/lib/textus/doctor.rb +2 -1
- data/lib/textus/domain/action.rb +3 -3
- data/lib/textus/domain/duration.rb +22 -0
- data/lib/textus/domain/freshness/evaluator.rb +3 -3
- data/lib/textus/domain/freshness/policy.rb +2 -2
- data/lib/textus/domain/freshness.rb +7 -7
- data/lib/textus/domain/outcome.rb +2 -2
- data/lib/textus/domain/permission.rb +2 -10
- data/lib/textus/domain/policy/base_guards.rb +25 -0
- data/lib/textus/domain/policy/evaluation.rb +18 -0
- data/lib/textus/domain/policy/{refresh.rb → fetch.rb} +2 -16
- data/lib/textus/domain/policy/guard.rb +35 -0
- data/lib/textus/domain/policy/guard_factory.rb +40 -0
- data/lib/textus/domain/policy/predicates/author_held.rb +33 -0
- data/lib/textus/domain/policy/predicates/etag_match.rb +32 -0
- data/lib/textus/domain/policy/predicates/fresh_within.rb +58 -0
- data/lib/textus/domain/policy/predicates/registry.rb +39 -0
- data/lib/textus/domain/policy/predicates/schema_valid.rb +30 -19
- data/lib/textus/domain/policy/predicates/target_is_canon.rb +33 -0
- data/lib/textus/domain/policy/predicates/zone_writable_by.rb +39 -0
- data/lib/textus/domain/policy/retention.rb +26 -0
- data/lib/textus/domain/retention.rb +44 -0
- data/lib/textus/domain/staleness/intake_check.rb +6 -6
- data/lib/textus/envelope/io/reader.rb +4 -0
- data/lib/textus/envelope/io/writer.rb +8 -0
- data/lib/textus/envelope.rb +2 -2
- data/lib/textus/errors.rb +25 -28
- data/lib/textus/hooks/event_bus.rb +12 -24
- data/lib/textus/hooks/rpc_registry.rb +9 -35
- data/lib/textus/hooks/signature.rb +31 -0
- data/lib/textus/init.rb +24 -18
- data/lib/textus/maintenance/zone_mv.rb +1 -1
- data/lib/textus/manifest/capabilities.rb +29 -0
- data/lib/textus/manifest/data.rb +16 -8
- data/lib/textus/manifest/entry/base.rb +2 -2
- data/lib/textus/manifest/policy.rb +62 -19
- data/lib/textus/manifest/rules.rb +25 -14
- data/lib/textus/manifest/schema.rb +78 -38
- data/lib/textus/manifest.rb +6 -5
- data/lib/textus/mcp/server.rb +2 -10
- data/lib/textus/mcp/session.rb +7 -23
- data/lib/textus/mcp/tool_schemas.rb +3 -3
- data/lib/textus/mcp/tools.rb +7 -7
- data/lib/textus/ports/audit_subscriber.rb +1 -1
- data/lib/textus/ports/{refresh → fetch}/detached.rb +4 -4
- data/lib/textus/ports/{refresh → fetch}/lock.rb +1 -1
- data/lib/textus/projection.rb +1 -1
- data/lib/textus/read/freshness.rb +9 -9
- data/lib/textus/read/get.rb +8 -8
- data/lib/textus/read/{get_or_refresh.rb → get_or_fetch.rb} +11 -11
- data/lib/textus/read/policy_explain.rb +19 -10
- data/lib/textus/read/pulse.rb +5 -4
- data/lib/textus/read/retainable.rb +17 -0
- data/lib/textus/read/validator.rb +1 -1
- data/lib/textus/role_scope.rb +3 -2
- data/lib/textus/schema/tools.rb +5 -5
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +19 -55
- data/lib/textus/write/delete.rb +15 -17
- data/lib/textus/write/{refresh_all.rb → fetch_all.rb} +6 -6
- data/lib/textus/write/{refresh_orchestrator.rb → fetch_orchestrator.rb} +14 -14
- data/lib/textus/write/{refresh_worker.rb → fetch_worker.rb} +23 -30
- data/lib/textus/write/intake_fetch.rb +23 -0
- data/lib/textus/write/mv.rb +17 -15
- data/lib/textus/write/put.rb +15 -17
- data/lib/textus/write/reject.rb +11 -5
- data/lib/textus/write/retention_sweep.rb +55 -0
- metadata +32 -18
- data/lib/textus/cli/verb/refresh.rb +0 -14
- data/lib/textus/domain/authorizer.rb +0 -37
- data/lib/textus/domain/policy/predicates/accept_authority_signed.rb +0 -33
- data/lib/textus/domain/policy/promote.rb +0 -26
- data/lib/textus/domain/policy/promotion.rb +0 -57
- data/lib/textus/manifest/role_kinds.rb +0 -21
- data/lib/textus/write/authority_gate.rb +0 -24
data/lib/textus/projection.rb
CHANGED
|
@@ -8,7 +8,7 @@ module Textus
|
|
|
8
8
|
|
|
9
9
|
# `reader` — a callable `->(key) { envelope_or_nil }`. Caller picks
|
|
10
10
|
# semantics: pure read (`ops.get`) for materialization paths;
|
|
11
|
-
# `ops.
|
|
11
|
+
# `ops.get_or_fetch` if you want fetch-on-stale.
|
|
12
12
|
# `lister` — a callable `->(prefix:) { [ { "key" => ... }, ... ] }`.
|
|
13
13
|
# `rpc` — a `Hooks::RpcRegistry` used to dispatch `transform_rows` callables.
|
|
14
14
|
# `transform_context` — capability object handed to transform reducers as `caps:`.
|
|
@@ -3,8 +3,8 @@ require "time"
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Read
|
|
5
5
|
# Per-entry freshness report. Walks every entry declared in the manifest,
|
|
6
|
-
# consults `rules_for(key)` for a
|
|
7
|
-
# current status. Status is one of :fresh, :stale, :
|
|
6
|
+
# consults `rules_for(key)` for a fetch rule, and reports the
|
|
7
|
+
# current status. Status is one of :fresh, :stale, :never_fetched, or
|
|
8
8
|
# :no_policy.
|
|
9
9
|
class Freshness
|
|
10
10
|
def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator)
|
|
@@ -16,7 +16,7 @@ module Textus
|
|
|
16
16
|
@cache = {}
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
# Returns the soonest `next_due_at` across all entries with a
|
|
19
|
+
# Returns the soonest `next_due_at` across all entries with a fetch
|
|
20
20
|
# policy, as an ISO-8601 string, or nil if none.
|
|
21
21
|
def soonest_due(prefix: nil, zone: nil)
|
|
22
22
|
times = call(prefix: prefix, zone: zone)
|
|
@@ -43,17 +43,17 @@ module Textus
|
|
|
43
43
|
|
|
44
44
|
def row_for(mentry)
|
|
45
45
|
set = @manifest.rules.for(mentry.key)
|
|
46
|
-
|
|
46
|
+
fetch = set.fetch
|
|
47
47
|
envelope = safe_get(mentry.key)
|
|
48
|
-
last = envelope&.meta&.dig("
|
|
48
|
+
last = envelope&.meta&.dig("last_fetched_at")
|
|
49
49
|
|
|
50
|
-
return base_row(mentry, last).merge(status: :no_policy) if
|
|
50
|
+
return base_row(mentry, last).merge(status: :no_policy) if fetch.nil?
|
|
51
51
|
|
|
52
|
-
fp =
|
|
52
|
+
fp = fetch.to_freshness_policy
|
|
53
53
|
cache_key = [mentry.key, last]
|
|
54
54
|
verdict = (@cache[cache_key] ||= @evaluator.call(fp, envelope, now: @call.now))
|
|
55
55
|
status = if verdict.fresh? then :fresh
|
|
56
|
-
elsif last.nil? then :
|
|
56
|
+
elsif last.nil? then :never_fetched
|
|
57
57
|
else :stale
|
|
58
58
|
end
|
|
59
59
|
|
|
@@ -69,7 +69,7 @@ module Textus
|
|
|
69
69
|
{
|
|
70
70
|
key: mentry.key,
|
|
71
71
|
zone: mentry.zone,
|
|
72
|
-
|
|
72
|
+
last_fetched_at: last,
|
|
73
73
|
age_seconds: last ? (@call.now - Time.parse(last)).to_i : nil,
|
|
74
74
|
}
|
|
75
75
|
end
|
data/lib/textus/read/get.rb
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Read
|
|
3
3
|
# Pure read: returns the on-disk envelope annotated with a freshness
|
|
4
|
-
# verdict. Never triggers
|
|
4
|
+
# verdict. Never triggers fetch; never invokes the orchestrator.
|
|
5
5
|
#
|
|
6
|
-
# For interactive reads that want
|
|
7
|
-
# `Read::
|
|
6
|
+
# For interactive reads that want fetch-on-stale, use
|
|
7
|
+
# `Read::GetOrFetch`, which composes this with the orchestrator.
|
|
8
8
|
class Get
|
|
9
9
|
def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator)
|
|
10
10
|
@container = container
|
|
@@ -19,16 +19,16 @@ module Textus
|
|
|
19
19
|
return nil if envelope.nil?
|
|
20
20
|
|
|
21
21
|
policy_set = @manifest.rules.for(key)
|
|
22
|
-
|
|
23
|
-
return annotate_fresh(envelope) if
|
|
22
|
+
fetch_policy = policy_set.fetch
|
|
23
|
+
return annotate_fresh(envelope) if fetch_policy.nil?
|
|
24
24
|
|
|
25
|
-
policy =
|
|
25
|
+
policy = fetch_policy.to_freshness_policy
|
|
26
26
|
verdict = @evaluator.call(policy, envelope, now: @call.now)
|
|
27
27
|
|
|
28
28
|
envelope.with(freshness: Textus::Domain::Freshness.build(
|
|
29
29
|
stale: verdict.stale?,
|
|
30
30
|
reason: verdict.reason,
|
|
31
|
-
|
|
31
|
+
fetching: false,
|
|
32
32
|
))
|
|
33
33
|
end
|
|
34
34
|
|
|
@@ -58,7 +58,7 @@ module Textus
|
|
|
58
58
|
|
|
59
59
|
def annotate_fresh(envelope)
|
|
60
60
|
envelope.with(freshness: Textus::Domain::Freshness.build(
|
|
61
|
-
stale: false, reason: nil,
|
|
61
|
+
stale: false, reason: nil, fetching: false,
|
|
62
62
|
))
|
|
63
63
|
end
|
|
64
64
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Read
|
|
3
|
-
# Composes pure `Read::Get` with the
|
|
3
|
+
# Composes pure `Read::Get` with the fetch orchestrator: runs Get
|
|
4
4
|
# to obtain the envelope and freshness verdict, then if the verdict
|
|
5
5
|
# is stale and the rule's `on_stale` policy demands action, hands
|
|
6
6
|
# off to the orchestrator. Use for interactive reads where the
|
|
@@ -8,7 +8,7 @@ module Textus
|
|
|
8
8
|
#
|
|
9
9
|
# Pure reads (build, projection, schema tooling) should use
|
|
10
10
|
# `Read::Get` directly; it has no orchestrator dependency.
|
|
11
|
-
class
|
|
11
|
+
class GetOrFetch
|
|
12
12
|
def initialize(container:, call:, get: nil, orchestrator: nil)
|
|
13
13
|
@container = container
|
|
14
14
|
@call = call
|
|
@@ -24,10 +24,10 @@ module Textus
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def build_orchestrator
|
|
27
|
-
worker = Textus::Write::
|
|
27
|
+
worker = Textus::Write::FetchWorker.new(
|
|
28
28
|
container: @container, call: @call,
|
|
29
29
|
)
|
|
30
|
-
Textus::Write::
|
|
30
|
+
Textus::Write::FetchOrchestrator.new(
|
|
31
31
|
worker: worker, store_root: @container.root, events: @container.events,
|
|
32
32
|
hook_context: hook_context
|
|
33
33
|
)
|
|
@@ -41,10 +41,10 @@ module Textus
|
|
|
41
41
|
return envelope unless envelope.freshness&.stale
|
|
42
42
|
|
|
43
43
|
policy_set = @manifest.rules.for(key)
|
|
44
|
-
|
|
45
|
-
return envelope if
|
|
44
|
+
fetch_policy = policy_set.fetch
|
|
45
|
+
return envelope if fetch_policy.nil?
|
|
46
46
|
|
|
47
|
-
policy =
|
|
47
|
+
policy = fetch_policy.to_freshness_policy
|
|
48
48
|
verdict = Textus::Domain::Freshness::Verdict.stale(envelope.freshness.reason)
|
|
49
49
|
action = policy.decide(verdict)
|
|
50
50
|
outcome = @orchestrator.execute(action, key: key)
|
|
@@ -52,15 +52,15 @@ module Textus
|
|
|
52
52
|
case outcome
|
|
53
53
|
when Textus::Domain::Outcome::Skipped
|
|
54
54
|
envelope
|
|
55
|
-
when Textus::Domain::Outcome::
|
|
55
|
+
when Textus::Domain::Outcome::Fetched
|
|
56
56
|
outcome.envelope.with(
|
|
57
|
-
freshness: Textus::Domain::Freshness.build(stale: false, reason: nil,
|
|
57
|
+
freshness: Textus::Domain::Freshness.build(stale: false, reason: nil, fetching: false),
|
|
58
58
|
)
|
|
59
59
|
when Textus::Domain::Outcome::Detached
|
|
60
|
-
envelope.with(freshness: envelope.freshness.with(
|
|
60
|
+
envelope.with(freshness: envelope.freshness.with(fetching: true))
|
|
61
61
|
when Textus::Domain::Outcome::Failed
|
|
62
62
|
envelope.with(
|
|
63
|
-
freshness: envelope.freshness.with(
|
|
63
|
+
freshness: envelope.freshness.with(fetch_error: outcome.error.message),
|
|
64
64
|
)
|
|
65
65
|
end
|
|
66
66
|
end
|
|
@@ -1,35 +1,44 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Read
|
|
3
3
|
# For one key, surface every matching policy block along with the
|
|
4
|
-
# per-slot effective value (which loses ties win-by-specificity)
|
|
4
|
+
# per-slot effective value (which loses ties win-by-specificity) and the
|
|
5
|
+
# effective guard predicate names for every write transition (ADR 0031).
|
|
5
6
|
class PolicyExplain
|
|
6
7
|
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
7
8
|
@manifest = container.manifest
|
|
9
|
+
@schemas = container.schemas
|
|
8
10
|
end
|
|
9
11
|
|
|
10
12
|
def call(key:)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
matching = @manifest.rules.explain(key)
|
|
14
|
+
winners = @manifest.rules.for(key)
|
|
15
|
+
factory = Textus::Domain::Policy::GuardFactory.new(manifest: @manifest, schemas: @schemas)
|
|
14
16
|
|
|
15
17
|
{
|
|
16
18
|
key: key,
|
|
17
19
|
matched_blocks: matching.map do |b|
|
|
18
20
|
{
|
|
19
21
|
match: b.match,
|
|
20
|
-
|
|
22
|
+
fetch: !b.fetch.nil?,
|
|
21
23
|
handler_allowlist: !b.handler_allowlist.nil?,
|
|
22
|
-
|
|
24
|
+
guard: !b.guard.nil?,
|
|
25
|
+
retention: !b.retention.nil?,
|
|
23
26
|
}
|
|
24
27
|
end,
|
|
25
28
|
effective: {
|
|
26
|
-
|
|
27
|
-
ttl_seconds: winners.
|
|
28
|
-
on_stale: winners.
|
|
29
|
+
fetch: winners.fetch && {
|
|
30
|
+
ttl_seconds: winners.fetch.ttl_seconds,
|
|
31
|
+
on_stale: winners.fetch.on_stale,
|
|
29
32
|
},
|
|
30
33
|
handler_allowlist: winners.handler_allowlist&.handlers,
|
|
31
|
-
|
|
34
|
+
retention: winners.retention && {
|
|
35
|
+
expire_after: winners.retention.expire_after,
|
|
36
|
+
archive_after: winners.retention.archive_after,
|
|
37
|
+
},
|
|
32
38
|
},
|
|
39
|
+
guards: Textus::Domain::Policy::BaseGuards::BASE.keys.to_h do |transition|
|
|
40
|
+
[transition, factory.for(transition, key).predicates.map(&:name)]
|
|
41
|
+
end,
|
|
33
42
|
}
|
|
34
43
|
end
|
|
35
44
|
end
|
data/lib/textus/read/pulse.rb
CHANGED
|
@@ -49,11 +49,12 @@ module Textus
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def review_keys
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
|
|
52
|
+
# The single queue zone (kind: queue; schema guarantees ≤1), derived
|
|
53
|
+
# from the manifest rather than a hardcoded zone name (ADR 0034 / D1).
|
|
54
|
+
queue = @manifest.policy.queue_zone
|
|
55
|
+
return [] unless queue
|
|
55
56
|
|
|
56
|
-
rows = Read::List.new(container: @container).call(zone:
|
|
57
|
+
rows = Read::List.new(container: @container).call(zone: queue)
|
|
57
58
|
rows.map { |r| r.is_a?(Hash) ? (r["key"] || r[:key]) : r }
|
|
58
59
|
end
|
|
59
60
|
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
|
@@ -54,7 +54,7 @@ module Textus
|
|
|
54
54
|
last_writer = @audit_log.last_writer_for(key)
|
|
55
55
|
return if last_writer.nil?
|
|
56
56
|
|
|
57
|
-
last_writer_is_authority = @manifest.policy.
|
|
57
|
+
last_writer_is_authority = @manifest.policy.roles_with_capability("author").include?(last_writer)
|
|
58
58
|
|
|
59
59
|
env.meta.each_key do |field|
|
|
60
60
|
owner = schema.maintained_by(field)
|
data/lib/textus/role_scope.rb
CHANGED
|
@@ -38,11 +38,12 @@ module Textus
|
|
|
38
38
|
|
|
39
39
|
Textus::Dispatcher::VERBS.each_key do |verb|
|
|
40
40
|
define_method(verb) do |*args, **kwargs|
|
|
41
|
-
klass = Textus::Dispatcher.fetch(verb)
|
|
42
41
|
call_value = Textus::Call.build(
|
|
43
42
|
role: @role, correlation_id: @correlation_id, dry_run: @dry_run,
|
|
44
43
|
)
|
|
45
|
-
|
|
44
|
+
Textus::Dispatcher.invoke(
|
|
45
|
+
verb, container: @container, call: call_value, args: args, kwargs: kwargs
|
|
46
|
+
)
|
|
46
47
|
end
|
|
47
48
|
end
|
|
48
49
|
end
|
data/lib/textus/schema/tools.rb
CHANGED
|
@@ -49,7 +49,7 @@ module Textus
|
|
|
49
49
|
end
|
|
50
50
|
raise UsageError.new("schema migrate needs --rename=OLD:NEW or schema.evolution.migrate_from") if renames.empty?
|
|
51
51
|
|
|
52
|
-
authority =
|
|
52
|
+
authority = accept_role_for(store)
|
|
53
53
|
ops = store.as(authority)
|
|
54
54
|
touched = []
|
|
55
55
|
store.manifest.resolver.enumerate.each do |row|
|
|
@@ -87,13 +87,13 @@ module Textus
|
|
|
87
87
|
raise UsageError.new("schema not found: #{name}")
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
-
def self.
|
|
91
|
-
authority = store.manifest.policy.
|
|
90
|
+
def self.accept_role_for(store)
|
|
91
|
+
authority = store.manifest.policy.roles_with_capability("author").first
|
|
92
92
|
return authority if authority
|
|
93
93
|
|
|
94
94
|
raise UsageError.new(
|
|
95
|
-
"schema migrate requires a role
|
|
96
|
-
"none declared (add e.g. `- { name: owner,
|
|
95
|
+
"schema migrate requires a role holding the 'author' capability; " \
|
|
96
|
+
"none declared (add e.g. `- { name: owner, can: [author] }` to roles:)",
|
|
97
97
|
)
|
|
98
98
|
end
|
|
99
99
|
end
|
data/lib/textus/version.rb
CHANGED
data/lib/textus/write/accept.rb
CHANGED
|
@@ -1,37 +1,30 @@
|
|
|
1
|
-
require_relative "authority_gate"
|
|
2
|
-
|
|
3
1
|
module Textus
|
|
4
2
|
module Write
|
|
5
3
|
class Accept
|
|
6
|
-
include AuthorityGate
|
|
7
|
-
|
|
8
4
|
def initialize(container:, call:)
|
|
9
|
-
@container
|
|
10
|
-
@call
|
|
11
|
-
@manifest
|
|
12
|
-
@schemas
|
|
13
|
-
@events
|
|
5
|
+
@container = container
|
|
6
|
+
@call = call
|
|
7
|
+
@manifest = container.manifest
|
|
8
|
+
@schemas = container.schemas
|
|
9
|
+
@events = container.events
|
|
14
10
|
end
|
|
15
11
|
|
|
16
12
|
def call(pending_key)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
env = Textus::Read::Get.new(
|
|
20
|
-
container: @container, call: @call,
|
|
21
|
-
).call(pending_key)
|
|
13
|
+
env = Textus::Read::Get.new(container: @container, call: @call).call(pending_key)
|
|
22
14
|
proposal = env.meta["proposal"] or raise ProposalError.new("entry has no proposal block: #{pending_key}")
|
|
23
15
|
target = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
|
|
24
16
|
action = proposal["action"] || "put"
|
|
25
17
|
|
|
26
|
-
|
|
18
|
+
guard.for(:accept, target).check!(
|
|
19
|
+
Textus::Domain::Policy::Evaluation.new(
|
|
20
|
+
actor: @call.role, transition: :accept, origin: pending_key,
|
|
21
|
+
target: target, envelope: env, snapshot: @manifest
|
|
22
|
+
),
|
|
23
|
+
)
|
|
27
24
|
|
|
28
25
|
case action
|
|
29
26
|
when "put"
|
|
30
|
-
|
|
31
|
-
# target. Not related to the removed intake-handler legacy bridge.
|
|
32
|
-
target_meta = env.meta["frontmatter"] || {}
|
|
33
|
-
target_body = env.body
|
|
34
|
-
put_op.call(target, meta: target_meta, body: target_body)
|
|
27
|
+
put_op.call(target, meta: env.meta["frontmatter"] || {}, body: env.body)
|
|
35
28
|
when "delete"
|
|
36
29
|
delete_op.call(target)
|
|
37
30
|
else
|
|
@@ -39,48 +32,19 @@ module Textus
|
|
|
39
32
|
end
|
|
40
33
|
|
|
41
34
|
delete_op.call(pending_key)
|
|
42
|
-
|
|
43
|
-
@events.publish(:proposal_accepted,
|
|
44
|
-
ctx: hook_context,
|
|
45
|
-
key: pending_key,
|
|
46
|
-
target_key: target)
|
|
47
|
-
|
|
35
|
+
@events.publish(:proposal_accepted, ctx: hook_context, key: pending_key, target_key: target)
|
|
48
36
|
{ "protocol" => PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action }
|
|
49
37
|
end
|
|
50
38
|
|
|
51
39
|
private
|
|
52
40
|
|
|
53
|
-
def
|
|
54
|
-
@
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def put_op
|
|
58
|
-
@put_op ||= Textus::Write::Put.new(
|
|
59
|
-
container: @container, call: @call,
|
|
60
|
-
)
|
|
41
|
+
def guard
|
|
42
|
+
@guard ||= Textus::Domain::Policy::GuardFactory.new(manifest: @manifest, schemas: @schemas)
|
|
61
43
|
end
|
|
62
44
|
|
|
63
|
-
def
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
)
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def evaluate_promotion!(env, target_key)
|
|
70
|
-
rules = @manifest.rules.for(target_key)
|
|
71
|
-
promote = rules.promote
|
|
72
|
-
return if promote.nil? || promote.requires.empty?
|
|
73
|
-
|
|
74
|
-
policy = Textus::Domain::Policy::Promotion.from_names(promote.requires)
|
|
75
|
-
result = policy.evaluate(
|
|
76
|
-
entry: env, schemas: @schemas, manifest: @manifest, role: @call.role,
|
|
77
|
-
)
|
|
78
|
-
return if result.ok?
|
|
79
|
-
|
|
80
|
-
raise ProposalError.new(
|
|
81
|
-
"promotion gate failed: #{result.reasons.join("; ")}",
|
|
82
|
-
)
|
|
83
|
-
end
|
|
45
|
+
def hook_context = @hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
|
|
46
|
+
def put_op = @put_op ||= Textus::Write::Put.new(container: @container, call: @call)
|
|
47
|
+
def delete_op = @delete_op ||= Textus::Write::Delete.new(container: @container, call: @call)
|
|
84
48
|
end
|
|
85
49
|
end
|
|
86
50
|
end
|
data/lib/textus/write/delete.rb
CHANGED
|
@@ -5,7 +5,6 @@ module Textus
|
|
|
5
5
|
@container = container
|
|
6
6
|
@call = call
|
|
7
7
|
@manifest = container.manifest
|
|
8
|
-
@authorizer = container.authorizer
|
|
9
8
|
@events = container.events
|
|
10
9
|
end
|
|
11
10
|
|
|
@@ -13,7 +12,7 @@ module Textus
|
|
|
13
12
|
Textus::Manifest::Data.validate_key!(key)
|
|
14
13
|
mentry = @manifest.resolver.resolve(key).entry
|
|
15
14
|
|
|
16
|
-
|
|
15
|
+
guard_for(:delete, key, if_etag: if_etag).check!(eval_for(:delete, target_key: key))
|
|
17
16
|
|
|
18
17
|
writer.delete(key, mentry: mentry, if_etag: if_etag)
|
|
19
18
|
|
|
@@ -28,26 +27,25 @@ module Textus
|
|
|
28
27
|
|
|
29
28
|
private
|
|
30
29
|
|
|
31
|
-
def
|
|
32
|
-
|
|
30
|
+
def guard_for(transition, key, if_etag: nil)
|
|
31
|
+
Textus::Domain::Policy::GuardFactory.new(
|
|
32
|
+
manifest: @manifest, schemas: @container.schemas, extra: { if_etag: if_etag },
|
|
33
|
+
).for(transition, key)
|
|
33
34
|
end
|
|
34
35
|
|
|
35
|
-
def
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
schemas: @container.schemas,
|
|
40
|
-
audit_log: @container.audit_log,
|
|
41
|
-
call: @call,
|
|
42
|
-
reader: reader,
|
|
36
|
+
def eval_for(transition, target_key:, envelope: nil)
|
|
37
|
+
Textus::Domain::Policy::Evaluation.new(
|
|
38
|
+
actor: @call.role, transition: transition, origin: nil,
|
|
39
|
+
target: target_key, envelope: envelope, snapshot: @manifest
|
|
43
40
|
)
|
|
44
41
|
end
|
|
45
42
|
|
|
46
|
-
def
|
|
47
|
-
@
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
43
|
+
def hook_context
|
|
44
|
+
@hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def writer
|
|
48
|
+
@writer ||= Textus::Envelope::IO::Writer.from(container: @container, call: @call)
|
|
51
49
|
end
|
|
52
50
|
end
|
|
53
51
|
end
|
|
@@ -1,28 +1,28 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Write
|
|
3
|
-
class
|
|
3
|
+
class FetchAll
|
|
4
4
|
def initialize(container:, call:)
|
|
5
5
|
@container = container
|
|
6
6
|
@call = call
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def call(prefix: nil, zone: nil)
|
|
10
|
-
worker = Textus::Write::
|
|
10
|
+
worker = Textus::Write::FetchWorker.new(
|
|
11
11
|
container: @container, call: @call,
|
|
12
12
|
)
|
|
13
13
|
|
|
14
14
|
stale_rows = Textus::Read::Stale.new(container: @container, call: @call).call(prefix: prefix, zone: zone)
|
|
15
|
-
|
|
15
|
+
fetched = []
|
|
16
16
|
failed = []
|
|
17
17
|
skipped = []
|
|
18
18
|
|
|
19
19
|
stale_rows.each do |row|
|
|
20
20
|
key = row["key"] || row[:key]
|
|
21
21
|
reason = row["reason"] || row[:reason]
|
|
22
|
-
if reason.to_s.match?(/ttl exceeded|never
|
|
22
|
+
if reason.to_s.match?(/ttl exceeded|never fetched/)
|
|
23
23
|
begin
|
|
24
24
|
worker.run(key)
|
|
25
|
-
|
|
25
|
+
fetched << key
|
|
26
26
|
rescue Textus::Error => e
|
|
27
27
|
failed << { "key" => key, "error" => e.message }
|
|
28
28
|
end
|
|
@@ -34,7 +34,7 @@ module Textus
|
|
|
34
34
|
{
|
|
35
35
|
"protocol" => Textus::PROTOCOL,
|
|
36
36
|
"ok" => failed.empty?,
|
|
37
|
-
"
|
|
37
|
+
"fetched" => fetched,
|
|
38
38
|
"failed" => failed,
|
|
39
39
|
"skipped" => skipped,
|
|
40
40
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Write
|
|
3
|
-
class
|
|
4
|
-
# Collaborator (not a Dispatcher verb): constructed directly by
|
|
5
|
-
#
|
|
3
|
+
class FetchOrchestrator
|
|
4
|
+
# Collaborator (not a Dispatcher verb): constructed directly by FetchWorker /
|
|
5
|
+
# GetOrFetch, which pass their derived hook_context in. That's why this takes
|
|
6
6
|
# hook_context: explicitly while verb use cases derive their own.
|
|
7
7
|
def initialize(worker:, store_root:, events:, hook_context: nil, detached_spawner: nil)
|
|
8
8
|
@worker = worker
|
|
@@ -14,9 +14,9 @@ module Textus
|
|
|
14
14
|
|
|
15
15
|
def execute(action, key:)
|
|
16
16
|
case action
|
|
17
|
-
when Textus::Domain::Action::Return
|
|
18
|
-
when Textus::Domain::Action::
|
|
19
|
-
when Textus::Domain::Action::
|
|
17
|
+
when Textus::Domain::Action::Return then Textus::Domain::Outcome::Skipped.new
|
|
18
|
+
when Textus::Domain::Action::FetchSync then run_sync(key)
|
|
19
|
+
when Textus::Domain::Action::FetchTimed then run_timed(action.budget_ms, key)
|
|
20
20
|
else raise ArgumentError.new("unknown action: #{action.inspect}")
|
|
21
21
|
end
|
|
22
22
|
end
|
|
@@ -25,13 +25,13 @@ module Textus
|
|
|
25
25
|
|
|
26
26
|
def run_sync(key)
|
|
27
27
|
envelope = @worker.run(key)
|
|
28
|
-
Textus::Domain::Outcome::
|
|
28
|
+
Textus::Domain::Outcome::Fetched.new(envelope: envelope)
|
|
29
29
|
rescue Textus::Error => e
|
|
30
30
|
Textus::Domain::Outcome::Failed.new(error: e)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def run_timed(budget_ms, key)
|
|
34
|
-
return run_timed_with_fork(budget_ms, key) if Textus::Ports::
|
|
34
|
+
return run_timed_with_fork(budget_ms, key) if Textus::Ports::Fetch::Detached.supported?
|
|
35
35
|
|
|
36
36
|
run_timed_cooperative(budget_ms, key)
|
|
37
37
|
end
|
|
@@ -49,7 +49,7 @@ module Textus
|
|
|
49
49
|
thread.kill
|
|
50
50
|
return Textus::Domain::Outcome::Failed.new(
|
|
51
51
|
error: Textus::UsageError.new(
|
|
52
|
-
"
|
|
52
|
+
"fetch exceeded budget #{budget_ms}ms (no fork available — cooperative cancel)",
|
|
53
53
|
),
|
|
54
54
|
)
|
|
55
55
|
end
|
|
@@ -57,7 +57,7 @@ module Textus
|
|
|
57
57
|
if result.is_a?(Textus::Error)
|
|
58
58
|
Textus::Domain::Outcome::Failed.new(error: result)
|
|
59
59
|
else
|
|
60
|
-
Textus::Domain::Outcome::
|
|
60
|
+
Textus::Domain::Outcome::Fetched.new(envelope: result)
|
|
61
61
|
end
|
|
62
62
|
end
|
|
63
63
|
|
|
@@ -77,25 +77,25 @@ module Textus
|
|
|
77
77
|
# Single-flight: if a sibling process / earlier fork holds the
|
|
78
78
|
# per-leaf lock, don't fork another worker — they're already
|
|
79
79
|
# doing this work.
|
|
80
|
-
probe = Textus::Ports::
|
|
80
|
+
probe = Textus::Ports::Fetch::Lock.new(root: @store_root, key: key)
|
|
81
81
|
return Textus::Domain::Outcome::Detached.new unless probe.try_acquire
|
|
82
82
|
|
|
83
83
|
probe.release
|
|
84
84
|
|
|
85
85
|
payload = { key: key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms }
|
|
86
86
|
payload[:ctx] = @hook_context if @hook_context
|
|
87
|
-
@events.publish(:
|
|
87
|
+
@events.publish(:fetch_backgrounded, **payload)
|
|
88
88
|
@detached_spawner.call(store_root: @store_root, key: key)
|
|
89
89
|
Textus::Domain::Outcome::Detached.new
|
|
90
90
|
elsif result.is_a?(Textus::Error)
|
|
91
91
|
Textus::Domain::Outcome::Failed.new(error: result)
|
|
92
92
|
else
|
|
93
|
-
Textus::Domain::Outcome::
|
|
93
|
+
Textus::Domain::Outcome::Fetched.new(envelope: result)
|
|
94
94
|
end
|
|
95
95
|
end
|
|
96
96
|
|
|
97
97
|
def default_spawner
|
|
98
|
-
Textus::Ports::
|
|
98
|
+
Textus::Ports::Fetch::Detached.method(:spawn)
|
|
99
99
|
end
|
|
100
100
|
end
|
|
101
101
|
end
|