textus 0.4.0 → 0.5.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -1
  3. data/README.md +13 -11
  4. data/SPEC.md +13 -9
  5. data/docs/architecture.md +63 -28
  6. data/lib/textus/audit_log.rb +46 -11
  7. data/lib/textus/builder.rb +3 -3
  8. data/lib/textus/builtin_actions.rb +5 -5
  9. data/lib/textus/cli/accept.rb +13 -0
  10. data/lib/textus/cli/action.rb +51 -0
  11. data/lib/textus/cli/build.rb +11 -0
  12. data/lib/textus/cli/delete.rb +14 -0
  13. data/lib/textus/cli/deprecated_alias.rb +31 -0
  14. data/lib/textus/cli/deps.rb +10 -0
  15. data/lib/textus/cli/doctor.rb +13 -0
  16. data/lib/textus/cli/extension_group.rb +9 -0
  17. data/lib/textus/cli/extensions.rb +49 -0
  18. data/lib/textus/cli/get.rb +10 -0
  19. data/lib/textus/cli/group.rb +51 -0
  20. data/lib/textus/cli/init.rb +12 -0
  21. data/lib/textus/cli/intro.rb +9 -0
  22. data/lib/textus/cli/key_group.rb +10 -0
  23. data/lib/textus/cli/list.rb +12 -0
  24. data/lib/textus/cli/migrate.rb +41 -0
  25. data/lib/textus/cli/migrate_keys.rb +19 -0
  26. data/lib/textus/cli/mv.rb +20 -0
  27. data/lib/textus/cli/published.rb +9 -0
  28. data/lib/textus/cli/put.rb +48 -0
  29. data/lib/textus/cli/rdeps.rb +10 -0
  30. data/lib/textus/cli/refresh.rb +13 -0
  31. data/lib/textus/cli/schema.rb +10 -0
  32. data/lib/textus/cli/schema_diff.rb +15 -0
  33. data/lib/textus/cli/schema_group.rb +33 -0
  34. data/lib/textus/cli/schema_init.rb +19 -0
  35. data/lib/textus/cli/schema_migrate.rb +19 -0
  36. data/lib/textus/cli/stale.rb +12 -0
  37. data/lib/textus/cli/uid.rb +15 -0
  38. data/lib/textus/cli/verb.rb +62 -0
  39. data/lib/textus/cli/where.rb +10 -0
  40. data/lib/textus/cli.rb +65 -387
  41. data/lib/textus/doctor.rb +64 -33
  42. data/lib/textus/entry/json.rb +6 -4
  43. data/lib/textus/entry/markdown.rb +4 -4
  44. data/lib/textus/entry/text.rb +3 -3
  45. data/lib/textus/entry/yaml.rb +6 -4
  46. data/lib/textus/entry.rb +2 -2
  47. data/lib/textus/errors.rb +2 -2
  48. data/lib/textus/init.rb +1 -1
  49. data/lib/textus/intro.rb +2 -2
  50. data/lib/textus/manifest.rb +11 -221
  51. data/lib/textus/manifest_entry.rb +185 -0
  52. data/lib/textus/migrate_v2.rb +27 -0
  53. data/lib/textus/projection.rb +1 -1
  54. data/lib/textus/proposal.rb +3 -3
  55. data/lib/textus/refresh.rb +7 -7
  56. data/lib/textus/schema_tools.rb +8 -8
  57. data/lib/textus/store/events.rb +31 -0
  58. data/lib/textus/store/mover.rb +118 -0
  59. data/lib/textus/store/staleness.rb +142 -0
  60. data/lib/textus/store/validator.rb +53 -0
  61. data/lib/textus/store.rb +49 -354
  62. data/lib/textus/version.rb +2 -2
  63. data/lib/textus.rb +38 -0
  64. metadata +38 -1
data/lib/textus/store.rb CHANGED
@@ -1,13 +1,8 @@
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
- HOOK_TIMEOUT_SECONDS = 2
10
-
11
6
  attr_reader :root, :manifest, :registry
