textus 0.14.4 → 0.15.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d1e2a12b234bfc677ce44ec7b49b3b04b4ac69abbc15f13496911c82d89549c8
4
- data.tar.gz: 6b83336a05cb3cee509d3e0b90ce2048ed57fdeb5fbc603f22303e10c2d5c27c
3
+ metadata.gz: 54b9e7266b017d02abba0f6daeba977c580c07f1bdab798ebc7a9b943be555cd
4
+ data.tar.gz: 47ebf2a56523dbf33058fe95c45163899a6f7b91500a2f749ce3d731e60ab502
5
5
  SHA512:
6
- metadata.gz: f126d898385e0d0236a1818a741923f8ee9f88f1f13cab8ef37bbb810d14145904186084f72d0fbc5a4d6bfdf53b5e7e68699f56db4f9aa02d22772019a11316
7
- data.tar.gz: 054ea4fea78cf26d570319d49f2a0ffbd4023e6355b56b8830650e7d59b1b57ae2de7b158ef0611b465369136e11b292ba9f968113cd425ecd1cfed1bf96633d
6
+ metadata.gz: 76abb1c22c3f519574dfd310a7ff2162b023bbbc8667b2e4dc2c32edbb94d432bf507620116dbcbc1f7ce3328964515ee8eded62abe1c833249b3b2bb1bd9fce
7
+ data.tar.gz: a405b5d81159c8dd0638e9ca6f4fca419358792c7a3a29f64cb0b19386553f37aa091f913c65ad078a039205ad6ac0368af681eba455fcb4c1787bcfb213ac05
data/CHANGELOG.md CHANGED
@@ -9,6 +9,71 @@ The **gem version** (`0.x.y`) is distinct from the **protocol version**
9
9
  bump is a breaking change that requires a store migration; the gem version
10
10
  tracks both additive improvements and breaking protocol bumps independently.
11
11
 
