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
@@ -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