textus 0.18.0 → 0.20.2

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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +43 -48
  3. data/CHANGELOG.md +238 -0
  4. data/SPEC.md +35 -2
  5. data/lib/textus/application/context.rb +20 -58
  6. data/lib/textus/application/policy/predicates/accept_authority_signed.rb +33 -0
  7. data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
  8. data/lib/textus/{domain → application}/policy/promotion.rb +18 -6
  9. data/lib/textus/application/projection.rb +91 -0
  10. data/lib/textus/application/reads/audit.rb +4 -4
  11. data/lib/textus/application/reads/blame.rb +9 -8
  12. data/lib/textus/application/reads/deps.rb +14 -3
  13. data/lib/textus/application/reads/freshness.rb +10 -8
  14. data/lib/textus/application/reads/get.rb +10 -8
  15. data/lib/textus/application/reads/get_or_refresh.rb +3 -3
  16. data/lib/textus/application/reads/list.rb +3 -3
  17. data/lib/textus/application/reads/policy_explain.rb +3 -3
  18. data/lib/textus/application/reads/published.rb +5 -3
  19. data/lib/textus/application/reads/rdeps.rb +15 -3
  20. data/lib/textus/application/reads/schema_envelope.rb +5 -4
  21. data/lib/textus/application/reads/stale.rb +3 -3
  22. data/lib/textus/application/reads/uid.rb +11 -3
  23. data/lib/textus/application/reads/validate_all.rb +10 -6
  24. data/lib/textus/application/reads/validator.rb +5 -3
  25. data/lib/textus/application/reads/where.rb +3 -3
  26. data/lib/textus/application/refresh/all.rb +15 -11
  27. data/lib/textus/application/refresh/orchestrator.rb +9 -8
  28. data/lib/textus/application/refresh/worker.rb +56 -32
  29. data/lib/textus/application/writes/accept.rb +43 -16
  30. data/lib/textus/application/writes/authority_gate.rb +26 -0
  31. data/lib/textus/application/writes/delete.rb +13 -10
  32. data/lib/textus/application/writes/envelope_io.rb +64 -4
  33. data/lib/textus/application/writes/materializer.rb +50 -0
  34. data/lib/textus/application/writes/mv.rb +57 -94
  35. data/lib/textus/application/writes/publish.rb +132 -26
  36. data/lib/textus/application/writes/put.rb +15 -14
  37. data/lib/textus/application/writes/reject.rb +25 -12
  38. data/lib/textus/builder/pipeline.rb +21 -15
  39. data/lib/textus/builder/renderer/json.rb +4 -1
  40. data/lib/textus/builder/renderer/markdown.rb +7 -1
  41. data/lib/textus/builder/renderer/yaml.rb +4 -1
  42. data/lib/textus/cli/verb/build.rb +4 -6
  43. data/lib/textus/cli/verb/get.rb +1 -1
  44. data/lib/textus/cli/verb/hook_run.rb +3 -4
  45. data/lib/textus/cli/verb/hooks.rb +5 -5
  46. data/lib/textus/cli/verb/put.rb +2 -3
  47. data/lib/textus/cli/verb/refresh_stale.rb +1 -2
  48. data/lib/textus/doctor/check/handler_allowlist.rb +3 -2
  49. data/lib/textus/doctor/check/hooks.rb +2 -2
  50. data/lib/textus/doctor/check/illegal_keys.rb +7 -7
  51. data/lib/textus/doctor/check/intake_registration.rb +2 -2
  52. data/lib/textus/doctor/check/manifest_files.rb +1 -1
  53. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  54. data/lib/textus/doctor/check/templates.rb +4 -3
  55. data/lib/textus/doctor.rb +3 -4
  56. data/lib/textus/domain/authorizer.rb +37 -0
  57. data/lib/textus/domain/policy/promote.rb +4 -2
  58. data/lib/textus/domain/policy/refresh.rb +2 -0
  59. data/lib/textus/domain/staleness/generator_check.rb +8 -7
  60. data/lib/textus/domain/staleness/intake_check.rb +2 -2
  61. data/lib/textus/hooks/builtin.rb +6 -6
  62. data/lib/textus/hooks/bus.rb +155 -0
  63. data/lib/textus/hooks/context.rb +38 -0
  64. data/lib/textus/hooks/fire_report.rb +23 -0
  65. data/lib/textus/hooks/loader.rb +3 -3
  66. data/lib/textus/infra/audit_subscriber.rb +4 -4
  67. data/lib/textus/infra/event_bus.rb +3 -3
  68. data/lib/textus/infra/refresh/detached.rb +1 -1
  69. data/lib/textus/init.rb +3 -2
  70. data/lib/textus/intro.rb +51 -27
  71. data/lib/textus/manifest/entry/base.rb +38 -0
  72. data/lib/textus/manifest/entry/derived.rb +25 -0
  73. data/lib/textus/manifest/entry/intake.rb +19 -0
  74. data/lib/textus/manifest/entry/leaf.rb +16 -0
  75. data/lib/textus/manifest/entry/nested.rb +39 -0
  76. data/lib/textus/manifest/entry/parser.rb +58 -31
  77. data/lib/textus/manifest/entry/validators/events.rb +3 -2
  78. data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -3
  79. data/lib/textus/manifest/entry/validators/index_filename.rb +11 -10
  80. data/lib/textus/manifest/entry/validators/inject_intro.rb +5 -2
  81. data/lib/textus/manifest/entry/validators/publish_each.rb +9 -6
  82. data/lib/textus/manifest/entry.rb +0 -72
  83. data/lib/textus/manifest/resolver.rb +112 -0
  84. data/lib/textus/manifest/role_kinds.rb +21 -0
  85. data/lib/textus/manifest/schema.rb +46 -2
  86. data/lib/textus/manifest.rb +24 -101
  87. data/lib/textus/operations.rb +131 -74
  88. data/lib/textus/schema/tools.rb +10 -3
  89. data/lib/textus/store.rb +6 -6
  90. data/lib/textus/version.rb +1 -1
  91. metadata +18 -14
  92. data/lib/textus/application/writes/build.rb +0 -78
  93. data/lib/textus/cli/verb/key_normalize.rb +0 -19
  94. data/lib/textus/dependencies.rb +0 -23
  95. data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
  96. data/lib/textus/domain/policy.rb +0 -7
  97. data/lib/textus/hooks/dispatcher.rb +0 -71
  98. data/lib/textus/hooks/registry.rb +0 -85
  99. data/lib/textus/manifest/resolution.rb +0 -5
  100. data/lib/textus/migrate_keys.rb +0 -187
  101. data/lib/textus/projection.rb +0 -89
  102. data/lib/textus/refresh.rb +0 -39
