textus 0.4.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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +147 -2
  3. data/README.md +38 -28
  4. data/SPEC.md +84 -147
  5. data/docs/architecture.md +82 -28
  6. data/lib/textus/builder/pipeline.rb +56 -0
  7. data/lib/textus/builder/renderer/json.rb +42 -0
  8. data/lib/textus/builder/renderer/markdown.rb +22 -0
  9. data/lib/textus/builder/renderer/text.rb +14 -0
  10. data/lib/textus/builder/renderer/yaml.rb +42 -0
  11. data/lib/textus/builder/renderer.rb +17 -0
  12. data/lib/textus/builder.rb +9 -114
  13. data/lib/textus/cli/group/hook.rb +11 -0
  14. data/lib/textus/cli/group/key.rb +12 -0
  15. data/lib/textus/cli/group/schema.rb +13 -0
  16. data/lib/textus/cli/group.rb +51 -0
  17. data/lib/textus/cli/verb/accept.rb +15 -0
  18. data/lib/textus/cli/verb/build.rb +13 -0
  19. data/lib/textus/cli/verb/delete.rb +16 -0
  20. data/lib/textus/cli/verb/deps.rb +12 -0
  21. data/lib/textus/cli/verb/doctor.rb +15 -0
  22. data/lib/textus/cli/verb/get.rb +12 -0
  23. data/lib/textus/cli/verb/hook_run.rb +48 -0
  24. data/lib/textus/cli/verb/hooks.rb +50 -0
  25. data/lib/textus/cli/verb/init.rb +14 -0
  26. data/lib/textus/cli/verb/intro.rb +11 -0
  27. data/lib/textus/cli/verb/list.rb +14 -0
  28. data/lib/textus/cli/verb/migrate_keys.rb +16 -0
  29. data/lib/textus/cli/verb/mv.rb +17 -0
  30. data/lib/textus/cli/verb/published.rb +11 -0
  31. data/lib/textus/cli/verb/put.rb +50 -0
  32. data/lib/textus/cli/verb/rdeps.rb +12 -0
  33. data/lib/textus/cli/verb/refresh.rb +15 -0
  34. data/lib/textus/cli/verb/schema.rb +12 -0
  35. data/lib/textus/cli/verb/schema_diff.rb +12 -0
  36. data/lib/textus/cli/verb/schema_init.rb +16 -0
  37. data/lib/textus/cli/verb/schema_migrate.rb +16 -0
  38. data/lib/textus/cli/verb/stale.rb +14 -0
  39. data/lib/textus/cli/verb/uid.rb +12 -0
  40. data/lib/textus/cli/verb/where.rb +12 -0
  41. data/lib/textus/cli/verb.rb +62 -0
  42. data/lib/textus/cli.rb +44 -385
  43. data/lib/textus/doctor/check/audit_log.rb +50 -0
  44. data/lib/textus/doctor/check/hooks.rb +29 -0
  45. data/lib/textus/doctor/check/illegal_keys.rb +49 -0
  46. data/lib/textus/doctor/check/manifest_files.rb +38 -0
  47. data/lib/textus/doctor/check/schema_violations.rb +22 -0
  48. data/lib/textus/doctor/check/schemas.rb +26 -0
  49. data/lib/textus/doctor/check/sentinels.rb +57 -0
  50. data/lib/textus/doctor/check/templates.rb +26 -0
  51. data/lib/textus/doctor/check/unowned_schema_fields.rb +34 -0
  52. data/lib/textus/doctor/check.rb +30 -0
  53. data/lib/textus/doctor.rb +29 -264
  54. data/lib/textus/entry/base.rb +30 -0
  55. data/lib/textus/entry/json.rb +11 -5
  56. data/lib/textus/entry/markdown.rb +5 -5
  57. data/lib/textus/entry/text.rb +4 -4
  58. data/lib/textus/entry/yaml.rb +11 -5
  59. data/lib/textus/entry.rb +2 -7
  60. data/lib/textus/envelope.rb +30 -0
  61. data/lib/textus/errors.rb +2 -2
  62. data/lib/textus/hooks/builtin.rb +70 -0
  63. data/lib/textus/hooks/dispatcher.rb +49 -0
  64. data/lib/textus/hooks/loader.rb +26 -0
  65. data/lib/textus/hooks/registry.rb +73 -0
  66. data/lib/textus/init.rb +14 -11
  67. data/lib/textus/intro.rb +16 -18
  68. data/lib/textus/key/distance.rb +55 -0
  69. data/lib/textus/key/grammar.rb +33 -0
  70. data/lib/textus/key/path.rb +17 -0
  71. data/lib/textus/manifest/entry.rb +199 -0
  72. data/lib/textus/manifest.rb +20 -254
  73. data/lib/textus/migrate_keys.rb +1 -1
  74. data/lib/textus/projection.rb +6 -5
  75. data/lib/textus/proposal.rb +4 -4
  76. data/lib/textus/refresh.rb +17 -17
  77. data/lib/textus/schema/tools.rb +89 -0
  78. data/lib/textus/store/audit_log.rb +71 -0
  79. data/lib/textus/store/mover.rb +121 -0
  80. data/lib/textus/store/reader.rb +67 -0
  81. data/lib/textus/store/staleness.rb +133 -0
  82. data/lib/textus/store/validator.rb +56 -0
  83. data/lib/textus/store/view.rb +29 -0
  84. data/lib/textus/store/writer.rb +132 -0
  85. data/lib/textus/store.rb +26 -527
  86. data/lib/textus/version.rb +2 -2
  87. data/lib/textus.rb +14 -29
  88. metadata +78 -8
  89. data/lib/textus/audit_log.rb +0 -32
  90. data/lib/textus/builtin_actions.rb +0 -68
  91. data/lib/textus/extension_registry.rb +0 -61
  92. data/lib/textus/extensions.rb +0 -33
  93. data/lib/textus/key_distance.rb +0 -53
  94. data/lib/textus/schema_tools.rb +0 -87
  95. data/lib/textus/store_view.rb +0 -27
