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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -0
- data/README.md +8 -1
- data/SPEC.md +27 -0
- data/docs/architecture/README.md +20 -8
- data/docs/reference/conventions.md +1 -1
- data/exe/textus +1 -1
- data/lib/textus/action/accept.rb +23 -21
- data/lib/textus/action/audit.rb +24 -61
- data/lib/textus/action/base.rb +9 -9
- data/lib/textus/action/blame.rb +18 -36
- data/lib/textus/action/boot.rb +2 -4
- data/lib/textus/action/data_mv.rb +20 -31
- data/lib/textus/action/deps.rb +3 -18
- data/lib/textus/action/doctor.rb +2 -9
- data/lib/textus/action/drain.rb +11 -19
- data/lib/textus/action/enqueue.rb +14 -30
- data/lib/textus/action/get.rb +12 -56
- data/lib/textus/action/ingest.rb +74 -78
- data/lib/textus/action/jobs.rb +6 -15
- data/lib/textus/action/key_delete.rb +6 -16
- data/lib/textus/action/key_delete_prefix.rb +8 -17
- data/lib/textus/action/key_mv.rb +54 -61
- data/lib/textus/action/key_mv_prefix.rb +13 -22
- data/lib/textus/action/list.rb +7 -21
- data/lib/textus/action/propose.rb +16 -26
- data/lib/textus/action/published.rb +3 -5
- data/lib/textus/action/pulse.rb +19 -26
- data/lib/textus/action/put.rb +15 -29
- data/lib/textus/action/rdeps.rb +3 -18
- data/lib/textus/action/reject.rb +12 -21
- data/lib/textus/action/rule_explain.rb +12 -22
- data/lib/textus/action/rule_lint.rb +10 -16
- data/lib/textus/action/rule_list.rb +5 -9
- data/lib/textus/action/schema_envelope.rb +3 -10
- data/lib/textus/action/uid.rb +3 -17
- data/lib/textus/action/where.rb +3 -18
- data/lib/textus/boot.rb +7 -15
- data/lib/textus/contract/arg.rb +10 -0
- data/lib/textus/contract/dsl.rb +88 -0
- data/lib/textus/contract/spec.rb +25 -0
- data/lib/textus/contract.rb +0 -162
- data/lib/textus/doctor/check/audit_log.rb +2 -2
- data/lib/textus/doctor/check/generator_drift.rb +2 -2
- data/lib/textus/doctor/check/orphaned_publish_targets.rb +3 -3
- data/lib/textus/doctor/check/protocol_version.rb +2 -2
- data/lib/textus/doctor/check/raw_asset_paths.rb +1 -1
- data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +2 -2
- data/lib/textus/doctor/check/schemas.rb +1 -1
- data/lib/textus/doctor/check/sentinels.rb +4 -4
- data/lib/textus/doctor/check/templates.rb +1 -1
- data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
- data/lib/textus/doctor/check.rb +4 -7
- data/lib/textus/doctor.rb +1 -1
- data/lib/textus/errors.rb +6 -0
- data/lib/textus/format/base.rb +0 -4
- data/lib/textus/format/json.rb +5 -6
- data/lib/textus/format/markdown.rb +5 -6
- data/lib/textus/format/shared.rb +17 -0
- data/lib/textus/format/text.rb +5 -4
- data/lib/textus/format/yaml.rb +30 -6
- data/lib/textus/format.rb +6 -0
- data/lib/textus/gate/auth.rb +2 -17
- data/lib/textus/gate/binder.rb +50 -0
- data/lib/textus/gate.rb +64 -88
- data/lib/textus/init.rb +2 -4
- data/lib/textus/jobs.rb +3 -9
- data/lib/textus/manifest/capabilities.rb +3 -3
- data/lib/textus/manifest/entry/base.rb +1 -1
- data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
- data/lib/textus/manifest/entry/publish/to_paths.rb +1 -1
- data/lib/textus/manifest/schema/semantics/cross_field.rb +53 -0
- data/lib/textus/manifest/schema/semantics/invariants.rb +125 -0
- data/lib/textus/manifest/schema/semantics/migration.rb +83 -0
- data/lib/textus/manifest/schema/semantics.rb +11 -216
- data/lib/textus/meta.rb +54 -0
- data/lib/textus/{ports → port}/audit_log.rb +44 -4
- data/lib/textus/{ports → port}/build_lock.rb +2 -2
- data/lib/textus/{ports → port}/clock.rb +1 -1
- data/lib/textus/{ports → port}/publisher.rb +5 -5
- data/lib/textus/{ports → port}/sentinel_store.rb +3 -3
- data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
- data/lib/textus/{ports → port}/storage/file_store.rb +2 -2
- data/lib/textus/port/store.rb +93 -0
- data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
- data/lib/textus/produce/engine.rb +1 -1
- data/lib/textus/schema/tools.rb +11 -7
- data/lib/textus/store/compositor.rb +34 -0
- data/lib/textus/store/container.rb +43 -0
- data/lib/textus/store/cursor.rb +26 -0
- data/lib/textus/store/envelope/reader.rb +43 -0
- data/lib/textus/store/envelope/writer.rb +195 -0
- data/lib/textus/store/geometry.rb +81 -0
- data/lib/textus/store/index/builder.rb +74 -0
- data/lib/textus/store/index/lookup.rb +60 -0
- data/lib/textus/store/jobs/base.rb +13 -0
- data/lib/textus/store/jobs/index.rb +15 -0
- data/lib/textus/store/jobs/materialize.rb +15 -0
- data/lib/textus/store/jobs/plan.rb +11 -0
- data/lib/textus/store/jobs/planner.rb +104 -0
- data/lib/textus/store/jobs/queue.rb +154 -0
- data/lib/textus/store/jobs/registry.rb +19 -0
- data/lib/textus/store/jobs/retention.rb +50 -0
- data/lib/textus/store/jobs/sweep.rb +21 -0
- data/lib/textus/store/jobs/worker.rb +64 -0
- data/lib/textus/store/session.rb +37 -0
- data/lib/textus/store.rb +21 -13
- data/lib/textus/{surfaces → surface}/cli/group/data.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/key.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/mcp.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/rule.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/schema.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group.rb +2 -2
- data/lib/textus/{surfaces → surface}/cli/runner.rb +10 -63
- data/lib/textus/surface/cli/sources.rb +41 -0
- data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +4 -6
- data/lib/textus/{surfaces → surface}/cli/verb/get.rb +4 -4
- data/lib/textus/{surfaces → surface}/cli/verb/init.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/mcp_serve.rb +3 -3
- data/lib/textus/{surfaces → surface}/cli/verb/put.rb +6 -11
- data/lib/textus/{surfaces → surface}/cli/verb/schema_diff.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/schema_init.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/schema_migrate.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/watch.rb +2 -2
- data/lib/textus/{surfaces → surface}/cli/verb.rb +3 -8
- data/lib/textus/{surfaces → surface}/cli.rb +1 -1
- data/lib/textus/{surfaces → surface}/mcp/catalog.rb +9 -26
- data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -1
- data/lib/textus/{surfaces → surface}/mcp/server.rb +5 -5
- data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
- data/lib/textus/surface/projector.rb +27 -0
- data/lib/textus/{surfaces → surface}/role_scope.rb +1 -1
- data/lib/textus/{surfaces → surface}/watcher.rb +8 -8
- data/lib/textus/value/call.rb +30 -0
- data/lib/textus/value/command.rb +16 -0
- data/lib/textus/value/envelope.rb +89 -0
- data/lib/textus/value/etag.rb +39 -0
- data/lib/textus/value/result.rb +26 -0
- data/lib/textus/value/role.rb +38 -0
- data/lib/textus/value/types.rb +13 -0
- data/lib/textus/{uid.rb → value/uid.rb} +9 -7
- data/lib/textus/version.rb +1 -1
- data/lib/textus/workflow/loader.rb +4 -4
- data/lib/textus/workflow/runner.rb +4 -18
- data/lib/textus.rb +9 -10
- metadata +100 -63
- data/lib/textus/action/write_verb.rb +0 -44
- data/lib/textus/call.rb +0 -28
- data/lib/textus/command.rb +0 -41
- data/lib/textus/container.rb +0 -26
- data/lib/textus/contract/around.rb +0 -29
- data/lib/textus/contract/binder.rb +0 -88
- data/lib/textus/contract/resources/build_lock.rb +0 -17
- data/lib/textus/contract/resources/cursor.rb +0 -26
- data/lib/textus/contract/sources.rb +0 -39
- data/lib/textus/contract/view.rb +0 -15
- data/lib/textus/cursor_store.rb +0 -24
- data/lib/textus/envelope/reader.rb +0 -46
- data/lib/textus/envelope/writer.rb +0 -209
- data/lib/textus/envelope.rb +0 -79
- data/lib/textus/etag.rb +0 -36
- data/lib/textus/jobs/base.rb +0 -23
- data/lib/textus/jobs/materialize.rb +0 -20
- data/lib/textus/jobs/plan.rb +0 -9
- data/lib/textus/jobs/planner.rb +0 -101
- data/lib/textus/jobs/retention.rb +0 -48
- data/lib/textus/jobs/sweep.rb +0 -27
- data/lib/textus/jobs/worker.rb +0 -67
- data/lib/textus/layout.rb +0 -91
- data/lib/textus/ports/job_store/job.rb +0 -65
- data/lib/textus/ports/job_store.rb +0 -123
- data/lib/textus/ports/raw_index.rb +0 -61
- data/lib/textus/role.rb +0 -36
- data/lib/textus/session.rb +0 -35
- 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,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,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
|