12
7
 
13
8
  # A Textus UID: 16 lowercase hex chars (SecureRandom.hex(8)). Not a UUID —
@@ -82,18 +77,18 @@ module Textus
82
77
 
83
78
  raw = File.binread(path)
84
79
  parsed = Entry.for_format(mentry.format).parse(raw, path: path)
85
- fm = parsed["frontmatter"]
80
+ meta = parsed["_meta"]
86
81
  content = parsed["content"]
87
- enforce_name_match!(path, fm, mentry.format)
82
+ enforce_name_match!(path, meta, mentry.format)
88
83
  schema = schema_for(mentry.schema)
89
84
  if schema
90
85
  case mentry.format
91
- when "markdown" then schema.validate!(fm)
86
+ when "markdown" then schema.validate!(meta)
92
87
  when "json", "yaml" then schema.validate!(content || {})
93
88
  # text: schema forbidden by manifest validation
94
89
  end
95
90
  end
96
- build_envelope(key, mentry, path, fm, parsed["body"], Etag.for_bytes(raw), content: content)
91
+ build_envelope(key, mentry, path, meta, parsed["body"], Etag.for_bytes(raw), content: content)
97
92
  end
98
93
 
99
94
  def where(key)
@@ -131,30 +126,30 @@ module Textus
131
126
  end
132
127
 
133
128
  # rubocop:disable Metrics/ParameterLists
134
- def put(key, frontmatter: nil, body: nil, content: nil, if_etag: nil, as: Role::DEFAULT, suppress_events: false)
129
+ def put(key, meta: nil, body: nil, content: nil, if_etag: nil, as: Role::DEFAULT, suppress_events: false)
135
130
  # rubocop:enable Metrics/ParameterLists
136
131
  @manifest.validate_key!(key)
137
132
  mentry, path, = @manifest.resolve(key)
138
133
  writers = @manifest.zone_writers(mentry.zone)
139
134
  raise WriteForbidden.new(key, mentry.zone, writers: writers) unless writers.include?(as)
140
135
 
141
- frontmatter ||= {}
136
+ meta ||= {}
142
137
  strategy = Entry.for_format(mentry.format)
143
138
 
144
139
  existing_uid = existing_uid_for(mentry, path)
145
- frontmatter, content = ensure_uid(mentry.format, frontmatter, content, existing_uid)
140
+ meta, content = ensure_uid(mentry.format, meta, content, existing_uid)
146
141
 
147
- bytes, eff_fm, eff_body, eff_content = serialize_for_put(
142
+ bytes, eff_meta, eff_body, eff_content = serialize_for_put(
148
143
  mentry: mentry, path: path, strategy: strategy,
149
- frontmatter: frontmatter, body: body, content: content
144
+ meta: meta, body: body, content: content
150
145
  )
151
146
 
152
- enforce_name_match!(path, eff_fm, mentry.format)
147
+ enforce_name_match!(path, eff_meta, mentry.format)
153
148
 
154
149
  schema = schema_for(mentry.schema)
155
150
  if schema
156
151
  case mentry.format
157
- when "markdown" then schema.validate!(eff_fm)
152
+ when "markdown" then schema.validate!(eff_meta)
158
153
  when "json", "yaml" then schema.validate!(eff_content || {})
159
154
  end
160
155
  end
@@ -166,7 +161,7 @@ module Textus
166
161
  File.binwrite(path, bytes)
167
162
  etag_after = Etag.for_bytes(bytes)
168
163
  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)
164
+ envelope = build_envelope(key, mentry, path, eff_meta, eff_body, etag_after, content: eff_content)
170
165
  fire_event(:put, key: key, envelope: envelope) unless suppress_events
171
166
  envelope
172
167
  end
@@ -186,22 +181,8 @@ module Textus
186
181
  { "protocol" => PROTOCOL, "ok" => true, "key" => key, "deleted" => true }
187
182
  end
188
183
 
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
184
+ def fire_event(event, **)
185
+ Events.new(self).call(event, **)
205
186
  end
