textus 0.55.1 → 0.56.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 (329) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +175 -2768
  3. data/README.md +17 -18
  4. data/lib/textus/artifact.rb +8 -0
  5. data/lib/textus/errors.rb +2 -39
  6. data/lib/textus/human.rb +7 -0
  7. data/lib/textus/infra/adapters/concurrency_adapter.rb +15 -0
  8. data/lib/textus/infra/clock.rb +7 -0
  9. data/lib/textus/infra/database.rb +127 -0
  10. data/lib/textus/infra/file_system.rb +54 -0
  11. data/lib/textus/infra/locks.rb +50 -0
  12. data/lib/textus/infra/port/clock.rb +18 -0
  13. data/lib/textus/infra/port/publisher.rb +71 -0
  14. data/lib/textus/infra/port/sentinel.rb +25 -0
  15. data/lib/textus/infra/port/sentinel_store.rb +93 -0
  16. data/lib/textus/infra/store/audit_log.rb +42 -0
  17. data/lib/textus/infra/store/base.rb +25 -0
  18. data/lib/textus/infra/store/cursor_store.rb +27 -0
  19. data/lib/textus/infra/store/entry_index.rb +31 -0
  20. data/lib/textus/infra/store/envelope/meta.rb +63 -0
  21. data/lib/textus/infra/store/file_store.rb +23 -0
  22. data/lib/textus/infra/store/index/builder.rb +28 -0
  23. data/lib/textus/infra/store/index/lookup.rb +23 -0
  24. data/lib/textus/infra/store/link_store.rb +43 -0
  25. data/lib/textus/infra/store/workflow_queue.rb +54 -0
  26. data/lib/textus/infra/store.rb +29 -0
  27. data/lib/textus/ingest.rb +8 -0
  28. data/lib/textus/lanes/artifact/cache.rb +35 -0
  29. data/lib/textus/lanes/artifact/handlers.rb +29 -0
  30. data/lib/textus/lanes/artifact/lifecycle.rb +23 -0
  31. data/lib/textus/lanes/artifact/verbs.rb +18 -0
  32. data/lib/textus/lanes/ingest/dedup.rb +17 -0
  33. data/lib/textus/lanes/ingest/entry_types/asset.rb +45 -0
  34. data/lib/textus/lanes/ingest/entry_types/link.rb +16 -0
  35. data/lib/textus/lanes/ingest/entry_types/text.rb +17 -0
  36. data/lib/textus/lanes/ingest/entry_types.rb +25 -0
  37. data/lib/textus/lanes/ingest/handlers.rb +54 -0
  38. data/lib/textus/lanes/ingest/index_rebuilder.rb +13 -0
  39. data/lib/textus/lanes/ingest/key_builder.rb +33 -0
  40. data/lib/textus/lanes/ingest/resolver.rb +21 -0
  41. data/lib/textus/lanes/knowledge/freeform.rb +15 -0
  42. data/lib/textus/lanes/knowledge/handlers.rb +53 -0
  43. data/lib/textus/lanes/knowledge/runner.rb +45 -0
  44. data/lib/textus/lanes/knowledge/verbs.rb +16 -0
  45. data/lib/textus/lanes/scratchpad/handlers.rb +164 -0
  46. data/lib/textus/lanes/scratchpad/verbs.rb +73 -0
  47. data/lib/textus/{gate → protocol}/binder.rb +2 -10
  48. data/lib/textus/protocol/boot/catalog.rb +34 -0
  49. data/lib/textus/protocol/boot/context.rb +124 -0
  50. data/lib/textus/protocol/boot/protocol.rb +33 -0
  51. data/lib/textus/protocol/boot.rb +42 -0
  52. data/lib/textus/protocol/builder/domain.rb +17 -0
  53. data/lib/textus/protocol/builder/infrastructure.rb +17 -0
  54. data/lib/textus/protocol/builder/runtime.rb +46 -0
  55. data/lib/textus/protocol/builder.rb +11 -0
  56. data/lib/textus/protocol/container.rb +22 -0
  57. data/lib/textus/protocol/diff.rb +170 -0
  58. data/lib/textus/protocol/entry_constraint.rb +54 -0
  59. data/lib/textus/protocol/envelope.rb +70 -0
  60. data/lib/textus/protocol/format/base.rb +80 -0
  61. data/lib/textus/protocol/format/bash.rb +10 -0
  62. data/lib/textus/protocol/format/javascript.rb +10 -0
  63. data/lib/textus/protocol/format/json.rb +82 -0
  64. data/lib/textus/protocol/format/markdown.rb +53 -0
  65. data/lib/textus/protocol/format/python.rb +10 -0
  66. data/lib/textus/protocol/format/ruby.rb +10 -0
  67. data/lib/textus/protocol/format/script.rb +55 -0
  68. data/lib/textus/protocol/format/text.rb +48 -0
  69. data/lib/textus/protocol/format/yaml.rb +105 -0
  70. data/lib/textus/protocol/format.rb +73 -0
  71. data/lib/textus/protocol/freshness/ttl_evaluator.rb +61 -0
  72. data/lib/textus/protocol/gate.rb +61 -0
  73. data/lib/textus/protocol/handlers/command_context.rb +19 -0
  74. data/lib/textus/protocol/handlers/concern.rb +15 -0
  75. data/lib/textus/protocol/handlers/ops/audit_filter.rb +37 -0
  76. data/lib/textus/protocol/handlers/ops/bulk_planner.rb +38 -0
  77. data/lib/textus/protocol/handlers/ops/data_mv.rb +49 -0
  78. data/lib/textus/protocol/handlers/ops/git_blame.rb +65 -0
  79. data/lib/textus/protocol/handlers/ops/pulse_coordinator.rb +37 -0
  80. data/lib/textus/protocol/handlers/ops/source_expander.rb +41 -0
  81. data/lib/textus/protocol/handlers/query_context.rb +41 -0
  82. data/lib/textus/protocol/handlers/read.rb +67 -0
  83. data/lib/textus/protocol/handlers/system.rb +93 -0
  84. data/lib/textus/protocol/handlers/write.rb +57 -0
  85. data/lib/textus/protocol/init.rb +143 -0
  86. data/lib/textus/protocol/key/grammar.rb +35 -0
  87. data/lib/textus/protocol/key/matching.rb +23 -0
  88. data/lib/textus/protocol/key/path.rb +23 -0
  89. data/lib/textus/protocol/key.rb +25 -0
  90. data/lib/textus/protocol/lane.rb +12 -0
  91. data/lib/textus/{store/geometry.rb → protocol/layout.rb} +32 -22
  92. data/lib/textus/protocol/links/resolver.rb +33 -0
  93. data/lib/textus/protocol/links/uri_rewriter.rb +30 -0
  94. data/lib/textus/protocol/links.rb +10 -0
  95. data/lib/textus/protocol/manifest/data.rb +90 -0
  96. data/lib/textus/protocol/manifest/entry/base.rb +95 -0
  97. data/lib/textus/protocol/manifest/entry/leaf.rb +19 -0
  98. data/lib/textus/protocol/manifest/entry/nested.rb +19 -0
  99. data/lib/textus/protocol/manifest/entry/parser.rb +71 -0
  100. data/lib/textus/protocol/manifest/entry/publish.rb +209 -0
  101. data/lib/textus/protocol/manifest/entry.rb +10 -0
  102. data/lib/textus/protocol/manifest/policy/publish_target.rb +36 -0
  103. data/lib/textus/protocol/manifest/policy.rb +69 -0
  104. data/lib/textus/protocol/manifest/resolver.rb +156 -0
  105. data/lib/textus/protocol/manifest/schema/contract.rb +53 -0
  106. data/lib/textus/protocol/manifest/schema/validator.rb +52 -0
  107. data/lib/textus/protocol/manifest/schema.rb +40 -0
  108. data/lib/textus/protocol/manifest.rb +62 -0
  109. data/lib/textus/protocol/produce/render/context.rb +43 -0
  110. data/lib/textus/protocol/produce/render.rb +44 -0
  111. data/lib/textus/protocol/schema/registry.rb +44 -0
  112. data/lib/textus/protocol/schema/tools.rb +104 -0
  113. data/lib/textus/protocol/schema.rb +117 -0
  114. data/lib/textus/protocol/session/bridge.rb +11 -0
  115. data/lib/textus/protocol/session/context.rb +5 -0
  116. data/lib/textus/protocol/session.rb +87 -0
  117. data/lib/textus/protocol/store_engine/delete.rb +37 -0
  118. data/lib/textus/protocol/store_engine/move.rb +109 -0
  119. data/lib/textus/protocol/store_engine/pipeline.rb +61 -0
  120. data/lib/textus/protocol/store_engine/put.rb +104 -0
  121. data/lib/textus/protocol/store_engine.rb +86 -0
  122. data/lib/textus/protocol/v4.rb +25 -0
  123. data/lib/textus/protocol/verb_registry/arg_spec.rb +19 -0
  124. data/lib/textus/protocol/verb_registry/core_verbs.rb +164 -0
  125. data/lib/textus/protocol/verb_registry/verb_spec.rb +42 -0
  126. data/lib/textus/protocol/verb_registry.rb +42 -0
  127. data/lib/textus/surface/cli/group/{rule.rb → web.rb} +2 -2
  128. data/lib/textus/surface/cli/runner.rb +25 -25
  129. data/lib/textus/surface/cli/sources.rb +1 -3
  130. data/lib/textus/surface/cli/verb/get.rb +3 -6
  131. data/lib/textus/surface/cli/verb/init.rb +1 -1
  132. data/lib/textus/surface/cli/verb/put.rb +8 -6
  133. data/lib/textus/surface/cli/verb/web_serve.rb +22 -0
  134. data/lib/textus/surface/cli.rb +25 -6
  135. data/lib/textus/surface/dispatch.rb +17 -0
  136. data/lib/textus/surface/mcp/adapters/mcp_adapter.rb +28 -0
  137. data/lib/textus/surface/mcp/catalog.rb +26 -62
  138. data/lib/textus/surface/mcp/cursor_manager.rb +22 -0
  139. data/lib/textus/surface/mcp/drift_annotation.rb +19 -0
  140. data/lib/textus/surface/mcp/errors.rb +0 -10
  141. data/lib/textus/surface/mcp/server.rb +46 -46
  142. data/lib/textus/surface/watcher.rb +18 -12
  143. data/lib/textus/surface/web.rb +50 -0
  144. data/lib/textus/value/call.rb +1 -1
  145. data/lib/textus/{core → value}/duration.rb +1 -4
  146. data/lib/textus/value/etag.rb +7 -7
  147. data/lib/textus/value/payload.rb +7 -0
  148. data/lib/textus/version.rb +1 -1
  149. data/lib/textus/workflow/consumer.rb +39 -0
  150. data/lib/textus/workflow/dsl/lifecycle.rb +32 -0
  151. data/lib/textus/workflow/dsl/pattern_matcher.rb +20 -0
  152. data/lib/textus/workflow/dsl/step_builder.rb +56 -0
  153. data/lib/textus/workflow/dsl.rb +111 -21
  154. data/lib/textus/workflow/event_emitter.rb +21 -0
  155. data/lib/textus/workflow/publisher.rb +59 -0
  156. data/lib/textus/workflow/registry.rb +31 -8
  157. data/lib/textus/workflow/runner.rb +25 -61
  158. data/lib/textus/workflow/scheduler.rb +45 -0
  159. data/lib/textus/workflow/step_executor.rb +78 -0
  160. data/lib/textus/workflow/step_helpers.rb +102 -0
  161. data/lib/textus/workflow.rb +2 -5
  162. data/lib/textus.rb +16 -80
  163. metadata +167 -183
  164. data/SPEC.md +0 -908
  165. data/docs/architecture/README.md +0 -273
  166. data/docs/reference/conventions.md +0 -138
  167. data/lib/textus/action/accept.rb +0 -46
  168. data/lib/textus/action/audit.rb +0 -94
  169. data/lib/textus/action/base.rb +0 -42
  170. data/lib/textus/action/blame.rb +0 -79
  171. data/lib/textus/action/boot.rb +0 -15
  172. data/lib/textus/action/data_mv.rb +0 -58
  173. data/lib/textus/action/deps.rb +0 -19
  174. data/lib/textus/action/doctor.rb +0 -17
  175. data/lib/textus/action/drain.rb +0 -31
  176. data/lib/textus/action/enqueue.rb +0 -37
  177. data/lib/textus/action/get.rb +0 -34
  178. data/lib/textus/action/ingest.rb +0 -199
  179. data/lib/textus/action/jobs.rb +0 -27
  180. data/lib/textus/action/key_delete.rb +0 -26
  181. data/lib/textus/action/key_delete_prefix.rb +0 -35
  182. data/lib/textus/action/key_mv.rb +0 -122
  183. data/lib/textus/action/key_mv_prefix.rb +0 -48
  184. data/lib/textus/action/list.rb +0 -28
  185. data/lib/textus/action/propose.rb +0 -42
  186. data/lib/textus/action/published.rb +0 -22
  187. data/lib/textus/action/pulse.rb +0 -49
  188. data/lib/textus/action/put.rb +0 -38
  189. data/lib/textus/action/rdeps.rb +0 -24
  190. data/lib/textus/action/reject.rb +0 -28
  191. data/lib/textus/action/rule_explain.rb +0 -81
  192. data/lib/textus/action/rule_lint.rb +0 -62
  193. data/lib/textus/action/rule_list.rb +0 -38
  194. data/lib/textus/action/schema_envelope.rb +0 -22
  195. data/lib/textus/action/uid.rb +0 -19
  196. data/lib/textus/action/where.rb +0 -21
  197. data/lib/textus/boot.rb +0 -202
  198. data/lib/textus/contract/arg.rb +0 -10
  199. data/lib/textus/contract/dsl.rb +0 -88
  200. data/lib/textus/contract/spec.rb +0 -25
  201. data/lib/textus/contract.rb +0 -12
  202. data/lib/textus/core/freshness/evaluator.rb +0 -150
  203. data/lib/textus/core/freshness/verdict.rb +0 -34
  204. data/lib/textus/core/freshness.rb +0 -11
  205. data/lib/textus/core/retention/sweep.rb +0 -57
  206. data/lib/textus/core/retention.rb +0 -11
  207. data/lib/textus/core/sentinel.rb +0 -30
  208. data/lib/textus/doctor/check/audit_log.rb +0 -34
  209. data/lib/textus/doctor/check/generator_drift.rb +0 -29
  210. data/lib/textus/doctor/check/illegal_keys.rb +0 -60
  211. data/lib/textus/doctor/check/manifest_files.rb +0 -25
  212. data/lib/textus/doctor/check/notebook_sources.rb +0 -50
  213. data/lib/textus/doctor/check/orphaned_publish_targets.rb +0 -35
  214. data/lib/textus/doctor/check/proposal_targets.rb +0 -45
  215. data/lib/textus/doctor/check/protocol_version.rb +0 -47
  216. data/lib/textus/doctor/check/publish_tree_index_overlap.rb +0 -48
  217. data/lib/textus/doctor/check/raw_asset_paths.rb +0 -50
  218. data/lib/textus/doctor/check/rule_ambiguity.rb +0 -49
  219. data/lib/textus/doctor/check/schema_parse_error.rb +0 -28
  220. data/lib/textus/doctor/check/schema_violations.rb +0 -28
  221. data/lib/textus/doctor/check/schemas.rb +0 -26
  222. data/lib/textus/doctor/check/sentinels.rb +0 -60
  223. data/lib/textus/doctor/check/stale_reviewed_stamp.rb +0 -54
  224. data/lib/textus/doctor/check/templates.rb +0 -29
  225. data/lib/textus/doctor/check/unowned_schema_fields.rb +0 -40
  226. data/lib/textus/doctor/check.rb +0 -41
  227. data/lib/textus/doctor/validator.rb +0 -93
  228. data/lib/textus/doctor.rb +0 -54
  229. data/lib/textus/format/base.rb +0 -49
  230. data/lib/textus/format/json.rb +0 -102
  231. data/lib/textus/format/markdown.rb +0 -80
  232. data/lib/textus/format/shared.rb +0 -17
  233. data/lib/textus/format/text.rb +0 -55
  234. data/lib/textus/format/yaml.rb +0 -125
  235. data/lib/textus/format.rb +0 -46
  236. data/lib/textus/gate/auth.rb +0 -212
  237. data/lib/textus/gate.rb +0 -92
  238. data/lib/textus/init.rb +0 -157
  239. data/lib/textus/jobs.rb +0 -9
  240. data/lib/textus/key/distance.rb +0 -55
  241. data/lib/textus/key/grammar.rb +0 -33
  242. data/lib/textus/key/matching.rb +0 -24
  243. data/lib/textus/key/path.rb +0 -28
  244. data/lib/textus/manifest/capabilities.rb +0 -29
  245. data/lib/textus/manifest/data.rb +0 -102
  246. data/lib/textus/manifest/entry/base.rb +0 -105
  247. data/lib/textus/manifest/entry/ignore_matcher.rb +0 -46
  248. data/lib/textus/manifest/entry/leaf.rb +0 -17
  249. data/lib/textus/manifest/entry/nested.rb +0 -37
  250. data/lib/textus/manifest/entry/parser.rb +0 -99
  251. data/lib/textus/manifest/entry/produced.rb +0 -36
  252. data/lib/textus/manifest/entry/publish/mode.rb +0 -45
  253. data/lib/textus/manifest/entry/publish/none.rb +0 -14
  254. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +0 -73
  255. data/lib/textus/manifest/entry/publish/template.rb +0 -16
  256. data/lib/textus/manifest/entry/publish/to_paths.rb +0 -76
  257. data/lib/textus/manifest/entry/publish/tree.rb +0 -50
  258. data/lib/textus/manifest/entry/publish.rb +0 -45
  259. data/lib/textus/manifest/entry/validators/format_matrix.rb +0 -21
  260. data/lib/textus/manifest/entry/validators/ignore.rb +0 -28
  261. data/lib/textus/manifest/entry/validators/publish.rb +0 -30
  262. data/lib/textus/manifest/entry/validators.rb +0 -18
  263. data/lib/textus/manifest/entry.rb +0 -8
  264. data/lib/textus/manifest/policy/matcher.rb +0 -51
  265. data/lib/textus/manifest/policy/publish_target.rb +0 -34
  266. data/lib/textus/manifest/policy/react.rb +0 -30
  267. data/lib/textus/manifest/policy/retention.rb +0 -29
  268. data/lib/textus/manifest/policy/source.rb +0 -30
  269. data/lib/textus/manifest/policy.rb +0 -95
  270. data/lib/textus/manifest/resolver.rb +0 -118
  271. data/lib/textus/manifest/rules.rb +0 -86
  272. data/lib/textus/manifest/schema/contract.rb +0 -61
  273. data/lib/textus/manifest/schema/keys.rb +0 -90
  274. data/lib/textus/manifest/schema/semantics/cross_field.rb +0 -53
  275. data/lib/textus/manifest/schema/semantics/invariants.rb +0 -125
  276. data/lib/textus/manifest/schema/semantics/migration.rb +0 -83
  277. data/lib/textus/manifest/schema/semantics.rb +0 -27
  278. data/lib/textus/manifest/schema/validator.rb +0 -48
  279. data/lib/textus/manifest/schema/vocabulary.rb +0 -25
  280. data/lib/textus/manifest/schema.rb +0 -30
  281. data/lib/textus/manifest.rb +0 -65
  282. data/lib/textus/meta.rb +0 -54
  283. data/lib/textus/port/audit_log.rb +0 -268
  284. data/lib/textus/port/build_lock.rb +0 -64
  285. data/lib/textus/port/clock.rb +0 -10
  286. data/lib/textus/port/publisher.rb +0 -60
  287. data/lib/textus/port/sentinel_store.rb +0 -83
  288. data/lib/textus/port/storage/file_stat.rb +0 -19
  289. data/lib/textus/port/storage/file_store.rb +0 -26
  290. data/lib/textus/port/store.rb +0 -93
  291. data/lib/textus/port/watcher_lock.rb +0 -48
  292. data/lib/textus/produce/engine.rb +0 -50
  293. data/lib/textus/produce/render.rb +0 -20
  294. data/lib/textus/schema/tools.rb +0 -115
  295. data/lib/textus/schema.rb +0 -100
  296. data/lib/textus/schemas.rb +0 -54
  297. data/lib/textus/store/compositor.rb +0 -34
  298. data/lib/textus/store/container.rb +0 -43
  299. data/lib/textus/store/cursor.rb +0 -26
  300. data/lib/textus/store/envelope/reader.rb +0 -43
  301. data/lib/textus/store/envelope/writer.rb +0 -195
  302. data/lib/textus/store/index/builder.rb +0 -74
  303. data/lib/textus/store/index/lookup.rb +0 -60
  304. data/lib/textus/store/jobs/base.rb +0 -13
  305. data/lib/textus/store/jobs/index.rb +0 -15
  306. data/lib/textus/store/jobs/materialize.rb +0 -15
  307. data/lib/textus/store/jobs/plan.rb +0 -11
  308. data/lib/textus/store/jobs/planner.rb +0 -104
  309. data/lib/textus/store/jobs/queue.rb +0 -154
  310. data/lib/textus/store/jobs/registry.rb +0 -19
  311. data/lib/textus/store/jobs/retention.rb +0 -50
  312. data/lib/textus/store/jobs/sweep.rb +0 -21
  313. data/lib/textus/store/jobs/worker.rb +0 -64
  314. data/lib/textus/store/session.rb +0 -37
  315. data/lib/textus/store.rb +0 -99
  316. data/lib/textus/surface/cli/verb/doctor.rb +0 -19
  317. data/lib/textus/surface/cli/verb/schema_diff.rb +0 -17
  318. data/lib/textus/surface/cli/verb/schema_init.rb +0 -21
  319. data/lib/textus/surface/cli/verb/schema_migrate.rb +0 -21
  320. data/lib/textus/surface/mcp.rb +0 -8
  321. data/lib/textus/surface/projector.rb +0 -27
  322. data/lib/textus/surface/role_scope.rb +0 -34
  323. data/lib/textus/value/command.rb +0 -16
  324. data/lib/textus/value/envelope.rb +0 -89
  325. data/lib/textus/value/result.rb +0 -26
  326. data/lib/textus/workflow/collector.rb +0 -27
  327. data/lib/textus/workflow/context.rb +0 -5
  328. data/lib/textus/workflow/loader.rb +0 -17
  329. data/lib/textus/workflow/pattern.rb +0 -18