12
+ ## 0.15.0 — 2026-05-26
13
+
14
+ ### Breaking
15
+
16
+ - `Application::Reads::Get#call` is now a **pure read**: it returns the
17
+ on-disk envelope annotated with a freshness verdict, and never
18
+ triggers refresh. `Reads::Get.new` no longer accepts `orchestrator:`.
19
+ Callers that relied on refresh-on-read should switch to
20
+ `Application::Reads::GetOrRefresh` (new), accessible via
21
+ `ops.reads.get_or_refresh`. The CLI verb `textus get` is migrated
22
+ internally; users of `textus get` see no behavior change.
23
+ - `Application::Context#bypass_freshness?` and the `bypass_freshness:`
24
+ kwarg on `Context.new` / `Operations.for` are **removed**. They
25
+ shipped in 0.14.4 as a workaround; with `Reads::Get` now pure by
26
+ default, the flag is dead code. Callers passing `bypass_freshness:`
27
+ will see `ArgumentError`.
28
+ - `Projection.new` signature is **breaking**: now
29
+ `Projection.new(reader:, spec:, lister:, transform_resolver:, transform_context:)`.
30
+ `Projection` no longer constructs its own `Operations` chain. Callers
31
+ inject collaborators. `Builder::Pipeline` is migrated internally.
32
+ - Intake handlers now receive `args: { trigger_key:, leaf_segments: }`
33
+ instead of `args: {}`. Handlers that destructure `args` should
34
+ expect the new keys. Handlers that pass `args` through unchanged
35
+ are unaffected.
36
+
37
+ ### Fixed
38
+
39
+ - **Bug 1 / single-flight.** `Refresh::Orchestrator#run_timed` now
40
+ probes the per-leaf lock before forking the detached refresh
41
+ worker. If the lock is held (by a sibling process or earlier fork),
42
+ the orchestrator returns `Outcome::Detached` without spawning a
43
+ redundant worker. Prevents wasted forks when the same key is read
44
+ concurrently across processes.
45
+ - **Bug 2 / leaf-aware intake.** `Refresh::Worker` now keeps the
46
+ `remaining` segments from `Manifest#resolve(key)` and passes them
47
+ to the intake handler as `args: { trigger_key:, leaf_segments: }`.
48
+ Handlers can scope to one leaf instead of re-processing the full
49
+ parent `intake_config` for every leaf refresh.
50
+ - **`textus refresh stale` now exits 0 on success.** Previously the
51
+ verb fell off the end returning `nil`, which propagated up through
52
+ `CLI.run` to `exe/textus:4`'s `exit nil` and raised `TypeError`,
53
+ exit-coding 1 on every successful refresh. Fixed by returning an
54
+ explicit Integer from the verb. The verb return-value contract is
55
+ now codified: every verb's `#call` returns Integer (or `nil` →
56
+ treated as 0); `CLI.run` coerces. (#61)
57
+
58
+ ### Added
59
+
60
+ - `Application::Reads::GetOrRefresh` — explicit composition of pure
61
+ `Reads::Get` with the refresh orchestrator. Use for interactive
62
+ reads that want freshest-obtainable envelopes.
63
+ - `ops.reads.get_or_refresh` accessor.
64
+
65
+ ### Migration
66
+
67
+ If your code (or hook / handler / extension) called:
68
+
69
+ | Old | New |
70
+ |---|---|
71
+ | `ops.reads.get.call(key)` to get the freshest envelope | `ops.reads.get_or_refresh.call(key)` |
72
+ | `ops.reads.get.call(key)` for pure read | unchanged; now also pure semantics |
73
+ | `Operations.for(store, bypass_freshness: true)` | `Operations.for(store)` |
74
+ | `Projection.new(store, spec)` | `Projection.new(reader:, spec:, lister:, transform_resolver:, transform_context:)` — see `builder/pipeline.rb` for canonical wiring |
75
+ | Handler signature `lambda { \|store:, config:, args:\| ... }` with `args == {}` | unchanged — `args` is now populated with `:trigger_key` and `:leaf_segments`; handlers that ignore them keep working |
76
+
12
77
  ## 0.14.4 — 2026-05-26
13
78
 
14
79
  ### Fixed
data/docs/conventions.md CHANGED
@@ -105,6 +105,17 @@ textus refresh-stale --zone=intake --as=runner # in cron / CI
105
105
 
106
106
  See [`./zones.md` §6](zones.md) for the full intake contract and [`./events.md`](events.md) for writing custom handlers.
107
107
 
108
+ ### Read vs. refresh
109
+
110
+ There are two read operations, and the difference matters in custom code:
111
+
112
+ | Operation | Triggers refresh? | Use for |
113
+ |-----------|-------------------|---------|
114
+ | `ops.reads.get` | No — pure read of on-disk envelope + freshness verdict | build / materialization paths, schema tooling, any context where a side-effecting read would surprise the caller |
115
+ | `ops.reads.get_or_refresh` | Yes — composes `get` with the orchestrator per the rule's `on_stale` policy | interactive reads (`textus get`, dashboards) where the caller wants the freshest envelope obtainable |
116
+
117
+ Build always uses the pure path; injecting refresh into materialization caused the cascading-staleness incident behind issue #59. Pick `get_or_refresh` only when you genuinely want side effects on read.
118
+
108
119
  ## Body content
109
120
 
110
121
  - **Bodies are Markdown.** Headings, lists, code fences — whatever a human or agent finds useful.
@@ -9,13 +9,12 @@ module Textus
9
9
  new(store: store, role: "human")
10
10
  end
11
11
 
12
- def initialize(store:, role:, correlation_id: nil, clock: Time, dry_run: false, bypass_freshness: false)
12
+ def initialize(store:, role:, correlation_id: nil, clock: Time, dry_run: false)
13
13
  @store = store
14
14
  @role = role.to_s
15
15
  @correlation_id = correlation_id || SecureRandom.uuid
16
16
  @clock = clock
17
17
  @dry_run = dry_run
18
- @bypass_freshness = bypass_freshness
19
18
  @now = nil
20
19
  end
21
20
 
@@ -27,10 +26,6 @@ module Textus
27
26
  @dry_run
28
27
  end
29
28
 
30
- def bypass_freshness?
31
- @bypass_freshness
32
- end
33
-
34
29
  def can_write?(zone)
35
30
  store.manifest.permission_for(zone.to_s).allows_write?(role)
36
31
  end
@@ -46,7 +41,6 @@ module Textus
46
41
  correlation_id: @correlation_id,
47
42
  clock: @clock,
48
43
  dry_run: @dry_run,
49
- bypass_freshness: @bypass_freshness,
50
44
  )
51
45
  end
52
46
  end
@@ -1,17 +1,20 @@
1
1
  module Textus
2
2
  module Application
3
3
  module Reads
4
+ # Pure read: returns the on-disk envelope annotated with a freshness
5
+ # verdict. Never triggers refresh; never invokes the orchestrator.
6
+ #
7
+ # For interactive reads that want refresh-on-stale, use
8
+ # `Reads::GetOrRefresh`, which composes this with the orchestrator.
4
9
  class Get
