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.
Files changed (159) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +50 -55
  3. data/CHANGELOG.md +486 -0
  4. data/README.md +13 -9
  5. data/SPEC.md +13 -10
  6. data/docs/conventions.md +2 -2
  7. data/lib/textus/application/context.rb +20 -34
  8. data/lib/textus/application/policy/predicates/human_accept.rb +30 -0
  9. data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
  10. data/lib/textus/{domain → application}/policy/promotion.rb +20 -3
  11. data/lib/textus/application/projection.rb +91 -0
  12. data/lib/textus/application/reads/audit.rb +4 -4
  13. data/lib/textus/application/reads/blame.rb +11 -8
  14. data/lib/textus/application/reads/deps.rb +14 -3
  15. data/lib/textus/application/reads/freshness.rb +17 -6
  16. data/lib/textus/application/reads/get.rb +37 -11
  17. data/lib/textus/application/reads/get_or_refresh.rb +8 -8
  18. data/lib/textus/application/reads/list.rb +5 -3
  19. data/lib/textus/application/reads/policy_explain.rb +3 -3
  20. data/lib/textus/application/reads/published.rb +5 -3
  21. data/lib/textus/application/reads/rdeps.rb +15 -3
  22. data/lib/textus/application/reads/schema_envelope.rb +6 -3
  23. data/lib/textus/application/reads/stale.rb +3 -3
  24. data/lib/textus/application/reads/uid.rb +11 -3
  25. data/lib/textus/application/reads/validate_all.rb +12 -3
  26. data/lib/textus/application/reads/validator.rb +84 -0
  27. data/lib/textus/application/reads/where.rb +6 -3
  28. data/lib/textus/application/refresh/all.rb +16 -5
  29. data/lib/textus/application/refresh/orchestrator.rb +9 -9
  30. data/lib/textus/application/refresh/worker.rb +59 -32
  31. data/lib/textus/application/tools/migrate_keys.rb +191 -0
  32. data/lib/textus/application/tools/migrate_manifest_to_kinds.rb +31 -0
  33. data/lib/textus/application/writes/accept.rb +36 -13
  34. data/lib/textus/application/writes/delete.rb +13 -15
  35. data/lib/textus/application/writes/envelope_io.rb +166 -0
  36. data/lib/textus/application/writes/materializer.rb +50 -0
  37. data/lib/textus/application/writes/mv.rb +56 -95
  38. data/lib/textus/application/writes/publish.rb +132 -27
  39. data/lib/textus/application/writes/put.rb +17 -20
  40. data/lib/textus/application/writes/reject.rb +18 -9
  41. data/lib/textus/builder/pipeline.rb +21 -15
  42. data/lib/textus/builder/renderer/json.rb +4 -1
  43. data/lib/textus/builder/renderer/markdown.rb +7 -1
  44. data/lib/textus/builder/renderer/yaml.rb +4 -1
  45. data/lib/textus/cli/group/hook.rb +1 -3
  46. data/lib/textus/cli/group/key.rb +1 -4
  47. data/lib/textus/cli/group/refresh.rb +1 -2
  48. data/lib/textus/cli/group/rule.rb +1 -3
  49. data/lib/textus/cli/group/schema.rb +1 -5
  50. data/lib/textus/cli/group.rb +12 -16
  51. data/lib/textus/cli/verb/accept.rb +3 -1
  52. data/lib/textus/cli/verb/audit.rb +3 -1
  53. data/lib/textus/cli/verb/blame.rb +3 -1
  54. data/lib/textus/cli/verb/build.rb +4 -5
  55. data/lib/textus/cli/verb/delete.rb +3 -1
  56. data/lib/textus/cli/verb/deps.rb +3 -1
  57. data/lib/textus/cli/verb/doctor.rb +2 -0
  58. data/lib/textus/cli/verb/freshness.rb +3 -1
  59. data/lib/textus/cli/verb/get.rb +4 -2
  60. data/lib/textus/cli/verb/hook_run.rb +6 -4
  61. data/lib/textus/cli/verb/hooks.rb +8 -5
  62. data/lib/textus/cli/verb/init.rb +2 -0
  63. data/lib/textus/cli/verb/intro.rb +2 -0
  64. data/lib/textus/cli/verb/key_normalize.rb +35 -3
  65. data/lib/textus/cli/verb/list.rb +3 -1
  66. data/lib/textus/cli/verb/mv.rb +4 -1
  67. data/lib/textus/cli/verb/published.rb +3 -1
  68. data/lib/textus/cli/verb/put.rb +5 -4
  69. data/lib/textus/cli/verb/rdeps.rb +3 -1
  70. data/lib/textus/cli/verb/refresh.rb +1 -1
  71. data/lib/textus/cli/verb/refresh_stale.rb +4 -2
  72. data/lib/textus/cli/verb/reject.rb +3 -1
  73. data/lib/textus/cli/verb/rule_explain.rb +4 -1
  74. data/lib/textus/cli/verb/rule_list.rb +3 -0
  75. data/lib/textus/cli/verb/schema.rb +4 -1
  76. data/lib/textus/cli/verb/schema_diff.rb +3 -0
  77. data/lib/textus/cli/verb/schema_init.rb +3 -0
  78. data/lib/textus/cli/verb/schema_migrate.rb +3 -0
  79. data/lib/textus/cli/verb/uid.rb +4 -1
  80. data/lib/textus/cli/verb/where.rb +3 -1
  81. data/lib/textus/cli/verb.rb +30 -0
  82. data/lib/textus/cli.rb +18 -27
  83. data/lib/textus/doctor/check/audit_log.rb +1 -1
  84. data/lib/textus/doctor/check/handler_allowlist.rb +3 -2
  85. data/lib/textus/doctor/check/hooks.rb +4 -2
  86. data/lib/textus/doctor/check/illegal_keys.rb +6 -5
  87. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  88. data/lib/textus/doctor/check/manifest_files.rb +1 -1
  89. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  90. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  91. data/lib/textus/doctor/check/sentinels.rb +2 -2
  92. data/lib/textus/doctor/check/templates.rb +4 -3
  93. data/lib/textus/doctor.rb +3 -4
  94. data/lib/textus/domain/authorizer.rb +37 -0
  95. data/lib/textus/domain/freshness/evaluator.rb +1 -1
  96. data/lib/textus/domain/freshness/policy.rb +1 -1
  97. data/lib/textus/domain/freshness/verdict.rb +1 -1
  98. data/lib/textus/domain/freshness.rb +40 -0
  99. data/lib/textus/{store → domain}/sentinel.rb +1 -1
  100. data/lib/textus/{store → domain}/staleness/generator_check.rb +9 -8
  101. data/lib/textus/{store → domain}/staleness/intake_check.rb +3 -3
  102. data/lib/textus/{store → domain}/staleness.rb +1 -1
  103. data/lib/textus/entry/json.rb +1 -1
  104. data/lib/textus/entry/markdown.rb +1 -1
  105. data/lib/textus/entry/yaml.rb +1 -1
  106. data/lib/textus/envelope.rb +7 -3
  107. data/lib/textus/errors.rb +19 -0
  108. data/lib/textus/hooks/builtin.rb +6 -6
  109. data/lib/textus/hooks/bus.rb +155 -0
  110. data/lib/textus/hooks/context.rb +38 -0
  111. data/lib/textus/hooks/fire_report.rb +23 -0
  112. data/lib/textus/hooks/loader.rb +20 -17
  113. data/lib/textus/{store → infra}/audit_log.rb +1 -1
  114. data/lib/textus/infra/audit_subscriber.rb +43 -0
  115. data/lib/textus/infra/event_bus.rb +3 -3
  116. data/lib/textus/infra/publisher.rb +3 -3
  117. data/lib/textus/infra/refresh/detached.rb +1 -1
  118. data/lib/textus/infra/storage/file_store.rb +26 -0
  119. data/lib/textus/init.rb +14 -11
  120. data/lib/textus/intro.rb +7 -7
  121. data/lib/textus/manifest/entry/base.rb +38 -0
  122. data/lib/textus/manifest/entry/derived.rb +25 -0
  123. data/lib/textus/manifest/entry/intake.rb +19 -0
  124. data/lib/textus/manifest/entry/leaf.rb +16 -0
  125. data/lib/textus/manifest/entry/nested.rb +39 -0
  126. data/lib/textus/manifest/entry/parser.rb +64 -31
  127. data/lib/textus/manifest/entry/validators/events.rb +3 -2
  128. data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -3
  129. data/lib/textus/manifest/entry/validators/index_filename.rb +11 -10
  130. data/lib/textus/manifest/entry/validators/inject_intro.rb +5 -2
  131. data/lib/textus/manifest/entry/validators/publish_each.rb +9 -6
  132. data/lib/textus/manifest/entry.rb +0 -72
  133. data/lib/textus/manifest/resolution.rb +5 -0
  134. data/lib/textus/manifest/resolver.rb +109 -0
  135. data/lib/textus/manifest/schema.rb +1 -1
  136. data/lib/textus/manifest.rb +4 -100
  137. data/lib/textus/operations.rb +147 -23
  138. data/lib/textus/schema/tools.rb +7 -7
  139. data/lib/textus/schemas.rb +46 -0
  140. data/lib/textus/store.rb +12 -49
  141. data/lib/textus/uid.rb +18 -0
  142. data/lib/textus/version.rb +1 -1
  143. data/lib/textus.rb +17 -1
  144. metadata +31 -23
  145. data/lib/textus/application/writes/build.rb +0 -79
  146. data/lib/textus/dependencies.rb +0 -23
  147. data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
  148. data/lib/textus/hooks/dispatcher.rb +0 -63
  149. data/lib/textus/hooks/dsl.rb +0 -11
  150. data/lib/textus/hooks/registry.rb +0 -81
  151. data/lib/textus/migrate_keys.rb +0 -187
  152. data/lib/textus/operations/reads.rb +0 -56
  153. data/lib/textus/operations/refresh.rb +0 -27
  154. data/lib/textus/operations/writes.rb +0 -21
  155. data/lib/textus/projection.rb +0 -89
  156. data/lib/textus/refresh.rb +0 -39
  157. data/lib/textus/store/reader.rb +0 -69
  158. data/lib/textus/store/validator.rb +0 -82
  159. 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.14.0`. Decoupled from the protocol string — internal refactors bump the gem; only wire-format changes bump the protocol.
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.on(:resolve_intake, :local_file) do |config:, args:, **|
123
- path = config["path"] or raise "local-file requires intake.config.path"
124
- {
125
- _meta: { "last_refreshed_at" => Time.now.utc.iso8601, "source_path" => path },
126
- body: File.read(File.expand_path(path)),
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.on(:transform_rows, :rank_by_recency) do |rows:, **|
133
- rows.sort_by { |r| r["updated_at"].to_s }.reverse
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.on(:resolve_intake, :my_source) { |config:, args:, **| … }
462
- Textus.on(:transform_rows, :rank_by_recency) { |rows:, **| … }
463
- Textus.on(:validate, :storage_writable) { |store:| … }
464
- Textus.on(:entry_put, :audit, keys: ["working.*"]) { |key:, envelope:, **| … }
465
- Textus.on(:file_published, :git_add, keys: ["derived.*"]) { |target:, **| `git add #{target.shellescape}` }
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.on` is the sole entry point; there is no separate `Textus.hook` primitive.
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, ...)`, `Textus.on(:entry_refreshed, ...)`, etc.) and the manifest YAML schema. The layering is internal and may evolve.
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 memoized facade with `.reads`, `.writes`, and `.refresh` namespaces mirroring the
834
- files under `lib/textus/application/{reads,writes,refresh}/`.
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.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 |
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
- class Context
6
- attr_reader :store, :role, :correlation_id
7
-
8
- def self.system(store)
9
- new(store: store, role: "human")
10
- end
11
-
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
19
- end
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 can_read?(zone)
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
- store: @store,
40
- role: new_role,
41
- correlation_id: @correlation_id,
42
- clock: @clock,
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 Domain
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:, store:) # rubocop:disable Metrics/PerceivedComplexity
13
- return true if entry.nil? || store.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, = store.manifest.resolve(target_key)
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 = store.schema_for(schema_ref)
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 Domain
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:, store:)
39
+ def evaluate(entry:, schemas:, manifest:, role:)
35
40
  reasons = []
