textus 0.22.0 → 0.26.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 +148 -45
- data/CHANGELOG.md +102 -0
- data/README.md +1 -1
- data/SPEC.md +12 -12
- data/docs/conventions.md +10 -0
- data/lib/textus/application/caps.rb +49 -0
- data/lib/textus/application/context.rb +2 -2
- data/lib/textus/application/envelope/reader.rb +44 -0
- data/lib/textus/application/{writes/envelope_io.rb → envelope/writer.rb} +24 -50
- data/lib/textus/application/maintenance/key_delete_prefix.rb +44 -0
- data/lib/textus/application/maintenance/key_mv_prefix.rb +57 -0
- data/lib/textus/application/maintenance/migrate.rb +59 -0
- data/lib/textus/application/maintenance/rule_lint.rb +65 -0
- data/lib/textus/application/maintenance/zone_mv.rb +60 -0
- data/lib/textus/application/maintenance.rb +17 -0
- data/lib/textus/application/projection.rb +12 -10
- data/lib/textus/application/read/audit.rb +106 -0
- data/lib/textus/application/read/blame.rb +91 -0
- data/lib/textus/application/read/deps.rb +34 -0
- data/lib/textus/application/read/freshness.rb +110 -0
- data/lib/textus/application/read/get.rb +75 -0
- data/lib/textus/application/read/get_or_refresh.rb +63 -0
- data/lib/textus/application/read/list.rb +25 -0
- data/lib/textus/application/read/policy_explain.rb +47 -0
- data/lib/textus/application/read/published.rb +25 -0
- data/lib/textus/application/read/pulse.rb +101 -0
- data/lib/textus/application/read/rdeps.rb +35 -0
- data/lib/textus/application/read/schema_envelope.rb +26 -0
- data/lib/textus/application/read/stale.rb +23 -0
- data/lib/textus/application/read/uid.rb +30 -0
- data/lib/textus/application/read/validate_all.rb +32 -0
- data/lib/textus/application/{reads → read}/validator.rb +2 -2
- data/lib/textus/application/read/where.rb +26 -0
- data/lib/textus/application/use_case.rb +22 -0
- data/lib/textus/application/write/accept.rb +102 -0
- data/lib/textus/application/{writes → write}/authority_gate.rb +3 -3
- data/lib/textus/application/write/delete.rb +45 -0
- data/lib/textus/application/{writes → write}/materializer.rb +14 -15
- data/lib/textus/application/write/mv.rb +118 -0
- data/lib/textus/application/write/publish.rb +96 -0
- data/lib/textus/application/write/put.rb +49 -0
- data/lib/textus/application/write/refresh_all.rb +63 -0
- data/lib/textus/application/{refresh/orchestrator.rb → write/refresh_orchestrator.rb} +32 -8
- data/lib/textus/application/write/refresh_worker.rb +134 -0
- data/lib/textus/application/write/reject.rb +62 -0
- data/lib/textus/boot.rb +27 -29
- data/lib/textus/builder/pipeline.rb +3 -3
- 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 +2 -2
- 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 +4 -4
- 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 +2 -2
- 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 +5 -3
- data/lib/textus/doctor.rb +24 -27
- data/lib/textus/domain/authorizer.rb +4 -4
- 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/staleness/generator_check.rb +2 -2
- data/lib/textus/domain/staleness/intake_check.rb +2 -2
- data/lib/textus/domain/staleness.rb +1 -1
- data/lib/textus/hooks/builtin.rb +14 -14
- data/lib/textus/hooks/context.rb +13 -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/infra/audit_subscriber.rb +6 -7
- data/lib/textus/infra/refresh/detached.rb +1 -1
- data/lib/textus/key/path.rb +7 -3
- data/lib/textus/manifest/data.rb +78 -0
- data/lib/textus/manifest/entry/base.rb +4 -4
- data/lib/textus/manifest/entry/derived.rb +4 -5
- data/lib/textus/manifest/entry/validators/events.rb +1 -1
- 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 +53 -111
- data/lib/textus/mcp/errors.rb +32 -0
- data/lib/textus/mcp/server.rb +127 -0
- data/lib/textus/mcp/session.rb +31 -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/schema/tools.rb +14 -10
- data/lib/textus/session.rb +84 -0
- data/lib/textus/store.rb +14 -9
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +8 -1
- metadata +61 -36
- 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/where.rb +0 -18
- data/lib/textus/application/refresh/all.rb +0 -52
- data/lib/textus/application/refresh/worker.rb +0 -116
- data/lib/textus/application/writes/accept.rb +0 -89
- data/lib/textus/application/writes/delete.rb +0 -33
- 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
|
@@ -2,55 +2,37 @@ require "fileutils"
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Application
|
|
5
|
-
module
|
|
6
|
-
# Owns the write pipeline (validate, serialize, etag-check, write, audit)
|
|
7
|
-
#
|
|
8
|
-
#
|
|
5
|
+
module Envelope
|
|
6
|
+
# Owns the write pipeline (validate, serialize, etag-check, write, audit).
|
|
7
|
+
# Talks to ports (FileStore, Schemas, AuditLog, Manifest) and an
|
|
8
|
+
# Reader for the existing-uid lookup.
|
|
9
|
+
#
|
|
10
|
+
# Invariant: every public method's final action is @audit_log.append(...).
|
|
9
11
|
#
|
|
10
12
|
# No permission check, no event firing — those belong to the caller
|
|
11
|
-
# (Application::
|
|
12
|
-
class
|
|
13
|
+
# (Application::Write::Put / ::Delete / ::Mv).
|
|
14
|
+
class Writer
|
|
13
15
|
Payload = Data.define(:meta, :body, :content)
|
|
14
16
|
|
|
15
|
-
def initialize(file_store:, manifest:, schemas:, audit_log:, ctx:)
|
|
17
|
+
def initialize(file_store:, manifest:, schemas:, audit_log:, ctx:, reader:)
|
|
16
18
|
@file_store = file_store
|
|
17
19
|
@manifest = manifest
|
|
18
20
|
@schemas = schemas
|
|
19
21
|
@audit_log = audit_log
|
|
20
22
|
@ctx = ctx
|
|
23
|
+
@reader = reader
|
|
21
24
|
end
|
|
22
25
|
|
|
23
|
-
def
|
|
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)
|
|
26
|
+
def put(key, mentry:, payload:, if_etag: nil)
|
|
44
27
|
path = @manifest.resolver.resolve(key).path
|
|
45
28
|
|
|
46
29
|
meta = payload.meta || {}
|
|
47
|
-
strategy = Entry.for_format(mentry.format)
|
|
48
30
|
|
|
49
|
-
existing_uid =
|
|
31
|
+
existing_uid = @reader.existing_uid(key)
|
|
50
32
|
meta, content = ensure_uid(mentry.format, meta, payload.content, existing_uid)
|
|
51
33
|
|
|
52
34
|
bytes, eff_meta, eff_body, eff_content = serialize_for_put(
|
|
53
|
-
mentry: mentry, path: path,
|
|
35
|
+
mentry: mentry, path: path,
|
|
54
36
|
meta: meta, body: payload.body, content: content
|
|
55
37
|
)
|
|
56
38
|
|
|
@@ -69,19 +51,22 @@ module Textus
|
|
|
69
51
|
|
|
70
52
|
@file_store.write(path, bytes)
|
|
71
53
|
etag_after = Etag.for_bytes(bytes)
|
|
54
|
+
envelope = Textus::Envelope.build(
|
|
55
|
+
key: key, mentry: mentry, path: path,
|
|
56
|
+
meta: eff_meta, body: eff_body, etag: etag_after, content: eff_content
|
|
57
|
+
)
|
|
72
58
|
@audit_log.append(
|
|
73
59
|
role: @ctx.role, verb: "put", key: key,
|
|
74
60
|
etag_before: etag_before, etag_after: etag_after,
|
|
75
61
|
extras: @ctx.correlation_id ? { "correlation_id" => @ctx.correlation_id } : nil
|
|
76
62
|
)
|
|
77
|
-
|
|
78
|
-
key: key, mentry: mentry, path: path,
|
|
79
|
-
meta: eff_meta, body: eff_body, etag: etag_after, content: eff_content
|
|
80
|
-
)
|
|
63
|
+
envelope
|
|
81
64
|
end
|
|
82
65
|
|
|
83
|
-
def delete(key, mentry
|
|
84
|
-
|
|
66
|
+
def delete(key, mentry: nil, if_etag: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
67
|
+
# `mentry:` is accepted for symmetry with `put` / `move` and to
|
|
68
|
+
# leave room for future format-specific delete hooks; no field
|
|
69
|
+
# on it is needed today.
|
|
85
70
|
path = @manifest.resolver.resolve(key).path
|
|
86
71
|
raise UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)) unless @file_store.exists?(path)
|
|
87
72
|
|
|
@@ -112,7 +97,7 @@ module Textus
|
|
|
112
97
|
|
|
113
98
|
raw = @file_store.read(to_path)
|
|
114
99
|
parsed = Entry.for_format(new_mentry.format).parse(raw, path: to_path)
|
|
115
|
-
envelope = Envelope.build(
|
|
100
|
+
envelope = Textus::Envelope.build(
|
|
116
101
|
key: to_key, mentry: new_mentry, path: to_path,
|
|
117
102
|
meta: parsed["_meta"], body: parsed["body"],
|
|
118
103
|
etag: etag_after, content: parsed["content"]
|
|
@@ -136,16 +121,6 @@ module Textus
|
|
|
136
121
|
|
|
137
122
|
private
|
|
138
123
|
|
|
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
124
|
def ensure_uid(format, meta, content, existing_uid)
|
|
150
125
|
Textus::Entry.for_format(format).inject_uid(meta, content, existing_uid)
|
|
151
126
|
end
|
|
@@ -154,8 +129,7 @@ module Textus
|
|
|
154
129
|
Textus::Entry.for_format(format).enforce_name_match!(path, meta)
|
|
155
130
|
end
|
|
156
131
|
|
|
157
|
-
def serialize_for_put(mentry:, path:,
|
|
158
|
-
_ = strategy
|
|
132
|
+
def serialize_for_put(mentry:, path:, meta:, body:, content:)
|
|
159
133
|
Textus::Entry.for_format(mentry.format).serialize_for_put(
|
|
160
134
|
meta: meta, body: body, content: content, path: path,
|
|
161
135
|
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Maintenance
|
|
4
|
+
# Bulk-delete every leaf key under `prefix`.
|
|
5
|
+
module KeyDeletePrefix
|
|
6
|
+
def self.call(*, session:, ctx:, caps:, **)
|
|
7
|
+
Impl.new(ctx: ctx, caps: caps, session: session).call(*, **)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class Impl
|
|
11
|
+
def initialize(ctx:, caps:, session:)
|
|
12
|
+
@ctx = ctx
|
|
13
|
+
@caps = caps
|
|
14
|
+
@session = session
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(prefix:, dry_run: false)
|
|
18
|
+
raise UsageError.new("prefix required") if prefix.nil? || prefix.empty?
|
|
19
|
+
|
|
20
|
+
leaves = Read::List::Impl.new(caps: @caps)
|
|
21
|
+
.call(prefix: prefix)
|
|
22
|
+
.map { |r| r.is_a?(Hash) ? (r["key"] || r[:key]) : r }
|
|
23
|
+
|
|
24
|
+
warnings = leaves.empty? ? ["no keys under #{prefix}"] : []
|
|
25
|
+
steps = leaves.map { |k| { "op" => "delete", "key" => k } }
|
|
26
|
+
|
|
27
|
+
plan = Plan.new(steps: steps, warnings: warnings)
|
|
28
|
+
return plan if dry_run
|
|
29
|
+
|
|
30
|
+
steps.each do |s|
|
|
31
|
+
Textus::Application::Write::Delete.call(
|
|
32
|
+
s["key"],
|
|
33
|
+
session: @session, ctx: @ctx, caps: @session.write_caps,
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
plan
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
Textus::Application::UseCase.register(:key_delete_prefix, Textus::Application::Maintenance::KeyDeletePrefix, caps: :write)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Maintenance
|
|
4
|
+
# Bulk-rename every leaf key under `from_prefix` to `to_prefix`.
|
|
5
|
+
# Calls Write::Mv directly for each entry — emits one audit row per file moved.
|
|
6
|
+
module KeyMvPrefix
|
|
7
|
+
def self.call(*, session:, ctx:, caps:, **)
|
|
8
|
+
Impl.new(ctx: ctx, caps: caps, session: session).call(*, **)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class Impl
|
|
12
|
+
def initialize(ctx:, caps:, session:)
|
|
13
|
+
@ctx = ctx
|
|
14
|
+
@caps = caps
|
|
15
|
+
@session = session
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(from_prefix:, to_prefix:, dry_run: false)
|
|
19
|
+
raise UsageError.new("from_prefix and to_prefix required") if from_prefix.nil? || to_prefix.nil?
|
|
20
|
+
|
|
21
|
+
leaves = list_leaves_under(from_prefix)
|
|
22
|
+
warnings = []
|
|
23
|
+
warnings << "no keys under #{from_prefix}" if leaves.empty?
|
|
24
|
+
|
|
25
|
+
steps = leaves.map do |old_key|
|
|
26
|
+
tail = old_key.delete_prefix("#{from_prefix}.")
|
|
27
|
+
new_key = "#{to_prefix}.#{tail}"
|
|
28
|
+
{ "op" => "mv", "from" => old_key, "to" => new_key }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
plan = Plan.new(steps: steps, warnings: warnings)
|
|
32
|
+
return plan if dry_run
|
|
33
|
+
|
|
34
|
+
steps.each do |s|
|
|
35
|
+
Textus::Application::Write::Mv.call(
|
|
36
|
+
s["from"], s["to"],
|
|
37
|
+
session: @session, ctx: @ctx, caps: @session.write_caps,
|
|
38
|
+
dry_run: false
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
plan
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def list_leaves_under(prefix)
|
|
47
|
+
Read::List::Impl.new(caps: @caps)
|
|
48
|
+
.call(prefix: prefix)
|
|
49
|
+
.map { |row| row.is_a?(Hash) ? (row["key"] || row[:key]) : row }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
Textus::Application::UseCase.register(:key_mv_prefix, Textus::Application::Maintenance::KeyMvPrefix, caps: :write)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Application
|
|
5
|
+
module Maintenance
|
|
6
|
+
# Loads a YAML migration plan and dispatches each op to the
|
|
7
|
+
# appropriate Maintenance use case. Concatenates resulting Plans.
|
|
8
|
+
module Migrate
|
|
9
|
+
def self.call(*, session:, ctx:, caps:, **)
|
|
10
|
+
Impl.new(ctx: ctx, caps: caps, session: session).call(*, **)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class Impl
|
|
14
|
+
def initialize(ctx:, caps:, session:)
|
|
15
|
+
@ctx = ctx
|
|
16
|
+
@caps = caps
|
|
17
|
+
@session = session
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(plan_yaml:, dry_run: false)
|
|
21
|
+
raw = YAML.safe_load(plan_yaml, permitted_classes: [Symbol], aliases: false)
|
|
22
|
+
raise UsageError.new("migration plan must be a YAML mapping") unless raw.is_a?(Hash)
|
|
23
|
+
|
|
24
|
+
ops = Array(raw["operations"])
|
|
25
|
+
all_steps = []
|
|
26
|
+
warnings = []
|
|
27
|
+
|
|
28
|
+
ops.each do |op_hash|
|
|
29
|
+
op_name = op_hash["op"]
|
|
30
|
+
sub_plan = invoke_op(op_name, op_hash, dry_run: dry_run)
|
|
31
|
+
all_steps.concat(sub_plan.steps)
|
|
32
|
+
warnings.concat(sub_plan.warnings)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
Plan.new(steps: all_steps, warnings: warnings)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def invoke_op(op_name, op_hash, dry_run:)
|
|
41
|
+
kwargs = op_hash.except("op").transform_keys(&:to_sym).merge(dry_run: dry_run)
|
|
42
|
+
case op_name
|
|
43
|
+
when "key_mv_prefix"
|
|
44
|
+
KeyMvPrefix.call(session: @session, ctx: @ctx, caps: @caps, **kwargs)
|
|
45
|
+
when "key_delete_prefix"
|
|
46
|
+
KeyDeletePrefix.call(session: @session, ctx: @ctx, caps: @caps, **kwargs)
|
|
47
|
+
when "zone_mv"
|
|
48
|
+
ZoneMv.call(session: @session, ctx: @ctx, caps: @caps, **kwargs)
|
|
49
|
+
else
|
|
50
|
+
raise UsageError.new("unknown op: #{op_name}")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
Textus::Application::UseCase.register(:migrate, Textus::Application::Maintenance::Migrate, caps: :write)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Application
|
|
5
|
+
module Maintenance
|
|
6
|
+
# Compare the live manifest's `rules:` block against a candidate
|
|
7
|
+
# YAML string. Returns a Plan describing rule additions/removals/
|
|
8
|
+
# changes. Does NOT write anything.
|
|
9
|
+
module RuleLint
|
|
10
|
+
def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
|
|
11
|
+
Impl.new(ctx: ctx, caps: caps).call(*, **)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class Impl
|
|
15
|
+
def initialize(ctx:, caps:)
|
|
16
|
+
@ctx = ctx
|
|
17
|
+
@root = caps.root
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(candidate_yaml:)
|
|
21
|
+
live_rules = current_rules
|
|
22
|
+
candidate_rules = parse_candidate(candidate_yaml)
|
|
23
|
+
|
|
24
|
+
live_by_match = live_rules.to_h { |r| [r["match"], r] }
|
|
25
|
+
candidate_by_match = candidate_rules.to_h { |r| [r["match"], r] }
|
|
26
|
+
|
|
27
|
+
steps = (candidate_by_match.keys - live_by_match.keys).map do |m|
|
|
28
|
+
{ "op" => "add_rule", "match" => m, "rule" => candidate_by_match[m] }
|
|
29
|
+
end
|
|
30
|
+
(live_by_match.keys - candidate_by_match.keys).each do |m|
|
|
31
|
+
steps << { "op" => "remove_rule", "match" => m }
|
|
32
|
+
end
|
|
33
|
+
(live_by_match.keys & candidate_by_match.keys).each do |m|
|
|
34
|
+
next if live_by_match[m] == candidate_by_match[m]
|
|
35
|
+
|
|
36
|
+
steps << { "op" => "change_rule", "match" => m,
|
|
37
|
+
"from" => live_by_match[m], "to" => candidate_by_match[m] }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
Plan.new(steps: steps, warnings: [])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def current_rules
|
|
46
|
+
raw = YAML.safe_load_file(File.join(@root, "manifest.yaml"),
|
|
47
|
+
permitted_classes: [Symbol], aliases: false)
|
|
48
|
+
Array(raw["rules"])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def parse_candidate(yaml_text)
|
|
52
|
+
raw = YAML.safe_load(yaml_text, permitted_classes: [Symbol], aliases: false)
|
|
53
|
+
raise UsageError.new("candidate is not a YAML mapping") unless raw.is_a?(Hash)
|
|
54
|
+
|
|
55
|
+
Array(raw["rules"])
|
|
56
|
+
rescue Psych::Exception => e
|
|
57
|
+
raise UsageError.new("candidate YAML parse error: #{e.message}")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
Textus::Application::UseCase.register(:rule_lint, Textus::Application::Maintenance::RuleLint, caps: :read)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Application
|
|
5
|
+
module Maintenance
|
|
6
|
+
# Rename a zone — rewrites the manifest's zones[] entry, rewrites
|
|
7
|
+
# the `zone:` field on every entry under the old zone, and moves
|
|
8
|
+
# every file from zones/<old>/ to zones/<new>/.
|
|
9
|
+
module ZoneMv
|
|
10
|
+
def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
|
|
11
|
+
Impl.new(ctx: ctx, caps: caps).call(*, **)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class Impl
|
|
15
|
+
def initialize(ctx:, caps:)
|
|
16
|
+
@ctx = ctx
|
|
17
|
+
@manifest = caps.manifest
|
|
18
|
+
@root = caps.root
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(from:, to:, dry_run: false)
|
|
22
|
+
raise UsageError.new("from and to required") if from.nil? || to.nil? || from.empty? || to.empty?
|
|
23
|
+
raise UsageError.new("zone '#{from}' not declared") unless @manifest.data.zones.key?(from)
|
|
24
|
+
|
|
25
|
+
dest_dir = File.join(@root, "zones", to)
|
|
26
|
+
raise UsageError.new("destination 'zones/#{to}' already exists") if File.exist?(dest_dir)
|
|
27
|
+
|
|
28
|
+
affected_keys = @manifest.data.entries.select { |e| e.zone == from }.map(&:key)
|
|
29
|
+
|
|
30
|
+
steps = [{ "op" => "rename_zone", "from" => from, "to" => to }]
|
|
31
|
+
steps += affected_keys.map { |k| { "op" => "mv", "from" => k, "to" => "#{to}#{k[from.length..]}" } }
|
|
32
|
+
|
|
33
|
+
plan = Plan.new(steps: steps, warnings: [])
|
|
34
|
+
return plan if dry_run
|
|
35
|
+
|
|
36
|
+
rewrite_manifest!(from, to)
|
|
37
|
+
FileUtils.mv(File.join(@root, "zones", from), dest_dir)
|
|
38
|
+
plan
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def rewrite_manifest!(from, to)
|
|
44
|
+
path = File.join(@root, "manifest.yaml")
|
|
45
|
+
raw = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false)
|
|
46
|
+
raw["zones"].each { |z| z["name"] = to if z["name"] == from }
|
|
47
|
+
raw["entries"].each do |e|
|
|
48
|
+
e["zone"] = to if e["zone"] == from
|
|
49
|
+
e["key"] = e["key"].sub(/\A#{Regexp.escape(from)}(\.|\z)/, "#{to}\\1")
|
|
50
|
+
e["path"] = e["path"].sub(%r{\A#{Regexp.escape(from)}(/|\z)}, "#{to}\\1")
|
|
51
|
+
end
|
|
52
|
+
File.write(path, YAML.dump(raw))
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
Textus::Application::UseCase.register(:zone_mv, Textus::Application::Maintenance::ZoneMv, caps: :write)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
# Bulk and structural changes to a textus store. Each use case returns
|
|
4
|
+
# a Plan when called with dry_run: true, and applies the plan when
|
|
5
|
+
# called with dry_run: false.
|
|
6
|
+
module Maintenance
|
|
7
|
+
# A Plan is a JSON-shaped preview. Steps are op-tagged hashes the
|
|
8
|
+
# use case knows how to apply. Warnings are strings surfaced to
|
|
9
|
+
# the operator (skipped keys, ambiguities).
|
|
10
|
+
Plan = Data.define(:steps, :warnings) do
|
|
11
|
+
def to_h
|
|
12
|
+
{ "steps" => steps, "warnings" => warnings }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -11,14 +11,14 @@ module Textus
|
|
|
11
11
|
# semantics: pure read (`ops.get`) for materialization paths;
|
|
12
12
|
# `ops.get_or_refresh` if you want refresh-on-stale.
|
|
13
13
|
# `lister` — a callable `->(prefix:) { [ { "key" => ... }, ... ] }`.
|
|
14
|
-
# `
|
|
15
|
-
# `transform_context` —
|
|
16
|
-
def initialize(reader:, spec:, lister:,
|
|
17
|
-
@reader
|
|
18
|
-
@spec
|
|
19
|
-
@lister
|
|
20
|
-
@
|
|
21
|
-
@transform_context
|
|
14
|
+
# `rpc` — a `Hooks::RpcRegistry` used to dispatch `transform_rows` callables.
|
|
15
|
+
# `transform_context` — capability object handed to transform reducers as `caps:`.
|
|
16
|
+
def initialize(reader:, spec:, lister:, rpc:, transform_context:)
|
|
17
|
+
@reader = reader
|
|
18
|
+
@spec = spec || {}
|
|
19
|
+
@lister = lister
|
|
20
|
+
@rpc = rpc
|
|
21
|
+
@transform_context = transform_context
|
|
22
22
|
@limit = (@spec["limit"] || MAX_LIMIT).to_i
|
|
23
23
|
raise InvalidProjection.new("limit #{@limit} exceeds max #{MAX_LIMIT}") if @limit > MAX_LIMIT
|
|
24
24
|
end
|
|
@@ -49,9 +49,11 @@ module Textus
|
|
|
49
49
|
|
|
50
50
|
def apply_reducer(rows)
|
|
51
51
|
name = @spec["transform"] or return rows
|
|
52
|
-
callable = @transform_resolver.call(name)
|
|
53
52
|
Timeout.timeout(REDUCER_TIMEOUT_SECONDS) do
|
|
54
|
-
|
|
53
|
+
@rpc.invoke(:transform_rows, name,
|
|
54
|
+
caps: @transform_context,
|
|
55
|
+
rows: rows,
|
|
56
|
+
config: @spec["transform_config"] || {})
|
|
55
57
|
end
|
|
56
58
|
rescue Timeout::Error
|
|
57
59
|
raise UsageError.new("transform_rows '#{name}' exceeded #{REDUCER_TIMEOUT_SECONDS}s timeout")
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "time"
|
|
3
|
+
|
|
4
|
+
module Textus
|
|
5
|
+
module Application
|
|
6
|
+
module Read
|
|
7
|
+
# Queries .textus/audit.log. Filters: key, zone, role, verb, since,
|
|
8
|
+
# correlation_id, limit. Reads the log file as JSON-Lines (legacy TSV
|
|
9
|
+
# rows produce nil and are skipped).
|
|
10
|
+
module Audit
|
|
11
|
+
def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
|
|
12
|
+
Impl.new(caps: caps).call(*, **)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.parse_since(str, now: Time.now.utc)
|
|
16
|
+
Impl.parse_since(str, now: now)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class Impl
|
|
20
|
+
def initialize(caps:)
|
|
21
|
+
@manifest = caps.manifest
|
|
22
|
+
@root = caps.root
|
|
23
|
+
@log_path = File.join(caps.root, "audit.log")
|
|
24
|
+
@audit_log = caps.audit_log
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# rubocop:disable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
28
|
+
def call(key: nil, zone: nil, role: nil, verb: nil, since: nil, seq_since: nil, correlation_id: nil, limit: nil)
|
|
29
|
+
check_cursor_expiry!(seq_since)
|
|
30
|
+
|
|
31
|
+
files = all_log_files
|
|
32
|
+
return [] if files.empty?
|
|
33
|
+
|
|
34
|
+
rows = []
|
|
35
|
+
files.each do |file|
|
|
36
|
+
File.foreach(file) do |line|
|
|
37
|
+
parsed = parse_row(line.chomp)
|
|
38
|
+
next unless parsed
|
|
39
|
+
next if key && parsed["key"] != key
|
|
40
|
+
next if role && parsed["role"] != role
|
|
41
|
+
next if verb && parsed["verb"] != verb
|
|
42
|
+
next if zone && !key_in_zone?(parsed["key"], zone)
|
|
43
|
+
next if since && (parsed["ts"].nil? || Time.parse(parsed["ts"]) < since)
|
|
44
|
+
next if seq_since && (parsed["seq"].nil? || parsed["seq"] <= seq_since)
|
|
45
|
+
next if correlation_id && parsed.dig("extras", "correlation_id") != correlation_id
|
|
46
|
+
|
|
47
|
+
rows << parsed
|
|
48
|
+
break if limit && rows.length >= limit
|
|
49
|
+
end
|
|
50
|
+
break if limit && rows.length >= limit
|
|
51
|
+
end
|
|
52
|
+
rows
|
|
53
|
+
end
|
|
54
|
+
# rubocop:enable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
55
|
+
|
|
56
|
+
# Accepts ISO8601 ("2026-01-15", "2026-01-15T10:00:00Z") or a relative
|
|
57
|
+
# offset matching /\A(\d+)([smhd])\z/. Returns nil for unparseable input.
|
|
58
|
+
def self.parse_since(str, now: Time.now.utc)
|
|
59
|
+
return nil if str.nil? || str.empty?
|
|
60
|
+
return Time.parse(str) if str =~ /\A\d{4}-\d{2}-\d{2}/
|
|
61
|
+
|
|
62
|
+
m = str.match(/\A(\d+)([smhd])\z/) or return nil
|
|
63
|
+
mult = { "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }[m[2]]
|
|
64
|
+
now - (m[1].to_i * mult)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def check_cursor_expiry!(seq_since)
|
|
70
|
+
return unless seq_since
|
|
71
|
+
|
|
72
|
+
log = @audit_log || Textus::Infra::AuditLog.new(@root)
|
|
73
|
+
min = log.min_available_seq
|
|
74
|
+
raise Textus::CursorExpired.new(requested: seq_since, min_available: min) if min && seq_since < min - 1
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def all_log_files
|
|
78
|
+
rotated = Dir.glob(File.join(@root, "audit.log.*"))
|
|
79
|
+
.reject { |p| p.end_with?(".meta.json") }
|
|
80
|
+
.sort_by { |p| -p.scan(/\d+$/).first.to_i } # .5 .4 .3 .2 .1 → oldest first
|
|
81
|
+
active = File.exist?(@log_path) ? [@log_path] : []
|
|
82
|
+
rotated + active
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def parse_row(line)
|
|
86
|
+
return nil if line.empty?
|
|
87
|
+
return nil unless line.start_with?("{")
|
|
88
|
+
|
|
89
|
+
JSON.parse(line)
|
|
90
|
+
rescue JSON::ParserError
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def key_in_zone?(key, zone)
|
|
95
|
+
mentry = @manifest.resolver.resolve(key).entry
|
|
96
|
+
mentry && mentry.zone == zone
|
|
97
|
+
rescue Textus::Error
|
|
98
|
+
false
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
Textus::Application::UseCase.register(:audit, Textus::Application::Read::Audit, caps: :read)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Application
|
|
5
|
+
module Read
|
|
6
|
+
# For one key, joins every audit-log row with the git commit (sha,
|
|
7
|
+
# author, date, subject) that introduced the file state at that audit
|
|
8
|
+
# row. Falls back to `git => nil` when not in a git repo or when the
|
|
9
|
+
# file is untracked.
|
|
10
|
+
module Blame
|
|
11
|
+
def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
|
|
12
|
+
Impl.new(caps: caps).call(*, **)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class Impl
|
|
16
|
+
def initialize(caps:)
|
|
17
|
+
@caps = caps
|
|
18
|
+
@manifest = caps.manifest
|
|
19
|
+
@root = caps.root
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(key:, limit: nil)
|
|
23
|
+
audit_rows = Textus::Application::Read::Audit::Impl.new(caps: @caps).call(key: key, limit: limit)
|
|
24
|
+
path = resolve_path(key)
|
|
25
|
+
return audit_rows.map { |r| r.merge("git" => nil) } unless git_tracked?(path)
|
|
26
|
+
|
|
27
|
+
audit_rows.map { |r| r.merge("git" => git_commit_at(path, timestamp: r["ts"])) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def resolve_path(key)
|
|
33
|
+
res = @manifest.resolver.resolve(key)
|
|
34
|
+
mentry = res.entry
|
|
35
|
+
path = res.path
|
|
36
|
+
# Nested entries resolve to a file under the entry path; leaf entries
|
|
37
|
+
# already have a fully-resolved path. Either way `path` is what git
|
|
38
|
+
# needs to know about.
|
|
39
|
+
path || Textus::Key::Path.resolve(@manifest.data, mentry)
|
|
40
|
+
rescue Textus::Error
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def git_tracked?(path)
|
|
45
|
+
return false if path.nil?
|
|
46
|
+
return false unless File.exist?(path)
|
|
47
|
+
return false unless git_repo?
|
|
48
|
+
|
|
49
|
+
_out, _err, status = Open3.capture3(
|
|
50
|
+
"git", "ls-files", "--error-unmatch", path,
|
|
51
|
+
chdir: @root
|
|
52
|
+
)
|
|
53
|
+
status.success?
|
|
54
|
+
rescue Errno::ENOENT
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def git_repo?
|
|
59
|
+
# Walk up from store root to find a .git directory.
|
|
60
|
+
dir = @root
|
|
61
|
+
loop do
|
|
62
|
+
return true if File.directory?(File.join(dir, ".git"))
|
|
63
|
+
|
|
64
|
+
parent = File.dirname(dir)
|
|
65
|
+
return false if parent == dir
|
|
66
|
+
|
|
67
|
+
dir = parent
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def git_commit_at(path, timestamp:)
|
|
72
|
+
args = ["git", "log", "-1"]
|
|
73
|
+
args << "--before=#{timestamp}" if timestamp
|
|
74
|
+
args += ["--format=%H%x09%an%x09%aI%x09%s", "--", path]
|
|
75
|
+
out, _err, status = Open3.capture3(*args, chdir: @root)
|
|
76
|
+
return nil unless status.success?
|
|
77
|
+
|
|
78
|
+
sha, author, date, subject = out.strip.split("\t", 4)
|
|
79
|
+
return nil if sha.nil? || sha.empty?
|
|
80
|
+
|
|
81
|
+
{ "sha" => sha, "author" => author, "date" => date, "subject" => subject }
|
|
82
|
+
rescue Errno::ENOENT
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
Textus::Application::UseCase.register(:blame, Textus::Application::Read::Blame, caps: :read)
|