textus 0.54.2 → 0.55.2

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 (242) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -1
  3. data/README.md +17 -10
  4. data/SPEC.md +39 -11
  5. data/docs/architecture/README.md +23 -11
  6. data/docs/reference/conventions.md +5 -2
  7. data/exe/textus +1 -1
  8. data/lib/textus/boot.rb +64 -93
  9. data/lib/textus/dispatch/binder.rb +48 -0
  10. data/lib/textus/dispatch/contracts.rb +63 -0
  11. data/lib/textus/dispatch/handler_registry.rb +21 -0
  12. data/lib/textus/dispatch/middleware/audit_index.rb +51 -0
  13. data/lib/textus/dispatch/middleware/auth.rb +40 -0
  14. data/lib/textus/dispatch/middleware/base.rb +26 -0
  15. data/lib/textus/dispatch/middleware/binder.rb +20 -0
  16. data/lib/textus/dispatch/middleware/cascade.rb +53 -0
  17. data/lib/textus/dispatch/pipeline.rb +35 -0
  18. data/lib/textus/doctor/check/audit_log.rb +2 -2
  19. data/lib/textus/doctor/check/generator_drift.rb +4 -4
  20. data/lib/textus/doctor/check/orphaned_publish_targets.rb +3 -3
  21. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  22. data/lib/textus/doctor/check/raw_asset_paths.rb +1 -1
  23. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  24. data/lib/textus/doctor/check/schema_violations.rb +2 -2
  25. data/lib/textus/doctor/check/schemas.rb +1 -1
  26. data/lib/textus/doctor/check/{notebook_sources.rb → scratchpad_sources.rb} +10 -5
  27. data/lib/textus/doctor/check/sentinels.rb +4 -4
  28. data/lib/textus/doctor/check/templates.rb +1 -1
  29. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  30. data/lib/textus/doctor/check.rb +9 -10
  31. data/lib/textus/doctor.rb +2 -2
  32. data/lib/textus/errors.rb +8 -0
  33. data/lib/textus/format/base.rb +34 -10
  34. data/lib/textus/format/json.rb +5 -27
  35. data/lib/textus/format/markdown.rb +5 -27
  36. data/lib/textus/format/text.rb +5 -4
  37. data/lib/textus/format/yaml.rb +30 -27
  38. data/lib/textus/format.rb +22 -1
  39. data/lib/textus/handlers/maintenance/boot_store.rb +15 -0
  40. data/lib/textus/handlers/maintenance/doctor_store.rb +15 -0
  41. data/lib/textus/handlers/maintenance/drain_store.rb +21 -0
  42. data/lib/textus/handlers/maintenance/ingest_entry.rb +159 -0
  43. data/lib/textus/handlers/maintenance/jobs_action.rb +21 -0
  44. data/lib/textus/handlers/maintenance/published_entries.rb +17 -0
  45. data/lib/textus/handlers/maintenance/rule_explain.rb +77 -0
  46. data/lib/textus/handlers/maintenance/rule_lint.rb +54 -0
  47. data/lib/textus/handlers/maintenance/rule_list.rb +32 -0
  48. data/lib/textus/handlers/maintenance/schema_envelope.rb +19 -0
  49. data/lib/textus/handlers/read/audit_entries.rb +48 -0
  50. data/lib/textus/handlers/read/blame_entry.rb +71 -0
  51. data/lib/textus/handlers/read/deps_entry.rb +17 -0
  52. data/lib/textus/handlers/read/get_entry.rb +68 -0
  53. data/lib/textus/handlers/read/list_keys.rb +36 -0
  54. data/lib/textus/handlers/read/pulse_entries.rb +66 -0
  55. data/lib/textus/handlers/read/rdeps_entry.rb +21 -0
  56. data/lib/textus/handlers/read/uid_entry.rb +18 -0
  57. data/lib/textus/handlers/read/where_entry.rb +18 -0
  58. data/lib/textus/handlers/write/accept_proposal.rb +39 -0
  59. data/lib/textus/handlers/write/data_mv.rb +55 -0
  60. data/lib/textus/handlers/write/delete_key.rb +17 -0
  61. data/lib/textus/handlers/write/enqueue_job.rb +27 -0
  62. data/lib/textus/handlers/write/key_delete_prefix.rb +32 -0
  63. data/lib/textus/handlers/write/key_mv_prefix.rb +45 -0
  64. data/lib/textus/handlers/write/move_key.rb +80 -0
  65. data/lib/textus/handlers/write/propose_entry.rb +29 -0
  66. data/lib/textus/handlers/write/put_entry.rb +29 -0
  67. data/lib/textus/handlers/write/reject_proposal.rb +29 -0
  68. data/lib/textus/init.rb +5 -7
  69. data/lib/textus/jobs.rb +3 -9
  70. data/lib/textus/manifest/capabilities.rb +4 -4
  71. data/lib/textus/manifest/entry/base.rb +3 -3
  72. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
  73. data/lib/textus/manifest/entry/publish/to_paths.rb +2 -2
  74. data/lib/textus/manifest/policy/predicates/author_held.rb +22 -0
  75. data/lib/textus/manifest/policy/predicates/etag_match.rb +18 -0
  76. data/lib/textus/manifest/policy/predicates/fresh_within.rb +13 -0
  77. data/lib/textus/manifest/policy/predicates/lane_deletable_by.rb +31 -0
  78. data/lib/textus/manifest/policy/predicates/lane_writable_by.rb +23 -0
  79. data/lib/textus/manifest/policy/predicates/raw_lane_ingest_only.rb +25 -0
  80. data/lib/textus/manifest/policy/predicates/raw_write_once.rb +24 -0
  81. data/lib/textus/manifest/policy/predicates/schema_valid.rb +41 -0
  82. data/lib/textus/manifest/policy/predicates/target_is_canon.rb +20 -0
  83. data/lib/textus/manifest/policy/predicates.rb +54 -0
  84. data/lib/textus/manifest/policy/retention.rb +1 -1
  85. data/lib/textus/manifest/schema/semantics/cross_field.rb +53 -0
  86. data/lib/textus/manifest/schema/semantics/invariants.rb +125 -0
  87. data/lib/textus/manifest/schema/semantics/migration.rb +83 -0
  88. data/lib/textus/manifest/schema/semantics.rb +11 -216
  89. data/lib/textus/orchestration.rb +55 -0
  90. data/lib/textus/{ports → port}/audit_log.rb +46 -6
  91. data/lib/textus/{ports → port}/build_lock.rb +2 -2
  92. data/lib/textus/{ports → port}/clock.rb +1 -1
  93. data/lib/textus/{ports → port}/publisher.rb +5 -5
  94. data/lib/textus/{core → port}/sentinel.rb +1 -6
  95. data/lib/textus/{ports → port}/sentinel_store.rb +4 -4
  96. data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
  97. data/lib/textus/port/storage/file_store.rb +49 -0
  98. data/lib/textus/port/storage/interface.rb +17 -0
  99. data/lib/textus/port/store.rb +149 -0
  100. data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
  101. data/lib/textus/produce/engine.rb +1 -11
  102. data/lib/textus/produce/publisher.rb +21 -0
  103. data/lib/textus/schema/registry.rb +42 -0
  104. data/lib/textus/schema/tools.rb +7 -10
  105. data/lib/textus/store/container.rb +173 -0
  106. data/lib/textus/store/cursor.rb +26 -0
  107. data/lib/textus/store/entry/reader.rb +47 -0
  108. data/lib/textus/store/entry/writer.rb +219 -0
  109. data/lib/textus/store/envelope/meta.rb +61 -0
  110. data/lib/textus/store/freshness/drift_detector.rb +93 -0
  111. data/lib/textus/store/freshness/evaluator.rb +20 -0
  112. data/lib/textus/store/freshness/ttl_evaluator.rb +57 -0
  113. data/lib/textus/{core → store}/freshness/verdict.rb +1 -11
  114. data/lib/textus/store/freshness.rb +8 -0
  115. data/lib/textus/store/index/builder.rb +76 -0
  116. data/lib/textus/store/index/lookup.rb +60 -0
  117. data/lib/textus/store/jobs/base.rb +13 -0
  118. data/lib/textus/store/jobs/index.rb +15 -0
  119. data/lib/textus/store/jobs/materialize.rb +15 -0
  120. data/lib/textus/store/jobs/plan.rb +11 -0
  121. data/lib/textus/store/jobs/planner.rb +124 -0
  122. data/lib/textus/store/jobs/queue.rb +162 -0
  123. data/lib/textus/store/jobs/registry.rb +19 -0
  124. data/lib/textus/store/jobs/retention/base.rb +52 -0
  125. data/lib/textus/store/jobs/retention/sweep.rb +55 -0
  126. data/lib/textus/store/jobs/retention.rb +8 -0
  127. data/lib/textus/store/jobs/sweep.rb +21 -0
  128. data/lib/textus/store/jobs/worker.rb +64 -0
  129. data/lib/textus/store/layout.rb +97 -0
  130. data/lib/textus/store.rb +63 -32
  131. data/lib/textus/{surfaces → surface}/cli/group/data.rb +1 -1
  132. data/lib/textus/{surfaces → surface}/cli/group/key.rb +1 -1
  133. data/lib/textus/{surfaces → surface}/cli/group/mcp.rb +1 -1
  134. data/lib/textus/{surfaces → surface}/cli/group/rule.rb +1 -1
  135. data/lib/textus/{surfaces → surface}/cli/group/schema.rb +1 -1
  136. data/lib/textus/{surfaces → surface}/cli/group.rb +2 -2
  137. data/lib/textus/{surfaces → surface}/cli/runner.rb +14 -68
  138. data/lib/textus/surface/cli/sources.rb +41 -0
  139. data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +5 -6
  140. data/lib/textus/{surfaces → surface}/cli/verb/get.rb +7 -5
  141. data/lib/textus/{surfaces → surface}/cli/verb/init.rb +1 -1
  142. data/lib/textus/{surfaces → surface}/cli/verb/mcp_serve.rb +3 -3
  143. data/lib/textus/surface/cli/verb/put.rb +27 -0
  144. data/lib/textus/{surfaces → surface}/cli/verb/schema_diff.rb +1 -1
  145. data/lib/textus/{surfaces → surface}/cli/verb/schema_init.rb +1 -1
  146. data/lib/textus/{surfaces → surface}/cli/verb/schema_migrate.rb +1 -1
  147. data/lib/textus/{surfaces → surface}/cli/verb/watch.rb +2 -2
  148. data/lib/textus/{surfaces → surface}/cli/verb.rb +3 -8
  149. data/lib/textus/{surfaces → surface}/cli.rb +1 -1
  150. data/lib/textus/surface/mcp/catalog.rb +58 -0
  151. data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -11
  152. data/lib/textus/surface/mcp/projector.rb +20 -0
  153. data/lib/textus/{surfaces → surface}/mcp/server.rb +24 -35
  154. data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
  155. data/lib/textus/{surfaces → surface}/watcher.rb +8 -8
  156. data/lib/textus/value/call.rb +30 -0
  157. data/lib/textus/value/command.rb +16 -0
  158. data/lib/textus/{core → value}/duration.rb +1 -4
  159. data/lib/textus/value/envelope.rb +90 -0
  160. data/lib/textus/value/etag.rb +39 -0
  161. data/lib/textus/value/payload.rb +7 -0
  162. data/lib/textus/value/result.rb +46 -0
  163. data/lib/textus/value/role.rb +38 -0
  164. data/lib/textus/value/types.rb +13 -0
  165. data/lib/textus/{uid.rb → value/uid.rb} +9 -7
  166. data/lib/textus/verb_registry.rb +417 -0
  167. data/lib/textus/version.rb +1 -1
  168. data/lib/textus/workflow/loader.rb +4 -4
  169. data/lib/textus/workflow/runner.rb +11 -33
  170. data/lib/textus.rb +4 -69
  171. metadata +142 -105
  172. data/lib/textus/action/accept.rb +0 -44
  173. data/lib/textus/action/audit.rb +0 -131
  174. data/lib/textus/action/base.rb +0 -42
  175. data/lib/textus/action/blame.rb +0 -97
  176. data/lib/textus/action/boot.rb +0 -17
  177. data/lib/textus/action/data_mv.rb +0 -69
  178. data/lib/textus/action/deps.rb +0 -34
  179. data/lib/textus/action/doctor.rb +0 -24
  180. data/lib/textus/action/drain.rb +0 -39
  181. data/lib/textus/action/enqueue.rb +0 -53
  182. data/lib/textus/action/get.rb +0 -78
  183. data/lib/textus/action/ingest.rb +0 -203
  184. data/lib/textus/action/jobs.rb +0 -36
  185. data/lib/textus/action/key_delete.rb +0 -36
  186. data/lib/textus/action/key_delete_prefix.rb +0 -44
  187. data/lib/textus/action/key_mv.rb +0 -129
  188. data/lib/textus/action/key_mv_prefix.rb +0 -57
  189. data/lib/textus/action/list.rb +0 -42
  190. data/lib/textus/action/propose.rb +0 -52
  191. data/lib/textus/action/published.rb +0 -24
  192. data/lib/textus/action/pulse.rb +0 -56
  193. data/lib/textus/action/put.rb +0 -52
  194. data/lib/textus/action/rdeps.rb +0 -39
  195. data/lib/textus/action/reject.rb +0 -37
  196. data/lib/textus/action/rule_explain.rb +0 -91
  197. data/lib/textus/action/rule_lint.rb +0 -68
  198. data/lib/textus/action/rule_list.rb +0 -42
  199. data/lib/textus/action/schema_envelope.rb +0 -29
  200. data/lib/textus/action/uid.rb +0 -33
  201. data/lib/textus/action/where.rb +0 -36
  202. data/lib/textus/action/write_verb.rb +0 -44
  203. data/lib/textus/call.rb +0 -28
  204. data/lib/textus/command.rb +0 -41
  205. data/lib/textus/container.rb +0 -26
  206. data/lib/textus/contract/around.rb +0 -29
  207. data/lib/textus/contract/binder.rb +0 -88
  208. data/lib/textus/contract/resources/build_lock.rb +0 -17
  209. data/lib/textus/contract/resources/cursor.rb +0 -26
  210. data/lib/textus/contract/sources.rb +0 -39
  211. data/lib/textus/contract/view.rb +0 -15
  212. data/lib/textus/contract.rb +0 -174
  213. data/lib/textus/core/freshness/evaluator.rb +0 -150
  214. data/lib/textus/core/freshness.rb +0 -11
  215. data/lib/textus/core/retention/sweep.rb +0 -57
  216. data/lib/textus/core/retention.rb +0 -11
  217. data/lib/textus/cursor_store.rb +0 -24
  218. data/lib/textus/envelope/reader.rb +0 -46
  219. data/lib/textus/envelope/writer.rb +0 -209
  220. data/lib/textus/envelope.rb +0 -79
  221. data/lib/textus/etag.rb +0 -36
  222. data/lib/textus/gate/auth.rb +0 -227
  223. data/lib/textus/gate.rb +0 -116
  224. data/lib/textus/jobs/base.rb +0 -23
  225. data/lib/textus/jobs/materialize.rb +0 -20
  226. data/lib/textus/jobs/plan.rb +0 -9
  227. data/lib/textus/jobs/planner.rb +0 -101
  228. data/lib/textus/jobs/retention.rb +0 -48
  229. data/lib/textus/jobs/sweep.rb +0 -27
  230. data/lib/textus/jobs/worker.rb +0 -67
  231. data/lib/textus/layout.rb +0 -91
  232. data/lib/textus/ports/job_store/job.rb +0 -65
  233. data/lib/textus/ports/job_store.rb +0 -123
  234. data/lib/textus/ports/raw_index.rb +0 -61
  235. data/lib/textus/ports/storage/file_store.rb +0 -26
  236. data/lib/textus/role.rb +0 -36
  237. data/lib/textus/schemas.rb +0 -54
  238. data/lib/textus/session.rb +0 -35
  239. data/lib/textus/surfaces/cli/verb/put.rb +0 -30
  240. data/lib/textus/surfaces/mcp/catalog.rb +0 -111
  241. data/lib/textus/surfaces/role_scope.rb +0 -34
  242. data/lib/textus/types.rb +0 -15
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ class Store
5
+ module Freshness
6
+ class TtlEvaluator
7
+ def initialize(manifest:, file_stat:, clock:)
8
+ @manifest = manifest
9
+ @file_stat = file_stat
10
+ @clock = clock
11
+ end
12
+
13
+ def verdict(mentry)
14
+ ttl = @manifest.rules.for(mentry.key).retention&.ttl_seconds
15
+ return fresh if ttl.nil?
16
+
17
+ stale = age_stale?(file_basis(mentry), ttl)
18
+ Verdict.build(stale: stale, reason: stale ? "ttl exceeded" : nil, fetching: false)
19
+ end
20
+
21
+ def stale_keys(prefix: nil, lane: nil)
22
+ @manifest.data.entries.select { |m| due?(m, prefix: prefix, lane: lane) }.map(&:key)
23
+ end
24
+
25
+ private
26
+
27
+ def fresh = Verdict.build(stale: false, reason: nil, fetching: false)
28
+
29
+ def file_basis(mentry)
30
+ path = @manifest.resolver.resolve(mentry.key).path
31
+ return nil unless @file_stat.exists?(path)
32
+
33
+ @file_stat.mtime(path)
34
+ end
35
+
36
+ def due?(mentry, prefix:, lane:)
37
+ return false if lane && mentry.lane != lane
38
+ return false if prefix && !mentry.key.start_with?(prefix)
39
+
40
+ ttl = @manifest.rules.for(mentry.key).retention&.ttl_seconds
41
+ return false if ttl.nil?
42
+
43
+ path = @manifest.resolver.resolve(mentry.key).path
44
+ return true unless @file_stat.exists?(path)
45
+
46
+ age_stale?(file_basis(mentry), ttl)
47
+ end
48
+
49
+ def age_stale?(basis, ttl)
50
+ return true if basis.nil?
51
+
52
+ (@clock.now - basis).to_i > ttl
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -1,16 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  module Textus
4
- module Core
2
+ class Store
5
3
  module Freshness
