textus 0.15.0 → 0.18.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.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +14 -14
- data/CHANGELOG.md +313 -0
- data/README.md +13 -9
- data/SPEC.md +13 -10
- data/docs/conventions.md +2 -2
- data/lib/textus/application/context.rb +24 -0
- data/lib/textus/application/reads/audit.rb +1 -1
- data/lib/textus/application/reads/blame.rb +3 -1
- data/lib/textus/application/reads/deps.rb +1 -1
- data/lib/textus/application/reads/freshness.rb +12 -3
- data/lib/textus/application/reads/get.rb +32 -8
- data/lib/textus/application/reads/get_or_refresh.rb +5 -5
- data/lib/textus/application/reads/list.rb +3 -1
- data/lib/textus/application/reads/published.rb +1 -1
- data/lib/textus/application/reads/rdeps.rb +1 -1
- data/lib/textus/application/reads/schema_envelope.rb +3 -1
- data/lib/textus/application/reads/stale.rb +1 -1
- data/lib/textus/application/reads/uid.rb +1 -1
- data/lib/textus/application/reads/validate_all.rb +6 -1
- data/lib/textus/application/reads/validator.rb +84 -0
- data/lib/textus/application/reads/where.rb +4 -1
- data/lib/textus/application/refresh/all.rb +8 -1
- data/lib/textus/application/refresh/orchestrator.rb +2 -3
- data/lib/textus/application/refresh/worker.rb +18 -15
- data/lib/textus/application/writes/accept.rb +12 -12
- data/lib/textus/application/writes/build.rb +3 -4
- data/lib/textus/application/writes/delete.rb +10 -15
- data/lib/textus/application/writes/envelope_io.rb +106 -0
- data/lib/textus/application/writes/mv.rb +25 -27
- data/lib/textus/application/writes/publish.rb +8 -9
- data/lib/textus/application/writes/put.rb +12 -16
- data/lib/textus/application/writes/reject.rb +10 -10
- data/lib/textus/builder/pipeline.rb +2 -2
- data/lib/textus/cli/group/hook.rb +1 -3
- data/lib/textus/cli/group/key.rb +1 -4
- data/lib/textus/cli/group/refresh.rb +1 -2
- data/lib/textus/cli/group/rule.rb +1 -3
- data/lib/textus/cli/group/schema.rb +1 -5
- data/lib/textus/cli/group.rb +12 -16
- data/lib/textus/cli/verb/accept.rb +3 -1
- data/lib/textus/cli/verb/audit.rb +3 -1
- data/lib/textus/cli/verb/blame.rb +3 -1
- data/lib/textus/cli/verb/build.rb +4 -2
- data/lib/textus/cli/verb/delete.rb +3 -1
- data/lib/textus/cli/verb/deps.rb +3 -1
- data/lib/textus/cli/verb/doctor.rb +2 -0
- data/lib/textus/cli/verb/freshness.rb +3 -1
- data/lib/textus/cli/verb/get.rb +3 -1
- data/lib/textus/cli/verb/hook_run.rb +3 -0
- data/lib/textus/cli/verb/hooks.rb +3 -0
- data/lib/textus/cli/verb/init.rb +2 -0
- data/lib/textus/cli/verb/intro.rb +2 -0
- data/lib/textus/cli/verb/key_normalize.rb +3 -0
- data/lib/textus/cli/verb/list.rb +3 -1
- data/lib/textus/cli/verb/mv.rb +4 -1
- data/lib/textus/cli/verb/published.rb +3 -1
- data/lib/textus/cli/verb/put.rb +3 -1
- data/lib/textus/cli/verb/rdeps.rb +3 -1
- data/lib/textus/cli/verb/refresh.rb +1 -1
- data/lib/textus/cli/verb/refresh_stale.rb +3 -0
- data/lib/textus/cli/verb/reject.rb +3 -1
- data/lib/textus/cli/verb/rule_explain.rb +4 -1
- data/lib/textus/cli/verb/rule_list.rb +3 -0
- data/lib/textus/cli/verb/schema.rb +4 -1
- data/lib/textus/cli/verb/schema_diff.rb +3 -0
- data/lib/textus/cli/verb/schema_init.rb +3 -0
- data/lib/textus/cli/verb/schema_migrate.rb +3 -0
- data/lib/textus/cli/verb/uid.rb +4 -1
- data/lib/textus/cli/verb/where.rb +3 -1
- data/lib/textus/cli/verb.rb +30 -0
- data/lib/textus/cli.rb +18 -27
- data/lib/textus/doctor/check/audit_log.rb +1 -1
- data/lib/textus/doctor/check/hooks.rb +3 -1
- data/lib/textus/doctor/check/intake_registration.rb +3 -3
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/sentinels.rb +2 -2
- data/lib/textus/domain/freshness/evaluator.rb +1 -1
- data/lib/textus/domain/freshness/policy.rb +1 -1
- data/lib/textus/domain/freshness/verdict.rb +1 -1
- data/lib/textus/domain/freshness.rb +40 -0
- data/lib/textus/domain/policy/predicates/schema_valid.rb +2 -2
- data/lib/textus/{store → domain}/sentinel.rb +1 -1
- data/lib/textus/{store → domain}/staleness/generator_check.rb +1 -1
- data/lib/textus/{store → domain}/staleness/intake_check.rb +1 -1
- data/lib/textus/{store → domain}/staleness.rb +1 -1
- data/lib/textus/entry/json.rb +1 -1
- data/lib/textus/entry/markdown.rb +1 -1
- data/lib/textus/entry/yaml.rb +1 -1
- data/lib/textus/envelope.rb +7 -3
- data/lib/textus/errors.rb +19 -0
- data/lib/textus/hooks/builtin.rb +6 -6
- data/lib/textus/hooks/dispatcher.rb +17 -9
- data/lib/textus/hooks/loader.rb +20 -17
- data/lib/textus/hooks/registry.rb +4 -0
- data/lib/textus/{store → infra}/audit_log.rb +1 -1
- data/lib/textus/infra/audit_subscriber.rb +43 -0
- data/lib/textus/infra/publisher.rb +3 -3
- data/lib/textus/infra/storage/file_store.rb +26 -0
- data/lib/textus/init.rb +11 -9
- data/lib/textus/manifest/resolution.rb +5 -0
- data/lib/textus/manifest.rb +4 -3
- data/lib/textus/migrate_keys.rb +1 -1
- data/lib/textus/operations.rb +83 -16
- data/lib/textus/projection.rb +2 -2
- data/lib/textus/refresh.rb +1 -1
- data/lib/textus/schema/tools.rb +5 -5
- data/lib/textus/schemas.rb +46 -0
- data/lib/textus/store.rb +12 -49
- data/lib/textus/uid.rb +18 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +17 -1
- metadata +14 -13
- data/lib/textus/hooks/dsl.rb +0 -11
- data/lib/textus/operations/reads.rb +0 -56
- data/lib/textus/operations/refresh.rb +0 -27
- data/lib/textus/operations/writes.rb +0 -21
- data/lib/textus/store/reader.rb +0 -69
- data/lib/textus/store/validator.rb +0 -82
- data/lib/textus/store/writer.rb +0 -102
|
@@ -13,27 +13,51 @@ module Textus
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def call(key)
|
|
16
|
-
envelope =
|
|
16
|
+
envelope = read_raw_envelope(key)
|
|
17
17
|
return nil if envelope.nil?
|
|
18
18
|
|
|
19
|
-
policy_set = @ctx.
|
|
19
|
+
policy_set = @ctx.manifest.rules_for(key)
|
|
20
20
|
refresh_policy = policy_set.refresh
|
|
21
21
|
return annotate_fresh(envelope) if refresh_policy.nil?
|
|
22
22
|
|
|
23
23
|
policy = refresh_policy.to_freshness_policy
|
|
24
24
|
verdict = @evaluator.call(policy, envelope, now: @ctx.now)
|
|
25
25
|
|
|
26
|
-
envelope.with(freshness:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
envelope.with(freshness: Textus::Domain::Freshness.build(
|
|
27
|
+
stale: verdict.stale?,
|
|
28
|
+
reason: verdict.reason,
|
|
29
|
+
refreshing: false,
|
|
30
|
+
))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Strict variant: raises UnknownKey when the entry is missing.
|
|
34
|
+
# Used by consumers (e.g. Validator) that need to distinguish absence
|
|
35
|
+
# from emptiness.
|
|
36
|
+
def get(key)
|
|
37
|
+
call(key) || raise(UnknownKey.new(key, suggestions: @ctx.manifest.suggestions_for(key)))
|
|
31
38
|
end
|
|
32
39
|
|
|
33
40
|
private
|
|
34
41
|
|
|
42
|
+
def read_raw_envelope(key)
|
|
43
|
+
res = @ctx.manifest.resolve(key)
|
|
44
|
+
mentry = res.entry
|
|
45
|
+
path = res.path
|
|
46
|
+
return nil unless @ctx.file_store.exists?(path)
|
|
47
|
+
|
|
48
|
+
raw = @ctx.file_store.read(path)
|
|
49
|
+
parsed = Entry.for_format(mentry.format).parse(raw, path: path)
|
|
50
|
+
Envelope.build(
|
|
51
|
+
key: key, mentry: mentry, path: path,
|
|
52
|
+
meta: parsed["_meta"], body: parsed["body"],
|
|
53
|
+
etag: Etag.for_bytes(raw), content: parsed["content"]
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
35
57
|
def annotate_fresh(envelope)
|
|
36
|
-
envelope.with(freshness:
|
|
58
|
+
envelope.with(freshness: Textus::Domain::Freshness.build(
|
|
59
|
+
stale: false, reason: nil, refreshing: false,
|
|
60
|
+
))
|
|
37
61
|
end
|
|
38
62
|
end
|
|
39
63
|
end
|
|
@@ -19,14 +19,14 @@ module Textus
|
|
|
19
19
|
def call(key)
|
|
20
20
|
envelope = @get.call(key)
|
|
21
21
|
return nil if envelope.nil?
|
|
22
|
-
return envelope unless envelope.freshness
|
|
22
|
+
return envelope unless envelope.freshness&.stale
|
|
23
23
|
|
|
24
24
|
policy_set = @ctx.store.manifest.rules_for(key)
|
|
25
25
|
refresh_policy = policy_set.refresh
|
|
26
26
|
return envelope if refresh_policy.nil?
|
|
27
27
|
|
|
28
28
|
policy = refresh_policy.to_freshness_policy
|
|
29
|
-
verdict = Textus::Domain::Freshness::Verdict.stale(envelope.freshness
|
|
29
|
+
verdict = Textus::Domain::Freshness::Verdict.stale(envelope.freshness.reason)
|
|
30
30
|
action = policy.decide(verdict)
|
|
31
31
|
outcome = @orchestrator.execute(action, key: key)
|
|
32
32
|
|
|
@@ -35,13 +35,13 @@ module Textus
|
|
|
35
35
|
envelope
|
|
36
36
|
when Textus::Domain::Outcome::Refreshed
|
|
37
37
|
outcome.envelope.with(
|
|
38
|
-
freshness:
|
|
38
|
+
freshness: Textus::Domain::Freshness.build(stale: false, reason: nil, refreshing: false),
|
|
39
39
|
)
|
|
40
40
|
when Textus::Domain::Outcome::Detached
|
|
41
|
-
envelope.with(freshness: envelope.freshness.
|
|
41
|
+
envelope.with(freshness: envelope.freshness.with(refreshing: true))
|
|
42
42
|
when Textus::Domain::Outcome::Failed
|
|
43
43
|
envelope.with(
|
|
44
|
-
freshness: envelope.freshness.
|
|
44
|
+
freshness: envelope.freshness.with(refresh_error: outcome.error.message),
|
|
45
45
|
)
|
|
46
46
|
end
|
|
47
47
|
end
|
|
@@ -7,7 +7,9 @@ module Textus
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def call(prefix: nil, zone: nil)
|
|
10
|
-
@ctx.
|
|
10
|
+
rows = @ctx.manifest.enumerate(prefix: prefix)
|
|
11
|
+
rows = rows.select { |r| r[:manifest_entry].zone == zone } if zone
|
|
12
|
+
rows.map { |row| { "key" => row[:key], "zone" => row[:manifest_entry].zone, "path" => row[:path] } }
|
|
11
13
|
end
|
|
12
14
|
end
|
|
13
15
|
end
|
|
@@ -7,7 +7,9 @@ module Textus
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def call(key)
|
|
10
|
-
@ctx.
|
|
10
|
+
mentry = @ctx.manifest.resolve(key).entry
|
|
11
|
+
schema = @ctx.schemas.fetch_or_nil(mentry.schema)
|
|
12
|
+
{ "protocol" => PROTOCOL, "key" => key, "schema_ref" => mentry.schema, "schema" => schema&.to_h }
|
|
11
13
|
end
|
|
12
14
|
end
|
|
13
15
|
end
|
|
@@ -7,7 +7,12 @@ module Textus
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def call
|
|
10
|
-
|
|
10
|
+
Validator.new(
|
|
11
|
+
reader: Get.new(ctx: @ctx),
|
|
12
|
+
manifest: @ctx.manifest,
|
|
13
|
+
audit_log: @ctx.audit_log,
|
|
14
|
+
schema_for: ->(name) { @ctx.schemas.fetch_or_nil(name) },
|
|
15
|
+
).call
|
|
11
16
|
end
|
|
12
17
|
end
|
|
13
18
|
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Reads
|
|
4
|
+
class Validator
|
|
5
|
+
def initialize(reader:, manifest:, audit_log:, schema_for:)
|
|
6
|
+
@reader = reader
|
|
7
|
+
@manifest = manifest
|
|
8
|
+
@audit_log = audit_log
|
|
9
|
+
@schema_for = schema_for
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
violations = []
|
|
14
|
+
check_content_violations(violations)
|
|
15
|
+
check_role_authority_violations(violations)
|
|
16
|
+
{ "protocol" => PROTOCOL, "ok" => violations.empty?, "violations" => violations }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def check_content_violations(violations)
|
|
22
|
+
@manifest.enumerate.each do |row|
|
|
23
|
+
key = row[:key]
|
|
24
|
+
mentry = row[:manifest_entry]
|
|
25
|
+
env = fetch_envelope(key, violations) or next
|
|
26
|
+
schema = mentry.schema && @schema_for.call(mentry.schema)
|
|
27
|
+
next unless schema
|
|
28
|
+
|
|
29
|
+
begin
|
|
30
|
+
validate_schema!(schema, env, mentry.format)
|
|
31
|
+
rescue Textus::Error => e
|
|
32
|
+
violations << { "key" => key, "code" => e.code, "message" => e.message }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def check_role_authority_violations(violations)
|
|
38
|
+
@manifest.enumerate.each do |row|
|
|
39
|
+
mentry = row[:manifest_entry]
|
|
40
|
+
next unless mentry.schema
|
|
41
|
+
|
|
42
|
+
schema = @schema_for.call(mentry.schema)
|
|
43
|
+
next unless schema
|
|
44
|
+
|
|
45
|
+
env = begin
|
|
46
|
+
@reader.get(row[:key])
|
|
47
|
+
rescue StandardError
|
|
48
|
+
next
|
|
49
|
+
end
|
|
50
|
+
append_authority_violations(violations, row[:key], env, schema)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def append_authority_violations(violations, key, env, schema)
|
|
55
|
+
last_writer = @audit_log.last_writer_for(key)
|
|
56
|
+
return if last_writer.nil?
|
|
57
|
+
|
|
58
|
+
env.meta.each_key do |field|
|
|
59
|
+
owner = schema.maintained_by(field)
|
|
60
|
+
next if owner.nil? || last_writer == owner || last_writer == "human"
|
|
61
|
+
|
|
62
|
+
violations << { "key" => key, "code" => "role_authority",
|
|
63
|
+
"field" => field, "expected" => owner, "last_writer" => last_writer }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def fetch_envelope(key, violations)
|
|
68
|
+
@reader.get(key)
|
|
69
|
+
rescue Textus::Error => e
|
|
70
|
+
violations << { "key" => key, "code" => e.code, "message" => e.message }
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate_schema!(schema, envelope, format)
|
|
75
|
+
payload = case format
|
|
76
|
+
when "json", "yaml" then envelope.content || {}
|
|
77
|
+
else envelope.meta || {}
|
|
78
|
+
end
|
|
79
|
+
schema.validate!(payload)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -7,7 +7,10 @@ module Textus
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def call(key)
|
|
10
|
-
@ctx.
|
|
10
|
+
res = @ctx.manifest.resolve(key)
|
|
11
|
+
mentry = res.entry
|
|
12
|
+
path = res.path
|
|
13
|
+
{ "protocol" => PROTOCOL, "key" => key, "zone" => mentry.zone, "owner" => mentry.owner, "path" => path }
|
|
11
14
|
end
|
|
12
15
|
end
|
|
13
16
|
end
|
|
@@ -5,7 +5,14 @@ module Textus
|
|
|
5
5
|
module_function
|
|
6
6
|
|
|
7
7
|
def call(ctx, prefix: nil, zone: nil)
|
|
8
|
-
|
|
8
|
+
envelope_io = Textus::Application::Writes::EnvelopeIO.new(
|
|
9
|
+
file_store: ctx.file_store,
|
|
10
|
+
manifest: ctx.manifest,
|
|
11
|
+
schemas: ctx.schemas,
|
|
12
|
+
audit_log: ctx.audit_log,
|
|
13
|
+
ctx: ctx,
|
|
14
|
+
)
|
|
15
|
+
worker = Textus::Application::Refresh::Worker.new(ctx: ctx, envelope_io: envelope_io)
|
|
9
16
|
|
|
10
17
|
stale_rows = Textus::Application::Reads::Stale.new(ctx: ctx).call(prefix: prefix, zone: zone)
|
|
11
18
|
refreshed = []
|
|
@@ -2,9 +2,8 @@ module Textus
|
|
|
2
2
|
module Application
|
|
3
3
|
module Refresh
|
|
4
4
|
class Orchestrator
|
|
5
|
-
def initialize(worker:,
|
|
5
|
+
def initialize(worker:, store_root:, store: nil, role: "human", detached_spawner: nil)
|
|
6
6
|
@worker = worker
|
|
7
|
-
@bus = bus
|
|
8
7
|
@store_root = store_root
|
|
9
8
|
@store = store
|
|
10
9
|
@role = role
|
|
@@ -59,7 +58,7 @@ module Textus
|
|
|
59
58
|
store_view = @store ? Textus::Application::Context.new(store: @store, role: @role) : nil
|
|
60
59
|
payload = { key: key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms }
|
|
61
60
|
payload[:store] = store_view if store_view
|
|
62
|
-
@bus
|
|
61
|
+
@store&.bus&.publish(:refresh_backgrounded, **payload)
|
|
63
62
|
@detached_spawner.call(store_root: @store_root, key: key)
|
|
64
63
|
Textus::Domain::Outcome::Detached.new
|
|
65
64
|
elsif result.is_a?(Textus::Error)
|
|
@@ -6,13 +6,16 @@ module Textus
|
|
|
6
6
|
class Worker
|
|
7
7
|
FETCH_TIMEOUT_SECONDS = 30
|
|
8
8
|
|
|
9
|
-
def initialize(ctx:,
|
|
9
|
+
def initialize(ctx:, envelope_io:)
|
|
10
10
|
@ctx = ctx
|
|
11
|
-
@
|
|
11
|
+
@envelope_io = envelope_io
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def run(key)
|
|
15
|
-
|
|
15
|
+
res = @ctx.store.manifest.resolve(key)
|
|
16
|
+
mentry = res.entry
|
|
17
|
+
path = res.path
|
|
18
|
+
remaining = res.remaining
|
|
16
19
|
raise UsageError.new("no intake declared for '#{key}'") unless mentry.intake_handler
|
|
17
20
|
|
|
18
21
|
before_etag = File.exist?(path) ? Etag.for_file(path) : nil
|
|
@@ -33,8 +36,8 @@ module Textus
|
|
|
33
36
|
|
|
34
37
|
def fetch_with_bus(key, mentry, remaining)
|
|
35
38
|
callable = @ctx.store.registry.rpc_callable(:resolve_intake, mentry.intake_handler)
|
|
36
|
-
@bus.publish(:refresh_started, store: read_view, key: key, mode: :sync,
|
|
37
|
-
|
|
39
|
+
@ctx.bus.publish(:refresh_started, store: read_view, key: key, mode: :sync,
|
|
40
|
+
correlation_id: @ctx.correlation_id)
|
|
38
41
|
call_intake(key, mentry, callable, remaining)
|
|
39
42
|
end
|
|
40
43
|
|
|
@@ -48,31 +51,31 @@ module Textus
|
|
|
48
51
|
)
|
|
49
52
|
end
|
|
50
53
|
rescue Timeout::Error
|
|
51
|
-
@bus.publish(:refresh_failed, store: read_view, key: key, error_class: "Timeout::Error",
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
@ctx.bus.publish(:refresh_failed, store: read_view, key: key, error_class: "Timeout::Error",
|
|
55
|
+
error_message: "intake '#{mentry.intake_handler}' exceeded #{timeout}s",
|
|
56
|
+
correlation_id: @ctx.correlation_id)
|
|
54
57
|
raise UsageError.new("intake '#{mentry.intake_handler}' exceeded #{timeout}s timeout")
|
|
55
58
|
rescue Textus::Error => e
|
|
56
|
-
@bus.publish(:refresh_failed, store: read_view, key: key, error_class: e.class.name,
|
|
57
|
-
|
|
59
|
+
@ctx.bus.publish(:refresh_failed, store: read_view, key: key, error_class: e.class.name,
|
|
60
|
+
error_message: e.message, correlation_id: @ctx.correlation_id)
|
|
58
61
|
raise
|
|
59
62
|
rescue StandardError => e
|
|
60
|
-
@bus.publish(:refresh_failed, store: read_view, key: key, error_class: e.class.name,
|
|
61
|
-
|
|
63
|
+
@ctx.bus.publish(:refresh_failed, store: read_view, key: key, error_class: e.class.name,
|
|
64
|
+
error_message: e.message, correlation_id: @ctx.correlation_id)
|
|
62
65
|
raise UsageError.new("intake '#{mentry.intake_handler}' raised: #{e.class}: #{e.message}")
|
|
63
66
|
end
|
|
64
67
|
|
|
65
68
|
def persist_and_notify(key, mentry, result, before_etag)
|
|
66
69
|
normalized = Textus::Refresh.normalize_action_result(result, format: mentry.format)
|
|
67
|
-
envelope = Textus::Application::Writes::Put.new(ctx: @ctx,
|
|
70
|
+
envelope = Textus::Application::Writes::Put.new(ctx: @ctx, envelope_io: @envelope_io).call(
|
|
68
71
|
key,
|
|
69
72
|
meta: normalized[:meta], body: normalized[:body], content: normalized[:content],
|
|
70
73
|
suppress_events: true
|
|
71
74
|
)
|
|
72
75
|
change = detect_change(before_etag, envelope)
|
|
73
76
|
unless change == :unchanged
|
|
74
|
-
@bus.publish(:entry_refreshed, store: read_view, key: key, envelope: envelope, change: change,
|
|
75
|
-
|
|
77
|
+
@ctx.bus.publish(:entry_refreshed, store: read_view, key: key, envelope: envelope, change: change,
|
|
78
|
+
correlation_id: @ctx.correlation_id)
|
|
76
79
|
end
|
|
77
80
|
envelope
|
|
78
81
|
end
|
|
@@ -2,15 +2,15 @@ module Textus
|
|
|
2
2
|
module Application
|
|
3
3
|
module Writes
|
|
4
4
|
class Accept
|
|
5
|
-
def initialize(ctx:,
|
|
5
|
+
def initialize(ctx:, envelope_io:)
|
|
6
6
|
@ctx = ctx
|
|
7
|
-
@
|
|
7
|
+
@envelope_io = envelope_io
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
def call(pending_key)
|
|
11
11
|
raise ProposalError.new("only human role can accept proposals; got '#{@ctx.role}'") unless @ctx.role == "human"
|
|
12
12
|
|
|
13
|
-
env = @ctx.
|
|
13
|
+
env = Textus::Application::Reads::Get.new(ctx: @ctx).call(pending_key)
|
|
14
14
|
proposal = env.meta["proposal"] or raise ProposalError.new("entry has no proposal block: #{pending_key}")
|
|
15
15
|
target = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
|
|
16
16
|
action = proposal["action"] || "put"
|
|
@@ -23,20 +23,20 @@ module Textus
|
|
|
23
23
|
# target. Not related to the removed intake-handler legacy bridge.
|
|
24
24
|
target_meta = env.meta["frontmatter"] || {}
|
|
25
25
|
target_body = env.body
|
|
26
|
-
Textus::Application::Writes::Put.new(ctx: @ctx,
|
|
26
|
+
Textus::Application::Writes::Put.new(ctx: @ctx, envelope_io: @envelope_io).call(target, meta: target_meta, body: target_body)
|
|
27
27
|
when "delete"
|
|
28
|
-
Textus::Application::Writes::Delete.new(ctx: @ctx,
|
|
28
|
+
Textus::Application::Writes::Delete.new(ctx: @ctx, envelope_io: @envelope_io).call(target)
|
|
29
29
|
else
|
|
30
30
|
raise ProposalError.new("unknown action: #{action}")
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
Textus::Application::Writes::Delete.new(ctx: @ctx,
|
|
33
|
+
Textus::Application::Writes::Delete.new(ctx: @ctx, envelope_io: @envelope_io).call(pending_key)
|
|
34
34
|
|
|
35
|
-
@bus.publish(:proposal_accepted,
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
@ctx.bus.publish(:proposal_accepted,
|
|
36
|
+
store: @ctx.with_role(@ctx.role),
|
|
37
|
+
key: pending_key,
|
|
38
|
+
target_key: target,
|
|
39
|
+
correlation_id: @ctx.correlation_id)
|
|
40
40
|
|
|
41
41
|
{ "protocol" => PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action }
|
|
42
42
|
end
|
|
@@ -44,7 +44,7 @@ module Textus
|
|
|
44
44
|
private
|
|
45
45
|
|
|
46
46
|
def evaluate_promotion!(env, target_key)
|
|
47
|
-
rules = @ctx.
|
|
47
|
+
rules = @ctx.manifest.rules_for(target_key)
|
|
48
48
|
promote = rules.promote
|
|
49
49
|
return if promote.nil? || promote.requires.empty?
|
|
50
50
|
|
|
@@ -11,9 +11,8 @@ module Textus
|
|
|
11
11
|
# `Application::Writes::Publish`. The CLI verb `textus build` calls
|
|
12
12
|
# both classes and merges the results.
|
|
13
13
|
class Build
|
|
14
|
-
def initialize(ctx
|
|
14
|
+
def initialize(ctx:)
|
|
15
15
|
@ctx = ctx
|
|
16
|
-
@bus = bus
|
|
17
16
|
end
|
|
18
17
|
|
|
19
18
|
def call(prefix: nil)
|
|
@@ -51,7 +50,7 @@ module Textus
|
|
|
51
50
|
end
|
|
52
51
|
|
|
53
52
|
def publish_and_fire(mentry, target_path)
|
|
54
|
-
envelope =
|
|
53
|
+
envelope = Textus::Application::Reads::Get.new(ctx: @ctx).call(mentry.key)
|
|
55
54
|
repo_root = File.dirname(root)
|
|
56
55
|
|
|
57
56
|
mentry.publish_to.each do |rel|
|
|
@@ -71,7 +70,7 @@ module Textus
|
|
|
71
70
|
end
|
|
72
71
|
|
|
73
72
|
def publish_event(event, **payload)
|
|
74
|
-
@bus.publish(event, store: @ctx.with_role(@ctx.role), correlation_id: @ctx.correlation_id, **payload)
|
|
73
|
+
@ctx.bus.publish(event, store: @ctx.with_role(@ctx.role), correlation_id: @ctx.correlation_id, **payload)
|
|
75
74
|
end
|
|
76
75
|
end
|
|
77
76
|
end
|
|
@@ -2,29 +2,24 @@ module Textus
|
|
|
2
2
|
module Application
|
|
3
3
|
module Writes
|
|
4
4
|
class Delete
|
|
5
|
-
def initialize(ctx:,
|
|
5
|
+
def initialize(ctx:, envelope_io:)
|
|
6
6
|
@ctx = ctx
|
|
7
|
-
@
|
|
7
|
+
@envelope_io = envelope_io
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
def call(key, if_etag: nil, suppress_events: false)
|
|
11
|
-
@ctx.
|
|
12
|
-
mentry
|
|
11
|
+
@ctx.manifest.validate_key!(key)
|
|
12
|
+
mentry = @ctx.manifest.resolve(key).entry
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
raise WriteForbidden.new(key, mentry.zone,
|
|
16
|
-
writers: @ctx.store.manifest.zone_writers(mentry.zone))
|
|
17
|
-
end
|
|
14
|
+
@ctx.authorize_write!(mentry)
|
|
18
15
|
|
|
19
|
-
@
|
|
20
|
-
key, ctx: @ctx, if_etag: if_etag
|
|
21
|
-
)
|
|
16
|
+
@envelope_io.delete(key, mentry: mentry, if_etag: if_etag)
|
|
22
17
|
|
|
23
18
|
unless suppress_events
|
|
24
|
-
@bus.publish(:entry_deleted,
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
19
|
+
@ctx.bus.publish(:entry_deleted,
|
|
20
|
+
store: @ctx.with_role(@ctx.role),
|
|
21
|
+
key: key,
|
|
22
|
+
correlation_id: @ctx.correlation_id)
|
|
28
23
|
end
|
|
29
24
|
|
|
30
25
|
{ "protocol" => Textus::PROTOCOL, "ok" => true, "key" => key, "deleted" => true }
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Writes
|
|
4
|
+
# Owns the write pipeline (validate, serialize, etag-check, write, audit)
|
|
5
|
+
# extracted from Store::Writer. Talks to ports (FileStore, Schemas,
|
|
6
|
+
# AuditLog, Manifest) instead of File/FileUtils and Store directly.
|
|
7
|
+
#
|
|
8
|
+
# No permission check, no event firing — those belong to the caller
|
|
9
|
+
# (Application::Writes::Put / ::Delete).
|
|
10
|
+
class EnvelopeIO
|
|
11
|
+
Payload = Data.define(:meta, :body, :content)
|
|
12
|
+
|
|
13
|
+
def initialize(file_store:, manifest:, schemas:, audit_log:, ctx:)
|
|
14
|
+
@file_store = file_store
|
|
15
|
+
@manifest = manifest
|
|
16
|
+
@schemas = schemas
|
|
17
|
+
@audit_log = audit_log
|
|
18
|
+
@ctx = ctx
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def write(key, mentry:, payload:, if_etag: nil)
|
|
22
|
+
path = @manifest.resolve(key).path
|
|
23
|
+
|
|
24
|
+
meta = payload.meta || {}
|
|
25
|
+
strategy = Entry.for_format(mentry.format)
|
|
26
|
+
|
|
27
|
+
existing_uid = existing_uid_for(mentry, path)
|
|
28
|
+
meta, content = ensure_uid(mentry.format, meta, payload.content, existing_uid)
|
|
29
|
+
|
|
30
|
+
bytes, eff_meta, eff_body, eff_content = serialize_for_put(
|
|
31
|
+
mentry: mentry, path: path, strategy: strategy,
|
|
32
|
+
meta: meta, body: payload.body, content: content
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
enforce_name_match!(path, eff_meta, mentry.format)
|
|
36
|
+
|
|
37
|
+
schema = @schemas.fetch_or_nil(mentry.schema)
|
|
38
|
+
if schema
|
|
39
|
+
Entry.for_format(mentry.format).validate_against(
|
|
40
|
+
schema,
|
|
41
|
+
{ "_meta" => eff_meta, "content" => eff_content },
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
etag_before = @file_store.exists?(path) ? @file_store.etag(path) : nil
|
|
46
|
+
raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && (etag_before != if_etag)
|
|
47
|
+
|
|
48
|
+
@file_store.write(path, bytes)
|
|
49
|
+
etag_after = Etag.for_bytes(bytes)
|
|
50
|
+
@audit_log.append(
|
|
51
|
+
role: @ctx.role, verb: "put", key: key,
|
|
52
|
+
etag_before: etag_before, etag_after: etag_after,
|
|
53
|
+
extras: @ctx.correlation_id ? { "correlation_id" => @ctx.correlation_id } : nil
|
|
54
|
+
)
|
|
55
|
+
Envelope.build(
|
|
56
|
+
key: key, mentry: mentry, path: path,
|
|
57
|
+
meta: eff_meta, body: eff_body, etag: etag_after, content: eff_content
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def delete(key, mentry:, if_etag: nil)
|
|
62
|
+
_ = mentry
|
|
63
|
+
path = @manifest.resolve(key).path
|
|
64
|
+
raise UnknownKey.new(key, suggestions: @manifest.suggestions_for(key)) unless @file_store.exists?(path)
|
|
65
|
+
|
|
66
|
+
etag_before = @file_store.etag(path)
|
|
67
|
+
raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before
|
|
68
|
+
|
|
69
|
+
@file_store.delete(path)
|
|
70
|
+
@audit_log.append(
|
|
71
|
+
role: @ctx.role, verb: "delete", key: key,
|
|
72
|
+
etag_before: etag_before, etag_after: nil,
|
|
73
|
+
extras: @ctx.correlation_id ? { "correlation_id" => @ctx.correlation_id } : nil
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def existing_uid_for(mentry, path)
|
|
80
|
+
return nil unless @file_store.exists?(path)
|
|
81
|
+
|
|
82
|
+
raw = @file_store.read(path)
|
|
83
|
+
parsed = Entry.for_format(mentry.format).parse(raw, path: path)
|
|
84
|
+
Envelope.extract_uid(parsed["_meta"])
|
|
85
|
+
rescue StandardError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def ensure_uid(format, meta, content, existing_uid)
|
|
90
|
+
Textus::Entry.for_format(format).inject_uid(meta, content, existing_uid)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def enforce_name_match!(path, meta, format)
|
|
94
|
+
Textus::Entry.for_format(format).enforce_name_match!(path, meta)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def serialize_for_put(mentry:, path:, strategy:, meta:, body:, content:)
|
|
98
|
+
_ = strategy
|
|
99
|
+
Textus::Entry.for_format(mentry.format).serialize_for_put(
|
|
100
|
+
meta: meta, body: body, content: content, path: path,
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|