textus 0.52.0 → 0.54.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 (261) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/README.md +62 -54
  4. data/SPEC.md +64 -192
  5. data/docs/architecture/README.md +157 -156
  6. data/exe/textus +1 -1
  7. data/lib/textus/action/accept.rb +44 -0
  8. data/lib/textus/action/audit.rb +131 -0
  9. data/lib/textus/action/base.rb +42 -0
  10. data/lib/textus/{read → action}/blame.rb +28 -22
  11. data/lib/textus/action/boot.rb +17 -0
  12. data/lib/textus/action/data_mv.rb +69 -0
  13. data/lib/textus/action/deps.rb +34 -0
  14. data/lib/textus/action/doctor.rb +24 -0
  15. data/lib/textus/action/drain.rb +39 -0
  16. data/lib/textus/action/enqueue.rb +53 -0
  17. data/lib/textus/action/get.rb +78 -0
  18. data/lib/textus/action/ingest.rb +135 -0
  19. data/lib/textus/action/jobs.rb +36 -0
  20. data/lib/textus/action/key_delete.rb +36 -0
  21. data/lib/textus/action/key_delete_prefix.rb +44 -0
  22. data/lib/textus/action/key_mv.rb +129 -0
  23. data/lib/textus/action/key_mv_prefix.rb +57 -0
  24. data/lib/textus/action/list.rb +42 -0
  25. data/lib/textus/action/propose.rb +52 -0
  26. data/lib/textus/action/published.rb +24 -0
  27. data/lib/textus/action/pulse.rb +56 -0
  28. data/lib/textus/action/put.rb +52 -0
  29. data/lib/textus/action/rdeps.rb +39 -0
  30. data/lib/textus/action/reject.rb +37 -0
  31. data/lib/textus/action/rule_explain.rb +91 -0
  32. data/lib/textus/action/rule_lint.rb +68 -0
  33. data/lib/textus/action/rule_list.rb +42 -0
  34. data/lib/textus/action/schema_envelope.rb +29 -0
  35. data/lib/textus/action/uid.rb +33 -0
  36. data/lib/textus/action/where.rb +36 -0
  37. data/lib/textus/action/write_verb.rb +44 -0
  38. data/lib/textus/boot.rb +64 -132
  39. data/lib/textus/command.rb +41 -0
  40. data/lib/textus/container.rb +1 -1
  41. data/lib/textus/{domain → core}/duration.rb +1 -1
  42. data/lib/textus/{domain → core}/freshness/evaluator.rb +25 -41
  43. data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
  44. data/lib/textus/{domain → core}/freshness.rb +2 -2
  45. data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
  46. data/lib/textus/{domain → core}/retention.rb +2 -2
  47. data/lib/textus/{domain → core}/sentinel.rb +1 -1
  48. data/lib/textus/doctor/check/generator_drift.rb +1 -1
  49. data/lib/textus/doctor/check/illegal_keys.rb +1 -1
  50. data/lib/textus/doctor/check/notebook_sources.rb +50 -0
  51. data/lib/textus/doctor/check/proposal_targets.rb +3 -3
  52. data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
  53. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  54. data/lib/textus/doctor/check/schema_violations.rb +8 -2
  55. data/lib/textus/doctor/check.rb +11 -9
  56. data/lib/textus/{read → doctor}/validator.rb +22 -13
  57. data/lib/textus/doctor.rb +4 -44
  58. data/lib/textus/envelope/reader.rb +46 -0
  59. data/lib/textus/envelope/writer.rb +209 -0
  60. data/lib/textus/envelope.rb +5 -3
  61. data/lib/textus/errors.rb +25 -13
  62. data/lib/textus/{entry → format}/base.rb +1 -6
  63. data/lib/textus/{entry → format}/json.rb +1 -4
  64. data/lib/textus/{entry → format}/markdown.rb +2 -6
  65. data/lib/textus/{entry → format}/text.rb +2 -6
  66. data/lib/textus/{entry → format}/yaml.rb +1 -4
  67. data/lib/textus/{entry.rb → format.rb} +9 -10
  68. data/lib/textus/gate/auth.rb +227 -0
  69. data/lib/textus/gate.rb +116 -0
  70. data/lib/textus/init.rb +59 -105
  71. data/lib/textus/jobs/base.rb +23 -0
  72. data/lib/textus/jobs/materialize.rb +20 -0
  73. data/lib/textus/jobs/plan.rb +9 -0
  74. data/lib/textus/jobs/planner.rb +101 -0
  75. data/lib/textus/jobs/retention.rb +48 -0
  76. data/lib/textus/jobs/sweep.rb +27 -0
  77. data/lib/textus/jobs/worker.rb +67 -0
  78. data/lib/textus/jobs.rb +15 -0
  79. data/lib/textus/key/path.rb +10 -3
  80. data/lib/textus/layout.rb +13 -0
  81. data/lib/textus/manifest/data.rb +14 -14
  82. data/lib/textus/manifest/entry/base.rb +14 -25
  83. data/lib/textus/manifest/entry/parser.rb +31 -14
  84. data/lib/textus/manifest/entry/produced.rb +13 -33
  85. data/lib/textus/manifest/entry/publish/mode.rb +1 -1
  86. data/lib/textus/manifest/entry/publish/to_paths.rb +4 -3
  87. data/lib/textus/manifest/entry/publish.rb +0 -12
  88. data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
  89. data/lib/textus/manifest/entry/validators.rb +0 -1
  90. data/lib/textus/{domain → manifest}/policy/matcher.rb +2 -2
  91. data/lib/textus/{domain → manifest}/policy/publish_target.rb +2 -2
  92. data/lib/textus/manifest/policy/react.rb +30 -0
  93. data/lib/textus/{domain → manifest}/policy/retention.rb +3 -3
  94. data/lib/textus/manifest/policy/source.rb +30 -0
  95. data/lib/textus/manifest/policy.rb +38 -53
  96. data/lib/textus/manifest/resolver.rb +5 -4
  97. data/lib/textus/manifest/rules.rb +4 -4
  98. data/lib/textus/manifest/schema/keys.rb +20 -28
  99. data/lib/textus/manifest/schema/validator.rb +27 -33
  100. data/lib/textus/manifest/schema/vocabulary.rb +2 -1
  101. data/lib/textus/manifest/schema.rb +2 -2
  102. data/lib/textus/manifest.rb +2 -2
  103. data/lib/textus/ports/audit_log.rb +31 -5
  104. data/lib/textus/{domain/jobs → ports/job_store}/job.rb +19 -12
  105. data/lib/textus/ports/{queue.rb → job_store.rb} +7 -14
  106. data/lib/textus/ports/sentinel_store.rb +2 -2
  107. data/lib/textus/ports/watcher_lock.rb +48 -0
  108. data/lib/textus/produce/engine.rb +25 -70
  109. data/lib/textus/produce/render.rb +2 -7
  110. data/lib/textus/schema/tools.rb +4 -3
  111. data/lib/textus/session.rb +6 -3
  112. data/lib/textus/store.rb +12 -19
  113. data/lib/textus/surfaces/cli/group/data.rb +11 -0
  114. data/lib/textus/surfaces/cli/group/key.rb +11 -0
  115. data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
  116. data/lib/textus/surfaces/cli/group/rule.rb +11 -0
  117. data/lib/textus/surfaces/cli/group/schema.rb +11 -0
  118. data/lib/textus/surfaces/cli/group.rb +50 -0
  119. data/lib/textus/surfaces/cli/runner.rb +236 -0
  120. data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
  121. data/lib/textus/surfaces/cli/verb/get.rb +21 -0
  122. data/lib/textus/surfaces/cli/verb/init.rb +20 -0
  123. data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
  124. data/lib/textus/surfaces/cli/verb/put.rb +30 -0
  125. data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
  126. data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
  127. data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
  128. data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
  129. data/lib/textus/surfaces/cli/verb.rb +111 -0
  130. data/lib/textus/surfaces/cli.rb +148 -0
  131. data/lib/textus/surfaces/mcp/catalog.rb +99 -0
  132. data/lib/textus/surfaces/mcp/errors.rb +25 -0
  133. data/lib/textus/surfaces/mcp/routing.rb +51 -0
  134. data/lib/textus/surfaces/mcp/server.rb +184 -0
  135. data/lib/textus/surfaces/mcp/session.rb +9 -0
  136. data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
  137. data/lib/textus/surfaces/mcp.rb +8 -0
  138. data/lib/textus/surfaces/role_scope.rb +34 -0
  139. data/lib/textus/surfaces/watcher.rb +38 -0
  140. data/lib/textus/version.rb +1 -1
  141. data/lib/textus/workflow/collector.rb +27 -0
  142. data/lib/textus/workflow/context.rb +5 -0
  143. data/lib/textus/workflow/dsl.rb +44 -0
  144. data/lib/textus/workflow/loader.rb +17 -0
  145. data/lib/textus/workflow/pattern.rb +18 -0
  146. data/lib/textus/workflow/registry.rb +21 -0
  147. data/lib/textus/workflow/runner.rb +84 -0
  148. data/lib/textus/workflow.rb +28 -0
  149. data/lib/textus.rb +75 -22
  150. metadata +118 -132
  151. data/lib/textus/cli/group/hook.rb +0 -9
  152. data/lib/textus/cli/group/key.rb +0 -9
  153. data/lib/textus/cli/group/mcp.rb +0 -9
  154. data/lib/textus/cli/group/rule.rb +0 -9
  155. data/lib/textus/cli/group/schema.rb +0 -9
  156. data/lib/textus/cli/group/zone.rb +0 -9
  157. data/lib/textus/cli/group.rb +0 -48
  158. data/lib/textus/cli/runner.rb +0 -193
  159. data/lib/textus/cli/verb/doctor.rb +0 -17
  160. data/lib/textus/cli/verb/get.rb +0 -18
  161. data/lib/textus/cli/verb/hook_run.rb +0 -48
  162. data/lib/textus/cli/verb/hooks.rb +0 -50
  163. data/lib/textus/cli/verb/init.rb +0 -18
  164. data/lib/textus/cli/verb/mcp_serve.rb +0 -22
  165. data/lib/textus/cli/verb/put.rb +0 -30
  166. data/lib/textus/cli/verb/schema_diff.rb +0 -15
  167. data/lib/textus/cli/verb/schema_init.rb +0 -19
  168. data/lib/textus/cli/verb/schema_migrate.rb +0 -19
  169. data/lib/textus/cli/verb/serve.rb +0 -19
  170. data/lib/textus/cli/verb.rb +0 -116
  171. data/lib/textus/cli.rb +0 -138
  172. data/lib/textus/dispatcher.rb +0 -54
  173. data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
  174. data/lib/textus/doctor/check/hooks.rb +0 -32
  175. data/lib/textus/doctor/check/intake_registration.rb +0 -46
  176. data/lib/textus/domain/action.rb +0 -9
  177. data/lib/textus/domain/jobs/registry.rb +0 -37
  178. data/lib/textus/domain/permission.rb +0 -7
  179. data/lib/textus/domain/policy/base_guards.rb +0 -25
  180. data/lib/textus/domain/policy/evaluation.rb +0 -15
  181. data/lib/textus/domain/policy/guard.rb +0 -35
  182. data/lib/textus/domain/policy/guard_factory.rb +0 -40
  183. data/lib/textus/domain/policy/handler_allowlist.rb +0 -17
  184. data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
  185. data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
  186. data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
  187. data/lib/textus/domain/policy/predicates/registry.rb +0 -39
  188. data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
  189. data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
  190. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
  191. data/lib/textus/domain/policy/source.rb +0 -73
  192. data/lib/textus/envelope/io/reader.rb +0 -48
  193. data/lib/textus/envelope/io/writer.rb +0 -182
  194. data/lib/textus/hooks/builtin.rb +0 -70
  195. data/lib/textus/hooks/catalog.rb +0 -37
  196. data/lib/textus/hooks/context.rb +0 -72
  197. data/lib/textus/hooks/error_log.rb +0 -32
  198. data/lib/textus/hooks/event_bus.rb +0 -114
  199. data/lib/textus/hooks/fire_report.rb +0 -23
  200. data/lib/textus/hooks/loader.rb +0 -54
  201. data/lib/textus/hooks/rpc_registry.rb +0 -43
  202. data/lib/textus/hooks/signature.rb +0 -31
  203. data/lib/textus/init/templates/machine_intake.rb +0 -45
  204. data/lib/textus/init/templates/orientation_reducer.rb +0 -17
  205. data/lib/textus/jobs/handlers.rb +0 -62
  206. data/lib/textus/jobs/scheduler.rb +0 -36
  207. data/lib/textus/jobs/seeder.rb +0 -57
  208. data/lib/textus/maintenance/drain.rb +0 -42
  209. data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
  210. data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
  211. data/lib/textus/maintenance/retention/apply.rb +0 -52
  212. data/lib/textus/maintenance/rule_lint.rb +0 -66
  213. data/lib/textus/maintenance/serve.rb +0 -30
  214. data/lib/textus/maintenance/worker.rb +0 -74
  215. data/lib/textus/maintenance/zone_mv.rb +0 -64
  216. data/lib/textus/maintenance.rb +0 -15
  217. data/lib/textus/manifest/entry/validators/events.rb +0 -22
  218. data/lib/textus/mcp/catalog.rb +0 -70
  219. data/lib/textus/mcp/errors.rb +0 -32
  220. data/lib/textus/mcp/server.rb +0 -138
  221. data/lib/textus/mcp/session.rb +0 -7
  222. data/lib/textus/mcp/tool_schemas.rb +0 -15
  223. data/lib/textus/mcp.rb +0 -6
  224. data/lib/textus/mustache.rb +0 -117
  225. data/lib/textus/ports/audit_subscriber.rb +0 -42
  226. data/lib/textus/ports/produce_on_write_subscriber.rb +0 -73
  227. data/lib/textus/produce/acquire/handler.rb +0 -29
  228. data/lib/textus/produce/acquire/intake.rb +0 -130
  229. data/lib/textus/produce/acquire/projection.rb +0 -127
  230. data/lib/textus/produce/acquire/serializer/json.rb +0 -31
  231. data/lib/textus/produce/acquire/serializer/text.rb +0 -16
  232. data/lib/textus/produce/acquire/serializer/yaml.rb +0 -31
  233. data/lib/textus/produce/acquire/serializer.rb +0 -17
  234. data/lib/textus/produce/events.rb +0 -36
  235. data/lib/textus/projection.rb +0 -103
  236. data/lib/textus/read/audit.rb +0 -130
  237. data/lib/textus/read/boot.rb +0 -26
  238. data/lib/textus/read/capabilities.rb +0 -70
  239. data/lib/textus/read/deps.rb +0 -38
  240. data/lib/textus/read/doctor.rb +0 -27
  241. data/lib/textus/read/freshness.rb +0 -152
  242. data/lib/textus/read/get.rb +0 -73
  243. data/lib/textus/read/jobs.rb +0 -31
  244. data/lib/textus/read/list.rb +0 -24
  245. data/lib/textus/read/published.rb +0 -22
  246. data/lib/textus/read/pulse.rb +0 -98
  247. data/lib/textus/read/rdeps.rb +0 -39
  248. data/lib/textus/read/rule_explain.rb +0 -96
  249. data/lib/textus/read/rule_list.rb +0 -54
  250. data/lib/textus/read/schema_envelope.rb +0 -25
  251. data/lib/textus/read/uid.rb +0 -29
  252. data/lib/textus/read/validate_all.rb +0 -36
  253. data/lib/textus/read/where.rb +0 -24
  254. data/lib/textus/role_scope.rb +0 -78
  255. data/lib/textus/write/accept.rb +0 -58
  256. data/lib/textus/write/enqueue.rb +0 -50
  257. data/lib/textus/write/key_delete.rb +0 -65
  258. data/lib/textus/write/key_mv.rb +0 -141
  259. data/lib/textus/write/propose.rb +0 -54
  260. data/lib/textus/write/put.rb +0 -74
  261. data/lib/textus/write/reject.rb +0 -68
