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
@@ -1,187 +0,0 @@
1
- module Textus
2
- # Run-once helper that renames files/directories whose basenames don't
3
- # conform to the strict key grammar (§3 of plan-1.2). Only walks
4
- # nested: true manifest entries — leaf entries with illegal declared
5
- # keys are caught by Manifest load and must be fixed by hand.
6
- module MigrateKeys
7
- SEGMENT = /\A[a-z0-9][a-z0-9-]*\z/
8
-
9
- module_function
10
-
11
- # Returns the envelope hash described in plan-1.2 §3.
12
- def run(store, write: false)
13
- plan = build_plan(store)
14
- collisions = plan[:collisions]
15
- renames = plan[:renames]
16
-
17
- ok = collisions.empty?
18
- apply!(store, renames) if write && ok
19
-
20
- {
21
- "protocol" => Textus::PROTOCOL,
22
- "mode" => write ? "write" : "dry-run",
23
- "renames" => renames.map { |r| envelope_rename(r) },
24
- "collisions" => collisions.map { |c| envelope_collision(c) },
25
- "ok" => ok,
26
- }
27
- end
28
-
29
- # ------------------------------------------------------------------
30
- # Plan construction
31
- # ------------------------------------------------------------------
32
-
33
- # Returns { renames: [...], collisions: [...] }
34
- # Each rename: { from:, to:, old_key:, new_key:, kind: :file|:dir }
35
- # Each collision: { target:, sources: [...] }
36
- def build_plan(store) # rubocop:disable Metrics/AbcSize
37
- renames = []
38
- target_buckets = Hash.new { |h, k| h[k] = [] } # target_path => [source_path, ...]
39
-
40
- store.manifest.entries.each do |entry|
41
- next unless entry.nested
42
-
43
- base = File.join(store.root, "zones", entry.path)
44
- next unless File.directory?(base)
45
-
46
- # Walk depth-first. Order matters when computing the "new key"
47
- # for files inside a renamed directory: we record renames bottom-up,
48
- # so children are renamed before their parents on apply.
49
- walk(base) do |abs_path, is_dir|
50
- next if abs_path == base
51
-
52
- basename = File.basename(abs_path)
53
- stem = is_dir ? basename : basename.sub(/#{Regexp.escape(File.extname(basename))}\z/, "")
54
- next if stem.match?(SEGMENT)
55
-
56
- new_stem = normalize(stem)
57
- # Skip if normalization yields the same stem (e.g. already-legal
58
- # under a different lens). In practice match?(SEGMENT) catches that
59
- # above; this is a safety net.
60
- next if new_stem == stem
61
-
62
- new_basename = is_dir ? new_stem : new_stem + File.extname(basename)
63
- target = File.join(File.dirname(abs_path), new_basename)
64
- target_buckets[target] << abs_path
65
-
66
- renames << {
67
- from: abs_path,
68
- to: target,
69
- kind: is_dir ? :dir : :file,
70
- entry: entry,
71
- base: base,
72
- }
73
- end
74
- end
75
-
76
- collisions = target_buckets.select { |_, srcs| srcs.length > 1 }
77
- .map { |t, srcs| { target: t, sources: srcs.sort } }
78
-
79
- # Drop colliding entries from renames (we won't apply any of them)
80
- colliding_targets = collisions.to_set { |c| c[:target] }
81
- renames.reject! { |r| colliding_targets.include?(r[:to]) }
82
-
83
- # Sort renames bottom-up (deepest path first) so children move before parents.
84
- renames.sort_by! { |r| -r[:from].count("/") }
85
-
86
- { renames: renames, collisions: collisions }
87
- end
88
-
89
- # Yields [absolute_path, is_dir] for every entry under root. Depth-first.
90
- def walk(root, &block)
91
- Dir.each_child(root) do |name|
92
- abs = File.join(root, name)
93
- if File.directory?(abs)
94
- walk(abs, &block)
95
- yield abs, true
96
- else
97
- yield abs, false
98
- end
99
- end
100
- end
101
-
102
- # Deterministic transform per plan §3.
103
- def normalize(s)
104
- s = s.downcase
105
- s = s.gsub(/[^a-z0-9-]/, "-") # ., _, and anything else become -
106
- s = s.gsub(/-+/, "-")
107
- s.sub(/\A-+/, "").sub(/-+\z/, "")
108
- end
109
-
110
- # ------------------------------------------------------------------
111
- # Apply
112
- # ------------------------------------------------------------------
113
-
114
- def apply!(store, renames)
115
- audit = Store::AuditLog.new(store.root)
116
- renames.each do |r|
117
- # Bottom-up order means a child's ancestors haven't moved yet, so
118
- # `from`/`to` are valid as-recorded. The audit `key` reflects the
119
- # eventual full key once every rename in this batch has applied.
120
- from = r[:from]
121
- to = r[:to]
122
- File.rename(from, to)
123
- new_key = compute_new_key(r, renames)
124
- audit.append(
125
- role: "runner",
126
- verb: "migrate-keys",
127
- key: new_key,
128
- etag_before: nil,
129
- etag_after: nil,
130
- extras: { "from" => from, "to" => to },
131
- )
132
- end
133
- end
134
-
135
- # If an ancestor of `path` was renamed earlier in this batch, rewrite the path.
136
- def resolve_current_path(path, renames)
137
- out = path
138
- renames.each do |r|
139
- prefix = r[:from] + "/"
140
- out = r[:to] + out[r[:from].length..] if out.start_with?(prefix)
141
- end
142
- out
143
- end
144
-
145
- # New full key after applying all renames up through this one.
146
- def compute_new_key(rename, renames)
147
- base = rename[:base]
148
- entry = rename[:entry]
149
- new_to = resolve_current_path(rename[:to], renames)
150
-
151
- rel = new_to.sub(%r{\A#{Regexp.escape(base)}/?}, "")
152
- stripped = rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "") unless rename[:kind] == :dir
153
- stripped ||= rel
154
- segs = stripped.split("/").reject(&:empty?)
155
- (entry.key.split(".") + segs).join(".")
156
- end
157
-
158
- # ------------------------------------------------------------------
159
- # Envelope helpers
160
- # ------------------------------------------------------------------
161
-
162
- def envelope_rename(r)
163
- {
164
- "from" => r[:from],
165
- "to" => r[:to],
166
- "old_key" => path_to_key(r[:from], r[:base], r[:entry], r[:kind]),
167
- "new_key" => path_to_key(r[:to], r[:base], r[:entry], r[:kind]),
168
- }
169
- end
170
-
171
- def envelope_collision(col)
172
- { "target" => col[:target], "sources" => col[:sources] }
173
- end
174
-
175
- def path_to_key(path, base, entry, kind)
176
- rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
177
- stripped =
178
- if kind == :dir
179
- rel
180
- else
181
- rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
182
- end
183
- segs = stripped.split("/").reject(&:empty?)
184
- (entry.key.split(".") + segs).join(".")
185
- end
186
- end
187
- end
@@ -1,56 +0,0 @@
1
- module Textus
2
- class Operations
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.
13
- def initialize(ctx)
14
- @ctx = ctx
15
- end
16
-
17
- def get
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
- )
27
- end
28
-
29
- def freshness = Application::Reads::Freshness.new(ctx: @ctx)
30
- def audit = Application::Reads::Audit.new(ctx: @ctx)
31
- def blame = Application::Reads::Blame.new(ctx: @ctx)
32
- def policy_explain = Application::Reads::PolicyExplain.new(ctx: @ctx)
33
- def list = Application::Reads::List.new(ctx: @ctx)
34
- def where = Application::Reads::Where.new(ctx: @ctx)
35
- def uid = Application::Reads::Uid.new(ctx: @ctx)
36
- def schema_envelope = Application::Reads::SchemaEnvelope.new(ctx: @ctx)
37
- def deps = Application::Reads::Deps.new(ctx: @ctx)
38
- def rdeps = Application::Reads::Rdeps.new(ctx: @ctx)
39
- def published = Application::Reads::Published.new(ctx: @ctx)
40
- def stale = Application::Reads::Stale.new(ctx: @ctx)
41
- def validate_all = Application::Reads::ValidateAll.new(ctx: @ctx)
42
-
43
- private
44
-
45
- def orchestrator
46
- Application::Refresh::Orchestrator.new(
47
- worker: Application::Refresh::Worker.new(ctx: @ctx, bus: @ctx.store.bus),
48
- bus: @ctx.store.bus,
49
- store_root: @ctx.store.root,
50
- store: @ctx.store,
51
- role: @ctx.role,
52
- )
53
- end
54
- end
55
- end
56
- end
@@ -1,27 +0,0 @@
1
- module Textus
2
- class Operations
3
- class Refresh
4
- def initialize(ctx)
5
- @ctx = ctx
6
- end
7
-
8
- def worker
9
- Application::Refresh::Worker.new(ctx: @ctx, bus: @ctx.store.bus)
10
- end
11
-
12
- def orchestrator
13
- Application::Refresh::Orchestrator.new(
14
- worker: worker,
15
- bus: @ctx.store.bus,
16
- store_root: @ctx.store.root,
17
- store: @ctx.store,
18
- role: @ctx.role,
19
- )
20
- end
21
-
22
- def all
23
- Application::Refresh::All.new(ctx: @ctx, bus: @ctx.store.bus)
24
- end
25
- end
26
- end
27
- end
@@ -1,21 +0,0 @@
1
- module Textus
2
- class Operations
3
- class Writes
4
- def initialize(ctx)
5
- @ctx = ctx
6
- end
7
-
8
- def put = Application::Writes::Put.new(ctx: @ctx, bus: bus)
9
- def delete = Application::Writes::Delete.new(ctx: @ctx, bus: bus)
10
- def mv = Application::Writes::Mv.new(ctx: @ctx, bus: bus)
11
- def accept = Application::Writes::Accept.new(ctx: @ctx, bus: bus)
12
- def build = Application::Writes::Build.new(ctx: @ctx, bus: bus)
13
- def publish = Application::Writes::Publish.new(ctx: @ctx, bus: bus)
14
- def reject = Application::Writes::Reject.new(ctx: @ctx, bus: bus)
15
-
16
- private
17
-
18
- def bus = @ctx.store.bus
19
- end
20
- end
21
- end
@@ -1,89 +0,0 @@
1
- require "time"
2
- require "timeout"
3
-
4
- module Textus
5
- class Projection
6
- MAX_LIMIT = 1000
7
- REDUCER_TIMEOUT_SECONDS = 2
8
-
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
21
- @limit = (@spec["limit"] || MAX_LIMIT).to_i
22
- raise InvalidProjection.new("limit #{@limit} exceeds max #{MAX_LIMIT}") if @limit > MAX_LIMIT
23
- end
24
-
25
- def run
26
- keys = collect_keys
27
- explicit_pluck = !@spec["pluck"].nil? && @spec["pluck"] != "*"
28
- rows = keys.map do |key|
29
- env = @reader.call(key)
30
- row = pluck(env.meta, env.body)
31
- explicit_pluck ? row : row.merge("_key" => key)
32
- end
33
- reduced = apply_reducer(rows)
34
- # Reducers may return either an Array of rows (legacy / templated builds)
35
- # or a Hash that becomes the structured-format payload base. In the Hash
36
- # case, downstream sort/limit/position markers don't apply, and the
37
- # builder owns `_meta.generated_at` so we don't stamp it here.
38
- return reduced if reduced.is_a?(Hash)
39
-
40
- rows = reduced
41
- rows = sort(rows)
42
- rows = rows.first(@limit)
43
- mark_positions(rows)
44
- { "entries" => rows, "count" => rows.length, "generated_at" => Time.now.utc.iso8601 }
45
- end
46
-
47
- private
48
-
49
- def apply_reducer(rows)
50
- name = @spec["transform"] or return rows
51
- callable = @transform_resolver.call(name)
52
- Timeout.timeout(REDUCER_TIMEOUT_SECONDS) do
53
- callable.call(store: @transform_context, rows: rows, config: @spec["transform_config"] || {})
54
- end
55
- rescue Timeout::Error
56
- raise UsageError.new("transform_rows '#{name}' exceeded #{REDUCER_TIMEOUT_SECONDS}s timeout")
57
- end
58
-
59
- def collect_keys
60
- prefixes = Array(@spec["select"])
61
- prefixes.flat_map { |p| @lister.call(prefix: p).map { |row| row["key"] } }.uniq
62
- end
63
-
64
- def pluck(frontmatter, _body)
65
- fields = @spec["pluck"]
66
- if fields.nil? || fields == "*"
67
- frontmatter
68
- else
69
- Array(fields).each_with_object({}) { |f, h| h[f] = frontmatter[f] if frontmatter.key?(f) }
70
- end
71
- end
72
-
73
- # Adds `_first`, `_last`, and `_index` markers so templates can emit
74
- # delimiters (e.g. JSON commas) via {{^_last}},{{/_last}}.
75
- def mark_positions(rows)
76
- last_idx = rows.length - 1
77
- rows.each_with_index do |row, i|
78
- row["_index"] = i
79
- row["_first"] = i.zero?
80
- row["_last"] = (i == last_idx)
81
- end
82
- end
83
-
84
- def sort(rows)
85
- sb = @spec["sort_by"] or return rows
86
- rows.sort_by { |r| r[sb].to_s }
87
- end
88
- end
89
- end
@@ -1,39 +0,0 @@
1
- module Textus
2
- module Refresh
3
- def self.call(store, key, as:)
4
- Textus::Operations.for(store, role: as).refresh.worker.run(key)
5
- end
6
-
7
- def self.refresh_stale(store, prefix: nil, zone: nil, as: "runner")
8
- ops = Textus::Operations.for(store, role: as)
9
- Textus::Application::Refresh::All.call(ops.ctx, prefix: prefix, zone: zone)
10
- end
11
-
12
- # Normalize the three accepted intake return shapes into the store's
13
- # internal {frontmatter, body, content} representation.
14
- def self.normalize_action_result(res, format:)
15
- res = res.transform_keys(&:to_s) if res.is_a?(Hash)
16
- res ||= {}
17
- meta_val = res["_meta"]
18
- body = res["body"]
19
- content = res["content"]
20
-
21
- case format
22
- when "markdown"
23
- { meta: meta_val || {}, body: body.to_s, content: nil }
24
- when "text"
25
- { meta: {}, body: body.to_s, content: nil }
26
- when "json", "yaml"
27
- if !content.nil?
28
- { meta: meta_val || {}, body: nil, content: content }
29
- elsif !body.nil?
30
- { meta: {}, body: body.to_s, content: nil }
31
- else
32
- raise UsageError.new("intake for #{format} returned neither content nor body")
33
- end
34
- else
35
- raise UsageError.new("unknown format #{format.inspect}")
36
- end
37
- end
38
- end
39
- end
@@ -1,69 +0,0 @@
1
- module Textus
2
- class Store
3
- class Reader
4
- def initialize(store)
5
- @store = store
6
- @manifest = store.manifest
7
- end
8
-
9
- def get(key)
10
- read_raw_envelope(key) || raise(UnknownKey.new(key, suggestions: @manifest.suggestions_for(key)))
11
- end
12
-
13
- # Reads the current on-disk state of key as a bare envelope, skipping
14
- # freshness annotation to avoid recursion. Used by Freshness.refresh_sync
15
- # after a sync refresh completes.
16
- def read_raw_envelope(key)
17
- mentry, path, = @manifest.resolve(key)
18
- return nil unless File.exist?(path)
19
-
20
- raw = File.binread(path)
21
- parsed = Entry.for_format(mentry.format).parse(raw, path: path)
22
- Envelope.build(
23
- key: key, mentry: mentry, path: path,
24
- meta: parsed["_meta"], body: parsed["body"],
25
- etag: Etag.for_bytes(raw), content: parsed["content"]
26
- )
27
- end
28
-
29
- def list(prefix: nil, zone: nil)
30
- rows = @manifest.enumerate(prefix: prefix)
31
- rows = rows.select { |r| r[:manifest_entry].zone == zone } if zone
32
- rows.map { |row| { "key" => row[:key], "zone" => row[:manifest_entry].zone, "path" => row[:path] } }
33
- end
34
-
35
- def where(key)
36
- mentry, path, = @manifest.resolve(key)
37
- { "protocol" => PROTOCOL, "key" => key, "zone" => mentry.zone, "owner" => mentry.owner, "path" => path }
38
- end
39
-
40
- def schema_envelope(key)
41
- mentry, = @manifest.resolve(key)
42
- schema = @store.schema_for(mentry.schema)
43
- { "protocol" => PROTOCOL, "key" => key, "schema_ref" => mentry.schema, "schema" => schema&.to_h }
44
- end
45
-
46
- # Returns the Textus UID for a key (or nil if the entry has none yet).
47
- # Raises UnknownKey if the key doesn't resolve to a real file.
48
- def uid(key)
49
- get(key).uid
50
- end
51
-
52
- def deps(key) = Dependencies.deps_of(@manifest, key)
53
- def rdeps(key) = Dependencies.rdeps_of(@manifest, key)
54
- def published = Dependencies.published_of(@manifest)
55
-
56
- def stale(prefix: nil, zone: nil)
57
- Staleness.new(manifest: @manifest).call(prefix: prefix, zone: zone)
58
- end
59
-
60
- def validate_all
61
- Validator.new(
62
- reader: self, manifest: @manifest,
63
- audit_log: @store.audit_log,
64
- schema_for: ->(name) { @store.schema_for(name) }
65
- ).call
66
- end
67
- end
68
- end
69
- end
@@ -1,82 +0,0 @@
1
- module Textus
2
- class Store
3
- class Validator
4
- def initialize(reader:, manifest:, audit_log:, schema_for:)
5
- @reader = reader
6
- @manifest = manifest
7
- @audit_log = audit_log
8
- @schema_for = schema_for
9
- end
10
-
11
- def call
12
- violations = []
13
- check_content_violations(violations)
14
- check_role_authority_violations(violations)
15
- { "protocol" => PROTOCOL, "ok" => violations.empty?, "violations" => violations }
16
- end
17
-
18
- private
19
-
20
- def check_content_violations(violations)
21
- @manifest.enumerate.each do |row|
22
- key = row[:key]
23
- mentry = row[:manifest_entry]
24
- env = fetch_envelope(key, violations) or next
25
- schema = mentry.schema && @schema_for.call(mentry.schema)
26
- next unless schema
27
-
28
- begin
29
- validate_schema!(schema, env, mentry.format)
30
- rescue Textus::Error => e
31
- violations << { "key" => key, "code" => e.code, "message" => e.message }
32
- end
33
- end
34
- end
35
-
36
- def check_role_authority_violations(violations)
37
- @manifest.enumerate.each do |row|
38
- mentry = row[:manifest_entry]
39
- next unless mentry.schema
40
-
41
- schema = @schema_for.call(mentry.schema)
42
- next unless schema
43
-
44
- env = begin
45
- @reader.get(row[:key])
46
- rescue StandardError
47
- next
48
- end
49
- append_authority_violations(violations, row[:key], env, schema)
50
- end
51
- end
52
-
53
- def append_authority_violations(violations, key, env, schema)
54
- last_writer = @audit_log.last_writer_for(key)
55
- return if last_writer.nil?
56
-
57
- env.meta.each_key do |field|
58
- owner = schema.maintained_by(field)
59
- next if owner.nil? || last_writer == owner || last_writer == "human"
60
-
61
- violations << { "key" => key, "code" => "role_authority",
62
- "field" => field, "expected" => owner, "last_writer" => last_writer }
63
- end
64
- end
65
-
66
- def fetch_envelope(key, violations)
67
- @reader.get(key)
68
- rescue Textus::Error => e
69
- violations << { "key" => key, "code" => e.code, "message" => e.message }
70
- nil
71
- end
72
-
73
- def validate_schema!(schema, envelope, format)
74
- payload = case format
75
- when "json", "yaml" then envelope.content || {}
76
- else envelope.meta || {}
77
- end
78
- schema.validate!(payload)
79
- end
80
- end
81
- end
82
- end
@@ -1,102 +0,0 @@
1
- require "fileutils"
2
-
3
- module Textus
4
- class Store
5
- class Writer
6
- Payload = Data.define(:meta, :body, :content)
7
-
8
- def initialize(store)
9
- @store = store
10
- @manifest = store.manifest
11
- @reader = store.reader
12
- end
13
-
14
- # Pure I/O: validate, serialize, etag-check, write to disk, audit. No
15
- # permission check and no event firing — those are handled by the caller
16
- # (Application::Writes::Put).
17
- def write_envelope_to_disk(key, mentry:, payload:, ctx:, if_etag: nil)
18
- _, path, = @manifest.resolve(key)
19
-
20
- meta = payload.meta || {}
21
- strategy = Entry.for_format(mentry.format)
22
-
23
- existing_uid = existing_uid_for(mentry, path)
24
- meta, content = ensure_uid(mentry.format, meta, payload.content, existing_uid)
25
-
26
- bytes, eff_meta, eff_body, eff_content = serialize_for_put(
27
- mentry: mentry, path: path, strategy: strategy,
28
- meta: meta, body: payload.body, content: content
29
- )
30
-
31
- enforce_name_match!(path, eff_meta, mentry.format)
32
-
33
- schema = @store.schema_for(mentry.schema)
34
- if schema
35
- Entry.for_format(mentry.format).validate_against(
36
- schema,
37
- { "_meta" => eff_meta, "content" => eff_content },
38
- )
39
- end
40
-
41
- etag_before = File.exist?(path) ? Etag.for_file(path) : nil
42
- raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && (etag_before != if_etag)
43
-
44
- FileUtils.mkdir_p(File.dirname(path))
45
- File.binwrite(path, bytes)
46
- etag_after = Etag.for_bytes(bytes)
47
- @store.audit_log.append(
48
- role: ctx.role, verb: "put", key: key,
49
- etag_before: etag_before, etag_after: etag_after,
50
- extras: ctx.correlation_id ? { "correlation_id" => ctx.correlation_id } : nil
51
- )
52
- Envelope.build(
53
- key: key, mentry: mentry, path: path,
54
- meta: eff_meta, body: eff_body, etag: etag_after, content: eff_content
55
- )
56
- end
57
-
58
- def existing_uid_for(mentry, path)
59
- return nil unless File.exist?(path)
60
-
61
- raw = File.binread(path)
62
- parsed = Entry.for_format(mentry.format).parse(raw, path: path)
63
- Envelope.extract_uid(parsed["_meta"])
64
- rescue StandardError
65
- nil
66
- end
67
-
68
- def ensure_uid(format, meta, content, existing_uid)
69
- Textus::Entry.for_format(format).inject_uid(meta, content, existing_uid)
70
- end
71
-
72
- def enforce_name_match!(path, meta, format)
73
- Textus::Entry.for_format(format).enforce_name_match!(path, meta)
74
- end
75
-
76
- def serialize_for_put(mentry:, path:, strategy:, meta:, body:, content:)
77
- _ = strategy
78
- Textus::Entry.for_format(mentry.format).serialize_for_put(
79
- meta: meta, body: body, content: content, path: path,
80
- )
81
- end
82
-
83
- # Pure I/O: resolve path, validate etag, delete from disk, audit. No
84
- # permission check and no event firing — those are handled by the caller
85
- # (Application::Writes::Delete).
86
- def delete_envelope_from_disk(key, ctx:, if_etag: nil)
87
- _, path, = @manifest.resolve(key)
88
- raise UnknownKey.new(key, suggestions: @manifest.suggestions_for(key)) unless File.exist?(path)
89
-
90
- etag_before = Etag.for_file(path)
91
- raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before
92
-
93
- File.delete(path)
94
- @store.audit_log.append(
95
- role: ctx.role, verb: "delete", key: key,
96
- etag_before: etag_before, etag_after: nil,
97
- extras: ctx.correlation_id ? { "correlation_id" => ctx.correlation_id } : nil
98
- )
99
- end
100
- end
101
- end
102
- end