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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +147 -2
- data/README.md +38 -28
- data/SPEC.md +84 -147
- data/docs/architecture.md +82 -28
- data/lib/textus/builder/pipeline.rb +56 -0
- data/lib/textus/builder/renderer/json.rb +42 -0
- data/lib/textus/builder/renderer/markdown.rb +22 -0
- data/lib/textus/builder/renderer/text.rb +14 -0
- data/lib/textus/builder/renderer/yaml.rb +42 -0
- data/lib/textus/builder/renderer.rb +17 -0
- data/lib/textus/builder.rb +9 -114
- data/lib/textus/cli/group/hook.rb +11 -0
- data/lib/textus/cli/group/key.rb +12 -0
- data/lib/textus/cli/group/schema.rb +13 -0
- data/lib/textus/cli/group.rb +51 -0
- data/lib/textus/cli/verb/accept.rb +15 -0
- data/lib/textus/cli/verb/build.rb +13 -0
- data/lib/textus/cli/verb/delete.rb +16 -0
- data/lib/textus/cli/verb/deps.rb +12 -0
- data/lib/textus/cli/verb/doctor.rb +15 -0
- data/lib/textus/cli/verb/get.rb +12 -0
- data/lib/textus/cli/verb/hook_run.rb +48 -0
- data/lib/textus/cli/verb/hooks.rb +50 -0
- data/lib/textus/cli/verb/init.rb +14 -0
- data/lib/textus/cli/verb/intro.rb +11 -0
- data/lib/textus/cli/verb/list.rb +14 -0
- data/lib/textus/cli/verb/migrate_keys.rb +16 -0
- data/lib/textus/cli/verb/mv.rb +17 -0
- data/lib/textus/cli/verb/published.rb +11 -0
- data/lib/textus/cli/verb/put.rb +50 -0
- data/lib/textus/cli/verb/rdeps.rb +12 -0
- data/lib/textus/cli/verb/refresh.rb +15 -0
- data/lib/textus/cli/verb/schema.rb +12 -0
- data/lib/textus/cli/verb/schema_diff.rb +12 -0
- data/lib/textus/cli/verb/schema_init.rb +16 -0
- data/lib/textus/cli/verb/schema_migrate.rb +16 -0
- data/lib/textus/cli/verb/stale.rb +14 -0
- data/lib/textus/cli/verb/uid.rb +12 -0
- data/lib/textus/cli/verb/where.rb +12 -0
- data/lib/textus/cli/verb.rb +62 -0
- data/lib/textus/cli.rb +44 -385
- data/lib/textus/doctor/check/audit_log.rb +50 -0
- data/lib/textus/doctor/check/hooks.rb +29 -0
- data/lib/textus/doctor/check/illegal_keys.rb +49 -0
- data/lib/textus/doctor/check/manifest_files.rb +38 -0
- data/lib/textus/doctor/check/schema_violations.rb +22 -0
- data/lib/textus/doctor/check/schemas.rb +26 -0
- data/lib/textus/doctor/check/sentinels.rb +57 -0
- data/lib/textus/doctor/check/templates.rb +26 -0
- data/lib/textus/doctor/check/unowned_schema_fields.rb +34 -0
- data/lib/textus/doctor/check.rb +30 -0
- data/lib/textus/doctor.rb +29 -264
- data/lib/textus/entry/base.rb +30 -0
- data/lib/textus/entry/json.rb +11 -5
- data/lib/textus/entry/markdown.rb +5 -5
- data/lib/textus/entry/text.rb +4 -4
- data/lib/textus/entry/yaml.rb +11 -5
- data/lib/textus/entry.rb +2 -7
- data/lib/textus/envelope.rb +30 -0
- data/lib/textus/errors.rb +2 -2
- data/lib/textus/hooks/builtin.rb +70 -0
- data/lib/textus/hooks/dispatcher.rb +49 -0
- data/lib/textus/hooks/loader.rb +26 -0
- data/lib/textus/hooks/registry.rb +73 -0
- data/lib/textus/init.rb +14 -11
- data/lib/textus/intro.rb +16 -18
- data/lib/textus/key/distance.rb +55 -0
- data/lib/textus/key/grammar.rb +33 -0
- data/lib/textus/key/path.rb +17 -0
- data/lib/textus/manifest/entry.rb +199 -0
- data/lib/textus/manifest.rb +20 -254
- data/lib/textus/migrate_keys.rb +1 -1
- data/lib/textus/projection.rb +6 -5
- data/lib/textus/proposal.rb +4 -4
- data/lib/textus/refresh.rb +17 -17
- data/lib/textus/schema/tools.rb +89 -0
- data/lib/textus/store/audit_log.rb +71 -0
- data/lib/textus/store/mover.rb +121 -0
- data/lib/textus/store/reader.rb +67 -0
- data/lib/textus/store/staleness.rb +133 -0
- data/lib/textus/store/validator.rb +56 -0
- data/lib/textus/store/view.rb +29 -0
- data/lib/textus/store/writer.rb +132 -0
- data/lib/textus/store.rb +26 -527
- data/lib/textus/version.rb +2 -2
- data/lib/textus.rb +14 -29
- metadata +78 -8
- data/lib/textus/audit_log.rb +0 -32
- data/lib/textus/builtin_actions.rb +0 -68
- data/lib/textus/extension_registry.rb +0 -61
- data/lib/textus/extensions.rb +0 -33
- data/lib/textus/key_distance.rb +0 -53
- data/lib/textus/schema_tools.rb +0 -87
- data/lib/textus/store_view.rb +0 -27
data/lib/textus/store.rb
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
require "fileutils"
|
|
2
2
|
require "securerandom"
|
|
3
|
-
require "time"
|
|
4
|
-
require "timeout"
|
|
5
3
|
|
|
6
4
|
module Textus
|
|
7
|
-
# rubocop:disable Metrics/ClassLength
|
|
8
5
|
class Store
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
attr_reader :root, :manifest, :registry
|
|
6
|
+
attr_reader :root, :manifest, :registry, :reader, :writer, :bus
|
|
12
7
|
|
|
13
8
|
# A Textus UID: 16 lowercase hex chars (SecureRandom.hex(8)). Not a UUID —
|
|
14
9
|
# short on purpose. Random enough for collision-never-in-practice within a
|
|
@@ -44,15 +39,18 @@ module Textus
|
|
|
44
39
|
def initialize(root)
|
|
45
40
|
@root = File.expand_path(root)
|
|
46
41
|
@manifest = Manifest.load(@root)
|
|
47
|
-
@
|
|
42
|
+
@bus = Hooks::Dispatcher.new(audit_log: audit_log)
|
|
43
|
+
@registry = Hooks::Registry.new(dispatcher: @bus)
|
|
48
44
|
@schemas = {}
|
|
49
45
|
load_extensions
|
|
46
|
+
@reader = Reader.new(self)
|
|
47
|
+
@writer = Writer.new(self)
|
|
50
48
|
end
|
|
51
49
|
|
|
52
50
|
def load_extensions
|
|
53
51
|
Textus.with_registry(@registry) do
|
|
54
|
-
|
|
55
|
-
dir = File.join(@root, "
|
|
52
|
+
Hooks::Builtin.register_all
|
|
53
|
+
dir = File.join(@root, "hooks")
|
|
56
54
|
return unless File.directory?(dir)
|
|
57
55
|
|
|
58
56
|
Dir.glob(File.join(dir, "*.rb")).sort.each do |f| # rubocop:disable Lint/RedundantDirGlobSort
|
|
@@ -77,541 +75,42 @@ module Textus
|
|
|
77
75
|
end
|
|
78
76
|
|
|
79
77
|
def get(key)
|
|
80
|
-
|
|
81
|
-
raise UnknownKey.new(key, suggestions: @manifest.suggestions_for(key)) unless File.exist?(path)
|
|
82
|
-
|
|
83
|
-
raw = File.binread(path)
|
|
84
|
-
parsed = Entry.for_format(mentry.format).parse(raw, path: path)
|
|
85
|
-
fm = parsed["frontmatter"]
|
|
86
|
-
content = parsed["content"]
|
|
87
|
-
enforce_name_match!(path, fm, mentry.format)
|
|
88
|
-
schema = schema_for(mentry.schema)
|
|
89
|
-
if schema
|
|
90
|
-
case mentry.format
|
|
91
|
-
when "markdown" then schema.validate!(fm)
|
|
92
|
-
when "json", "yaml" then schema.validate!(content || {})
|
|
93
|
-
# text: schema forbidden by manifest validation
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
build_envelope(key, mentry, path, fm, parsed["body"], Etag.for_bytes(raw), content: content)
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def where(key)
|
|
100
|
-
mentry, path, = @manifest.resolve(key)
|
|
101
|
-
{
|
|
102
|
-
"protocol" => PROTOCOL,
|
|
103
|
-
"key" => key,
|
|
104
|
-
"zone" => mentry.zone,
|
|
105
|
-
"owner" => mentry.owner,
|
|
106
|
-
"path" => path,
|
|
107
|
-
}
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def list(prefix: nil, zone: nil)
|
|
111
|
-
rows = @manifest.enumerate(prefix: prefix)
|
|
112
|
-
rows = rows.select { |r| r[:manifest_entry].zone == zone } if zone
|
|
113
|
-
rows.map do |row|
|
|
114
|
-
{
|
|
115
|
-
"key" => row[:key],
|
|
116
|
-
"zone" => row[:manifest_entry].zone,
|
|
117
|
-
"path" => row[:path],
|
|
118
|
-
}
|
|
119
|
-
end
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def schema_envelope(key)
|
|
123
|
-
mentry, = @manifest.resolve(key)
|
|
124
|
-
schema = schema_for(mentry.schema)
|
|
125
|
-
{
|
|
126
|
-
"protocol" => PROTOCOL,
|
|
127
|
-
"key" => key,
|
|
128
|
-
"schema_ref" => mentry.schema,
|
|
129
|
-
"schema" => schema&.to_h,
|
|
130
|
-
}
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
# rubocop:disable Metrics/ParameterLists
|
|
134
|
-
def put(key, frontmatter: nil, body: nil, content: nil, if_etag: nil, as: Role::DEFAULT, suppress_events: false)
|
|
135
|
-
# rubocop:enable Metrics/ParameterLists
|
|
136
|
-
@manifest.validate_key!(key)
|
|
137
|
-
mentry, path, = @manifest.resolve(key)
|
|
138
|
-
writers = @manifest.zone_writers(mentry.zone)
|
|
139
|
-
raise WriteForbidden.new(key, mentry.zone, writers: writers) unless writers.include?(as)
|
|
140
|
-
|
|
141
|
-
frontmatter ||= {}
|
|
142
|
-
strategy = Entry.for_format(mentry.format)
|
|
143
|
-
|
|
144
|
-
existing_uid = existing_uid_for(mentry, path)
|
|
145
|
-
frontmatter, content = ensure_uid(mentry.format, frontmatter, content, existing_uid)
|
|
146
|
-
|
|
147
|
-
bytes, eff_fm, eff_body, eff_content = serialize_for_put(
|
|
148
|
-
mentry: mentry, path: path, strategy: strategy,
|
|
149
|
-
frontmatter: frontmatter, body: body, content: content
|
|
150
|
-
)
|
|
151
|
-
|
|
152
|
-
enforce_name_match!(path, eff_fm, mentry.format)
|
|
153
|
-
|
|
154
|
-
schema = schema_for(mentry.schema)
|
|
155
|
-
if schema
|
|
156
|
-
case mentry.format
|
|
157
|
-
when "markdown" then schema.validate!(eff_fm)
|
|
158
|
-
when "json", "yaml" then schema.validate!(eff_content || {})
|
|
159
|
-
end
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
etag_before = File.exist?(path) ? Etag.for_file(path) : nil
|
|
163
|
-
raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && (etag_before != if_etag)
|
|
164
|
-
|
|
165
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
166
|
-
File.binwrite(path, bytes)
|
|
167
|
-
etag_after = Etag.for_bytes(bytes)
|
|
168
|
-
audit_log.append(role: as, verb: "put", key: key, etag_before: etag_before, etag_after: etag_after)
|
|
169
|
-
envelope = build_envelope(key, mentry, path, eff_fm, eff_body, etag_after, content: eff_content)
|
|
170
|
-
fire_event(:put, key: key, envelope: envelope) unless suppress_events
|
|
171
|
-
envelope
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
def delete(key, if_etag: nil, as: Role::DEFAULT, suppress_events: false)
|
|
175
|
-
mentry, path, = @manifest.resolve(key)
|
|
176
|
-
writers = @manifest.zone_writers(mentry.zone)
|
|
177
|
-
raise WriteForbidden.new(key, mentry.zone, writers: writers) unless writers.include?(as)
|
|
178
|
-
raise UnknownKey.new(key, suggestions: @manifest.suggestions_for(key)) unless File.exist?(path)
|
|
179
|
-
|
|
180
|
-
etag_before = Etag.for_file(path)
|
|
181
|
-
raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before
|
|
182
|
-
|
|
183
|
-
File.delete(path)
|
|
184
|
-
audit_log.append(role: as, verb: "delete", key: key, etag_before: etag_before, etag_after: nil)
|
|
185
|
-
fire_event(:delete, key: key) unless suppress_events
|
|
186
|
-
{ "protocol" => PROTOCOL, "ok" => true, "key" => key, "deleted" => true }
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
def fire_event(event, **kwargs)
|
|
190
|
-
view = StoreView.new(self)
|
|
191
|
-
@registry.hooks(event).each do |entry|
|
|
192
|
-
name = entry[:name]
|
|
193
|
-
Timeout.timeout(HOOK_TIMEOUT_SECONDS) { entry[:callable].call(store: view, **kwargs) }
|
|
194
|
-
rescue StandardError => e
|
|
195
|
-
extras = { "event" => event.to_s, "hook" => name.to_s, "error" => "#{e.class}: #{e.message}" }
|
|
196
|
-
extras["target_key"] = kwargs[:target_key] if kwargs.key?(:target_key)
|
|
197
|
-
extras["pending_key"] = kwargs[:pending_key] if kwargs.key?(:pending_key)
|
|
198
|
-
audit_log.append(
|
|
199
|
-
role: "script", verb: "event_error",
|
|
200
|
-
key: kwargs[:key] || kwargs[:target_key] || kwargs[:pending_key] || "-",
|
|
201
|
-
etag_before: nil, etag_after: nil,
|
|
202
|
-
extras: extras
|
|
203
|
-
)
|
|
204
|
-
end
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
def accept(key, as:)
|
|
208
|
-
Proposal.accept(self, key, as: as)
|
|
78
|
+
@reader.get(key)
|
|
209
79
|
end
|
|
210
80
|
|
|
211
|
-
def
|
|
212
|
-
def
|
|
213
|
-
def
|
|
81
|
+
def where(key) = @reader.where(key)
|
|
82
|
+
def list(**) = @reader.list(**)
|
|
83
|
+
def schema_envelope(key) = @reader.schema_envelope(key)
|
|
214
84
|
|
|
215
|
-
def
|
|
216
|
-
violations = []
|
|
217
|
-
@manifest.enumerate.each do |row|
|
|
218
|
-
begin
|
|
219
|
-
get(row[:key])
|
|
220
|
-
rescue Textus::Error => e
|
|
221
|
-
violations << { "key" => row[:key], "code" => e.code, "message" => e.message }
|
|
222
|
-
end
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
@manifest.enumerate.each do |row|
|
|
226
|
-
mentry = row[:manifest_entry]
|
|
227
|
-
next unless mentry.schema
|
|
228
|
-
|
|
229
|
-
schema = schema_for(mentry.schema)
|
|
230
|
-
next unless schema
|
|
85
|
+
def put(...) = @writer.put(...)
|
|
231
86
|
|
|
232
|
-
|
|
233
|
-
get(row[:key])
|
|
234
|
-
rescue StandardError
|
|
235
|
-
next
|
|
236
|
-
end
|
|
237
|
-
last_writer = audit_log.last_writer_for(row[:key])
|
|
238
|
-
next if last_writer.nil?
|
|
239
|
-
|
|
240
|
-
env["frontmatter"].each_key do |field|
|
|
241
|
-
owner = schema.maintained_by(field)
|
|
242
|
-
next if owner.nil?
|
|
243
|
-
next if last_writer == owner
|
|
244
|
-
next if last_writer == "human"
|
|
245
|
-
|
|
246
|
-
violations << {
|
|
247
|
-
"key" => row[:key],
|
|
248
|
-
"code" => "role_authority",
|
|
249
|
-
"field" => field,
|
|
250
|
-
"expected" => owner,
|
|
251
|
-
"last_writer" => last_writer,
|
|
252
|
-
}
|
|
253
|
-
end
|
|
254
|
-
end
|
|
87
|
+
def delete(...) = @writer.delete(...)
|
|
255
88
|
|
|
256
|
-
|
|
89
|
+
def fire_event(event, **)
|
|
90
|
+
view = Store::View.new(self)
|
|
91
|
+
@bus.publish(event, store: view, **)
|
|
257
92
|
end
|
|
258
93
|
|
|
259
|
-
|
|
260
|
-
def stale(prefix: nil, zone: nil)
|
|
261
|
-
out = []
|
|
262
|
-
@manifest.entries.each do |mentry|
|
|
263
|
-
next unless mentry.zone == "derived"
|
|
264
|
-
next if zone && mentry.zone != zone
|
|
265
|
-
|
|
266
|
-
gen = mentry.generator
|
|
267
|
-
next unless gen
|
|
268
|
-
next if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
|
|
269
|
-
|
|
270
|
-
path = path_for_entry(mentry)
|
|
271
|
-
|
|
272
|
-
unless File.exist?(path)
|
|
273
|
-
out << stale_row(mentry, path, "derived entry has never been generated")
|
|
274
|
-
next
|
|
275
|
-
end
|
|
94
|
+
def accept(...) = @writer.accept(...)
|
|
276
95
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
next
|
|
283
|
-
end
|
|
284
|
-
gen_time = begin
|
|
285
|
-
Time.parse(generated_at.to_s)
|
|
286
|
-
rescue StandardError
|
|
287
|
-
nil
|
|
288
|
-
end
|
|
289
|
-
unless gen_time
|
|
290
|
-
out << stale_row(mentry, path, "unparseable generated.at: #{generated_at.inspect}")
|
|
291
|
-
next
|
|
292
|
-
end
|
|
96
|
+
def deps(key) = @reader.deps(key)
|
|
97
|
+
def rdeps(key) = @reader.rdeps(key)
|
|
98
|
+
def published = @reader.published
|
|
99
|
+
def stale(**) = @reader.stale(**)
|
|
100
|
+
def validate_all = @reader.validate_all
|
|
293
101
|
|
|
294
|
-
|
|
295
|
-
out << stale_row(mentry, path, "source '#{offender}' modified after generated.at") if offender
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
@manifest.entries.each do |mentry|
|
|
299
|
-
next unless mentry.action
|
|
300
|
-
next if zone && mentry.zone != zone
|
|
301
|
-
next if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
|
|
302
|
-
|
|
303
|
-
ttl = parse_ttl(mentry.ttl)
|
|
304
|
-
next unless ttl
|
|
305
|
-
|
|
306
|
-
path = path_for_entry(mentry)
|
|
307
|
-
|
|
308
|
-
unless File.exist?(path)
|
|
309
|
-
out << intake_stale_row(mentry, path, "never refreshed")
|
|
310
|
-
next
|
|
311
|
-
end
|
|
312
|
-
|
|
313
|
-
fm = Entry.for_format(mentry.format).parse(File.binread(path), path: path)["frontmatter"]
|
|
314
|
-
last_str = fm["last_refreshed_at"]
|
|
315
|
-
if last_str.nil?
|
|
316
|
-
out << intake_stale_row(mentry, path, "never refreshed (no last_refreshed_at)")
|
|
317
|
-
next
|
|
318
|
-
end
|
|
319
|
-
|
|
320
|
-
last = begin
|
|
321
|
-
Time.parse(last_str.to_s)
|
|
322
|
-
rescue StandardError
|
|
323
|
-
nil
|
|
324
|
-
end
|
|
325
|
-
out << intake_stale_row(mentry, path, "ttl exceeded (#{ttl}s)") if last.nil? || (Time.now - last) > ttl
|
|
326
|
-
end
|
|
327
|
-
|
|
328
|
-
out
|
|
329
|
-
end
|
|
330
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
|
|
331
|
-
|
|
332
|
-
# Returns the Textus UID for a key (or nil if the entry has none yet).
|
|
333
|
-
# Raises UnknownKey if the key doesn't resolve to a real file.
|
|
334
|
-
def uid(key)
|
|
335
|
-
env = get(key)
|
|
336
|
-
env["uid"]
|
|
337
|
-
end
|
|
102
|
+
def uid(key) = @reader.uid(key)
|
|
338
103
|
|
|
339
104
|
# Move an entry from old_key to new_key within the same zone. Preserves
|
|
340
105
|
# uid (minting one first if absent), validates both keys against the
|
|
341
106
|
# manifest, refuses to clobber, and writes one mv audit row.
|
|
342
|
-
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
343
107
|
def mv(old_key, new_key, as: Role::DEFAULT, dry_run: false)
|
|
344
|
-
@manifest
|
|
345
|
-
|
|
346
|
-
raise UsageError.new("mv: old and new keys are identical") if old_key == new_key
|
|
347
|
-
|
|
348
|
-
old_mentry, old_path, = @manifest.resolve(old_key)
|
|
349
|
-
raise UnknownKey.new(old_key) unless File.exist?(old_path)
|
|
350
|
-
|
|
351
|
-
new_mentry, new_path, = @manifest.resolve(new_key)
|
|
352
|
-
|
|
353
|
-
if old_mentry.zone != new_mentry.zone
|
|
354
|
-
raise UsageError.new(
|
|
355
|
-
"mv: cross-zone move refused (#{old_mentry.zone} → #{new_mentry.zone}). " \
|
|
356
|
-
"Use put+delete for cross-zone moves.",
|
|
357
|
-
)
|
|
358
|
-
end
|
|
359
|
-
if old_mentry.format != new_mentry.format
|
|
360
|
-
raise UsageError.new(
|
|
361
|
-
"mv: format mismatch (#{old_mentry.format} → #{new_mentry.format}); refusing.",
|
|
362
|
-
)
|
|
363
|
-
end
|
|
364
|
-
|
|
365
|
-
writers = @manifest.zone_writers(old_mentry.zone)
|
|
366
|
-
raise WriteForbidden.new(old_key, old_mentry.zone, writers: writers) unless writers.include?(as)
|
|
367
|
-
|
|
368
|
-
raise UsageError.new("mv: target '#{new_key}' already exists at #{new_path}") if File.exist?(new_path)
|
|
369
|
-
|
|
370
|
-
# Mint uid before the move so the audit row carries it.
|
|
371
|
-
pre_env = get(old_key)
|
|
372
|
-
current_uid = pre_env["uid"]
|
|
373
|
-
etag_before = pre_env["etag"]
|
|
374
|
-
|
|
375
|
-
if dry_run
|
|
376
|
-
return {
|
|
377
|
-
"protocol" => PROTOCOL, "ok" => true, "dry_run" => true,
|
|
378
|
-
"from_key" => old_key, "to_key" => new_key,
|
|
379
|
-
"from_path" => old_path, "to_path" => new_path,
|
|
380
|
-
"uid" => current_uid
|
|
381
|
-
}
|
|
382
|
-
end
|
|
383
|
-
|
|
384
|
-
if current_uid.nil?
|
|
385
|
-
# Write the uid in place first so the source file carries it before mv.
|
|
386
|
-
pre_env = put(old_key,
|
|
387
|
-
frontmatter: pre_env["frontmatter"],
|
|
388
|
-
body: pre_env["body"],
|
|
389
|
-
content: pre_env["content"],
|
|
390
|
-
as: as,
|
|
391
|
-
suppress_events: true)
|
|
392
|
-
current_uid = pre_env["uid"]
|
|
393
|
-
etag_before = pre_env["etag"]
|
|
394
|
-
end
|
|
395
|
-
|
|
396
|
-
FileUtils.mkdir_p(File.dirname(new_path))
|
|
397
|
-
FileUtils.mv(old_path, new_path)
|
|
398
|
-
rewrite_name_for_mv!(new_mentry, new_path, new_key)
|
|
399
|
-
etag_after = Etag.for_file(new_path)
|
|
400
|
-
|
|
401
|
-
audit_log.append(
|
|
402
|
-
role: as, verb: "mv", key: new_key,
|
|
403
|
-
etag_before: etag_before, etag_after: etag_after,
|
|
404
|
-
extras: {
|
|
405
|
-
"from_key" => old_key, "to_key" => new_key,
|
|
406
|
-
"from_path" => old_path, "to_path" => new_path,
|
|
407
|
-
"uid" => current_uid
|
|
408
|
-
}
|
|
409
|
-
)
|
|
410
|
-
|
|
411
|
-
env = get(new_key)
|
|
412
|
-
{
|
|
413
|
-
"protocol" => PROTOCOL, "ok" => true,
|
|
414
|
-
"from_key" => old_key, "to_key" => new_key,
|
|
415
|
-
"from_path" => old_path, "to_path" => new_path,
|
|
416
|
-
"uid" => current_uid,
|
|
417
|
-
"envelope" => env
|
|
418
|
-
}
|
|
419
|
-
end
|
|
420
|
-
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
421
|
-
|
|
422
|
-
private
|
|
423
|
-
|
|
424
|
-
# If the moved file carries a `name:` field (markdown) or `_meta.name`
|
|
425
|
-
# (json/yaml), rewrite it to the new basename so enforce_name_match! stays
|
|
426
|
-
# happy on the next read. Only touches the bytes when name actually changes.
|
|
427
|
-
def rewrite_name_for_mv!(mentry, new_path, new_key)
|
|
428
|
-
strategy = Entry.for_format(mentry.format)
|
|
429
|
-
raw = File.binread(new_path)
|
|
430
|
-
parsed = strategy.parse(raw, path: new_path)
|
|
431
|
-
basename = new_key.split(".").last
|
|
432
|
-
|
|
433
|
-
case mentry.format
|
|
434
|
-
when "markdown"
|
|
435
|
-
fm = parsed["frontmatter"] || {}
|
|
436
|
-
return unless fm.is_a?(Hash) && fm["name"].is_a?(String) && fm["name"] != basename
|
|
437
|
-
|
|
438
|
-
fm = fm.merge("name" => basename)
|
|
439
|
-
File.binwrite(new_path, strategy.serialize(frontmatter: fm, body: parsed["body"]))
|
|
440
|
-
when "json", "yaml"
|
|
441
|
-
content = parsed["content"]
|
|
442
|
-
return unless content.is_a?(Hash) && content["_meta"].is_a?(Hash) &&
|
|
443
|
-
content["_meta"]["name"].is_a?(String) && content["_meta"]["name"] != basename
|
|
444
|
-
|
|
445
|
-
meta = content["_meta"].merge("name" => basename)
|
|
446
|
-
content = { "_meta" => meta }.merge(content.except("_meta"))
|
|
447
|
-
File.binwrite(new_path, strategy.serialize(frontmatter: {}, body: "", content: content))
|
|
448
|
-
end
|
|
449
|
-
end
|
|
450
|
-
|
|
451
|
-
def existing_uid_for(mentry, path)
|
|
452
|
-
return nil unless File.exist?(path)
|
|
453
|
-
|
|
454
|
-
raw = File.binread(path)
|
|
455
|
-
parsed = Entry.for_format(mentry.format).parse(raw, path: path)
|
|
456
|
-
extract_uid(mentry.format, parsed["frontmatter"], parsed["content"])
|
|
457
|
-
rescue StandardError
|
|
458
|
-
nil
|
|
459
|
-
end
|
|
460
|
-
|
|
461
|
-
# Ensures the payload carries a uid: preserve existing, else mint.
|
|
462
|
-
# Returns [frontmatter, content] possibly mutated.
|
|
463
|
-
def ensure_uid(format, frontmatter, content, existing_uid)
|
|
464
|
-
case format
|
|
465
|
-
when "markdown"
|
|
466
|
-
fm = frontmatter.is_a?(Hash) ? frontmatter.dup : {}
|
|
467
|
-
fm["uid"] = existing_uid || Store.mint_uid unless fm["uid"].is_a?(String) && !fm["uid"].empty?
|
|
468
|
-
[fm, content]
|
|
469
|
-
when "json", "yaml"
|
|
470
|
-
c = content.is_a?(Hash) ? content.dup : nil
|
|
471
|
-
if c
|
|
472
|
-
meta = c["_meta"].is_a?(Hash) ? c["_meta"].dup : {}
|
|
473
|
-
meta["uid"] = existing_uid || Store.mint_uid if !meta["uid"].is_a?(String) || meta["uid"].empty?
|
|
474
|
-
# Keep _meta first for etag stability.
|
|
475
|
-
c = { "_meta" => meta }.merge(c.except("_meta"))
|
|
476
|
-
end
|
|
477
|
-
[frontmatter, c]
|
|
478
|
-
else
|
|
479
|
-
# text: no uid channel
|
|
480
|
-
[frontmatter, content]
|
|
481
|
-
end
|
|
108
|
+
Mover.new(reader: @reader, writer: @writer, manifest: @manifest, audit_log: audit_log)
|
|
109
|
+
.call(old_key, new_key, as: as, dry_run: dry_run)
|
|
482
110
|
end
|
|
483
111
|
|
|
484
112
|
def audit_log
|
|
485
|
-
@audit_log ||= AuditLog.new(@root)
|
|
486
|
-
end
|
|
487
|
-
|
|
488
|
-
def path_for_entry(mentry)
|
|
489
|
-
primary_ext = Entry.for_format(mentry.format).extensions.first
|
|
490
|
-
if File.extname(mentry.path) == ""
|
|
491
|
-
File.join(@root, "zones", mentry.path + primary_ext)
|
|
492
|
-
else
|
|
493
|
-
File.join(@root, "zones", mentry.path)
|
|
494
|
-
end
|
|
495
|
-
end
|
|
496
|
-
|
|
497
|
-
def newest_source_after(gen, gen_time)
|
|
498
|
-
Array(gen["sources"]).each do |src|
|
|
499
|
-
if src.match?(/\A[a-z0-9.][a-z0-9._-]*\z/) && !src.include?("/")
|
|
500
|
-
@manifest.enumerate(prefix: src).each do |row|
|
|
501
|
-
return src if File.mtime(row[:path]) > gen_time
|
|
502
|
-
end
|
|
503
|
-
else
|
|
504
|
-
abs = File.absolute_path?(src) ? src : File.join(File.dirname(@root), src)
|
|
505
|
-
if File.directory?(abs)
|
|
506
|
-
Dir.glob(File.join(abs, "**", "*")).each do |fp|
|
|
507
|
-
next unless File.file?(fp)
|
|
508
|
-
return src if File.mtime(fp) > gen_time
|
|
509
|
-
end
|
|
510
|
-
elsif File.exist?(abs)
|
|
511
|
-
return src if File.mtime(abs) > gen_time
|
|
512
|
-
end
|
|
513
|
-
end
|
|
514
|
-
end
|
|
515
|
-
nil
|
|
516
|
-
end
|
|
517
|
-
|
|
518
|
-
def parse_ttl(s)
|
|
519
|
-
return nil unless s
|
|
520
|
-
|
|
521
|
-
m = s.to_s.match(/\A(\d+)([smhd])\z/) or return nil
|
|
522
|
-
n = m[1].to_i
|
|
523
|
-
case m[2]
|
|
524
|
-
when "s" then n
|
|
525
|
-
when "m" then n * 60
|
|
526
|
-
when "h" then n * 3600
|
|
527
|
-
when "d" then n * 86_400
|
|
528
|
-
end
|
|
529
|
-
end
|
|
530
|
-
|
|
531
|
-
def intake_stale_row(mentry, path, reason)
|
|
532
|
-
{ "key" => mentry.key, "path" => path, "action" => mentry.action, "reason" => reason }
|
|
533
|
-
end
|
|
534
|
-
|
|
535
|
-
def stale_row(mentry, path, reason)
|
|
536
|
-
{
|
|
537
|
-
"key" => mentry.key,
|
|
538
|
-
"path" => path,
|
|
539
|
-
"generator" => mentry.generator,
|
|
540
|
-
"reason" => reason,
|
|
541
|
-
}
|
|
542
|
-
end
|
|
543
|
-
|
|
544
|
-
def enforce_name_match!(path, fm, format)
|
|
545
|
-
# Name<->basename check only meaningful for markdown frontmatter.
|
|
546
|
-
return unless format == "markdown"
|
|
547
|
-
|
|
548
|
-
basename = File.basename(path, ".md")
|
|
549
|
-
return unless fm.is_a?(Hash) && fm["name"] && fm["name"] != basename
|
|
550
|
-
|
|
551
|
-
raise BadFrontmatter.new(path, "frontmatter name '#{fm["name"]}' does not match basename '#{basename}'")
|
|
552
|
-
end
|
|
553
|
-
|
|
554
|
-
def serialize_for_put(mentry:, path:, strategy:, frontmatter:, body:, content:)
|
|
555
|
-
case mentry.format
|
|
556
|
-
when "markdown", "text"
|
|
557
|
-
bytes = strategy.serialize(frontmatter: frontmatter, body: body.to_s)
|
|
558
|
-
[bytes, frontmatter, body.to_s, nil]
|
|
559
|
-
when "json", "yaml"
|
|
560
|
-
raise UsageError.new("put for #{mentry.format} requires content: or body:") if content.nil? && (body.nil? || body.to_s.empty?)
|
|
561
|
-
|
|
562
|
-
if content.nil?
|
|
563
|
-
# Caller passed raw body; validate by parsing.
|
|
564
|
-
begin
|
|
565
|
-
parsed = strategy.parse(body.to_s, path: path)
|
|
566
|
-
rescue BadFrontmatter => e
|
|
567
|
-
raise BadContent.new(path, "bad_content: #{e.message}")
|
|
568
|
-
end
|
|
569
|
-
eff_content = parsed["content"]
|
|
570
|
-
eff_fm = eff_content.is_a?(Hash) && eff_content["_meta"].is_a?(Hash) ? eff_content["_meta"] : {}
|
|
571
|
-
[body.to_s, eff_fm, body.to_s, eff_content]
|
|
572
|
-
else
|
|
573
|
-
bytes = strategy.serialize(frontmatter: {}, body: "", content: content)
|
|
574
|
-
eff_fm = content.is_a?(Hash) && content["_meta"].is_a?(Hash) ? content["_meta"] : (frontmatter || {})
|
|
575
|
-
[bytes, eff_fm, bytes, content]
|
|
576
|
-
end
|
|
577
|
-
else
|
|
578
|
-
raise UsageError.new("unknown format #{mentry.format.inspect}")
|
|
579
|
-
end
|
|
580
|
-
end
|
|
581
|
-
|
|
582
|
-
# rubocop:disable Metrics/ParameterLists
|
|
583
|
-
def build_envelope(key, mentry, path, fm, body, etag, content: nil)
|
|
584
|
-
# rubocop:enable Metrics/ParameterLists
|
|
585
|
-
env = {
|
|
586
|
-
"protocol" => PROTOCOL,
|
|
587
|
-
"key" => key,
|
|
588
|
-
"zone" => mentry.zone,
|
|
589
|
-
"owner" => mentry.owner,
|
|
590
|
-
"path" => path,
|
|
591
|
-
"format" => mentry.format,
|
|
592
|
-
"frontmatter" => fm,
|
|
593
|
-
"body" => body,
|
|
594
|
-
"etag" => etag,
|
|
595
|
-
"schema_ref" => mentry.schema,
|
|
596
|
-
"uid" => extract_uid(mentry.format, fm, content),
|
|
597
|
-
}
|
|
598
|
-
env["content"] = content unless content.nil?
|
|
599
|
-
env
|
|
600
|
-
end
|
|
601
|
-
|
|
602
|
-
# Pull a Textus UID out of the parsed entry shape per format.
|
|
603
|
-
# markdown: frontmatter["uid"]; json/yaml: content["_meta"]["uid"]; text: nil.
|
|
604
|
-
def extract_uid(format, fm, content)
|
|
605
|
-
case format
|
|
606
|
-
when "markdown"
|
|
607
|
-
v = fm.is_a?(Hash) ? fm["uid"] : nil
|
|
608
|
-
v.is_a?(String) ? v : nil
|
|
609
|
-
when "json", "yaml"
|
|
610
|
-
meta = content.is_a?(Hash) ? content["_meta"] : nil
|
|
611
|
-
v = meta.is_a?(Hash) ? meta["uid"] : nil
|
|
612
|
-
v.is_a?(String) ? v : nil
|
|
613
|
-
end
|
|
113
|
+
@audit_log ||= Store::AuditLog.new(@root)
|
|
614
114
|
end
|
|
615
115
|
end
|
|
616
|
-
# rubocop:enable Metrics/ClassLength
|
|
617
116
|
end
|
data/lib/textus/version.rb
CHANGED
data/lib/textus.rb
CHANGED
|
@@ -1,31 +1,16 @@
|
|
|
1
|
+
require "zeitwerk"
|
|
1
2
|
require_relative "textus/version"
|
|
2
3
|
require_relative "textus/errors"
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
require_relative "textus/dependencies"
|
|
17
|
-
require_relative "textus/store"
|
|
18
|
-
require_relative "textus/store_view"
|
|
19
|
-
require_relative "textus/refresh"
|
|
20
|
-
require_relative "textus/mustache"
|
|
21
|
-
require_relative "textus/projection"
|
|
22
|
-
require_relative "textus/builtin_actions"
|
|
23
|
-
require_relative "textus/publisher"
|
|
24
|
-
require_relative "textus/builder"
|
|
25
|
-
require_relative "textus/proposal"
|
|
26
|
-
require_relative "textus/init"
|
|
27
|
-
require_relative "textus/schema_tools"
|
|
28
|
-
require_relative "textus/migrate_keys"
|
|
29
|
-
require_relative "textus/doctor"
|
|
30
|
-
require_relative "textus/intro"
|
|
31
|
-
require_relative "textus/cli"
|
|
4
|
+
|
|
5
|
+
loader = Zeitwerk::Loader.for_gem
|
|
6
|
+
loader.inflector.inflect(
|
|
7
|
+
"cli" => "CLI",
|
|
8
|
+
"json" => "Json",
|
|
9
|
+
"yaml" => "Yaml",
|
|
10
|
+
)
|
|
11
|
+
loader.ignore(File.expand_path("textus/errors.rb", __dir__))
|
|
12
|
+
loader.setup
|
|
13
|
+
loader.eager_load
|
|
14
|
+
|
|
15
|
+
module Textus
|
|
16
|
+
end
|