textus 0.20.2 → 0.22.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +92 -0
- data/README.md +7 -4
- data/SPEC.md +42 -3
- data/lib/textus/application/reads/audit.rb +40 -15
- data/lib/textus/application/reads/pulse.rb +63 -0
- data/lib/textus/application/writes/materializer.rb +1 -1
- data/lib/textus/application/writes/publish.rb +25 -106
- data/lib/textus/{intro.rb → boot.rb} +27 -5
- data/lib/textus/builder/pipeline.rb +2 -2
- data/lib/textus/cli/verb/audit.rb +2 -0
- data/lib/textus/cli/verb/{intro.rb → boot.rb} +3 -3
- data/lib/textus/cli/verb/pulse.rb +17 -0
- data/lib/textus/cli.rb +1 -1
- data/lib/textus/errors.rb +16 -0
- data/lib/textus/infra/audit_log.rb +126 -16
- data/lib/textus/manifest/entry/base.rb +41 -4
- data/lib/textus/manifest/entry/derived.rb +40 -4
- data/lib/textus/manifest/entry/intake.rb +15 -3
- data/lib/textus/manifest/entry/leaf.rb +6 -5
- data/lib/textus/manifest/entry/nested.rb +42 -3
- data/lib/textus/manifest/entry/parser.rb +8 -44
- data/lib/textus/manifest/entry/validators/events.rb +1 -1
- data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -4
- data/lib/textus/manifest/entry/validators/index_filename.rb +2 -1
- data/lib/textus/manifest/entry/validators/inject_boot.rb +19 -0
- data/lib/textus/manifest/entry/validators/publish_each.rb +4 -3
- data/lib/textus/manifest/entry/validators.rb +1 -1
- data/lib/textus/manifest/entry.rb +3 -0
- data/lib/textus/manifest/resolver.rb +4 -4
- data/lib/textus/manifest/schema.rb +20 -6
- data/lib/textus/manifest.rb +10 -0
- data/lib/textus/operations.rb +8 -1
- data/lib/textus/store.rb +5 -1
- data/lib/textus/version.rb +1 -1
- metadata +6 -4
- data/lib/textus/manifest/entry/validators/inject_intro.rb +0 -21
|
@@ -9,6 +9,7 @@ module Textus
|
|
|
9
9
|
option :role_filter, "--role=ROLE"
|
|
10
10
|
option :verb_filter, "--verb=V"
|
|
11
11
|
option :since, "--since=ISO8601|RELATIVE"
|
|
12
|
+
option :seq_since, "--seq-since=N"
|
|
12
13
|
option :correlation_id, "--correlation-id=ID"
|
|
13
14
|
option :limit, "--limit=N"
|
|
14
15
|
|
|
@@ -21,6 +22,7 @@ module Textus
|
|
|
21
22
|
role: role_filter,
|
|
22
23
|
verb: verb_filter,
|
|
23
24
|
since: since_time,
|
|
25
|
+
seq_since: seq_since&.to_i,
|
|
24
26
|
correlation_id: correlation_id,
|
|
25
27
|
limit: limit&.to_i,
|
|
26
28
|
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
class Verb
|
|
4
|
+
class Pulse < Verb
|
|
5
|
+
command_name "pulse"
|
|
6
|
+
|
|
7
|
+
option :since, "--since=N"
|
|
8
|
+
|
|
9
|
+
def call(store)
|
|
10
|
+
ops = operations_for(store)
|
|
11
|
+
since_n = (since || "0").to_i
|
|
12
|
+
emit(ops.pulse(since: since_n))
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/textus/cli.rb
CHANGED
|
@@ -99,7 +99,7 @@ module Textus
|
|
|
99
99
|
textus audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]
|
|
100
100
|
textus blame KEY [--limit=N]
|
|
101
101
|
textus doctor
|
|
102
|
-
textus
|
|
102
|
+
textus boot
|
|
103
103
|
|
|
104
104
|
textus key {mv,uid,normalize}
|
|
105
105
|
textus rule {list,explain}
|
data/lib/textus/errors.rb
CHANGED
|
@@ -215,4 +215,20 @@ module Textus
|
|
|
215
215
|
)
|
|
216
216
|
end
|
|
217
217
|
end
|
|
218
|
+
|
|
219
|
+
class CursorExpired < Error
|
|
220
|
+
attr_reader :requested, :min_available
|
|
221
|
+
|
|
222
|
+
def initialize(requested:, min_available:)
|
|
223
|
+
@requested = requested
|
|
224
|
+
@min_available = min_available
|
|
225
|
+
super(
|
|
226
|
+
"cursor_expired",
|
|
227
|
+
"audit cursor expired: requested seq=#{requested} but oldest available is #{min_available}; " \
|
|
228
|
+
"call `textus boot` to re-orient and resume from latest_seq",
|
|
229
|
+
details: { "requested" => requested, "min_available" => min_available },
|
|
230
|
+
hint: "call `textus boot` to get the current latest_seq and resume from there",
|
|
231
|
+
)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
218
234
|
end
|
|
@@ -1,11 +1,18 @@
|
|
|
1
|
+
require "fileutils"
|
|
1
2
|
require "json"
|
|
2
3
|
require "time"
|
|
3
4
|
|
|
4
5
|
module Textus
|
|
5
6
|
module Infra
|
|
6
7
|
class AuditLog
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
DEFAULT_MAX_SIZE = 10_485_760
|
|
9
|
+
DEFAULT_KEEP = 5
|
|
10
|
+
|
|
11
|
+
def initialize(root, max_size: DEFAULT_MAX_SIZE, keep: DEFAULT_KEEP)
|
|
12
|
+
@root = root
|
|
13
|
+
@path = File.join(root, "audit.log")
|
|
14
|
+
@max_size = max_size
|
|
15
|
+
@keep = keep
|
|
9
16
|
end
|
|
10
17
|
|
|
11
18
|
def last_writer_for(key)
|
|
@@ -23,27 +30,38 @@ module Textus
|
|
|
23
30
|
last_role
|
|
24
31
|
end
|
|
25
32
|
|
|
26
|
-
def
|
|
27
|
-
|
|
28
|
-
"ts" => Time.now.utc.iso8601,
|
|
29
|
-
"role" => role,
|
|
30
|
-
"verb" => verb,
|
|
31
|
-
"key" => key,
|
|
32
|
-
"etag_before" => etag_before,
|
|
33
|
-
"etag_after" => etag_after,
|
|
34
|
-
}
|
|
33
|
+
def latest_seq
|
|
34
|
+
return scan_max_seq(@path) if File.exist?(@path) && File.size(@path).positive?
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
# Active log is empty/missing — consult the most recent rotated file's sidecar.
|
|
37
|
+
meta = read_meta(1)
|
|
38
|
+
return meta["max_seq"] if meta
|
|
39
|
+
|
|
40
|
+
0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def min_available_seq
|
|
44
|
+
rotated_metas = (1..@keep).map { |n| read_meta(n) }.compact
|
|
45
|
+
if rotated_metas.any?
|
|
46
|
+
rotated_metas.map { |m| m["min_seq"] }.min
|
|
47
|
+
elsif File.exist?(@path)
|
|
48
|
+
File.foreach(@path) do |line|
|
|
49
|
+
parsed = parse_row(line.chomp)
|
|
50
|
+
return parsed["seq"] if parsed && parsed["seq"]
|
|
40
51
|
end
|
|
41
|
-
|
|
52
|
+
nil
|
|
42
53
|
end
|
|
54
|
+
end
|
|
43
55
|
|
|
56
|
+
def append(role:, verb:, key:, etag_before:, etag_after:, extras: nil)
|
|
44
57
|
File.open(@path, File::WRONLY | File::APPEND | File::CREAT, 0o644) do |f|
|
|
45
58
|
f.flock(File::LOCK_EX)
|
|
59
|
+
next_seq = current_max_seq_unlocked + 1
|
|
60
|
+
row = assemble_row(next_seq, { role: role, verb: verb, key: key,
|
|
61
|
+
etag_before: etag_before, etag_after: etag_after }, extras)
|
|
46
62
|
f.write(JSON.generate(row) + "\n")
|
|
63
|
+
f.flush
|
|
64
|
+
rotate!(f) if f.size > @max_size
|
|
47
65
|
end
|
|
48
66
|
end
|
|
49
67
|
|
|
@@ -63,6 +81,98 @@ module Textus
|
|
|
63
81
|
|
|
64
82
|
private
|
|
65
83
|
|
|
84
|
+
# Caller holds the flock. Returns the highest seq across the active log,
|
|
85
|
+
# OR the most-recent rotated file's max_seq if the active log is empty.
|
|
86
|
+
def current_max_seq_unlocked
|
|
87
|
+
return scan_max_seq(@path) if File.exist?(@path) && File.size(@path).positive?
|
|
88
|
+
|
|
89
|
+
meta = read_meta(1)
|
|
90
|
+
meta ? meta["max_seq"] : 0
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def scan_max_seq(file)
|
|
94
|
+
last = 0
|
|
95
|
+
File.foreach(file) do |line|
|
|
96
|
+
parsed = parse_row(line.chomp)
|
|
97
|
+
last = parsed["seq"] if parsed && parsed["seq"].is_a?(Integer)
|
|
98
|
+
end
|
|
99
|
+
last
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def scan_seq_range(file)
|
|
103
|
+
min = nil
|
|
104
|
+
max = 0
|
|
105
|
+
File.foreach(file) do |line|
|
|
106
|
+
parsed = parse_row(line.chomp)
|
|
107
|
+
next unless parsed && parsed["seq"]
|
|
108
|
+
|
|
109
|
+
min ||= parsed["seq"]
|
|
110
|
+
max = parsed["seq"]
|
|
111
|
+
end
|
|
112
|
+
[min, max]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def read_meta(n)
|
|
116
|
+
path = File.join(@root, "audit.log.#{n}.meta.json")
|
|
117
|
+
return nil unless File.exist?(path)
|
|
118
|
+
|
|
119
|
+
JSON.parse(File.read(path))
|
|
120
|
+
rescue JSON::ParserError
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def assemble_row(seq, fields, extras = nil)
|
|
125
|
+
row = {
|
|
126
|
+
"seq" => seq,
|
|
127
|
+
"ts" => Time.now.utc.iso8601,
|
|
128
|
+
"role" => fields[:role],
|
|
129
|
+
"verb" => fields[:verb],
|
|
130
|
+
"key" => fields[:key],
|
|
131
|
+
"etag_before" => fields[:etag_before],
|
|
132
|
+
"etag_after" => fields[:etag_after],
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if extras.is_a?(Hash) && !extras.empty?
|
|
136
|
+
extras = extras.dup
|
|
137
|
+
%w[from_key to_key uid].each do |k|
|
|
138
|
+
row[k] = extras.delete(k) if extras.key?(k)
|
|
139
|
+
end
|
|
140
|
+
row["extras"] = extras unless extras.empty?
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
row
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Called inside the flock, after a successful write that pushed size over max.
|
|
147
|
+
# Renames audit.log → audit.log.1 (shifting older files), writes sidecar meta.
|
|
148
|
+
def rotate!(open_file)
|
|
149
|
+
open_file.flush
|
|
150
|
+
min_seq, max_seq = scan_seq_range(@path)
|
|
151
|
+
meta = { "min_seq" => min_seq, "max_seq" => max_seq, "rotated_at" => Time.now.utc.iso8601 }
|
|
152
|
+
|
|
153
|
+
# Drop the file that would be shifted past @keep.
|
|
154
|
+
oldest = File.join(@root, "audit.log.#{@keep}")
|
|
155
|
+
oldest_meta = File.join(@root, "audit.log.#{@keep}.meta.json")
|
|
156
|
+
FileUtils.rm_f(oldest)
|
|
157
|
+
FileUtils.rm_f(oldest_meta)
|
|
158
|
+
|
|
159
|
+
# Shift .N → .(N+1) for N = keep-1 down to 1.
|
|
160
|
+
(@keep - 1).downto(1) do |n|
|
|
161
|
+
src = File.join(@root, "audit.log.#{n}")
|
|
162
|
+
dst = File.join(@root, "audit.log.#{n + 1}")
|
|
163
|
+
File.rename(src, dst) if File.exist?(src)
|
|
164
|
+
|
|
165
|
+
src_meta = File.join(@root, "audit.log.#{n}.meta.json")
|
|
166
|
+
dst_meta = File.join(@root, "audit.log.#{n + 1}.meta.json")
|
|
167
|
+
File.rename(src_meta, dst_meta) if File.exist?(src_meta)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Active log → .1
|
|
171
|
+
File.rename(@path, File.join(@root, "audit.log.1"))
|
|
172
|
+
File.write(File.join(@root, "audit.log.1.meta.json"), JSON.generate(meta) + "\n")
|
|
173
|
+
# Next append will create a fresh audit.log via File::CREAT.
|
|
174
|
+
end
|
|
175
|
+
|
|
66
176
|
def parse_row(line)
|
|
67
177
|
return nil if line.empty?
|
|
68
178
|
|
|
@@ -2,10 +2,10 @@ module Textus
|
|
|
2
2
|
class Manifest
|
|
3
3
|
class Entry
|
|
4
4
|
class Base < Entry
|
|
5
|
-
attr_reader :raw, :key, :path, :zone, :schema, :owner, :format, :manifest
|
|
5
|
+
attr_reader :raw, :key, :path, :zone, :schema, :owner, :format, :manifest, :publish_to
|
|
6
6
|
|
|
7
7
|
# rubocop:disable Metrics/ParameterLists, Lint/MissingSuper
|
|
8
|
-
def initialize(manifest:, raw:, key:, path:, zone:, schema:, owner:, format:)
|
|
8
|
+
def initialize(manifest:, raw:, key:, path:, zone:, schema:, owner:, format:, publish_to: [])
|
|
9
9
|
@manifest = manifest
|
|
10
10
|
@raw = raw
|
|
11
11
|
@key = key
|
|
@@ -14,11 +14,10 @@ module Textus
|
|
|
14
14
|
@schema = schema
|
|
15
15
|
@owner = owner
|
|
16
16
|
@format = format
|
|
17
|
+
@publish_to = Array(publish_to)
|
|
17
18
|
end
|
|
18
19
|
# rubocop:enable Metrics/ParameterLists, Lint/MissingSuper
|
|
19
20
|
|
|
20
|
-
def kind = self.class.name.split("::").last.downcase.to_sym
|
|
21
|
-
|
|
22
21
|
def zone_writers
|
|
23
22
|
@manifest.zone_writers(@zone)
|
|
24
23
|
rescue UsageError => e
|
|
@@ -32,6 +31,44 @@ module Textus
|
|
|
32
31
|
def derived? = false
|
|
33
32
|
def intake? = false
|
|
34
33
|
def leaf? = false
|
|
34
|
+
|
|
35
|
+
# Nil stubs for cross-cutting optional attrs. Subclasses override the
|
|
36
|
+
# ones they own. Validators and serializers can call these directly
|
|
37
|
+
# without `respond_to?` guards.
|
|
38
|
+
def template = nil
|
|
39
|
+
def inject_boot = false # rubocop:disable Naming/PredicateMethod
|
|
40
|
+
def events = {}
|
|
41
|
+
def publish_each = nil
|
|
42
|
+
def index_filename = nil
|
|
43
|
+
|
|
44
|
+
PublishContext = Struct.new(
|
|
45
|
+
:repo_root, :manifest, :file_store, :root, :store, :ctx, :bus, :hook_context,
|
|
46
|
+
:reader, :emit, # callables: reader.call(key) → envelope; emit.call(event, **payload)
|
|
47
|
+
keyword_init: true
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Subclasses override to customize publish behavior.
|
|
51
|
+
# Default: copy the stored file to each publish_to target.
|
|
52
|
+
# Returns: { kind: :built|:leaves, value: ... } to be accumulated by
|
|
53
|
+
# Publish#call, or nil to skip.
|
|
54
|
+
def publish_via(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
55
|
+
return nil if Array(publish_to).empty?
|
|
56
|
+
|
|
57
|
+
source_path = pctx.manifest.resolver.resolve(@key).path
|
|
58
|
+
envelope = pctx.reader.call(@key)
|
|
59
|
+
|
|
60
|
+
publish_to.each do |rel|
|
|
61
|
+
target_abs = File.join(pctx.repo_root, rel)
|
|
62
|
+
Textus::Infra::Publisher.publish(source: source_path, target: target_abs, store_root: pctx.root)
|
|
63
|
+
pctx.emit.call(:file_published,
|
|
64
|
+
key: @key,
|
|
65
|
+
envelope: envelope,
|
|
66
|
+
source: source_path,
|
|
67
|
+
target: target_abs)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
{ kind: :built, value: { "key" => @key, "path" => source_path, "published_to" => publish_to } }
|
|
71
|
+
end
|
|
35
72
|
end
|
|
36
73
|
end
|
|
37
74
|
end
|
|
@@ -5,20 +5,56 @@ module Textus
|
|
|
5
5
|
Projection = Data.define(:select, :pluck, :sort_by, :transform)
|
|
6
6
|
External = Data.define(:sources, :runner)
|
|
7
7
|
|
|
8
|
-
attr_reader :source, :template, :
|
|
8
|
+
attr_reader :source, :template, :inject_boot, :events
|
|
9
9
|
|
|
10
|
-
def initialize(source:, template: nil,
|
|
10
|
+
def initialize(source:, template: nil, inject_boot: false, events: {}, **rest)
|
|
11
11
|
super(**rest)
|
|
12
12
|
@source = source
|
|
13
13
|
@template = template
|
|
14
|
-
@
|
|
15
|
-
@publish_to = Array(publish_to)
|
|
14
|
+
@inject_boot = inject_boot
|
|
16
15
|
@events = events || {}
|
|
17
16
|
end
|
|
18
17
|
|
|
19
18
|
def derived? = true
|
|
20
19
|
def projection? = @source.is_a?(Projection)
|
|
21
20
|
def external? = @source.is_a?(External)
|
|
21
|
+
|
|
22
|
+
def publish_via(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
23
|
+
return nil unless in_generator_zone?
|
|
24
|
+
|
|
25
|
+
target_path = Textus::Application::Writes::Materializer.new(
|
|
26
|
+
ctx: pctx.ctx, manifest: pctx.manifest, file_store: pctx.file_store,
|
|
27
|
+
bus: pctx.bus, root: pctx.root, store: pctx.store
|
|
28
|
+
).run(self)
|
|
29
|
+
|
|
30
|
+
envelope = pctx.reader.call(@key)
|
|
31
|
+
Array(publish_to).each do |rel|
|
|
32
|
+
target_abs = File.join(pctx.repo_root, rel)
|
|
33
|
+
Textus::Infra::Publisher.publish(source: target_path, target: target_abs, store_root: pctx.root)
|
|
34
|
+
pctx.emit.call(:file_published, key: @key, envelope: envelope, source: target_path, target: target_abs)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
src = @source
|
|
38
|
+
selects = src.is_a?(Projection) ? Array(src.select).compact : []
|
|
39
|
+
pctx.emit.call(:build_completed, key: @key, envelope: envelope, sources: selects)
|
|
40
|
+
|
|
41
|
+
{ kind: :built, value: { "key" => @key, "path" => target_path, "published_to" => publish_to } }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
KIND = :derived
|
|
45
|
+
|
|
46
|
+
def self.from_raw(common, raw)
|
|
47
|
+
source = Parser.parse_source(raw, common[:key])
|
|
48
|
+
new(
|
|
49
|
+
source: source,
|
|
50
|
+
template: raw["template"],
|
|
51
|
+
inject_boot: raw["inject_boot"] == true,
|
|
52
|
+
events: raw["events"] || {},
|
|
53
|
+
**common,
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
Entry::REGISTRY[KIND] = self
|
|
22
58
|
end
|
|
23
59
|
end
|
|
24
60
|
end
|
|
@@ -2,17 +2,29 @@ module Textus
|
|
|
2
2
|
class Manifest
|
|
3
3
|
class Entry
|
|
4
4
|
class Intake < Base
|
|
5
|
-
attr_reader :handler, :config, :events
|
|
5
|
+
attr_reader :handler, :config, :events
|
|
6
6
|
|
|
7
7
|
def initialize(handler:, config: {}, events: {}, **rest)
|
|
8
8
|
super(**rest)
|
|
9
9
|
@handler = handler
|
|
10
10
|
@config = config || {}
|
|
11
11
|
@events = events || {}
|
|
12
|
-
@publish_to = []
|
|
13
12
|
end
|
|
14
13
|
|
|
15
|
-
def intake?
|
|
14
|
+
def intake? = true
|
|
15
|
+
def nested? = !!@raw["nested"]
|
|
16
|
+
|
|
17
|
+
KIND = :intake
|
|
18
|
+
|
|
19
|
+
def self.from_raw(common, raw)
|
|
20
|
+
intake = raw["intake"] || {}
|
|
21
|
+
handler = intake["handler"] || raw["intake_handler"] or
|
|
22
|
+
raise UsageError.new("intake entry '#{common[:key]}' missing handler")
|
|
23
|
+
config = intake["config"] || raw["intake_config"] || {}
|
|
24
|
+
new(handler: handler, config: config, events: raw["events"] || {}, **common)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
Entry::REGISTRY[KIND] = self
|
|
16
28
|
end
|
|
17
29
|
end
|
|
18
30
|
end
|
|
@@ -2,14 +2,15 @@ module Textus
|
|
|
2
2
|
class Manifest
|
|
3
3
|
class Entry
|
|
4
4
|
class Leaf < Base
|
|
5
|
-
|
|
5
|
+
KIND = :leaf
|
|
6
6
|
|
|
7
|
-
def
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
def leaf? = true
|
|
8
|
+
|
|
9
|
+
def self.from_raw(common, _raw)
|
|
10
|
+
new(**common)
|
|
10
11
|
end
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
Entry::REGISTRY[KIND] = self
|
|
13
14
|
end
|
|
14
15
|
end
|
|
15
16
|
end
|
|
@@ -7,13 +7,12 @@ module Textus
|
|
|
7
7
|
PUBLISH_EACH_VARS = Validators::PublishEach::KNOWN_VARS
|
|
8
8
|
PUBLISH_EACH_VAR_RE = Validators::PublishEach::VAR_RE
|
|
9
9
|
|
|
10
|
-
attr_reader :index_filename, :publish_each
|
|
10
|
+
attr_reader :index_filename, :publish_each
|
|
11
11
|
|
|
12
|
-
def initialize(index_filename: nil, publish_each: nil,
|
|
12
|
+
def initialize(index_filename: nil, publish_each: nil, **rest)
|
|
13
13
|
super(**rest)
|
|
14
14
|
@index_filename = index_filename
|
|
15
15
|
@publish_each = publish_each
|
|
16
|
-
@publish_to = Array(publish_to)
|
|
17
16
|
end
|
|
18
17
|
|
|
19
18
|
def nested? = true
|
|
@@ -33,6 +32,46 @@ module Textus
|
|
|
33
32
|
vars = { "leaf" => leaf, "basename" => basename, "key" => full_key, "ext" => ext }
|
|
34
33
|
@publish_each.gsub(PUBLISH_EACH_VAR_RE) { vars.fetch(::Regexp.last_match(1)) }
|
|
35
34
|
end
|
|
35
|
+
|
|
36
|
+
def publish_via(pctx, prefix: nil)
|
|
37
|
+
return nil if @publish_each.nil?
|
|
38
|
+
|
|
39
|
+
leaves = []
|
|
40
|
+
@manifest.resolver.enumerate(prefix: @key).each do |row|
|
|
41
|
+
next unless row[:manifest_entry].equal?(self)
|
|
42
|
+
next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
|
|
43
|
+
|
|
44
|
+
target_rel = publish_target_for(row[:key])
|
|
45
|
+
target_abs = File.expand_path(File.join(pctx.repo_root, target_rel))
|
|
46
|
+
unless target_abs.start_with?(File.expand_path(pctx.repo_root) + File::SEPARATOR)
|
|
47
|
+
raise Textus::PublishError.new(
|
|
48
|
+
"entry '#{@key}': publish_each target '#{target_rel}' for key '#{row[:key]}' escapes repo root",
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
Textus::Infra::Publisher.publish(source: row[:path], target: target_abs, store_root: pctx.root)
|
|
53
|
+
pctx.emit.call(:file_published,
|
|
54
|
+
key: row[:key],
|
|
55
|
+
envelope: pctx.reader.call(row[:key]),
|
|
56
|
+
source: row[:path],
|
|
57
|
+
target: target_abs)
|
|
58
|
+
leaves << { "key" => row[:key], "source" => row[:path], "target" => target_abs }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
{ kind: :leaves, value: leaves }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
KIND = :nested
|
|
65
|
+
|
|
66
|
+
def self.from_raw(common, raw)
|
|
67
|
+
new(
|
|
68
|
+
index_filename: raw["index_filename"],
|
|
69
|
+
publish_each: raw["publish_each"],
|
|
70
|
+
**common,
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
Entry::REGISTRY[KIND] = self
|
|
36
75
|
end
|
|
37
76
|
end
|
|
38
77
|
end
|
|
@@ -9,7 +9,7 @@ module Textus
|
|
|
9
9
|
path = raw["path"] or raise UsageError.new("manifest entry '#{key}' missing path")
|
|
10
10
|
zone = raw["zone"] or raise UsageError.new("manifest entry '#{key}' missing zone")
|
|
11
11
|
|
|
12
|
-
raw_kind = raw["kind"] or raise BadManifest.new("entry '#{key}' missing required `kind:` (
|
|
12
|
+
raw_kind = raw["kind"] or raise BadManifest.new("entry '#{key}' missing required `kind:` (#{Entry::REGISTRY.keys.join("|")})")
|
|
13
13
|
kind = raw_kind.to_sym
|
|
14
14
|
format = resolve_format(raw, path)
|
|
15
15
|
|
|
@@ -17,49 +17,13 @@ module Textus
|
|
|
17
17
|
manifest: manifest, raw: raw,
|
|
18
18
|
key: key, path: path, zone: zone,
|
|
19
19
|
schema: raw["schema"], owner: raw["owner"],
|
|
20
|
-
format: format
|
|
20
|
+
format: format,
|
|
21
|
+
publish_to: raw["publish_to"]
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
when :derived then build_derived(common, raw, key)
|
|
27
|
-
when :intake then build_intake(common, raw, key)
|
|
28
|
-
else raise BadManifest.new("entry '#{key}': unknown kind: #{kind.inspect}")
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def self.build_leaf(common, raw)
|
|
33
|
-
Leaf.new(publish_to: raw["publish_to"], **common)
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def self.build_nested(common, raw)
|
|
37
|
-
Nested.new(
|
|
38
|
-
index_filename: raw["index_filename"],
|
|
39
|
-
publish_each: raw["publish_each"],
|
|
40
|
-
publish_to: raw["publish_to"],
|
|
41
|
-
**common,
|
|
42
|
-
)
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def self.build_derived(common, raw, key)
|
|
46
|
-
source = parse_source(raw, key)
|
|
47
|
-
Derived.new(
|
|
48
|
-
source: source,
|
|
49
|
-
template: raw["template"],
|
|
50
|
-
inject_intro: raw["inject_intro"] == true,
|
|
51
|
-
publish_to: raw["publish_to"],
|
|
52
|
-
events: raw["events"] || {},
|
|
53
|
-
**common,
|
|
54
|
-
)
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def self.build_intake(common, raw, key)
|
|
58
|
-
intake = raw["intake"] || {}
|
|
59
|
-
handler = intake["handler"] || raw["intake_handler"] or
|
|
60
|
-
raise UsageError.new("intake entry '#{key}' missing handler")
|
|
61
|
-
config = intake["config"] || raw["intake_config"] || {}
|
|
62
|
-
Intake.new(handler: handler, config: config, events: raw["events"] || {}, **common)
|
|
24
|
+
klass = Entry::REGISTRY[kind] or
|
|
25
|
+
raise BadManifest.new("entry '#{key}': unknown kind: #{kind.inspect} (known: #{Entry::REGISTRY.keys.join(", ")})")
|
|
26
|
+
klass.from_raw(common, raw)
|
|
63
27
|
end
|
|
64
28
|
|
|
65
29
|
def self.parse_source(raw, key)
|
|
@@ -73,14 +37,14 @@ module Textus
|
|
|
73
37
|
end
|
|
74
38
|
|
|
75
39
|
if compute["kind"] == "projection"
|
|
76
|
-
Derived::Projection.new(
|
|
40
|
+
Entry::Derived::Projection.new(
|
|
77
41
|
select: compute["select"],
|
|
78
42
|
pluck: compute["pluck"],
|
|
79
43
|
sort_by: compute["sort_by"],
|
|
80
44
|
transform: compute["transform"],
|
|
81
45
|
)
|
|
82
46
|
else
|
|
83
|
-
Derived::External.new(sources: compute["sources"], runner: compute["runner"])
|
|
47
|
+
Entry::Derived::External.new(sources: compute["sources"], runner: compute["runner"])
|
|
84
48
|
end
|
|
85
49
|
end
|
|
86
50
|
|
|
@@ -5,7 +5,7 @@ module Textus
|
|
|
5
5
|
module Events
|
|
6
6
|
def self.call(entry)
|
|
7
7
|
pubsub_events = Textus::Hooks::Bus::EVENTS.select { |_, s| s[:mode] == :pubsub }.keys
|
|
8
|
-
events = entry.
|
|
8
|
+
events = entry.events
|
|
9
9
|
events.each_key do |evt|
|
|
10
10
|
next if pubsub_events.include?(evt.to_sym)
|
|
11
11
|
|
|
@@ -14,12 +14,13 @@ module Textus
|
|
|
14
14
|
raise UsageError.new("entry '#{entry.key}': text format must not declare a schema")
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
has_template =
|
|
18
|
-
is_external
|
|
19
|
-
|
|
17
|
+
has_template = !entry.template.nil?
|
|
18
|
+
is_external = entry.derived? && entry.external?
|
|
19
|
+
is_intake = entry.intake?
|
|
20
|
+
return unless entry.in_generator_zone? && !has_template && !is_external && !is_intake &&
|
|
20
21
|
%w[markdown text].include?(entry.format) && !entry.nested?
|
|
21
22
|
|
|
22
|
-
raise UsageError.new("entry '#{entry.key}':
|
|
23
|
+
raise UsageError.new("entry '#{entry.key}': #{entry.format} entries in a generator zone require a template")
|
|
23
24
|
end
|
|
24
25
|
end
|
|
25
26
|
end
|
|
@@ -4,7 +4,8 @@ module Textus
|
|
|
4
4
|
module Validators
|
|
5
5
|
module IndexFilename
|
|
6
6
|
def self.call(entry)
|
|
7
|
-
|
|
7
|
+
# Use raw to detect misuse on non-nested entries (typed attr stubs nil on Base).
|
|
8
|
+
index_filename = entry.nested? ? entry.index_filename : entry.raw["index_filename"]
|
|
8
9
|
return if index_filename.nil?
|
|
9
10
|
|
|
10
11
|
check_shape!(entry, index_filename)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
module Validators
|
|
5
|
+
module InjectBoot
|
|
6
|
+
def self.call(entry)
|
|
7
|
+
return unless entry.inject_boot
|
|
8
|
+
|
|
9
|
+
raise UsageError.new("entry '#{entry.key}': inject_boot: is only valid on derived entries") unless entry.in_generator_zone?
|
|
10
|
+
|
|
11
|
+
return unless entry.template.nil?
|
|
12
|
+
|
|
13
|
+
raise UsageError.new("entry '#{entry.key}': inject_boot: requires a template:")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -7,13 +7,14 @@ module Textus
|
|
|
7
7
|
VAR_RE = /\{([a-z]+)\}/
|
|
8
8
|
REQUIRED_DISCRIMINATOR_VARS = %w[leaf basename key].freeze
|
|
9
9
|
|
|
10
|
-
def self.call(entry)
|
|
11
|
-
|
|
10
|
+
def self.call(entry)
|
|
11
|
+
# Use raw to detect misuse on non-nested entries (typed attr stubs nil on Base).
|
|
12
|
+
publish_each = entry.nested? ? entry.publish_each : entry.raw["publish_each"]
|
|
12
13
|
return if publish_each.nil?
|
|
13
14
|
|
|
14
15
|
raise UsageError.new("entry '#{entry.key}': publish_each requires nested: true") unless entry.nested?
|
|
15
16
|
|
|
16
|
-
publish_to = entry.
|
|
17
|
+
publish_to = entry.publish_to
|
|
17
18
|
raise UsageError.new("entry '#{entry.key}': publish_to and publish_each are mutually exclusive") unless publish_to.empty?
|
|
18
19
|
raise UsageError.new("entry '#{entry.key}': publish_each must be a string") unless publish_each.is_a?(String)
|
|
19
20
|
|