6
- # Value object describing the freshness annotation attached to an Envelope
7
- # after a currency evaluation (ADR 0099 — was Core::Freshness).
8
- #
9
- # Note on wire format: `#to_h_for_wire` is intentionally narrower than the
10
- # full field set. It emits the legacy keys ("stale", "stale_reason",
11
- # "fetching", and "fetch_error" when present) so the CLI JSON wire stays
12
- # byte-identical with textus/4. The gem-side fields `checked_at` and
13
- # `ttl_remaining_ms` are NOT emitted on the wire.
14
4
  Verdict = Data.define(
15
5
  :stale, :fetching, :reason, :fetch_error, :checked_at, :ttl_remaining_ms
16
6
  ) do
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ class Store
5
+ module Freshness
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Textus
7
+ class Store
8
+ module Index
9
+ class Builder
10
+ def initialize(store:)
11
+ @store = store
12
+ end
13
+
14
+ def rebuild!(resolver:)
15
+ rows = resolver.enumerate.filter_map { |row| build_row(row) }
16
+ now_iso = Time.now.utc.iso8601
17
+
18
+ @store.transaction do
19
+ @store.execute("DELETE FROM entries")
20
+ rows.each do |data|
21
+ @store.execute(
22
+ "INSERT INTO entries (key, lane, format, etag, content, extra, indexed_at, schema_ref)
23
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
24
+ [data[:key], data[:lane], data[:format], data[:etag],
25
+ data[:content], data[:extra], now_iso, data[:schema_ref]],
26
+ )
27
+ end
28
+ @store.execute("INSERT INTO entries_fts(entries_fts) VALUES('rebuild')")
29
+ end
30
+ { indexed: rows.size }
31
+ end
32
+
33
+ private
34
+
35
+ def build_row(row)
36
+ key = row.fetch(:key)
37
+ path = row.fetch(:path)
38
+ entry = row.fetch(:manifest_entry)
39
+ return nil unless path && File.file?(path)
40
+
41
+ raw = File.read(path)
42
+ parsed = Textus::Format.for(entry.format).parse(raw, path: path)
43
+ {
44
+ key: key,
45
+ lane: entry.lane,
46
+ format: entry.format.to_s,
47
+ etag: Textus::Value::Etag.for_bytes(raw),
48
+ content: content_text(parsed),
49
+ extra: extra_json(parsed),
50
+ schema_ref: entry.schema,
51
+ }
52
+ end
53
+
54
+ def content_text(parsed)
55
+ content = parsed["content"]
56
+ body = parsed["body"]
57
+ parts = []
58
+ parts << body if body
59
+ parts << JSON.dump(content) if content
60
+ parts.join("\n")
61
+ end
62
+
63
+ def extra_json(parsed)
64
+ content = parsed["content"]
65
+ extra = {}
66
+ if content.is_a?(Hash)
67
+ extra["content_hash"] = content["content_hash"] if content["content_hash"]
68
+ url = content.dig("source", "url")
69
+ extra["url"] = url if url
70
+ end
71
+ JSON.dump(extra)
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Textus
6
+ class Store
7
+ module Index
8
+ class Lookup
9
+ def initialize(store:)
10
+ @store = store
11
+ end
12
+
13
+ def search(query, lane: nil)
14
+ return [] if query.to_s.strip.empty?
15
+
16
+ clauses = ["entries_fts MATCH ?"]
17
+ params = [query]
18
+ if lane
19
+ clauses << "entries.lane = ?"
20
+ params << lane
21
+ end
22
+ conditions = "WHERE #{clauses.join(" AND ")}"
23
+ @store.execute(
24
+ "SELECT entries.key, entries.lane, entries.format, entries.etag, bm25(entries_fts) AS rank
25
+ FROM entries_fts JOIN entries ON entries_fts.rowid = entries.rowid
26
+ #{conditions}
27
+ ORDER BY rank",
28
+ params,
29
+ )
30
+ rescue SQLite3::SQLException
31
+ []
32
+ end
33
+
34
+ def find_by_hash(content_hash)
35
+ return nil if content_hash.to_s.empty?
36
+
37
+ find_extra("content_hash", content_hash)
38
+ end
39
+
40
+ def find_by_url(url)
41
+ return nil if url.to_s.empty?
42
+
43
+ find_extra("url", url)
44
+ end
45
+
46
+ private
47
+
48
+ def find_extra(name, value)
49
+ @store.execute("SELECT key, extra FROM entries ORDER BY indexed_at DESC").each do |row|
50
+ extra = JSON.parse(row["extra"] || "{}")
51
+ return row["key"] if extra[name] == value
52
+ end
53
+ nil
54
+ rescue SQLite3::SQLException
55
+ nil
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,13 @@
1
+ module Textus
2
+ class Store
3
+ module Jobs
4
+ class Base
5
+ def call(**)
6
+ raise NotImplementedError.new("#{self.class}#call")
7
+ end
8
+
9
+ def args = {}
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ class Store
5
+ module Jobs
6
+ class Index < Base
7
+ TYPE = "index"
8
+
9
+ def self.call(container:, call:) # rubocop:disable Lint/UnusedMethodArgument
10
+ Textus::Store::Index::Builder.new(store: container.job_store).rebuild!(resolver: container.manifest.resolver)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ class Store
5
+ module Jobs
6
+ class Materialize < Base
7
+ TYPE = "materialize"
8
+
9
+ def self.call(container:, call:, key:)
10
+ Textus::Produce::Engine.converge(container: container, call: call, keys: [key])
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,11 @@
1
+ module Textus
2
+ class Store
3
+ module Jobs
4
+ Plan = Data.define(:steps, :warnings) do
5
+ def to_h
6
+ { "steps" => steps, "warnings" => warnings }
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ class Store
5
+ module Jobs
6
+ class Planner
7
+ ACTIONS_BY_TRIGGER = {
8
+ "convergence" => %w[materialize sweep index],
9
+ "entry.written" => %w[materialize],
10
+ "entry.deleted" => %w[materialize],
11
+ "entry.moved" => %w[materialize],
12
+ "proposal.accepted" => %w[materialize],
13
+ "proposal.rejected" => %w[materialize],
14
+ }.freeze
15
+
16
+ ENTRY_LEVEL_TRIGGERS = %w[
17
+ entry.written entry.deleted entry.moved
18
+ proposal.accepted proposal.rejected
19
+ ].freeze
20
+
21
+ SCOPE_RESOLVERS = {
22
+ "materialize" => :producible_keys,
23
+ "sweep" => :lane_keys,
24
+ }.freeze
25
+
26
+ GLOBAL_ACTIONS = {
27
+ "index" => {},
28
+ "sweep" => { "scope" => {} },
29
+ }.freeze
30
+
31
+ def self.seed(container:, queue:, role:)
32
+ jobs = new(container: container).plan(
33
+ trigger: { "type" => "convergence" },
34
+ role: role,
35
+ )
36
+ jobs.each { |j| queue.enqueue(j) }
37
+ end
38
+
39
+ def initialize(container:)
40
+ @container = container
41
+ @manifest = container.manifest
42
+ end
43
+
44
+ def plan(trigger:, role:)
45
+ type = trigger["type"] || trigger[:type]
46
+ target = trigger["target"] || trigger[:target]
47
+ return [] if type.nil?
48
+
49
+ blocks_with_react = @manifest.rules.blocks.select(&:react)
50
+ if blocks_with_react.any?
51
+ plan_from_rules(blocks_with_react, type, role, target: target)
52
+ else
53
+ plan_from_defaults(type, role, target: target)
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def plan_from_rules(blocks, type, role, target: nil) # rubocop:disable Lint/UnusedMethodArgument
60
+ jobs = []
61
+ blocks
62
+ .select { |b| matches_trigger?(b.react, type) }
63
+ .each do |block|
64
+ do_action = block.react.raw["do"]
65
+ Array(do_action).each do |action|
66
+ if (global_args = GLOBAL_ACTIONS[action])
67
+ jobs << Textus::Store::Jobs::Queue::Job.new(type: action, args: global_args, role: role)
68
+ else
69
+ resolver = SCOPE_RESOLVERS.fetch(action, :producible_keys)
70
+ keys = send(resolver, nil)
71
+ keys.each { |key| jobs << job(action, key, role) }
72
+ end
73
+ end
74
+ end
75
+ jobs
76
+ end
77
+
78
+ def plan_from_defaults(type, role, target: nil)
79
+ actions = ACTIONS_BY_TRIGGER.fetch(type, [])
80
+ jobs = []
81
+ if actions.include?("materialize")
82
+ keys = target && ENTRY_LEVEL_TRIGGERS.include?(type) ? dependent_keys(target) : producible_keys(nil)
83
+ keys.each { |k| jobs << job("materialize", k, role) }
84
+ end
85
+ GLOBAL_ACTIONS.each do |action, args|
86
+ jobs << Textus::Store::Jobs::Queue::Job.new(type: action, args: args, role: role) if actions.include?(action)
87
+ end
88
+ jobs
89
+ end
90
+
91
+ def matches_trigger?(react, type)
92
+ on = react.raw["on"]
93
+ Array(on).include?(type)
94
+ end
95
+
96
+ def job(type, key, role)
97
+ Textus::Store::Jobs::Queue::Job.new(type: type, args: { "key" => key }, role: role)
98
+ end
99
+
100
+ def producible_keys(_target)
101
+ @manifest.data.entries
102
+ .select { |e| !e.publish_tree.nil? || !e.publish_to.empty? }
103
+ .map(&:key)
104
+ end
105
+
106
+ def lane_keys(_target)
107
+ @manifest.data.entries.map(&:key)
108
+ end
109
+
110
+ def dependent_keys(target_key)
111
+ @manifest.data.entries
112
+ .select(&:external?)
113
+ .select do |e|
114
+ Array(e.source&.sources).compact.any? do |s|
115
+ target_key == s || target_key.start_with?("#{s}.")
116
+ end
117
+ end
118
+ .select { |e| !e.publish_tree.nil? || !e.publish_to.empty? }
119
+ .map(&:key)
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "securerandom"
6
+ require "time"
7
+
8
+ module Textus
9
+ class Store
10
+ module Jobs
11
+ class Queue
12
+ VALID_STATES = %w[ready leased done failed].freeze
13
+
14
+ Leased = Data.define(:job)
15
+
16
+ class Job
17
+ DIGEST_BYTES = 16
18
+
19
+ attr_reader :type, :args, :role, :attempts, :max_attempts, :errors
20
+
21
+ def initialize(type:, args:, role:, attempts: 0, max_attempts: 3, errors: [])
22
+ @type = type.to_s
23
+ @args = stringify(args)
24
+ @role = role.to_s
25
+ @attempts = attempts.to_i
26
+ @max_attempts = max_attempts.to_i
27
+ @errors = Array(errors)
28
+ end
29
+
30
+ def id
31
+ "#{type}:#{Digest::SHA256.hexdigest(JSON.dump(args.sort.to_h))[0, DIGEST_BYTES]}"
32
+ end
33
+
34
+ private
35
+
36
+ def stringify(hash)
37
+ hash.to_h.transform_keys(&:to_s)
38
+ end
39
+ end
40
+
41
+ def initialize(store:)
42
+ @store = store
43
+ end
44
+
45
+ def enqueue(job)
46
+ now = iso_now
47
+ inserted = @store.execute(
48
+ "INSERT OR IGNORE INTO jobs (id, type, args, state, role, attempts, max_attempts, errors, lease, created_at, updated_at)
49
+ VALUES (?, ?, ?, 'ready', ?, ?, ?, ?, NULL, ?, ?)",
50
+ [job.id, job.type, JSON.dump(job.args), job.role, job.attempts, job.max_attempts, JSON.dump(job.errors), now, now],
51
+ )
52
+ return inserted if @store.query_value("SELECT changes()")&.to_i&.positive?
53
+
54
+ @store.execute(
55
+ "UPDATE jobs
56
+ SET state = 'ready', role = ?, args = ?, attempts = 0, errors = ?, lease = NULL, max_attempts = ?, updated_at = ?
57
+ WHERE id = ? AND state IN ('done', 'failed')",
58
+ [job.role, JSON.dump(job.args), JSON.dump([]), job.max_attempts, now, job.id],
59
+ )
60
+ end
61
+
62
+ def ready_ids
63
+ list(:ready)
64
+ end
65
+
66
+ def lease(worker_id:, lease_ttl:)
67
+ now = Time.now.utc
68
+ expires_at = now + lease_ttl
69
+ token = SecureRandom.hex(8)
70
+ marked_lease = JSON.dump({ "worker_id" => worker_id, "expires_at" => expires_at.iso8601, "token" => token })
71
+
72
+ @store.execute(
73
+ "UPDATE jobs
74
+ SET state = 'leased', lease = ?, updated_at = ?
75
+ WHERE id = (
76
+ SELECT id FROM jobs WHERE state = 'ready' ORDER BY created_at, id LIMIT 1
77
+ )",
78
+ [marked_lease, now.iso8601],
79
+ )
80
+ row = @store.execute("SELECT * FROM jobs WHERE state = 'leased' AND lease = ? LIMIT 1", [marked_lease]).first
81
+ return nil unless row
82
+
83
+ Leased.new(job_from_row(row))
84
+ end
85
+
86
+ def ack(leased)
87
+ @store.execute(
88
+ "UPDATE jobs SET state = 'done', lease = NULL, updated_at = ? WHERE id = ? AND state = 'leased'",
89
+ [iso_now, leased.job.id],
90
+ )
91
+ end
92
+
93
+ def fail(leased, error:)
94
+ job = leased.job
95
+ attempts = job.attempts + 1
96
+ errors = job.errors + [{ "attempt" => attempts, "error" => error, "at" => iso_now }]
97
+ dead = attempts >= job.max_attempts
98
+ state = dead ? "failed" : "ready"
99
+ @store.execute(
100
+ "UPDATE jobs SET state = ?, attempts = ?, errors = ?, lease = NULL, updated_at = ? WHERE id = ?",
101
+ [state, attempts, JSON.dump(errors), iso_now, job.id],
102
+ )
103
+ dead ? :dead_lettered : :requeued
104
+ end
105
+
106
+ def reclaim(now:)
107
+ rows = @store.execute("SELECT id, lease FROM jobs WHERE state = 'leased'")
108
+ expired = rows.select do |row|
109
+ lease = JSON.parse(row["lease"] || "{}")
110
+ expires_at = lease["expires_at"]
111
+ expires_at.nil? || Time.parse(expires_at) <= now
112
+ end
113
+ expired.each do |row|
114
+ @store.execute(
115
+ "UPDATE jobs SET state = 'ready', lease = NULL, updated_at = ? WHERE id = ?",
116
+ [now.utc.iso8601, row["id"]],
117
+ )
118
+ end
119
+ expired.size
120
+ end
121
+
122
+ def list(state)
123
+ state = state.to_s
124
+ raise Textus::UsageError.new("unknown job state: #{state}") unless VALID_STATES.include?(state)
125
+
126
+ @store.execute("SELECT id FROM jobs WHERE state = ? ORDER BY created_at, id", [state]).map { |row| row["id"] }
127
+ end
128
+
129
+ def retry_failed(job_id)
130
+ @store.execute(
131
+ "UPDATE jobs SET state = 'ready', attempts = 0, errors = ?, lease = NULL, updated_at = ? WHERE id = ? AND state = 'failed'",
132
+ [JSON.dump([]), iso_now, job_id],
133
+ )
134
+ end
135
+
136
+ def purge(state)
137
+ state = state.to_s
138
+ raise Textus::UsageError.new("unknown job state: #{state}") unless VALID_STATES.include?(state)
139
+
140
+ @store.execute("DELETE FROM jobs WHERE state = ?", [state])
141
+ end
142
+
143
+ private
144
+
145
+ def job_from_row(row)
146
+ Job.new(
147
+ type: row["type"],
148
+ args: JSON.parse(row["args"] || "{}"),
149
+ role: row["role"],
150
+ attempts: row["attempts"],
151
+ max_attempts: row["max_attempts"],
152
+ errors: JSON.parse(row["errors"] || "[]"),
153
+ )
154
+ end
155
+
156
+ def iso_now
157
+ Time.now.utc.iso8601
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,19 @@
1
+ module Textus
2
+ class Store
3
+ module Jobs
4
+ module Registry
5
+ class UnknownJob < KeyError; end
6
+
7
+ JOBS = {
8
+ "index" => Store::Jobs::Index,
9
+ "materialize" => Store::Jobs::Materialize,
10
+ "sweep" => Store::Jobs::Sweep,
11
+ }.freeze
12
+
13
+ def self.fetch(type)
14
+ JOBS.fetch(type.to_s) { raise UnknownJob.new("Unknown job type: #{type}") }
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,52 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ class Store
5
+ module Jobs
6
+ module Retention
7
+ class Base
8
+ def initialize(container:, call:)
9
+ @container = container
10
+ @call = call
11
+ end
12
+
13
+ def call(rows)
14
+ out = { dropped: [], archived: [], failed: [] }
15
+ rows.each do |row|
16
+ key = row["key"]
17
+ begin
18
+ case row["action"]
19
+ when "drop"
20
+ delete(key)
21
+ out[:dropped] << key
22
+ when "archive"
23
+ archive_leaf(row)
24
+ delete(key)
25
+ out[:archived] << key
26
+ end
27
+ rescue Textus::Error => e
28
+ out[:failed] << { "key" => key, "error" => e.message }
29
+ end
30
+ end
31
+ out
32
+ end
33
+
34
+ private
35
+
36
+ def archive_leaf(row)
37
+ src = row["path"]
38
+ dest = @container.layout.archive_path(src)
39
+ FileUtils.mkdir_p(File.dirname(dest))
40
+ FileUtils.cp(src, dest)
41
+ end
42
+
43
+ def delete(key)
44
+ mentry = @container.manifest.resolver.resolve(key).entry
45
+ writer = Textus::Store::Entry::Writer.from(container: @container, call: @call)
46
+ writer.delete(key, mentry: mentry)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,55 @@
1
+ require "time"
2
+
3
+ module Textus
4
+ class Store
5
+ module Jobs
6
+ module Retention
7
+ class Sweep
8
+ def self.expired?(ttl_seconds:, mtime:, now:)
9
+ return false if ttl_seconds.nil? || mtime.nil?
10
+
11
+ (now - mtime).to_i > ttl_seconds
12
+ end
13
+
14
+ def initialize(manifest:, file_stat:, clock:)
15
+ @manifest = manifest
16
+ @file_stat = file_stat
17
+ @clock = clock
18
+ end
19
+
20
+ def call(prefix: nil, lane: nil)
21
+ @manifest.data.entries
22
+ .select { |m| matches?(m, prefix: prefix, lane: lane) }
23
+ .flat_map { |m| rows_for(m) }
24
+ end
25
+
26
+ private
27
+
28
+ def matches?(mentry, prefix:, lane:)
29
+ return false if lane && mentry.lane != lane
30
+ return false if prefix && !Textus::Key::Matching.matches_prefix?(
31
+ mentry.key, prefix, nested: mentry.is_a?(Textus::Manifest::Entry::Nested)
32
+ )
33
+
34
+ true
35
+ end
36
+
37
+ def rows_for(mentry)
38
+ policy = @manifest.rules.for(mentry.key).retention
39
+ return [] if policy.nil?
40
+
41
+ @manifest.resolver.enumerate(prefix: mentry.key).filter_map do |row|
42
+ path = row[:path]
43
+ next unless @file_stat.exists?(path)
44
+ next unless self.class.expired?(
45
+ ttl_seconds: policy.ttl_seconds, mtime: @file_stat.mtime(path), now: @clock.now,
46
+ )
47
+
48
+ { "key" => row[:key], "path" => path, "action" => policy.action.to_s }
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end