@@ -0,0 +1,209 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ class Envelope
5
+ # Owns the write pipeline (validate, serialize, etag-check, write, audit).
6
+ # Talks to ports (FileStore, Schemas, AuditLog, Manifest) and an
7
+ # Reader for the existing-uid lookup.
8
+ #
9
+ # Invariant: every public method's final action is @audit_log.append(...).
10
+ #
11
+ # No permission check, no event firing — those belong to the caller
12
+ # (Write::Put / ::Delete / ::Mv).
13
+ class Writer
14
+ Payload = Data.define(:meta, :body, :content)
15
+
16
+ def self.from(container:, call:)
17
+ new(
18
+ file_store: container.file_store, manifest: container.manifest,
19
+ schemas: container.schemas, audit_log: container.audit_log,
20
+ call: call, reader: Reader.from(container: container)
21
+ )
22
+ end
23
+
24
+ def initialize(file_store:, manifest:, schemas:, audit_log:, call:, reader:)
25
+ @file_store = file_store
26
+ @manifest = manifest
27
+ @schemas = schemas
28
+ @audit_log = audit_log
29
+ @call = call
30
+ @reader = reader
31
+ end
32
+
33
+ def put(key, mentry:, payload:, if_etag: nil)
34
+ path = resolve_path(key)
35
+ meta, content = prepare_uid(mentry, payload, key)
36
+ bytes, eff_meta, eff_body, eff_content = serialize_entry(mentry, path, meta, payload, content)
37
+ enforce_name_match!(path, eff_meta, mentry.format)
38
+ validate_schema(mentry, eff_meta, eff_content)
39
+ etag_before = check_etag!(path, key, if_etag)
40
+ write_bytes(path, bytes)
41
+ envelope = build_envelope(key, mentry, path, eff_meta, eff_body, eff_content)
42
+ audit_put(key, etag_before, envelope.etag)
43
+ envelope
44
+ end
45
+
46
+ def delete(key, mentry: nil, if_etag: nil) # rubocop:disable Lint/UnusedMethodArgument
47
+ # `mentry:` is accepted for symmetry with `put` / `move` and to
48
+ # leave room for future format-specific delete hooks; no field
49
+ # on it is needed today.
50
+ path = @manifest.resolver.resolve(key).path
51
+ raise UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)) unless @file_store.exists?(path)
52
+
53
+ etag_before = @file_store.etag(path)
54
+ raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before
55
+
56
+ @file_store.delete(path)
57
+ prune_empty_parents(path)
58
+ @audit_log.append(
59
+ role: @call.role, verb: "key_delete", key: key,
60
+ etag_before: etag_before, etag_after: nil,
61
+ extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
62
+ )
63
+ end
64
+
65
+ def move(from_key:, to_key:, new_mentry:, if_etag: nil)
66
+ from_path = @manifest.resolver.resolve(from_key).path
67
+ to_path = @manifest.resolver.resolve(to_key).path
68
+ raise UnknownKey.new(from_key, suggestions: @manifest.resolver.suggestions_for(from_key)) unless @file_store.exists?(from_path)
69
+
70
+ etag_before = @file_store.etag(from_path)
71
+ raise EtagMismatch.new(from_key, if_etag, etag_before) if if_etag && if_etag != etag_before
72
+
73
+ FileUtils.mkdir_p(File.dirname(to_path))
74
+ FileUtils.mv(from_path, to_path)
75
+ prune_empty_parents(from_path)
76
+ basename = to_key.split(".").last
77
+ Format.for(new_mentry.format).rewrite_name(to_path, basename)
78
+ etag_after = Etag.for_file(to_path)
79
+
80
+ raw = @file_store.read(to_path)
81
+ parsed = Format.for(new_mentry.format).parse(raw, path: to_path)
82
+ envelope = Textus::Envelope.build(
83
+ key: to_key, mentry: new_mentry, path: to_path,
84
+ meta: parsed["_meta"], body: parsed["body"],
85
+ etag: etag_after, content: parsed["content"]
86
+ )
87
+
88
+ extras = {
89
+ "from_key" => from_key, "to_key" => to_key,
90
+ "from_path" => from_path, "to_path" => to_path,
91
+ "uid" => envelope.uid
92
+ }
93
+ extras["correlation_id"] = @call.correlation_id if @call.correlation_id
94
+
95
+ @audit_log.append(
96
+ role: @call.role, verb: "key_mv", key: to_key,
97
+ etag_before: etag_before, etag_after: etag_after,
98
+ extras: extras
99
+ )
100
+
101
+ envelope
102
+ end
103
+
104
+ private
105
+
106
+ # After a file leaves a directory (delete or move-source), remove any
107
+ # now-empty parent dirs so bulk move/delete doesn't accrue orphan dirs
108
+ # (F3 of #161). Floored at the entry's *zone directory* — a zone is a
109
+ # declared, first-class container, so its own dir is preserved even when
110
+ # momentarily empty; only the sub-dirs the bulk op carved out are
111
+ # pruned. Stops at the first non-empty ancestor, so a dir holding a
112
+ # `.gitkeep` or sibling entries survives. Best-effort: a lost race or a
113
+ # non-empty dir is silently fine, never fatal to the write.
114
+ def prune_empty_parents(path)
115
+ floor = zone_floor(path)
116
+ return unless floor
117
+
118
+ dir = File.dirname(path)
119
+ while dir.start_with?("#{floor}/") && Dir.empty?(dir)
120
+ Dir.rmdir(dir)
121
+ dir = File.dirname(dir)
122
+ end
123
+ rescue SystemCallError
124
+ nil
125
+ end
126
+
127
+ # The zone directory under which `path` lives (`<root>/zones/<zone>`),
128
+ # or nil if `path` is not under the store's zones tree.
129
+ def zone_floor(path)
130
+ zones_root = File.join(@manifest.data.root, "data")
131
+ prefix = "#{zones_root}/"
132
+ return nil unless path.start_with?(prefix)
133
+
134
+ zone_seg = path.delete_prefix(prefix).split("/").first
135
+ zone_seg && File.join(zones_root, zone_seg)
136
+ end
137
+
138
+ def ensure_uid(format, meta, content, existing_uid)
139
+ Textus::Format.for(format).inject_uid(meta, content, existing_uid)
140
+ end
141
+
142
+ def enforce_name_match!(path, meta, format)
143
+ Textus::Format.for(format).enforce_name_match!(path, meta)
144
+ end
145
+
146
+ def serialize_for_put(mentry:, path:, meta:, body:, content:)
147
+ Textus::Format.for(mentry.format).serialize_for_put(
148
+ meta: meta, body: body, content: content, path: path,
149
+ )
150
+ end
151
+
152
+ def resolve_path(key)
153
+ @manifest.resolver.resolve(key).path
154
+ end
155
+
156
+ def prepare_uid(mentry, payload, key)
157
+ meta = payload.meta || {}
158
+ existing_uid = @reader.existing_uid(key)
159
+ ensure_uid(mentry.format, meta, payload.content, existing_uid)
160
+ end
161
+
162
+ def serialize_entry(mentry, path, meta, payload, content)
163
+ serialize_for_put(
164
+ mentry: mentry, path: path,
165
+ meta: meta, body: payload.body, content: content
166
+ )
167
+ end
168
+
169
+ def validate_schema(mentry, eff_meta, eff_content)
170
+ schema = @schemas.fetch_or_nil(mentry.schema)
171
+ return unless schema
172
+
173
+ Format.for(mentry.format).validate_against(
174
+ schema,
175
+ { "_meta" => eff_meta, "content" => eff_content },
176
+ )
177
+ end
178
+
179
+ def check_etag!(path, key, if_etag)
180
+ etag_before = @file_store.exists?(path) ? @file_store.etag(path) : nil
181
+ raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && (etag_before != if_etag)
182
+
183
+ etag_before
184
+ end
185
+
186
+ def write_bytes(path, bytes)
187
+ @file_store.write(path, bytes)
188
+ end
189
+
190
+ def build_envelope(key, mentry, path, eff_meta, eff_body, eff_content)
191
+ Textus::Envelope.build(
192
+ key: key, mentry: mentry, path: path,
193
+ meta: eff_meta, body: eff_body,
194
+ etag: Etag.for_bytes(@file_store.read(path)),
195
+ content: eff_content
196
+ )
197
+ end
198
+
199
+ def audit_put(key, etag_before, etag_after)
200
+ extras = @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
201
+ @audit_log.append(
202
+ role: @call.role, verb: "put", key: key,
203
+ etag_before: etag_before, etag_after: etag_after,
204
+ extras: extras
205
+ )
206
+ end
207
+ end
208
+ end
209
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Textus
4
4
  Envelope = Data.define(
5
- :protocol, :key, :zone, :owner, :path, :format,
5
+ :protocol, :key, :lane, :owner, :path, :format,
6
6
  :uid, :etag, :schema_ref, :meta, :body, :content, :freshness
7
7
  ) do