206
187
 
207
188
  def accept(key, as:)
@@ -213,121 +194,12 @@ module Textus
213
194
  def published = Dependencies.published_of(@manifest)
214
195
 
215
196
  def validate_all
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
231
-
232
- env = begin
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
255
-
256
- { "protocol" => PROTOCOL, "ok" => violations.empty?, "violations" => violations }
197
+ Validator.new(self).call
257
198
  end
258
199
 
259
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
260
200
  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
276
-
277
- raw = File.binread(path)
278
- parsed = Entry.for_format(mentry.format).parse(raw, path: path)
279
- generated_at = parsed["frontmatter"].dig("generated", "at")
280
- unless generated_at
281
- out << stale_row(mentry, path, "missing generated.at frontmatter")
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
293
-
294
- offender = newest_source_after(gen, gen_time)
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
201
+ Staleness.new(self).call(prefix: prefix, zone: zone)
329
202
  end
330
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
331
203
 
332
204
  # Returns the Textus UID for a key (or nil if the entry has none yet).
333
205
  # Raises UnknownKey if the key doesn't resolve to a real file.
@@ -339,223 +211,56 @@ module Textus
339
211
  # Move an entry from old_key to new_key within the same zone. Preserves
340
212
  # uid (minting one first if absent), validates both keys against the
341
213
  # manifest, refuses to clobber, and writes one mv audit row.
342
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
343
214
  def mv(old_key, new_key, as: Role::DEFAULT, dry_run: false)
344
- @manifest.validate_key!(old_key)
345
- @manifest.validate_key!(new_key)
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
- )
215
+ Mover.new(self).call(old_key, new_key, as: as, dry_run: dry_run)
216
+ end
410
217
 
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
- }
218
+ def audit_log
219
+ @audit_log ||= AuditLog.new(@root)
419
220
  end
420
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
421
221
 
422
222
  private
423
223
 
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
224
  def existing_uid_for(mentry, path)
452
225
  return nil unless File.exist?(path)
453
226
 
454
227
  raw = File.binread(path)
455
228
  parsed = Entry.for_format(mentry.format).parse(raw, path: path)
456
- extract_uid(mentry.format, parsed["frontmatter"], parsed["content"])
229
+ extract_uid(parsed["_meta"])
457
230
  rescue StandardError
458
231
  nil
459
232
  end
460
233
 
461
234
  # 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)
235
+ # Returns [meta, content] possibly mutated.
236
+ def ensure_uid(format, meta, content, existing_uid)
464
237
  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]
238
+ when "markdown", "json", "yaml"
239
+ m = meta.is_a?(Hash) ? meta.dup : {}
240
+ m["uid"] = existing_uid || Store.mint_uid unless m["uid"].is_a?(String) && !m["uid"].empty?
241
+ [m, content]
478
242
  else
479
243
  # text: no uid channel
480
- [frontmatter, content]
244
+ [meta, content]
481
245
  end
482
246
  end
483
247
 
484
- def audit_log
485
- @audit_log ||= AuditLog.new(@root)
486
- end
248
+ def enforce_name_match!(path, meta, format)
249
+ return unless %w[markdown json yaml].include?(format)
250
+ return unless meta.is_a?(Hash) && meta["name"]
487
251
 
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
252
+ ext = Entry.for_format(format).extensions.first
253
+ basename = File.basename(path, ext)
254
+ return if meta["name"] == basename
496
255
 
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
256
+ raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
529
257
  end
530
258
 
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:)
259
+ def serialize_for_put(mentry:, path:, strategy:, meta:, body:, content:)
555
260
  case mentry.format
556
261
  when "markdown", "text"
557
- bytes = strategy.serialize(frontmatter: frontmatter, body: body.to_s)
558
- [bytes, frontmatter, body.to_s, nil]
262
+ bytes = strategy.serialize(meta: meta, body: body.to_s)
263
+ [bytes, meta, body.to_s, nil]
559
264
  when "json", "yaml"