5
- def initialize(ctx:, orchestrator:, evaluator: Textus::Domain::Freshness::Evaluator)
6
- @ctx = ctx
7
- @orchestrator = orchestrator
8
- @evaluator = evaluator
10
+ def initialize(ctx:, evaluator: Textus::Domain::Freshness::Evaluator)
11
+ @ctx = ctx
12
+ @evaluator = evaluator
9
13
  end
10
14
 
11
15
  def call(key)
12
16
  envelope = @ctx.store.reader.read_raw_envelope(key)
13
17
  return nil if envelope.nil?
14
- return annotate_fresh(envelope) if @ctx.bypass_freshness?
15
18
 
16
19
  policy_set = @ctx.store.manifest.rules_for(key)
17
20
  refresh_policy = policy_set.refresh
@@ -20,37 +23,15 @@ module Textus
20
23
  policy = refresh_policy.to_freshness_policy
21
24
  verdict = @evaluator.call(policy, envelope, now: @ctx.now)
22
25
 
23
- return annotate(envelope, verdict, refreshing: false) if verdict.fresh?
24
-
25
- action = policy.decide(verdict)
26
- outcome = @orchestrator.execute(action, key: key)
27
-
28
- case outcome
29
- when Textus::Domain::Outcome::Skipped
30
- annotate(envelope, verdict, refreshing: false)
31
- when Textus::Domain::Outcome::Refreshed
32
- fresh_verdict = @evaluator.call(policy, outcome.envelope, now: @ctx.now)
33
- annotate(outcome.envelope, fresh_verdict, refreshing: false)
34
- when Textus::Domain::Outcome::Detached
35
- annotate(envelope, verdict, refreshing: true)
36
- when Textus::Domain::Outcome::Failed
37
- annotate(envelope, verdict, refreshing: false, refresh_error: outcome.error.message)
38
- end
26
+ envelope.with(freshness: {
27
+ "stale" => verdict.stale?,
28
+ "stale_reason" => verdict.reason,
29
+ "refreshing" => false,
30
+ })
39
31
  end
40
32
 
41
33
  private
42
34
 
43
- def annotate(envelope, verdict, refreshing:, refresh_error: nil)
44
- fresh = {
45
- "stale" => verdict.stale?,
46
- "stale_reason" => verdict.reason,
47
- "refreshing" => refreshing,
48
- }
49
- fresh["refresh_error"] = refresh_error if refresh_error
50
- envelope.with(freshness: fresh)
51
- end
52
-
53
- # No refresh policy applies to this key — treat as fresh, skip evaluation/orchestration.
54
35
  def annotate_fresh(envelope)
55
36
  envelope.with(freshness: { "stale" => false, "stale_reason" => nil, "refreshing" => false })
56
37
  end
@@ -0,0 +1,51 @@
1
+ module Textus
2
+ module Application
3
+ module Reads
4
+ # Composes pure `Reads::Get` with the refresh orchestrator: runs Get
5
+ # to obtain the envelope and freshness verdict, then if the verdict
6
+ # is stale and the rule's `on_stale` policy demands action, hands
7
+ # off to the orchestrator. Use for interactive reads where the
8
+ # caller wants the freshest obtainable envelope.
9
+ #
10
+ # Pure reads (build, projection, schema tooling) should use
11
+ # `Reads::Get` directly; it has no orchestrator dependency.
12
+ class GetOrRefresh
13
+ def initialize(ctx:, get:, orchestrator:)
14
+ @ctx = ctx
15
+ @get = get
16
+ @orchestrator = orchestrator
17
+ end
18
+
19
+ def call(key)
20
+ envelope = @get.call(key)
21
+ return nil if envelope.nil?
22
+ return envelope unless envelope.freshness["stale"]
23
+
24
+ policy_set = @ctx.store.manifest.rules_for(key)
25
+ refresh_policy = policy_set.refresh
26
+ return envelope if refresh_policy.nil?
27
+
28
+ policy = refresh_policy.to_freshness_policy
29
+ verdict = Textus::Domain::Freshness::Verdict.stale(envelope.freshness["stale_reason"])
30
+ action = policy.decide(verdict)
31
+ outcome = @orchestrator.execute(action, key: key)
32
+
33
+ case outcome
34
+ when Textus::Domain::Outcome::Skipped
35
+ envelope
36
+ when Textus::Domain::Outcome::Refreshed
37
+ outcome.envelope.with(
38
+ freshness: { "stale" => false, "stale_reason" => nil, "refreshing" => false },
39
+ )
40
+ when Textus::Domain::Outcome::Detached
41
+ envelope.with(freshness: envelope.freshness.merge("refreshing" => true))
42
+ when Textus::Domain::Outcome::Failed
43
+ envelope.with(
44
+ freshness: envelope.freshness.merge("refresh_error" => outcome.error.message),
45
+ )
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -47,6 +47,15 @@ module Textus
47
47
 
