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.
Files changed (124) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -1
  3. data/README.md +29 -21
  4. data/SPEC.md +75 -142
  5. data/docs/architecture.md +42 -23
  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/verb/accept.rb +15 -0
  17. data/lib/textus/cli/verb/build.rb +13 -0
  18. data/lib/textus/cli/verb/delete.rb +16 -0
  19. data/lib/textus/cli/verb/deps.rb +12 -0
  20. data/lib/textus/cli/verb/doctor.rb +15 -0
  21. data/lib/textus/cli/verb/get.rb +12 -0
  22. data/lib/textus/cli/verb/hook_run.rb +48 -0
  23. data/lib/textus/cli/verb/hooks.rb +50 -0
  24. data/lib/textus/cli/verb/init.rb +14 -0
  25. data/lib/textus/cli/verb/intro.rb +11 -0
  26. data/lib/textus/cli/verb/list.rb +14 -0
  27. data/lib/textus/cli/verb/migrate_keys.rb +16 -0
  28. data/lib/textus/cli/verb/mv.rb +17 -0
  29. data/lib/textus/cli/verb/published.rb +11 -0
  30. data/lib/textus/cli/verb/put.rb +50 -0
  31. data/lib/textus/cli/verb/rdeps.rb +12 -0
  32. data/lib/textus/cli/verb/refresh.rb +15 -0
  33. data/lib/textus/cli/verb/schema.rb +12 -0
  34. data/lib/textus/cli/verb/schema_diff.rb +12 -0
  35. data/lib/textus/cli/verb/schema_init.rb +16 -0
  36. data/lib/textus/cli/verb/schema_migrate.rb +16 -0
  37. data/lib/textus/cli/verb/stale.rb +14 -0
  38. data/lib/textus/cli/verb/uid.rb +12 -0
  39. data/lib/textus/cli/verb/where.rb +12 -0
  40. data/lib/textus/cli.rb +23 -42
  41. data/lib/textus/doctor/check/audit_log.rb +50 -0
  42. data/lib/textus/doctor/check/hooks.rb +29 -0
  43. data/lib/textus/doctor/check/illegal_keys.rb +49 -0
  44. data/lib/textus/doctor/check/manifest_files.rb +38 -0
  45. data/lib/textus/doctor/check/schema_violations.rb +22 -0
  46. data/lib/textus/doctor/check/schemas.rb +26 -0
  47. data/lib/textus/doctor/check/sentinels.rb +57 -0
  48. data/lib/textus/doctor/check/templates.rb +26 -0
  49. data/lib/textus/doctor/check/unowned_schema_fields.rb +34 -0
  50. data/lib/textus/doctor/check.rb +30 -0
  51. data/lib/textus/doctor.rb +22 -288
  52. data/lib/textus/entry/base.rb +30 -0
  53. data/lib/textus/entry/json.rb +5 -1
  54. data/lib/textus/entry/markdown.rb +1 -1
  55. data/lib/textus/entry/text.rb +1 -1
  56. data/lib/textus/entry/yaml.rb +5 -1
  57. data/lib/textus/entry.rb +0 -5
  58. data/lib/textus/envelope.rb +30 -0
  59. data/lib/textus/hooks/builtin.rb +70 -0
  60. data/lib/textus/hooks/dispatcher.rb +49 -0
  61. data/lib/textus/hooks/loader.rb +26 -0
  62. data/lib/textus/hooks/registry.rb +73 -0
  63. data/lib/textus/init.rb +13 -10
  64. data/lib/textus/intro.rb +14 -16
  65. data/lib/textus/key/distance.rb +55 -0
  66. data/lib/textus/key/grammar.rb +33 -0
  67. data/lib/textus/key/path.rb +17 -0
  68. data/lib/textus/manifest/entry.rb +199 -0
  69. data/lib/textus/manifest.rb +10 -34
  70. data/lib/textus/migrate_keys.rb +1 -1
  71. data/lib/textus/projection.rb +5 -4
  72. data/lib/textus/proposal.rb +1 -1
  73. data/lib/textus/refresh.rb +11 -11
  74. data/lib/textus/schema/tools.rb +89 -0
  75. data/lib/textus/store/audit_log.rb +71 -0
  76. data/lib/textus/store/mover.rb +19 -16
  77. data/lib/textus/store/reader.rb +67 -0
  78. data/lib/textus/store/staleness.rb +10 -19
  79. data/lib/textus/store/validator.rb +11 -8
  80. data/lib/textus/store/view.rb +29 -0
  81. data/lib/textus/store/writer.rb +132 -0
  82. data/lib/textus/store.rb +25 -221
  83. data/lib/textus/version.rb +1 -1
  84. data/lib/textus.rb +14 -67
  85. metadata +73 -40
  86. data/lib/textus/audit_log.rb +0 -67
  87. data/lib/textus/builtin_actions.rb +0 -68
  88. data/lib/textus/cli/accept.rb +0 -13
  89. data/lib/textus/cli/action.rb +0 -51
  90. data/lib/textus/cli/build.rb +0 -11
  91. data/lib/textus/cli/delete.rb +0 -14
  92. data/lib/textus/cli/deprecated_alias.rb +0 -31
  93. data/lib/textus/cli/deps.rb +0 -10
  94. data/lib/textus/cli/doctor.rb +0 -13
  95. data/lib/textus/cli/extension_group.rb +0 -9
  96. data/lib/textus/cli/extensions.rb +0 -49
  97. data/lib/textus/cli/get.rb +0 -10
  98. data/lib/textus/cli/init.rb +0 -12
  99. data/lib/textus/cli/intro.rb +0 -9
  100. data/lib/textus/cli/key_group.rb +0 -10
  101. data/lib/textus/cli/list.rb +0 -12
  102. data/lib/textus/cli/migrate.rb +0 -41
  103. data/lib/textus/cli/migrate_keys.rb +0 -19
  104. data/lib/textus/cli/mv.rb +0 -20
  105. data/lib/textus/cli/published.rb +0 -9
  106. data/lib/textus/cli/put.rb +0 -48
  107. data/lib/textus/cli/rdeps.rb +0 -10
  108. data/lib/textus/cli/refresh.rb +0 -13
  109. data/lib/textus/cli/schema.rb +0 -10
  110. data/lib/textus/cli/schema_diff.rb +0 -15
  111. data/lib/textus/cli/schema_group.rb +0 -33
  112. data/lib/textus/cli/schema_init.rb +0 -19
  113. data/lib/textus/cli/schema_migrate.rb +0 -19
  114. data/lib/textus/cli/stale.rb +0 -12
  115. data/lib/textus/cli/uid.rb +0 -15
  116. data/lib/textus/cli/where.rb +0 -10
  117. data/lib/textus/extension_registry.rb +0 -61
  118. data/lib/textus/extensions.rb +0 -33
  119. data/lib/textus/key_distance.rb +0 -53
  120. data/lib/textus/manifest_entry.rb +0 -185
  121. data/lib/textus/migrate_v2.rb +0 -27
  122. data/lib/textus/schema_tools.rb +0 -87
  123. data/lib/textus/store/events.rb +0 -31
  124. data/lib/textus/store_view.rb +0 -27
