textus 0.2.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 +7 -0
- data/CHANGELOG.md +163 -0
- data/README.md +200 -0
- data/SPEC.md +720 -0
- data/docs/architecture.md +57 -0
- data/docs/conventions.md +85 -0
- data/exe/textus +4 -0
- data/lib/textus/audit_log.rb +32 -0
- data/lib/textus/builder.rb +191 -0
- data/lib/textus/builtin_fetchers.rb +63 -0
- data/lib/textus/cli.rb +394 -0
- data/lib/textus/dependencies.rb +23 -0
- data/lib/textus/doctor.rb +281 -0
- data/lib/textus/entry/json.rb +41 -0
- data/lib/textus/entry/markdown.rb +39 -0
- data/lib/textus/entry/text.rb +23 -0
- data/lib/textus/entry/yaml.rb +39 -0
- data/lib/textus/entry.rb +30 -0
- data/lib/textus/errors.rb +168 -0
- data/lib/textus/etag.rb +13 -0
- data/lib/textus/extension_registry.rb +48 -0
- data/lib/textus/extensions.rb +29 -0
- data/lib/textus/init.rb +51 -0
- data/lib/textus/intro.rb +104 -0
- data/lib/textus/key_distance.rb +53 -0
- data/lib/textus/manifest.rb +394 -0
- data/lib/textus/migrate_keys.rb +187 -0
- data/lib/textus/mustache.rb +117 -0
- data/lib/textus/projection.rb +80 -0
- data/lib/textus/proposal.rb +27 -0
- data/lib/textus/publisher.rb +71 -0
- data/lib/textus/refresh.rb +75 -0
- data/lib/textus/role.rb +20 -0
- data/lib/textus/schema.rb +90 -0
- data/lib/textus/schema_tools.rb +87 -0
- data/lib/textus/store.rb +607 -0
- data/lib/textus/store_view.rb +18 -0
- data/lib/textus/version.rb +4 -0
- data/lib/textus.rb +31 -0
- metadata +156 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
require "timeout"
|
|
3
|
+
|
|
4
|
+
module Textus
|
|
5
|
+
class Projection
|
|
6
|
+
MAX_LIMIT = 1000
|
|
7
|
+
REDUCER_TIMEOUT_SECONDS = 2
|
|
8
|
+
|
|
9
|
+
def initialize(store, spec)
|
|
10
|
+
@store = store
|
|
11
|
+
@spec = spec || {}
|
|
12
|
+
@limit = (@spec["limit"] || MAX_LIMIT).to_i
|
|
13
|
+
raise InvalidProjection.new("limit #{@limit} exceeds max #{MAX_LIMIT}") if @limit > MAX_LIMIT
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
keys = collect_keys
|
|
18
|
+
explicit_pluck = !@spec["pluck"].nil? && @spec["pluck"] != "*"
|
|
19
|
+
rows = keys.map do |key|
|
|
20
|
+
env = @store.get(key)
|
|
21
|
+
row = pluck(env["frontmatter"], env["body"])
|
|
22
|
+
explicit_pluck ? row : row.merge("_key" => key)
|
|
23
|
+
end
|
|
24
|
+
reduced = apply_reducer(rows)
|
|
25
|
+
# Reducers may return either an Array of rows (legacy / templated builds)
|
|
26
|
+
# or a Hash that becomes the structured-format payload base. In the Hash
|
|
27
|
+
# case, downstream sort/limit/position markers don't apply, and the
|
|
28
|
+
# builder owns `_meta.generated_at` so we don't stamp it here.
|
|
29
|
+
return reduced if reduced.is_a?(Hash)
|
|
30
|
+
|
|
31
|
+
rows = reduced
|
|
32
|
+
rows = sort(rows)
|
|
33
|
+
rows = rows.first(@limit)
|
|
34
|
+
mark_positions(rows)
|
|
35
|
+
{ "entries" => rows, "count" => rows.length, "generated_at" => Time.now.utc.iso8601 }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def apply_reducer(rows)
|
|
41
|
+
name = @spec["reducer"] or return rows
|
|
42
|
+
callable = @store.registry.reducer(name)
|
|
43
|
+
Timeout.timeout(REDUCER_TIMEOUT_SECONDS) do
|
|
44
|
+
callable.call(rows: rows, config: @spec["reducer_config"] || {})
|
|
45
|
+
end
|
|
46
|
+
rescue Timeout::Error
|
|
47
|
+
raise UsageError.new("reducer '#{name}' exceeded #{REDUCER_TIMEOUT_SECONDS}s timeout")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def collect_keys
|
|
51
|
+
prefixes = Array(@spec["select"])
|
|
52
|
+
prefixes.flat_map { |p| @store.list(prefix: p).map { |row| row["key"] } }.uniq
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def pluck(frontmatter, _body)
|
|
56
|
+
fields = @spec["pluck"]
|
|
57
|
+
if fields.nil? || fields == "*"
|
|
58
|
+
frontmatter
|
|
59
|
+
else
|
|
60
|
+
Array(fields).each_with_object({}) { |f, h| h[f] = frontmatter[f] if frontmatter.key?(f) }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Adds `_first`, `_last`, and `_index` markers so templates can emit
|
|
65
|
+
# delimiters (e.g. JSON commas) via {{^_last}},{{/_last}}.
|
|
66
|
+
def mark_positions(rows)
|
|
67
|
+
last_idx = rows.length - 1
|
|
68
|
+
rows.each_with_index do |row, i|
|
|
69
|
+
row["_index"] = i
|
|
70
|
+
row["_first"] = i.zero?
|
|
71
|
+
row["_last"] = (i == last_idx)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def sort(rows)
|
|
76
|
+
sb = @spec["sort_by"] or return rows
|
|
77
|
+
rows.sort_by { |r| r[sb].to_s }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Proposal
|
|
3
|
+
def self.accept(store, pending_key, as:)
|
|
4
|
+
raise ProposalError.new("only human role can accept proposals; got '#{as}'") unless as == "human"
|
|
5
|
+
|
|
6
|
+
env = store.get(pending_key)
|
|
7
|
+
proposal = env["frontmatter"]["proposal"] or raise ProposalError.new("entry has no proposal block: #{pending_key}")
|
|
8
|
+
target = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
|
|
9
|
+
action = proposal["action"] || "put"
|
|
10
|
+
|
|
11
|
+
case action
|
|
12
|
+
when "put"
|
|
13
|
+
target_fm = env["frontmatter"]["frontmatter"] || {}
|
|
14
|
+
target_body = env["body"]
|
|
15
|
+
store.put(target, frontmatter: target_fm, body: target_body, as: "human")
|
|
16
|
+
when "delete"
|
|
17
|
+
store.delete(target, as: "human")
|
|
18
|
+
else
|
|
19
|
+
raise ProposalError.new("unknown action: #{action}")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
store.delete(pending_key, as: "human")
|
|
23
|
+
store.fire_event(:accept, pending_key: pending_key, target_key: target)
|
|
24
|
+
{ "protocol" => PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Textus
|
|
6
|
+
# Publishes built artifacts from the store to repo-relative consumer paths.
|
|
7
|
+
# Publish = copy + sentinel. The in-store file is already the consumer-shaped
|
|
8
|
+
# artifact; no parsing or stripping. Sentinels live under
|
|
9
|
+
# `<store_root>/sentinels/` and mirror the target's repo-relative layout so
|
|
10
|
+
# consumer directories aren't polluted with `.textus-managed.json` siblings.
|
|
11
|
+
module Publisher
|
|
12
|
+
SENTINEL_SUFFIX = ".textus-managed.json".freeze
|
|
13
|
+
SENTINEL_DIR = "sentinels".freeze
|
|
14
|
+
|
|
15
|
+
def self.publish(source:, target:, store_root:)
|
|
16
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
17
|
+
refuse_if_unmanaged(target, store_root)
|
|
18
|
+
File.delete(target) if File.symlink?(target)
|
|
19
|
+
FileUtils.cp(source, target)
|
|
20
|
+
write_sentinel(target, store_root: store_root, source: source)
|
|
21
|
+
cleanup_legacy_sentinel(target)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.refuse_if_unmanaged(target, store_root)
|
|
25
|
+
return unless File.exist?(target) || File.symlink?(target)
|
|
26
|
+
return if managed?(target, store_root)
|
|
27
|
+
|
|
28
|
+
raise PublishError.new("refusing to clobber unmanaged file at #{target}", target: target)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.managed?(target, store_root)
|
|
32
|
+
File.exist?(sentinel_path(target, store_root)) || File.exist?(legacy_sentinel_path(target))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.write_sentinel(target, store_root:, source:)
|
|
36
|
+
path = sentinel_path(target, store_root)
|
|
37
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
38
|
+
File.write(path, JSON.generate(
|
|
39
|
+
"source" => source,
|
|
40
|
+
"target" => target,
|
|
41
|
+
"sha256" => Digest::SHA256.hexdigest(File.binread(target)),
|
|
42
|
+
"mode" => "copy",
|
|
43
|
+
))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Sentinel layout: <store_root>/sentinels/<target_rel_to_repo>.textus-managed.json
|
|
47
|
+
# The full target extension is preserved so a marketplace.json and
|
|
48
|
+
# marketplace.yaml don't collide.
|
|
49
|
+
def self.sentinel_path(target, store_root)
|
|
50
|
+
repo_root = File.dirname(store_root)
|
|
51
|
+
rel = relative_to(target, repo_root) || File.basename(target)
|
|
52
|
+
File.join(store_root, SENTINEL_DIR, rel + SENTINEL_SUFFIX)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.legacy_sentinel_path(target)
|
|
56
|
+
target + SENTINEL_SUFFIX
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.cleanup_legacy_sentinel(target)
|
|
60
|
+
FileUtils.rm_f(legacy_sentinel_path(target))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.relative_to(path, base)
|
|
64
|
+
path = File.expand_path(path)
|
|
65
|
+
base = File.expand_path(base)
|
|
66
|
+
return nil unless path.start_with?(base + File::SEPARATOR)
|
|
67
|
+
|
|
68
|
+
path[(base.length + 1)..]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
require "timeout"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Refresh
|
|
5
|
+
FETCHER_TIMEOUT_SECONDS = 2
|
|
6
|
+
|
|
7
|
+
def self.call(store, key, as:)
|
|
8
|
+
mentry, path, = store.manifest.resolve(key)
|
|
9
|
+
raise UsageError.new("no fetcher declared for '#{key}'") unless mentry.fetcher
|
|
10
|
+
|
|
11
|
+
before_etag = File.exist?(path) ? Etag.for_file(path) : nil
|
|
12
|
+
callable = store.registry.fetcher(mentry.fetcher)
|
|
13
|
+
view = StoreView.new(store)
|
|
14
|
+
result =
|
|
15
|
+
begin
|
|
16
|
+
Timeout.timeout(FETCHER_TIMEOUT_SECONDS) { callable.call(config: mentry.fetcher_config, store: view) }
|
|
17
|
+
rescue Timeout::Error
|
|
18
|
+
raise UsageError.new("fetcher '#{mentry.fetcher}' exceeded #{FETCHER_TIMEOUT_SECONDS}s timeout")
|
|
19
|
+
rescue Textus::Error
|
|
20
|
+
raise
|
|
21
|
+
rescue StandardError => e
|
|
22
|
+
raise UsageError.new("fetcher '#{mentry.fetcher}' raised: #{e.class}: #{e.message}")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
normalized = normalize_fetcher_result(result, format: mentry.format)
|
|
26
|
+
envelope = store.put(
|
|
27
|
+
key,
|
|
28
|
+
frontmatter: normalized[:frontmatter],
|
|
29
|
+
body: normalized[:body],
|
|
30
|
+
content: normalized[:content],
|
|
31
|
+
as: as,
|
|
32
|
+
suppress_events: true,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
change = if before_etag.nil?
|
|
36
|
+
:created
|
|
37
|
+
elsif envelope["etag"] == before_etag
|
|
38
|
+
:unchanged
|
|
39
|
+
else
|
|
40
|
+
:updated
|
|
41
|
+
end
|
|
42
|
+
store.fire_event(:refresh, key: key, envelope: envelope, change: change) unless change == :unchanged
|
|
43
|
+
envelope
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Normalize the three accepted fetcher return shapes into the store's
|
|
47
|
+
# internal {frontmatter, body, content} representation. See plan-1.2 §7.
|
|
48
|
+
def self.normalize_fetcher_result(res, format:)
|
|
49
|
+
res = res.transform_keys(&:to_s) if res.is_a?(Hash)
|
|
50
|
+
res ||= {}
|
|
51
|
+
fm = res["frontmatter"]
|
|
52
|
+
body = res["body"]
|
|
53
|
+
content = res["content"]
|
|
54
|
+
|
|
55
|
+
case format
|
|
56
|
+
when "markdown"
|
|
57
|
+
{ frontmatter: fm || {}, body: body.to_s, content: nil }
|
|
58
|
+
when "text"
|
|
59
|
+
{ frontmatter: {}, body: body.to_s, content: nil }
|
|
60
|
+
when "json", "yaml"
|
|
61
|
+
if !content.nil?
|
|
62
|
+
meta = content.is_a?(Hash) && content["_meta"].is_a?(Hash) ? content["_meta"] : {}
|
|
63
|
+
{ frontmatter: meta, body: nil, content: content }
|
|
64
|
+
elsif !body.nil?
|
|
65
|
+
# Store#put will re-parse and validate the bytes.
|
|
66
|
+
{ frontmatter: {}, body: body.to_s, content: nil }
|
|
67
|
+
else
|
|
68
|
+
raise UsageError.new("fetcher for #{format} returned neither content nor body")
|
|
69
|
+
end
|
|
70
|
+
else
|
|
71
|
+
raise UsageError.new("unknown format #{format.inspect}")
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/textus/role.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Role
|
|
3
|
+
PATTERN = /\A[a-z][a-z0-9_-]*\z/
|
|
4
|
+
DEFAULT = "human".freeze
|
|
5
|
+
|
|
6
|
+
def self.resolve(root:, flag: nil, env: ENV)
|
|
7
|
+
candidate = flag || env["TEXTUS_ROLE"] || read_file(root) || DEFAULT
|
|
8
|
+
raise InvalidRole.new(candidate) unless candidate.match?(PATTERN)
|
|
9
|
+
|
|
10
|
+
candidate
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.read_file(root)
|
|
14
|
+
path = File.join(root, "role")
|
|
15
|
+
return nil unless File.exist?(path)
|
|
16
|
+
|
|
17
|
+
File.read(path).strip.then { |s| s.empty? ? nil : s }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
class Schema
|
|
5
|
+
attr_reader :name, :required, :optional, :fields, :raw
|
|
6
|
+
|
|
7
|
+
def self.load(path)
|
|
8
|
+
raw = YAML.safe_load_file(path, permitted_classes: [Symbol, Date, Time], aliases: false)
|
|
9
|
+
new(raw)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(raw)
|
|
13
|
+
@raw = raw || {}
|
|
14
|
+
@name = @raw["name"]
|
|
15
|
+
@required = Array(@raw["required"])
|
|
16
|
+
@optional = Array(@raw["optional"])
|
|
17
|
+
@fields = @raw["fields"] || {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def to_h
|
|
21
|
+
@raw
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def maintained_by(field)
|
|
25
|
+
meta = @fields[field] or return nil
|
|
26
|
+
meta["maintained_by"]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def evolution
|
|
30
|
+
raw = @raw["evolution"] || {}
|
|
31
|
+
raw.each_with_object({}) do |(k, v), h|
|
|
32
|
+
h[k] = v.is_a?(Date) || v.is_a?(Time) ? v.to_s : v
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns nil on success; raises SchemaViolation on hard failure.
|
|
37
|
+
# Unknown fields produce warnings, returned as a String[] alongside.
|
|
38
|
+
def validate!(frontmatter)
|
|
39
|
+
missing = @required - frontmatter.keys
|
|
40
|
+
raise SchemaViolation.new("missing" => missing) unless missing.empty?
|
|
41
|
+
|
|
42
|
+
known = (@required + @optional).uniq
|
|
43
|
+
frontmatter.each do |k, v|
|
|
44
|
+
next unless @fields.key?(k)
|
|
45
|
+
|
|
46
|
+
check_type!(k, v, @fields[k])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
warnings = frontmatter.keys - known
|
|
50
|
+
warnings.map { |w| "unknown field: #{w}" }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def check_type!(field, value, spec) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
56
|
+
type = spec["type"]
|
|
57
|
+
case type
|
|
58
|
+
when "string"
|
|
59
|
+
bad!(field, "expected string") unless value.is_a?(String)
|
|
60
|
+
if (max = spec["max"]) && value.bytesize > max
|
|
61
|
+
bad!(field, "exceeds max #{max}")
|
|
62
|
+
end
|
|
63
|
+
when "number"
|
|
64
|
+
bad!(field, "expected number") unless value.is_a?(Numeric)
|
|
65
|
+
when "boolean"
|
|
66
|
+
bad!(field, "expected boolean") unless [true, false].include?(value)
|
|
67
|
+
when "enum"
|
|
68
|
+
values = Array(spec["values"])
|
|
69
|
+
bad!(field, "not in enum #{values.inspect}") unless values.include?(value)
|
|
70
|
+
when "array"
|
|
71
|
+
bad!(field, "expected array") unless value.is_a?(Array)
|
|
72
|
+
if (items = spec["items"])
|
|
73
|
+
value.each_with_index { |v, i| check_type!("#{field}[#{i}]", v, items) }
|
|
74
|
+
end
|
|
75
|
+
when "object"
|
|
76
|
+
bad!(field, "expected object") unless value.is_a?(Hash)
|
|
77
|
+
if (sub = spec["fields"])
|
|
78
|
+
sub.each { |fk, fspec| check_type!("#{field}.#{fk}", value[fk], fspec) if value.key?(fk) }
|
|
79
|
+
end
|
|
80
|
+
when nil
|
|
81
|
+
# untyped — no check
|
|
82
|
+
# unknown type spec — vendor extension; ignore
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def bad!(field, reason)
|
|
87
|
+
raise SchemaViolation.new("field" => field, "reason" => reason)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
|
|
4
|
+
module Textus
|
|
5
|
+
module SchemaTools
|
|
6
|
+
# textus schema-init NAME --from=KEY → infer YAML schema from an entry's frontmatter
|
|
7
|
+
def self.init(store, name:, from:)
|
|
8
|
+
env = store.get(from)
|
|
9
|
+
fm = env["frontmatter"]
|
|
10
|
+
schema = {
|
|
11
|
+
"name" => name,
|
|
12
|
+
"required" => fm.keys,
|
|
13
|
+
"optional" => [],
|
|
14
|
+
"fields" => fm.each_with_object({}) { |(k, v), h| h[k] = { "type" => infer_type(v) } },
|
|
15
|
+
}
|
|
16
|
+
FileUtils.mkdir_p(File.join(store.root, "schemas"))
|
|
17
|
+
target = File.join(store.root, "schemas", "#{name}.yaml")
|
|
18
|
+
File.write(target, YAML.dump(schema))
|
|
19
|
+
{ "protocol" => PROTOCOL, "schema_name" => name, "path" => target }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# textus schema-diff NAME → list keys whose frontmatter violates the schema
|
|
23
|
+
def self.diff(store, name:)
|
|
24
|
+
schema = load_schema(store, name)
|
|
25
|
+
drift = []
|
|
26
|
+
store.manifest.enumerate.each do |row|
|
|
27
|
+
env = store.get(row[:key])
|
|
28
|
+
begin
|
|
29
|
+
schema.validate!(env["frontmatter"])
|
|
30
|
+
rescue SchemaViolation => e
|
|
31
|
+
drift << { "key" => row[:key], "details" => e.details }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
{ "protocol" => PROTOCOL, "schema_name" => name, "drift" => drift }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# textus schema-migrate NAME --rename=OLD:NEW → rewrites frontmatter across affected entries
|
|
38
|
+
# If --rename is omitted, falls back to schema.evolution.migrate_from.
|
|
39
|
+
def self.migrate(store, name:, rename: nil)
|
|
40
|
+
renames =
|
|
41
|
+
if rename
|
|
42
|
+
old_field, new_field = rename.split(":", 2)
|
|
43
|
+
raise UsageError.new("--rename=OLD:NEW") unless old_field && new_field && !new_field.empty?
|
|
44
|
+
|
|
45
|
+
{ old_field => new_field }
|
|
46
|
+
else
|
|
47
|
+
load_schema(store, name).evolution["migrate_from"] || {}
|
|
48
|
+
end
|
|
49
|
+
raise UsageError.new("schema-migrate needs --rename=OLD:NEW or schema.evolution.migrate_from") if renames.empty?
|
|
50
|
+
|
|
51
|
+
touched = []
|
|
52
|
+
store.manifest.enumerate.each do |row|
|
|
53
|
+
env = store.get(row[:key])
|
|
54
|
+
fm = env["frontmatter"]
|
|
55
|
+
changed = false
|
|
56
|
+
renames.each do |old, new|
|
|
57
|
+
if fm.key?(old)
|
|
58
|
+
fm[new] = fm.delete(old)
|
|
59
|
+
changed = true
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
next unless changed
|
|
63
|
+
|
|
64
|
+
store.put(row[:key], frontmatter: fm, body: env["body"], as: "human")
|
|
65
|
+
touched << row[:key]
|
|
66
|
+
end
|
|
67
|
+
{ "protocol" => PROTOCOL, "migrated" => touched, "renames" => renames }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.infer_type(value)
|
|
71
|
+
case value
|
|
72
|
+
when String then "string"
|
|
73
|
+
when Numeric then "number"
|
|
74
|
+
when true, false then "boolean"
|
|
75
|
+
when Array then "array"
|
|
76
|
+
when Hash then "object"
|
|
77
|
+
else "string"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.load_schema(store, name)
|
|
82
|
+
store.schema_for(name)
|
|
83
|
+
rescue IoError
|
|
84
|
+
raise UsageError.new("schema not found: #{name}")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|