48
48
  if thread.alive?
49
49
  thread.kill
50
+
51
+ # Single-flight: if a sibling process / earlier fork holds the
52
+ # per-leaf lock, don't fork another worker — they're already
53
+ # doing this work.
54
+ probe = Textus::Infra::Refresh::Lock.new(root: @store_root, key: key)
55
+ return Textus::Domain::Outcome::Detached.new unless probe.try_acquire
56
+
57
+ probe.release
58
+
50
59
  store_view = @store ? Textus::Application::Context.new(store: @store, role: @role) : nil
51
60
  payload = { key: key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms }
52
61
  payload[:store] = store_view if store_view
@@ -12,11 +12,11 @@ module Textus
12
12
  end
13
13
 
14
14
  def run(key)
15
- mentry, path, = @ctx.store.manifest.resolve(key)
15
+ mentry, path, remaining = @ctx.store.manifest.resolve(key)
16
16
  raise UsageError.new("no intake declared for '#{key}'") unless mentry.intake_handler
17
17
 
18
18
  before_etag = File.exist?(path) ? Etag.for_file(path) : nil
19
- result = fetch_with_bus(key, mentry)
19
+ result = fetch_with_bus(key, mentry, remaining)
20
20
  persist_and_notify(key, mentry, result, before_etag)
21
21
  end
22
22
 
@@ -31,17 +31,21 @@ module Textus
31
31
  rule&.refresh&.fetch_timeout_seconds || FETCH_TIMEOUT_SECONDS
32
32
  end
33
33
 
34
- def fetch_with_bus(key, mentry)
34
+ def fetch_with_bus(key, mentry, remaining)
35
35
  callable = @ctx.store.registry.rpc_callable(:resolve_intake, mentry.intake_handler)
36
36
  @bus.publish(:refresh_started, store: read_view, key: key, mode: :sync,
37
37
  correlation_id: @ctx.correlation_id)
38
- call_intake(key, mentry, callable)
38
+ call_intake(key, mentry, callable, remaining)
39
39
  end
40
40
 
41
- def call_intake(key, mentry, callable)
41
+ def call_intake(key, mentry, callable, remaining)
42
42
  timeout = fetch_timeout_for(key)
43
43
  Timeout.timeout(timeout) do
44
- callable.call(store: @ctx, config: mentry.intake_config, args: {})
44
+ callable.call(
45
+ store: @ctx,
46
+ config: mentry.intake_config,
47
+ args: { trigger_key: key, leaf_segments: remaining || [] },
48
+ )
45
49
  end
46
50
  rescue Timeout::Error
