textus 0.14.4 → 0.18.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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +14 -14
  3. data/CHANGELOG.md +378 -0
  4. data/README.md +13 -9
  5. data/SPEC.md +13 -10
  6. data/docs/conventions.md +11 -0
  7. data/lib/textus/application/context.rb +25 -7
  8. data/lib/textus/application/reads/audit.rb +1 -1
  9. data/lib/textus/application/reads/blame.rb +3 -1
  10. data/lib/textus/application/reads/deps.rb +1 -1
  11. data/lib/textus/application/reads/freshness.rb +12 -3
  12. data/lib/textus/application/reads/get.rb +38 -33
  13. data/lib/textus/application/reads/get_or_refresh.rb +51 -0
  14. data/lib/textus/application/reads/list.rb +3 -1
  15. data/lib/textus/application/reads/published.rb +1 -1
  16. data/lib/textus/application/reads/rdeps.rb +1 -1
  17. data/lib/textus/application/reads/schema_envelope.rb +3 -1
  18. data/lib/textus/application/reads/stale.rb +1 -1
  19. data/lib/textus/application/reads/uid.rb +1 -1
  20. data/lib/textus/application/reads/validate_all.rb +6 -1
  21. data/lib/textus/application/reads/validator.rb +84 -0
  22. data/lib/textus/application/reads/where.rb +4 -1
  23. data/lib/textus/application/refresh/all.rb +8 -1
  24. data/lib/textus/application/refresh/orchestrator.rb +11 -3
  25. data/lib/textus/application/refresh/worker.rb +27 -20
  26. data/lib/textus/application/writes/accept.rb +12 -12
  27. data/lib/textus/application/writes/build.rb +3 -4
  28. data/lib/textus/application/writes/delete.rb +10 -15
  29. data/lib/textus/application/writes/envelope_io.rb +106 -0
  30. data/lib/textus/application/writes/mv.rb +25 -27
  31. data/lib/textus/application/writes/publish.rb +8 -9
  32. data/lib/textus/application/writes/put.rb +12 -16
  33. data/lib/textus/application/writes/reject.rb +10 -10
  34. data/lib/textus/builder/pipeline.rb +8 -1
  35. data/lib/textus/cli/group/hook.rb +1 -3
  36. data/lib/textus/cli/group/key.rb +1 -4
  37. data/lib/textus/cli/group/refresh.rb +1 -2
  38. data/lib/textus/cli/group/rule.rb +1 -3
  39. data/lib/textus/cli/group/schema.rb +1 -5
  40. data/lib/textus/cli/group.rb +12 -16
  41. data/lib/textus/cli/verb/accept.rb +3 -1
  42. data/lib/textus/cli/verb/audit.rb +3 -1
  43. data/lib/textus/cli/verb/blame.rb +3 -1
  44. data/lib/textus/cli/verb/build.rb +4 -2
  45. data/lib/textus/cli/verb/delete.rb +3 -1
  46. data/lib/textus/cli/verb/deps.rb +3 -1
  47. data/lib/textus/cli/verb/doctor.rb +2 -0
  48. data/lib/textus/cli/verb/freshness.rb +3 -1
  49. data/lib/textus/cli/verb/get.rb +3 -1
  50. data/lib/textus/cli/verb/hook_run.rb +3 -0
  51. data/lib/textus/cli/verb/hooks.rb +3 -0
  52. data/lib/textus/cli/verb/init.rb +2 -0
  53. data/lib/textus/cli/verb/intro.rb +2 -0
  54. data/lib/textus/cli/verb/key_normalize.rb +3 -0
  55. data/lib/textus/cli/verb/list.rb +3 -1
  56. data/lib/textus/cli/verb/mv.rb +4 -1
  57. data/lib/textus/cli/verb/published.rb +3 -1
  58. data/lib/textus/cli/verb/put.rb +3 -1
  59. data/lib/textus/cli/verb/rdeps.rb +3 -1
  60. data/lib/textus/cli/verb/refresh.rb +1 -1
  61. data/lib/textus/cli/verb/refresh_stale.rb +4 -1
  62. data/lib/textus/cli/verb/reject.rb +3 -1
  63. data/lib/textus/cli/verb/rule_explain.rb +4 -1
  64. data/lib/textus/cli/verb/rule_list.rb +3 -0
  65. data/lib/textus/cli/verb/schema.rb +4 -1
  66. data/lib/textus/cli/verb/schema_diff.rb +3 -0
  67. data/lib/textus/cli/verb/schema_init.rb +3 -0
  68. data/lib/textus/cli/verb/schema_migrate.rb +3 -0
  69. data/lib/textus/cli/verb/uid.rb +4 -1
  70. data/lib/textus/cli/verb/where.rb +3 -1
  71. data/lib/textus/cli/verb.rb +30 -0
  72. data/lib/textus/cli.rb +40 -35
  73. data/lib/textus/doctor/check/audit_log.rb +1 -1
  74. data/lib/textus/doctor/check/hooks.rb +3 -1
  75. data/lib/textus/doctor/check/intake_registration.rb +3 -3
  76. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  77. data/lib/textus/doctor/check/sentinels.rb +2 -2
  78. data/lib/textus/domain/freshness/evaluator.rb +1 -1
  79. data/lib/textus/domain/freshness/policy.rb +1 -1
  80. data/lib/textus/domain/freshness/verdict.rb +1 -1
  81. data/lib/textus/domain/freshness.rb +40 -0
  82. data/lib/textus/domain/policy/predicates/schema_valid.rb +2 -2
  83. data/lib/textus/{store → domain}/sentinel.rb +1 -1
  84. data/lib/textus/{store → domain}/staleness/generator_check.rb +1 -1
  85. data/lib/textus/{store → domain}/staleness/intake_check.rb +1 -1
  86. data/lib/textus/{store → domain}/staleness.rb +1 -1
  87. data/lib/textus/entry/json.rb +1 -1
  88. data/lib/textus/entry/markdown.rb +1 -1
  89. data/lib/textus/entry/yaml.rb +1 -1
  90. data/lib/textus/envelope.rb +7 -3
  91. data/lib/textus/errors.rb +19 -0
  92. data/lib/textus/hooks/builtin.rb +6 -6
  93. data/lib/textus/hooks/dispatcher.rb +17 -9
  94. data/lib/textus/hooks/loader.rb +20 -17
  95. data/lib/textus/hooks/registry.rb +4 -0
  96. data/lib/textus/{store → infra}/audit_log.rb +1 -1
  97. data/lib/textus/infra/audit_subscriber.rb +43 -0
  98. data/lib/textus/infra/publisher.rb +3 -3
  99. data/lib/textus/infra/storage/file_store.rb +26 -0
  100. data/lib/textus/init.rb +11 -9
  101. data/lib/textus/manifest/resolution.rb +5 -0
  102. data/lib/textus/manifest.rb +4 -3
  103. data/lib/textus/migrate_keys.rb +1 -1
  104. data/lib/textus/operations.rb +84 -17
  105. data/lib/textus/projection.rb +16 -11
  106. data/lib/textus/refresh.rb +1 -1
  107. data/lib/textus/schema/tools.rb +5 -5
  108. data/lib/textus/schemas.rb +46 -0
  109. data/lib/textus/store.rb +12 -49
  110. data/lib/textus/uid.rb +18 -0
  111. data/lib/textus/version.rb +1 -1
  112. data/lib/textus.rb +17 -1
  113. metadata +15 -13
  114. data/lib/textus/hooks/dsl.rb +0 -11
  115. data/lib/textus/operations/reads.rb +0 -39
  116. data/lib/textus/operations/refresh.rb +0 -27
  117. data/lib/textus/operations/writes.rb +0 -21
  118. data/lib/textus/store/reader.rb +0 -69
  119. data/lib/textus/store/validator.rb +0 -82
  120. data/lib/textus/store/writer.rb +0 -102