36
41
  @predicates.each do |pred|
37
- ok = pred.call(entry: entry, store: store)
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(ctx:)
12
- @ctx = ctx
13
- @log_path = File.join(@ctx.store.root, "audit.log")
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, = @ctx.store.manifest.resolve(key)
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(ctx:)
12
- @ctx = ctx
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(ctx: @ctx).call(key: key, limit: limit)
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
- mentry, path, = @ctx.store.manifest.resolve(key)
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(@ctx.store.manifest, mentry)
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: @ctx.store.root
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 = @ctx.store.root
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: @ctx.store.root)
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(ctx:)
6
- @ctx = ctx
5
+ def initialize(manifest:)
6
+ @manifest = manifest
7
7
  end
8
8
 
9
9
  def call(key)
10
- @ctx.store.reader.deps(key)
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 = ctx
13
- @evaluator = evaluator
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
- @ctx.store.manifest.entries.each do |mentry|
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 = @ctx.store.manifest.rules_for(mentry.key)
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
- @ctx.store.reader.read_raw_envelope(key)
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 = ctx
12
- @evaluator = evaluator
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 = @ctx.store.reader.read_raw_envelope(key)
18
+ envelope = read_raw_envelope(key)
17
19
  return nil if envelope.nil?
18
20
 
19
- policy_set = @ctx.store.manifest.rules_for(key)
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
- "stale" => verdict.stale?,
28
- "stale_reason" => verdict.reason,
29
- "refreshing" => false,
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: { "stale" => false, "stale_reason" => nil, "refreshing" => false })
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(ctx:, get:, orchestrator:)
14
- @ctx = ctx
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["stale"]
22
+ return envelope unless envelope.freshness&.stale
23
23
 
24
- policy_set = @ctx.store.manifest.rules_for(key)
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["stale_reason"])
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: { "stale" => false, "stale_reason" => nil, "refreshing" => false },
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.merge("refreshing" => true))
41
+ envelope.with(freshness: envelope.freshness.with(refreshing: true))
42
42
  when Textus::Domain::Outcome::Failed
43
43
  envelope.with(
44
- freshness: envelope.freshness.merge("refresh_error" => outcome.error.message),
44
+ freshness: envelope.freshness.with(refresh_error: outcome.error.message),
45
45
  )
46
46
  end
47
47
  end