textus 0.22.0 → 0.29.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 +195 -48
- data/CHANGELOG.md +178 -0
- data/README.md +55 -13
- data/SPEC.md +79 -42
- data/docs/conventions.md +10 -0
- data/lib/textus/boot.rb +31 -29
- data/lib/textus/builder/pipeline.rb +13 -12
- data/lib/textus/call.rb +28 -0
- data/lib/textus/cli/group/mcp.rb +9 -0
- data/lib/textus/cli/group/zone.rb +9 -0
- data/lib/textus/cli/verb/accept.rb +1 -1
- data/lib/textus/cli/verb/audit.rb +2 -2
- data/lib/textus/cli/verb/blame.rb +1 -1
- data/lib/textus/cli/verb/boot.rb +1 -1
- data/lib/textus/cli/verb/build.rb +3 -3
- data/lib/textus/cli/verb/delete.rb +1 -1
- data/lib/textus/cli/verb/deps.rb +1 -1
- data/lib/textus/cli/verb/doctor.rb +1 -1
- data/lib/textus/cli/verb/freshness.rb +1 -1
- data/lib/textus/cli/verb/get.rb +1 -1
- data/lib/textus/cli/verb/hook_run.rb +3 -4
- data/lib/textus/cli/verb/hooks.rb +11 -14
- data/lib/textus/cli/verb/key_delete.rb +24 -0
- data/lib/textus/cli/verb/list.rb +1 -1
- data/lib/textus/cli/verb/mcp_serve.rb +17 -0
- data/lib/textus/cli/verb/migrate.rb +18 -0
- data/lib/textus/cli/verb/mv.rb +11 -3
- data/lib/textus/cli/verb/published.rb +1 -1
- data/lib/textus/cli/verb/pulse.rb +1 -1
- data/lib/textus/cli/verb/put.rb +8 -6
- data/lib/textus/cli/verb/rdeps.rb +1 -1
- data/lib/textus/cli/verb/refresh.rb +1 -1
- data/lib/textus/cli/verb/refresh_stale.rb +1 -1
- data/lib/textus/cli/verb/reject.rb +1 -1
- data/lib/textus/cli/verb/rule_explain.rb +1 -1
- data/lib/textus/cli/verb/rule_lint.rb +18 -0
- data/lib/textus/cli/verb/schema.rb +1 -1
- data/lib/textus/cli/verb/uid.rb +1 -1
- data/lib/textus/cli/verb/where.rb +1 -1
- data/lib/textus/cli/verb/zone_mv.rb +19 -0
- data/lib/textus/cli/verb.rb +7 -7
- data/lib/textus/cli.rb +0 -7
- data/lib/textus/container.rb +23 -0
- data/lib/textus/dispatcher.rb +49 -0
- data/lib/textus/doctor/check/audit_log.rb +2 -2
- data/lib/textus/doctor/check/handler_allowlist.rb +2 -2
- data/lib/textus/doctor/check/hooks.rb +4 -3
- data/lib/textus/doctor/check/illegal_keys.rb +2 -2
- data/lib/textus/doctor/check/intake_registration.rb +2 -2
- data/lib/textus/doctor/check/manifest_files.rb +2 -2
- data/lib/textus/doctor/check/protocol_version.rb +2 -2
- data/lib/textus/doctor/check/refresh_locks.rb +2 -2
- data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
- data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/schemas.rb +2 -2
- data/lib/textus/doctor/check/sentinels.rb +11 -9
- data/lib/textus/doctor/check/templates.rb +2 -2
- data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
- data/lib/textus/doctor/check.rb +12 -3
- data/lib/textus/doctor.rb +24 -27
- data/lib/textus/domain/authorizer.rb +6 -6
- data/lib/textus/{application → domain}/policy/predicates/accept_authority_signed.rb +2 -2
- data/lib/textus/{application → domain}/policy/predicates/schema_valid.rb +1 -1
- data/lib/textus/{application → domain}/policy/promotion.rb +1 -1
- data/lib/textus/domain/sentinel.rb +9 -65
- data/lib/textus/domain/staleness/generator_check.rb +46 -26
- data/lib/textus/domain/staleness/intake_check.rb +20 -12
- data/lib/textus/domain/staleness.rb +4 -4
- data/lib/textus/envelope/io/reader.rb +44 -0
- data/lib/textus/{application/writes/envelope_io.rb → envelope/io/writer.rb} +32 -58
- data/lib/textus/hooks/builtin.rb +14 -14
- data/lib/textus/hooks/context.rb +30 -13
- data/lib/textus/hooks/error_log.rb +32 -0
- data/lib/textus/hooks/{bus.rb → event_bus.rb} +41 -53
- data/lib/textus/hooks/loader.rb +29 -3
- data/lib/textus/hooks/rpc_registry.rb +77 -0
- data/lib/textus/key/path.rb +7 -3
- data/lib/textus/maintenance/key_delete_prefix.rb +36 -0
- data/lib/textus/maintenance/key_mv_prefix.rb +46 -0
- data/lib/textus/maintenance/migrate.rb +51 -0
- data/lib/textus/maintenance/rule_lint.rb +56 -0
- data/lib/textus/maintenance/zone_mv.rb +51 -0
- data/lib/textus/maintenance.rb +15 -0
- data/lib/textus/manifest/data.rb +79 -0
- data/lib/textus/manifest/entry/base.rb +38 -18
- data/lib/textus/manifest/entry/derived.rb +8 -9
- data/lib/textus/manifest/entry/nested.rb +7 -9
- data/lib/textus/manifest/entry/parser.rb +2 -2
- data/lib/textus/manifest/entry/validators/events.rb +2 -2
- data/lib/textus/manifest/entry/validators/format_matrix.rb +2 -2
- data/lib/textus/manifest/entry/validators/index_filename.rb +1 -1
- data/lib/textus/manifest/entry/validators/inject_boot.rb +4 -2
- data/lib/textus/manifest/entry/validators/publish_each.rb +1 -1
- data/lib/textus/manifest/entry/validators.rb +2 -2
- data/lib/textus/manifest/entry.rb +0 -5
- data/lib/textus/manifest/policy.rb +48 -0
- data/lib/textus/manifest/resolver.rb +14 -14
- data/lib/textus/manifest/rules.rb +1 -1
- data/lib/textus/manifest.rb +47 -110
- data/lib/textus/mcp/errors.rb +32 -0
- data/lib/textus/mcp/server.rb +126 -0
- data/lib/textus/mcp/session.rb +40 -0
- data/lib/textus/mcp/tool_schemas.rb +71 -0
- data/lib/textus/mcp/tools.rb +129 -0
- data/lib/textus/mcp.rb +6 -0
- data/lib/textus/{infra → ports}/audit_log.rb +1 -1
- data/lib/textus/{infra → ports}/audit_subscriber.rb +7 -8
- data/lib/textus/{infra → ports}/build_lock.rb +1 -1
- data/lib/textus/{infra → ports}/clock.rb +1 -1
- data/lib/textus/{infra → ports}/publisher.rb +6 -6
- data/lib/textus/{infra → ports}/refresh/detached.rb +3 -3
- data/lib/textus/{infra → ports}/refresh/lock.rb +1 -1
- data/lib/textus/ports/sentinel_store.rb +67 -0
- data/lib/textus/ports/storage/file_stat.rb +19 -0
- data/lib/textus/{infra → ports}/storage/file_store.rb +1 -1
- data/lib/textus/projection.rb +91 -0
- data/lib/textus/read/audit.rb +111 -0
- data/lib/textus/read/blame.rb +81 -0
- data/lib/textus/read/boot.rb +18 -0
- data/lib/textus/read/deps.rb +24 -0
- data/lib/textus/read/doctor.rb +19 -0
- data/lib/textus/read/freshness.rb +101 -0
- data/lib/textus/read/get.rb +66 -0
- data/lib/textus/read/get_or_refresh.rb +69 -0
- data/lib/textus/read/list.rb +15 -0
- data/lib/textus/read/policy_explain.rb +37 -0
- data/lib/textus/read/published.rb +15 -0
- data/lib/textus/read/pulse.rb +89 -0
- data/lib/textus/read/rdeps.rb +25 -0
- data/lib/textus/read/schema_envelope.rb +16 -0
- data/lib/textus/read/stale.rb +17 -0
- data/lib/textus/read/uid.rb +20 -0
- data/lib/textus/read/validate_all.rb +22 -0
- data/lib/textus/read/validator.rb +84 -0
- data/lib/textus/read/where.rb +16 -0
- data/lib/textus/role_scope.rb +49 -0
- data/lib/textus/schema/tools.rb +14 -10
- data/lib/textus/store.rb +25 -11
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +86 -0
- data/lib/textus/write/authority_gate.rb +24 -0
- data/lib/textus/write/delete.rb +54 -0
- data/lib/textus/write/materializer.rb +48 -0
- data/lib/textus/write/mv.rb +123 -0
- data/lib/textus/write/publish.rb +66 -0
- data/lib/textus/write/put.rb +59 -0
- data/lib/textus/write/refresh_all.rb +44 -0
- data/lib/textus/write/refresh_orchestrator.rb +102 -0
- data/lib/textus/write/refresh_worker.rb +138 -0
- data/lib/textus/write/reject.rb +54 -0
- data/lib/textus.rb +7 -1
- metadata +75 -46
- data/lib/textus/application/context.rb +0 -34
- data/lib/textus/application/projection.rb +0 -91
- data/lib/textus/application/reads/audit.rb +0 -94
- data/lib/textus/application/reads/blame.rb +0 -82
- data/lib/textus/application/reads/deps.rb +0 -26
- data/lib/textus/application/reads/freshness.rb +0 -88
- data/lib/textus/application/reads/get.rb +0 -67
- data/lib/textus/application/reads/get_or_refresh.rb +0 -51
- data/lib/textus/application/reads/list.rb +0 -17
- data/lib/textus/application/reads/policy_explain.rb +0 -39
- data/lib/textus/application/reads/published.rb +0 -17
- data/lib/textus/application/reads/pulse.rb +0 -63
- data/lib/textus/application/reads/rdeps.rb +0 -27
- data/lib/textus/application/reads/schema_envelope.rb +0 -18
- data/lib/textus/application/reads/stale.rb +0 -15
- data/lib/textus/application/reads/uid.rb +0 -23
- data/lib/textus/application/reads/validate_all.rb +0 -24
- data/lib/textus/application/reads/validator.rb +0 -86
- data/lib/textus/application/reads/where.rb +0 -18
- data/lib/textus/application/refresh/all.rb +0 -52
- data/lib/textus/application/refresh/orchestrator.rb +0 -78
- data/lib/textus/application/refresh/worker.rb +0 -116
- data/lib/textus/application/writes/accept.rb +0 -89
- data/lib/textus/application/writes/authority_gate.rb +0 -26
- data/lib/textus/application/writes/delete.rb +0 -33
- data/lib/textus/application/writes/materializer.rb +0 -50
- data/lib/textus/application/writes/mv.rb +0 -105
- data/lib/textus/application/writes/publish.rb +0 -81
- data/lib/textus/application/writes/put.rb +0 -37
- data/lib/textus/application/writes/reject.rb +0 -50
- data/lib/textus/infra/event_bus.rb +0 -27
- data/lib/textus/operations.rb +0 -176
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
require_relative "authority_gate"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Write
|
|
5
|
+
class Accept
|
|
6
|
+
include AuthorityGate
|
|
7
|
+
|
|
8
|
+
def initialize(container:, call:)
|
|
9
|
+
@container = container
|
|
10
|
+
@call = call
|
|
11
|
+
@manifest = container.manifest
|
|
12
|
+
@schemas = container.schemas
|
|
13
|
+
@events = container.events
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(pending_key)
|
|
17
|
+
assert_accept_authority!("accept")
|
|
18
|
+
|
|
19
|
+
env = Textus::Read::Get.new(
|
|
20
|
+
container: @container, call: @call,
|
|
21
|
+
).call(pending_key)
|
|
22
|
+
proposal = env.meta["proposal"] or raise ProposalError.new("entry has no proposal block: #{pending_key}")
|
|
23
|
+
target = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
|
|
24
|
+
action = proposal["action"] || "put"
|
|
25
|
+
|
|
26
|
+
evaluate_promotion!(env, target)
|
|
27
|
+
|
|
28
|
+
case action
|
|
29
|
+
when "put"
|
|
30
|
+
# Nested proposal "frontmatter" — the meta to write to the accepted
|
|
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)
|
|
35
|
+
when "delete"
|
|
36
|
+
delete_op.call(target)
|
|
37
|
+
else
|
|
38
|
+
raise ProposalError.new("unknown action: #{action}")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
delete_op.call(pending_key)
|
|
42
|
+
|
|
43
|
+
@events.publish(:proposal_accepted,
|
|
44
|
+
ctx: hook_context,
|
|
45
|
+
key: pending_key,
|
|
46
|
+
target_key: target)
|
|
47
|
+
|
|
48
|
+
{ "protocol" => PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def hook_context
|
|
54
|
+
@hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def put_op
|
|
58
|
+
@put_op ||= Textus::Write::Put.new(
|
|
59
|
+
container: @container, call: @call,
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def delete_op
|
|
64
|
+
@delete_op ||= Textus::Write::Delete.new(
|
|
65
|
+
container: @container, call: @call,
|
|
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
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Write
|
|
3
|
+
# Shared gate for write verbs that require the caller to hold the
|
|
4
|
+
# manifest's accept_authority role. Provides one method, expressed
|
|
5
|
+
# as two early-returns rather than a ternary, so each failure mode
|
|
6
|
+
# reads on its own line.
|
|
7
|
+
module AuthorityGate
|
|
8
|
+
def assert_accept_authority!(verb)
|
|
9
|
+
return if @manifest.policy.role_kind(@call.role) == :accept_authority
|
|
10
|
+
|
|
11
|
+
authority = @manifest.policy.roles_with_kind(:accept_authority).first
|
|
12
|
+
if authority.nil?
|
|
13
|
+
raise ProposalError.new(
|
|
14
|
+
"no role with accept_authority kind is declared in this manifest; #{verb} is disabled",
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
raise ProposalError.new(
|
|
19
|
+
"only #{authority} role can #{verb} proposals; got '#{@call.role}'",
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Write
|
|
3
|
+
class Delete
|
|
4
|
+
def initialize(container:, call:)
|
|
5
|
+
@container = container
|
|
6
|
+
@call = call
|
|
7
|
+
@manifest = container.manifest
|
|
8
|
+
@authorizer = container.authorizer
|
|
9
|
+
@events = container.events
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(key, if_etag: nil, suppress_events: false)
|
|
13
|
+
Textus::Manifest::Data.validate_key!(key)
|
|
14
|
+
mentry = @manifest.resolver.resolve(key).entry
|
|
15
|
+
|
|
16
|
+
@authorizer.authorize_write!(mentry, role: @call.role)
|
|
17
|
+
|
|
18
|
+
writer.delete(key, mentry: mentry, if_etag: if_etag)
|
|
19
|
+
|
|
20
|
+
unless suppress_events
|
|
21
|
+
@events.publish(:entry_deleted,
|
|
22
|
+
ctx: hook_context,
|
|
23
|
+
key: key)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
{ "protocol" => Textus::PROTOCOL, "ok" => true, "key" => key, "deleted" => true }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def hook_context
|
|
32
|
+
@hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def writer
|
|
36
|
+
@writer ||= Textus::Envelope::IO::Writer.new(
|
|
37
|
+
file_store: @container.file_store,
|
|
38
|
+
manifest: @container.manifest,
|
|
39
|
+
schemas: @container.schemas,
|
|
40
|
+
audit_log: @container.audit_log,
|
|
41
|
+
call: @call,
|
|
42
|
+
reader: reader,
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def reader
|
|
47
|
+
@reader ||= Textus::Envelope::IO::Reader.new(
|
|
48
|
+
file_store: @container.file_store,
|
|
49
|
+
manifest: @container.manifest,
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Write
|
|
5
|
+
# Materializes a single Derived manifest entry onto disk by running
|
|
6
|
+
# the builder pipeline (template + projection + external runner).
|
|
7
|
+
# Extracted from Write::Build so that Publish can reuse
|
|
8
|
+
# it without creating a Build dependency.
|
|
9
|
+
class Materializer
|
|
10
|
+
def initialize(container:, call:)
|
|
11
|
+
@container = container
|
|
12
|
+
@call = call
|
|
13
|
+
@manifest = container.manifest
|
|
14
|
+
@file_store = container.file_store
|
|
15
|
+
@rpc = container.rpc
|
|
16
|
+
@root = container.root
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Runs the builder pipeline for `mentry` and returns the on-disk
|
|
20
|
+
# target_path string.
|
|
21
|
+
def run(mentry)
|
|
22
|
+
reader = Textus::Read::Get.new(container: @container, call: @call)
|
|
23
|
+
lister = Textus::Read::List.new(container: @container)
|
|
24
|
+
Builder::Pipeline.run(
|
|
25
|
+
mentry: mentry,
|
|
26
|
+
deps: Builder::Pipeline::Deps.new(
|
|
27
|
+
manifest: @manifest,
|
|
28
|
+
reader: reader.method(:call),
|
|
29
|
+
lister: lister.method(:call),
|
|
30
|
+
rpc: @rpc,
|
|
31
|
+
template_loader: ->(name) { read_template(name) },
|
|
32
|
+
transform_context: @container,
|
|
33
|
+
inject_boot: -> { Textus::Boot.build(container: @container) },
|
|
34
|
+
),
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def read_template(name)
|
|
41
|
+
tpl_path = File.join(@root, "templates", name)
|
|
42
|
+
raise TemplateError.new("template not found: #{tpl_path}", template_name: name) unless File.exist?(tpl_path)
|
|
43
|
+
|
|
44
|
+
File.read(tpl_path)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Write
|
|
3
|
+
class Mv
|
|
4
|
+
def initialize(container:, call:)
|
|
5
|
+
@container = container
|
|
6
|
+
@call = call
|
|
7
|
+
@manifest = container.manifest
|
|
8
|
+
@events = container.events
|
|
9
|
+
@authorizer = container.authorizer
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(old_key, new_key, dry_run: false)
|
|
13
|
+
old_res, new_res = prepare(old_key, new_key)
|
|
14
|
+
return dry_run_result(old_key, new_key, old_res, new_res) if dry_run
|
|
15
|
+
|
|
16
|
+
ensure_uid!(old_key, old_res.entry)
|
|
17
|
+
envelope = writer.move(
|
|
18
|
+
from_key: old_key, to_key: new_key,
|
|
19
|
+
new_mentry: new_res.entry
|
|
20
|
+
)
|
|
21
|
+
publish_renamed(old_key, new_key, envelope)
|
|
22
|
+
success_result(old_key, new_key, old_res, new_res, envelope)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def hook_context
|
|
28
|
+
@hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def prepare(old_key, new_key)
|
|
32
|
+
Textus::Manifest::Data.validate_key!(old_key)
|
|
33
|
+
Textus::Manifest::Data.validate_key!(new_key)
|
|
34
|
+
raise UsageError.new("mv: old and new keys are identical") if old_key == new_key
|
|
35
|
+
|
|
36
|
+
old_res = @manifest.resolver.resolve(old_key)
|
|
37
|
+
new_res = @manifest.resolver.resolve(new_key)
|
|
38
|
+
raise UnknownKey.new(old_key) unless reader.exists?(old_key)
|
|
39
|
+
|
|
40
|
+
validate_zone_and_format!(old_res.entry, new_res.entry)
|
|
41
|
+
@authorizer.authorize_write!(old_res.entry, role: @call.role)
|
|
42
|
+
@authorizer.authorize_write!(new_res.entry, role: @call.role)
|
|
43
|
+
raise UsageError.new("mv: target '#{new_key}' already exists at #{new_res.path}") if reader.exists?(new_key)
|
|
44
|
+
|
|
45
|
+
[old_res, new_res]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def validate_zone_and_format!(old_mentry, new_mentry)
|
|
49
|
+
if old_mentry.zone != new_mentry.zone
|
|
50
|
+
raise UsageError.new(
|
|
51
|
+
"mv: cross-zone move refused (#{old_mentry.zone} → #{new_mentry.zone}). " \
|
|
52
|
+
"Use put+delete for cross-zone moves.",
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
return if old_mentry.format == new_mentry.format
|
|
56
|
+
|
|
57
|
+
raise UsageError.new("mv: format mismatch (#{old_mentry.format} → #{new_mentry.format}); refusing.")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# If the source file lacks a UID, rewrite it in-place via the writer
|
|
61
|
+
# so a UID gets injected before the move. This produces one "put"
|
|
62
|
+
# audit row, then the "mv" row from Writer#move.
|
|
63
|
+
def ensure_uid!(old_key, old_mentry)
|
|
64
|
+
pre_env = reader.read(old_key)
|
|
65
|
+
return if pre_env.uid
|
|
66
|
+
|
|
67
|
+
writer.put(
|
|
68
|
+
old_key, mentry: old_mentry,
|
|
69
|
+
payload: Textus::Envelope::IO::Writer::Payload.new(
|
|
70
|
+
meta: pre_env.meta, body: pre_env.body, content: pre_env.content,
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def publish_renamed(old_key, new_key, envelope)
|
|
76
|
+
@events.publish(:entry_renamed,
|
|
77
|
+
ctx: hook_context,
|
|
78
|
+
key: new_key,
|
|
79
|
+
from_key: old_key,
|
|
80
|
+
to_key: new_key,
|
|
81
|
+
envelope: envelope)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def dry_run_result(old_key, new_key, old_res, new_res)
|
|
85
|
+
pre_env = reader.read(old_key)
|
|
86
|
+
{
|
|
87
|
+
"protocol" => PROTOCOL, "ok" => true, "dry_run" => true,
|
|
88
|
+
"from_key" => old_key, "to_key" => new_key,
|
|
89
|
+
"from_path" => old_res.path, "to_path" => new_res.path,
|
|
90
|
+
"uid" => pre_env.uid
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def success_result(old_key, new_key, old_res, new_res, envelope)
|
|
95
|
+
{
|
|
96
|
+
"protocol" => PROTOCOL, "ok" => true,
|
|
97
|
+
"from_key" => old_key, "to_key" => new_key,
|
|
98
|
+
"from_path" => old_res.path, "to_path" => new_res.path,
|
|
99
|
+
"uid" => envelope.uid,
|
|
100
|
+
"envelope" => envelope.to_h_for_wire
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def writer
|
|
105
|
+
@writer ||= Textus::Envelope::IO::Writer.new(
|
|
106
|
+
file_store: @container.file_store,
|
|
107
|
+
manifest: @container.manifest,
|
|
108
|
+
schemas: @container.schemas,
|
|
109
|
+
audit_log: @container.audit_log,
|
|
110
|
+
call: @call,
|
|
111
|
+
reader: reader,
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def reader
|
|
116
|
+
@reader ||= Textus::Envelope::IO::Reader.new(
|
|
117
|
+
file_store: @container.file_store,
|
|
118
|
+
manifest: @container.manifest,
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Write
|
|
3
|
+
# Single-pass publish use case: dispatches polymorphically to each
|
|
4
|
+
# entry's `publish_via` method. Derived entries materialize their body
|
|
5
|
+
# via Materializer; Nested entries fan out via publish_each; Leaf and
|
|
6
|
+
# Intake entries copy their stored body to publish_to targets. The
|
|
7
|
+
# Publish layer owns wiring (context, accumulation) but not per-kind
|
|
8
|
+
# logic.
|
|
9
|
+
#
|
|
10
|
+
# Return shape: { "protocol", "built", "published_leaves" }
|
|
11
|
+
class Publish
|
|
12
|
+
def initialize(container:, call:)
|
|
13
|
+
@container = container
|
|
14
|
+
@call = call
|
|
15
|
+
@manifest = container.manifest
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(prefix: nil)
|
|
19
|
+
built = []
|
|
20
|
+
leaves = []
|
|
21
|
+
context = build_context
|
|
22
|
+
|
|
23
|
+
@manifest.data.entries.each do |mentry|
|
|
24
|
+
next if prefix && !entry_matches_prefix?(mentry, prefix)
|
|
25
|
+
|
|
26
|
+
result = mentry.publish_via(context, prefix: prefix)
|
|
27
|
+
next if result.nil?
|
|
28
|
+
|
|
29
|
+
case result[:kind]
|
|
30
|
+
when :built then built << result[:value]
|
|
31
|
+
when :leaves then leaves.concat(result[:value])
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
{ "protocol" => Textus::PROTOCOL, "built" => built, "published_leaves" => leaves }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def build_context
|
|
41
|
+
Textus::Manifest::Entry::Base::PublishContext.new(
|
|
42
|
+
container: @container,
|
|
43
|
+
call: @call,
|
|
44
|
+
reader: reader,
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Whether the entry should be processed for the given prefix filter.
|
|
49
|
+
def entry_matches_prefix?(mentry, prefix)
|
|
50
|
+
return true unless prefix
|
|
51
|
+
|
|
52
|
+
case mentry
|
|
53
|
+
when Textus::Manifest::Entry::Nested
|
|
54
|
+
mentry.key.start_with?(prefix) ||
|
|
55
|
+
prefix.start_with?("#{mentry.key}.")
|
|
56
|
+
else
|
|
57
|
+
mentry.key.start_with?(prefix)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def reader
|
|
62
|
+
@reader ||= Textus::Read::Get.new(container: @container, call: @call)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Write
|
|
3
|
+
class Put
|
|
4
|
+
def initialize(container:, call:)
|
|
5
|
+
@container = container
|
|
6
|
+
@call = call
|
|
7
|
+
@manifest = container.manifest
|
|
8
|
+
@authorizer = container.authorizer
|
|
9
|
+
@events = container.events
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(key, meta: nil, body: nil, content: nil, if_etag: nil)
|
|
13
|
+
Textus::Manifest::Data.validate_key!(key)
|
|
14
|
+
mentry = @manifest.resolver.resolve(key).entry
|
|
15
|
+
@authorizer.authorize_write!(mentry, role: @call.role)
|
|
16
|
+
|
|
17
|
+
envelope = writer.put(
|
|
18
|
+
key,
|
|
19
|
+
mentry: mentry,
|
|
20
|
+
payload: Textus::Envelope::IO::Writer::Payload.new(
|
|
21
|
+
meta: meta, body: body, content: content,
|
|
22
|
+
),
|
|
23
|
+
if_etag: if_etag,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
@events.publish(:entry_put,
|
|
27
|
+
ctx: hook_context,
|
|
28
|
+
key: key,
|
|
29
|
+
envelope: envelope)
|
|
30
|
+
|
|
31
|
+
envelope
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def hook_context
|
|
37
|
+
@hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def writer
|
|
41
|
+
@writer ||= Textus::Envelope::IO::Writer.new(
|
|
42
|
+
file_store: @container.file_store,
|
|
43
|
+
manifest: @container.manifest,
|
|
44
|
+
schemas: @container.schemas,
|
|
45
|
+
audit_log: @container.audit_log,
|
|
46
|
+
call: @call,
|
|
47
|
+
reader: reader,
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def reader
|
|
52
|
+
@reader ||= Textus::Envelope::IO::Reader.new(
|
|
53
|
+
file_store: @container.file_store,
|
|
54
|
+
manifest: @container.manifest,
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Write
|
|
3
|
+
class RefreshAll
|
|
4
|
+
def initialize(container:, call:)
|
|
5
|
+
@container = container
|
|
6
|
+
@call = call
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(prefix: nil, zone: nil)
|
|
10
|
+
worker = Textus::Write::RefreshWorker.new(
|
|
11
|
+
container: @container, call: @call,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
stale_rows = Textus::Read::Stale.new(container: @container, call: @call).call(prefix: prefix, zone: zone)
|
|
15
|
+
refreshed = []
|
|
16
|
+
failed = []
|
|
17
|
+
skipped = []
|
|
18
|
+
|
|
19
|
+
stale_rows.each do |row|
|
|
20
|
+
key = row["key"] || row[:key]
|
|
21
|
+
reason = row["reason"] || row[:reason]
|
|
22
|
+
if reason.to_s.match?(/ttl exceeded|never refreshed/)
|
|
23
|
+
begin
|
|
24
|
+
worker.run(key)
|
|
25
|
+
refreshed << key
|
|
26
|
+
rescue Textus::Error => e
|
|
27
|
+
failed << { "key" => key, "error" => e.message }
|
|
28
|
+
end
|
|
29
|
+
else
|
|
30
|
+
skipped << { "key" => key, "reason" => reason }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
{
|
|
35
|
+
"protocol" => Textus::PROTOCOL,
|
|
36
|
+
"ok" => failed.empty?,
|
|
37
|
+
"refreshed" => refreshed,
|
|
38
|
+
"failed" => failed,
|
|
39
|
+
"skipped" => skipped,
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Write
|
|
3
|
+
class RefreshOrchestrator
|
|
4
|
+
# Collaborator (not a Dispatcher verb): constructed directly by RefreshWorker /
|
|
5
|
+
# GetOrRefresh, which pass their derived hook_context in. That's why this takes
|
|
6
|
+
# hook_context: explicitly while verb use cases derive their own.
|
|
7
|
+
def initialize(worker:, store_root:, events:, hook_context: nil, detached_spawner: nil)
|
|
8
|
+
@worker = worker
|
|
9
|
+
@store_root = store_root
|
|
10
|
+
@events = events
|
|
11
|
+
@hook_context = hook_context
|
|
12
|
+
@detached_spawner = detached_spawner || default_spawner
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def execute(action, key:)
|
|
16
|
+
case action
|
|
17
|
+
when Textus::Domain::Action::Return then Textus::Domain::Outcome::Skipped.new
|
|
18
|
+
when Textus::Domain::Action::RefreshSync then run_sync(key)
|
|
19
|
+
when Textus::Domain::Action::RefreshTimed then run_timed(action.budget_ms, key)
|
|
20
|
+
else raise ArgumentError.new("unknown action: #{action.inspect}")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def run_sync(key)
|
|
27
|
+
envelope = @worker.run(key)
|
|
28
|
+
Textus::Domain::Outcome::Refreshed.new(envelope: envelope)
|
|
29
|
+
rescue Textus::Error => e
|
|
30
|
+
Textus::Domain::Outcome::Failed.new(error: e)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def run_timed(budget_ms, key)
|
|
34
|
+
return run_timed_with_fork(budget_ms, key) if Textus::Ports::Refresh::Detached.supported?
|
|
35
|
+
|
|
36
|
+
run_timed_cooperative(budget_ms, key)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def run_timed_cooperative(budget_ms, key)
|
|
40
|
+
result = nil
|
|
41
|
+
thread = Thread.new do
|
|
42
|
+
result = @worker.run(key)
|
|
43
|
+
rescue Textus::Error => e
|
|
44
|
+
result = e
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
thread.join(budget_ms / 1000.0)
|
|
48
|
+
if thread.alive?
|
|
49
|
+
thread.kill
|
|
50
|
+
return Textus::Domain::Outcome::Failed.new(
|
|
51
|
+
error: Textus::UsageError.new(
|
|
52
|
+
"refresh exceeded budget #{budget_ms}ms (no fork available — cooperative cancel)",
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
if result.is_a?(Textus::Error)
|
|
58
|
+
Textus::Domain::Outcome::Failed.new(error: result)
|
|
59
|
+
else
|
|
60
|
+
Textus::Domain::Outcome::Refreshed.new(envelope: result)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def run_timed_with_fork(budget_ms, key)
|
|
65
|
+
result = nil
|
|
66
|
+
thread = Thread.new do
|
|
67
|
+
result = @worker.run(key)
|
|
68
|
+
rescue Textus::Error => e
|
|
69
|
+
result = e
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
thread.join(budget_ms / 1000.0)
|
|
73
|
+
|
|
74
|
+
if thread.alive?
|
|
75
|
+
thread.kill
|
|
76
|
+
|
|
77
|
+
# Single-flight: if a sibling process / earlier fork holds the
|
|
78
|
+
# per-leaf lock, don't fork another worker — they're already
|
|
79
|
+
# doing this work.
|
|
80
|
+
probe = Textus::Ports::Refresh::Lock.new(root: @store_root, key: key)
|
|
81
|
+
return Textus::Domain::Outcome::Detached.new unless probe.try_acquire
|
|
82
|
+
|
|
83
|
+
probe.release
|
|
84
|
+
|
|
85
|
+
payload = { key: key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms }
|
|
86
|
+
payload[:ctx] = @hook_context if @hook_context
|
|
87
|
+
@events.publish(:refresh_backgrounded, **payload)
|
|
88
|
+
@detached_spawner.call(store_root: @store_root, key: key)
|
|
89
|
+
Textus::Domain::Outcome::Detached.new
|
|
90
|
+
elsif result.is_a?(Textus::Error)
|
|
91
|
+
Textus::Domain::Outcome::Failed.new(error: result)
|
|
92
|
+
else
|
|
93
|
+
Textus::Domain::Outcome::Refreshed.new(envelope: result)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def default_spawner
|
|
98
|
+
Textus::Ports::Refresh::Detached.method(:spawn)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|