textus 0.5.0 → 0.8.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/CHANGELOG.md +83 -1
- data/README.md +29 -21
- data/SPEC.md +75 -142
- data/docs/architecture.md +42 -23
- data/lib/textus/builder/pipeline.rb +56 -0
- data/lib/textus/builder/renderer/json.rb +42 -0
- data/lib/textus/builder/renderer/markdown.rb +22 -0
- data/lib/textus/builder/renderer/text.rb +14 -0
- data/lib/textus/builder/renderer/yaml.rb +42 -0
- data/lib/textus/builder/renderer.rb +17 -0
- data/lib/textus/builder.rb +9 -114
- data/lib/textus/cli/group/hook.rb +11 -0
- data/lib/textus/cli/group/key.rb +12 -0
- data/lib/textus/cli/group/schema.rb +13 -0
- data/lib/textus/cli/verb/accept.rb +15 -0
- data/lib/textus/cli/verb/build.rb +13 -0
- data/lib/textus/cli/verb/delete.rb +16 -0
- data/lib/textus/cli/verb/deps.rb +12 -0
- data/lib/textus/cli/verb/doctor.rb +15 -0
- data/lib/textus/cli/verb/get.rb +12 -0
- data/lib/textus/cli/verb/hook_run.rb +48 -0
- data/lib/textus/cli/verb/hooks.rb +50 -0
- data/lib/textus/cli/verb/init.rb +14 -0
- data/lib/textus/cli/verb/intro.rb +11 -0
- data/lib/textus/cli/verb/list.rb +14 -0
- data/lib/textus/cli/verb/migrate_keys.rb +16 -0
- data/lib/textus/cli/verb/mv.rb +17 -0
- data/lib/textus/cli/verb/published.rb +11 -0
- data/lib/textus/cli/verb/put.rb +50 -0
- data/lib/textus/cli/verb/rdeps.rb +12 -0
- data/lib/textus/cli/verb/refresh.rb +15 -0
- data/lib/textus/cli/verb/schema.rb +12 -0
- data/lib/textus/cli/verb/schema_diff.rb +12 -0
- data/lib/textus/cli/verb/schema_init.rb +16 -0
- data/lib/textus/cli/verb/schema_migrate.rb +16 -0
- data/lib/textus/cli/verb/stale.rb +14 -0
- data/lib/textus/cli/verb/uid.rb +12 -0
- data/lib/textus/cli/verb/where.rb +12 -0
- data/lib/textus/cli.rb +23 -42
- data/lib/textus/doctor/check/audit_log.rb +50 -0
- data/lib/textus/doctor/check/hooks.rb +29 -0
- data/lib/textus/doctor/check/illegal_keys.rb +49 -0
- data/lib/textus/doctor/check/manifest_files.rb +38 -0
- data/lib/textus/doctor/check/schema_violations.rb +22 -0
- data/lib/textus/doctor/check/schemas.rb +26 -0
- data/lib/textus/doctor/check/sentinels.rb +57 -0
- data/lib/textus/doctor/check/templates.rb +26 -0
- data/lib/textus/doctor/check/unowned_schema_fields.rb +34 -0
- data/lib/textus/doctor/check.rb +30 -0
- data/lib/textus/doctor.rb +22 -288
- data/lib/textus/entry/base.rb +30 -0
- data/lib/textus/entry/json.rb +5 -1
- data/lib/textus/entry/markdown.rb +1 -1
- data/lib/textus/entry/text.rb +1 -1
- data/lib/textus/entry/yaml.rb +5 -1
- data/lib/textus/entry.rb +0 -5
- data/lib/textus/envelope.rb +30 -0
- data/lib/textus/hooks/builtin.rb +70 -0
- data/lib/textus/hooks/dispatcher.rb +49 -0
- data/lib/textus/hooks/loader.rb +26 -0
- data/lib/textus/hooks/registry.rb +73 -0
- data/lib/textus/init.rb +13 -10
- data/lib/textus/intro.rb +14 -16
- data/lib/textus/key/distance.rb +55 -0
- data/lib/textus/key/grammar.rb +33 -0
- data/lib/textus/key/path.rb +17 -0
- data/lib/textus/manifest/entry.rb +199 -0
- data/lib/textus/manifest.rb +10 -34
- data/lib/textus/migrate_keys.rb +1 -1
- data/lib/textus/projection.rb +5 -4
- data/lib/textus/proposal.rb +1 -1
- data/lib/textus/refresh.rb +11 -11
- data/lib/textus/schema/tools.rb +89 -0
- data/lib/textus/store/audit_log.rb +71 -0
- data/lib/textus/store/mover.rb +19 -16
- data/lib/textus/store/reader.rb +67 -0
- data/lib/textus/store/staleness.rb +10 -19
- data/lib/textus/store/validator.rb +11 -8
- data/lib/textus/store/view.rb +29 -0
- data/lib/textus/store/writer.rb +132 -0
- data/lib/textus/store.rb +25 -221
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +14 -67
- metadata +73 -40
- data/lib/textus/audit_log.rb +0 -67
- data/lib/textus/builtin_actions.rb +0 -68
- data/lib/textus/cli/accept.rb +0 -13
- data/lib/textus/cli/action.rb +0 -51
- data/lib/textus/cli/build.rb +0 -11
- data/lib/textus/cli/delete.rb +0 -14
- data/lib/textus/cli/deprecated_alias.rb +0 -31
- data/lib/textus/cli/deps.rb +0 -10
- data/lib/textus/cli/doctor.rb +0 -13
- data/lib/textus/cli/extension_group.rb +0 -9
- data/lib/textus/cli/extensions.rb +0 -49
- data/lib/textus/cli/get.rb +0 -10
- data/lib/textus/cli/init.rb +0 -12
- data/lib/textus/cli/intro.rb +0 -9
- data/lib/textus/cli/key_group.rb +0 -10
- data/lib/textus/cli/list.rb +0 -12
- data/lib/textus/cli/migrate.rb +0 -41
- data/lib/textus/cli/migrate_keys.rb +0 -19
- data/lib/textus/cli/mv.rb +0 -20
- data/lib/textus/cli/published.rb +0 -9
- data/lib/textus/cli/put.rb +0 -48
- data/lib/textus/cli/rdeps.rb +0 -10
- data/lib/textus/cli/refresh.rb +0 -13
- data/lib/textus/cli/schema.rb +0 -10
- data/lib/textus/cli/schema_diff.rb +0 -15
- data/lib/textus/cli/schema_group.rb +0 -33
- data/lib/textus/cli/schema_init.rb +0 -19
- data/lib/textus/cli/schema_migrate.rb +0 -19
- data/lib/textus/cli/stale.rb +0 -12
- data/lib/textus/cli/uid.rb +0 -15
- data/lib/textus/cli/where.rb +0 -10
- data/lib/textus/extension_registry.rb +0 -61
- data/lib/textus/extensions.rb +0 -33
- data/lib/textus/key_distance.rb +0 -53
- data/lib/textus/manifest_entry.rb +0 -185
- data/lib/textus/migrate_v2.rb +0 -27
- data/lib/textus/schema_tools.rb +0 -87
- data/lib/textus/store/events.rb +0 -31
- data/lib/textus/store_view.rb +0 -27
data/lib/textus/refresh.rb
CHANGED
|
@@ -2,26 +2,26 @@ require "timeout"
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Refresh
|
|
5
|
-
|
|
5
|
+
FETCH_TIMEOUT_SECONDS = 2
|
|
6
6
|
|
|
7
7
|
def self.call(store, key, as:)
|
|
8
8
|
mentry, path, = store.manifest.resolve(key)
|
|
9
|
-
raise UsageError.new("no
|
|
9
|
+
raise UsageError.new("no fetch declared for '#{key}'") unless mentry.fetch
|
|
10
10
|
|
|
11
11
|
before_etag = File.exist?(path) ? Etag.for_file(path) : nil
|
|
12
|
-
callable = store.registry.
|
|
13
|
-
view =
|
|
12
|
+
callable = store.registry.rpc_callable(:fetch, mentry.fetch)
|
|
13
|
+
view = Store::View.new(store, writable: true, as: as)
|
|
14
14
|
result =
|
|
15
15
|
begin
|
|
16
|
-
Timeout.timeout(
|
|
17
|
-
callable.call(
|
|
16
|
+
Timeout.timeout(FETCH_TIMEOUT_SECONDS) do
|
|
17
|
+
callable.call(store: view, config: mentry.fetch_config, args: {})
|
|
18
18
|
end
|
|
19
19
|
rescue Timeout::Error
|
|
20
|
-
raise UsageError.new("
|
|
20
|
+
raise UsageError.new("fetch '#{mentry.fetch}' exceeded #{FETCH_TIMEOUT_SECONDS}s timeout")
|
|
21
21
|
rescue Textus::Error
|
|
22
22
|
raise
|
|
23
23
|
rescue StandardError => e
|
|
24
|
-
raise UsageError.new("
|
|
24
|
+
raise UsageError.new("fetch '#{mentry.fetch}' raised: #{e.class}: #{e.message}")
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
normalized = normalize_action_result(result, format: mentry.format)
|
|
@@ -45,12 +45,12 @@ module Textus
|
|
|
45
45
|
envelope
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
-
# Normalize the three accepted
|
|
48
|
+
# Normalize the three accepted fetch return shapes into the store's
|
|
49
49
|
# internal {frontmatter, body, content} representation.
|
|
50
50
|
def self.normalize_action_result(res, format:)
|
|
51
51
|
res = res.transform_keys(&:to_s) if res.is_a?(Hash)
|
|
52
52
|
res ||= {}
|
|
53
|
-
# Accept both legacy :frontmatter/:_meta key names from
|
|
53
|
+
# Accept both legacy :frontmatter/:_meta key names from fetch hooks.
|
|
54
54
|
meta_val = res["_meta"] || res["frontmatter"]
|
|
55
55
|
body = res["body"]
|
|
56
56
|
content = res["content"]
|
|
@@ -66,7 +66,7 @@ module Textus
|
|
|
66
66
|
elsif !body.nil?
|
|
67
67
|
{ meta: {}, body: body.to_s, content: nil }
|
|
68
68
|
else
|
|
69
|
-
raise UsageError.new("
|
|
69
|
+
raise UsageError.new("fetch for #{format} returned neither content nor body")
|
|
70
70
|
end
|
|
71
71
|
else
|
|
72
72
|
raise UsageError.new("unknown format #{format.inspect}")
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
|
|
4
|
+
module Textus
|
|
5
|
+
class Schema
|
|
6
|
+
module Tools
|
|
7
|
+
# textus schema init NAME --from=KEY → infer YAML schema from an entry's frontmatter
|
|
8
|
+
def self.init(store, name:, from:)
|
|
9
|
+
env = store.get(from)
|
|
10
|
+
meta = env["_meta"]
|
|
11
|
+
schema = {
|
|
12
|
+
"name" => name,
|
|
13
|
+
"required" => meta.keys,
|
|
14
|
+
"optional" => [],
|
|
15
|
+
"fields" => meta.each_with_object({}) { |(k, v), h| h[k] = { "type" => infer_type(v) } },
|
|
16
|
+
}
|
|
17
|
+
FileUtils.mkdir_p(File.join(store.root, "schemas"))
|
|
18
|
+
target = File.join(store.root, "schemas", "#{name}.yaml")
|
|
19
|
+
File.write(target, YAML.dump(schema))
|
|
20
|
+
{ "protocol" => PROTOCOL, "schema_name" => name, "path" => target }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# textus schema diff NAME → list keys whose frontmatter violates the schema
|
|
24
|
+
def self.diff(store, name:)
|
|
25
|
+
schema = load_schema(store, name)
|
|
26
|
+
drift = []
|
|
27
|
+
store.manifest.enumerate.each do |row|
|
|
28
|
+
env = store.get(row[:key])
|
|
29
|
+
begin
|
|
30
|
+
schema.validate!(env["_meta"])
|
|
31
|
+
rescue SchemaViolation => e
|
|
32
|
+
drift << { "key" => row[:key], "details" => e.details }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
{ "protocol" => PROTOCOL, "schema_name" => name, "drift" => drift }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# textus schema migrate NAME --rename=OLD:NEW → rewrites frontmatter across affected entries
|
|
39
|
+
# If --rename is omitted, falls back to schema.evolution.migrate_from.
|
|
40
|
+
def self.migrate(store, name:, rename: nil)
|
|
41
|
+
renames =
|
|
42
|
+
if rename
|
|
43
|
+
old_field, new_field = rename.split(":", 2)
|
|
44
|
+
raise UsageError.new("--rename=OLD:NEW") unless old_field && new_field && !new_field.empty?
|
|
45
|
+
|
|
46
|
+
{ old_field => new_field }
|
|
47
|
+
else
|
|
48
|
+
load_schema(store, name).evolution["migrate_from"] || {}
|
|
49
|
+
end
|
|
50
|
+
raise UsageError.new("schema migrate needs --rename=OLD:NEW or schema.evolution.migrate_from") if renames.empty?
|
|
51
|
+
|
|
52
|
+
touched = []
|
|
53
|
+
store.manifest.enumerate.each do |row|
|
|
54
|
+
env = store.get(row[:key])
|
|
55
|
+
meta = env["_meta"]
|
|
56
|
+
changed = false
|
|
57
|
+
renames.each do |old, new|
|
|
58
|
+
if meta.key?(old)
|
|
59
|
+
meta[new] = meta.delete(old)
|
|
60
|
+
changed = true
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
next unless changed
|
|
64
|
+
|
|
65
|
+
store.put(row[:key], meta: meta, body: env["body"], as: "human")
|
|
66
|
+
touched << row[:key]
|
|
67
|
+
end
|
|
68
|
+
{ "protocol" => PROTOCOL, "migrated" => touched, "renames" => renames }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.infer_type(value)
|
|
72
|
+
case value
|
|
73
|
+
when String then "string"
|
|
74
|
+
when Numeric then "number"
|
|
75
|
+
when true, false then "boolean"
|
|
76
|
+
when Array then "array"
|
|
77
|
+
when Hash then "object"
|
|
78
|
+
else "string"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.load_schema(store, name)
|
|
83
|
+
store.schema_for(name)
|
|
84
|
+
rescue IoError
|
|
85
|
+
raise UsageError.new("schema not found: #{name}")
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "time"
|
|
3
|
+
|
|
4
|
+
module Textus
|
|
5
|
+
class Store
|
|
6
|
+
class AuditLog
|
|
7
|
+
def initialize(root)
|
|
8
|
+
@path = File.join(root, "audit.log")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def last_writer_for(key)
|
|
12
|
+
return nil unless File.exist?(@path)
|
|
13
|
+
|
|
14
|
+
last_role = nil
|
|
15
|
+
File.foreach(@path) do |line|
|
|
16
|
+
parsed = parse_row(line.chomp)
|
|
17
|
+
next unless parsed
|
|
18
|
+
next unless parsed["key"] == key
|
|
19
|
+
next unless %w[put delete].include?(parsed["verb"])
|
|
20
|
+
|
|
21
|
+
last_role = parsed["role"]
|
|
22
|
+
end
|
|
23
|
+
last_role
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def append(role:, verb:, key:, etag_before:, etag_after:, extras: nil)
|
|
27
|
+
row = {
|
|
28
|
+
"ts" => Time.now.utc.iso8601,
|
|
29
|
+
"role" => role,
|
|
30
|
+
"verb" => verb,
|
|
31
|
+
"key" => key,
|
|
32
|
+
"etag_before" => etag_before,
|
|
33
|
+
"etag_after" => etag_after,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if extras.is_a?(Hash) && !extras.empty?
|
|
37
|
+
extras = extras.dup
|
|
38
|
+
%w[from_key to_key uid].each do |k|
|
|
39
|
+
row[k] = extras.delete(k) if extras.key?(k)
|
|
40
|
+
end
|
|
41
|
+
row["extras"] = extras unless extras.empty?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
File.open(@path, File::WRONLY | File::APPEND | File::CREAT, 0o644) do |f|
|
|
45
|
+
f.flock(File::LOCK_EX)
|
|
46
|
+
f.write(JSON.generate(row) + "\n")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def parse_row(line)
|
|
53
|
+
return nil if line.empty?
|
|
54
|
+
|
|
55
|
+
if line.start_with?("{")
|
|
56
|
+
JSON.parse(line)
|
|
57
|
+
else
|
|
58
|
+
# Legacy TSV (pre-0.5): read-only support retained for on-disk logs
|
|
59
|
+
# written by older textus versions. Never written by current code.
|
|
60
|
+
# Format: ts, role, verb, key, etag_before, etag_after [, json_extras]
|
|
61
|
+
fields = line.split("\t")
|
|
62
|
+
return nil if fields.length < 4
|
|
63
|
+
|
|
64
|
+
{ "ts" => fields[0], "role" => fields[1], "verb" => fields[2], "key" => fields[3] }
|
|
65
|
+
end
|
|
66
|
+
rescue JSON::ParserError
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/textus/store/mover.rb
CHANGED
|
@@ -4,19 +4,22 @@ module Textus
|
|
|
4
4
|
class Store
|
|
5
5
|
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
6
6
|
class Mover
|
|
7
|
-
def initialize(
|
|
8
|
-
@
|
|
7
|
+
def initialize(reader:, writer:, manifest:, audit_log:)
|
|
8
|
+
@reader = reader
|
|
9
|
+
@writer = writer
|
|
10
|
+
@manifest = manifest
|
|
11
|
+
@audit_log = audit_log
|
|
9
12
|
end
|
|
10
13
|
|
|
11
14
|
def call(old_key, new_key, as: Role::DEFAULT, dry_run: false)
|
|
12
|
-
@
|
|
13
|
-
@
|
|
15
|
+
@manifest.validate_key!(old_key)
|
|
16
|
+
@manifest.validate_key!(new_key)
|
|
14
17
|
raise UsageError.new("mv: old and new keys are identical") if old_key == new_key
|
|
15
18
|
|
|
16
|
-
old_mentry, old_path, = @
|
|
19
|
+
old_mentry, old_path, = @manifest.resolve(old_key)
|
|
17
20
|
raise UnknownKey.new(old_key) unless File.exist?(old_path)
|
|
18
21
|
|
|
19
|
-
new_mentry, new_path, = @
|
|
22
|
+
new_mentry, new_path, = @manifest.resolve(new_key)
|
|
20
23
|
|
|
21
24
|
if old_mentry.zone != new_mentry.zone
|
|
22
25
|
raise UsageError.new(
|
|
@@ -30,13 +33,13 @@ module Textus
|
|
|
30
33
|
)
|
|
31
34
|
end
|
|
32
35
|
|
|
33
|
-
writers = @
|
|
36
|
+
writers = @manifest.zone_writers(old_mentry.zone)
|
|
34
37
|
raise WriteForbidden.new(old_key, old_mentry.zone, writers: writers) unless writers.include?(as)
|
|
35
38
|
|
|
36
39
|
raise UsageError.new("mv: target '#{new_key}' already exists at #{new_path}") if File.exist?(new_path)
|
|
37
40
|
|
|
38
41
|
# Mint uid before the move so the audit row carries it.
|
|
39
|
-
pre_env = @
|
|
42
|
+
pre_env = @reader.get(old_key)
|
|
40
43
|
current_uid = pre_env["uid"]
|
|
41
44
|
etag_before = pre_env["etag"]
|
|
42
45
|
|
|
@@ -51,12 +54,12 @@ module Textus
|
|
|
51
54
|
|
|
52
55
|
if current_uid.nil?
|
|
53
56
|
# Write the uid in place first so the source file carries it before mv.
|
|
54
|
-
pre_env = @
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
pre_env = @writer.put(old_key,
|
|
58
|
+
meta: pre_env["_meta"],
|
|
59
|
+
body: pre_env["body"],
|
|
60
|
+
content: pre_env["content"],
|
|
61
|
+
as: as,
|
|
62
|
+
suppress_events: true)
|
|
60
63
|
current_uid = pre_env["uid"]
|
|
61
64
|
etag_before = pre_env["etag"]
|
|
62
65
|
end
|
|
@@ -66,7 +69,7 @@ module Textus
|
|
|
66
69
|
rewrite_name_for_mv!(new_mentry, new_path, new_key)
|
|
67
70
|
etag_after = Etag.for_file(new_path)
|
|
68
71
|
|
|
69
|
-
@
|
|
72
|
+
@audit_log.append(
|
|
70
73
|
role: as, verb: "mv", key: new_key,
|
|
71
74
|
etag_before: etag_before, etag_after: etag_after,
|
|
72
75
|
extras: {
|
|
@@ -76,7 +79,7 @@ module Textus
|
|
|
76
79
|
}
|
|
77
80
|
)
|
|
78
81
|
|
|
79
|
-
env = @
|
|
82
|
+
env = @reader.get(new_key)
|
|
80
83
|
{
|
|
81
84
|
"protocol" => PROTOCOL, "ok" => true,
|
|
82
85
|
"from_key" => old_key, "to_key" => new_key,
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
mentry, path, = @manifest.resolve(key)
|
|
11
|
+
raise UnknownKey.new(key, suggestions: @manifest.suggestions_for(key)) unless File.exist?(path)
|
|
12
|
+
|
|
13
|
+
raw = File.binread(path)
|
|
14
|
+
parsed = Entry.for_format(mentry.format).parse(raw, path: path)
|
|
15
|
+
meta = parsed["_meta"]
|
|
16
|
+
content = parsed["content"]
|
|
17
|
+
@store.writer.enforce_name_match!(path, meta, mentry.format)
|
|
18
|
+
schema = @store.schema_for(mentry.schema)
|
|
19
|
+
Entry.for_format(mentry.format).validate_against(schema, parsed) if schema
|
|
20
|
+
Envelope.build(
|
|
21
|
+
key: key, mentry: mentry, path: path,
|
|
22
|
+
meta: meta, body: parsed["body"],
|
|
23
|
+
etag: Etag.for_bytes(raw), content: content
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def list(prefix: nil, zone: nil)
|
|
28
|
+
rows = @manifest.enumerate(prefix: prefix)
|
|
29
|
+
rows = rows.select { |r| r[:manifest_entry].zone == zone } if zone
|
|
30
|
+
rows.map { |row| { "key" => row[:key], "zone" => row[:manifest_entry].zone, "path" => row[:path] } }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def where(key)
|
|
34
|
+
mentry, path, = @manifest.resolve(key)
|
|
35
|
+
{ "protocol" => PROTOCOL, "key" => key, "zone" => mentry.zone, "owner" => mentry.owner, "path" => path }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def schema_envelope(key)
|
|
39
|
+
mentry, = @manifest.resolve(key)
|
|
40
|
+
schema = @store.schema_for(mentry.schema)
|
|
41
|
+
{ "protocol" => PROTOCOL, "key" => key, "schema_ref" => mentry.schema, "schema" => schema&.to_h }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Returns the Textus UID for a key (or nil if the entry has none yet).
|
|
45
|
+
# Raises UnknownKey if the key doesn't resolve to a real file.
|
|
46
|
+
def uid(key)
|
|
47
|
+
get(key)["uid"]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def deps(key) = Dependencies.deps_of(@manifest, key)
|
|
51
|
+
def rdeps(key) = Dependencies.rdeps_of(@manifest, key)
|
|
52
|
+
def published = Dependencies.published_of(@manifest)
|
|
53
|
+
|
|
54
|
+
def stale(prefix: nil, zone: nil)
|
|
55
|
+
Staleness.new(manifest: @manifest).call(prefix: prefix, zone: zone)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def validate_all
|
|
59
|
+
Validator.new(
|
|
60
|
+
reader: self, manifest: @manifest,
|
|
61
|
+
audit_log: @store.audit_log,
|
|
62
|
+
schema_for: ->(name) { @store.schema_for(name) }
|
|
63
|
+
).call
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -4,13 +4,13 @@ module Textus
|
|
|
4
4
|
class Store
|
|
5
5
|
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
|
|
6
6
|
class Staleness
|
|
7
|
-
def initialize(
|
|
8
|
-
@
|
|
7
|
+
def initialize(manifest:)
|
|
8
|
+
@manifest = manifest
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def call(prefix: nil, zone: nil)
|
|
12
12
|
out = []
|
|
13
|
-
@
|
|
13
|
+
@manifest.entries.each do |mentry|
|
|
14
14
|
next unless mentry.zone == "derived"
|
|
15
15
|
next if zone && mentry.zone != zone
|
|
16
16
|
|
|
@@ -18,7 +18,7 @@ module Textus
|
|
|
18
18
|
next unless gen
|
|
19
19
|
next if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
|
|
20
20
|
|
|
21
|
-
path =
|
|
21
|
+
path = Textus::Key::Path.resolve(@manifest, mentry)
|
|
22
22
|
|
|
23
23
|
unless File.exist?(path)
|
|
24
24
|
out << stale_row(mentry, path, "derived entry has never been generated")
|
|
@@ -46,15 +46,15 @@ module Textus
|
|
|
46
46
|
out << stale_row(mentry, path, "source '#{offender}' modified after generated.at") if offender
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
-
@
|
|
50
|
-
next unless mentry.
|
|
49
|
+
@manifest.entries.each do |mentry|
|
|
50
|
+
next unless mentry.fetch
|
|
51
51
|
next if zone && mentry.zone != zone
|
|
52
52
|
next if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
|
|
53
53
|
|
|
54
54
|
ttl = parse_ttl(mentry.ttl)
|
|
55
55
|
next unless ttl
|
|
56
56
|
|
|
57
|
-
path =
|
|
57
|
+
path = Textus::Key::Path.resolve(@manifest, mentry)
|
|
58
58
|
|
|
59
59
|
unless File.exist?(path)
|
|
60
60
|
out << intake_stale_row(mentry, path, "never refreshed")
|
|
@@ -81,23 +81,14 @@ module Textus
|
|
|
81
81
|
|
|
82
82
|
private
|
|
83
83
|
|
|
84
|
-
def path_for_entry(mentry)
|
|
85
|
-
primary_ext = Entry.for_format(mentry.format).extensions.first
|
|
86
|
-
if File.extname(mentry.path) == ""
|
|
87
|
-
File.join(@store.root, "zones", mentry.path + primary_ext)
|
|
88
|
-
else
|
|
89
|
-
File.join(@store.root, "zones", mentry.path)
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
|
|
93
84
|
def newest_source_after(gen, gen_time)
|
|
94
85
|
Array(gen["sources"]).each do |src|
|
|
95
86
|
if src.match?(/\A[a-z0-9.][a-z0-9._-]*\z/) && !src.include?("/")
|
|
96
|
-
@
|
|
87
|
+
@manifest.enumerate(prefix: src).each do |row|
|
|
97
88
|
return src if File.mtime(row[:path]) > gen_time
|
|
98
89
|
end
|
|
99
90
|
else
|
|
100
|
-
abs = File.absolute_path?(src) ? src : File.join(File.dirname(@
|
|
91
|
+
abs = File.absolute_path?(src) ? src : File.join(File.dirname(@manifest.root), src)
|
|
101
92
|
if File.directory?(abs)
|
|
102
93
|
Dir.glob(File.join(abs, "**", "*")).each do |fp|
|
|
103
94
|
next unless File.file?(fp)
|
|
@@ -125,7 +116,7 @@ module Textus
|
|
|
125
116
|
end
|
|
126
117
|
|
|
127
118
|
def intake_stale_row(mentry, path, reason)
|
|
128
|
-
{ "key" => mentry.key, "path" => path, "
|
|
119
|
+
{ "key" => mentry.key, "path" => path, "fetch" => mentry.fetch, "reason" => reason }
|
|
129
120
|
end
|
|
130
121
|
|
|
131
122
|
def stale_row(mentry, path, reason)
|
|
@@ -1,33 +1,36 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class Store
|
|
3
3
|
class Validator
|
|
4
|
-
def initialize(
|
|
5
|
-
@
|
|
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
|
|
6
9
|
end
|
|
7
10
|
|
|
8
11
|
def call
|
|
9
12
|
violations = []
|
|
10
|
-
@
|
|
13
|
+
@manifest.enumerate.each do |row|
|
|
11
14
|
begin
|
|
12
|
-
@
|
|
15
|
+
@reader.get(row[:key])
|
|
13
16
|
rescue Textus::Error => e
|
|
14
17
|
violations << { "key" => row[:key], "code" => e.code, "message" => e.message }
|
|
15
18
|
end
|
|
16
19
|
end
|
|
17
20
|
|
|
18
|
-
@
|
|
21
|
+
@manifest.enumerate.each do |row|
|
|
19
22
|
mentry = row[:manifest_entry]
|
|
20
23
|
next unless mentry.schema
|
|
21
24
|
|
|
22
|
-
schema = @
|
|
25
|
+
schema = @schema_for.call(mentry.schema)
|
|
23
26
|
next unless schema
|
|
24
27
|
|
|
25
28
|
env = begin
|
|
26
|
-
@
|
|
29
|
+
@reader.get(row[:key])
|
|
27
30
|
rescue StandardError
|
|
28
31
|
next
|
|
29
32
|
end
|
|
30
|
-
last_writer = @
|
|
33
|
+
last_writer = @audit_log.last_writer_for(row[:key])
|
|
31
34
|
next if last_writer.nil?
|
|
32
35
|
|
|
33
36
|
env["_meta"].each_key do |field|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Store
|
|
3
|
+
class View
|
|
4
|
+
READ_METHODS = %i[get list where schema_envelope deps rdeps published stale validate_all].freeze
|
|
5
|
+
WRITE_METHODS = %i[put delete accept].freeze
|
|
6
|
+
|
|
7
|
+
def initialize(store, writable: false, as: nil)
|
|
8
|
+
raise UsageError.new("writable Store::View requires an as: role") if writable && (as.nil? || as.to_s.empty?)
|
|
9
|
+
|
|
10
|
+
@store = store
|
|
11
|
+
@writable = writable
|
|
12
|
+
@as = as
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
READ_METHODS.each do |m|
|
|
16
|
+
define_method(m) { |*args, **kw| @store.reader.public_send(m, *args, **kw) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
WRITE_METHODS.each do |m|
|
|
20
|
+
define_method(m) do |*args, **kw|
|
|
21
|
+
raise UsageError.new("Store::View is read-only") unless @writable
|
|
22
|
+
|
|
23
|
+
kw[:as] = @as unless kw.key?(:as)
|
|
24
|
+
@store.public_send(m, *args, **kw)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
class Store
|
|
5
|
+
# rubocop:disable Metrics/ParameterLists
|
|
6
|
+
class Writer
|
|
7
|
+
def initialize(store)
|
|
8
|
+
@store = store
|
|
9
|
+
@manifest = store.manifest
|
|
10
|
+
@reader = store.reader
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def put(key, meta: nil, body: nil, content: nil, if_etag: nil, as: Role::DEFAULT, suppress_events: false)
|
|
14
|
+
@manifest.validate_key!(key)
|
|
15
|
+
mentry, path, = @manifest.resolve(key)
|
|
16
|
+
writers = @manifest.zone_writers(mentry.zone)
|
|
17
|
+
raise WriteForbidden.new(key, mentry.zone, writers: writers) unless writers.include?(as)
|
|
18
|
+
|
|
19
|
+
meta ||= {}
|
|
20
|
+
strategy = Entry.for_format(mentry.format)
|
|
21
|
+
|
|
22
|
+
existing_uid = existing_uid_for(mentry, path)
|
|
23
|
+
meta, content = ensure_uid(mentry.format, meta, content, existing_uid)
|
|
24
|
+
|
|
25
|
+
bytes, eff_meta, eff_body, eff_content = serialize_for_put(
|
|
26
|
+
mentry: mentry, path: path, strategy: strategy,
|
|
27
|
+
meta: meta, body: body, content: content
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
enforce_name_match!(path, eff_meta, mentry.format)
|
|
31
|
+
|
|
32
|
+
schema = @store.schema_for(mentry.schema)
|
|
33
|
+
if schema
|
|
34
|
+
Entry.for_format(mentry.format).validate_against(
|
|
35
|
+
schema,
|
|
36
|
+
{ "_meta" => eff_meta, "content" => eff_content },
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
etag_before = File.exist?(path) ? Etag.for_file(path) : nil
|
|
41
|
+
raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && (etag_before != if_etag)
|
|
42
|
+
|
|
43
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
44
|
+
File.binwrite(path, bytes)
|
|
45
|
+
etag_after = Etag.for_bytes(bytes)
|
|
46
|
+
@store.audit_log.append(role: as, verb: "put", key: key, etag_before: etag_before, etag_after: etag_after)
|
|
47
|
+
envelope = Envelope.build(
|
|
48
|
+
key: key, mentry: mentry, path: path,
|
|
49
|
+
meta: eff_meta, body: eff_body, etag: etag_after, content: eff_content
|
|
50
|
+
)
|
|
51
|
+
@store.fire_event(:put, key: key, envelope: envelope) unless suppress_events
|
|
52
|
+
envelope
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def existing_uid_for(mentry, path)
|
|
56
|
+
return nil unless File.exist?(path)
|
|
57
|
+
|
|
58
|
+
raw = File.binread(path)
|
|
59
|
+
parsed = Entry.for_format(mentry.format).parse(raw, path: path)
|
|
60
|
+
Envelope.extract_uid(parsed["_meta"])
|
|
61
|
+
rescue StandardError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def ensure_uid(format, meta, content, existing_uid)
|
|
66
|
+
case format
|
|
67
|
+
when "markdown", "json", "yaml"
|
|
68
|
+
m = meta.is_a?(Hash) ? meta.dup : {}
|
|
69
|
+
m["uid"] = existing_uid || Store.mint_uid unless m["uid"].is_a?(String) && !m["uid"].empty?
|
|
70
|
+
[m, content]
|
|
71
|
+
else
|
|
72
|
+
[meta, content]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def enforce_name_match!(path, meta, format)
|
|
77
|
+
return unless %w[markdown json yaml].include?(format)
|
|
78
|
+
return unless meta.is_a?(Hash) && meta["name"]
|
|
79
|
+
|
|
80
|
+
ext = Entry.for_format(format).extensions.first
|
|
81
|
+
basename = File.basename(path, ext)
|
|
82
|
+
return if meta["name"] == basename
|
|
83
|
+
|
|
84
|
+
raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def serialize_for_put(mentry:, path:, strategy:, meta:, body:, content:)
|
|
88
|
+
case mentry.format
|
|
89
|
+
when "markdown", "text"
|
|
90
|
+
bytes = strategy.serialize(meta: meta, body: body.to_s)
|
|
91
|
+
[bytes, meta, body.to_s, nil]
|
|
92
|
+
when "json", "yaml"
|
|
93
|
+
raise UsageError.new("put for #{mentry.format} requires content: or body:") if content.nil? && (body.nil? || body.to_s.empty?)
|
|
94
|
+
|
|
95
|
+
if content.nil?
|
|
96
|
+
begin
|
|
97
|
+
parsed = strategy.parse(body.to_s, path: path)
|
|
98
|
+
rescue BadFrontmatter => e
|
|
99
|
+
raise BadContent.new(path, "bad_content: #{e.message}")
|
|
100
|
+
end
|
|
101
|
+
[body.to_s, parsed["_meta"], body.to_s, parsed["content"]]
|
|
102
|
+
else
|
|
103
|
+
bytes = strategy.serialize(meta: meta, body: "", content: content)
|
|
104
|
+
[bytes, meta, bytes, content]
|
|
105
|
+
end
|
|
106
|
+
else
|
|
107
|
+
raise UsageError.new("unknown format #{mentry.format.inspect}")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def delete(key, if_etag: nil, as: Role::DEFAULT, suppress_events: false)
|
|
112
|
+
mentry, path, = @manifest.resolve(key)
|
|
113
|
+
writers = @manifest.zone_writers(mentry.zone)
|
|
114
|
+
raise WriteForbidden.new(key, mentry.zone, writers: writers) unless writers.include?(as)
|
|
115
|
+
raise UnknownKey.new(key, suggestions: @manifest.suggestions_for(key)) unless File.exist?(path)
|
|
116
|
+
|
|
117
|
+
etag_before = Etag.for_file(path)
|
|
118
|
+
raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before
|
|
119
|
+
|
|
120
|
+
File.delete(path)
|
|
121
|
+
@store.audit_log.append(role: as, verb: "delete", key: key, etag_before: etag_before, etag_after: nil)
|
|
122
|
+
@store.fire_event(:delete, key: key) unless suppress_events
|
|
123
|
+
{ "protocol" => PROTOCOL, "ok" => true, "key" => key, "deleted" => true }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def accept(key, as:)
|
|
127
|
+
Proposal.accept(@store, key, as: as)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
# rubocop:enable Metrics/ParameterLists
|
|
131
|
+
end
|
|
132
|
+
end
|