@@ -7,22 +7,22 @@ module Textus
7
7
  module Hooks
8
8
  module Builtin
9
9
  # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
10
- def self.register_all
11
- Textus.on(:resolve_intake, :json) do |store:, config:, args:|
10
+ def self.register_all(registry)
11
+ registry.on(:resolve_intake, :json) do |store:, config:, args:|
12
12
  _ = store
13
13
  _ = args
14
14
  data = JSON.parse(config["bytes"].to_s)
15
15
  { _meta: {}, body: YAML.dump(data) }
16
16
  end
17
17
 
18
- Textus.on(:resolve_intake, :csv) do |store:, config:, args:|
18
+ registry.on(:resolve_intake, :csv) do |store:, config:, args:|
19
19
  _ = store
20
20
  _ = args
21
21
  rows = CSV.parse(config["bytes"].to_s, headers: true).map(&:to_h)
22
22
  { _meta: {}, body: YAML.dump(rows) }
23
23
  end
24
24
 
25
- Textus.on(:resolve_intake, :"markdown-links") do |store:, config:, args:|
25
+ registry.on(:resolve_intake, :"markdown-links") do |store:, config:, args:|
26
26
  _ = store
27
27
  _ = args
28
28
  links = config["bytes"].to_s.scan(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}).map do |text, href|
