textus 0.4.0 → 0.5.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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -1
  3. data/README.md +13 -11
  4. data/SPEC.md +13 -9
  5. data/docs/architecture.md +63 -28
  6. data/lib/textus/audit_log.rb +46 -11
  7. data/lib/textus/builder.rb +3 -3
  8. data/lib/textus/builtin_actions.rb +5 -5
  9. data/lib/textus/cli/accept.rb +13 -0
  10. data/lib/textus/cli/action.rb +51 -0
  11. data/lib/textus/cli/build.rb +11 -0
  12. data/lib/textus/cli/delete.rb +14 -0
  13. data/lib/textus/cli/deprecated_alias.rb +31 -0
  14. data/lib/textus/cli/deps.rb +10 -0
  15. data/lib/textus/cli/doctor.rb +13 -0
  16. data/lib/textus/cli/extension_group.rb +9 -0
  17. data/lib/textus/cli/extensions.rb +49 -0
  18. data/lib/textus/cli/get.rb +10 -0
  19. data/lib/textus/cli/group.rb +51 -0
  20. data/lib/textus/cli/init.rb +12 -0
  21. data/lib/textus/cli/intro.rb +9 -0
  22. data/lib/textus/cli/key_group.rb +10 -0
  23. data/lib/textus/cli/list.rb +12 -0
  24. data/lib/textus/cli/migrate.rb +41 -0
  25. data/lib/textus/cli/migrate_keys.rb +19 -0
  26. data/lib/textus/cli/mv.rb +20 -0
  27. data/lib/textus/cli/published.rb +9 -0
  28. data/lib/textus/cli/put.rb +48 -0
  29. data/lib/textus/cli/rdeps.rb +10 -0
  30. data/lib/textus/cli/refresh.rb +13 -0
  31. data/lib/textus/cli/schema.rb +10 -0
  32. data/lib/textus/cli/schema_diff.rb +15 -0
  33. data/lib/textus/cli/schema_group.rb +33 -0
  34. data/lib/textus/cli/schema_init.rb +19 -0
  35. data/lib/textus/cli/schema_migrate.rb +19 -0
  36. data/lib/textus/cli/stale.rb +12 -0
  37. data/lib/textus/cli/uid.rb +15 -0
  38. data/lib/textus/cli/verb.rb +62 -0
  39. data/lib/textus/cli/where.rb +10 -0
  40. data/lib/textus/cli.rb +65 -387
  41. data/lib/textus/doctor.rb +64 -33
  42. data/lib/textus/entry/json.rb +6 -4
  43. data/lib/textus/entry/markdown.rb +4 -4
  44. data/lib/textus/entry/text.rb +3 -3
  45. data/lib/textus/entry/yaml.rb +6 -4
  46. data/lib/textus/entry.rb +2 -2
  47. data/lib/textus/errors.rb +2 -2
  48. data/lib/textus/init.rb +1 -1
  49. data/lib/textus/intro.rb +2 -2
  50. data/lib/textus/manifest.rb +11 -221
  51. data/lib/textus/manifest_entry.rb +185 -0
  52. data/lib/textus/migrate_v2.rb +27 -0
  53. data/lib/textus/projection.rb +1 -1
  54. data/lib/textus/proposal.rb +3 -3
  55. data/lib/textus/refresh.rb +7 -7
  56. data/lib/textus/schema_tools.rb +8 -8
  57. data/lib/textus/store/events.rb +31 -0
  58. data/lib/textus/store/mover.rb +118 -0
  59. data/lib/textus/store/staleness.rb +142 -0
  60. data/lib/textus/store/validator.rb +53 -0
  61. data/lib/textus/store.rb +49 -354
  62. data/lib/textus/version.rb +2 -2
  63. data/lib/textus.rb +38 -0
  64. metadata +38 -1
