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 +4 -4
- data/CHANGELOG.md +65 -0
- data/docs/conventions.md +11 -0
- data/lib/textus/application/context.rb +1 -7
- data/lib/textus/application/reads/get.rb +13 -32
- data/lib/textus/application/reads/get_or_refresh.rb +51 -0
- data/lib/textus/application/refresh/orchestrator.rb +9 -0
- data/lib/textus/application/refresh/worker.rb +10 -6
- data/lib/textus/builder/pipeline.rb +8 -1
- data/lib/textus/cli/verb/get.rb +1 -1
- data/lib/textus/cli/verb/refresh_stale.rb +1 -1
- data/lib/textus/cli.rb +23 -9
- data/lib/textus/operations/reads.rb +18 -1
- data/lib/textus/operations.rb +3 -3
- data/lib/textus/projection.rb +16 -11
- data/lib/textus/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 54b9e7266b017d02abba0f6daeba977c580c07f1bdab798ebc7a9b943be555cd
|
|
4
|
+
data.tar.gz: 47ebf2a56523dbf33058fe95c45163899a6f7b91500a2f749ce3d731e60ab502
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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:,
|
|
6
|
-
@ctx
|
|
7
|
-
@
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
data/lib/textus/cli/verb/get.rb
CHANGED
|
@@ -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.
|
|
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)
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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)
|
data/lib/textus/operations.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/textus/projection.rb
CHANGED
|
@@ -6,10 +6,18 @@ module Textus
|
|
|
6
6
|
MAX_LIMIT = 1000
|
|
7
7
|
REDUCER_TIMEOUT_SECONDS = 2
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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 =
|
|
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 = @
|
|
45
|
-
view = Application::Context.system(@store)
|
|
51
|
+
callable = @transform_resolver.call(name)
|
|
46
52
|
Timeout.timeout(REDUCER_TIMEOUT_SECONDS) do
|
|
47
|
-
callable.call(store:
|
|
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
|
-
|
|
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)
|
data/lib/textus/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: textus
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
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
|