@@ -31,7 +31,7 @@ module Textus
31
31
  { _meta: {}, body: YAML.dump(links) }
32
32
  end
33
33
 
34
- Textus.on(:resolve_intake, :"ical-events") do |store:, config:, args:|
34
+ registry.on(:resolve_intake, :"ical-events") do |store:, config:, args:|
35
35
  _ = store
36
36
  _ = args
37
37
  events = []
@@ -50,7 +50,7 @@ module Textus
50
50
  { _meta: {}, body: YAML.dump(events) }
51
51
  end
52
52
 
53
- Textus.on(:resolve_intake, :rss) do |store:, config:, args:|
53
+ registry.on(:resolve_intake, :rss) do |store:, config:, args:|
54
54
  _ = store
55
55
  _ = args
56
56
  doc = REXML::Document.new(config["bytes"].to_s)
@@ -7,9 +7,15 @@ module Textus
7
7
  class Dispatcher
8
8
  HOOK_TIMEOUT_SECONDS = 2
9
9
 
10
- def initialize(audit_log:)
11
- @audit_log = audit_log
10
+ def initialize
12
11
  @subscribers = Hash.new { |h, k| h[k] = [] }
12
+ @error_handlers = []
13
+ end
14
+
15
+ # Register an error callback invoked when a user hook raises.
16
+ # Used by Infra::AuditSubscriber to record an "event_error" audit row.
17
+ def on_error(&block)
18
+ @error_handlers << block
13
19
  end
14
20
 
15
21
  def subscribe(event, name, keys: nil, &block)
@@ -31,13 +37,15 @@ module Textus
31
37
  accepted = filter_kwargs(sub[:callable], kwargs)
32
38
  Timeout.timeout(HOOK_TIMEOUT_SECONDS) { sub[:callable].call(**accepted) }
33
39
  rescue StandardError => e
34
- extras = { "event" => event.to_s, "hook" => sub[:name].to_s, "error" => "#{e.class}: #{e.message}" }
35
- extras["target_key"] = kwargs[:target_key] if kwargs.key?(:target_key)
36
- extras["pending_key"] = kwargs[:pending_key] if kwargs.key?(:pending_key)
37
- @audit_log.append(
38
- role: "runner", verb: "event_error", key: key,
39
- etag_before: nil, etag_after: nil, extras: extras
40
- )
40
+ notify_error(event, sub, key, kwargs, e)
41
+ end
42
+
43
+ def notify_error(event, sub, key, kwargs, error)
44
+ @error_handlers.each do |handler|
45
+ handler.call(event: event, hook: sub[:name], key: key, kwargs: kwargs, error: error)
46
+ rescue StandardError => e
47
+ warn "[textus] error handler failed: #{e.class}: #{e.message}"
48
+ end
41
49
  end
42
50
 
43
51
  # Passes only the kwargs a hook block declares. Lets us extend event
@@ -1,25 +1,28 @@
1
1
  module Textus
2
2
  module Hooks
3
- module Loader
4
- THREAD_REGISTRY_KEY = :__textus_active_registry__
5
- private_constant :THREAD_REGISTRY_KEY
6
-
7
- def self.with_registry(registry)
8
- prev = Thread.current[THREAD_REGISTRY_KEY]
9
- Thread.current[THREAD_REGISTRY_KEY] = registry
10
- yield
11
- ensure
12
- Thread.current[THREAD_REGISTRY_KEY] = prev
3
+ class Loader
4
+ def initialize(registry:)
5
+ @registry = registry
13
6
  end
14
7
 
15
- def self.current_registry
16
- Thread.current[THREAD_REGISTRY_KEY] or
17
- raise UsageError.new("no active registry; hook code must be loaded by a Store")
8
+ def load_dir(dir)
9
+ return unless File.directory?(dir)
10
+
11
+ # Discard any leftover blocks from a prior partial load.
12
+ Textus.drain_hook_blocks
13
+
14
+ Dir.glob(File.join(dir, "**/*.rb")).sort.each do |f| # rubocop:disable Lint/RedundantDirGlobSort
15
+ load(f)
16
+ rescue StandardError, ScriptError => e
17
+ raise UsageError.new("failed loading hook #{File.basename(f)}: #{e.class}: #{e.message}")
18
+ end
19
+
20
+ Textus.drain_hook_blocks.each do |blk|
21
+ blk.call(@registry)
22
+ rescue StandardError, ScriptError => e
23
+ raise UsageError.new("failed registering hook: #{e.class}: #{e.message}")
24
+ end
18
25
  end
19
26
  end
20
27
  end