47
51
  @bus.publish(:refresh_failed, store: read_view, key: key, error_class: "Timeout::Error",
@@ -62,7 +62,14 @@ module Textus
62
62
  # 1. Load sources + project + reduce
63
63
  data =
64
64
  if mentry.projection
65
- Projection.new(store, mentry.projection, bypass_freshness: true).run
65
+ ops = Operations.for(store)
66
+ Projection.new(
67
+ reader: ops.reads.get.method(:call),
68
+ spec: mentry.projection,
69
+ lister: ops.reads.list.method(:call),
70
+ transform_resolver: ->(name) { store.registry.rpc_callable(:transform_rows, name) },
71
+ transform_context: Application::Context.system(store),
72
+ ).run
66
73
  else
67
74
  { "entries" => [], "count" => 0, "generated_at" => Time.now.utc.iso8601 }
68
75
  end
@@ -6,7 +6,7 @@ module Textus
6
6
 
7
7
  def call(store)
8
8
  key = positional.shift or raise UsageError.new("get requires a key")
9
- result = operations_for(store).reads.get.call(key)
9
+ result = operations_for(store).reads.get_or_refresh.call(key)
10
10
  raise Textus::UnknownKey.new(key, suggestions: store.manifest.suggestions_for(key)) if result.nil?
11
11
 
12
12
  emit(result.to_h_for_wire)
@@ -10,7 +10,7 @@ module Textus
10
10
  ctx = context_for(store)
11
11
  result = Textus::Application::Refresh::All.call(ctx, prefix: prefix, zone: zone)
12
12
  emit(result)
13
- exit(1) unless result["ok"]
13
+ result["ok"] ? 0 : 1
14
14
  end
15
15
  end
16
16
  end
data/lib/textus/cli.rb CHANGED
@@ -47,21 +47,35 @@ module Textus
47
47
  verb = argv.shift
48
48
  raise UsageError.new("missing verb") if verb.nil?
49
49
 
50
- case verb
51
- when "--version", "-v" then @stdout.puts(VERSION)
52
- 0
53
- when "--help", "-h" then print_help
54
- 0
55
- else
56
- klass = VERBS[verb] or raise UsageError.new("unknown verb: #{verb}")
57
- dispatch(klass, argv)
58
- end
50
+ result =
51
+ case verb
52
+ when "--version", "-v" then @stdout.puts(VERSION)
53
+ 0
54
+ when "--help", "-h" then print_help
55
+ 0
56
+ else
57
+ klass = VERBS[verb] or raise UsageError.new("unknown verb: #{verb}")
58
+ dispatch(klass, argv)
59
+ end
60
+
61
+ coerce_exit_code(result)
59
62
  rescue Textus::Error => e
60
63
  emit_error(e)
61
64
  end
62
65
 
63
66
  private
64
67
 
68
+ def coerce_exit_code(value)
69
+ case value
70
+ when Integer then value
71
+ when true, nil then 0
72
+ when false then 1
73
+ else
74
+ @stderr.puts("warning: verb returned non-Integer #{value.class}; treating as 0")
75
+ 0
76
+ end
77
+ end
78
+
65
79
  def store
66
80
  @store ||= Store.discover(@cwd, root: @root_arg)
67
81
  end
@@ -1,12 +1,29 @@
1
1
  module Textus
2
2
  class Operations
3
3
  class Reads
4
+ # `get` — pure read; returns envelope + freshness verdict;
5
+ # never triggers refresh; no orchestrator dependency.
6
+ # `get_or_refresh` — composes `get` with the refresh orchestrator; runs
7
+ # refresh per policy when the verdict says stale.
8
+ # Use this for interactive reads where the caller
9
+ # wants the freshest envelope obtainable.
10
+ #
11
+ # Pick `get` for materialization paths (build, projection, schema tooling).
12
+ # Pick `get_or_refresh` for interactive `textus get` and equivalent.
4
13
  def initialize(ctx)
5
14
  @ctx = ctx
6
15
  end
7
16
 
8
17
  def get
9
- Application::Reads::Get.new(ctx: @ctx, orchestrator: orchestrator)
18
+ Application::Reads::Get.new(ctx: @ctx)
19
+ end
20
+
21
+ def get_or_refresh # rubocop:disable Naming/AccessorMethodName
22
+ Application::Reads::GetOrRefresh.new(
23
+ ctx: @ctx,
24
+ get: get,
25
+ orchestrator: orchestrator,
26
+ )
10
27
  end
11
28
 
12
29
  def freshness = Application::Reads::Freshness.new(ctx: @ctx)
@@ -4,18 +4,18 @@ module Textus
4
4
  #
5
5
  # ops = Textus::Operations.for(store, role: "agent")
6
6
  # ops.writes.put.call(key, body: "...")
7
- # ops.reads.get.call(key)
7
+ # ops.reads.get.call(key) # pure read
8
+ # ops.reads.get_or_refresh.call(key) # read + refresh-on-stale
8
9
  # ops.refresh.worker.call(key)
9
10
  #
10
11
  # Replaces the prior `Textus::Composition` module (deleted in v0.12.2).
11
12
  class Operations
12
- def self.for(store, role: Role::DEFAULT, correlation_id: nil, dry_run: false, bypass_freshness: false)
13
+ def self.for(store, role: Role::DEFAULT, correlation_id: nil, dry_run: false)
13
14
  ctx = Application::Context.new(
14
15
  store: store,
15
16
  role: role,
16
17
  correlation_id: correlation_id,
17
18
  dry_run: dry_run,
18
- bypass_freshness: bypass_freshness,
19
19
  )
20
20
  new(ctx)
21
21
  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.reads.get`) for materialization paths;
11
+ # `ops.reads.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,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.14.4"
2
+ VERSION = "0.15.0"
3
3
  PROTOCOL = "textus/3"
4
4
  end
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.14.4
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -115,6 +115,7 @@ files:
115
115
  - lib/textus/application/reads/deps.rb
116
116
  - lib/textus/application/reads/freshness.rb
117
117
  - lib/textus/application/reads/get.rb
118
+ - lib/textus/application/reads/get_or_refresh.rb
118
119
  - lib/textus/application/reads/list.rb
119
120
  - lib/textus/application/reads/policy_explain.rb
120
121
  - lib/textus/application/reads/published.rb