8
8
  # rubocop:disable Metrics/ParameterLists
@@ -11,7 +11,7 @@ module Textus
11
11
  new(
12
12
  protocol: Textus::PROTOCOL,
13
13
  key: key,
14
- zone: mentry.zone,
14
+ lane: mentry.lane,
15
15
  owner: mentry.owner,
16
16
  path: path,
17
17
  format: mentry.format,
@@ -34,7 +34,7 @@ module Textus
34
34
  h = {
35
35
  "protocol" => protocol,
36
36
  "key" => key,
37
- "zone" => zone,
37
+ "lane" => lane,
38
38
  "owner" => owner,
39
39
  "path" => path,
40
40
  "format" => format,
@@ -49,6 +49,8 @@ module Textus
49
49
  h
50
50
  end
51
51
 
52
+ alias_method :zone, :lane
53
+
52
54
  def stale?
53
55
  return false if freshness.nil?
54
56
 
data/lib/textus/errors.rb CHANGED
@@ -1,24 +1,32 @@
1
1
  module Textus
2
+ ErrorInfo = Data.define(:details, :exit_code, :hint) do
3
+ def self.for(details: {}, exit_code: 1, hint: nil)
4
+ new(details: details, exit_code: exit_code, hint: hint)
5
+ end
6
+ end
7
+
2
8
  class Error < StandardError
3
- attr_reader :code, :details, :exit_code, :hint
9
+ attr_reader :code
4
10
 
5
- def initialize(code, message, details: {}, exit_code: 1, hint: nil)
11
+ def initialize(code, message, info: nil, details: {}, exit_code: 1, hint: nil)
6
12
  super(message)
7
13
  @code = code
8
- @details = details
9
- @exit_code = exit_code
10
- @hint = hint
14
+ @info = info || ErrorInfo.for(details:, exit_code:, hint:)
11
15
  end
12
16
 
17
+ def details = @info.details
18
+ def exit_code = @info.exit_code
19
+ def hint = @info.hint
20
+
13
21
  def to_envelope
14
22
  env = {
15
23
  "protocol" => Textus::PROTOCOL,
16
24
  "ok" => false,
17
25
  "code" => @code,
18
26
  "message" => message,
19
- "details" => @details,
27
+ "details" => details,
20
28
  }
21
- env["hint"] = @hint if @hint
29
+ env["hint"] = hint if hint
22
30
  env
23
31
  end
24
32
  end
@@ -97,7 +105,7 @@ module Textus
97
105
  else
98
106
  "no declared role"
99
107
  end
100
- details = { "key" => k, "zone" => z }
108
+ details = { "key" => k, "lane" => z }
101
109
  details["verb"] = verb if verb
102
110
  details["holders"] = holders if holders
103
111
  super(
@@ -150,10 +158,6 @@ module Textus
150
158
  end
151
159
  end
152
160
 
153
- class InvalidProjection < Error
154
- def initialize(m) = super("invalid_projection", m)
155
- end
156
-
157
161
  class TemplateError < Error
158
162
  def initialize(m, template_name: nil)
159
163
  hint =
@@ -177,7 +181,7 @@ module Textus
177
181
  class PublishError < Error
178
182
  def initialize(m, target: nil)
179
183
  hint =
180
- ("file at #{target} wasn't published by textus; back it up and delete it, or move it under .textus/zones/" if target)
184
+ ("file at #{target} wasn't published by textus; back it up and delete it, or move it under .textus/data/" if target)
181
185
  super("publish_error", m, details: target ? { "target" => target } : {}, hint: hint)
182
186
  end
183
187
  end
@@ -201,6 +205,14 @@ module Textus
201
205
  end
202
206
  end
203
207
 
208
+ class ContractDrift < Error
209
+ JSONRPC_CODE = -32_001
210
+
211
+ def initialize(message, details: {})
212
+ super("contract_drift", message, details: details)
213
+ end
214
+ end
215
+
204
216
  class FlagRenamed < Error
205
217
  def initialize(old_flag, new_flag)
206
218
  super(
@@ -1,8 +1,5 @@
1
1
  module Textus
2
- module Entry
3
- # Abstract base for entry format strategies. Each concrete strategy
4
- # owns parsing, serialization, file-extension claims, and schema
5
- # validation for entries declared with its format.
2
+ module Format
6
3
  class Base
7
4
  def self.parse(_raw, path: nil)
8
5
  _ = path
@@ -20,8 +17,6 @@ module Textus
20
17
  raise NotImplementedError.new("#{name}.extensions not implemented")
21
18
  end
22
19
 
23
- # Default: validate the meta hash. Overridden by formats that put the
24
- # validatable payload elsewhere (json/yaml put it under "content").
25
20
  def self.validate_against(schema, parsed)
26
21
  schema.validate!(parsed["_meta"] || {})
27
22
  end
@@ -1,8 +1,7 @@
1
1
  require "json"
2
2
 
3
3
  module Textus
4
- module Entry
5
- # JSON entry storage. Top-level must be an object so we can carry _meta.
4
+ module Format
6
5
  class Json < Base
7
6
  def self.parse(raw, path: nil)
8
7
  raw = raw.dup.force_encoding(Encoding::UTF_8)
@@ -23,7 +22,6 @@ module Textus
23
22
 
24
23
  def self.serialize(meta:, body:, content: nil)
25
24
  if content.is_a?(Hash)
26
- # Re-inject _meta as the first key so on-disk shape is stable.
27
25
  on_disk = meta && !meta.empty? ? { "_meta" => meta }.merge(content) : content
28
26
  out = ::JSON.pretty_generate(on_disk)
29
27
  out += "\n" unless out.end_with?("\n")
@@ -61,7 +59,6 @@ module Textus
61
59
  end
62
60
  end
63
61
 
64
- # Mutating filesystem op; returns true if a write happened.
65
62
  def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
66
63
  raw = File.binread(path)
67
64
  parsed = parse(raw, path: path)
@@ -1,8 +1,7 @@
1
1
  require "yaml"
2
2
 
3
3
  module Textus
4
- module Entry
5
- # Markdown with YAML frontmatter. Original Entry implementation.
4
+ module Format
6
5
  class Markdown < Base
7
6
  def self.parse(raw, path: nil)
8
7
  raw = raw.dup.force_encoding(Encoding::UTF_8)
@@ -26,7 +25,7 @@ module Textus
26
25
  end
27
26
 
28
27
  def self.serialize(meta:, body:, content: nil)
29
- _ = content # markdown ignores content
28
+ _ = content
30
29
  fm_yaml = meta.empty? ? "" : ::YAML.dump(meta).sub(/\A---\n/, "")
31
30
  body = body.to_s
32
31
  body += "\n" unless body.empty? || body.end_with?("\n")
@@ -44,9 +43,6 @@ module Textus
44
43
  [bytes, meta, body.to_s, nil]
45
44
  end
46
45
 
47
- # Mutating filesystem op; returns true if a write happened (boolean is
48
- # informational, not a predicate). Rubocop's predicate-name heuristic
49
- # disabled here on purpose.
50
46
  def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
51
47
  raw = File.binread(path)
52
48
  parsed = parse(raw, path: path)
@@ -1,6 +1,5 @@
1
1
  module Textus
2
- module Entry
3
- # Plain-text entry storage. No frontmatter or structured content.
2
+ module Format
4
3
  class Text < Base
5
4
  def self.parse(raw, path: nil)
6
5
  raw = raw.dup.force_encoding(Encoding::UTF_8)
@@ -25,9 +24,7 @@ module Textus
25
24
  [meta, content]
26
25
  end
27
26
 
28
- def self.enforce_name_match!(_path, _meta)
29
- # text has no meta home; no-op
30
- end
27
+ def self.enforce_name_match!(_path, _meta); end
31
28
 
32
29
  def self.serialize_for_put(meta:, body:, content:, path:)
33
30
  _ = path
@@ -36,7 +33,6 @@ module Textus
36
33
  [bytes, meta, body.to_s, nil]
37
34
  end
38
35
 
39
- # No-op; text has no meta. Returns false (never writes).
40
36
  def self.rewrite_name(_path, _basename) # rubocop:disable Naming/PredicateMethod
41
37
  false
42
38
  end
@@ -1,8 +1,7 @@
1
1
  require "yaml"
2
2
 
3
3
  module Textus
4
- module Entry
5
- # YAML entry storage. Top-level must be a mapping so we can carry _meta.
4
+ module Format
6
5
  class Yaml < Base
7
6
  def self.parse(raw, path: nil)
8
7
  raw = raw.dup.force_encoding(Encoding::UTF_8)
@@ -23,7 +22,6 @@ module Textus
23
22
 
24
23
  def self.serialize(meta:, body:, content: nil)
25
24
  if content.is_a?(Hash)
26
- # Re-inject _meta as the first key so on-disk shape is stable.
27
25
  on_disk = meta && !meta.empty? ? { "_meta" => meta }.merge(content) : content
28
26
  ::YAML.dump(on_disk).sub(/\A---\n/, "")
29
27
  elsif body && !body.to_s.empty?
@@ -59,7 +57,6 @@ module Textus
59
57
  end
60
58
  end
61
59
 
62
- # Mutating filesystem op; returns true if a write happened.
63
60
  def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
64
61
  raw = File.binread(path)
65
62
  parsed = parse(raw, path: path)
@@ -1,13 +1,12 @@
1
1
  module Textus
2
- # Public entry-format dispatcher.
3
- module Entry
2
+ module Format
4
3
  SEP = "---".freeze
5
4
 
6
5
  STRATEGIES = {
7
- "markdown" => Markdown,
8
- "json" => Json,
9
- "yaml" => Yaml,
10
- "text" => Text,
6
+ "markdown" => -> { Format::Markdown },
7
+ "json" => -> { Format::Json },
8
+ "yaml" => -> { Format::Yaml },
9
+ "text" => -> { Format::Text },
11
10
  }.freeze
12
11
 
13
12
  EXT_TO_FORMAT = {
@@ -18,8 +17,8 @@ module Textus
18
17
  ".txt" => "text",
19
18
  }.freeze
20
19
 
21
- def self.for_format(format)
22
- STRATEGIES.fetch(format.to_s) { raise UsageError.new("unknown entry format: #{format.inspect}") }
20
+ def self.for(format)
21
+ STRATEGIES.fetch(format.to_s) { raise Textus::UsageError.new("unknown entry format: #{format.inspect}") }.call
23
22
  end
24
23
 
25
24
  def self.infer_from_extension(ext)
@@ -31,11 +30,11 @@ module Textus
31
30
  end
32
31
 
33
32
  def self.parse(raw, path: nil, format: "markdown")
34
- for_format(format).parse(raw, path: path)
33
+ Format.for(format).parse(raw, path: path)
35
34
  end
36
35
 
37
36
  def self.serialize(meta: {}, body: "", content: nil, format: "markdown")
38
- for_format(format).serialize(meta: meta, body: body, content: content)
37
+ Format.for(format).serialize(meta: meta, body: body, content: content)
39
38
  end
40
39
  end
41
40
  end