21
-
22
- # Public DSL
23
- def self.with_registry(registry, &) = Hooks::Loader.with_registry(registry, &)
24
- def self.current_registry = Hooks::Loader.current_registry
25
28
  end
@@ -28,6 +28,10 @@ module Textus
28
28
  @dispatcher = dispatcher
29
29
  end
30
30
 
31
+ def on(event, name, keys: nil, &)
32
+ register(event, name, keys: keys, &)
33
+ end
34
+
31
35
  def register(event, name, keys: nil, &blk)
32
36
  event_sym = event.to_sym
33
37
  spec = EVENTS[event_sym] or raise UsageError.new("unknown event: #{event}")
@@ -2,7 +2,7 @@ require "json"
2
2
  require "time"
3
3
 
4
4
  module Textus
5
- class Store
5
+ module Infra
6
6
  class AuditLog
7
7
  def initialize(root)
8
8
  @path = File.join(root, "audit.log")
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Infra
5
+ # Writes an "event_error" audit row when a user hook raises during
6
+ # Hooks::Dispatcher publish. Attached at Store boot.
7
+ #
8
+ # Integration: uses Hooks::Dispatcher#on_error callback (chosen over a
9
+ # synthetic :hook_error event because the dispatcher already owns the
10
+ # rescue and the failure is a dispatcher-internal concern, not a domain
11
+ # event subscribers should be able to filter by key glob).
12
+ #
13
+ # NOTE (0.16 scope): lifecycle audit rows for verb: "put" / "delete" /
14
+ # "rename" are still written directly by Store::Writer and
15
+ # Application::Writes::Mv. Moving those into this subscriber requires
16
+ # event payloads to carry etag_before/etag_after across many write paths;
17
+ # that is properly a 0.18 port-extraction concern.
18
+ class AuditSubscriber
19
+ def initialize(audit_log)
20
+ @audit_log = audit_log
21
+ end
22
+
23
+ def attach(bus)
24
+ bus.on_error do |event:, hook:, key:, kwargs:, error:|
25
+ record_error(event: event, hook: hook, key: key, kwargs: kwargs, error: error)
26
+ end
27
+ self
28
+ end
29
+
30
+ private
31
+
32
+ def record_error(event:, hook:, key:, kwargs:, error:)
33
+ extras = { "event" => event.to_s, "hook" => hook.to_s, "error" => "#{error.class}: #{error.message}" }
34
+ extras["target_key"] = kwargs[:target_key] if kwargs.key?(:target_key)
35
+ extras["pending_key"] = kwargs[:pending_key] if kwargs.key?(:pending_key)
36
+ @audit_log.append(
37
+ role: "runner", verb: "event_error", key: key,
38
+ etag_before: nil, etag_after: nil, extras: extras
39
+ )
40
+ end
41
+ end
42
+ end
43
+ end
@@ -6,7 +6,7 @@ module Textus
6
6
  # Publish = copy + sentinel. The in-store file is already the consumer-shaped
7
7
  # artifact; no parsing or stripping.
8
8
  #
9
- # Sentinel I/O is delegated to Store::Sentinel. Sentinels live under
9
+ # Sentinel I/O is delegated to Textus::Domain::Sentinel. Sentinels live under
10
10
  # `<store_root>/sentinels/` and mirror the target's repo-relative layout so
11
11
  # consumer directories aren't polluted with `.textus-managed.json` siblings.
12
12
  module Publisher
@@ -15,7 +15,7 @@ module Textus
15
15
  refuse_if_unmanaged(target, store_root)
16
16
  File.delete(target) if File.symlink?(target)
17
17
  FileUtils.cp(source, target)
18
- Store::Sentinel.write!(target: target, source: source, store_root: store_root)
18
+ Textus::Domain::Sentinel.write!(target: target, source: source, store_root: store_root)
19
19
  end
20
20
 
21
21
  def self.refuse_if_unmanaged(target, store_root)
@@ -26,7 +26,7 @@ module Textus
26
26
  end
27
27
 
28
28
  def self.managed?(target, store_root)
29
- File.exist?(Store::Sentinel.sentinel_path(target, store_root))
29
+ File.exist?(Textus::Domain::Sentinel.sentinel_path(target, store_root))
30
30
  end
31
31
  end
32
32
  end
