textus 0.18.0 → 0.20.2
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 +43 -48
- data/CHANGELOG.md +238 -0
- data/SPEC.md +35 -2
- data/lib/textus/application/context.rb +20 -58
- data/lib/textus/application/policy/predicates/accept_authority_signed.rb +33 -0
- data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
- data/lib/textus/{domain → application}/policy/promotion.rb +18 -6
- data/lib/textus/application/projection.rb +91 -0
- data/lib/textus/application/reads/audit.rb +4 -4
- data/lib/textus/application/reads/blame.rb +9 -8
- data/lib/textus/application/reads/deps.rb +14 -3
- data/lib/textus/application/reads/freshness.rb +10 -8
- data/lib/textus/application/reads/get.rb +10 -8
- data/lib/textus/application/reads/get_or_refresh.rb +3 -3
- data/lib/textus/application/reads/list.rb +3 -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 +5 -4
- 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 +10 -6
- data/lib/textus/application/reads/validator.rb +5 -3
- data/lib/textus/application/reads/where.rb +3 -3
- data/lib/textus/application/refresh/all.rb +15 -11
- data/lib/textus/application/refresh/orchestrator.rb +9 -8
- data/lib/textus/application/refresh/worker.rb +56 -32
- data/lib/textus/application/writes/accept.rb +43 -16
- data/lib/textus/application/writes/authority_gate.rb +26 -0
- data/lib/textus/application/writes/delete.rb +13 -10
- data/lib/textus/application/writes/envelope_io.rb +64 -4
- data/lib/textus/application/writes/materializer.rb +50 -0
- data/lib/textus/application/writes/mv.rb +57 -94
- data/lib/textus/application/writes/publish.rb +132 -26
- data/lib/textus/application/writes/put.rb +15 -14
- data/lib/textus/application/writes/reject.rb +25 -12
- 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/verb/build.rb +4 -6
- 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 +5 -5
- data/lib/textus/cli/verb/put.rb +2 -3
- data/lib/textus/cli/verb/refresh_stale.rb +1 -2
- data/lib/textus/doctor/check/handler_allowlist.rb +3 -2
- data/lib/textus/doctor/check/hooks.rb +2 -2
- data/lib/textus/doctor/check/illegal_keys.rb +7 -7
- data/lib/textus/doctor/check/intake_registration.rb +2 -2
- 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/templates.rb +4 -3
- data/lib/textus/doctor.rb +3 -4
- data/lib/textus/domain/authorizer.rb +37 -0
- data/lib/textus/domain/policy/promote.rb +4 -2
- data/lib/textus/domain/policy/refresh.rb +2 -0
- data/lib/textus/domain/staleness/generator_check.rb +8 -7
- data/lib/textus/domain/staleness/intake_check.rb +2 -2
- 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 +3 -3
- data/lib/textus/infra/audit_subscriber.rb +4 -4
- data/lib/textus/infra/event_bus.rb +3 -3
- data/lib/textus/infra/refresh/detached.rb +1 -1
- data/lib/textus/init.rb +3 -2
- data/lib/textus/intro.rb +51 -27
- 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 +58 -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/resolver.rb +112 -0
- data/lib/textus/manifest/role_kinds.rb +21 -0
- data/lib/textus/manifest/schema.rb +46 -2
- data/lib/textus/manifest.rb +24 -101
- data/lib/textus/operations.rb +131 -74
- data/lib/textus/schema/tools.rb +10 -3
- data/lib/textus/store.rb +6 -6
- data/lib/textus/version.rb +1 -1
- metadata +18 -14
- data/lib/textus/application/writes/build.rb +0 -78
- data/lib/textus/cli/verb/key_normalize.rb +0 -19
- data/lib/textus/dependencies.rb +0 -23
- data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
- data/lib/textus/domain/policy.rb +0 -7
- data/lib/textus/hooks/dispatcher.rb +0 -71
- data/lib/textus/hooks/registry.rb +0 -85
- data/lib/textus/manifest/resolution.rb +0 -5
- data/lib/textus/migrate_keys.rb +0 -187
- data/lib/textus/projection.rb +0 -89
- data/lib/textus/refresh.rb +0 -39
data/lib/textus/dependencies.rb
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Dependencies
|
|
3
|
-
def self.deps_of(manifest, key)
|
|
4
|
-
entry = manifest.entries.find { |e| e.key == key } or return []
|
|
5
|
-
result = Array(entry.projection&.fetch("select", nil)).map { |s| s }
|
|
6
|
-
Array(entry.generator&.fetch("sources", nil)).each { |s| result << s }
|
|
7
|
-
result.uniq
|
|
8
|
-
end
|
|
9
|
-
|
|
10
|
-
def self.rdeps_of(manifest, key)
|
|
11
|
-
manifest.entries.each_with_object([]) do |e, acc|
|
|
12
|
-
sources = Array(e.projection&.fetch("select", nil)) + Array(e.generator&.fetch("sources", nil))
|
|
13
|
-
acc << e.key if sources.any? { |s| s == key || key.start_with?("#{s}.") }
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def self.published_of(manifest)
|
|
18
|
-
manifest.entries.reject { |e| e.publish_to.empty? }.map do |e|
|
|
19
|
-
{ "key" => e.key, "publish_to" => e.publish_to }
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
end
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Domain
|
|
3
|
-
module Policy
|
|
4
|
-
module Predicates
|
|
5
|
-
class HumanAccept
|
|
6
|
-
attr_reader :reason
|
|
7
|
-
|
|
8
|
-
def name
|
|
9
|
-
"human_accept"
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
# The role is passed via `store` (an Application::Context-like object
|
|
13
|
-
# with a `role` reader) or through the entry metadata. In practice,
|
|
14
|
-
# Accept already enforces role == "human" before reaching the
|
|
15
|
-
# promotion gate, so this predicate trivially passes. It documents
|
|
16
|
-
# intent and future-proofs multi-actor accept flows.
|
|
17
|
-
def call(store:, entry: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
18
|
-
role = store.respond_to?(:role) ? store.role.to_s : nil
|
|
19
|
-
# If we cannot determine the role (e.g. store doesn't expose it),
|
|
20
|
-
# we trust that Accept has already checked — allow through.
|
|
21
|
-
return true if role.nil?
|
|
22
|
-
|
|
23
|
-
ok = (role == "human")
|
|
24
|
-
@reason = "current role is '#{role}', expected 'human'" unless ok
|
|
25
|
-
ok
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
end
|
data/lib/textus/domain/policy.rb
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "timeout"
|
|
4
|
-
|
|
5
|
-
module Textus
|
|
6
|
-
module Hooks
|
|
7
|
-
class Dispatcher
|
|
8
|
-
HOOK_TIMEOUT_SECONDS = 2
|
|
9
|
-
|
|
10
|
-
def initialize
|
|
11
|
-
@subscribers = Hash.new { |h, k| h[k] = [] }
|
|
12
|
-
@error_handlers = []
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
# Register an error callback invoked when a user hook raises.
|
|
16
|
-
# Used by Infra::AuditSubscriber to record an "event_error" audit row.
|
|
17
|
-
def on_error(&block)
|
|
18
|
-
@error_handlers << block
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def subscribe(event, name, keys: nil, &block)
|
|
22
|
-
@subscribers[event.to_sym] << { name: name.to_sym, callable: block, keys: keys }
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def publish(event, **kwargs)
|
|
26
|
-
key = kwargs[:key] || "-"
|
|
27
|
-
@subscribers[event.to_sym].each do |sub|
|
|
28
|
-
next unless match?(sub[:keys], key)
|
|
29
|
-
|
|
30
|
-
invoke(event, sub, key, kwargs)
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
private
|
|
35
|
-
|
|
36
|
-
def invoke(event, sub, key, kwargs)
|
|
37
|
-
accepted = filter_kwargs(sub[:callable], kwargs)
|
|
38
|
-
Timeout.timeout(HOOK_TIMEOUT_SECONDS) { sub[:callable].call(**accepted) }
|
|
39
|
-
rescue StandardError => e
|
|
40
|
-
notify_error(event, sub, key, kwargs, e)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def notify_error(event, sub, key, kwargs, error)
|
|
44
|
-
@error_handlers.each do |handler|
|
|
45
|
-
handler.call(event: event, hook: sub[:name], key: key, kwargs: kwargs, error: error)
|
|
46
|
-
rescue StandardError => e
|
|
47
|
-
warn "[textus] error handler failed: #{e.class}: #{e.message}"
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# Passes only the kwargs a hook block declares. Lets us extend event
|
|
52
|
-
# payloads (e.g., correlation_id) without breaking hooks written against
|
|
53
|
-
# the old signature.
|
|
54
|
-
def filter_kwargs(callable, kwargs)
|
|
55
|
-
params = callable.parameters
|
|
56
|
-
return kwargs if params.any? { |type, _| type == :keyrest }
|
|
57
|
-
|
|
58
|
-
accepted = params.each_with_object([]) do |(type, name), acc|
|
|
59
|
-
acc << name if %i[key keyreq].include?(type)
|
|
60
|
-
end
|
|
61
|
-
kwargs.slice(*accepted)
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def match?(globs, key)
|
|
65
|
-
return true if globs.nil?
|
|
66
|
-
|
|
67
|
-
Array(globs).any? { |g| File.fnmatch?(g, key.to_s, File::FNM_PATHNAME) }
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
end
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Hooks
|
|
3
|
-
class Registry
|
|
4
|
-
EVENTS = {
|
|
5
|
-
# RPC: exactly 1 handler per name; return value flows into store; failure aborts.
|
|
6
|
-
resolve_intake: { mode: :rpc, args: %i[store config args] },
|
|
7
|
-
transform_rows: { mode: :rpc, args: %i[store rows config] },
|
|
8
|
-
validate: { mode: :rpc, args: %i[store] },
|
|
9
|
-
|
|
10
|
-
# Pub-sub: 0..N handlers per event; return discarded; failure logged to audit.
|
|
11
|
-
entry_put: { mode: :pubsub, args: %i[store key envelope] },
|
|
12
|
-
entry_deleted: { mode: :pubsub, args: %i[store key] },
|
|
13
|
-
entry_refreshed: { mode: :pubsub, args: %i[store key envelope change] },
|
|
14
|
-
entry_renamed: { mode: :pubsub, args: %i[store key from_key to_key envelope] },
|
|
15
|
-
build_completed: { mode: :pubsub, args: %i[store key envelope sources] },
|
|
16
|
-
proposal_accepted: { mode: :pubsub, args: %i[store key target_key] },
|
|
17
|
-
proposal_rejected: { mode: :pubsub, args: %i[store key target_key] },
|
|
18
|
-
file_published: { mode: :pubsub, args: %i[store key envelope source target] },
|
|
19
|
-
store_loaded: { mode: :pubsub, args: %i[store] },
|
|
20
|
-
refresh_started: { mode: :pubsub, args: %i[store key mode] },
|
|
21
|
-
refresh_failed: { mode: :pubsub, args: %i[store key error_class error_message] },
|
|
22
|
-
refresh_backgrounded: { mode: :pubsub, args: %i[store key started_at budget_ms] },
|
|
23
|
-
}.freeze
|
|
24
|
-
|
|
25
|
-
def initialize(dispatcher: nil)
|
|
26
|
-
@rpc = Hash.new { |h, k| h[k] = {} } # event => { name => callable }
|
|
27
|
-
@pubsub = Hash.new { |h, k| h[k] = [] } # event => [{name:, callable:, keys:}]
|
|
28
|
-
@dispatcher = dispatcher
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def on(event, name, keys: nil, &)
|
|
32
|
-
register(event, name, keys: keys, &)
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def register(event, name, keys: nil, &blk)
|
|
36
|
-
event_sym = event.to_sym
|
|
37
|
-
spec = EVENTS[event_sym] or raise UsageError.new("unknown event: #{event}")
|
|
38
|
-
shape_check!(event_sym, spec, blk)
|
|
39
|
-
name = name.to_sym
|
|
40
|
-
|
|
41
|
-
case spec[:mode]
|
|
42
|
-
when :rpc
|
|
43
|
-
raise UsageError.new("#{event_sym} '#{name}' already registered") if @rpc[event_sym].key?(name)
|
|
44
|
-
|
|
45
|
-
@rpc[event_sym][name] = blk
|
|
46
|
-
when :pubsub
|
|
47
|
-
raise UsageError.new("#{event_sym} hook '#{name}' already registered") if @pubsub[event_sym].any? { |h| h[:name] == name }
|
|
48
|
-
|
|
49
|
-
@pubsub[event_sym] << { name: name, callable: blk, keys: keys }
|
|
50
|
-
@dispatcher&.subscribe(event_sym, name, keys: keys, &blk)
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def rpc_callable(event, name)
|
|
55
|
-
@rpc[event.to_sym][name.to_sym] or
|
|
56
|
-
raise UsageError.new("unknown #{event}: #{name}")
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def listeners(event, key:)
|
|
60
|
-
@pubsub[event.to_sym].select { |h| h[:keys].nil? || matches_any?(h[:keys], key) }
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def rpc_names(event) = @rpc[event.to_sym].keys
|
|
64
|
-
def pubsub_handlers(event) = @pubsub[event.to_sym]
|
|
65
|
-
|
|
66
|
-
private
|
|
67
|
-
|
|
68
|
-
def shape_check!(event, spec, blk)
|
|
69
|
-
required = spec[:args]
|
|
70
|
-
provided = blk.parameters.select { |t, _| %i[keyreq key keyrest].include?(t) } # rubocop:disable Style/HashSlice
|
|
71
|
-
keyrest = provided.any? { |t, _| t == :keyrest }
|
|
72
|
-
missing = required - provided.map { |_, n| n }
|
|
73
|
-
return if keyrest || missing.empty?
|
|
74
|
-
|
|
75
|
-
raise UsageError.new(
|
|
76
|
-
"#{event} hooks must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})",
|
|
77
|
-
)
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def matches_any?(globs, key)
|
|
81
|
-
Array(globs).any? { |g| File.fnmatch?(g, key.to_s, File::FNM_PATHNAME) }
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
end
|
data/lib/textus/migrate_keys.rb
DELETED
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
# Run-once helper that renames files/directories whose basenames don't
|
|
3
|
-
# conform to the strict key grammar (§3 of plan-1.2). Only walks
|
|
4
|
-
# nested: true manifest entries — leaf entries with illegal declared
|
|
5
|
-
# keys are caught by Manifest load and must be fixed by hand.
|
|
6
|
-
module MigrateKeys
|
|
7
|
-
SEGMENT = /\A[a-z0-9][a-z0-9-]*\z/
|
|
8
|
-
|
|
9
|
-
module_function
|
|
10
|
-
|
|
11
|
-
# Returns the envelope hash described in plan-1.2 §3.
|
|
12
|
-
def run(store, write: false)
|
|
13
|
-
plan = build_plan(store)
|
|
14
|
-
collisions = plan[:collisions]
|
|
15
|
-
renames = plan[:renames]
|
|
16
|
-
|
|
17
|
-
ok = collisions.empty?
|
|
18
|
-
apply!(store, renames) if write && ok
|
|
19
|
-
|
|
20
|
-
{
|
|
21
|
-
"protocol" => Textus::PROTOCOL,
|
|
22
|
-
"mode" => write ? "write" : "dry-run",
|
|
23
|
-
"renames" => renames.map { |r| envelope_rename(r) },
|
|
24
|
-
"collisions" => collisions.map { |c| envelope_collision(c) },
|
|
25
|
-
"ok" => ok,
|
|
26
|
-
}
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
# ------------------------------------------------------------------
|
|
30
|
-
# Plan construction
|
|
31
|
-
# ------------------------------------------------------------------
|
|
32
|
-
|
|
33
|
-
# Returns { renames: [...], collisions: [...] }
|
|
34
|
-
# Each rename: { from:, to:, old_key:, new_key:, kind: :file|:dir }
|
|
35
|
-
# Each collision: { target:, sources: [...] }
|
|
36
|
-
def build_plan(store) # rubocop:disable Metrics/AbcSize
|
|
37
|
-
renames = []
|
|
38
|
-
target_buckets = Hash.new { |h, k| h[k] = [] } # target_path => [source_path, ...]
|
|
39
|
-
|
|
40
|
-
store.manifest.entries.each do |entry|
|
|
41
|
-
next unless entry.nested
|
|
42
|
-
|
|
43
|
-
base = File.join(store.root, "zones", entry.path)
|
|
44
|
-
next unless File.directory?(base)
|
|
45
|
-
|
|
46
|
-
# Walk depth-first. Order matters when computing the "new key"
|
|
47
|
-
# for files inside a renamed directory: we record renames bottom-up,
|
|
48
|
-
# so children are renamed before their parents on apply.
|
|
49
|
-
walk(base) do |abs_path, is_dir|
|
|
50
|
-
next if abs_path == base
|
|
51
|
-
|
|
52
|
-
basename = File.basename(abs_path)
|
|
53
|
-
stem = is_dir ? basename : basename.sub(/#{Regexp.escape(File.extname(basename))}\z/, "")
|
|
54
|
-
next if stem.match?(SEGMENT)
|
|
55
|
-
|
|
56
|
-
new_stem = normalize(stem)
|
|
57
|
-
# Skip if normalization yields the same stem (e.g. already-legal
|
|
58
|
-
# under a different lens). In practice match?(SEGMENT) catches that
|
|
59
|
-
# above; this is a safety net.
|
|
60
|
-
next if new_stem == stem
|
|
61
|
-
|
|
62
|
-
new_basename = is_dir ? new_stem : new_stem + File.extname(basename)
|
|
63
|
-
target = File.join(File.dirname(abs_path), new_basename)
|
|
64
|
-
target_buckets[target] << abs_path
|
|
65
|
-
|
|
66
|
-
renames << {
|
|
67
|
-
from: abs_path,
|
|
68
|
-
to: target,
|
|
69
|
-
kind: is_dir ? :dir : :file,
|
|
70
|
-
entry: entry,
|
|
71
|
-
base: base,
|
|
72
|
-
}
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
collisions = target_buckets.select { |_, srcs| srcs.length > 1 }
|
|
77
|
-
.map { |t, srcs| { target: t, sources: srcs.sort } }
|
|
78
|
-
|
|
79
|
-
# Drop colliding entries from renames (we won't apply any of them)
|
|
80
|
-
colliding_targets = collisions.to_set { |c| c[:target] }
|
|
81
|
-
renames.reject! { |r| colliding_targets.include?(r[:to]) }
|
|
82
|
-
|
|
83
|
-
# Sort renames bottom-up (deepest path first) so children move before parents.
|
|
84
|
-
renames.sort_by! { |r| -r[:from].count("/") }
|
|
85
|
-
|
|
86
|
-
{ renames: renames, collisions: collisions }
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
# Yields [absolute_path, is_dir] for every entry under root. Depth-first.
|
|
90
|
-
def walk(root, &block)
|
|
91
|
-
Dir.each_child(root) do |name|
|
|
92
|
-
abs = File.join(root, name)
|
|
93
|
-
if File.directory?(abs)
|
|
94
|
-
walk(abs, &block)
|
|
95
|
-
yield abs, true
|
|
96
|
-
else
|
|
97
|
-
yield abs, false
|
|
98
|
-
end
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
# Deterministic transform per plan §3.
|
|
103
|
-
def normalize(s)
|
|
104
|
-
s = s.downcase
|
|
105
|
-
s = s.gsub(/[^a-z0-9-]/, "-") # ., _, and anything else become -
|
|
106
|
-
s = s.gsub(/-+/, "-")
|
|
107
|
-
s.sub(/\A-+/, "").sub(/-+\z/, "")
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
# ------------------------------------------------------------------
|
|
111
|
-
# Apply
|
|
112
|
-
# ------------------------------------------------------------------
|
|
113
|
-
|
|
114
|
-
def apply!(store, renames)
|
|
115
|
-
audit = Textus::Infra::AuditLog.new(store.root)
|
|
116
|
-
renames.each do |r|
|
|
117
|
-
# Bottom-up order means a child's ancestors haven't moved yet, so
|
|
118
|
-
# `from`/`to` are valid as-recorded. The audit `key` reflects the
|
|
119
|
-
# eventual full key once every rename in this batch has applied.
|
|
120
|
-
from = r[:from]
|
|
121
|
-
to = r[:to]
|
|
122
|
-
File.rename(from, to)
|
|
123
|
-
new_key = compute_new_key(r, renames)
|
|
124
|
-
audit.append(
|
|
125
|
-
role: "runner",
|
|
126
|
-
verb: "migrate-keys",
|
|
127
|
-
key: new_key,
|
|
128
|
-
etag_before: nil,
|
|
129
|
-
etag_after: nil,
|
|
130
|
-
extras: { "from" => from, "to" => to },
|
|
131
|
-
)
|
|
132
|
-
end
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
# If an ancestor of `path` was renamed earlier in this batch, rewrite the path.
|
|
136
|
-
def resolve_current_path(path, renames)
|
|
137
|
-
out = path
|
|
138
|
-
renames.each do |r|
|
|
139
|
-
prefix = r[:from] + "/"
|
|
140
|
-
out = r[:to] + out[r[:from].length..] if out.start_with?(prefix)
|
|
141
|
-
end
|
|
142
|
-
out
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
# New full key after applying all renames up through this one.
|
|
146
|
-
def compute_new_key(rename, renames)
|
|
147
|
-
base = rename[:base]
|
|
148
|
-
entry = rename[:entry]
|
|
149
|
-
new_to = resolve_current_path(rename[:to], renames)
|
|
150
|
-
|
|
151
|
-
rel = new_to.sub(%r{\A#{Regexp.escape(base)}/?}, "")
|
|
152
|
-
stripped = rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "") unless rename[:kind] == :dir
|
|
153
|
-
stripped ||= rel
|
|
154
|
-
segs = stripped.split("/").reject(&:empty?)
|
|
155
|
-
(entry.key.split(".") + segs).join(".")
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
# ------------------------------------------------------------------
|
|
159
|
-
# Envelope helpers
|
|
160
|
-
# ------------------------------------------------------------------
|
|
161
|
-
|
|
162
|
-
def envelope_rename(r)
|
|
163
|
-
{
|
|
164
|
-
"from" => r[:from],
|
|
165
|
-
"to" => r[:to],
|
|
166
|
-
"old_key" => path_to_key(r[:from], r[:base], r[:entry], r[:kind]),
|
|
167
|
-
"new_key" => path_to_key(r[:to], r[:base], r[:entry], r[:kind]),
|
|
168
|
-
}
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
def envelope_collision(col)
|
|
172
|
-
{ "target" => col[:target], "sources" => col[:sources] }
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
def path_to_key(path, base, entry, kind)
|
|
176
|
-
rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
|
|
177
|
-
stripped =
|
|
178
|
-
if kind == :dir
|
|
179
|
-
rel
|
|
180
|
-
else
|
|
181
|
-
rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
|
|
182
|
-
end
|
|
183
|
-
segs = stripped.split("/").reject(&:empty?)
|
|
184
|
-
(entry.key.split(".") + segs).join(".")
|
|
185
|
-
end
|
|
186
|
-
end
|
|
187
|
-
end
|
data/lib/textus/projection.rb
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
require "time"
|
|
2
|
-
require "timeout"
|
|
3
|
-
|
|
4
|
-
module Textus
|
|
5
|
-
class Projection
|
|
6
|
-
MAX_LIMIT = 1000
|
|
7
|
-
REDUCER_TIMEOUT_SECONDS = 2
|
|
8
|
-
|
|
9
|
-
# `reader` — a callable `->(key) { envelope_or_nil }`. Caller picks
|
|
10
|
-
# semantics: pure read (`ops.get`) for materialization paths;
|
|
11
|
-
# `ops.get_or_refresh` if you want refresh-on-stale.
|
|
12
|
-
# `lister` — a callable `->(prefix:) { [ { "key" => ... }, ... ] }`.
|
|
13
|
-
# `transform_resolver` — a callable `->(name) { callable_or_raise }`.
|
|
14
|
-
# `transform_context` — `Application::Context` handed to the transform reducer.
|
|
15
|
-
def initialize(reader:, spec:, lister:, transform_resolver:, transform_context:)
|
|
16
|
-
@reader = reader
|
|
17
|
-
@spec = spec || {}
|
|
18
|
-
@lister = lister
|
|
19
|
-
@transform_resolver = transform_resolver
|
|
20
|
-
@transform_context = transform_context
|
|
21
|
-
@limit = (@spec["limit"] || MAX_LIMIT).to_i
|
|
22
|
-
raise InvalidProjection.new("limit #{@limit} exceeds max #{MAX_LIMIT}") if @limit > MAX_LIMIT
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def run
|
|
26
|
-
keys = collect_keys
|
|
27
|
-
explicit_pluck = !@spec["pluck"].nil? && @spec["pluck"] != "*"
|
|
28
|
-
rows = keys.map do |key|
|
|
29
|
-
env = @reader.call(key)
|
|
30
|
-
row = pluck(env.meta, env.body)
|
|
31
|
-
explicit_pluck ? row : row.merge("_key" => key)
|
|
32
|
-
end
|
|
33
|
-
reduced = apply_reducer(rows)
|
|
34
|
-
# Reducers may return either an Array of rows (legacy / templated builds)
|
|
35
|
-
# or a Hash that becomes the structured-format payload base. In the Hash
|
|
36
|
-
# case, downstream sort/limit/position markers don't apply, and the
|
|
37
|
-
# builder owns `_meta.generated_at` so we don't stamp it here.
|
|
38
|
-
return reduced if reduced.is_a?(Hash)
|
|
39
|
-
|
|
40
|
-
rows = reduced
|
|
41
|
-
rows = sort(rows)
|
|
42
|
-
rows = rows.first(@limit)
|
|
43
|
-
mark_positions(rows)
|
|
44
|
-
{ "entries" => rows, "count" => rows.length, "generated_at" => Time.now.utc.iso8601 }
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
private
|
|
48
|
-
|
|
49
|
-
def apply_reducer(rows)
|
|
50
|
-
name = @spec["transform"] or return rows
|
|
51
|
-
callable = @transform_resolver.call(name)
|
|
52
|
-
Timeout.timeout(REDUCER_TIMEOUT_SECONDS) do
|
|
53
|
-
callable.call(store: @transform_context, rows: rows, config: @spec["transform_config"] || {})
|
|
54
|
-
end
|
|
55
|
-
rescue Timeout::Error
|
|
56
|
-
raise UsageError.new("transform_rows '#{name}' exceeded #{REDUCER_TIMEOUT_SECONDS}s timeout")
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def collect_keys
|
|
60
|
-
prefixes = Array(@spec["select"])
|
|
61
|
-
prefixes.flat_map { |p| @lister.call(prefix: p).map { |row| row["key"] } }.uniq
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def pluck(frontmatter, _body)
|
|
65
|
-
fields = @spec["pluck"]
|
|
66
|
-
if fields.nil? || fields == "*"
|
|
67
|
-
frontmatter
|
|
68
|
-
else
|
|
69
|
-
Array(fields).each_with_object({}) { |f, h| h[f] = frontmatter[f] if frontmatter.key?(f) }
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# Adds `_first`, `_last`, and `_index` markers so templates can emit
|
|
74
|
-
# delimiters (e.g. JSON commas) via {{^_last}},{{/_last}}.
|
|
75
|
-
def mark_positions(rows)
|
|
76
|
-
last_idx = rows.length - 1
|
|
77
|
-
rows.each_with_index do |row, i|
|
|
78
|
-
row["_index"] = i
|
|
79
|
-
row["_first"] = i.zero?
|
|
80
|
-
row["_last"] = (i == last_idx)
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def sort(rows)
|
|
85
|
-
sb = @spec["sort_by"] or return rows
|
|
86
|
-
rows.sort_by { |r| r[sb].to_s }
|
|
87
|
-
end
|
|
88
|
-
end
|
|
89
|
-
end
|
data/lib/textus/refresh.rb
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Refresh
|
|
3
|
-
def self.call(store, key, as:)
|
|
4
|
-
Textus::Operations.for(store, role: as).refresh(key)
|
|
5
|
-
end
|
|
6
|
-
|
|
7
|
-
def self.refresh_stale(store, prefix: nil, zone: nil, as: "runner")
|
|
8
|
-
ops = Textus::Operations.for(store, role: as)
|
|
9
|
-
Textus::Application::Refresh::All.call(ops.ctx, prefix: prefix, zone: zone)
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
# Normalize the three accepted intake return shapes into the store's
|
|
13
|
-
# internal {frontmatter, body, content} representation.
|
|
14
|
-
def self.normalize_action_result(res, format:)
|
|
15
|
-
res = res.transform_keys(&:to_s) if res.is_a?(Hash)
|
|
16
|
-
res ||= {}
|
|
17
|
-
meta_val = res["_meta"]
|
|
18
|
-
body = res["body"]
|
|
19
|
-
content = res["content"]
|
|
20
|
-
|
|
21
|
-
case format
|
|
22
|
-
when "markdown"
|
|
23
|
-
{ meta: meta_val || {}, body: body.to_s, content: nil }
|
|
24
|
-
when "text"
|
|
25
|
-
{ meta: {}, body: body.to_s, content: nil }
|
|
26
|
-
when "json", "yaml"
|
|
27
|
-
if !content.nil?
|
|
28
|
-
{ meta: meta_val || {}, body: nil, content: content }
|
|
29
|
-
elsif !body.nil?
|
|
30
|
-
{ meta: {}, body: body.to_s, content: nil }
|
|
31
|
-
else
|
|
32
|
-
raise UsageError.new("intake for #{format} returned neither content nor body")
|
|
33
|
-
end
|
|
34
|
-
else
|
|
35
|
-
raise UsageError.new("unknown format #{format.inspect}")
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
end
|