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
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 = Store::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
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
class Operations
|
|
3
|
-
class Reads
|
|
4
|
-
# `get` — pure read; returns envelope + freshness verdict;
|
|
5
|
-
# never triggers refresh; no orchestrator dependency.
|
|
6
|
-
# `get_or_refresh` — composes `get` with the refresh orchestrator; runs
|
|
7
|
-
# refresh per policy when the verdict says stale.
|
|
8
|
-
# Use this for interactive reads where the caller
|
|
9
|
-
# wants the freshest envelope obtainable.
|
|
10
|
-
#
|
|
11
|
-
# Pick `get` for materialization paths (build, projection, schema tooling).
|
|
12
|
-
# Pick `get_or_refresh` for interactive `textus get` and equivalent.
|
|
13
|
-
def initialize(ctx)
|
|
14
|
-
@ctx = ctx
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def get
|
|
18
|
-
Application::Reads::Get.new(ctx: @ctx)
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def get_or_refresh # rubocop:disable Naming/AccessorMethodName
|
|
22
|
-
Application::Reads::GetOrRefresh.new(
|
|
23
|
-
ctx: @ctx,
|
|
24
|
-
get: get,
|
|
25
|
-
orchestrator: orchestrator,
|
|
26
|
-
)
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def freshness = Application::Reads::Freshness.new(ctx: @ctx)
|
|
30
|
-
def audit = Application::Reads::Audit.new(ctx: @ctx)
|
|
31
|
-
def blame = Application::Reads::Blame.new(ctx: @ctx)
|
|
32
|
-
def policy_explain = Application::Reads::PolicyExplain.new(ctx: @ctx)
|
|
33
|
-
def list = Application::Reads::List.new(ctx: @ctx)
|
|
34
|
-
def where = Application::Reads::Where.new(ctx: @ctx)
|
|
35
|
-
def uid = Application::Reads::Uid.new(ctx: @ctx)
|
|
36
|
-
def schema_envelope = Application::Reads::SchemaEnvelope.new(ctx: @ctx)
|
|
37
|
-
def deps = Application::Reads::Deps.new(ctx: @ctx)
|
|
38
|
-
def rdeps = Application::Reads::Rdeps.new(ctx: @ctx)
|
|
39
|
-
def published = Application::Reads::Published.new(ctx: @ctx)
|
|
40
|
-
def stale = Application::Reads::Stale.new(ctx: @ctx)
|
|
41
|
-
def validate_all = Application::Reads::ValidateAll.new(ctx: @ctx)
|
|
42
|
-
|
|
43
|
-
private
|
|
44
|
-
|
|
45
|
-
def orchestrator
|
|
46
|
-
Application::Refresh::Orchestrator.new(
|
|
47
|
-
worker: Application::Refresh::Worker.new(ctx: @ctx, bus: @ctx.store.bus),
|
|
48
|
-
bus: @ctx.store.bus,
|
|
49
|
-
store_root: @ctx.store.root,
|
|
50
|
-
store: @ctx.store,
|
|
51
|
-
role: @ctx.role,
|
|
52
|
-
)
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
end
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
class Operations
|
|
3
|
-
class Refresh
|
|
4
|
-
def initialize(ctx)
|
|
5
|
-
@ctx = ctx
|
|
6
|
-
end
|
|
7
|
-
|
|
8
|
-
def worker
|
|
9
|
-
Application::Refresh::Worker.new(ctx: @ctx, bus: @ctx.store.bus)
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
def orchestrator
|
|
13
|
-
Application::Refresh::Orchestrator.new(
|
|
14
|
-
worker: worker,
|
|
15
|
-
bus: @ctx.store.bus,
|
|
16
|
-
store_root: @ctx.store.root,
|
|
17
|
-
store: @ctx.store,
|
|
18
|
-
role: @ctx.role,
|
|
19
|
-
)
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def all
|
|
23
|
-
Application::Refresh::All.new(ctx: @ctx, bus: @ctx.store.bus)
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
end
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
class Operations
|
|
3
|
-
class Writes
|
|
4
|
-
def initialize(ctx)
|
|
5
|
-
@ctx = ctx
|
|
6
|
-
end
|
|
7
|
-
|
|
8
|
-
def put = Application::Writes::Put.new(ctx: @ctx, bus: bus)
|
|
9
|
-
def delete = Application::Writes::Delete.new(ctx: @ctx, bus: bus)
|
|
10
|
-
def mv = Application::Writes::Mv.new(ctx: @ctx, bus: bus)
|
|
11
|
-
def accept = Application::Writes::Accept.new(ctx: @ctx, bus: bus)
|
|
12
|
-
def build = Application::Writes::Build.new(ctx: @ctx, bus: bus)
|
|
13
|
-
def publish = Application::Writes::Publish.new(ctx: @ctx, bus: bus)
|
|
14
|
-
def reject = Application::Writes::Reject.new(ctx: @ctx, bus: bus)
|
|
15
|
-
|
|
16
|
-
private
|
|
17
|
-
|
|
18
|
-
def bus = @ctx.store.bus
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
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.reads.get`) for materialization paths;
|
|
11
|
-
# `ops.reads.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.worker.run(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
|
data/lib/textus/store/reader.rb
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
class Store
|
|
3
|
-
class Reader
|
|
4
|
-
def initialize(store)
|
|
5
|
-
@store = store
|
|
6
|
-
@manifest = store.manifest
|
|
7
|
-
end
|
|
8
|
-
|
|
9
|
-
def get(key)
|
|
10
|
-
read_raw_envelope(key) || raise(UnknownKey.new(key, suggestions: @manifest.suggestions_for(key)))
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
# Reads the current on-disk state of key as a bare envelope, skipping
|
|
14
|
-
# freshness annotation to avoid recursion. Used by Freshness.refresh_sync
|
|
15
|
-
# after a sync refresh completes.
|
|
16
|
-
def read_raw_envelope(key)
|
|
17
|
-
mentry, path, = @manifest.resolve(key)
|
|
18
|
-
return nil unless File.exist?(path)
|
|
19
|
-
|
|
20
|
-
raw = File.binread(path)
|
|
21
|
-
parsed = Entry.for_format(mentry.format).parse(raw, path: path)
|
|
22
|
-
Envelope.build(
|
|
23
|
-
key: key, mentry: mentry, path: path,
|
|
24
|
-
meta: parsed["_meta"], body: parsed["body"],
|
|
25
|
-
etag: Etag.for_bytes(raw), content: parsed["content"]
|
|
26
|
-
)
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def list(prefix: nil, zone: nil)
|
|
30
|
-
rows = @manifest.enumerate(prefix: prefix)
|
|
31
|
-
rows = rows.select { |r| r[:manifest_entry].zone == zone } if zone
|
|
32
|
-
rows.map { |row| { "key" => row[:key], "zone" => row[:manifest_entry].zone, "path" => row[:path] } }
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def where(key)
|
|
36
|
-
mentry, path, = @manifest.resolve(key)
|
|
37
|
-
{ "protocol" => PROTOCOL, "key" => key, "zone" => mentry.zone, "owner" => mentry.owner, "path" => path }
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def schema_envelope(key)
|
|
41
|
-
mentry, = @manifest.resolve(key)
|
|
42
|
-
schema = @store.schema_for(mentry.schema)
|
|
43
|
-
{ "protocol" => PROTOCOL, "key" => key, "schema_ref" => mentry.schema, "schema" => schema&.to_h }
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# Returns the Textus UID for a key (or nil if the entry has none yet).
|
|
47
|
-
# Raises UnknownKey if the key doesn't resolve to a real file.
|
|
48
|
-
def uid(key)
|
|
49
|
-
get(key).uid
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def deps(key) = Dependencies.deps_of(@manifest, key)
|
|
53
|
-
def rdeps(key) = Dependencies.rdeps_of(@manifest, key)
|
|
54
|
-
def published = Dependencies.published_of(@manifest)
|
|
55
|
-
|
|
56
|
-
def stale(prefix: nil, zone: nil)
|
|
57
|
-
Staleness.new(manifest: @manifest).call(prefix: prefix, zone: zone)
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def validate_all
|
|
61
|
-
Validator.new(
|
|
62
|
-
reader: self, manifest: @manifest,
|
|
63
|
-
audit_log: @store.audit_log,
|
|
64
|
-
schema_for: ->(name) { @store.schema_for(name) }
|
|
65
|
-
).call
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
end
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
class Store
|
|
3
|
-
class Validator
|
|
4
|
-
def initialize(reader:, manifest:, audit_log:, schema_for:)
|
|
5
|
-
@reader = reader
|
|
6
|
-
@manifest = manifest
|
|
7
|
-
@audit_log = audit_log
|
|
8
|
-
@schema_for = schema_for
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
def call
|
|
12
|
-
violations = []
|
|
13
|
-
check_content_violations(violations)
|
|
14
|
-
check_role_authority_violations(violations)
|
|
15
|
-
{ "protocol" => PROTOCOL, "ok" => violations.empty?, "violations" => violations }
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
private
|
|
19
|
-
|
|
20
|
-
def check_content_violations(violations)
|
|
21
|
-
@manifest.enumerate.each do |row|
|
|
22
|
-
key = row[:key]
|
|
23
|
-
mentry = row[:manifest_entry]
|
|
24
|
-
env = fetch_envelope(key, violations) or next
|
|
25
|
-
schema = mentry.schema && @schema_for.call(mentry.schema)
|
|
26
|
-
next unless schema
|
|
27
|
-
|
|
28
|
-
begin
|
|
29
|
-
validate_schema!(schema, env, mentry.format)
|
|
30
|
-
rescue Textus::Error => e
|
|
31
|
-
violations << { "key" => key, "code" => e.code, "message" => e.message }
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def check_role_authority_violations(violations)
|
|
37
|
-
@manifest.enumerate.each do |row|
|
|
38
|
-
mentry = row[:manifest_entry]
|
|
39
|
-
next unless mentry.schema
|
|
40
|
-
|
|
41
|
-
schema = @schema_for.call(mentry.schema)
|
|
42
|
-
next unless schema
|
|
43
|
-
|
|
44
|
-
env = begin
|
|
45
|
-
@reader.get(row[:key])
|
|
46
|
-
rescue StandardError
|
|
47
|
-
next
|
|
48
|
-
end
|
|
49
|
-
append_authority_violations(violations, row[:key], env, schema)
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def append_authority_violations(violations, key, env, schema)
|
|
54
|
-
last_writer = @audit_log.last_writer_for(key)
|
|
55
|
-
return if last_writer.nil?
|
|
56
|
-
|
|
57
|
-
env.meta.each_key do |field|
|
|
58
|
-
owner = schema.maintained_by(field)
|
|
59
|
-
next if owner.nil? || last_writer == owner || last_writer == "human"
|
|
60
|
-
|
|
61
|
-
violations << { "key" => key, "code" => "role_authority",
|
|
62
|
-
"field" => field, "expected" => owner, "last_writer" => last_writer }
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def fetch_envelope(key, violations)
|
|
67
|
-
@reader.get(key)
|
|
68
|
-
rescue Textus::Error => e
|
|
69
|
-
violations << { "key" => key, "code" => e.code, "message" => e.message }
|
|
70
|
-
nil
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def validate_schema!(schema, envelope, format)
|
|
74
|
-
payload = case format
|
|
75
|
-
when "json", "yaml" then envelope.content || {}
|
|
76
|
-
else envelope.meta || {}
|
|
77
|
-
end
|
|
78
|
-
schema.validate!(payload)
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
end
|
data/lib/textus/store/writer.rb
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
require "fileutils"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
class Store
|
|
5
|
-
class Writer
|
|
6
|
-
Payload = Data.define(:meta, :body, :content)
|
|
7
|
-
|
|
8
|
-
def initialize(store)
|
|
9
|
-
@store = store
|
|
10
|
-
@manifest = store.manifest
|
|
11
|
-
@reader = store.reader
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
# Pure I/O: validate, serialize, etag-check, write to disk, audit. No
|
|
15
|
-
# permission check and no event firing — those are handled by the caller
|
|
16
|
-
# (Application::Writes::Put).
|
|
17
|
-
def write_envelope_to_disk(key, mentry:, payload:, ctx:, if_etag: nil)
|
|
18
|
-
_, path, = @manifest.resolve(key)
|
|
19
|
-
|
|
20
|
-
meta = payload.meta || {}
|
|
21
|
-
strategy = Entry.for_format(mentry.format)
|
|
22
|
-
|
|
23
|
-
existing_uid = existing_uid_for(mentry, path)
|
|
24
|
-
meta, content = ensure_uid(mentry.format, meta, payload.content, existing_uid)
|
|
25
|
-
|
|
26
|
-
bytes, eff_meta, eff_body, eff_content = serialize_for_put(
|
|
27
|
-
mentry: mentry, path: path, strategy: strategy,
|
|
28
|
-
meta: meta, body: payload.body, content: content
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
enforce_name_match!(path, eff_meta, mentry.format)
|
|
32
|
-
|
|
33
|
-
schema = @store.schema_for(mentry.schema)
|
|
34
|
-
if schema
|
|
35
|
-
Entry.for_format(mentry.format).validate_against(
|
|
36
|
-
schema,
|
|
37
|
-
{ "_meta" => eff_meta, "content" => eff_content },
|
|
38
|
-
)
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
etag_before = File.exist?(path) ? Etag.for_file(path) : nil
|
|
42
|
-
raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && (etag_before != if_etag)
|
|
43
|
-
|
|
44
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
45
|
-
File.binwrite(path, bytes)
|
|
46
|
-
etag_after = Etag.for_bytes(bytes)
|
|
47
|
-
@store.audit_log.append(
|
|
48
|
-
role: ctx.role, verb: "put", key: key,
|
|
49
|
-
etag_before: etag_before, etag_after: etag_after,
|
|
50
|
-
extras: ctx.correlation_id ? { "correlation_id" => ctx.correlation_id } : nil
|
|
51
|
-
)
|
|
52
|
-
Envelope.build(
|
|
53
|
-
key: key, mentry: mentry, path: path,
|
|
54
|
-
meta: eff_meta, body: eff_body, etag: etag_after, content: eff_content
|
|
55
|
-
)
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def existing_uid_for(mentry, path)
|
|
59
|
-
return nil unless File.exist?(path)
|
|
60
|
-
|
|
61
|
-
raw = File.binread(path)
|
|
62
|
-
parsed = Entry.for_format(mentry.format).parse(raw, path: path)
|
|
63
|
-
Envelope.extract_uid(parsed["_meta"])
|
|
64
|
-
rescue StandardError
|
|
65
|
-
nil
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def ensure_uid(format, meta, content, existing_uid)
|
|
69
|
-
Textus::Entry.for_format(format).inject_uid(meta, content, existing_uid)
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def enforce_name_match!(path, meta, format)
|
|
73
|
-
Textus::Entry.for_format(format).enforce_name_match!(path, meta)
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def serialize_for_put(mentry:, path:, strategy:, meta:, body:, content:)
|
|
77
|
-
_ = strategy
|
|
78
|
-
Textus::Entry.for_format(mentry.format).serialize_for_put(
|
|
79
|
-
meta: meta, body: body, content: content, path: path,
|
|
80
|
-
)
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
# Pure I/O: resolve path, validate etag, delete from disk, audit. No
|
|
84
|
-
# permission check and no event firing — those are handled by the caller
|
|
85
|
-
# (Application::Writes::Delete).
|
|
86
|
-
def delete_envelope_from_disk(key, ctx:, if_etag: nil)
|
|
87
|
-
_, path, = @manifest.resolve(key)
|
|
88
|
-
raise UnknownKey.new(key, suggestions: @manifest.suggestions_for(key)) unless File.exist?(path)
|
|
89
|
-
|
|
90
|
-
etag_before = Etag.for_file(path)
|
|
91
|
-
raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before
|
|
92
|
-
|
|
93
|
-
File.delete(path)
|
|
94
|
-
@store.audit_log.append(
|
|
95
|
-
role: ctx.role, verb: "delete", key: key,
|
|
96
|
-
etag_before: etag_before, etag_after: nil,
|
|
97
|
-
extras: ctx.correlation_id ? { "correlation_id" => ctx.correlation_id } : nil
|
|
98
|
-
)
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
end
|