@@ -0,0 +1,26 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ module Infra
5
+ module Storage
6
+ # Pure filesystem I/O port. Wraps File/FileUtils/Etag with no knowledge
7
+ # of envelopes, entries, schemas, or audit.
8
+ class FileStore
9
+ def read(path) = File.binread(path)
10
+
11
+ def write(path, bytes)
12
+ FileUtils.mkdir_p(File.dirname(path))
13
+ File.binwrite(path, bytes)
14
+ end
15
+
16
+ # Raises Errno::ENOENT if absent — mirrors File.delete and matches the
17
+ # semantics used by Store::Writer (which guards with File.exist? first).
18
+ def delete(path) = File.delete(path)
19
+
20
+ def exists?(path) = File.exist?(path)
21
+
22
+ def etag(path) = Etag.for_file(path)
23
+ end
24
+ end
25
+ end
26
+ end
data/lib/textus/init.rb CHANGED
@@ -28,17 +28,19 @@ module Textus
28
28
  ## DSL
29
29
 
30
30
  ```ruby
31
- Textus.on(:resolve_intake, :my_source) do |config:, args:, **|
32
- { _meta: { "last_refreshed_at" => Time.now.utc.iso8601 }, body: "…" }
33
- end
31
+ Textus.hook do |reg|
32
+ reg.on(:resolve_intake, :my_source) do |config:, args:, **|
33
+ { _meta: { "last_refreshed_at" => Time.now.utc.iso8601 }, body: "…" }
34
+ end
34
35
 
35
- Textus.on(:transform_rows, :my_source) { |rows:, **| rows.map { |r| r.merge(processed: true) } }
36
- Textus.on(:validate, :my_check) { |store:, **| [] }
37
- Textus.on(:entry_put, :my_listener, keys: ["working.*"]) { |key:, envelope:, **| }
36
+ reg.on(:transform_rows, :my_source) { |rows:, **| rows.map { |r| r.merge(processed: true) } }
37
+ reg.on(:validate, :my_check) { |store:, **| [] }
38
+ reg.on(:entry_put, :my_listener, keys: ["working.*"]) { |key:, envelope:, **| }
38
39
 
39
- # Run a side-effect every time textus writes a file to your repo:
40
- Textus.on(:file_published, :notify) do |key:, target:, **|
41
- warn "wrote \#{target} (from \#{key})"
40
+ # Run a side-effect every time textus writes a file to your repo:
41
+ reg.on(:file_published, :notify) do |key:, target:, **|
42
+ warn "wrote \#{target} (from \#{key})"
43
+ end
42
44
  end
43
45
  ```
44
46
 
@@ -0,0 +1,5 @@
1
+ module Textus
2
+ class Manifest
3
+ Resolution = Data.define(:entry, :path, :remaining)
4
+ end
5
+ end
@@ -1,5 +1,6 @@
1
1
  require "yaml"
2
2
  require_relative "manifest/schema"
3
+ require_relative "manifest/resolution"
3
4
 
4
5
  module Textus
5
6
  class Manifest
@@ -86,7 +87,7 @@ module Textus
86
87
  rules.for(key)
87
88
  end
88
89
 
89
- # Returns [Manifest::Entry, resolved_path, remaining_segments]
90
+ # Returns a Resolution(entry:, path:, remaining:) value object.
90
91
  def resolve(key)
91
92
  validate_key!(key)
92
93
  segments = key.split(".")
@@ -101,7 +102,7 @@ module Textus
101
102
  remaining = segments[esegs.length..]
102
103
  if remaining.empty?
103
104
  path = resolve_leaf_path(entry)
104
- [entry, path, []]
105
+ Resolution.new(entry: entry, path: path, remaining: [])
105
106
  else
106
107
  raise UnknownKey.new(key, suggestions: suggestions_for(key)) unless entry.nested
107
108
 
@@ -111,7 +112,7 @@ module Textus
111
112
  primary_ext = Textus::Entry.for_format(entry.format).extensions.first
112
113
  File.join(@root, "zones", entry.path, *remaining) + primary_ext
113
114
  end
114
- [entry, path, remaining]
115
+ Resolution.new(entry: entry, path: path, remaining: remaining)
115
116
  end
116
117
  end
117
118
 
@@ -112,7 +112,7 @@ module Textus
112
112
  # ------------------------------------------------------------------
113
113
 
114
114
  def apply!(store, renames)
115
- audit = Store::AuditLog.new(store.root)
115
+ audit = Textus::Infra::AuditLog.new(store.root)
116
116
  renames.each do |r|
117
117
  # Bottom-up order means a child's ancestors haven't moved yet, so
118
118
  # `from`/`to` are valid as-recorded. The audit `key` reflects the
@@ -1,21 +1,20 @@
1
1
  module Textus
2
2
  # Single canonical entrypoint for invoking application use-cases against a
3
- # store. Mirrors the directory structure under `lib/textus/application/`:
3
+ # store. Public surface is flat one method per use case:
4
4
  #
5
5
  # ops = Textus::Operations.for(store, role: "agent")