@@ -0,0 +1,31 @@
1
+ require "timeout"
2
+
3
+ module Textus
4
+ class Store
5
+ class Events
6
+ HOOK_TIMEOUT_SECONDS = 2
7
+
8
+ def initialize(store)
9
+ @store = store
10
+ end
11
+
12
+ def call(event, **kwargs)
13
+ view = StoreView.new(@store)
14
+ @store.registry.hooks(event).each do |entry|
15
+ name = entry[:name]
16
+ Timeout.timeout(HOOK_TIMEOUT_SECONDS) { entry[:callable].call(store: view, **kwargs) }
17
+ rescue StandardError => e
18
+ extras = { "event" => event.to_s, "hook" => name.to_s, "error" => "#{e.class}: #{e.message}" }
19
+ extras["target_key"] = kwargs[:target_key] if kwargs.key?(:target_key)
20
+ extras["pending_key"] = kwargs[:pending_key] if kwargs.key?(:pending_key)
21
+ @store.audit_log.append(
22
+ role: "script", verb: "event_error",
23
+ key: kwargs[:key] || kwargs[:target_key] || kwargs[:pending_key] || "-",
24
+ etag_before: nil, etag_after: nil,
25
+ extras: extras
26
+ )
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,118 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ class Store
5
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
6
+ class Mover
7
+ def initialize(store)
8
+ @store = store
9
+ end
10
+
11
+ def call(old_key, new_key, as: Role::DEFAULT, dry_run: false)
12
+ @store.manifest.validate_key!(old_key)
13
+ @store.manifest.validate_key!(new_key)
14
+ raise UsageError.new("mv: old and new keys are identical") if old_key == new_key
15
+
16
+ old_mentry, old_path, = @store.manifest.resolve(old_key)
17
+ raise UnknownKey.new(old_key) unless File.exist?(old_path)
18
+
19
+ new_mentry, new_path, = @store.manifest.resolve(new_key)
20
+
21
+ if old_mentry.zone != new_mentry.zone
22
+ raise UsageError.new(
23
+ "mv: cross-zone move refused (#{old_mentry.zone} → #{new_mentry.zone}). " \
24
+ "Use put+delete for cross-zone moves.",
25
+ )
26
+ end
27
+ if old_mentry.format != new_mentry.format
28
+ raise UsageError.new(
29
+ "mv: format mismatch (#{old_mentry.format} → #{new_mentry.format}); refusing.",
30
+ )
31
+ end
32
+
33
+ writers = @store.manifest.zone_writers(old_mentry.zone)
34
+ raise WriteForbidden.new(old_key, old_mentry.zone, writers: writers) unless writers.include?(as)
35
+
36
+ raise UsageError.new("mv: target '#{new_key}' already exists at #{new_path}") if File.exist?(new_path)
37
+
38
+ # Mint uid before the move so the audit row carries it.
39
+ pre_env = @store.get(old_key)
40
+ current_uid = pre_env["uid"]
41
+ etag_before = pre_env["etag"]
42
+
43
+ if dry_run
44
+ return {
45
+ "protocol" => PROTOCOL, "ok" => true, "dry_run" => true,
46
+ "from_key" => old_key, "to_key" => new_key,
47
+ "from_path" => old_path, "to_path" => new_path,
48
+ "uid" => current_uid
49
+ }
50
+ end
51
+
52
+ if current_uid.nil?
53
+ # Write the uid in place first so the source file carries it before mv.
54
+ pre_env = @store.put(old_key,
55
+ meta: pre_env["_meta"],
56
+ body: pre_env["body"],
57
+ content: pre_env["content"],
58
+ as: as,
59
+ suppress_events: true)
60
+ current_uid = pre_env["uid"]
61
+ etag_before = pre_env["etag"]
62
+ end
63
+
64
+ FileUtils.mkdir_p(File.dirname(new_path))
65
+ FileUtils.mv(old_path, new_path)
66
+ rewrite_name_for_mv!(new_mentry, new_path, new_key)
67
+ etag_after = Etag.for_file(new_path)
68
+
69
+ @store.audit_log.append(
70
+ role: as, verb: "mv", key: new_key,
71
+ etag_before: etag_before, etag_after: etag_after,
72
+ extras: {
73
+ "from_key" => old_key, "to_key" => new_key,
74
+ "from_path" => old_path, "to_path" => new_path,
75
+ "uid" => current_uid
76
+ }
77
+ )
78
+
79
+ env = @store.get(new_key)
80
+ {
81
+ "protocol" => PROTOCOL, "ok" => true,
82
+ "from_key" => old_key, "to_key" => new_key,
83
+ "from_path" => old_path, "to_path" => new_path,
84
+ "uid" => current_uid,
85
+ "envelope" => env
86
+ }
87
+ end
88
+
89
+ private
90
+
91
+ # If the moved file carries a `name:` field (markdown) or `_meta.name`
92
+ # (json/yaml), rewrite it to the new basename so enforce_name_match! stays
93
+ # happy on the next read. Only touches the bytes when name actually changes.
94
+ def rewrite_name_for_mv!(mentry, new_path, new_key)
95
+ strategy = Entry.for_format(mentry.format)
96
+ raw = File.binread(new_path)
97
+ parsed = strategy.parse(raw, path: new_path)
98
+ basename = new_key.split(".").last
99
+
100
+ case mentry.format
101
+ when "markdown"
102
+ meta = parsed["_meta"] || {}
103
+ return unless meta.is_a?(Hash) && meta["name"].is_a?(String) && meta["name"] != basename
104
+
105
+ meta = meta.merge("name" => basename)
106
+ File.binwrite(new_path, strategy.serialize(meta: meta, body: parsed["body"]))
107
+ when "json", "yaml"
108
+ meta = parsed["_meta"]
109
+ return unless meta.is_a?(Hash) && meta["name"].is_a?(String) && meta["name"] != basename
110
+
111
+ new_meta = meta.merge("name" => basename)
112
+ File.binwrite(new_path, strategy.serialize(meta: new_meta, body: "", content: parsed["content"]))
113
+ end
114
+ end
115
+ end
116
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
117
+ end
118
+ end
@@ -0,0 +1,142 @@
1
+ require "time"
2
+
3
+ module Textus
4
+ class Store
5
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
6
+ class Staleness
7
+ def initialize(store)
8
+ @store = store
9
+ end
10
+
11
+ def call(prefix: nil, zone: nil)
12
+ out = []
13
+ @store.manifest.entries.each do |mentry|
14
+ next unless mentry.zone == "derived"
15
+ next if zone && mentry.zone != zone
16
+
17
+ gen = mentry.generator
18
+ next unless gen
19
+ next if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
20
+
21
+ path = path_for_entry(mentry)
22
+
23
+ unless File.exist?(path)
24
+ out << stale_row(mentry, path, "derived entry has never been generated")
25
+ next
26
+ end
27
+
28
+ raw = File.binread(path)
29
+ parsed = Entry.for_format(mentry.format).parse(raw, path: path)
30
+ generated_at = parsed["_meta"].dig("generated", "at")
31
+ unless generated_at
32
+ out << stale_row(mentry, path, "missing generated.at frontmatter")
33
+ next
34
+ end
35
+ gen_time = begin
36
+ Time.parse(generated_at.to_s)
37
+ rescue StandardError
38
+ nil
39
+ end
40
+ unless gen_time
41
+ out << stale_row(mentry, path, "unparseable generated.at: #{generated_at.inspect}")
42
+ next
43
+ end
44
+
45
+ offender = newest_source_after(gen, gen_time)
46
+ out << stale_row(mentry, path, "source '#{offender}' modified after generated.at") if offender
47
+ end
48
+
49
+ @store.manifest.entries.each do |mentry|
50
+ next unless mentry.action
51
+ next if zone && mentry.zone != zone
52
+ next if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
53
+
54
+ ttl = parse_ttl(mentry.ttl)
55
+ next unless ttl
56
+
57
+ path = path_for_entry(mentry)
58
+
59
+ unless File.exist?(path)
60
+ out << intake_stale_row(mentry, path, "never refreshed")
61
+ next
62
+ end
63
+
64
+ meta = Entry.for_format(mentry.format).parse(File.binread(path), path: path)["_meta"]
65
+ last_str = meta["last_refreshed_at"]
66
+ if last_str.nil?
67
+ out << intake_stale_row(mentry, path, "never refreshed (no last_refreshed_at)")
68
+ next
69
+ end
70
+
71
+ last = begin
72
+ Time.parse(last_str.to_s)
73
+ rescue StandardError
74
+ nil
75
+ end
76
+ out << intake_stale_row(mentry, path, "ttl exceeded (#{ttl}s)") if last.nil? || (Time.now - last) > ttl
77
+ end
78
+
79
+ out
80
+ end
81
+
82
+ private
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
+ def newest_source_after(gen, gen_time)
94
+ Array(gen["sources"]).each do |src|
95
+ if src.match?(/\A[a-z0-9.][a-z0-9._-]*\z/) && !src.include?("/")
96
+ @store.manifest.enumerate(prefix: src).each do |row|
97
+ return src if File.mtime(row[:path]) > gen_time
98
+ end
99
+ else
100
+ abs = File.absolute_path?(src) ? src : File.join(File.dirname(@store.root), src)
101
+ if File.directory?(abs)
102
+ Dir.glob(File.join(abs, "**", "*")).each do |fp|
103
+ next unless File.file?(fp)
104
+ return src if File.mtime(fp) > gen_time
105
+ end
106
+ elsif File.exist?(abs)
107
+ return src if File.mtime(abs) > gen_time
108
+ end
109
+ end
110
+ end
111
+ nil
112
+ end
113
+
114
+ def parse_ttl(s)
115
+ return nil unless s
116
+
117
+ m = s.to_s.match(/\A(\d+)([smhd])\z/) or return nil
118
+ n = m[1].to_i
119
+ case m[2]
120
+ when "s" then n
121
+ when "m" then n * 60
122
+ when "h" then n * 3600
123
+ when "d" then n * 86_400
124
+ end
125
+ end
126
+
127
+ def intake_stale_row(mentry, path, reason)
128
+ { "key" => mentry.key, "path" => path, "action" => mentry.action, "reason" => reason }
129
+ end
130
+
131
+ def stale_row(mentry, path, reason)
132
+ {
133
+ "key" => mentry.key,
134
+ "path" => path,
135
+ "generator" => mentry.generator,
136
+ "reason" => reason,
137
+ }
138
+ end
139
+ end
140
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
141
+ end
142
+ end
@@ -0,0 +1,53 @@
1
+ module Textus
2
+ class Store
3
+ class Validator
4
+ def initialize(store)
5
+ @store = store
6
+ end
7
+
8
+ def call
9
+ violations = []
10
+ @store.manifest.enumerate.each do |row|
11
+ begin
12
+ @store.get(row[:key])
13
+ rescue Textus::Error => e
14
+ violations << { "key" => row[:key], "code" => e.code, "message" => e.message }
15
+ end
16
+ end
17
+
18
+ @store.manifest.enumerate.each do |row|
19
+ mentry = row[:manifest_entry]
20
+ next unless mentry.schema
21
+
22
+ schema = @store.schema_for(mentry.schema)
23
+ next unless schema
24
+
25
+ env = begin
26
+ @store.get(row[:key])
27
+ rescue StandardError
28
+ next
29
+ end
30
+ last_writer = @store.audit_log.last_writer_for(row[:key])
31
+ next if last_writer.nil?
32
+
33
+ env["_meta"].each_key do |field|
34
+ owner = schema.maintained_by(field)
35
+ next if owner.nil?
36
+ next if last_writer == owner
37
+ next if last_writer == "human"
38
+
39
+ violations << {
40
+ "key" => row[:key],
41
+ "code" => "role_authority",
42
+ "field" => field,
43
+ "expected" => owner,
44
+ "last_writer" => last_writer,
45
+ }
46
+ end
47
+ end
48
+
49
+ { "protocol" => PROTOCOL, "ok" => violations.empty?, "violations" => violations }
50
+ end
51
+ end
52
+ end
53
+ end