textus 0.54.2 → 0.55.1

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 (176) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +8 -1
  4. data/SPEC.md +27 -0
  5. data/docs/architecture/README.md +20 -8
  6. data/docs/reference/conventions.md +1 -1
  7. data/exe/textus +1 -1
  8. data/lib/textus/action/accept.rb +23 -21
  9. data/lib/textus/action/audit.rb +24 -61
  10. data/lib/textus/action/base.rb +9 -9
  11. data/lib/textus/action/blame.rb +18 -36
  12. data/lib/textus/action/boot.rb +2 -4
  13. data/lib/textus/action/data_mv.rb +20 -31
  14. data/lib/textus/action/deps.rb +3 -18
  15. data/lib/textus/action/doctor.rb +2 -9
  16. data/lib/textus/action/drain.rb +11 -19
  17. data/lib/textus/action/enqueue.rb +14 -30
  18. data/lib/textus/action/get.rb +12 -56
  19. data/lib/textus/action/ingest.rb +74 -78
  20. data/lib/textus/action/jobs.rb +6 -15
  21. data/lib/textus/action/key_delete.rb +6 -16
  22. data/lib/textus/action/key_delete_prefix.rb +8 -17
  23. data/lib/textus/action/key_mv.rb +54 -61
  24. data/lib/textus/action/key_mv_prefix.rb +13 -22
  25. data/lib/textus/action/list.rb +7 -21
  26. data/lib/textus/action/propose.rb +16 -26
  27. data/lib/textus/action/published.rb +3 -5
  28. data/lib/textus/action/pulse.rb +19 -26
  29. data/lib/textus/action/put.rb +15 -29
  30. data/lib/textus/action/rdeps.rb +3 -18
  31. data/lib/textus/action/reject.rb +12 -21
  32. data/lib/textus/action/rule_explain.rb +12 -22
  33. data/lib/textus/action/rule_lint.rb +10 -16
  34. data/lib/textus/action/rule_list.rb +5 -9
  35. data/lib/textus/action/schema_envelope.rb +3 -10
  36. data/lib/textus/action/uid.rb +3 -17
  37. data/lib/textus/action/where.rb +3 -18
  38. data/lib/textus/boot.rb +7 -15
  39. data/lib/textus/contract/arg.rb +10 -0
  40. data/lib/textus/contract/dsl.rb +88 -0
  41. data/lib/textus/contract/spec.rb +25 -0
  42. data/lib/textus/contract.rb +0 -162
  43. data/lib/textus/doctor/check/audit_log.rb +2 -2
  44. data/lib/textus/doctor/check/generator_drift.rb +2 -2
  45. data/lib/textus/doctor/check/orphaned_publish_targets.rb +3 -3
  46. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  47. data/lib/textus/doctor/check/raw_asset_paths.rb +1 -1
  48. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  49. data/lib/textus/doctor/check/schema_violations.rb +2 -2
  50. data/lib/textus/doctor/check/schemas.rb +1 -1
  51. data/lib/textus/doctor/check/sentinels.rb +4 -4
  52. data/lib/textus/doctor/check/templates.rb +1 -1
  53. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  54. data/lib/textus/doctor/check.rb +4 -7
  55. data/lib/textus/doctor.rb +1 -1
  56. data/lib/textus/errors.rb +6 -0
  57. data/lib/textus/format/base.rb +0 -4
  58. data/lib/textus/format/json.rb +5 -6
  59. data/lib/textus/format/markdown.rb +5 -6
  60. data/lib/textus/format/shared.rb +17 -0
  61. data/lib/textus/format/text.rb +5 -4
  62. data/lib/textus/format/yaml.rb +30 -6
  63. data/lib/textus/format.rb +6 -0
  64. data/lib/textus/gate/auth.rb +2 -17
  65. data/lib/textus/gate/binder.rb +50 -0
  66. data/lib/textus/gate.rb +64 -88
  67. data/lib/textus/init.rb +2 -4
  68. data/lib/textus/jobs.rb +3 -9
  69. data/lib/textus/manifest/capabilities.rb +3 -3
  70. data/lib/textus/manifest/entry/base.rb +1 -1
  71. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
  72. data/lib/textus/manifest/entry/publish/to_paths.rb +1 -1
  73. data/lib/textus/manifest/schema/semantics/cross_field.rb +53 -0
  74. data/lib/textus/manifest/schema/semantics/invariants.rb +125 -0
  75. data/lib/textus/manifest/schema/semantics/migration.rb +83 -0
  76. data/lib/textus/manifest/schema/semantics.rb +11 -216
  77. data/lib/textus/meta.rb +54 -0
  78. data/lib/textus/{ports → port}/audit_log.rb +44 -4
  79. data/lib/textus/{ports → port}/build_lock.rb +2 -2
  80. data/lib/textus/{ports → port}/clock.rb +1 -1
  81. data/lib/textus/{ports → port}/publisher.rb +5 -5
  82. data/lib/textus/{ports → port}/sentinel_store.rb +3 -3
  83. data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
  84. data/lib/textus/{ports → port}/storage/file_store.rb +2 -2
  85. data/lib/textus/port/store.rb +93 -0
  86. data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
  87. data/lib/textus/produce/engine.rb +1 -1
  88. data/lib/textus/schema/tools.rb +11 -7
  89. data/lib/textus/store/compositor.rb +34 -0
  90. data/lib/textus/store/container.rb +43 -0
  91. data/lib/textus/store/cursor.rb +26 -0
  92. data/lib/textus/store/envelope/reader.rb +43 -0
  93. data/lib/textus/store/envelope/writer.rb +195 -0
  94. data/lib/textus/store/geometry.rb +81 -0
  95. data/lib/textus/store/index/builder.rb +74 -0
  96. data/lib/textus/store/index/lookup.rb +60 -0
  97. data/lib/textus/store/jobs/base.rb +13 -0
  98. data/lib/textus/store/jobs/index.rb +15 -0
  99. data/lib/textus/store/jobs/materialize.rb +15 -0
  100. data/lib/textus/store/jobs/plan.rb +11 -0
  101. data/lib/textus/store/jobs/planner.rb +104 -0
  102. data/lib/textus/store/jobs/queue.rb +154 -0
  103. data/lib/textus/store/jobs/registry.rb +19 -0
  104. data/lib/textus/store/jobs/retention.rb +50 -0
  105. data/lib/textus/store/jobs/sweep.rb +21 -0
  106. data/lib/textus/store/jobs/worker.rb +64 -0
  107. data/lib/textus/store/session.rb +37 -0
  108. data/lib/textus/store.rb +21 -13
  109. data/lib/textus/{surfaces → surface}/cli/group/data.rb +1 -1
  110. data/lib/textus/{surfaces → surface}/cli/group/key.rb +1 -1
  111. data/lib/textus/{surfaces → surface}/cli/group/mcp.rb +1 -1
  112. data/lib/textus/{surfaces → surface}/cli/group/rule.rb +1 -1
  113. data/lib/textus/{surfaces → surface}/cli/group/schema.rb +1 -1
  114. data/lib/textus/{surfaces → surface}/cli/group.rb +2 -2
  115. data/lib/textus/{surfaces → surface}/cli/runner.rb +10 -63
  116. data/lib/textus/surface/cli/sources.rb +41 -0
  117. data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +4 -6
  118. data/lib/textus/{surfaces → surface}/cli/verb/get.rb +4 -4
  119. data/lib/textus/{surfaces → surface}/cli/verb/init.rb +1 -1
  120. data/lib/textus/{surfaces → surface}/cli/verb/mcp_serve.rb +3 -3
  121. data/lib/textus/{surfaces → surface}/cli/verb/put.rb +6 -11
  122. data/lib/textus/{surfaces → surface}/cli/verb/schema_diff.rb +1 -1
  123. data/lib/textus/{surfaces → surface}/cli/verb/schema_init.rb +1 -1
  124. data/lib/textus/{surfaces → surface}/cli/verb/schema_migrate.rb +1 -1
  125. data/lib/textus/{surfaces → surface}/cli/verb/watch.rb +2 -2
  126. data/lib/textus/{surfaces → surface}/cli/verb.rb +3 -8
  127. data/lib/textus/{surfaces → surface}/cli.rb +1 -1
  128. data/lib/textus/{surfaces → surface}/mcp/catalog.rb +9 -26
  129. data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -1
  130. data/lib/textus/{surfaces → surface}/mcp/server.rb +5 -5
  131. data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
  132. data/lib/textus/surface/projector.rb +27 -0
  133. data/lib/textus/{surfaces → surface}/role_scope.rb +1 -1
  134. data/lib/textus/{surfaces → surface}/watcher.rb +8 -8
  135. data/lib/textus/value/call.rb +30 -0
  136. data/lib/textus/value/command.rb +16 -0
  137. data/lib/textus/value/envelope.rb +89 -0
  138. data/lib/textus/value/etag.rb +39 -0
  139. data/lib/textus/value/result.rb +26 -0
  140. data/lib/textus/value/role.rb +38 -0
  141. data/lib/textus/value/types.rb +13 -0
  142. data/lib/textus/{uid.rb → value/uid.rb} +9 -7
  143. data/lib/textus/version.rb +1 -1
  144. data/lib/textus/workflow/loader.rb +4 -4
  145. data/lib/textus/workflow/runner.rb +4 -18
  146. data/lib/textus.rb +9 -10
  147. metadata +100 -63
  148. data/lib/textus/action/write_verb.rb +0 -44
  149. data/lib/textus/call.rb +0 -28
  150. data/lib/textus/command.rb +0 -41
  151. data/lib/textus/container.rb +0 -26
  152. data/lib/textus/contract/around.rb +0 -29
  153. data/lib/textus/contract/binder.rb +0 -88
  154. data/lib/textus/contract/resources/build_lock.rb +0 -17
  155. data/lib/textus/contract/resources/cursor.rb +0 -26
  156. data/lib/textus/contract/sources.rb +0 -39
  157. data/lib/textus/contract/view.rb +0 -15
  158. data/lib/textus/cursor_store.rb +0 -24
  159. data/lib/textus/envelope/reader.rb +0 -46
  160. data/lib/textus/envelope/writer.rb +0 -209
  161. data/lib/textus/envelope.rb +0 -79
  162. data/lib/textus/etag.rb +0 -36
  163. data/lib/textus/jobs/base.rb +0 -23
  164. data/lib/textus/jobs/materialize.rb +0 -20
  165. data/lib/textus/jobs/plan.rb +0 -9
  166. data/lib/textus/jobs/planner.rb +0 -101
  167. data/lib/textus/jobs/retention.rb +0 -48
  168. data/lib/textus/jobs/sweep.rb +0 -27
  169. data/lib/textus/jobs/worker.rb +0 -67
  170. data/lib/textus/layout.rb +0 -91
  171. data/lib/textus/ports/job_store/job.rb +0 -65
  172. data/lib/textus/ports/job_store.rb +0 -123
  173. data/lib/textus/ports/raw_index.rb +0 -61
  174. data/lib/textus/role.rb +0 -36
  175. data/lib/textus/session.rb +0 -35
  176. data/lib/textus/types.rb +0 -15