6
- # ops.writes.put.call(key, body: "...")
7
- # ops.reads.get.call(key)
8
- # ops.refresh.worker.call(key)
9
- #
10
- # Replaces the prior `Textus::Composition` module (deleted in v0.12.2).
6
+ # ops.put(key, body: "...")
7
+ # ops.get(key) # pure read
8
+ # ops.get_or_refresh(key) # read + refresh-on-stale
9
+ # ops.refresh(key) # synchronous worker refresh
10
+ # ops.refresh_all(prefix: ..., zone: ...)
11
11
  class Operations
12
- def self.for(store, role: Role::DEFAULT, correlation_id: nil, dry_run: false, bypass_freshness: false)
12
+ def self.for(store, role: Role::DEFAULT, correlation_id: nil, dry_run: false)
13
13
  ctx = Application::Context.new(
14
14
  store: store,
15
15
  role: role,
16
16
  correlation_id: correlation_id,
17
17
  dry_run: dry_run,
18
- bypass_freshness: bypass_freshness,
19
18
  )
20
19
  new(ctx)
21
20
  end
@@ -26,20 +25,88 @@ module Textus
26
25
  @ctx = ctx
27
26
  end
28
27
 
29
- def writes
30
- @writes ||= Writes.new(@ctx)
31
- end
28
+ def with_role(role) = self.class.new(@ctx.with_role(role))
29
+
30
+ # writes
31
+ def put(...) = put_op.call(...)
32
+ def delete(...) = delete_op.call(...)
33
+ def mv(...) = mv_op.call(...)
34
+ def accept(...) = accept_op.call(...)
35
+ def reject(...) = reject_op.call(...)
36
+ def build(...) = build_op.call(...)
37
+ def publish(...) = publish_op.call(...)
38
+
39
+ # reads
40
+ def get(...) = get_op.call(...)
41
+ def get_or_refresh(...) = get_or_refresh_op.call(...)
42
+ def list(...) = list_op.call(...)
43
+ def where(...) = where_op.call(...)
44
+ def uid(...) = uid_op.call(...)
45
+ def schema_envelope(...) = schema_envelope_op.call(...)
46
+ def deps(...) = deps_op.call(...)
47
+ def rdeps(...) = rdeps_op.call(...)
48
+ def published(...) = published_op.call(...)
49
+ def stale(...) = stale_op.call(...)
50
+ def audit(...) = audit_op.call(...)
51
+ def blame(...) = blame_op.call(...)
52
+ def policy_explain(...) = policy_explain_op.call(...)
53
+ def freshness(...) = freshness_op.call(...)
54
+ def validate_all(...) = validate_all_op.call(...)
55
+
56
+ # refresh
57
+ def refresh(key) = refresh_worker_op.run(key)
58
+ def refresh_all(**) = Application::Refresh::All.call(@ctx, **)
59
+
60
+ private
32
61
 
33
- def reads
34
- @reads ||= Reads.new(@ctx)
62
+ def envelope_io
63
+ @envelope_io ||= Application::Writes::EnvelopeIO.new(
64
+ file_store: @ctx.file_store,
65
+ manifest: @ctx.manifest,
66
+ schemas: @ctx.schemas,
67
+ audit_log: @ctx.audit_log,
68
+ ctx: @ctx,
69
+ )
35
70
  end
36
71
 
37
- def refresh
38
- @refresh ||= Refresh.new(@ctx)
72
+ def put_op = @put_op ||= Application::Writes::Put.new(ctx: @ctx, envelope_io: envelope_io)
73
+ def delete_op = @delete_op ||= Application::Writes::Delete.new(ctx: @ctx, envelope_io: envelope_io)
74
+ def mv_op = @mv_op ||= Application::Writes::Mv.new(ctx: @ctx, envelope_io: envelope_io)
75
+ def accept_op = @accept_op ||= Application::Writes::Accept.new(ctx: @ctx, envelope_io: envelope_io)
76
+ def reject_op = @reject_op ||= Application::Writes::Reject.new(ctx: @ctx, envelope_io: envelope_io)
77
+ def build_op = @build_op ||= Application::Writes::Build.new(ctx: @ctx)
78
+ def publish_op = @publish_op ||= Application::Writes::Publish.new(ctx: @ctx)
79
+
80
+ def get_op = @get_op ||= Application::Reads::Get.new(ctx: @ctx) # rubocop:disable Naming/AccessorMethodName
81
+
82
+ def get_or_refresh_op # rubocop:disable Naming/AccessorMethodName
83
+ @get_or_refresh_op ||= Application::Reads::GetOrRefresh.new(ctx: @ctx, get: get_op,
84
+ orchestrator: orchestrator_op)
39
85
  end