@@ -2,32 +2,32 @@ require "timeout"
2
2
 
3
3
  module Textus
4
4
  module Refresh
5
- ACTION_TIMEOUT_SECONDS = 2
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 action declared for '#{key}'") unless mentry.action
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.action(mentry.action)
13
- view = StoreView.new(store, writable: true, as: as)
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(ACTION_TIMEOUT_SECONDS) do
17
- callable.call(config: mentry.action_config, store: view, args: {})
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("action '#{mentry.action}' exceeded #{ACTION_TIMEOUT_SECONDS}s timeout")
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("action '#{mentry.action}' raised: #{e.class}: #{e.message}")
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)
28
28
  envelope = store.put(
29
29
  key,
30
- frontmatter: normalized[:frontmatter],
30
+ meta: normalized[:meta],
31
31
  body: normalized[:body],
32
32
  content: normalized[:content],
33
33
  as: as,
@@ -45,28 +45,28 @@ module Textus
45
45
  envelope
46
46
  end
47
47
 
48
- # Normalize the three accepted action return shapes into the store's
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
- fm = res["frontmatter"]
53
+ # Accept both legacy :frontmatter/:_meta key names from fetch hooks.
54
+ meta_val = res["_meta"] || res["frontmatter"]
54
55
  body = res["body"]
55
56
  content = res["content"]
56
57
 
57
58
  case format
58
59
  when "markdown"
59
- { frontmatter: fm || {}, body: body.to_s, content: nil }
60
+ { meta: meta_val || {}, body: body.to_s, content: nil }
60
61
  when "text"
61
- { frontmatter: {}, body: body.to_s, content: nil }
62
+ { meta: {}, body: body.to_s, content: nil }
62
63
  when "json", "yaml"
63
64
  if !content.nil?
64
- meta = content.is_a?(Hash) && content["_meta"].is_a?(Hash) ? content["_meta"] : {}
65
- { frontmatter: meta, body: nil, content: content }
65
+ { meta: meta_val || {}, body: nil, content: content }
66
66
  elsif !body.nil?
67
- { frontmatter: {}, body: body.to_s, content: nil }
67
+ { meta: {}, body: body.to_s, content: nil }
68
68
  else
69
- raise UsageError.new("action for #{format} returned neither content nor body")
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
@@ -0,0 +1,121 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ class Store
5
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
6
+ class Mover
7
+ def initialize(reader:, writer:, manifest:, audit_log:)
8
+ @reader = reader
9
+ @writer = writer
10
+ @manifest = manifest
11
+ @audit_log = audit_log
12
+ end
13
+
14
+ def call(old_key, new_key, as: Role::DEFAULT, dry_run: false)
15
+ @manifest.validate_key!(old_key)
16
+ @manifest.validate_key!(new_key)
17
+ raise UsageError.new("mv: old and new keys are identical") if old_key == new_key
18
+
19
+ old_mentry, old_path, = @manifest.resolve(old_key)
20
+ raise UnknownKey.new(old_key) unless File.exist?(old_path)
21
+
22
+ new_mentry, new_path, = @manifest.resolve(new_key)
23
+
24
+ if old_mentry.zone != new_mentry.zone
25
+ raise UsageError.new(
26
+ "mv: cross-zone move refused (#{old_mentry.zone} → #{new_mentry.zone}). " \
27
+ "Use put+delete for cross-zone moves.",
28
+ )
29
+ end
30
+ if old_mentry.format != new_mentry.format
31
+ raise UsageError.new(
32
+ "mv: format mismatch (#{old_mentry.format} → #{new_mentry.format}); refusing.",
33
+ )
34
+ end
35
+
36
+ writers = @manifest.zone_writers(old_mentry.zone)
37
+ raise WriteForbidden.new(old_key, old_mentry.zone, writers: writers) unless writers.include?(as)
38
+
39
+ raise UsageError.new("mv: target '#{new_key}' already exists at #{new_path}") if File.exist?(new_path)
40
+
41
+ # Mint uid before the move so the audit row carries it.
42
+ pre_env = @reader.get(old_key)
43
+ current_uid = pre_env["uid"]
44
+ etag_before = pre_env["etag"]
45
+
46
+ if dry_run
47
+ return {
48
+ "protocol" => PROTOCOL, "ok" => true, "dry_run" => true,
49
+ "from_key" => old_key, "to_key" => new_key,
50
+ "from_path" => old_path, "to_path" => new_path,
51
+ "uid" => current_uid
52
+ }
53
+ end
54
+
55
+ if current_uid.nil?
56
+ # Write the uid in place first so the source file carries it before mv.
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)
63
+ current_uid = pre_env["uid"]
64
+ etag_before = pre_env["etag"]
65
+ end
66
+
67
+ FileUtils.mkdir_p(File.dirname(new_path))
68
+ FileUtils.mv(old_path, new_path)
69
+ rewrite_name_for_mv!(new_mentry, new_path, new_key)
70
+ etag_after = Etag.for_file(new_path)
71
+
72
+ @audit_log.append(
73
+ role: as, verb: "mv", key: new_key,
74
+ etag_before: etag_before, etag_after: etag_after,
75
+ extras: {
76
+ "from_key" => old_key, "to_key" => new_key,
77
+ "from_path" => old_path, "to_path" => new_path,
78
+ "uid" => current_uid
79
+ }
80
+ )
81
+
82
+ env = @reader.get(new_key)
83
+ {
84
+ "protocol" => PROTOCOL, "ok" => true,
85
+ "from_key" => old_key, "to_key" => new_key,
86
+ "from_path" => old_path, "to_path" => new_path,
87
+ "uid" => current_uid,
88
+ "envelope" => env
89
+ }
90
+ end
91
+
92
+ private
93
+
94
+ # If the moved file carries a `name:` field (markdown) or `_meta.name`
95
+ # (json/yaml), rewrite it to the new basename so enforce_name_match! stays
96
+ # happy on the next read. Only touches the bytes when name actually changes.
97
+ def rewrite_name_for_mv!(mentry, new_path, new_key)
98
+ strategy = Entry.for_format(mentry.format)
99
+ raw = File.binread(new_path)
100
+ parsed = strategy.parse(raw, path: new_path)
101
+ basename = new_key.split(".").last
102
+
103
+ case mentry.format
104
+ when "markdown"
105
+ meta = parsed["_meta"] || {}
106
+ return unless meta.is_a?(Hash) && meta["name"].is_a?(String) && meta["name"] != basename
107
+
108
+ meta = meta.merge("name" => basename)
109
+ File.binwrite(new_path, strategy.serialize(meta: meta, body: parsed["body"]))
110
+ when "json", "yaml"
111
+ meta = parsed["_meta"]
112
+ return unless meta.is_a?(Hash) && meta["name"].is_a?(String) && meta["name"] != basename
113
+
114
+ new_meta = meta.merge("name" => basename)
115
+ File.binwrite(new_path, strategy.serialize(meta: new_meta, body: "", content: parsed["content"]))
116
+ end
117
+ end
118
+ end
119
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
120
+ end
121
+ end
@@ -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
@@ -0,0 +1,133 @@
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(manifest:)
8
+ @manifest = manifest
9
+ end
10
+
11
+ def call(prefix: nil, zone: nil)
12
+ out = []
13
+ @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 = Textus::Key::Path.resolve(@manifest, 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
+ @manifest.entries.each do |mentry|
50
+ next unless mentry.fetch
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 = Textus::Key::Path.resolve(@manifest, 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 newest_source_after(gen, gen_time)
85
+ Array(gen["sources"]).each do |src|
86
+ if src.match?(/\A[a-z0-9.][a-z0-9._-]*\z/) && !src.include?("/")
87
+ @manifest.enumerate(prefix: src).each do |row|
88
+ return src if File.mtime(row[:path]) > gen_time
89
+ end
90
+ else
91
+ abs = File.absolute_path?(src) ? src : File.join(File.dirname(@manifest.root), src)
92
+ if File.directory?(abs)
93
+ Dir.glob(File.join(abs, "**", "*")).each do |fp|
94
+ next unless File.file?(fp)
95
+ return src if File.mtime(fp) > gen_time
96
+ end
97
+ elsif File.exist?(abs)
98
+ return src if File.mtime(abs) > gen_time
99
+ end
100
+ end
101
+ end
102
+ nil
103
+ end
104
+
105
+ def parse_ttl(s)
106
+ return nil unless s
107
+
108
+ m = s.to_s.match(/\A(\d+)([smhd])\z/) or return nil
109
+ n = m[1].to_i
110
+ case m[2]
111
+ when "s" then n
112
+ when "m" then n * 60
113
+ when "h" then n * 3600
114
+ when "d" then n * 86_400
115
+ end
116
+ end
117
+
118
+ def intake_stale_row(mentry, path, reason)
119
+ { "key" => mentry.key, "path" => path, "fetch" => mentry.fetch, "reason" => reason }
120
+ end
121
+
122
+ def stale_row(mentry, path, reason)
123
+ {
124
+ "key" => mentry.key,
125
+ "path" => path,
126
+ "generator" => mentry.generator,
127
+ "reason" => reason,
128
+ }
129
+ end
130
+ end
131
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
132
+ end
133
+ end
@@ -0,0 +1,56 @@
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
+ @manifest.enumerate.each do |row|
14
+ begin
15
+ @reader.get(row[:key])
16
+ rescue Textus::Error => e
17
+ violations << { "key" => row[:key], "code" => e.code, "message" => e.message }
18
+ end
19
+ end
20
+
21
+ @manifest.enumerate.each do |row|
22
+ mentry = row[:manifest_entry]
23
+ next unless mentry.schema
24
+
25
+ schema = @schema_for.call(mentry.schema)
26
+ next unless schema
27
+
28
+ env = begin
29
+ @reader.get(row[:key])
30
+ rescue StandardError
31
+ next
32
+ end
33
+ last_writer = @audit_log.last_writer_for(row[:key])
34
+ next if last_writer.nil?
35
+
36
+ env["_meta"].each_key do |field|
37
+ owner = schema.maintained_by(field)
38
+ next if owner.nil?
39
+ next if last_writer == owner
40
+ next if last_writer == "human"
41
+
42
+ violations << {
43
+ "key" => row[:key],
44
+ "code" => "role_authority",
45
+ "field" => field,
46
+ "expected" => owner,
47
+ "last_writer" => last_writer,
48
+ }
49
+ end
50
+ end
51
+
52
+ { "protocol" => PROTOCOL, "ok" => violations.empty?, "violations" => violations }
53
+ end
54
+ end
55
+ end
56
+ end
@@ -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