@@ -1,23 +0,0 @@
1
- module Textus
2
- module Dependencies
3
- def self.deps_of(manifest, key)
4
- entry = manifest.entries.find { |e| e.key == key } or return []
5
- result = Array(entry.projection&.fetch("select", nil)).map { |s| s }
6
- Array(entry.generator&.fetch("sources", nil)).each { |s| result << s }
7
- result.uniq
8
- end
9
-
10
- def self.rdeps_of(manifest, key)
11
- manifest.entries.each_with_object([]) do |e, acc|
12
- sources = Array(e.projection&.fetch("select", nil)) + Array(e.generator&.fetch("sources", nil))
13
- acc << e.key if sources.any? { |s| s == key || key.start_with?("#{s}.") }
14
- end
15
- end
16
-
17
- def self.published_of(manifest)
18
- manifest.entries.reject { |e| e.publish_to.empty? }.map do |e|
19
- { "key" => e.key, "publish_to" => e.publish_to }
20
- end
21
- end
22
- end
23
- end
@@ -1,31 +0,0 @@
1
- module Textus
2
- module Domain
3
- module Policy
4
- module Predicates
5
- class HumanAccept
6
- attr_reader :reason
7
-
8
- def name
9
- "human_accept"
10
- end
11
-
12
- # The role is passed via `store` (an Application::Context-like object
13
- # with a `role` reader) or through the entry metadata. In practice,
14
- # Accept already enforces role == "human" before reaching the
15
- # promotion gate, so this predicate trivially passes. It documents
16
- # intent and future-proofs multi-actor accept flows.
17
- def call(store:, entry: nil) # rubocop:disable Lint/UnusedMethodArgument
18
- role = store.respond_to?(:role) ? store.role.to_s : nil
19
- # If we cannot determine the role (e.g. store doesn't expose it),
20
- # we trust that Accept has already checked — allow through.
21
- return true if role.nil?
22
-
23
- ok = (role == "human")
24
- @reason = "current role is '#{role}', expected 'human'" unless ok
25
- ok
26
- end
27
- end
28
- end
29
- end
30
- end
31
- end
@@ -1,7 +0,0 @@
1
- module Textus
2
- module Domain
3
- module Policy
4
- ALLOWED_ON_STALE = %i[warn sync timed_sync].freeze
5
- end
6
- end
7
- end
@@ -1,71 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "timeout"
4
-
5
- module Textus
6
- module Hooks
7
- class Dispatcher
8
- HOOK_TIMEOUT_SECONDS = 2
9
-
10
- def initialize
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
19
- end
20
-
21
- def subscribe(event, name, keys: nil, &block)
22
- @subscribers[event.to_sym] << { name: name.to_sym, callable: block, keys: keys }
23
- end
24
-
25
- def publish(event, **kwargs)
26
- key = kwargs[:key] || "-"
27
- @subscribers[event.to_sym].each do |sub|
28
- next unless match?(sub[:keys], key)
29
-
30
- invoke(event, sub, key, kwargs)
31
- end
32
- end
33
-
34
- private
35
-
36
- def invoke(event, sub, key, kwargs)
37
- accepted = filter_kwargs(sub[:callable], kwargs)
38
- Timeout.timeout(HOOK_TIMEOUT_SECONDS) { sub[:callable].call(**accepted) }
39
- rescue StandardError => e
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
49
- end
50
-
51
- # Passes only the kwargs a hook block declares. Lets us extend event
52
- # payloads (e.g., correlation_id) without breaking hooks written against
53
- # the old signature.
54
- def filter_kwargs(callable, kwargs)
55
- params = callable.parameters
56
- return kwargs if params.any? { |type, _| type == :keyrest }
57
-
58
- accepted = params.each_with_object([]) do |(type, name), acc|
59
- acc << name if %i[key keyreq].include?(type)
60
- end
61
- kwargs.slice(*accepted)
62
- end
63
-
64
- def match?(globs, key)
65
- return true if globs.nil?
66
-
67
- Array(globs).any? { |g| File.fnmatch?(g, key.to_s, File::FNM_PATHNAME) }
68
- end
69
- end
70
- end
71
- end
@@ -1,85 +0,0 @@
1
- module Textus
2
- module Hooks
3
- class Registry
4
- EVENTS = {
5
- # RPC: exactly 1 handler per name; return value flows into store; failure aborts.
6
- resolve_intake: { mode: :rpc, args: %i[store config args] },
7
- transform_rows: { mode: :rpc, args: %i[store rows config] },
8
- validate: { mode: :rpc, args: %i[store] },
9
-
10
- # Pub-sub: 0..N handlers per event; return discarded; failure logged to audit.
11
- entry_put: { mode: :pubsub, args: %i[store key envelope] },
12
- entry_deleted: { mode: :pubsub, args: %i[store key] },
13
- entry_refreshed: { mode: :pubsub, args: %i[store key envelope change] },
14
- entry_renamed: { mode: :pubsub, args: %i[store key from_key to_key envelope] },
15
- build_completed: { mode: :pubsub, args: %i[store key envelope sources] },
16
- proposal_accepted: { mode: :pubsub, args: %i[store key target_key] },
17
- proposal_rejected: { mode: :pubsub, args: %i[store key target_key] },
18
- file_published: { mode: :pubsub, args: %i[store key envelope source target] },
19
- store_loaded: { mode: :pubsub, args: %i[store] },
20
- refresh_started: { mode: :pubsub, args: %i[store key mode] },
21
- refresh_failed: { mode: :pubsub, args: %i[store key error_class error_message] },
22
- refresh_backgrounded: { mode: :pubsub, args: %i[store key started_at budget_ms] },
23
- }.freeze
24
-
25
- def initialize(dispatcher: nil)
26
- @rpc = Hash.new { |h, k| h[k] = {} } # event => { name => callable }
27
- @pubsub = Hash.new { |h, k| h[k] = [] } # event => [{name:, callable:, keys:}]
28
- @dispatcher = dispatcher
29
- end
30
-
31
- def on(event, name, keys: nil, &)
32
- register(event, name, keys: keys, &)
33
- end
34
-
35
- def register(event, name, keys: nil, &blk)
36
- event_sym = event.to_sym
37
- spec = EVENTS[event_sym] or raise UsageError.new("unknown event: #{event}")
38
- shape_check!(event_sym, spec, blk)
39
- name = name.to_sym
40
-
41
- case spec[:mode]
42
- when :rpc
43
- raise UsageError.new("#{event_sym} '#{name}' already registered") if @rpc[event_sym].key?(name)
44
-
45
- @rpc[event_sym][name] = blk
46
- when :pubsub
47
- raise UsageError.new("#{event_sym} hook '#{name}' already registered") if @pubsub[event_sym].any? { |h| h[:name] == name }
48
-
49
- @pubsub[event_sym] << { name: name, callable: blk, keys: keys }
50
- @dispatcher&.subscribe(event_sym, name, keys: keys, &blk)
51
- end
52
- end
53
-
54
- def rpc_callable(event, name)
55
- @rpc[event.to_sym][name.to_sym] or
56
- raise UsageError.new("unknown #{event}: #{name}")
57
- end
58
-
59
- def listeners(event, key:)
60
- @pubsub[event.to_sym].select { |h| h[:keys].nil? || matches_any?(h[:keys], key) }
61
- end
62
-
63
- def rpc_names(event) = @rpc[event.to_sym].keys
64
- def pubsub_handlers(event) = @pubsub[event.to_sym]
65
-
66
- private
67
-
68
- def shape_check!(event, spec, blk)
69
- required = spec[:args]
70
- provided = blk.parameters.select { |t, _| %i[keyreq key keyrest].include?(t) } # rubocop:disable Style/HashSlice
71
- keyrest = provided.any? { |t, _| t == :keyrest }
72
- missing = required - provided.map { |_, n| n }
73
- return if keyrest || missing.empty?
74
-
75
- raise UsageError.new(
76
- "#{event} hooks must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})",
77
- )
78
- end
79
-
80
- def matches_any?(globs, key)
81
- Array(globs).any? { |g| File.fnmatch?(g, key.to_s, File::FNM_PATHNAME) }
82
- end
83
- end
84
- end
85
- end
@@ -1,5 +0,0 @@
1
- module Textus
2
- class Manifest
3
- Resolution = Data.define(:entry, :path, :remaining)
4
- end
5
- end
@@ -1,187 +0,0 @@
1
- module Textus
2
- # Run-once helper that renames files/directories whose basenames don't
3
- # conform to the strict key grammar (§3 of plan-1.2). Only walks
4
- # nested: true manifest entries — leaf entries with illegal declared
5
- # keys are caught by Manifest load and must be fixed by hand.
6
- module MigrateKeys
7
- SEGMENT = /\A[a-z0-9][a-z0-9-]*\z/
8
-
9
- module_function
10
-
11
- # Returns the envelope hash described in plan-1.2 §3.
12
- def run(store, write: false)
13
- plan = build_plan(store)
14
- collisions = plan[:collisions]
15
- renames = plan[:renames]
16
-
17
- ok = collisions.empty?
18
- apply!(store, renames) if write && ok
19
-
20
- {
21
- "protocol" => Textus::PROTOCOL,
22
- "mode" => write ? "write" : "dry-run",
23
- "renames" => renames.map { |r| envelope_rename(r) },
24
- "collisions" => collisions.map { |c| envelope_collision(c) },
25
- "ok" => ok,
26
- }
27
- end
28
-
29
- # ------------------------------------------------------------------
30
- # Plan construction
31
- # ------------------------------------------------------------------
32
-
33
- # Returns { renames: [...], collisions: [...] }
34
- # Each rename: { from:, to:, old_key:, new_key:, kind: :file|:dir }
35
- # Each collision: { target:, sources: [...] }
36
- def build_plan(store) # rubocop:disable Metrics/AbcSize
37
- renames = []
38
- target_buckets = Hash.new { |h, k| h[k] = [] } # target_path => [source_path, ...]
39
-
40
- store.manifest.entries.each do |entry|
41
- next unless entry.nested
42
-
43
- base = File.join(store.root, "zones", entry.path)
44
- next unless File.directory?(base)
45
-
46
- # Walk depth-first. Order matters when computing the "new key"
47
- # for files inside a renamed directory: we record renames bottom-up,
48
- # so children are renamed before their parents on apply.
49
- walk(base) do |abs_path, is_dir|
50
- next if abs_path == base
51
-
52
- basename = File.basename(abs_path)
53
- stem = is_dir ? basename : basename.sub(/#{Regexp.escape(File.extname(basename))}\z/, "")
54
- next if stem.match?(SEGMENT)
55
-
56
- new_stem = normalize(stem)
57
- # Skip if normalization yields the same stem (e.g. already-legal
58
- # under a different lens). In practice match?(SEGMENT) catches that
59
- # above; this is a safety net.
60
- next if new_stem == stem
61
-
62
- new_basename = is_dir ? new_stem : new_stem + File.extname(basename)
63
- target = File.join(File.dirname(abs_path), new_basename)
64
- target_buckets[target] << abs_path
65
-
66
- renames << {
67
- from: abs_path,
68
- to: target,
69
- kind: is_dir ? :dir : :file,
70
- entry: entry,
71
- base: base,
72
- }
73
- end
74
- end
75
-
76
- collisions = target_buckets.select { |_, srcs| srcs.length > 1 }
77
- .map { |t, srcs| { target: t, sources: srcs.sort } }
78
-
79
- # Drop colliding entries from renames (we won't apply any of them)
80
- colliding_targets = collisions.to_set { |c| c[:target] }
81
- renames.reject! { |r| colliding_targets.include?(r[:to]) }
82
-
83
- # Sort renames bottom-up (deepest path first) so children move before parents.
84
- renames.sort_by! { |r| -r[:from].count("/") }
85
-
86
- { renames: renames, collisions: collisions }
87
- end
88
-
89
- # Yields [absolute_path, is_dir] for every entry under root. Depth-first.
90
- def walk(root, &block)
91
- Dir.each_child(root) do |name|
92
- abs = File.join(root, name)
93
- if File.directory?(abs)
94
- walk(abs, &block)
95
- yield abs, true
96
- else
97
- yield abs, false
98
- end
99
- end
100
- end
101
-
102
- # Deterministic transform per plan §3.
103
- def normalize(s)
104
- s = s.downcase
105
- s = s.gsub(/[^a-z0-9-]/, "-") # ., _, and anything else become -
106
- s = s.gsub(/-+/, "-")
107
- s.sub(/\A-+/, "").sub(/-+\z/, "")
108
- end
109
-
110
- # ------------------------------------------------------------------
111
- # Apply
112
- # ------------------------------------------------------------------
113
-
114
- def apply!(store, renames)
115
- audit = Textus::Infra::AuditLog.new(store.root)
116
- renames.each do |r|
117
- # Bottom-up order means a child's ancestors haven't moved yet, so
118
- # `from`/`to` are valid as-recorded. The audit `key` reflects the
119
- # eventual full key once every rename in this batch has applied.
120
- from = r[:from]
121
- to = r[:to]
122
- File.rename(from, to)
123
- new_key = compute_new_key(r, renames)
124
- audit.append(
125
- role: "runner",
126
- verb: "migrate-keys",
127
- key: new_key,
128
- etag_before: nil,
129
- etag_after: nil,
130
- extras: { "from" => from, "to" => to },
131
- )
132
- end
133
- end
134
-
135
- # If an ancestor of `path` was renamed earlier in this batch, rewrite the path.
136
- def resolve_current_path(path, renames)
137
- out = path
138
- renames.each do |r|
139
- prefix = r[:from] + "/"
140
- out = r[:to] + out[r[:from].length..] if out.start_with?(prefix)
141
- end
142
- out
143
- end
144
-
145
- # New full key after applying all renames up through this one.
146
- def compute_new_key(rename, renames)
147
- base = rename[:base]
148
- entry = rename[:entry]
149
- new_to = resolve_current_path(rename[:to], renames)
150
-
151
- rel = new_to.sub(%r{\A#{Regexp.escape(base)}/?}, "")
152
- stripped = rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "") unless rename[:kind] == :dir
153
- stripped ||= rel
154
- segs = stripped.split("/").reject(&:empty?)
155
- (entry.key.split(".") + segs).join(".")
156
- end
157
-
158
- # ------------------------------------------------------------------
159
- # Envelope helpers
160
- # ------------------------------------------------------------------
161
-
162
- def envelope_rename(r)
163
- {
164
- "from" => r[:from],
165
- "to" => r[:to],
166
- "old_key" => path_to_key(r[:from], r[:base], r[:entry], r[:kind]),
167
- "new_key" => path_to_key(r[:to], r[:base], r[:entry], r[:kind]),
168
- }
169
- end
170
-
171
- def envelope_collision(col)
172
- { "target" => col[:target], "sources" => col[:sources] }
173
- end
174
-
175
- def path_to_key(path, base, entry, kind)
176
- rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
177
- stripped =
178
- if kind == :dir
179
- rel
180
- else
181
- rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
182
- end
183
- segs = stripped.split("/").reject(&:empty?)
184
- (entry.key.split(".") + segs).join(".")
185
- end
186
- end
187
- end
@@ -1,89 +0,0 @@
1
- require "time"
2
- require "timeout"
3
-
4
- module Textus
5
- class Projection
6
- MAX_LIMIT = 1000
7
- REDUCER_TIMEOUT_SECONDS = 2
8
-
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
21
- @limit = (@spec["limit"] || MAX_LIMIT).to_i
22
- raise InvalidProjection.new("limit #{@limit} exceeds max #{MAX_LIMIT}") if @limit > MAX_LIMIT
23
- end
24
-
25
- def run
26
- keys = collect_keys
27
- explicit_pluck = !@spec["pluck"].nil? && @spec["pluck"] != "*"
28
- rows = keys.map do |key|
29
- env = @reader.call(key)
30
- row = pluck(env.meta, env.body)
31
- explicit_pluck ? row : row.merge("_key" => key)
32
- end
33
- reduced = apply_reducer(rows)
34
- # Reducers may return either an Array of rows (legacy / templated builds)
35
- # or a Hash that becomes the structured-format payload base. In the Hash
36
- # case, downstream sort/limit/position markers don't apply, and the
37
- # builder owns `_meta.generated_at` so we don't stamp it here.
38
- return reduced if reduced.is_a?(Hash)
39
-
40
- rows = reduced
41
- rows = sort(rows)
42
- rows = rows.first(@limit)
43
- mark_positions(rows)
44
- { "entries" => rows, "count" => rows.length, "generated_at" => Time.now.utc.iso8601 }
45
- end
46
-
47
- private
48
-
49
- def apply_reducer(rows)
50
- name = @spec["transform"] or return rows
51
- callable = @transform_resolver.call(name)
52
- Timeout.timeout(REDUCER_TIMEOUT_SECONDS) do
53
- callable.call(store: @transform_context, rows: rows, config: @spec["transform_config"] || {})
54
- end
55
- rescue Timeout::Error
56
- raise UsageError.new("transform_rows '#{name}' exceeded #{REDUCER_TIMEOUT_SECONDS}s timeout")
57
- end
58
-
59
- def collect_keys
60
- prefixes = Array(@spec["select"])
61
- prefixes.flat_map { |p| @lister.call(prefix: p).map { |row| row["key"] } }.uniq
62
- end
63
-
64
- def pluck(frontmatter, _body)
65
- fields = @spec["pluck"]
66
- if fields.nil? || fields == "*"
67
- frontmatter
68
- else
69
- Array(fields).each_with_object({}) { |f, h| h[f] = frontmatter[f] if frontmatter.key?(f) }
70
- end
71
- end
72
-
73
- # Adds `_first`, `_last`, and `_index` markers so templates can emit
74
- # delimiters (e.g. JSON commas) via {{^_last}},{{/_last}}.
75
- def mark_positions(rows)
76
- last_idx = rows.length - 1
77
- rows.each_with_index do |row, i|
78
- row["_index"] = i
79
- row["_first"] = i.zero?
80
- row["_last"] = (i == last_idx)
81
- end
82
- end
83
-
84
- def sort(rows)
85
- sb = @spec["sort_by"] or return rows
86
- rows.sort_by { |r| r[sb].to_s }
87
- end
88
- end
89
- end
@@ -1,39 +0,0 @@
1
- module Textus
2
- module Refresh
3
- def self.call(store, key, as:)
4
- Textus::Operations.for(store, role: as).refresh(key)
5
- end
6
-
7
- def self.refresh_stale(store, prefix: nil, zone: nil, as: "runner")
8
- ops = Textus::Operations.for(store, role: as)
9
- Textus::Application::Refresh::All.call(ops.ctx, prefix: prefix, zone: zone)
10
- end
11
-
12
- # Normalize the three accepted intake return shapes into the store's
13
- # internal {frontmatter, body, content} representation.
14
- def self.normalize_action_result(res, format:)
15
- res = res.transform_keys(&:to_s) if res.is_a?(Hash)
16
- res ||= {}
17
- meta_val = res["_meta"]
18
- body = res["body"]
19
- content = res["content"]
20
-
21
- case format
22
- when "markdown"
23
- { meta: meta_val || {}, body: body.to_s, content: nil }
24
- when "text"
25
- { meta: {}, body: body.to_s, content: nil }
26
- when "json", "yaml"
27
- if !content.nil?
28
- { meta: meta_val || {}, body: nil, content: content }
29
- elsif !body.nil?
30
- { meta: {}, body: body.to_s, content: nil }
31
- else
32
- raise UsageError.new("intake for #{format} returned neither content nor body")
33
- end
34
- else
35
- raise UsageError.new("unknown format #{format.inspect}")
36
- end
37
- end
38
- end
39
- end