40
86
 
41
- def with_role(role)
42
- self.class.new(@ctx.with_role(role))
87
+ def list_op = @list_op ||= Application::Reads::List.new(ctx: @ctx)
88
+ def where_op = @where_op ||= Application::Reads::Where.new(ctx: @ctx)
89
+ def uid_op = @uid_op ||= Application::Reads::Uid.new(ctx: @ctx)
90
+ def schema_envelope_op = @schema_envelope_op ||= Application::Reads::SchemaEnvelope.new(ctx: @ctx)
91
+ def deps_op = @deps_op ||= Application::Reads::Deps.new(ctx: @ctx)
92
+ def rdeps_op = @rdeps_op ||= Application::Reads::Rdeps.new(ctx: @ctx)
93
+ def published_op = @published_op ||= Application::Reads::Published.new(ctx: @ctx)
94
+ def stale_op = @stale_op ||= Application::Reads::Stale.new(ctx: @ctx)
95
+ def audit_op = @audit_op ||= Application::Reads::Audit.new(ctx: @ctx)
96
+ def blame_op = @blame_op ||= Application::Reads::Blame.new(ctx: @ctx)
97
+ def policy_explain_op = @policy_explain_op ||= Application::Reads::PolicyExplain.new(ctx: @ctx)
98
+ def freshness_op = @freshness_op ||= Application::Reads::Freshness.new(ctx: @ctx)
99
+ def validate_all_op = @validate_all_op ||= Application::Reads::ValidateAll.new(ctx: @ctx)
100
+
101
+ def refresh_worker_op = @refresh_worker_op ||= Application::Refresh::Worker.new(ctx: @ctx, envelope_io: envelope_io)
102
+
103
+ def orchestrator_op
104
+ @orchestrator_op ||= Application::Refresh::Orchestrator.new(
105
+ worker: refresh_worker_op,
106
+ store_root: @ctx.store.root,
107
+ store: @ctx.store,
108
+ role: @ctx.role,
109
+ )
43
110
  end
44
111
  end
45
112
  end
@@ -6,10 +6,18 @@ module Textus
6
6
  MAX_LIMIT = 1000
7
7
  REDUCER_TIMEOUT_SECONDS = 2
8
8
 
9
- def initialize(store, spec, bypass_freshness: false)
10
- @store = store
11
- @spec = spec || {}
12
- @bypass_freshness = bypass_freshness
9
+ # `reader` — a callable `->(key) { envelope_or_nil }`. Caller picks
10
+ # semantics: pure read (`ops.get`) for materialization paths;
11
+ # `ops.get_or_refresh` if you want refresh-on-stale.
12
+ # `lister` — a callable `->(prefix:) { [ { "key" => ... }, ... ] }`.
13
+ # `transform_resolver` — a callable `->(name) { callable_or_raise }`.
14
+ # `transform_context` — `Application::Context` handed to the transform reducer.
15
+ def initialize(reader:, spec:, lister:, transform_resolver:, transform_context:)
16
+ @reader = reader
17
+ @spec = spec || {}
18
+ @lister = lister
19
+ @transform_resolver = transform_resolver
20
+ @transform_context = transform_context
13
21
  @limit = (@spec["limit"] || MAX_LIMIT).to_i
14
22
  raise InvalidProjection.new("limit #{@limit} exceeds max #{MAX_LIMIT}") if @limit > MAX_LIMIT
15
23
  end
@@ -17,9 +25,8 @@ module Textus
17
25
  def run
18
26
  keys = collect_keys
19
27
  explicit_pluck = !@spec["pluck"].nil? && @spec["pluck"] != "*"
20
- ops = Operations.for(@store, bypass_freshness: @bypass_freshness)
21
28
  rows = keys.map do |key|
22
- env = ops.reads.get.call(key)
29
+ env = @reader.call(key)
23
30
  row = pluck(env.meta, env.body)
24
31
  explicit_pluck ? row : row.merge("_key" => key)
25
32
  end
@@ -41,10 +48,9 @@ module Textus
41
48
 
42
49
  def apply_reducer(rows)
43
50
  name = @spec["transform"] or return rows
44
- callable = @store.registry.rpc_callable(:transform_rows, name)
45
- view = Application::Context.system(@store)
51
+ callable = @transform_resolver.call(name)
46
52
  Timeout.timeout(REDUCER_TIMEOUT_SECONDS) do
47
- callable.call(store: view, rows: rows, config: @spec["transform_config"] || {})
53
+ callable.call(store: @transform_context, rows: rows, config: @spec["transform_config"] || {})
48
54
  end
49
55
  rescue Timeout::Error