560
265
  raise UsageError.new("put for #{mentry.format} requires content: or body:") if content.nil? && (body.nil? || body.to_s.empty?)
561
266
 
@@ -566,13 +271,12 @@ module Textus
566
271
  rescue BadFrontmatter => e
567
272
  raise BadContent.new(path, "bad_content: #{e.message}")
568
273
  end
274
+ eff_meta = parsed["_meta"]
569
275
  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]
276
+ [body.to_s, eff_meta, body.to_s, eff_content]
572
277
  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]
278
+ bytes = strategy.serialize(meta: meta, body: "", content: content)
279
+ [bytes, meta, bytes, content]
576
280
  end
577
281
  else
578
282
  raise UsageError.new("unknown format #{mentry.format.inspect}")
@@ -580,7 +284,7 @@ module Textus
580
284
  end
581
285
 
582
286
  # rubocop:disable Metrics/ParameterLists
583
- def build_envelope(key, mentry, path, fm, body, etag, content: nil)
287
+ def build_envelope(key, mentry, path, meta, body, etag, content: nil)
584
288
  # rubocop:enable Metrics/ParameterLists
585
289
  env = {
586
290
  "protocol" => PROTOCOL,
@@ -589,29 +293,20 @@ module Textus
589
293
  "owner" => mentry.owner,
590
294
  "path" => path,
591
295
  "format" => mentry.format,
592
- "frontmatter" => fm,
296
+ "_meta" => meta,
593
297
  "body" => body,
594
298
  "etag" => etag,
595
299
  "schema_ref" => mentry.schema,
596
- "uid" => extract_uid(mentry.format, fm, content),
300
+ "uid" => extract_uid(meta),
597
301
  }
598
302
  env["content"] = content unless content.nil?
599
303
  env
600
304
  end
601
305
 
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
306
+ # Pull a Textus UID out of the unified _meta hash.
307
+ def extract_uid(meta)
308
+ v = meta.is_a?(Hash) ? meta["uid"] : nil
309
+ v.is_a?(String) ? v : nil
614
310
  end
615
311
  end
616
- # rubocop:enable Metrics/ClassLength
617
312
  end
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.4.0"
3
- PROTOCOL = "textus/1"
2
+ VERSION = "0.5.0"
3
+ PROTOCOL = "textus/2"
4
4
  end
data/lib/textus.rb CHANGED
@@ -12,8 +12,13 @@ require_relative "textus/entry/text"
12
12
  require_relative "textus/entry"
13
13
  require_relative "textus/schema"
14
14
  require_relative "textus/key_distance"
15
+ require_relative "textus/manifest_entry"
15
16
  require_relative "textus/manifest"
16
17
  require_relative "textus/dependencies"
18
+ require_relative "textus/store/events"
19
+ require_relative "textus/store/validator"
20
+ require_relative "textus/store/staleness"
21
+ require_relative "textus/store/mover"
17
22
  require_relative "textus/store"
18
23
  require_relative "textus/store_view"
19
24
  require_relative "textus/refresh"
@@ -26,6 +31,39 @@ require_relative "textus/proposal"
26
31
  require_relative "textus/init"
27
32
  require_relative "textus/schema_tools"
28
33
  require_relative "textus/migrate_keys"
34
+ require_relative "textus/migrate_v2"
29
35
  require_relative "textus/doctor"
30
36
  require_relative "textus/intro"