@@ -2,26 +2,26 @@ 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)
@@ -45,12 +45,12 @@ 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
- # Accept both legacy :frontmatter/:_meta key names from actions.
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("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
@@ -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(store)
8
- @store = store
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
- @store.manifest.validate_key!(old_key)
13
- @store.manifest.validate_key!(new_key)
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, = @store.manifest.resolve(old_key)
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, = @store.manifest.resolve(new_key)
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 = @store.manifest.zone_writers(old_mentry.zone)
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 = @store.get(old_key)
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 = @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)
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
- @store.audit_log.append(
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 = @store.get(new_key)
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(store)
8
- @store = store
7
+ def initialize(manifest:)
8
+ @manifest = manifest
9
9
  end
10
10
 
11
11
  def call(prefix: nil, zone: nil)
12
12
  out = []
13
- @store.manifest.entries.each do |mentry|
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 = path_for_entry(mentry)
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
- @store.manifest.entries.each do |mentry|
50
- next unless mentry.action
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 = path_for_entry(mentry)
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
- @store.manifest.enumerate(prefix: src).each do |row|
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(@store.root), src)
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, "action" => mentry.action, "reason" => reason }
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(store)
5
- @store = store
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
- @store.manifest.enumerate.each do |row|
13
+ @manifest.enumerate.each do |row|
11
14
  begin
12
- @store.get(row[:key])
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
- @store.manifest.enumerate.each do |row|
21
+ @manifest.enumerate.each do |row|
19
22
  mentry = row[:manifest_entry]
20
23
  next unless mentry.schema
21
24
 
22
- schema = @store.schema_for(mentry.schema)
25
+ schema = @schema_for.call(mentry.schema)
23
26
  next unless schema
24
27
 
25
28
  env = begin
26
- @store.get(row[:key])
29
+ @reader.get(row[:key])
27
30
  rescue StandardError
28
31
  next
29
32
  end
30
- last_writer = @store.audit_log.last_writer_for(row[:key])
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