@@ -1,195 +0,0 @@
1
- require "fileutils"
2
-
3
- module Textus
4
- class Store
5
- module Envelope
6
- # Owns the write pipeline (validate, serialize, etag-check, write, audit).
7
- # Talks to ports (FileStore, Schemas, AuditLog, Manifest) and an
8
- # Reader for the existing-uid lookup.
9
- #
10
- # Invariant: every public method's final action is @audit_log.append(...).
11
- #
12
- # No permission check, no event firing — those belong to the caller
13
- # (Write::Put / ::Delete / ::Mv).
14
- class Writer
15
- Payload = Data.define(:meta, :body, :content)
16
-
17
- def self.from(container:, call:)
18
- new(
19
- file_store: container.file_store, manifest: container.manifest,
20
- schemas: container.schemas, audit_log: container.audit_log,
21
- call: call, reader: Reader.from(container: container),
22
- geometry: container.geometry
23
- )
24
- end
25
-
26
- def initialize(file_store:, manifest:, schemas:, audit_log:, call:, reader:, geometry:) # rubocop:disable Metrics/ParameterLists
27
- @file_store = file_store
28
- @manifest = manifest
29
- @schemas = schemas
30
- @audit_log = audit_log
31
- @call = call
32
- @reader = reader
33
- @geometry = geometry
34
- end
35
-
36
- def put(key, mentry:, payload:, if_etag: nil)
37
- path = resolve_path(key)
38
- meta = payload.meta || {}
39
- content = payload.content
40
- existing_env = @reader.read(key)
41
- existing_meta = existing_env ? existing_env.meta : {}
42
- meta, content = Textus::Meta.inject_all(meta, content, existing_meta, format: mentry.format)
43
- bytes, eff_meta, eff_body, eff_content = serialize_entry(mentry, path, meta, payload, content)
44
- enforce_name_match!(path, eff_meta, mentry.format)
45
- validate_schema(mentry, eff_meta, eff_content)
46
- Textus::Format::Yaml.validate_raw_entry!(
47
- { "_meta" => eff_meta, "content" => eff_content },
48
- mentry.lane,
49
- )
50
- etag_before = check_etag!(path, key, if_etag)
51
- write_bytes(path, bytes)
52
- envelope = build_envelope(key, mentry, path, eff_meta, eff_body, eff_content, bytes)
53
- audit_put(key, etag_before, envelope.etag)
54
- envelope
55
- end
56
-
57
- def delete(key, mentry: nil, if_etag: nil) # rubocop:disable Lint/UnusedMethodArgument
58
- # `mentry:` is accepted for symmetry with `put` / `move` and to
59
- # leave room for future format-specific delete hooks; no field
60
- # on it is needed today.
61
- path = @manifest.resolver.resolve(key).path
62
- raise UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)) unless @file_store.exists?(path)
63
-
64
- etag_before = @file_store.etag(path)
65
- raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before
66
-
67
- @file_store.delete(path)
68
- prune_empty_parents(path)
69
- @audit_log.append(
70
- role: @call.role, verb: "key_delete", key: key,
71
- etag_before: etag_before, etag_after: nil,
72
- extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
73
- )
74
- end
75
-
76
- def move(from_key:, to_key:, new_mentry:, if_etag: nil)
77
- from_path = @manifest.resolver.resolve(from_key).path
78
- to_path = @manifest.resolver.resolve(to_key).path
79
- raise UnknownKey.new(from_key, suggestions: @manifest.resolver.suggestions_for(from_key)) unless @file_store.exists?(from_path)
80
-
81
- etag_before = @file_store.etag(from_path)
82
- raise EtagMismatch.new(from_key, if_etag, etag_before) if if_etag && if_etag != etag_before
83
-
84
- FileUtils.mkdir_p(File.dirname(to_path))
85
- FileUtils.mv(from_path, to_path)
86
- prune_empty_parents(from_path)
87
- basename = to_key.split(".").last
88
- Format.for(new_mentry.format).rewrite_name(to_path, basename)
89
- etag_after = Value::Etag.for_file(to_path)
90
-
91
- envelope = @reader.read(to_key)
92
-
93
- extras = {
94
- "from_key" => from_key, "to_key" => to_key,
95
- "from_path" => from_path, "to_path" => to_path,
96
- "uid" => envelope.uid
97
- }
98
- extras["correlation_id"] = @call.correlation_id if @call.correlation_id
99
-
100
- @audit_log.append(
101
- role: @call.role, verb: "key_mv", key: to_key,
102
- etag_before: etag_before, etag_after: etag_after,
103
- extras: extras
104
- )
105
-
106
- envelope
107
- end
108
-
109
- private
110
-
111
- # After a file leaves a directory (delete or move-source), remove any
112
- # now-empty parent dirs so bulk move/delete doesn't accrue orphan dirs
113
- # (F3 of #161). Floored at the entry's *zone directory* — a zone is a
114
- # declared, first-class container, so its own dir is preserved even when
115
- # momentarily empty; only the sub-dirs the bulk op carved out are
116
- # pruned. Stops at the first non-empty ancestor, so a dir holding a
117
- # `.gitkeep` or sibling entries survives. Best-effort: a lost race or a
118
- # non-empty dir is silently fine, never fatal to the write.
119
- def prune_empty_parents(path)
120
- floor = @geometry.lane_floor(path)
121
- return unless floor
122
-
123
- dir = File.dirname(path)
124
- while dir.start_with?("#{floor}/") && Dir.empty?(dir)
125
- Dir.rmdir(dir)
126
- dir = File.dirname(dir)
127
- end
128
- rescue SystemCallError
129
- nil
130
- end
131
-
132
- def enforce_name_match!(path, meta, format)
133
- Textus::Format.for(format).enforce_name_match!(path, meta)
134
- end
135
-
136
- def serialize_for_put(mentry:, path:, meta:, body:, content:)
137
- Textus::Format.for(mentry.format).serialize_for_put(
138
- meta: meta, body: body, content: content, path: path,
139
- )
140
- end
141
-
142
- def resolve_path(key)
143
- @manifest.resolver.resolve(key).path
144
- end
145
-
146
- def serialize_entry(mentry, path, meta, payload, content)
147
- serialize_for_put(
148
- mentry: mentry, path: path,
149
- meta: meta, body: payload.body, content: content
150
- )
151
- end
152
-
153
- def validate_schema(mentry, eff_meta, eff_content)
154
- schema = @schemas.fetch_or_nil(mentry.schema)
155
- return unless schema
156
-
157
- Format.for(mentry.format).validate_against(
158
- schema,
159
- { "_meta" => eff_meta, "content" => eff_content },
160
- )
161
- end
162
-
163
- def check_etag!(path, key, if_etag)
164
- etag_before = @file_store.exists?(path) ? @file_store.etag(path) : nil
165
- raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && (etag_before != if_etag)
166
-
167
- etag_before
168
- end
169
-
170
- def write_bytes(path, bytes)
171
- @file_store.write(path, bytes)
172
- end
173
-
174
- def build_envelope(key, mentry, path, eff_meta, eff_body, eff_content, bytes = nil) # rubocop:disable Metrics/ParameterLists
175
- raw = bytes || @file_store.read(path)
176
- Textus::Value::Envelope.build(
177
- key: key, mentry: mentry, path: path,
178
- meta: eff_meta, body: eff_body,
179
- etag: Value::Etag.for_bytes(raw),
180
- content: eff_content
181
- )
182
- end
183
-
184
- def audit_put(key, etag_before, etag_after)
185
- extras = @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
186
- @audit_log.append(
187
- role: @call.role, verb: "put", key: key,
188
- etag_before: etag_before, etag_after: etag_after,
189
- extras: extras
190
- )
191
- end
192
- end
193
- end
194
- end
195
- end
@@ -1,74 +0,0 @@
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)
23
- VALUES (?, ?, ?, ?, ?, ?, ?)",
24
- [data[:key], data[:lane], data[:format], data[:etag], data[:content], data[:extra], now_iso],
25
- )
26
- end
27
- @store.execute("INSERT INTO entries_fts(entries_fts) VALUES('rebuild')")
28
- end
29
- { indexed: rows.size }
30
- end
31
-
32
- private
33
-
34
- def build_row(row)
35
- key = row.fetch(:key)
36
- path = row.fetch(:path)
37
- entry = row.fetch(:manifest_entry)
38
- return nil unless path && File.file?(path)
39
-
40
- raw = File.read(path)
41
- parsed = Textus::Format.for(entry.format).parse(raw, path: path)
42
- {
43
- key: key,
44
- lane: entry.lane,
45
- format: entry.format.to_s,
46
- etag: Textus::Value::Etag.for_bytes(raw),
47
- content: content_text(parsed),
48
- extra: extra_json(parsed),
49
- }
50
- end
51
-
52
- def content_text(parsed)
53
- content = parsed["content"]
54
- body = parsed["body"]
55
- parts = []
56
- parts << body if body
57
- parts << JSON.dump(content) if content
58
- parts.join("\n")
59
- end
60
-
61
- def extra_json(parsed)
62
- content = parsed["content"]
63
- extra = {}
64
- if content.is_a?(Hash)
65
- extra["content_hash"] = content["content_hash"] if content["content_hash"]
66
- url = content.dig("source", "url")
67
- extra["url"] = url if url
68
- end
69
- JSON.dump(extra)
70
- end
71
- end
72
- end
73
- end
74
- end
@@ -1,60 +0,0 @@
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
@@ -1,13 +0,0 @@
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
@@ -1,15 +0,0 @@
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
@@ -1,15 +0,0 @@
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
@@ -1,11 +0,0 @@
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
@@ -1,104 +0,0 @@
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
- SCOPE_RESOLVERS = {
17
- "materialize" => :producible_keys,
18
- "sweep" => :lane_keys,
19
- }.freeze
20
-
21
- GLOBAL_ACTIONS = {
22
- "index" => {},
23
- "sweep" => { "scope" => {} },
24
- }.freeze
25
-
26
- def self.seed(container:, queue:, role:)
27
- jobs = new(container: container).plan(
28
- trigger: { "type" => "convergence" },
29
- role: role,
30
- )
31
- jobs.each { |j| queue.enqueue(j) }
32
- end
33
-
34
- def initialize(container:)
35
- @container = container
36
- @manifest = container.manifest
37
- end
38
-
39
- def plan(trigger:, role:)
40
- type = trigger["type"] || trigger[:type]
41
- trigger["target"] || trigger[:target]
42
- return [] if type.nil?
43
-
44
- blocks_with_react = @manifest.rules.blocks.select(&:react)
45
- if blocks_with_react.any?
46
- plan_from_rules(blocks_with_react, type, role)
47
- else
48
- plan_from_defaults(type, role)
49
- end
50
- end
51
-
52
- private
53
-
54
- def plan_from_rules(blocks, type, role)
55
- jobs = []
56
- blocks
57
- .select { |b| matches_trigger?(b.react, type) }
58
- .each do |block|
59
- do_action = block.react.raw["do"]
60
- Array(do_action).each do |action|
61
- if (global_args = GLOBAL_ACTIONS[action])
62
- jobs << Textus::Store::Jobs::Queue::Job.new(type: action, args: global_args, role: role)
63
- else
64
- resolver = SCOPE_RESOLVERS.fetch(action, :producible_keys)
65
- keys = send(resolver, nil)
66
- keys.each { |key| jobs << job(action, key, role) }
67
- end
68
- end
69
- end
70
- jobs
71
- end
72
-
73
- def plan_from_defaults(type, role)
74
- actions = ACTIONS_BY_TRIGGER.fetch(type, [])
75
- jobs = []
76
- producible_keys(nil).each { |k| jobs << job("materialize", k, role) } if actions.include?("materialize")
77
- GLOBAL_ACTIONS.each do |action, args|
78
- jobs << Textus::Store::Jobs::Queue::Job.new(type: action, args: args, role: role) if actions.include?(action)
79
- end
80
- jobs
81
- end
82
-
83
- def matches_trigger?(react, type)
84
- on = react.raw["on"]
85
- Array(on).include?(type)
86
- end
87
-
88
- def job(type, key, role)
89
- Textus::Store::Jobs::Queue::Job.new(type: type, args: { "key" => key }, role: role)
90
- end
91
-
92
- def producible_keys(_target)
93
- @manifest.data.entries
94
- .select { |e| !e.publish_tree.nil? || !e.publish_to.empty? }
95
- .map(&:key)
96
- end
97
-
98
- def lane_keys(_target)
99
- @manifest.data.entries.map(&:key)
100
- end
101
- end
102
- end
103
- end
104
- end
@@ -1,154 +0,0 @@
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
- @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
- end
53
-
54
- def ready_ids
55
- list(:ready)
56
- end
57
-
58
- def lease(worker_id:, lease_ttl:)
59
- now = Time.now.utc
60
- expires_at = now + lease_ttl
61
- token = SecureRandom.hex(8)
62
- marked_lease = JSON.dump({ "worker_id" => worker_id, "expires_at" => expires_at.iso8601, "token" => token })
63
-
64
- @store.execute(
65
- "UPDATE jobs
66
- SET state = 'leased', lease = ?, updated_at = ?
67
- WHERE id = (
68
- SELECT id FROM jobs WHERE state = 'ready' ORDER BY created_at, id LIMIT 1
69
- )",
70
- [marked_lease, now.iso8601],
71
- )
72
- row = @store.execute("SELECT * FROM jobs WHERE state = 'leased' AND lease = ? LIMIT 1", [marked_lease]).first
73
- return nil unless row
74
-
75
- Leased.new(job_from_row(row))
76
- end
77
-
78
- def ack(leased)
79
- @store.execute(
80
- "UPDATE jobs SET state = 'done', lease = NULL, updated_at = ? WHERE id = ? AND state = 'leased'",
81
- [iso_now, leased.job.id],
82
- )
83
- end
84
-
85
- def fail(leased, error:)
86
- job = leased.job
87
- attempts = job.attempts + 1
88
- errors = job.errors + [{ "attempt" => attempts, "error" => error, "at" => iso_now }]
89
- dead = attempts >= job.max_attempts
90
- state = dead ? "failed" : "ready"
91
- @store.execute(
92
- "UPDATE jobs SET state = ?, attempts = ?, errors = ?, lease = NULL, updated_at = ? WHERE id = ?",
93
- [state, attempts, JSON.dump(errors), iso_now, job.id],
94
- )
95
- dead ? :dead_lettered : :requeued
96
- end
97
-
98
- def reclaim(now:)
99
- rows = @store.execute("SELECT id, lease FROM jobs WHERE state = 'leased'")
100
- expired = rows.select do |row|
101
- lease = JSON.parse(row["lease"] || "{}")
102
- expires_at = lease["expires_at"]
103
- expires_at.nil? || Time.parse(expires_at) <= now
104
- end
105
- expired.each do |row|
106
- @store.execute(
107
- "UPDATE jobs SET state = 'ready', lease = NULL, updated_at = ? WHERE id = ?",
108
- [now.utc.iso8601, row["id"]],
109
- )
110
- end
111
- expired.size
112
- end
113
-
114
- def list(state)
115
- state = state.to_s
116
- raise Textus::UsageError.new("unknown job state: #{state}") unless VALID_STATES.include?(state)
117
-
118
- @store.execute("SELECT id FROM jobs WHERE state = ? ORDER BY created_at, id", [state]).map { |row| row["id"] }
119
- end
120
-
121
- def retry_failed(job_id)
122
- @store.execute(
123
- "UPDATE jobs SET state = 'ready', attempts = 0, errors = ?, lease = NULL, updated_at = ? WHERE id = ? AND state = 'failed'",
124
- [JSON.dump([]), iso_now, job_id],
125
- )
126
- end
127
-
128
- def purge(state)
129
- state = state.to_s
130
- raise Textus::UsageError.new("unknown job state: #{state}") unless VALID_STATES.include?(state)
131
-
132
- @store.execute("DELETE FROM jobs WHERE state = ?", [state])
133
- end
134
-
135
- private
136
-
137
- def job_from_row(row)
138
- Job.new(
139
- type: row["type"],
140
- args: JSON.parse(row["args"] || "{}"),
141
- role: row["role"],
142
- attempts: row["attempts"],
143
- max_attempts: row["max_attempts"],
144
- errors: JSON.parse(row["errors"] || "[]"),
145
- )
146
- end
147
-
148
- def iso_now
149
- Time.now.utc.iso8601
150
- end
151
- end
152
- end
153
- end
154
- end
@@ -1,19 +0,0 @@
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