textus 0.14.3 → 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: 65cfe5f73a64dda44280d1dd58e418aecdc94b79e1abb68340e973d364da3465
4
- data.tar.gz: c35f1428a0965c11c946395a8fb7dfeb60202e2fac2514b1bb04dfae1e426ed2
3
+ metadata.gz: 54b9e7266b017d02abba0f6daeba977c580c07f1bdab798ebc7a9b943be555cd
4
+ data.tar.gz: 47ebf2a56523dbf33058fe95c45163899a6f7b91500a2f749ce3d731e60ab502
5
5
  SHA512:
6
- metadata.gz: edeefd203bb0f4af807457cb96353affd3a21de846331481bb3d6e69e344fc1ea8054f8066ed278e7a7ced366704a65df00269d38b58a5b729d408477bc4f5a5
7
- data.tar.gz: 4c30fa46409c381bfa8ef381c38314ce988786b9f2cf81c8b1c76f7508301587b9e5562ce1ced415f85484fe794fe3aeff5186203f733cedea34443d43a4eb57
6
+ metadata.gz: 76abb1c22c3f519574dfd310a7ff2162b023bbbc8667b2e4dc2c32edbb94d432bf507620116dbcbc1f7ce3328964515ee8eded62abe1c833249b3b2bb1bd9fce
7
+ data.tar.gz: a405b5d81159c8dd0638e9ca6f4fca419358792c7a3a29f64cb0b19386553f37aa091f913c65ad078a039205ad6ac0368af681eba455fcb4c1787bcfb213ac05
data/CHANGELOG.md CHANGED
@@ -9,6 +9,113 @@ 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
+
77
+ ## 0.14.4 — 2026-05-26
78
+
79
+ ### Fixed
80
+
81
+ - `textus build` no longer triggers per-leaf refresh fan-out when
82
+ projection reads encounter stale entries under `on_stale: timed_sync`
83
+ / `on_stale: sync`. Build is a downstream materialization step over
84
+ current store state; freshness is an inflow concern, and they
85
+ shouldn't compose. Previously, building a marketplace-style output
86
+ whose projection selected `intake.vendor.**` against ~400 stale
87
+ leaves spawned ~400 concurrent detached refresh workers, each
88
+ re-running the full intake handler, exhausting the system. The
89
+ build pipeline now reads through an `Application::Context` with
90
+ `bypass_freshness: true`, so `Application::Reads::Get` returns the
91
+ on-disk envelope annotated as fresh without consulting the
92
+ orchestrator. Explicit freshness before build still works via
93
+ `textus refresh stale`. (#59)
94
+
95
+ ### Added
96
+
97
+ - `Application::Context#bypass_freshness?` — new flag for read paths
98
+ that must not initiate refresh. Threaded through `Operations.for`
99
+ and propagated across `Context#with_role`. Used by `Builder::Pipeline`
100
+ via a `bypass_freshness:` constructor kwarg on `Projection`.
101
+ - `textus doctor` now reports stale per-key refresh lock files under
102
+ `<root>/.locks/` whose recorded PID is no longer running, as an
103
+ `info`-level `refresh_lock.stale` issue. The check is purely
104
+ informational: `Refresh::Lock` uses `flock(2)`, which the kernel
105
+ releases on process death, so stale `.lock` files on disk do not
106
+ block subsequent refresh acquires. The check exists so users can
107
+ clean up forensic clutter and notice unexpected accumulation. No
108
+ read-path changes — adding a PID probe + unlink there would
109
+ reintroduce the TOCTOU and PID-reuse hazards explicitly rejected
110
+ in 0.14.3 / PR #57. (#58)
111
+
112
+ ### Tested
113
+
114
+ - Added a regression spec that forks a child, takes a per-key
115
+ `Refresh::Lock`, SIGKILLs the child, and asserts a fresh acquire
116
+ on the same key succeeds without manual cleanup. Pins the
117
+ flock-survives-SIGKILL contract.
118
+
12
119
  ## 0.14.3 — 2026-05-26
13
120
 
14
121
  ### Added
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.
@@ -10,12 +10,12 @@ module Textus
10
10
  end
11
11
 
12
12
  def initialize(store:, role:, correlation_id: nil, clock: Time, dry_run: false)
13
- @store = store
14
- @role = role.to_s
15
- @correlation_id = correlation_id || SecureRandom.uuid
16
- @clock = clock
17
- @dry_run = dry_run
18
- @now = nil
13
+ @store = store
14
+ @role = role.to_s
15
+ @correlation_id = correlation_id || SecureRandom.uuid
16
+ @clock = clock
17
+ @dry_run = dry_run
18
+ @now = nil
19
19
  end
20
20
 
21
21
  def now
@@ -1,11 +1,15 @@
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)
@@ -19,37 +23,15 @@ module Textus
19
23
  policy = refresh_policy.to_freshness_policy
20
24
  verdict = @evaluator.call(policy, envelope, now: @ctx.now)
21
25
 
22
- return annotate(envelope, verdict, refreshing: false) if verdict.fresh?
23
-
24
- action = policy.decide(verdict)
25
- outcome = @orchestrator.execute(action, key: key)
26
-
27
- case outcome
28
- when Textus::Domain::Outcome::Skipped
29
- annotate(envelope, verdict, refreshing: false)
30
- when Textus::Domain::Outcome::Refreshed
31
- fresh_verdict = @evaluator.call(policy, outcome.envelope, now: @ctx.now)
32
- annotate(outcome.envelope, fresh_verdict, refreshing: false)
33
- when Textus::Domain::Outcome::Detached
34
- annotate(envelope, verdict, refreshing: true)
35
- when Textus::Domain::Outcome::Failed
36
- annotate(envelope, verdict, refreshing: false, refresh_error: outcome.error.message)
37
- end
26
+ envelope.with(freshness: {
27
+ "stale" => verdict.stale?,
28
+ "stale_reason" => verdict.reason,
29
+ "refreshing" => false,
30
+ })
38
31
  end