37
+ # CLI verb command objects — base class first, then verbs alphabetically.
38
+ require_relative "textus/cli/verb"
39
+ require_relative "textus/cli/deprecated_alias"
40
+ require_relative "textus/cli/accept"
41
+ require_relative "textus/cli/action"
42
+ require_relative "textus/cli/build"
43
+ require_relative "textus/cli/delete"
44
+ require_relative "textus/cli/deps"
45
+ require_relative "textus/cli/doctor"
46
+ require_relative "textus/cli/extensions"
47
+ require_relative "textus/cli/get"
48
+ require_relative "textus/cli/init"
49
+ require_relative "textus/cli/intro"
50
+ require_relative "textus/cli/list"
51
+ require_relative "textus/cli/migrate_keys"
52
+ require_relative "textus/cli/migrate"
53
+ require_relative "textus/cli/mv"
54
+ require_relative "textus/cli/published"
55
+ require_relative "textus/cli/put"
56
+ require_relative "textus/cli/rdeps"
57
+ require_relative "textus/cli/refresh"
58
+ require_relative "textus/cli/schema"
59
+ require_relative "textus/cli/schema_diff"
60
+ require_relative "textus/cli/schema_init"
61
+ require_relative "textus/cli/schema_migrate"
62
+ require_relative "textus/cli/stale"
63
+ require_relative "textus/cli/uid"
64
+ require_relative "textus/cli/where"
65
+ require_relative "textus/cli/group"
66
+ require_relative "textus/cli/key_group"
67
+ require_relative "textus/cli/schema_group"
68
+ require_relative "textus/cli/extension_group"
31
69
  require_relative "textus/cli"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: textus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -99,6 +99,37 @@ files:
99
99
  - lib/textus/builder.rb
100
100
  - lib/textus/builtin_actions.rb
101
101
  - lib/textus/cli.rb
102
+ - lib/textus/cli/accept.rb
103
+ - lib/textus/cli/action.rb
104
+ - lib/textus/cli/build.rb
105
+ - lib/textus/cli/delete.rb
106
+ - lib/textus/cli/deprecated_alias.rb
107
+ - lib/textus/cli/deps.rb
108
+ - lib/textus/cli/doctor.rb
109
+ - lib/textus/cli/extension_group.rb
110
+ - lib/textus/cli/extensions.rb
111
+ - lib/textus/cli/get.rb
112
+ - lib/textus/cli/group.rb
113
+ - lib/textus/cli/init.rb
114
+ - lib/textus/cli/intro.rb
115
+ - lib/textus/cli/key_group.rb
116
+ - lib/textus/cli/list.rb
117
+ - lib/textus/cli/migrate.rb
118
+ - lib/textus/cli/migrate_keys.rb
119
+ - lib/textus/cli/mv.rb
120
+ - lib/textus/cli/published.rb
121
+ - lib/textus/cli/put.rb
122
+ - lib/textus/cli/rdeps.rb
123
+ - lib/textus/cli/refresh.rb
124
+ - lib/textus/cli/schema.rb
125
+ - lib/textus/cli/schema_diff.rb
126
+ - lib/textus/cli/schema_group.rb
127
+ - lib/textus/cli/schema_init.rb
128
+ - lib/textus/cli/schema_migrate.rb
129
+ - lib/textus/cli/stale.rb
130
+ - lib/textus/cli/uid.rb
131
+ - lib/textus/cli/verb.rb
132
+ - lib/textus/cli/where.rb
102
133
  - lib/textus/dependencies.rb
103
134
  - lib/textus/doctor.rb
104
135
  - lib/textus/entry.rb
@@ -114,7 +145,9 @@ files:
114
145
  - lib/textus/intro.rb
115
146
  - lib/textus/key_distance.rb
116
147
  - lib/textus/manifest.rb
148
+ - lib/textus/manifest_entry.rb
117
149
  - lib/textus/migrate_keys.rb
150
+ - lib/textus/migrate_v2.rb
118
151
  - lib/textus/mustache.rb
119
152
  - lib/textus/projection.rb
120
153
  - lib/textus/proposal.rb
@@ -124,6 +157,10 @@ files:
124
157
  - lib/textus/schema.rb
125
158
  - lib/textus/schema_tools.rb
126
159
  - lib/textus/store.rb
160
+ - lib/textus/store/events.rb
161
+ - lib/textus/store/mover.rb
162
+ - lib/textus/store/staleness.rb
163
+ - lib/textus/store/validator.rb
127
164
  - lib/textus/store_view.rb
128
165
  - lib/textus/version.rb
129
166
  homepage: https://github.com/patrick204nqh/textus