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