textus 0.15.0 → 0.20.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/ARCHITECTURE.md +50 -55
- data/CHANGELOG.md +486 -0
- data/README.md +13 -9
- data/SPEC.md +13 -10
- data/docs/conventions.md +2 -2
- data/lib/textus/application/context.rb +20 -34
- data/lib/textus/application/policy/predicates/human_accept.rb +30 -0
- data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
- data/lib/textus/{domain → application}/policy/promotion.rb +20 -3
- data/lib/textus/application/projection.rb +91 -0
- data/lib/textus/application/reads/audit.rb +4 -4
- data/lib/textus/application/reads/blame.rb +11 -8
- data/lib/textus/application/reads/deps.rb +14 -3
- data/lib/textus/application/reads/freshness.rb +17 -6
- data/lib/textus/application/reads/get.rb +37 -11
- data/lib/textus/application/reads/get_or_refresh.rb +8 -8
- data/lib/textus/application/reads/list.rb +5 -3
- data/lib/textus/application/reads/policy_explain.rb +3 -3
- data/lib/textus/application/reads/published.rb +5 -3
- data/lib/textus/application/reads/rdeps.rb +15 -3
- data/lib/textus/application/reads/schema_envelope.rb +6 -3
- data/lib/textus/application/reads/stale.rb +3 -3
- data/lib/textus/application/reads/uid.rb +11 -3
- data/lib/textus/application/reads/validate_all.rb +12 -3
- data/lib/textus/application/reads/validator.rb +84 -0
- data/lib/textus/application/reads/where.rb +6 -3
- data/lib/textus/application/refresh/all.rb +16 -5
- data/lib/textus/application/refresh/orchestrator.rb +9 -9
- data/lib/textus/application/refresh/worker.rb +59 -32
- data/lib/textus/application/tools/migrate_keys.rb +191 -0
- data/lib/textus/application/tools/migrate_manifest_to_kinds.rb +31 -0
- data/lib/textus/application/writes/accept.rb +36 -13
- data/lib/textus/application/writes/delete.rb +13 -15
- data/lib/textus/application/writes/envelope_io.rb +166 -0
- data/lib/textus/application/writes/materializer.rb +50 -0
- data/lib/textus/application/writes/mv.rb +56 -95
- data/lib/textus/application/writes/publish.rb +132 -27
- data/lib/textus/application/writes/put.rb +17 -20
- data/lib/textus/application/writes/reject.rb +18 -9
- data/lib/textus/builder/pipeline.rb +21 -15
- data/lib/textus/builder/renderer/json.rb +4 -1
- data/lib/textus/builder/renderer/markdown.rb +7 -1
- data/lib/textus/builder/renderer/yaml.rb +4 -1
- data/lib/textus/cli/group/hook.rb +1 -3
- data/lib/textus/cli/group/key.rb +1 -4
- data/lib/textus/cli/group/refresh.rb +1 -2
- data/lib/textus/cli/group/rule.rb +1 -3
- data/lib/textus/cli/group/schema.rb +1 -5
- data/lib/textus/cli/group.rb +12 -16
- data/lib/textus/cli/verb/accept.rb +3 -1
- data/lib/textus/cli/verb/audit.rb +3 -1
- data/lib/textus/cli/verb/blame.rb +3 -1
- data/lib/textus/cli/verb/build.rb +4 -5
- data/lib/textus/cli/verb/delete.rb +3 -1
- data/lib/textus/cli/verb/deps.rb +3 -1
- data/lib/textus/cli/verb/doctor.rb +2 -0
- data/lib/textus/cli/verb/freshness.rb +3 -1
- data/lib/textus/cli/verb/get.rb +4 -2
- data/lib/textus/cli/verb/hook_run.rb +6 -4
- data/lib/textus/cli/verb/hooks.rb +8 -5
- data/lib/textus/cli/verb/init.rb +2 -0
- data/lib/textus/cli/verb/intro.rb +2 -0
- data/lib/textus/cli/verb/key_normalize.rb +35 -3
- data/lib/textus/cli/verb/list.rb +3 -1
- data/lib/textus/cli/verb/mv.rb +4 -1
- data/lib/textus/cli/verb/published.rb +3 -1
- data/lib/textus/cli/verb/put.rb +5 -4
- data/lib/textus/cli/verb/rdeps.rb +3 -1
- data/lib/textus/cli/verb/refresh.rb +1 -1
- data/lib/textus/cli/verb/refresh_stale.rb +4 -2
- data/lib/textus/cli/verb/reject.rb +3 -1
- data/lib/textus/cli/verb/rule_explain.rb +4 -1
- data/lib/textus/cli/verb/rule_list.rb +3 -0
- data/lib/textus/cli/verb/schema.rb +4 -1
- data/lib/textus/cli/verb/schema_diff.rb +3 -0
- data/lib/textus/cli/verb/schema_init.rb +3 -0
- data/lib/textus/cli/verb/schema_migrate.rb +3 -0
- data/lib/textus/cli/verb/uid.rb +4 -1
- data/lib/textus/cli/verb/where.rb +3 -1
- data/lib/textus/cli/verb.rb +30 -0
- data/lib/textus/cli.rb +18 -27
- data/lib/textus/doctor/check/audit_log.rb +1 -1
- data/lib/textus/doctor/check/handler_allowlist.rb +3 -2
- data/lib/textus/doctor/check/hooks.rb +4 -2
- data/lib/textus/doctor/check/illegal_keys.rb +6 -5
- data/lib/textus/doctor/check/intake_registration.rb +5 -5
- data/lib/textus/doctor/check/manifest_files.rb +1 -1
- data/lib/textus/doctor/check/protocol_version.rb +2 -2
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/sentinels.rb +2 -2
- data/lib/textus/doctor/check/templates.rb +4 -3
- data/lib/textus/doctor.rb +3 -4
- data/lib/textus/domain/authorizer.rb +37 -0
- data/lib/textus/domain/freshness/evaluator.rb +1 -1
- data/lib/textus/domain/freshness/policy.rb +1 -1
- data/lib/textus/domain/freshness/verdict.rb +1 -1
- data/lib/textus/domain/freshness.rb +40 -0
- data/lib/textus/{store → domain}/sentinel.rb +1 -1
- data/lib/textus/{store → domain}/staleness/generator_check.rb +9 -8
- data/lib/textus/{store → domain}/staleness/intake_check.rb +3 -3
- data/lib/textus/{store → domain}/staleness.rb +1 -1
- data/lib/textus/entry/json.rb +1 -1
- data/lib/textus/entry/markdown.rb +1 -1
- data/lib/textus/entry/yaml.rb +1 -1
- data/lib/textus/envelope.rb +7 -3
- data/lib/textus/errors.rb +19 -0
- data/lib/textus/hooks/builtin.rb +6 -6
- data/lib/textus/hooks/bus.rb +155 -0
- data/lib/textus/hooks/context.rb +38 -0
- data/lib/textus/hooks/fire_report.rb +23 -0
- data/lib/textus/hooks/loader.rb +20 -17
- data/lib/textus/{store → infra}/audit_log.rb +1 -1
- data/lib/textus/infra/audit_subscriber.rb +43 -0
- data/lib/textus/infra/event_bus.rb +3 -3
- data/lib/textus/infra/publisher.rb +3 -3
- data/lib/textus/infra/refresh/detached.rb +1 -1
- data/lib/textus/infra/storage/file_store.rb +26 -0
- data/lib/textus/init.rb +14 -11
- data/lib/textus/intro.rb +7 -7
- data/lib/textus/manifest/entry/base.rb +38 -0
- data/lib/textus/manifest/entry/derived.rb +25 -0
- data/lib/textus/manifest/entry/intake.rb +19 -0
- data/lib/textus/manifest/entry/leaf.rb +16 -0
- data/lib/textus/manifest/entry/nested.rb +39 -0
- data/lib/textus/manifest/entry/parser.rb +64 -31
- data/lib/textus/manifest/entry/validators/events.rb +3 -2
- data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -3
- data/lib/textus/manifest/entry/validators/index_filename.rb +11 -10
- data/lib/textus/manifest/entry/validators/inject_intro.rb +5 -2
- data/lib/textus/manifest/entry/validators/publish_each.rb +9 -6
- data/lib/textus/manifest/entry.rb +0 -72
- data/lib/textus/manifest/resolution.rb +5 -0
- data/lib/textus/manifest/resolver.rb +109 -0
- data/lib/textus/manifest/schema.rb +1 -1
- data/lib/textus/manifest.rb +4 -100
- data/lib/textus/operations.rb +147 -23
- data/lib/textus/schema/tools.rb +7 -7
- data/lib/textus/schemas.rb +46 -0
- data/lib/textus/store.rb +12 -49
- data/lib/textus/uid.rb +18 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +17 -1
- metadata +31 -23
- data/lib/textus/application/writes/build.rb +0 -79
- data/lib/textus/dependencies.rb +0 -23
- data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
- data/lib/textus/hooks/dispatcher.rb +0 -63
- data/lib/textus/hooks/dsl.rb +0 -11
- data/lib/textus/hooks/registry.rb +0 -81
- data/lib/textus/migrate_keys.rb +0 -187
- data/lib/textus/operations/reads.rb +0 -56
- data/lib/textus/operations/refresh.rb +0 -27
- data/lib/textus/operations/writes.rb +0 -21
- data/lib/textus/projection.rb +0 -89
- data/lib/textus/refresh.rb +0 -39
- data/lib/textus/store/reader.rb +0 -69
- data/lib/textus/store/validator.rb +0 -82
- data/lib/textus/store/writer.rb +0 -102
data/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Reference implementation in Ruby. Wire format `textus/3`. SPEC: [`SPEC.md`](SPEC
|
|
|
14
14
|
Two versions, deliberately independent:
|
|
15
15
|
|
|
16
16
|
- **Protocol wire string:** `textus/3`. Breaking changes require `textus/4`.
|
|
17
|
-
- **Gem version:** semver, currently `0.
|
|
17
|
+
- **Gem version:** semver, currently `0.18.0`. Decoupled from the protocol string — internal refactors bump the gem; only wire-format changes bump the protocol.
|
|
18
18
|
|
|
19
19
|
Envelope payloads carry the `protocol` field. The gem version is irrelevant to the wire format.
|
|
20
20
|
|
|
@@ -119,18 +119,22 @@ textus exposes a hook DSL. Drop `.rb` files into `.textus/hooks/` (subdirectorie
|
|
|
119
119
|
|
|
120
120
|
```ruby
|
|
121
121
|
# Inside .textus/hooks/local_file.rb
|
|
122
|
-
Textus.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
122
|
+
Textus.hook do |reg|
|
|
123
|
+
reg.on(:resolve_intake, :local_file) do |config:, args:, **|
|
|
124
|
+
path = config["path"] or raise "local-file requires intake.config.path"
|
|
125
|
+
{
|
|
126
|
+
_meta: { "last_refreshed_at" => Time.now.utc.iso8601, "source_path" => path },
|
|
127
|
+
body: File.read(File.expand_path(path)),
|
|
128
|
+
}
|
|
129
|
+
end
|
|
128
130
|
end
|
|
129
131
|
```
|
|
130
132
|
|
|
131
133
|
```ruby
|
|
132
|
-
Textus.
|
|
133
|
-
|
|
134
|
+
Textus.hook do |reg|
|
|
135
|
+
reg.on(:transform_rows, :rank_by_recency) do |rows:, **|
|
|
136
|
+
rows.sort_by { |r| r["updated_at"].to_s }.reverse
|
|
137
|
+
end
|
|
134
138
|
end
|
|
135
139
|
```
|
|
136
140
|
|
data/SPEC.md
CHANGED
|
@@ -450,7 +450,7 @@ Row transforms are RPC hooks on the `:transform_rows` event. See §5.10.
|
|
|
450
450
|
|
|
451
451
|
### 5.10 Hooks
|
|
452
452
|
|
|
453
|
-
textus has a single hook registration verb: `Textus.on(event, name, **opts) { ... }`. The EVENTS table below defines every extension point. Files in `.textus/hooks/**/*.rb` are `load`ed at `Store#initialize` in alphabetical order by full path.
|
|
453
|
+
textus has a single hook registration verb: `Textus.hook { |reg| reg.on(event, name, **opts) { ... } }`. The EVENTS table below defines every extension point. Files in `.textus/hooks/**/*.rb` are `load`ed at `Store#initialize` in alphabetical order by full path; the store-scoped loader drains the queued blocks and invokes each with its own registry.
|
|
454
454
|
|
|
455
455
|
The subdirectory layout under `hooks/` is organizational only; the registered event and name come from the DSL call, not the file path.
|
|
456
456
|
|
|
@@ -458,14 +458,16 @@ The subdirectory layout under `hooks/` is organizational only; the registered ev
|
|
|
458
458
|
|
|
459
459
|
```ruby
|
|
460
460
|
# Canonical form — works for every event:
|
|
461
|
-
Textus.
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
461
|
+
Textus.hook do |reg|
|
|
462
|
+
reg.on(:resolve_intake, :my_source) { |config:, args:, **| … }
|
|
463
|
+
reg.on(:transform_rows, :rank_by_recency) { |rows:, **| … }
|
|
464
|
+
reg.on(:validate, :storage_writable) { |store:| … }
|
|
465
|
+
reg.on(:entry_put, :audit, keys: ["working.*"]) { |key:, envelope:, **| … }
|
|
466
|
+
reg.on(:file_published, :git_add, keys: ["derived.*"]) { |target:, **| `git add #{target.shellescape}` }
|
|
467
|
+
end
|
|
466
468
|
```
|
|
467
469
|
|
|
468
|
-
`Textus.
|
|
470
|
+
`Textus.hook` is the sole entry point. The block receives the store's `Hooks::Registry`; `reg.on` is the only registration primitive.
|
|
469
471
|
|
|
470
472
|
#### Event table
|
|
471
473
|
|
|
@@ -821,7 +823,7 @@ Textus internals are organized into four layers. The dependency rule is one-way
|
|
|
821
823
|
|
|
822
824
|
The `lib/textus/store/`, `lib/textus/manifest/`, `lib/textus/hooks/` namespaces are infrastructure adapters that predate this split and remain at their existing paths for backward-compat with the plugin DSL.
|
|
823
825
|
|
|
824
|
-
Plugin authors interact only with the Hook DSL (`Textus.on(:resolve_intake, ...)`, `
|
|
826
|
+
Plugin authors interact only with the Hook DSL (`Textus.hook { |reg| reg.on(:resolve_intake, ...) }`, `reg.on(:entry_refreshed, ...)`, etc.) and the manifest YAML schema. The layering is internal and may evolve.
|
|
825
827
|
|
|
826
828
|
Both read and write paths flow through the application layer:
|
|
827
829
|
|
|
@@ -830,8 +832,9 @@ Both read and write paths flow through the application layer:
|
|
|
830
832
|
- `Application::Context` is the universal request object: it carries `store`, `role`, `correlation_id`, `clock`, and `dry_run`. Use cases never thread these as separate kwargs.
|
|
831
833
|
- `Textus::Operations` is the factory CLI verbs (and future MCP server / HTTP shim)
|
|
832
834
|
use to construct Contexts and use cases. `Operations.for(store, role:)` returns
|
|
833
|
-
a
|
|
834
|
-
|
|
835
|
+
a flat facade exposing one method per use case (`#put`, `#get`, `#refresh`, …);
|
|
836
|
+
internal use-case instances are memoized via `||=` and live under
|
|
837
|
+
`lib/textus/application/{reads,writes,refresh}/`.
|
|
835
838
|
|
|
836
839
|
See `ARCHITECTURE.md` for an ASCII diagram and the full read-path walkthrough.
|
|
837
840
|
|
data/docs/conventions.md
CHANGED
|
@@ -111,8 +111,8 @@ There are two read operations, and the difference matters in custom code:
|
|
|
111
111
|
|
|
112
112
|
| Operation | Triggers refresh? | Use for |
|
|
113
113
|
|-----------|-------------------|---------|
|
|
114
|
-
| `ops.
|
|
115
|
-
| `ops.
|
|
114
|
+
| `ops.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.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
116
|
|
|
117
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
118
|
|
|
@@ -2,45 +2,31 @@ require "securerandom"
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Application
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def now
|
|
22
|
-
@now ||= @clock.now
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def dry_run?
|
|
26
|
-
@dry_run
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def can_write?(zone)
|
|
30
|
-
store.manifest.permission_for(zone.to_s).allows_write?(role)
|
|
5
|
+
# A Context describes the call: who is acting (role), what request this
|
|
6
|
+
# is part of (correlation_id), what time it is (now), and whether
|
|
7
|
+
# writes should be suppressed (dry_run).
|
|
8
|
+
#
|
|
9
|
+
# Collaborators (manifest, file_store, bus, audit log, authorizer) are
|
|
10
|
+
# never read from Context — use cases declare them as explicit
|
|
11
|
+
# constructor ports, and Operations wires them in from the Store.
|
|
12
|
+
Context = Data.define(:role, :correlation_id, :now, :dry_run) do
|
|
13
|
+
def self.build(role:, correlation_id: nil, now: nil, dry_run: false)
|
|
14
|
+
new(
|
|
15
|
+
role: role.to_s,
|
|
16
|
+
correlation_id: correlation_id || SecureRandom.uuid,
|
|
17
|
+
now: now || Time.now,
|
|
18
|
+
dry_run: dry_run,
|
|
19
|
+
)
|
|
31
20
|
end
|
|
32
21
|
|
|
33
|
-
def
|
|
34
|
-
store.manifest.permission_for(zone.to_s).allows_read?(role)
|
|
35
|
-
end
|
|
22
|
+
def dry_run? = dry_run
|
|
36
23
|
|
|
37
24
|
def with_role(new_role)
|
|
38
25
|
self.class.new(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
dry_run: @dry_run,
|
|
26
|
+
role: new_role.to_s,
|
|
27
|
+
correlation_id: correlation_id,
|
|
28
|
+
now: now,
|
|
29
|
+
dry_run: dry_run,
|
|
44
30
|
)
|
|
45
31
|
end
|
|
46
32
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
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 explicitly. In practice, Accept already enforces
|
|
13
|
+
# role == "human" before reaching the promotion gate, so this predicate
|
|
14
|
+
# trivially passes. It documents intent and future-proofs multi-actor
|
|
15
|
+
# accept flows.
|
|
16
|
+
def call(role:, entry: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
17
|
+
role_str = role&.to_s
|
|
18
|
+
# If we cannot determine the role, trust that Accept has already
|
|
19
|
+
# checked — allow through.
|
|
20
|
+
return true if role_str.nil? || role_str.empty?
|
|
21
|
+
|
|
22
|
+
ok = (role_str == "human")
|
|
23
|
+
@reason = "current role is '#{role_str}', expected 'human'" unless ok
|
|
24
|
+
ok
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
module
|
|
2
|
+
module Application
|
|
3
3
|
module Policy
|
|
4
4
|
module Predicates
|
|
5
5
|
class SchemaValid
|
|
@@ -9,17 +9,17 @@ module Textus
|
|
|
9
9
|
"schema_valid"
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
def call(entry:,
|
|
13
|
-
return true if entry.nil? ||
|
|
12
|
+
def call(entry:, schemas:, manifest:) # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
|
|
13
|
+
return true if entry.nil? || manifest.nil? || schemas.nil?
|
|
14
14
|
|
|
15
15
|
target_key = entry.meta&.dig("proposal", "target_key")
|
|
16
16
|
return true unless target_key
|
|
17
17
|
|
|
18
|
-
mentry
|
|
18
|
+
mentry = manifest.resolver.resolve(target_key).entry
|
|
19
19
|
schema_ref = mentry&.schema
|
|
20
20
|
return true unless schema_ref
|
|
21
21
|
|
|
22
|
-
schema =
|
|
22
|
+
schema = schemas.fetch_or_nil(schema_ref)
|
|
23
23
|
return true unless schema
|
|
24
24
|
|
|
25
25
|
frontmatter = entry.meta&.dig("frontmatter") || {}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
module
|
|
2
|
+
module Application
|
|
3
3
|
module Policy
|
|
4
4
|
# Promotion evaluates a list of named predicates against a pending-proposal
|
|
5
5
|
# entry and returns a Result indicating whether all requirements are met.
|
|
6
|
+
#
|
|
7
|
+
# Lives in Application because the predicates it wires up read live state
|
|
8
|
+
# from explicit ports (schemas, manifest, role). The Domain-side rule
|
|
9
|
+
# statement ("this policy requires predicates X and Y") is captured by
|
|
10
|
+
# Textus::Domain::Policy::Promote.
|
|
6
11
|
class Promotion
|
|
7
12
|
Result = Struct.new(:ok?, :reasons, keyword_init: true)
|
|
8
13
|
|
|
@@ -31,14 +36,26 @@ module Textus
|
|
|
31
36
|
@predicates.map(&:name)
|
|
32
37
|
end
|
|
33
38
|
|
|
34
|
-
def evaluate(entry:,
|
|
39
|
+
def evaluate(entry:, schemas:, manifest:, role:)
|
|
35
40
|
reasons = []
|
|
36
41
|
@predicates.each do |pred|
|
|
37
|
-
ok = pred
|
|
42
|
+
ok = invoke(pred, entry: entry, schemas: schemas, manifest: manifest, role: role)
|
|
38
43
|
reasons << "#{pred.name}: #{pred.reason || "predicate failed"}" unless ok
|
|
39
44
|
end
|
|
40
45
|
Result.new(ok?: reasons.empty?, reasons: reasons)
|
|
41
46
|
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def invoke(pred, entry:, schemas:, manifest:, role:)
|
|
51
|
+
case pred.name
|
|
52
|
+
when "human_accept"
|
|
53
|
+
pred.call(role: role, entry: entry)
|
|
54
|
+
else
|
|
55
|
+
# Default shape: schema-style predicates that need entry + schemas + manifest.
|
|
56
|
+
pred.call(entry: entry, schemas: schemas, manifest: manifest)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
42
59
|
end
|
|
43
60
|
end
|
|
44
61
|
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
require "timeout"
|
|
3
|
+
|
|
4
|
+
module Textus
|
|
5
|
+
module Application
|
|
6
|
+
class Projection
|
|
7
|
+
MAX_LIMIT = 1000
|
|
8
|
+
REDUCER_TIMEOUT_SECONDS = 2
|
|
9
|
+
|
|
10
|
+
# `reader` — a callable `->(key) { envelope_or_nil }`. Caller picks
|
|
11
|
+
# semantics: pure read (`ops.get`) for materialization paths;
|
|
12
|
+
# `ops.get_or_refresh` if you want refresh-on-stale.
|
|
13
|
+
# `lister` — a callable `->(prefix:) { [ { "key" => ... }, ... ] }`.
|
|
14
|
+
# `transform_resolver` — a callable `->(name) { callable_or_raise }`.
|
|
15
|
+
# `transform_context` — `Application::Context` handed to the transform reducer.
|
|
16
|
+
def initialize(reader:, spec:, lister:, transform_resolver:, transform_context:)
|
|
17
|
+
@reader = reader
|
|
18
|
+
@spec = spec || {}
|
|
19
|
+
@lister = lister
|
|
20
|
+
@transform_resolver = transform_resolver
|
|
21
|
+
@transform_context = transform_context
|
|
22
|
+
@limit = (@spec["limit"] || MAX_LIMIT).to_i
|
|
23
|
+
raise InvalidProjection.new("limit #{@limit} exceeds max #{MAX_LIMIT}") if @limit > MAX_LIMIT
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run
|
|
27
|
+
keys = collect_keys
|
|
28
|
+
explicit_pluck = !@spec["pluck"].nil? && @spec["pluck"] != "*"
|
|
29
|
+
rows = keys.map do |key|
|
|
30
|
+
env = @reader.call(key)
|
|
31
|
+
row = pluck(env.meta, env.body)
|
|
32
|
+
explicit_pluck ? row : row.merge("_key" => key)
|
|
33
|
+
end
|
|
34
|
+
reduced = apply_reducer(rows)
|
|
35
|
+
# Reducers may return either an Array of rows (legacy / templated builds)
|
|
36
|
+
# or a Hash that becomes the structured-format payload base. In the Hash
|
|
37
|
+
# case, downstream sort/limit/position markers don't apply, and the
|
|
38
|
+
# builder owns `_meta.generated_at` so we don't stamp it here.
|
|
39
|
+
return reduced if reduced.is_a?(Hash)
|
|
40
|
+
|
|
41
|
+
rows = reduced
|
|
42
|
+
rows = sort(rows)
|
|
43
|
+
rows = rows.first(@limit)
|
|
44
|
+
mark_positions(rows)
|
|
45
|
+
{ "entries" => rows, "count" => rows.length, "generated_at" => Time.now.utc.iso8601 }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def apply_reducer(rows)
|
|
51
|
+
name = @spec["transform"] or return rows
|
|
52
|
+
callable = @transform_resolver.call(name)
|
|
53
|
+
Timeout.timeout(REDUCER_TIMEOUT_SECONDS) do
|
|
54
|
+
callable.call(store: @transform_context, rows: rows, config: @spec["transform_config"] || {})
|
|
55
|
+
end
|
|
56
|
+
rescue Timeout::Error
|
|
57
|
+
raise UsageError.new("transform_rows '#{name}' exceeded #{REDUCER_TIMEOUT_SECONDS}s timeout")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def collect_keys
|
|
61
|
+
prefixes = Array(@spec["select"])
|
|
62
|
+
prefixes.flat_map { |p| @lister.call(prefix: p).map { |row| row["key"] } }.uniq
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def pluck(frontmatter, _body)
|
|
66
|
+
fields = @spec["pluck"]
|
|
67
|
+
if fields.nil? || fields == "*"
|
|
68
|
+
frontmatter
|
|
69
|
+
else
|
|
70
|
+
Array(fields).each_with_object({}) { |f, h| h[f] = frontmatter[f] if frontmatter.key?(f) }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Adds `_first`, `_last`, and `_index` markers so templates can emit
|
|
75
|
+
# delimiters (e.g. JSON commas) via {{^_last}},{{/_last}}.
|
|
76
|
+
def mark_positions(rows)
|
|
77
|
+
last_idx = rows.length - 1
|
|
78
|
+
rows.each_with_index do |row, i|
|
|
79
|
+
row["_index"] = i
|
|
80
|
+
row["_first"] = i.zero?
|
|
81
|
+
row["_last"] = (i == last_idx)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def sort(rows)
|
|
86
|
+
sb = @spec["sort_by"] or return rows
|
|
87
|
+
rows.sort_by { |r| r[sb].to_s }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -8,9 +8,9 @@ module Textus
|
|
|
8
8
|
# correlation_id, limit. Reads the log file as JSON-Lines (legacy TSV
|
|
9
9
|
# rows produce nil and are skipped).
|
|
10
10
|
class Audit
|
|
11
|
-
def initialize(
|
|
12
|
-
@
|
|
13
|
-
@log_path = File.join(
|
|
11
|
+
def initialize(manifest:, root:)
|
|
12
|
+
@manifest = manifest
|
|
13
|
+
@log_path = File.join(root, "audit.log")
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
# rubocop:disable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
@@ -58,7 +58,7 @@ module Textus
|
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
def key_in_zone?(key, zone)
|
|
61
|
-
mentry
|
|
61
|
+
mentry = @manifest.resolver.resolve(key).entry
|
|
62
62
|
mentry && mentry.zone == zone
|
|
63
63
|
rescue Textus::Error
|
|
64
64
|
false
|
|
@@ -8,12 +8,13 @@ module Textus
|
|
|
8
8
|
# row. Falls back to `git => nil` when not in a git repo or when the
|
|
9
9
|
# file is untracked.
|
|
10
10
|
class Blame
|
|
11
|
-
def initialize(
|
|
12
|
-
@
|
|
11
|
+
def initialize(manifest:, root:)
|
|
12
|
+
@manifest = manifest
|
|
13
|
+
@root = root
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def call(key:, limit: nil)
|
|
16
|
-
audit_rows = Textus::Application::Reads::Audit.new(
|
|
17
|
+
audit_rows = Textus::Application::Reads::Audit.new(manifest: @manifest, root: @root).call(key: key, limit: limit)
|
|
17
18
|
path = resolve_path(key)
|
|
18
19
|
return audit_rows.map { |r| r.merge("git" => nil) } unless git_tracked?(path)
|
|
19
20
|
|
|
@@ -23,11 +24,13 @@ module Textus
|
|
|
23
24
|
private
|
|
24
25
|
|
|
25
26
|
def resolve_path(key)
|
|
26
|
-
|
|
27
|
+
res = @manifest.resolver.resolve(key)
|
|
28
|
+
mentry = res.entry
|
|
29
|
+
path = res.path
|
|
27
30
|
# Nested entries resolve to a file under the entry path; leaf entries
|
|
28
31
|
# already have a fully-resolved path. Either way `path` is what git
|
|
29
32
|
# needs to know about.
|
|
30
|
-
path || Textus::Key::Path.resolve(@
|
|
33
|
+
path || Textus::Key::Path.resolve(@manifest, mentry)
|
|
31
34
|
rescue Textus::Error
|
|
32
35
|
nil
|
|
33
36
|
end
|
|
@@ -39,7 +42,7 @@ module Textus
|
|
|
39
42
|
|
|
40
43
|
_out, _err, status = Open3.capture3(
|
|
41
44
|
"git", "ls-files", "--error-unmatch", path,
|
|
42
|
-
chdir: @
|
|
45
|
+
chdir: @root
|
|
43
46
|
)
|
|
44
47
|
status.success?
|
|
45
48
|
rescue Errno::ENOENT
|
|
@@ -48,7 +51,7 @@ module Textus
|
|
|
48
51
|
|
|
49
52
|
def git_repo?
|
|
50
53
|
# Walk up from store root to find a .git directory.
|
|
51
|
-
dir = @
|
|
54
|
+
dir = @root
|
|
52
55
|
loop do
|
|
53
56
|
return true if File.directory?(File.join(dir, ".git"))
|
|
54
57
|
|
|
@@ -63,7 +66,7 @@ module Textus
|
|
|
63
66
|
args = ["git", "log", "-1"]
|
|
64
67
|
args << "--before=#{timestamp}" if timestamp
|
|
65
68
|
args += ["--format=%H%x09%an%x09%aI%x09%s", "--", path]
|
|
66
|
-
out, _err, status = Open3.capture3(*args, chdir: @
|
|
69
|
+
out, _err, status = Open3.capture3(*args, chdir: @root)
|
|
67
70
|
return nil unless status.success?
|
|
68
71
|
|
|
69
72
|
sha, author, date, subject = out.strip.split("\t", 4)
|
|
@@ -2,12 +2,23 @@ module Textus
|
|
|
2
2
|
module Application
|
|
3
3
|
module Reads
|
|
4
4
|
class Deps
|
|
5
|
-
def initialize(
|
|
6
|
-
@
|
|
5
|
+
def initialize(manifest:)
|
|
6
|
+
@manifest = manifest
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def call(key)
|
|
10
|
-
@
|
|
10
|
+
entry = @manifest.entries.find { |e| e.key == key } or return []
|
|
11
|
+
return [] unless entry.is_a?(Textus::Manifest::Entry::Derived)
|
|
12
|
+
|
|
13
|
+
src = entry.source
|
|
14
|
+
result = if src.is_a?(Textus::Manifest::Entry::Derived::Projection)
|
|
15
|
+
Array(src.select).compact
|
|
16
|
+
elsif src.is_a?(Textus::Manifest::Entry::Derived::External)
|
|
17
|
+
Array(src.sources).compact
|
|
18
|
+
else
|
|
19
|
+
[]
|
|
20
|
+
end
|
|
21
|
+
result.uniq
|
|
11
22
|
end
|
|
12
23
|
end
|
|
13
24
|
end
|
|
@@ -8,14 +8,16 @@ module Textus
|
|
|
8
8
|
# current status. Status is one of :fresh, :stale, :never_refreshed, or
|
|
9
9
|
# :no_policy.
|
|
10
10
|
class Freshness
|
|
11
|
-
def initialize(ctx:, evaluator: Textus::Domain::Freshness::Evaluator)
|
|
12
|
-
@ctx
|
|
13
|
-
@
|
|
11
|
+
def initialize(ctx:, manifest:, file_store:, evaluator: Textus::Domain::Freshness::Evaluator)
|
|
12
|
+
@ctx = ctx
|
|
13
|
+
@manifest = manifest
|
|
14
|
+
@file_store = file_store
|
|
15
|
+
@evaluator = evaluator
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
def call(prefix: nil, zone: nil)
|
|
17
19
|
rows = []
|
|
18
|
-
@
|
|
20
|
+
@manifest.entries.each do |mentry|
|
|
19
21
|
next if prefix && !mentry.key.start_with?(prefix)
|
|
20
22
|
next if zone && mentry.zone != zone
|
|
21
23
|
|
|
@@ -27,7 +29,7 @@ module Textus
|
|
|
27
29
|
private
|
|
28
30
|
|
|
29
31
|
def row_for(mentry)
|
|
30
|
-
set = @
|
|
32
|
+
set = @manifest.rules_for(mentry.key)
|
|
31
33
|
refresh = set.refresh
|
|
32
34
|
envelope = safe_get(mentry.key)
|
|
33
35
|
last = envelope&.meta&.dig("last_refreshed_at")
|
|
@@ -61,7 +63,16 @@ module Textus
|
|
|
61
63
|
# Returns the raw envelope or nil. Nested entries (mentry.key is a
|
|
62
64
|
# prefix, not a leaf) and missing files both resolve to nil.
|
|
63
65
|
def safe_get(key)
|
|
64
|
-
@
|
|
66
|
+
res = @manifest.resolver.resolve(key)
|
|
67
|
+
return nil unless @file_store.exists?(res.path)
|
|
68
|
+
|
|
69
|
+
raw = @file_store.read(res.path)
|
|
70
|
+
parsed = Entry.for_format(res.entry.format).parse(raw, path: res.path)
|
|
71
|
+
Envelope.build(
|
|
72
|
+
key: key, mentry: res.entry, path: res.path,
|
|
73
|
+
meta: parsed["_meta"], body: parsed["body"],
|
|
74
|
+
etag: Etag.for_bytes(raw), content: parsed["content"]
|
|
75
|
+
)
|
|
65
76
|
rescue Textus::Error
|
|
66
77
|
nil
|
|
67
78
|
end
|
|
@@ -7,33 +7,59 @@ module Textus
|
|
|
7
7
|
# For interactive reads that want refresh-on-stale, use
|
|
8
8
|
# `Reads::GetOrRefresh`, which composes this with the orchestrator.
|
|
9
9
|
class Get
|
|
10
|
-
def initialize(ctx:, evaluator: Textus::Domain::Freshness::Evaluator)
|
|
11
|
-
@ctx
|
|
12
|
-
@
|
|
10
|
+
def initialize(ctx:, manifest:, file_store:, evaluator: Textus::Domain::Freshness::Evaluator)
|
|
11
|
+
@ctx = ctx
|
|
12
|
+
@manifest = manifest
|
|
13
|
+
@file_store = file_store
|
|
14
|
+
@evaluator = evaluator
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def call(key)
|
|
16
|
-
envelope =
|
|
18
|
+
envelope = read_raw_envelope(key)
|
|
17
19
|
return nil if envelope.nil?
|
|
18
20
|
|
|
19
|
-
policy_set = @
|
|
21
|
+
policy_set = @manifest.rules_for(key)
|
|
20
22
|
refresh_policy = policy_set.refresh
|
|
21
23
|
return annotate_fresh(envelope) if refresh_policy.nil?
|
|
22
24
|
|
|
23
25
|
policy = refresh_policy.to_freshness_policy
|
|
24
26
|
verdict = @evaluator.call(policy, envelope, now: @ctx.now)
|
|
25
27
|
|
|
26
|
-
envelope.with(freshness:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
envelope.with(freshness: Textus::Domain::Freshness.build(
|
|
29
|
+
stale: verdict.stale?,
|
|
30
|
+
reason: verdict.reason,
|
|
31
|
+
refreshing: false,
|
|
32
|
+
))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Strict variant: raises UnknownKey when the entry is missing.
|
|
36
|
+
# Used by consumers (e.g. Validator) that need to distinguish absence
|
|
37
|
+
# from emptiness.
|
|
38
|
+
def get(key)
|
|
39
|
+
call(key) || raise(UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)))
|
|
31
40
|
end
|
|
32
41
|
|
|
33
42
|
private
|
|
34
43
|
|
|
44
|
+
def read_raw_envelope(key)
|
|
45
|
+
res = @manifest.resolver.resolve(key)
|
|
46
|
+
mentry = res.entry
|
|
47
|
+
path = res.path
|
|
48
|
+
return nil unless @file_store.exists?(path)
|
|
49
|
+
|
|
50
|
+
raw = @file_store.read(path)
|
|
51
|
+
parsed = Entry.for_format(mentry.format).parse(raw, path: path)
|
|
52
|
+
Envelope.build(
|
|
53
|
+
key: key, mentry: mentry, path: path,
|
|
54
|
+
meta: parsed["_meta"], body: parsed["body"],
|
|
55
|
+
etag: Etag.for_bytes(raw), content: parsed["content"]
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
35
59
|
def annotate_fresh(envelope)
|
|
36
|
-
envelope.with(freshness:
|
|
60
|
+
envelope.with(freshness: Textus::Domain::Freshness.build(
|
|
61
|
+
stale: false, reason: nil, refreshing: false,
|
|
62
|
+
))
|
|
37
63
|
end
|
|
38
64
|
end
|
|
39
65
|
end
|
|
@@ -10,8 +10,8 @@ module Textus
|
|
|
10
10
|
# Pure reads (build, projection, schema tooling) should use
|
|
11
11
|
# `Reads::Get` directly; it has no orchestrator dependency.
|
|
12
12
|
class GetOrRefresh
|
|
13
|
-
def initialize(
|
|
14
|
-
@
|
|
13
|
+
def initialize(manifest:, get:, orchestrator:)
|
|
14
|
+
@manifest = manifest
|
|
15
15
|
@get = get
|
|
16
16
|
@orchestrator = orchestrator
|
|
17
17
|
end
|
|
@@ -19,14 +19,14 @@ module Textus
|
|
|
19
19
|
def call(key)
|
|
20
20
|
envelope = @get.call(key)
|
|
21
21
|
return nil if envelope.nil?
|
|
22
|
-
return envelope unless envelope.freshness
|
|
22
|
+
return envelope unless envelope.freshness&.stale
|
|
23
23
|
|
|
24
|
-
policy_set = @
|
|
24
|
+
policy_set = @manifest.rules_for(key)
|
|
25
25
|
refresh_policy = policy_set.refresh
|
|
26
26
|
return envelope if refresh_policy.nil?
|
|
27
27
|
|
|
28
28
|
policy = refresh_policy.to_freshness_policy
|
|
29
|
-
verdict = Textus::Domain::Freshness::Verdict.stale(envelope.freshness
|
|
29
|
+
verdict = Textus::Domain::Freshness::Verdict.stale(envelope.freshness.reason)
|
|
30
30
|
action = policy.decide(verdict)
|
|
31
31
|
outcome = @orchestrator.execute(action, key: key)
|
|
32
32
|
|
|
@@ -35,13 +35,13 @@ module Textus
|
|
|
35
35
|
envelope
|
|
36
36
|
when Textus::Domain::Outcome::Refreshed
|
|
37
37
|
outcome.envelope.with(
|
|
38
|
-
freshness:
|
|
38
|
+
freshness: Textus::Domain::Freshness.build(stale: false, reason: nil, refreshing: false),
|
|
39
39
|
)
|
|
40
40
|
when Textus::Domain::Outcome::Detached
|
|
41
|
-
envelope.with(freshness: envelope.freshness.
|
|
41
|
+
envelope.with(freshness: envelope.freshness.with(refreshing: true))
|
|
42
42
|
when Textus::Domain::Outcome::Failed
|
|
43
43
|
envelope.with(
|
|
44
|
-
freshness: envelope.freshness.
|
|
44
|
+
freshness: envelope.freshness.with(refresh_error: outcome.error.message),
|
|
45
45
|
)
|
|
46
46
|
end
|
|
47
47
|
end
|