textus 0.15.0 → 0.20.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 +50 -55
- data/CHANGELOG.md +486 -0
- data/README.md +13 -9
- data/SPEC.md +13 -10
- data/docs/conventions.md +2 -2
- data/lib/textus/application/context.rb +20 -34
- data/lib/textus/application/policy/predicates/human_accept.rb +30 -0
- data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
- data/lib/textus/{domain → application}/policy/promotion.rb +20 -3
- data/lib/textus/application/projection.rb +91 -0
- data/lib/textus/application/reads/audit.rb +4 -4
- data/lib/textus/application/reads/blame.rb +11 -8
- data/lib/textus/application/reads/deps.rb +14 -3
- data/lib/textus/application/reads/freshness.rb +17 -6
- data/lib/textus/application/reads/get.rb +37 -11
- data/lib/textus/application/reads/get_or_refresh.rb +8 -8
- data/lib/textus/application/reads/list.rb +5 -3
- data/lib/textus/application/reads/policy_explain.rb +3 -3
- data/lib/textus/application/reads/published.rb +5 -3
- data/lib/textus/application/reads/rdeps.rb +15 -3
- data/lib/textus/application/reads/schema_envelope.rb +6 -3
- data/lib/textus/application/reads/stale.rb +3 -3
- data/lib/textus/application/reads/uid.rb +11 -3
- data/lib/textus/application/reads/validate_all.rb +12 -3
- data/lib/textus/application/reads/validator.rb +84 -0
- data/lib/textus/application/reads/where.rb +6 -3
- data/lib/textus/application/refresh/all.rb +16 -5
- data/lib/textus/application/refresh/orchestrator.rb +9 -9
- data/lib/textus/application/refresh/worker.rb +59 -32
- data/lib/textus/application/tools/migrate_keys.rb +191 -0
- data/lib/textus/application/tools/migrate_manifest_to_kinds.rb +31 -0
- data/lib/textus/application/writes/accept.rb +36 -13
- data/lib/textus/application/writes/delete.rb +13 -15
- data/lib/textus/application/writes/envelope_io.rb +166 -0
- data/lib/textus/application/writes/materializer.rb +50 -0
- data/lib/textus/application/writes/mv.rb +56 -95
- data/lib/textus/application/writes/publish.rb +132 -27
- data/lib/textus/application/writes/put.rb +17 -20
- data/lib/textus/application/writes/reject.rb +18 -9
- data/lib/textus/builder/pipeline.rb +21 -15
- data/lib/textus/builder/renderer/json.rb +4 -1
- data/lib/textus/builder/renderer/markdown.rb +7 -1
- data/lib/textus/builder/renderer/yaml.rb +4 -1
- 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 -5
- 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 +4 -2
- data/lib/textus/cli/verb/hook_run.rb +6 -4
- data/lib/textus/cli/verb/hooks.rb +8 -5
- 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 +35 -3
- 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 +5 -4
- 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 +4 -2
- 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/handler_allowlist.rb +3 -2
- data/lib/textus/doctor/check/hooks.rb +4 -2
- data/lib/textus/doctor/check/illegal_keys.rb +6 -5
- data/lib/textus/doctor/check/intake_registration.rb +5 -5
- data/lib/textus/doctor/check/manifest_files.rb +1 -1
- data/lib/textus/doctor/check/protocol_version.rb +2 -2
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/sentinels.rb +2 -2
- data/lib/textus/doctor/check/templates.rb +4 -3
- data/lib/textus/doctor.rb +3 -4
- data/lib/textus/domain/authorizer.rb +37 -0
- 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/{store → domain}/sentinel.rb +1 -1
- data/lib/textus/{store → domain}/staleness/generator_check.rb +9 -8
- data/lib/textus/{store → domain}/staleness/intake_check.rb +3 -3
- 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/bus.rb +155 -0
- data/lib/textus/hooks/context.rb +38 -0
- data/lib/textus/hooks/fire_report.rb +23 -0
- data/lib/textus/hooks/loader.rb +20 -17
- data/lib/textus/{store → infra}/audit_log.rb +1 -1
- data/lib/textus/infra/audit_subscriber.rb +43 -0
- data/lib/textus/infra/event_bus.rb +3 -3
- data/lib/textus/infra/publisher.rb +3 -3
- data/lib/textus/infra/refresh/detached.rb +1 -1
- data/lib/textus/infra/storage/file_store.rb +26 -0
- data/lib/textus/init.rb +14 -11
- data/lib/textus/intro.rb +7 -7
- data/lib/textus/manifest/entry/base.rb +38 -0
- data/lib/textus/manifest/entry/derived.rb +25 -0
- data/lib/textus/manifest/entry/intake.rb +19 -0
- data/lib/textus/manifest/entry/leaf.rb +16 -0
- data/lib/textus/manifest/entry/nested.rb +39 -0
- data/lib/textus/manifest/entry/parser.rb +64 -31
- data/lib/textus/manifest/entry/validators/events.rb +3 -2
- data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -3
- data/lib/textus/manifest/entry/validators/index_filename.rb +11 -10
- data/lib/textus/manifest/entry/validators/inject_intro.rb +5 -2
- data/lib/textus/manifest/entry/validators/publish_each.rb +9 -6
- data/lib/textus/manifest/entry.rb +0 -72
- data/lib/textus/manifest/resolution.rb +5 -0
- data/lib/textus/manifest/resolver.rb +109 -0
- data/lib/textus/manifest/schema.rb +1 -1
- data/lib/textus/manifest.rb +4 -100
- data/lib/textus/operations.rb +147 -23
- data/lib/textus/schema/tools.rb +7 -7
- 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 +31 -23
- data/lib/textus/application/writes/build.rb +0 -79
- data/lib/textus/dependencies.rb +0 -23
- data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
- data/lib/textus/hooks/dispatcher.rb +0 -63
- data/lib/textus/hooks/dsl.rb +0 -11
- data/lib/textus/hooks/registry.rb +0 -81
- data/lib/textus/migrate_keys.rb +0 -187
- 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/projection.rb +0 -89
- data/lib/textus/refresh.rb +0 -39
- data/lib/textus/store/reader.rb +0 -69
- data/lib/textus/store/validator.rb +0 -82
- data/lib/textus/store/writer.rb +0 -102
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Application
|
|
5
|
+
module Tools
|
|
6
|
+
module MigrateManifestToKinds
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def upgrade_yaml(yaml_text)
|
|
10
|
+
raw = YAML.safe_load(yaml_text, aliases: false)
|
|
11
|
+
raw["entries"] = Array(raw["entries"]).map { |row| upgrade_row(row) }
|
|
12
|
+
YAML.dump(raw)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def upgrade_row(row)
|
|
16
|
+
return row if row["kind"]
|
|
17
|
+
|
|
18
|
+
row.merge("kind" => infer_kind(row))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def infer_kind(row)
|
|
22
|
+
return "intake" if row["intake"].is_a?(Hash) || row["intake_handler"]
|
|
23
|
+
return "derived" if row["template"] || row["compute"] || row["generator"] || row["projection"]
|
|
24
|
+
return "nested" if row["nested"] == true
|
|
25
|
+
|
|
26
|
+
"leaf"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -2,15 +2,23 @@ module Textus
|
|
|
2
2
|
module Application
|
|
3
3
|
module Writes
|
|
4
4
|
class Accept
|
|
5
|
-
def initialize(ctx:, bus:)
|
|
6
|
-
@ctx
|
|
7
|
-
@
|
|
5
|
+
def initialize(ctx:, manifest:, file_store:, schemas:, envelope_io:, bus:, authorizer:, hook_context:) # rubocop:disable Metrics/ParameterLists
|
|
6
|
+
@ctx = ctx
|
|
7
|
+
@manifest = manifest
|
|
8
|
+
@file_store = file_store
|
|
9
|
+
@schemas = schemas
|
|
10
|
+
@envelope_io = envelope_io
|
|
11
|
+
@bus = bus
|
|
12
|
+
@authorizer = authorizer
|
|
13
|
+
@hook_context = hook_context
|
|
8
14
|
end
|
|
9
15
|
|
|
10
16
|
def call(pending_key)
|
|
11
17
|
raise ProposalError.new("only human role can accept proposals; got '#{@ctx.role}'") unless @ctx.role == "human"
|
|
12
18
|
|
|
13
|
-
env =
|
|
19
|
+
env = Textus::Application::Reads::Get.new(
|
|
20
|
+
ctx: @ctx, manifest: @manifest, file_store: @file_store,
|
|
21
|
+
).call(pending_key)
|
|
14
22
|
proposal = env.meta["proposal"] or raise ProposalError.new("entry has no proposal block: #{pending_key}")
|
|
15
23
|
target = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
|
|
16
24
|
action = proposal["action"] || "put"
|
|
@@ -23,33 +31,48 @@ module Textus
|
|
|
23
31
|
# target. Not related to the removed intake-handler legacy bridge.
|
|
24
32
|
target_meta = env.meta["frontmatter"] || {}
|
|
25
33
|
target_body = env.body
|
|
26
|
-
|
|
34
|
+
put_op.call(target, meta: target_meta, body: target_body)
|
|
27
35
|
when "delete"
|
|
28
|
-
|
|
36
|
+
delete_op.call(target)
|
|
29
37
|
else
|
|
30
38
|
raise ProposalError.new("unknown action: #{action}")
|
|
31
39
|
end
|
|
32
40
|
|
|
33
|
-
|
|
41
|
+
delete_op.call(pending_key)
|
|
34
42
|
|
|
35
43
|
@bus.publish(:proposal_accepted,
|
|
36
|
-
|
|
44
|
+
ctx: @hook_context,
|
|
37
45
|
key: pending_key,
|
|
38
|
-
target_key: target
|
|
39
|
-
correlation_id: @ctx.correlation_id)
|
|
46
|
+
target_key: target)
|
|
40
47
|
|
|
41
48
|
{ "protocol" => PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action }
|
|
42
49
|
end
|
|
43
50
|
|
|
44
51
|
private
|
|
45
52
|
|
|
53
|
+
def put_op
|
|
54
|
+
@put_op ||= Textus::Application::Writes::Put.new(
|
|
55
|
+
ctx: @ctx, manifest: @manifest, envelope_io: @envelope_io,
|
|
56
|
+
bus: @bus, authorizer: @authorizer, hook_context: @hook_context
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def delete_op
|
|
61
|
+
@delete_op ||= Textus::Application::Writes::Delete.new(
|
|
62
|
+
ctx: @ctx, manifest: @manifest, envelope_io: @envelope_io,
|
|
63
|
+
bus: @bus, authorizer: @authorizer, hook_context: @hook_context
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
46
67
|
def evaluate_promotion!(env, target_key)
|
|
47
|
-
rules = @
|
|
68
|
+
rules = @manifest.rules_for(target_key)
|
|
48
69
|
promote = rules.promote
|
|
49
70
|
return if promote.nil? || promote.requires.empty?
|
|
50
71
|
|
|
51
|
-
policy = Textus::
|
|
52
|
-
result = policy.evaluate(
|
|
72
|
+
policy = Textus::Application::Policy::Promotion.from_names(promote.requires)
|
|
73
|
+
result = policy.evaluate(
|
|
74
|
+
entry: env, schemas: @schemas, manifest: @manifest, role: @ctx.role,
|
|
75
|
+
)
|
|
53
76
|
return if result.ok?
|
|
54
77
|
|
|
55
78
|
raise ProposalError.new(
|
|
@@ -2,29 +2,27 @@ module Textus
|
|
|
2
2
|
module Application
|
|
3
3
|
module Writes
|
|
4
4
|
class Delete
|
|
5
|
-
def initialize(ctx:, bus:)
|
|
6
|
-
@ctx
|
|
7
|
-
@
|
|
5
|
+
def initialize(ctx:, manifest:, envelope_io:, bus:, authorizer:, hook_context:)
|
|
6
|
+
@ctx = ctx
|
|
7
|
+
@manifest = manifest
|
|
8
|
+
@envelope_io = envelope_io
|
|
9
|
+
@bus = bus
|
|
10
|
+
@authorizer = authorizer
|
|
11
|
+
@hook_context = hook_context
|
|
8
12
|
end
|
|
9
13
|
|
|
10
14
|
def call(key, if_etag: nil, suppress_events: false)
|
|
11
|
-
@
|
|
12
|
-
mentry
|
|
15
|
+
@manifest.validate_key!(key)
|
|
16
|
+
mentry = @manifest.resolver.resolve(key).entry
|
|
13
17
|
|
|
14
|
-
|
|
15
|
-
raise WriteForbidden.new(key, mentry.zone,
|
|
16
|
-
writers: @ctx.store.manifest.zone_writers(mentry.zone))
|
|
17
|
-
end
|
|
18
|
+
@authorizer.authorize_write!(mentry, role: @ctx.role)
|
|
18
19
|
|
|
19
|
-
@
|
|
20
|
-
key, ctx: @ctx, if_etag: if_etag
|
|
21
|
-
)
|
|
20
|
+
@envelope_io.delete(key, mentry: mentry, if_etag: if_etag)
|
|
22
21
|
|
|
23
22
|
unless suppress_events
|
|
24
23
|
@bus.publish(:entry_deleted,
|
|
25
|
-
|
|
26
|
-
key: key
|
|
27
|
-
correlation_id: @ctx.correlation_id)
|
|
24
|
+
ctx: @hook_context,
|
|
25
|
+
key: key)
|
|
28
26
|
end
|
|
29
27
|
|
|
30
28
|
{ "protocol" => Textus::PROTOCOL, "ok" => true, "key" => key, "deleted" => true }
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Application
|
|
5
|
+
module Writes
|
|
6
|
+
# Owns the write pipeline (validate, serialize, etag-check, write, audit)
|
|
7
|
+
# extracted from Store::Writer. Talks to ports (FileStore, Schemas,
|
|
8
|
+
# AuditLog, Manifest) instead of File/FileUtils and Store directly.
|
|
9
|
+
#
|
|
10
|
+
# No permission check, no event firing — those belong to the caller
|
|
11
|
+
# (Application::Writes::Put / ::Delete / ::Mv).
|
|
12
|
+
class EnvelopeIO
|
|
13
|
+
Payload = Data.define(:meta, :body, :content)
|
|
14
|
+
|
|
15
|
+
def initialize(file_store:, manifest:, schemas:, audit_log:, ctx:)
|
|
16
|
+
@file_store = file_store
|
|
17
|
+
@manifest = manifest
|
|
18
|
+
@schemas = schemas
|
|
19
|
+
@audit_log = audit_log
|
|
20
|
+
@ctx = ctx
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def exists?(path) = @file_store.exists?(path)
|
|
24
|
+
|
|
25
|
+
# Reads an envelope by key, returning nil when absent. Used by Mv
|
|
26
|
+
# to inspect pre-move state (UID presence, content surfacing) so
|
|
27
|
+
# the move pipeline can consolidate I/O in one place.
|
|
28
|
+
def read_envelope(key)
|
|
29
|
+
res = @manifest.resolver.resolve(key)
|
|
30
|
+
path = res.path
|
|
31
|
+
return nil unless @file_store.exists?(path)
|
|
32
|
+
|
|
33
|
+
mentry = res.entry
|
|
34
|
+
raw = @file_store.read(path)
|
|
35
|
+
parsed = Entry.for_format(mentry.format).parse(raw, path: path)
|
|
36
|
+
Envelope.build(
|
|
37
|
+
key: key, mentry: mentry, path: path,
|
|
38
|
+
meta: parsed["_meta"], body: parsed["body"],
|
|
39
|
+
etag: Etag.for_bytes(raw), content: parsed["content"]
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def write(key, mentry:, payload:, if_etag: nil)
|
|
44
|
+
path = @manifest.resolver.resolve(key).path
|
|
45
|
+
|
|
46
|
+
meta = payload.meta || {}
|
|
47
|
+
strategy = Entry.for_format(mentry.format)
|
|
48
|
+
|
|
49
|
+
existing_uid = existing_uid_for(mentry, path)
|
|
50
|
+
meta, content = ensure_uid(mentry.format, meta, payload.content, existing_uid)
|
|
51
|
+
|
|
52
|
+
bytes, eff_meta, eff_body, eff_content = serialize_for_put(
|
|
53
|
+
mentry: mentry, path: path, strategy: strategy,
|
|
54
|
+
meta: meta, body: payload.body, content: content
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
enforce_name_match!(path, eff_meta, mentry.format)
|
|
58
|
+
|
|
59
|
+
schema = @schemas.fetch_or_nil(mentry.schema)
|
|
60
|
+
if schema
|
|
61
|
+
Entry.for_format(mentry.format).validate_against(
|
|
62
|
+
schema,
|
|
63
|
+
{ "_meta" => eff_meta, "content" => eff_content },
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
etag_before = @file_store.exists?(path) ? @file_store.etag(path) : nil
|
|
68
|
+
raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && (etag_before != if_etag)
|
|
69
|
+
|
|
70
|
+
@file_store.write(path, bytes)
|
|
71
|
+
etag_after = Etag.for_bytes(bytes)
|
|
72
|
+
@audit_log.append(
|
|
73
|
+
role: @ctx.role, verb: "put", key: key,
|
|
74
|
+
etag_before: etag_before, etag_after: etag_after,
|
|
75
|
+
extras: @ctx.correlation_id ? { "correlation_id" => @ctx.correlation_id } : nil
|
|
76
|
+
)
|
|
77
|
+
Envelope.build(
|
|
78
|
+
key: key, mentry: mentry, path: path,
|
|
79
|
+
meta: eff_meta, body: eff_body, etag: etag_after, content: eff_content
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def delete(key, mentry:, if_etag: nil)
|
|
84
|
+
_ = mentry
|
|
85
|
+
path = @manifest.resolver.resolve(key).path
|
|
86
|
+
raise UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)) unless @file_store.exists?(path)
|
|
87
|
+
|
|
88
|
+
etag_before = @file_store.etag(path)
|
|
89
|
+
raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before
|
|
90
|
+
|
|
91
|
+
@file_store.delete(path)
|
|
92
|
+
@audit_log.append(
|
|
93
|
+
role: @ctx.role, verb: "delete", key: key,
|
|
94
|
+
etag_before: etag_before, etag_after: nil,
|
|
95
|
+
extras: @ctx.correlation_id ? { "correlation_id" => @ctx.correlation_id } : nil
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def move(from_key:, to_key:, new_mentry:, if_etag: nil)
|
|
100
|
+
from_path = @manifest.resolver.resolve(from_key).path
|
|
101
|
+
to_path = @manifest.resolver.resolve(to_key).path
|
|
102
|
+
raise UnknownKey.new(from_key, suggestions: @manifest.resolver.suggestions_for(from_key)) unless @file_store.exists?(from_path)
|
|
103
|
+
|
|
104
|
+
etag_before = @file_store.etag(from_path)
|
|
105
|
+
raise EtagMismatch.new(from_key, if_etag, etag_before) if if_etag && if_etag != etag_before
|
|
106
|
+
|
|
107
|
+
FileUtils.mkdir_p(File.dirname(to_path))
|
|
108
|
+
FileUtils.mv(from_path, to_path)
|
|
109
|
+
basename = to_key.split(".").last
|
|
110
|
+
Entry.for_format(new_mentry.format).rewrite_name(to_path, basename)
|
|
111
|
+
etag_after = Etag.for_file(to_path)
|
|
112
|
+
|
|
113
|
+
raw = @file_store.read(to_path)
|
|
114
|
+
parsed = Entry.for_format(new_mentry.format).parse(raw, path: to_path)
|
|
115
|
+
envelope = Envelope.build(
|
|
116
|
+
key: to_key, mentry: new_mentry, path: to_path,
|
|
117
|
+
meta: parsed["_meta"], body: parsed["body"],
|
|
118
|
+
etag: etag_after, content: parsed["content"]
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
extras = {
|
|
122
|
+
"from_key" => from_key, "to_key" => to_key,
|
|
123
|
+
"from_path" => from_path, "to_path" => to_path,
|
|
124
|
+
"uid" => envelope.uid
|
|
125
|
+
}
|
|
126
|
+
extras["correlation_id"] = @ctx.correlation_id if @ctx.correlation_id
|
|
127
|
+
|
|
128
|
+
@audit_log.append(
|
|
129
|
+
role: @ctx.role, verb: "mv", key: to_key,
|
|
130
|
+
etag_before: etag_before, etag_after: etag_after,
|
|
131
|
+
extras: extras
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
envelope
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def existing_uid_for(mentry, path)
|
|
140
|
+
return nil unless @file_store.exists?(path)
|
|
141
|
+
|
|
142
|
+
raw = @file_store.read(path)
|
|
143
|
+
parsed = Entry.for_format(mentry.format).parse(raw, path: path)
|
|
144
|
+
Envelope.extract_uid(parsed["_meta"])
|
|
145
|
+
rescue StandardError
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def ensure_uid(format, meta, content, existing_uid)
|
|
150
|
+
Textus::Entry.for_format(format).inject_uid(meta, content, existing_uid)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def enforce_name_match!(path, meta, format)
|
|
154
|
+
Textus::Entry.for_format(format).enforce_name_match!(path, meta)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def serialize_for_put(mentry:, path:, strategy:, meta:, body:, content:)
|
|
158
|
+
_ = strategy
|
|
159
|
+
Textus::Entry.for_format(mentry.format).serialize_for_put(
|
|
160
|
+
meta: meta, body: body, content: content, path: path,
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Application
|
|
5
|
+
module Writes
|
|
6
|
+
# Materializes a single Derived manifest entry onto disk by running
|
|
7
|
+
# the builder pipeline (template + projection + external runner).
|
|
8
|
+
# Extracted from Application::Writes::Build so that Publish can reuse
|
|
9
|
+
# it without creating a Build dependency.
|
|
10
|
+
class Materializer
|
|
11
|
+
def initialize(ctx:, manifest:, file_store:, bus:, root:, store:)
|
|
12
|
+
@ctx = ctx
|
|
13
|
+
@manifest = manifest
|
|
14
|
+
@file_store = file_store
|
|
15
|
+
@bus = bus
|
|
16
|
+
@root = root
|
|
17
|
+
@store = store
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Runs the builder pipeline for `mentry` and returns the on-disk
|
|
21
|
+
# target_path string.
|
|
22
|
+
def run(mentry)
|
|
23
|
+
reader = Textus::Application::Reads::Get.new(
|
|
24
|
+
ctx: @ctx, manifest: @manifest, file_store: @file_store,
|
|
25
|
+
)
|
|
26
|
+
lister = Textus::Application::Reads::List.new(manifest: @manifest)
|
|
27
|
+
Builder::Pipeline.run(
|
|
28
|
+
mentry: mentry,
|
|
29
|
+
manifest: @manifest,
|
|
30
|
+
reader: reader.method(:call),
|
|
31
|
+
lister: lister.method(:call),
|
|
32
|
+
transform_resolver: ->(name) { @bus.rpc_callable(:transform_rows, name) },
|
|
33
|
+
template_loader: ->(name) { read_template(name) },
|
|
34
|
+
transform_context: @store,
|
|
35
|
+
inject_intro: -> { Textus::Intro.run(@store) },
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def read_template(name)
|
|
42
|
+
tpl_path = File.join(@root, "templates", name)
|
|
43
|
+
raise TemplateError.new("template not found: #{tpl_path}", template_name: name) unless File.exist?(tpl_path)
|
|
44
|
+
|
|
45
|
+
File.read(tpl_path)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -1,55 +1,46 @@
|
|
|
1
|
-
require "fileutils"
|
|
2
|
-
|
|
3
1
|
module Textus
|
|
4
2
|
module Application
|
|
5
3
|
module Writes
|
|
6
4
|
class Mv
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
@
|
|
14
|
-
@bus = bus
|
|
5
|
+
def initialize(ctx:, manifest:, envelope_io:, bus:, authorizer:, hook_context:)
|
|
6
|
+
@ctx = ctx
|
|
7
|
+
@manifest = manifest
|
|
8
|
+
@envelope_io = envelope_io
|
|
9
|
+
@bus = bus
|
|
10
|
+
@authorizer = authorizer
|
|
11
|
+
@hook_context = hook_context
|
|
15
12
|
end
|
|
16
13
|
|
|
17
14
|
def call(old_key, new_key, dry_run: false)
|
|
18
|
-
|
|
19
|
-
return dry_run_result(
|
|
15
|
+
old_res, new_res = prepare(old_key, new_key)
|
|
16
|
+
return dry_run_result(old_key, new_key, old_res, new_res) if dry_run
|
|
20
17
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
ensure_uid!(old_key, old_res.entry)
|
|
19
|
+
envelope = @envelope_io.move(
|
|
20
|
+
from_key: old_key, to_key: new_key,
|
|
21
|
+
new_mentry: new_res.entry
|
|
22
|
+
)
|
|
23
|
+
publish_renamed(old_key, new_key, envelope)
|
|
24
|
+
success_result(old_key, new_key, old_res, new_res, envelope)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
private
|
|
28
28
|
|
|
29
|
-
def
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def prepare_plan(old_key, new_key)
|
|
33
|
-
manifest.validate_key!(old_key)
|
|
34
|
-
manifest.validate_key!(new_key)
|
|
29
|
+
def prepare(old_key, new_key)
|
|
30
|
+
@manifest.validate_key!(old_key)
|
|
31
|
+
@manifest.validate_key!(new_key)
|
|
35
32
|
raise UsageError.new("mv: old and new keys are identical") if old_key == new_key
|
|
36
33
|
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
old_res = @manifest.resolver.resolve(old_key)
|
|
35
|
+
new_res = @manifest.resolver.resolve(new_key)
|
|
36
|
+
raise UnknownKey.new(old_key) unless @envelope_io.exists?(old_res.path)
|
|
39
37
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
raise UsageError.new("mv: target '#{new_key}' already exists at #{
|
|
38
|
+
validate_zone_and_format!(old_res.entry, new_res.entry)
|
|
39
|
+
@authorizer.authorize_write!(old_res.entry, role: @ctx.role)
|
|
40
|
+
@authorizer.authorize_write!(new_res.entry, role: @ctx.role)
|
|
41
|
+
raise UsageError.new("mv: target '#{new_key}' already exists at #{new_res.path}") if @envelope_io.exists?(new_res.path)
|
|
44
42
|
|
|
45
|
-
|
|
46
|
-
plan = MovePlan.new(
|
|
47
|
-
old_key: old_key, new_key: new_key,
|
|
48
|
-
old_path: old_path, new_path: new_path,
|
|
49
|
-
new_mentry: new_mentry,
|
|
50
|
-
uid: pre_env.uid, etag_before: pre_env.etag
|
|
51
|
-
)
|
|
52
|
-
[plan, pre_env]
|
|
43
|
+
[old_res, new_res]
|
|
53
44
|
end
|
|
54
45
|
|
|
55
46
|
def validate_zone_and_format!(old_mentry, new_mentry)
|
|
@@ -64,80 +55,50 @@ module Textus
|
|
|
64
55
|
raise UsageError.new("mv: format mismatch (#{old_mentry.format} → #{new_mentry.format}); refusing.")
|
|
65
56
|
end
|
|
66
57
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
body: pre_env.body,
|
|
81
|
-
content: pre_env.content,
|
|
82
|
-
suppress_events: true,
|
|
58
|
+
# If the source file lacks a UID, rewrite it in-place via EnvelopeIO#write
|
|
59
|
+
# so a UID gets injected before the move. This replaces the previous
|
|
60
|
+
# Put(suppress_events: true) bypass with a direct EnvelopeIO call —
|
|
61
|
+
# producing one "put" audit row, then the "mv" row from EnvelopeIO#move.
|
|
62
|
+
def ensure_uid!(old_key, old_mentry)
|
|
63
|
+
pre_env = @envelope_io.read_envelope(old_key)
|
|
64
|
+
return if pre_env.uid
|
|
65
|
+
|
|
66
|
+
@envelope_io.write(
|
|
67
|
+
old_key, mentry: old_mentry,
|
|
68
|
+
payload: EnvelopeIO::Payload.new(
|
|
69
|
+
meta: pre_env.meta, body: pre_env.body, content: pre_env.content,
|
|
70
|
+
)
|
|
83
71
|
)
|
|
84
|
-
plan.with(uid: env.uid, etag_before: env.etag)
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def perform_move!(plan)
|
|
88
|
-
FileUtils.mkdir_p(File.dirname(plan.new_path))
|
|
89
|
-
FileUtils.mv(plan.old_path, plan.new_path)
|
|
90
|
-
rewrite_name_for_mv!(plan.new_mentry, plan.new_path, plan.new_key)
|
|
91
|
-
Etag.for_file(plan.new_path)
|
|
92
72
|
end
|
|
93
73
|
|
|
94
|
-
def
|
|
95
|
-
extras = {
|
|
96
|
-
"from_key" => plan.old_key, "to_key" => plan.new_key,
|
|
97
|
-
"from_path" => plan.old_path, "to_path" => plan.new_path,
|
|
98
|
-
"uid" => plan.uid
|
|
99
|
-
}
|
|
100
|
-
extras["correlation_id"] = @ctx.correlation_id if @ctx.correlation_id
|
|
101
|
-
|
|
102
|
-
@ctx.store.audit_log.append(
|
|
103
|
-
role: @ctx.role, verb: "mv", key: plan.new_key,
|
|
104
|
-
etag_before: plan.etag_before, etag_after: etag_after,
|
|
105
|
-
extras: extras
|
|
106
|
-
)
|
|
107
|
-
new_envelope = reader.get(plan.new_key)
|
|
74
|
+
def publish_renamed(old_key, new_key, envelope)
|
|
108
75
|
@bus.publish(:entry_renamed,
|
|
109
|
-
|
|
110
|
-
key:
|
|
111
|
-
from_key:
|
|
112
|
-
to_key:
|
|
113
|
-
envelope:
|
|
114
|
-
correlation_id: @ctx.correlation_id)
|
|
115
|
-
new_envelope
|
|
76
|
+
ctx: @hook_context,
|
|
77
|
+
key: new_key,
|
|
78
|
+
from_key: old_key,
|
|
79
|
+
to_key: new_key,
|
|
80
|
+
envelope: envelope)
|
|
116
81
|
end
|
|
117
82
|
|
|
118
|
-
def dry_run_result(
|
|
83
|
+
def dry_run_result(old_key, new_key, old_res, new_res)
|
|
84
|
+
pre_env = @envelope_io.read_envelope(old_key)
|
|
119
85
|
{
|
|
120
86
|
"protocol" => PROTOCOL, "ok" => true, "dry_run" => true,
|
|
121
|
-
"from_key" =>
|
|
122
|
-
"from_path" =>
|
|
123
|
-
"uid" =>
|
|
87
|
+
"from_key" => old_key, "to_key" => new_key,
|
|
88
|
+
"from_path" => old_res.path, "to_path" => new_res.path,
|
|
89
|
+
"uid" => pre_env.uid
|
|
124
90
|
}
|
|
125
91
|
end
|
|
126
92
|
|
|
127
|
-
def success_result(
|
|
93
|
+
def success_result(old_key, new_key, old_res, new_res, envelope)
|
|
128
94
|
{
|
|
129
95
|
"protocol" => PROTOCOL, "ok" => true,
|
|
130
|
-
"from_key" =>
|
|
131
|
-
"from_path" =>
|
|
132
|
-
"uid" =>
|
|
133
|
-
"envelope" =>
|
|
96
|
+
"from_key" => old_key, "to_key" => new_key,
|
|
97
|
+
"from_path" => old_res.path, "to_path" => new_res.path,
|
|
98
|
+
"uid" => envelope.uid,
|
|
99
|
+
"envelope" => envelope.to_h_for_wire
|
|
134
100
|
}
|
|
135
101
|
end
|
|
136
|
-
|
|
137
|
-
def rewrite_name_for_mv!(mentry, new_path, new_key)
|
|
138
|
-
basename = new_key.split(".").last
|
|
139
|
-
Entry.for_format(mentry.format).rewrite_name(new_path, basename)
|
|
140
|
-
end
|
|
141
102
|
end
|
|
142
103
|
end
|
|
143
104
|
end
|