39
32
 
40
33
  private
41
34
 
42
- def annotate(envelope, verdict, refreshing:, refresh_error: nil)
43
- fresh = {
44
- "stale" => verdict.stale?,
45
- "stale_reason" => verdict.reason,
46
- "refreshing" => refreshing,
47
- }
48
- fresh["refresh_error"] = refresh_error if refresh_error
49
- envelope.with(freshness: fresh)
50
- end
51
-
52
- # No refresh policy applies to this key — treat as fresh, skip evaluation/orchestration.
53
35
  def annotate_fresh(envelope)
54
36
  envelope.with(freshness: { "stale" => false, "stale_reason" => nil, "refreshing" => false })
55
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).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
@@ -0,0 +1,49 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ # Lists per-key refresh lock files under <store.root>/.locks/ whose
5
+ # recorded PID is no longer running. These are forensic artifacts only:
6
+ # Refresh::Lock uses flock(2), which the kernel releases on process
7
+ # death, so stale files do not block subsequent acquires. The check
8
+ # exists to let users clean up clutter and notice unexpected accumulation
9
+ # (e.g. a refresh path that crashes repeatedly).
10
+ class RefreshLocks < Check
11
+ def call
12
+ dir = File.join(store.root, ".locks")
13
+ return [] unless File.directory?(dir)
14
+
15
+ Dir.glob(File.join(dir, "*.lock")).filter_map { |path| inspect_lock(path) }
16
+ end
17
+
18
+ private
19
+
20
+ def inspect_lock(path)
21
+ pid = File.read(path).strip.to_i
22
+ return nil if pid.zero?
23
+ return nil if pid_alive?(pid)
24
+
25
+ {
26
+ "code" => "refresh_lock.stale",
27
+ "level" => "info",
28
+ "subject" => path,
29
+ "message" => "refresh lock file at #{path} records dead PID #{pid} " \
30
+ "(does not block refresh; flock is kernel-released on exit)",
31
+ "fix" => "safe to delete: rm #{path}",
32
+ }
33
+ rescue Errno::ENOENT
34
+ nil
35
+ end
36
+
37
+ def pid_alive?(pid)
38
+ Process.kill(0, pid)
39
+ true
40
+ rescue Errno::ESRCH
41
+ false
42
+ rescue Errno::EPERM
43
+ # Process exists but owned by another user — treat as alive.
44
+ true
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
data/lib/textus/doctor.rb CHANGED
@@ -23,6 +23,7 @@ module Textus
23
23
  Check::SchemaViolations,
24
24
  Check::RuleAmbiguity,
25
25
  Check::HandlerAllowlist,
26
+ Check::RefreshLocks,
26
27
  ].freeze
27
28
 
28
29
  ALL_CHECKS = CHECKS.map(&:name_key).freeze
@@ -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,7 +4,8 @@ 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).
@@ -6,9 +6,18 @@ module Textus
6
6
  MAX_LIMIT = 1000
7
7
  REDUCER_TIMEOUT_SECONDS = 2
8
8
 
9
- def initialize(store, spec)
10
- @store = store
11
- @spec = spec || {}
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
12
21
  @limit = (@spec["limit"] || MAX_LIMIT).to_i
13
22
  raise InvalidProjection.new("limit #{@limit} exceeds max #{MAX_LIMIT}") if @limit > MAX_LIMIT
14
23
  end
@@ -17,7 +26,7 @@ module Textus
17
26
  keys = collect_keys
18
27
  explicit_pluck = !@spec["pluck"].nil? && @spec["pluck"] != "*"
19
28
  rows = keys.map do |key|
20
- env = Operations.for(@store).reads.get.call(key)
29
+ env = @reader.call(key)
21
30
  row = pluck(env.meta, env.body)
22
31
  explicit_pluck ? row : row.merge("_key" => key)
23
32
  end
@@ -39,10 +48,9 @@ module Textus
39
48
 
40
49
  def apply_reducer(rows)
41
50
  name = @spec["transform"] or return rows
42
- callable = @store.registry.rpc_callable(:transform_rows, name)
43
- view = Application::Context.system(@store)
51
+ callable = @transform_resolver.call(name)
44
52
  Timeout.timeout(REDUCER_TIMEOUT_SECONDS) do
45
- callable.call(store: view, rows: rows, config: @spec["transform_config"] || {})
53
+ callable.call(store: @transform_context, rows: rows, config: @spec["transform_config"] || {})
46
54
  end
47
55
  rescue Timeout::Error
48
56
  raise UsageError.new("transform_rows '#{name}' exceeded #{REDUCER_TIMEOUT_SECONDS}s timeout")
@@ -50,8 +58,7 @@ module Textus
50
58
 
51
59
  def collect_keys
52
60
  prefixes = Array(@spec["select"])
53
- ops = Operations.for(@store)
54
- 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
55
62
  end
56
63
 
57
64
  def pluck(frontmatter, _body)
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.14.3"
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.3
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
@@ -188,6 +189,7 @@ files:
188
189
  - lib/textus/doctor/check/intake_registration.rb
189
190
  - lib/textus/doctor/check/manifest_files.rb
190
191
  - lib/textus/doctor/check/protocol_version.rb
192
+ - lib/textus/doctor/check/refresh_locks.rb
191
193
  - lib/textus/doctor/check/rule_ambiguity.rb
192
194
  - lib/textus/doctor/check/schema_parse_error.rb
193
195
  - lib/textus/doctor/check/schema_violations.rb