50
56
  raise UsageError.new("transform_rows '#{name}' exceeded #{REDUCER_TIMEOUT_SECONDS}s timeout")
@@ -52,8 +58,7 @@ module Textus
52
58
 
53
59
  def collect_keys
54
60
  prefixes = Array(@spec["select"])
55
- ops = Operations.for(@store, bypass_freshness: @bypass_freshness)
56
- prefixes.flat_map { |p| ops.reads.list.call(prefix: p).map { |row| row["key"] } }.uniq
61
+ prefixes.flat_map { |p| @lister.call(prefix: p).map { |row| row["key"] } }.uniq
57
62
  end
58
63
 
59
64
  def pluck(frontmatter, _body)
@@ -1,7 +1,7 @@
1
1
  module Textus
2
2
  module Refresh
3
3
  def self.call(store, key, as:)
4
- Textus::Operations.for(store, role: as).refresh.worker.run(key)
4
+ Textus::Operations.for(store, role: as).refresh(key)
5
5
  end
6
6
 
7
7
  def self.refresh_stale(store, prefix: nil, zone: nil, as: "runner")
@@ -6,7 +6,7 @@ module Textus
6
6
  module Tools
7
7
  # textus schema init NAME --from=KEY → infer YAML schema from an entry's frontmatter
8
8
  def self.init(store, name:, from:)
9
- env = Textus::Operations.for(store).reads.get.call(from)
9
+ env = Textus::Operations.for(store).get(from)
10
10
  meta = env.meta
11
11
  schema = {
12
12
  "name" => name,
@@ -25,7 +25,7 @@ module Textus
25
25
  schema = load_schema(store, name)
26
26
  drift = []
27
27
  store.manifest.enumerate.each do |row|
28
- env = Textus::Operations.for(store).reads.get.call(row[:key])
28
+ env = Textus::Operations.for(store).get(row[:key])
29
29
  begin
30
30
  schema.validate!(env.meta)
31
31
  rescue SchemaViolation => e
@@ -52,7 +52,7 @@ module Textus
52
52
  ops = Textus::Operations.for(store, role: "human")
53
53
  touched = []
54
54
  store.manifest.enumerate.each do |row|
55
- env = ops.reads.get.call(row[:key])
55
+ env = ops.get(row[:key])
56
56
  meta = env.meta.dup
57
57
  changed = false
58
58
  renames.each do |old, new|
@@ -63,7 +63,7 @@ module Textus
63
63
  end
64
64
  next unless changed
65
65
 
66
- ops.writes.put.call(row[:key], meta: meta, body: env.body)
66
+ ops.put(row[:key], meta: meta, body: env.body)
67
67
  touched << row[:key]
68
68
  end
69
69
  { "protocol" => PROTOCOL, "migrated" => touched, "renames" => renames }
@@ -81,7 +81,7 @@ module Textus
81
81
  end
82
82
 
83
83
  def self.load_schema(store, name)
84
- store.schema_for(name)
84
+ store.schemas.fetch(name)
85
85
  rescue IoError
86
86
  raise UsageError.new("schema not found: #{name}")
87
87
  end
@@ -0,0 +1,46 @@
1
+ module Textus
2
+ # Eager-loading schema cache. Loads every *.yaml under +dir+ at construction.
3
+ # A missing directory is treated as "no schemas" (does not raise) to mirror
4
+ # the lazy behavior previously embedded in Store#schema_for.
5
+ class Schemas
6
+ def initialize(dir)
7
+ @dir = dir
8
+ @schemas = {}
9
+ load_all
10
+ end
11
+
12
+ def fetch(name)
13
+ @schemas[name] || raise(IoError.new("schema not found: #{File.join(@dir, "#{name}.yaml")}"))
14
+ end
15
+
16
+ # Only nil short-circuits. A missing-but-named schema still raises IoError.
17
+ def fetch_or_nil(name)
18
+ return nil if name.nil?
19
+
20
+ fetch(name)
21
+ end
22
+
23
+ def all
24
+ @schemas.values
25
+ end
26
+
27
+ private
28
+
29
+ def load_all
30
+ return unless File.directory?(@dir)
31
+
32
+ Dir.glob(File.join(@dir, "*.yaml")).each do |path|
33
+ name = File.basename(path, ".yaml")
34
+ begin
35
+ @schemas[name] = Schema.load(path)
36
+ rescue StandardError
37
+ # Tolerate broken schema files at construction time so the rest of
38
+ # the store remains loadable. Surfacing the failure is the job of
39
+ # Doctor::Check::SchemaParseError. Lookups via #fetch still raise
40
+ # IoError for the missing-but-named schema.
41
+ next
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end