textus 0.49.0 → 0.51.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 +45 -0
- data/README.md +41 -43
- data/SPEC.md +176 -197
- data/docs/architecture/README.md +46 -42
- data/docs/reference/conventions.md +33 -28
- data/lib/textus/boot.rb +58 -47
- data/lib/textus/call.rb +1 -1
- data/lib/textus/cli/runner.rb +15 -10
- data/lib/textus/cli/verb/get.rb +1 -3
- data/lib/textus/cli/verb/hook_run.rb +1 -1
- data/lib/textus/cli/verb/put.rb +4 -20
- data/lib/textus/cli.rb +1 -4
- data/lib/textus/dispatcher.rb +1 -3
- data/lib/textus/doctor/check/generator_drift.rb +4 -3
- data/lib/textus/doctor/check/handler_allowlist.rb +1 -1
- data/lib/textus/doctor/check/intake_registration.rb +5 -5
- data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
- data/lib/textus/doctor/check/sentinels.rb +2 -2
- data/lib/textus/doctor/check/templates.rb +13 -11
- data/lib/textus/doctor.rb +0 -2
- data/lib/textus/domain/freshness/evaluator.rb +150 -14
- data/lib/textus/domain/freshness/verdict.rb +28 -6
- data/lib/textus/domain/freshness.rb +4 -33
- data/lib/textus/domain/policy/base_guards.rb +1 -1
- data/lib/textus/domain/policy/predicates/fresh_within.rb +1 -1
- data/lib/textus/domain/policy/publish_target.rb +34 -0
- data/lib/textus/domain/policy/retention.rb +29 -0
- data/lib/textus/domain/policy/source.rb +79 -0
- data/lib/textus/domain/retention/sweep.rb +57 -0
- data/lib/textus/domain/retention.rb +11 -0
- data/lib/textus/errors.rb +4 -4
- data/lib/textus/hooks/builtin.rb +5 -5
- data/lib/textus/hooks/catalog.rb +8 -7
- data/lib/textus/hooks/context.rb +5 -10
- data/lib/textus/init/templates/machine_intake.rb +4 -4
- data/lib/textus/init.rb +47 -47
- data/lib/textus/key/matching.rb +24 -0
- data/lib/textus/maintenance/reconcile.rb +160 -0
- data/lib/textus/manifest/capabilities.rb +1 -1
- data/lib/textus/manifest/data.rb +2 -2
- data/lib/textus/manifest/entry/base.rb +28 -8
- data/lib/textus/manifest/entry/nested.rb +3 -4
- data/lib/textus/manifest/entry/parser.rb +25 -21
- data/lib/textus/manifest/entry/produced.rb +56 -0
- data/lib/textus/manifest/entry/publish/subtree_mirror.rb +7 -6
- data/lib/textus/manifest/entry/publish/to_paths.rb +62 -11
- data/lib/textus/manifest/entry/validators/format_matrix.rb +3 -11
- data/lib/textus/manifest/entry/validators/publish.rb +3 -1
- data/lib/textus/manifest/entry/validators.rb +0 -1
- data/lib/textus/manifest/policy.rb +16 -4
- data/lib/textus/manifest/resolver.rb +10 -4
- data/lib/textus/manifest/rules.rb +37 -36
- data/lib/textus/manifest/schema/keys.rb +98 -0
- data/lib/textus/manifest/schema/validator.rb +324 -0
- data/lib/textus/manifest/schema/vocabulary.rb +24 -0
- data/lib/textus/manifest/schema.rb +27 -247
- data/lib/textus/manifest.rb +5 -3
- data/lib/textus/mcp/server.rb +9 -2
- data/lib/textus/ports/audit_log.rb +6 -0
- data/lib/textus/ports/build_lock.rb +6 -0
- data/lib/textus/ports/clock.rb +4 -3
- data/lib/textus/ports/produce_on_write_subscriber.rb +69 -0
- data/lib/textus/ports/publisher.rb +11 -7
- data/lib/textus/produce/acquire/handler.rb +29 -0
- data/lib/textus/produce/acquire/intake.rb +130 -0
- data/lib/textus/produce/acquire/projection.rb +127 -0
- data/lib/textus/produce/acquire/serializer/json.rb +31 -0
- data/lib/textus/produce/acquire/serializer/text.rb +16 -0
- data/lib/textus/produce/acquire/serializer/yaml.rb +31 -0
- data/lib/textus/produce/acquire/serializer.rb +17 -0
- data/lib/textus/produce/engine.rb +143 -0
- data/lib/textus/produce/events.rb +36 -0
- data/lib/textus/produce/render.rb +23 -0
- data/lib/textus/projection.rb +17 -6
- data/lib/textus/read/boot.rb +4 -2
- data/lib/textus/read/deps.rb +3 -3
- data/lib/textus/read/freshness.rb +63 -29
- data/lib/textus/read/get.rb +20 -102
- data/lib/textus/read/rdeps.rb +3 -3
- data/lib/textus/read/rule_explain.rb +41 -23
- data/lib/textus/read/rule_list.rb +25 -8
- data/lib/textus/read/validate_all.rb +14 -0
- data/lib/textus/role.rb +2 -1
- data/lib/textus/schemas.rb +8 -0
- data/lib/textus/store.rb +1 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/put.rb +1 -1
- metadata +23 -30
- data/lib/textus/builder/pipeline.rb +0 -88
- data/lib/textus/builder/renderer/json.rb +0 -45
- data/lib/textus/builder/renderer/markdown.rb +0 -24
- data/lib/textus/builder/renderer/text.rb +0 -14
- data/lib/textus/builder/renderer/yaml.rb +0 -45
- data/lib/textus/builder/renderer.rb +0 -17
- data/lib/textus/cli/verb/boot.rb +0 -13
- data/lib/textus/cli/verb/build.rb +0 -15
- data/lib/textus/doctor/check/fetch_locks.rb +0 -49
- data/lib/textus/doctor/check/lifecycle_action_invalid.rb +0 -39
- data/lib/textus/domain/freshness/policy.rb +0 -18
- data/lib/textus/domain/lifecycle.rb +0 -83
- data/lib/textus/domain/outcome.rb +0 -10
- data/lib/textus/domain/policy/lifecycle.rb +0 -35
- data/lib/textus/domain/staleness/generator_check.rb +0 -109
- data/lib/textus/domain/staleness.rb +0 -29
- data/lib/textus/maintenance/tend.rb +0 -110
- data/lib/textus/manifest/entry/derived.rb +0 -65
- data/lib/textus/manifest/entry/intake.rb +0 -31
- data/lib/textus/manifest/entry/validators/inject_boot.rb +0 -21
- data/lib/textus/mcp/tools.rb +0 -14
- data/lib/textus/ports/fetch/detached.rb +0 -52
- data/lib/textus/ports/fetch/lock.rb +0 -44
- data/lib/textus/write/build.rb +0 -90
- data/lib/textus/write/fetch_events.rb +0 -42
- data/lib/textus/write/fetch_orchestrator.rb +0 -101
- data/lib/textus/write/fetch_worker.rb +0 -127
- data/lib/textus/write/intake_fetch.rb +0 -25
- data/lib/textus/write/materializer.rb +0 -51
|
@@ -4,6 +4,12 @@ require "time"
|
|
|
4
4
|
|
|
5
5
|
module Textus
|
|
6
6
|
module Ports
|
|
7
|
+
# Append-only audit log adapter: writes and rotates the on-disk audit JSONL
|
|
8
|
+
# under the store root. An instantiable class — it holds collaborators (the
|
|
9
|
+
# root path + size/keep config), so each store binds its own instance. It
|
|
10
|
+
# already satisfied ADR 0109's single-shape rule (every port is an
|
|
11
|
+
# instantiable class) before that ADR's Clock/Publisher conversions, so it
|
|
12
|
+
# was unchanged by them.
|
|
7
13
|
class AuditLog
|
|
8
14
|
DEFAULT_MAX_SIZE = 10_485_760
|
|
9
15
|
DEFAULT_KEEP = 5
|
|
@@ -4,6 +4,12 @@ require "time"
|
|
|
4
4
|
|
|
5
5
|
module Textus
|
|
6
6
|
module Ports
|
|
7
|
+
# Cross-process build lock: a pid/host-stamped lockfile under the store root
|
|
8
|
+
# that serializes reconcile's produce/sweep. An instantiable class — it holds
|
|
9
|
+
# the root and lock state; `self.with(root:)` is a convenience that constructs
|
|
10
|
+
# one and runs the block under the held lock. It already satisfied ADR 0109's
|
|
11
|
+
# single-shape rule (every port is an instantiable class) before that ADR's
|
|
12
|
+
# Clock/Publisher conversions, so it was unchanged by them.
|
|
7
13
|
class BuildLock
|
|
8
14
|
MAX_HOLDER_BYTES = 512
|
|
9
15
|
|
data/lib/textus/ports/clock.rb
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Ports
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
# The wall clock. An instantiable class (ADR 0109) — uniform with the other
|
|
4
|
+
# ports; `now` reads the system time. Callers that need a fixed time still
|
|
5
|
+
# pass it as data via `Call#now`.
|
|
6
|
+
class Clock
|
|
6
7
|
def now = Time.now
|
|
7
8
|
end
|
|
8
9
|
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Ports
|
|
5
|
+
# ADR 0093: on a canon write, converge the derived entries that depend on the
|
|
6
|
+
# written key (rdeps ∩ derived) by running Produce — scoped + non-destructive.
|
|
7
|
+
# This IS reconcile narrowed to a write's blast radius; there is no separate
|
|
8
|
+
# "reactive materialize" subsystem. Per-entry source.on_write (sync|async)
|
|
9
|
+
# picks inline-under-lock vs deferred. A write INTO a derived entry does not
|
|
10
|
+
# fan out (recursion guard). Failures never reach the writer (Produce.converge
|
|
11
|
+
# isolates them). Attached at Store boot, alongside AuditSubscriber.
|
|
12
|
+
class ProduceOnWriteSubscriber
|
|
13
|
+
def initialize(container)
|
|
14
|
+
@container = container
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def attach(bus)
|
|
18
|
+
bus.on(:entry_written, :produce_on_write) do |ctx:, key:, **|
|
|
19
|
+
call = Textus::Call.build(role: ctx.role, correlation_id: ctx.correlation_id)
|
|
20
|
+
on_write(key: key, call: call)
|
|
21
|
+
end
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def on_write(key:, call:)
|
|
26
|
+
return if derived_write?(key) # recursion guard: produce output is not a source change
|
|
27
|
+
|
|
28
|
+
affected = Textus::Read::Rdeps.new(container: @container).call(key)["rdeps"]
|
|
29
|
+
producible = affected.select { |k| producible?(k) }
|
|
30
|
+
return if producible.empty?
|
|
31
|
+
|
|
32
|
+
if any_sync?(producible)
|
|
33
|
+
Textus::Produce::Engine.converge(container: @container, call: call, keys: producible)
|
|
34
|
+
else
|
|
35
|
+
Textus::Produce::Engine::AsyncRunner.enqueue(container: @container, call: call, keys: producible)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def derived_write?(key)
|
|
42
|
+
@container.manifest.resolver.resolve(key).entry.derived?
|
|
43
|
+
rescue Textus::Error
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The producible scope mirrors Produce::Engine#produce_one: derived
|
|
48
|
+
# entries render+publish, and nested publish_tree entries mirror their
|
|
49
|
+
# source subtree (ADR 0047). Including the latter restores reactive
|
|
50
|
+
# re-mirroring on a write into a tree's source — dropped when the scope
|
|
51
|
+
# narrowed to `derived?` only.
|
|
52
|
+
def producible?(key)
|
|
53
|
+
entry = @container.manifest.resolver.resolve(key).entry
|
|
54
|
+
entry.derived? || !entry.publish_tree.nil?
|
|
55
|
+
rescue Textus::Error
|
|
56
|
+
false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Only derived entries carry a source with on_write semantics; a nested
|
|
60
|
+
# publish_tree entry has no source and defaults to async.
|
|
61
|
+
def any_sync?(keys)
|
|
62
|
+
keys.any? do |k|
|
|
63
|
+
entry = @container.manifest.resolver.resolve(k).entry
|
|
64
|
+
entry.derived? && entry.source.sync?
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -10,18 +10,20 @@ module Textus
|
|
|
10
10
|
# under `<store_root>/.run/sentinels/` (runtime, git-ignored — ADR 0070) and
|
|
11
11
|
# mirror the target's repo-relative layout so consumer directories aren't
|
|
12
12
|
# polluted with `.textus-managed.json` siblings.
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
#
|
|
14
|
+
# An instantiable class (ADR 0109).
|
|
15
|
+
class Publisher
|
|
16
|
+
def publish(source:, target:, store_root:, provenance_source: source)
|
|
15
17
|
FileUtils.mkdir_p(File.dirname(target))
|
|
16
18
|
guard_clobber(source, target, store_root)
|
|
17
19
|
File.delete(target) if File.symlink?(target)
|
|
18
20
|
FileUtils.cp(source, target)
|
|
19
|
-
Textus::Ports::SentinelStore.new.write!(target: target, source:
|
|
21
|
+
Textus::Ports::SentinelStore.new.write!(target: target, source: provenance_source, store_root: store_root)
|
|
20
22
|
end
|
|
21
23
|
|
|
22
24
|
# Removes a previously-published file and its sentinel. No-op unless the
|
|
23
25
|
# target is textus-managed — never deletes an unmanaged file.
|
|
24
|
-
def
|
|
26
|
+
def unpublish(target:, store_root:)
|
|
25
27
|
return unless managed?(target, store_root)
|
|
26
28
|
|
|
27
29
|
FileUtils.rm_f(target)
|
|
@@ -29,6 +31,8 @@ module Textus
|
|
|
29
31
|
FileUtils.rm_f(sentinel)
|
|
30
32
|
end
|
|
31
33
|
|
|
34
|
+
private
|
|
35
|
+
|
|
32
36
|
# Refuse to clobber an unmanaged target — EXCEPT adopt one whose bytes
|
|
33
37
|
# already equal the source (ADR 0050: a migration copies files into the
|
|
34
38
|
# store and publishes them back to where they already live, so the target
|
|
@@ -36,7 +40,7 @@ module Textus
|
|
|
36
40
|
# here; the normal publish path below does, and the cp is a content no-op.
|
|
37
41
|
# An unmanaged target whose content DIFFERS, or any unmanaged symlink, is
|
|
38
42
|
# still refused — that is the guard's real job.
|
|
39
|
-
def
|
|
43
|
+
def guard_clobber(source, target, store_root)
|
|
40
44
|
return unless File.exist?(target) || File.symlink?(target)
|
|
41
45
|
return if managed?(target, store_root)
|
|
42
46
|
return if adoptable?(source, target)
|
|
@@ -44,11 +48,11 @@ module Textus
|
|
|
44
48
|
raise PublishError.new("refusing to clobber unmanaged file at #{target}", target: target)
|
|
45
49
|
end
|
|
46
50
|
|
|
47
|
-
def
|
|
51
|
+
def adoptable?(source, target)
|
|
48
52
|
!File.symlink?(target) && File.file?(target) && FileUtils.identical?(source, target)
|
|
49
53
|
end
|
|
50
54
|
|
|
51
|
-
def
|
|
55
|
+
def managed?(target, store_root)
|
|
52
56
|
File.exist?(Textus::Ports::SentinelStore.new.sentinel_path(target, store_root))
|
|
53
57
|
end
|
|
54
58
|
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
require "timeout"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Produce
|
|
5
|
+
module Acquire
|
|
6
|
+
# Invokes a :resolve_handler hook handler by name under a timeout — the single
|
|
7
|
+
# home for "call the intake handler under a deadline" (ADR 0048 D1). Shared by
|
|
8
|
+
# Produce::Acquire::Intake (the internal ingest mechanism — no public verb since ADR 0079)
|
|
9
|
+
# as driven by the `reconcile` sweep and `textus hook run` (ADR 0089 made
|
|
10
|
+
# ingest system-pushed; there is no read or put trigger).
|
|
11
|
+
# Always passes a Container as `caps:` so the hook contract (ADR 0027) is
|
|
12
|
+
# uniform across every entry point. Maps Timeout::Error to a UsageError;
|
|
13
|
+
# leaves any other error to the caller (call sites differ in how they wrap).
|
|
14
|
+
module Handler
|
|
15
|
+
FETCH_TIMEOUT_SECONDS = 30
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def invoke(caps:, handler:, config:, args:, label:, timeout: FETCH_TIMEOUT_SECONDS)
|
|
20
|
+
Timeout.timeout(timeout) do
|
|
21
|
+
caps.rpc.invoke(:resolve_handler, handler, caps: caps, config: config, args: args)
|
|
22
|
+
end
|
|
23
|
+
rescue Timeout::Error
|
|
24
|
+
raise Textus::UsageError.new("#{label} '#{handler}' exceeded #{timeout}s timeout")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
require "timeout"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Produce
|
|
5
|
+
module Acquire
|
|
6
|
+
# Internal ingest executor for one machine-zone intake entry. No longer a
|
|
7
|
+
# public verb (ADR 0079 collapsed the `fetch` surface): used by the
|
|
8
|
+
# `reconcile` sweep and `textus hook run` only — ingest is system-pushed
|
|
9
|
+
# (ADR 0089 removed the read-through that once also drove it).
|
|
10
|
+
class Intake
|
|
11
|
+
FETCH_TIMEOUT_SECONDS = Textus::Produce::Acquire::Handler::FETCH_TIMEOUT_SECONDS
|
|
12
|
+
|
|
13
|
+
def initialize(container:, call:)
|
|
14
|
+
@container = container
|
|
15
|
+
@call = call
|
|
16
|
+
@manifest = container.manifest
|
|
17
|
+
@schemas = container.schemas
|
|
18
|
+
@rpc = container.rpc
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# call(key) is the primary entry; run is kept as an alias for
|
|
22
|
+
# Orchestrator and FetchAll which call worker.run(key).
|
|
23
|
+
def call(key)
|
|
24
|
+
run(key)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run(key)
|
|
28
|
+
res = @manifest.resolver.resolve(key)
|
|
29
|
+
mentry = res.entry
|
|
30
|
+
path = res.path
|
|
31
|
+
remaining = res.remaining
|
|
32
|
+
raise UsageError.new("no intake declared for '#{key}'") unless mentry.intake?
|
|
33
|
+
|
|
34
|
+
before_etag = @container.file_store.exists?(path) ? @container.file_store.etag(path) : nil
|
|
35
|
+
result = fetch_with_events(key, mentry, remaining)
|
|
36
|
+
persist_and_notify(key, mentry, result, before_etag)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.normalize_action_result(res, format:)
|
|
40
|
+
res = res.transform_keys(&:to_s) if res.is_a?(Hash)
|
|
41
|
+
res ||= {}
|
|
42
|
+
meta_val = res["_meta"]
|
|
43
|
+
body = res["body"]
|
|
44
|
+
content = res["content"]
|
|
45
|
+
|
|
46
|
+
case format
|
|
47
|
+
when "markdown" then { meta: meta_val || {}, body: body.to_s, content: nil }
|
|
48
|
+
when "text" then { meta: {}, body: body.to_s, content: nil }
|
|
49
|
+
when "json", "yaml"
|
|
50
|
+
if !content.nil?
|
|
51
|
+
{ meta: meta_val || {}, body: nil, content: content }
|
|
52
|
+
elsif !body.nil?
|
|
53
|
+
{ meta: {}, body: body.to_s, content: nil }
|
|
54
|
+
else
|
|
55
|
+
raise Textus::UsageError.new("intake for #{format} returned neither content nor body")
|
|
56
|
+
end
|
|
57
|
+
else
|
|
58
|
+
raise Textus::UsageError.new("unknown format #{format.inspect}")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def fetch_events
|
|
65
|
+
@fetch_events ||= Textus::Produce::Events.from(container: @container, call: @call)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# ADR 0079: a per-rule fetch_timeout_seconds override was an accepted loss
|
|
69
|
+
# in the fetch:/retention: → lifecycle: collapse; the constant ceiling
|
|
70
|
+
# applies to every intake.
|
|
71
|
+
def fetch_timeout_for(_key)
|
|
72
|
+
FETCH_TIMEOUT_SECONDS
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def fetch_with_events(key, mentry, remaining)
|
|
76
|
+
fetch_events.started(key)
|
|
77
|
+
call_intake(key, mentry, remaining)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def call_intake(key, mentry, remaining)
|
|
81
|
+
Textus::Produce::Acquire::Handler.invoke(
|
|
82
|
+
caps: @container, handler: mentry.handler,
|
|
83
|
+
config: mentry.config,
|
|
84
|
+
args: { trigger_key: key, leaf_segments: remaining || [] },
|
|
85
|
+
label: "intake", timeout: fetch_timeout_for(key)
|
|
86
|
+
)
|
|
87
|
+
rescue Textus::Error => e
|
|
88
|
+
fetch_events.failed(key, e)
|
|
89
|
+
raise
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
fetch_events.failed(key, e)
|
|
92
|
+
raise UsageError.new("intake '#{mentry.handler}' raised: #{e.class}: #{e.message}")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def persist_and_notify(key, mentry, result, before_etag)
|
|
96
|
+
normalized = self.class.normalize_action_result(result, format: mentry.format)
|
|
97
|
+
Textus::Domain::Policy::GuardFactory.new(
|
|
98
|
+
manifest: @manifest, schemas: @schemas,
|
|
99
|
+
).for(:reconcile, key).check!(
|
|
100
|
+
Textus::Domain::Policy::Evaluation.new(
|
|
101
|
+
actor: @call.role, transition: :reconcile, origin: nil,
|
|
102
|
+
target: key, envelope: nil, manifest: @manifest
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
envelope = writer.put(
|
|
106
|
+
key,
|
|
107
|
+
mentry: mentry,
|
|
108
|
+
payload: Textus::Envelope::IO::Writer::Payload.new(
|
|
109
|
+
meta: normalized[:meta], body: normalized[:body], content: normalized[:content],
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
change = detect_change(before_etag, envelope)
|
|
113
|
+
fetch_events.fetched(key, envelope, change)
|
|
114
|
+
envelope
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def detect_change(before_etag, envelope)
|
|
118
|
+
if before_etag.nil? then :created
|
|
119
|
+
elsif envelope.etag == before_etag then :unchanged
|
|
120
|
+
else :updated
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def writer
|
|
125
|
+
@writer ||= Textus::Envelope::IO::Writer.from(container: @container, call: @call)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Produce
|
|
5
|
+
module Acquire
|
|
6
|
+
# Builds an entry's DATA artifact (ADR 0094) by running the projection
|
|
7
|
+
# pipeline; rendering is a publish concern. External entries are NOT built
|
|
8
|
+
# here — they are generated by an out-of-band runner; Derived#publish_via
|
|
9
|
+
# filters them out before reaching this point.
|
|
10
|
+
#
|
|
11
|
+
# Merges the former Write::DataBuilder wrapper and Builder::Pipeline module
|
|
12
|
+
# into one class (ADR 0100 produce/ topology refactor).
|
|
13
|
+
class Projection
|
|
14
|
+
# Injects provenance metadata as the first key in the serialized output.
|
|
15
|
+
# Carries only deterministic provenance (`from`/`reduce`) — the volatile
|
|
16
|
+
# `generated_at` is deliberately NOT stamped, so the built artifact is
|
|
17
|
+
# content-addressed and a rebuild is a byte-for-byte no-op (ADR 0070).
|
|
18
|
+
# Build time lives out of the tracked artifact.
|
|
19
|
+
module InjectMeta
|
|
20
|
+
def self.call(content_hash, mentry)
|
|
21
|
+
meta = {}
|
|
22
|
+
if mentry.derived?
|
|
23
|
+
src = mentry.source
|
|
24
|
+
if src.projection?
|
|
25
|
+
from = Array(src.select).compact
|
|
26
|
+
meta["from"] = from unless from.empty?
|
|
27
|
+
meta["reduce"] = src.transform if src.transform
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
out = { "_meta" => meta }
|
|
32
|
+
content_hash.each { |k, v| out[k] = v unless k == "_meta" }
|
|
33
|
+
out
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
Deps = Data.define(:manifest, :reader, :lister, :rpc, :transform_context)
|
|
38
|
+
|
|
39
|
+
def self.renderers
|
|
40
|
+
@renderers ||= {
|
|
41
|
+
"text" => Produce::Acquire::Serializer::Text,
|
|
42
|
+
"json" => Produce::Acquire::Serializer::Json,
|
|
43
|
+
"yaml" => Produce::Acquire::Serializer::Yaml,
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def initialize(container:, call:)
|
|
48
|
+
@container = container
|
|
49
|
+
@call = call
|
|
50
|
+
@manifest = container.manifest
|
|
51
|
+
@file_store = container.file_store
|
|
52
|
+
@rpc = container.rpc
|
|
53
|
+
@root = container.root
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Runs the projection pipeline for `mentry` and returns the on-disk
|
|
57
|
+
# target_path string.
|
|
58
|
+
def run(mentry)
|
|
59
|
+
reader = Textus::Read::Get.new(container: @container, call: @call)
|
|
60
|
+
# Projections must be able to read source data from any nested entry,
|
|
61
|
+
# including keyless (publish_tree) ones like knowledge.decisions.
|
|
62
|
+
# The `include_keyless: true` option makes the resolver walk those dirs
|
|
63
|
+
# without exposing them on the public `list` / CLI surface (ADR 0047).
|
|
64
|
+
resolver = @manifest.resolver
|
|
65
|
+
lister = lambda do |prefix:|
|
|
66
|
+
resolver.enumerate(prefix: prefix, include_keyless: true)
|
|
67
|
+
.map { |row| { "key" => row[:key], "zone" => row[:manifest_entry].zone, "path" => row[:path] } }
|
|
68
|
+
end
|
|
69
|
+
self.class.pipeline_run(
|
|
70
|
+
mentry: mentry,
|
|
71
|
+
deps: Deps.new(
|
|
72
|
+
manifest: @manifest,
|
|
73
|
+
reader: reader.method(:call),
|
|
74
|
+
lister: lister,
|
|
75
|
+
rpc: @rpc,
|
|
76
|
+
transform_context: @container,
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.pipeline_run(mentry:, deps:)
|
|
82
|
+
# 1. Load sources + project + reduce. Only projection-derived entries are
|
|
83
|
+
# buildable in-process; External entries are generated out-of-band and are
|
|
84
|
+
# filtered out upstream (Derived#publish_via), so reaching here with a
|
|
85
|
+
# non-projection source is a wiring bug — fail loudly rather than emit an
|
|
86
|
+
# empty payload (and never re-stamp the volatile generated_at, ADR 0070).
|
|
87
|
+
unless mentry.projection?
|
|
88
|
+
raise UsageError.new(
|
|
89
|
+
"builder: '#{mentry.key}' is not a projection-derived entry; only projections are buildable",
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
data =
|
|
94
|
+
Textus::Projection.new(
|
|
95
|
+
reader: deps.reader,
|
|
96
|
+
spec: mentry.source.projection_spec,
|
|
97
|
+
lister: deps.lister,
|
|
98
|
+
rpc: deps.rpc,
|
|
99
|
+
transform_context: deps.transform_context,
|
|
100
|
+
).run
|
|
101
|
+
|
|
102
|
+
# 2. Serialize as DATA. Rendering through a template is a publish concern
|
|
103
|
+
# (ADR 0094) — the build never consults a template.
|
|
104
|
+
klass = renderers[mentry.format] or
|
|
105
|
+
raise UsageError.new("builder: unsupported data format #{mentry.format.inspect} for '#{mentry.key}'")
|
|
106
|
+
bytes = klass.new.call(mentry: mentry, data: data)
|
|
107
|
+
|
|
108
|
+
# 3. Write (idempotent: skip if only generated_at would differ)
|
|
109
|
+
target_path = Key::Path.resolve(deps.manifest.data, mentry)
|
|
110
|
+
FileUtils.mkdir_p(File.dirname(target_path))
|
|
111
|
+
write_if_changed(target_path, bytes, mentry.format)
|
|
112
|
+
|
|
113
|
+
target_path
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Built artifacts are content-addressed (no volatile timestamp, ADR 0070),
|
|
117
|
+
# so identity is plain byte-equality: skip the write when nothing changed.
|
|
118
|
+
# `format` is retained for signature stability across renderers.
|
|
119
|
+
def self.write_if_changed(target_path, bytes, _format)
|
|
120
|
+
return if File.exist?(target_path) && File.binread(target_path) == bytes
|
|
121
|
+
|
|
122
|
+
File.binwrite(target_path, bytes)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Produce
|
|
5
|
+
module Acquire
|
|
6
|
+
class Serializer
|
|
7
|
+
class Json < Serializer
|
|
8
|
+
def call(mentry:, data:)
|
|
9
|
+
content = default_shape(mentry, data)
|
|
10
|
+
final = Produce::Acquire::Projection::InjectMeta.call(content, mentry)
|
|
11
|
+
Entry.for_format("json").serialize(meta: {}, body: "", content: final)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def default_shape(mentry, data)
|
|
17
|
+
has_transform = mentry.projection? &&
|
|
18
|
+
mentry.source.transform
|
|
19
|
+
if has_transform && data.is_a?(Hash) && !data.key?("entries")
|
|
20
|
+
data
|
|
21
|
+
elsif data.is_a?(Hash) && data["entries"].is_a?(Array)
|
|
22
|
+
{ "entries" => data["entries"] }
|
|
23
|
+
else
|
|
24
|
+
data.is_a?(Hash) ? data : { "entries" => Array(data) }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Produce
|
|
3
|
+
module Acquire
|
|
4
|
+
class Serializer
|
|
5
|
+
class Text < Serializer
|
|
6
|
+
def call(mentry:, data:) # rubocop:disable Lint/UnusedMethodArgument
|
|
7
|
+
# Text format serializes data as plain-text. Rendering through a
|
|
8
|
+
# template is a publish concern (ADR 0094) — build emits data only.
|
|
9
|
+
body = data.is_a?(Hash) ? data.to_s : data.inspect
|
|
10
|
+
Entry.for_format("text").serialize(meta: {}, body: body)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Produce
|
|
5
|
+
module Acquire
|
|
6
|
+
class Serializer
|
|
7
|
+
class Yaml < Serializer
|
|
8
|
+
def call(mentry:, data:)
|
|
9
|
+
content = default_shape(mentry, data)
|
|
10
|
+
final = Produce::Acquire::Projection::InjectMeta.call(content, mentry)
|
|
11
|
+
Entry.for_format("yaml").serialize(meta: {}, body: "", content: final)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def default_shape(mentry, data)
|
|
17
|
+
has_transform = mentry.projection? &&
|
|
18
|
+
mentry.source.transform
|
|
19
|
+
if has_transform && data.is_a?(Hash) && !data.key?("entries")
|
|
20
|
+
data
|
|
21
|
+
elsif data.is_a?(Hash) && data["entries"].is_a?(Array)
|
|
22
|
+
{ "entries" => data["entries"] }
|
|
23
|
+
else
|
|
24
|
+
data.is_a?(Hash) ? data : { "entries" => Array(data) }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Produce
|
|
3
|
+
module Acquire
|
|
4
|
+
# Abstract base for output serializers. Each concrete serializer owns
|
|
5
|
+
# producing the bytes for one manifest format (json/yaml/text).
|
|
6
|
+
# Rendering through a template is a publish concern (ADR 0094) — serializers
|
|
7
|
+
# here only serialize data; they take no arguments.
|
|
8
|
+
class Serializer
|
|
9
|
+
def call(mentry:, data:)
|
|
10
|
+
_ = mentry
|
|
11
|
+
_ = data
|
|
12
|
+
raise NotImplementedError.new("#{self.class.name}#call not implemented")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|