@@ -0,0 +1,195 @@
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
@@ -0,0 +1,81 @@
1
+ module Textus
2
+ class Store
3
+ class Geometry
4
+ RUN = ".state"
5
+ DATA = "data"
6
+ ASSETS = "assets"
7
+
8
+ def initialize(root)
9
+ @root = root
10
+ freeze
11
+ end
12
+
13
+ attr_reader :root
14
+
15
+ # -- data paths --
16
+ def data_root = File.join(@root, DATA)
17
+ def lane_path(lane_name) = File.join(data_root, lane_name.to_s)
18
+
19
+ def entry_path(mentry)
20
+ primary_ext = Format.for(mentry.format).extensions.first
21
+ rel = normalize_relative_path(mentry.path)
22
+ if File.extname(mentry.path) == ""
23
+ File.join(@root, rel + primary_ext)
24
+ else
25
+ File.join(@root, rel)
26
+ end
27
+ end
28
+
29
+ # -- runtime paths --
30
+ def run_root = File.join(@root, RUN)
31
+ def cursor_path(role) = File.join(run_root, "ephemeral", "cursors", role.to_s)
32
+ def lock_path(name) = File.join(run_root, "ephemeral", "locks", "#{name}.lock")
33
+ def audit_dir_path = File.join(run_root, "audit")
34
+ def audit_log_path = File.join(audit_dir_path, "audit.log")
35
+ def sentinels_root = File.join(run_root, "tracking", "sentinels")
36
+ def store_db_path = File.join(run_root, "store.db")
37
+
38
+ # -- asset paths --
39
+ def asset_path(kind, date_str, zone, filename)
40
+ File.join(@root, ASSETS, kind, date_str, zone.to_s, filename)
41
+ end
42
+
43
+ # -- config paths --
44
+ def manifest_path = File.join(@root, "manifest.yaml")
45
+ def schemas_dir = File.join(@root, "schemas")
46
+ def schema_path(name) = File.join(schemas_dir, "#{name}.yaml")
47
+ def template_path(name) = File.join(@root, "templates", name)
48
+ def workflow_dir = File.join(@root, "workflows")
49
+ def hooks_dir = File.join(@root, "hooks")
50
+ def schemas_glob = File.join(schemas_dir, "**", "*")
51
+
52
+ # -- gitignore --
53
+ def gitignore_body(untracked_entries: [])
54
+ lines = ["# textus runtime artifacts — safe to delete, never commit",
55
+ "#{RUN}/"]
56
+ unless untracked_entries.empty?
57
+ lines << "# tracked:false entries — protocol-readable, not committed"
58
+ lines.concat(untracked_entries)
59
+ end
60
+ "#{lines.join("\n")}\n"
61
+ end
62
+
63
+ # -- lane boundary (replaces Writer#zone_floor) --
64
+ def lane_floor(path)
65
+ prefix = "#{data_root}/"
66
+ return nil unless path.start_with?(prefix)
67
+
68
+ seg = path.delete_prefix(prefix).split("/").first
69
+ seg && File.join(data_root, seg)
70
+ end
71
+
72
+ private
73
+
74
+ def normalize_relative_path(path)
75
+ return path if path.start_with?("data/")
76
+
77
+ File.join("data", path)
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,74 @@
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
@@ -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,104 @@
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