textus 0.52.0 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -0
- data/README.md +62 -54
- data/SPEC.md +64 -192
- data/docs/architecture/README.md +157 -156
- data/exe/textus +1 -1
- data/lib/textus/action/accept.rb +44 -0
- data/lib/textus/action/audit.rb +131 -0
- data/lib/textus/action/base.rb +42 -0
- data/lib/textus/{read → action}/blame.rb +28 -22
- data/lib/textus/action/boot.rb +17 -0
- data/lib/textus/action/data_mv.rb +69 -0
- data/lib/textus/action/deps.rb +34 -0
- data/lib/textus/action/doctor.rb +24 -0
- data/lib/textus/action/drain.rb +39 -0
- data/lib/textus/action/enqueue.rb +53 -0
- data/lib/textus/action/get.rb +78 -0
- data/lib/textus/action/ingest.rb +135 -0
- data/lib/textus/action/jobs.rb +36 -0
- data/lib/textus/action/key_delete.rb +36 -0
- data/lib/textus/action/key_delete_prefix.rb +44 -0
- data/lib/textus/action/key_mv.rb +129 -0
- data/lib/textus/action/key_mv_prefix.rb +57 -0
- data/lib/textus/action/list.rb +42 -0
- data/lib/textus/action/propose.rb +52 -0
- data/lib/textus/action/published.rb +24 -0
- data/lib/textus/action/pulse.rb +56 -0
- data/lib/textus/action/put.rb +52 -0
- data/lib/textus/action/rdeps.rb +39 -0
- data/lib/textus/action/reject.rb +37 -0
- data/lib/textus/action/rule_explain.rb +91 -0
- data/lib/textus/action/rule_lint.rb +68 -0
- data/lib/textus/action/rule_list.rb +42 -0
- data/lib/textus/action/schema_envelope.rb +29 -0
- data/lib/textus/action/uid.rb +33 -0
- data/lib/textus/action/where.rb +36 -0
- data/lib/textus/action/write_verb.rb +44 -0
- data/lib/textus/boot.rb +64 -132
- data/lib/textus/command.rb +41 -0
- data/lib/textus/container.rb +1 -1
- data/lib/textus/{domain → core}/duration.rb +1 -1
- data/lib/textus/{domain → core}/freshness/evaluator.rb +25 -41
- data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
- data/lib/textus/{domain → core}/freshness.rb +2 -2
- data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
- data/lib/textus/{domain → core}/retention.rb +2 -2
- data/lib/textus/{domain → core}/sentinel.rb +1 -1
- data/lib/textus/doctor/check/generator_drift.rb +1 -1
- data/lib/textus/doctor/check/illegal_keys.rb +1 -1
- data/lib/textus/doctor/check/notebook_sources.rb +50 -0
- data/lib/textus/doctor/check/proposal_targets.rb +3 -3
- data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
- data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
- data/lib/textus/doctor/check/schema_violations.rb +8 -2
- data/lib/textus/doctor/check.rb +11 -9
- data/lib/textus/{read → doctor}/validator.rb +22 -13
- data/lib/textus/doctor.rb +4 -44
- data/lib/textus/envelope/reader.rb +46 -0
- data/lib/textus/envelope/writer.rb +209 -0
- data/lib/textus/envelope.rb +5 -3
- data/lib/textus/errors.rb +25 -13
- data/lib/textus/{entry → format}/base.rb +1 -6
- data/lib/textus/{entry → format}/json.rb +1 -4
- data/lib/textus/{entry → format}/markdown.rb +2 -6
- data/lib/textus/{entry → format}/text.rb +2 -6
- data/lib/textus/{entry → format}/yaml.rb +1 -4
- data/lib/textus/{entry.rb → format.rb} +9 -10
- data/lib/textus/gate/auth.rb +227 -0
- data/lib/textus/gate.rb +116 -0
- data/lib/textus/init.rb +59 -105
- data/lib/textus/jobs/base.rb +23 -0
- data/lib/textus/jobs/materialize.rb +20 -0
- data/lib/textus/jobs/plan.rb +9 -0
- data/lib/textus/jobs/planner.rb +101 -0
- data/lib/textus/jobs/retention.rb +48 -0
- data/lib/textus/jobs/sweep.rb +27 -0
- data/lib/textus/jobs/worker.rb +67 -0
- data/lib/textus/jobs.rb +15 -0
- data/lib/textus/key/path.rb +10 -3
- data/lib/textus/layout.rb +13 -0
- data/lib/textus/manifest/data.rb +14 -14
- data/lib/textus/manifest/entry/base.rb +14 -25
- data/lib/textus/manifest/entry/parser.rb +31 -14
- data/lib/textus/manifest/entry/produced.rb +13 -33
- data/lib/textus/manifest/entry/publish/mode.rb +1 -1
- data/lib/textus/manifest/entry/publish/to_paths.rb +4 -3
- data/lib/textus/manifest/entry/publish.rb +0 -12
- data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
- data/lib/textus/manifest/entry/validators.rb +0 -1
- data/lib/textus/{domain → manifest}/policy/matcher.rb +2 -2
- data/lib/textus/{domain → manifest}/policy/publish_target.rb +2 -2
- data/lib/textus/manifest/policy/react.rb +30 -0
- data/lib/textus/{domain → manifest}/policy/retention.rb +3 -3
- data/lib/textus/manifest/policy/source.rb +30 -0
- data/lib/textus/manifest/policy.rb +38 -53
- data/lib/textus/manifest/resolver.rb +5 -4
- data/lib/textus/manifest/rules.rb +4 -4
- data/lib/textus/manifest/schema/keys.rb +20 -28
- data/lib/textus/manifest/schema/validator.rb +27 -33
- data/lib/textus/manifest/schema/vocabulary.rb +2 -1
- data/lib/textus/manifest/schema.rb +2 -2
- data/lib/textus/manifest.rb +2 -2
- data/lib/textus/ports/audit_log.rb +31 -5
- data/lib/textus/{domain/jobs → ports/job_store}/job.rb +19 -12
- data/lib/textus/ports/{queue.rb → job_store.rb} +7 -14
- data/lib/textus/ports/sentinel_store.rb +2 -2
- data/lib/textus/ports/watcher_lock.rb +48 -0
- data/lib/textus/produce/engine.rb +25 -70
- data/lib/textus/produce/render.rb +2 -7
- data/lib/textus/schema/tools.rb +4 -3
- data/lib/textus/session.rb +6 -3
- data/lib/textus/store.rb +12 -19
- data/lib/textus/surfaces/cli/group/data.rb +11 -0
- data/lib/textus/surfaces/cli/group/key.rb +11 -0
- data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
- data/lib/textus/surfaces/cli/group/rule.rb +11 -0
- data/lib/textus/surfaces/cli/group/schema.rb +11 -0
- data/lib/textus/surfaces/cli/group.rb +50 -0
- data/lib/textus/surfaces/cli/runner.rb +236 -0
- data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
- data/lib/textus/surfaces/cli/verb/get.rb +21 -0
- data/lib/textus/surfaces/cli/verb/init.rb +20 -0
- data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
- data/lib/textus/surfaces/cli/verb/put.rb +30 -0
- data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
- data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
- data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
- data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
- data/lib/textus/surfaces/cli/verb.rb +111 -0
- data/lib/textus/surfaces/cli.rb +148 -0
- data/lib/textus/surfaces/mcp/catalog.rb +99 -0
- data/lib/textus/surfaces/mcp/errors.rb +25 -0
- data/lib/textus/surfaces/mcp/routing.rb +51 -0
- data/lib/textus/surfaces/mcp/server.rb +184 -0
- data/lib/textus/surfaces/mcp/session.rb +9 -0
- data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
- data/lib/textus/surfaces/mcp.rb +8 -0
- data/lib/textus/surfaces/role_scope.rb +34 -0
- data/lib/textus/surfaces/watcher.rb +38 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus/workflow/collector.rb +27 -0
- data/lib/textus/workflow/context.rb +5 -0
- data/lib/textus/workflow/dsl.rb +44 -0
- data/lib/textus/workflow/loader.rb +17 -0
- data/lib/textus/workflow/pattern.rb +18 -0
- data/lib/textus/workflow/registry.rb +21 -0
- data/lib/textus/workflow/runner.rb +84 -0
- data/lib/textus/workflow.rb +28 -0
- data/lib/textus.rb +75 -22
- metadata +118 -132
- data/lib/textus/cli/group/hook.rb +0 -9
- data/lib/textus/cli/group/key.rb +0 -9
- data/lib/textus/cli/group/mcp.rb +0 -9
- data/lib/textus/cli/group/rule.rb +0 -9
- data/lib/textus/cli/group/schema.rb +0 -9
- data/lib/textus/cli/group/zone.rb +0 -9
- data/lib/textus/cli/group.rb +0 -48
- data/lib/textus/cli/runner.rb +0 -193
- data/lib/textus/cli/verb/doctor.rb +0 -17
- data/lib/textus/cli/verb/get.rb +0 -18
- data/lib/textus/cli/verb/hook_run.rb +0 -48
- data/lib/textus/cli/verb/hooks.rb +0 -50
- data/lib/textus/cli/verb/init.rb +0 -18
- data/lib/textus/cli/verb/mcp_serve.rb +0 -22
- data/lib/textus/cli/verb/put.rb +0 -30
- data/lib/textus/cli/verb/schema_diff.rb +0 -15
- data/lib/textus/cli/verb/schema_init.rb +0 -19
- data/lib/textus/cli/verb/schema_migrate.rb +0 -19
- data/lib/textus/cli/verb/serve.rb +0 -19
- data/lib/textus/cli/verb.rb +0 -116
- data/lib/textus/cli.rb +0 -138
- data/lib/textus/dispatcher.rb +0 -54
- data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
- data/lib/textus/doctor/check/hooks.rb +0 -32
- data/lib/textus/doctor/check/intake_registration.rb +0 -46
- data/lib/textus/domain/action.rb +0 -9
- data/lib/textus/domain/jobs/registry.rb +0 -37
- data/lib/textus/domain/permission.rb +0 -7
- data/lib/textus/domain/policy/base_guards.rb +0 -25
- data/lib/textus/domain/policy/evaluation.rb +0 -15
- data/lib/textus/domain/policy/guard.rb +0 -35
- data/lib/textus/domain/policy/guard_factory.rb +0 -40
- data/lib/textus/domain/policy/handler_allowlist.rb +0 -17
- data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
- data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
- data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
- data/lib/textus/domain/policy/predicates/registry.rb +0 -39
- data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
- data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
- data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
- data/lib/textus/domain/policy/source.rb +0 -73
- data/lib/textus/envelope/io/reader.rb +0 -48
- data/lib/textus/envelope/io/writer.rb +0 -182
- data/lib/textus/hooks/builtin.rb +0 -70
- data/lib/textus/hooks/catalog.rb +0 -37
- data/lib/textus/hooks/context.rb +0 -72
- data/lib/textus/hooks/error_log.rb +0 -32
- data/lib/textus/hooks/event_bus.rb +0 -114
- data/lib/textus/hooks/fire_report.rb +0 -23
- data/lib/textus/hooks/loader.rb +0 -54
- data/lib/textus/hooks/rpc_registry.rb +0 -43
- data/lib/textus/hooks/signature.rb +0 -31
- data/lib/textus/init/templates/machine_intake.rb +0 -45
- data/lib/textus/init/templates/orientation_reducer.rb +0 -17
- data/lib/textus/jobs/handlers.rb +0 -62
- data/lib/textus/jobs/scheduler.rb +0 -36
- data/lib/textus/jobs/seeder.rb +0 -57
- data/lib/textus/maintenance/drain.rb +0 -42
- data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
- data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
- data/lib/textus/maintenance/retention/apply.rb +0 -52
- data/lib/textus/maintenance/rule_lint.rb +0 -66
- data/lib/textus/maintenance/serve.rb +0 -30
- data/lib/textus/maintenance/worker.rb +0 -74
- data/lib/textus/maintenance/zone_mv.rb +0 -64
- data/lib/textus/maintenance.rb +0 -15
- data/lib/textus/manifest/entry/validators/events.rb +0 -22
- data/lib/textus/mcp/catalog.rb +0 -70
- data/lib/textus/mcp/errors.rb +0 -32
- data/lib/textus/mcp/server.rb +0 -138
- data/lib/textus/mcp/session.rb +0 -7
- data/lib/textus/mcp/tool_schemas.rb +0 -15
- data/lib/textus/mcp.rb +0 -6
- data/lib/textus/mustache.rb +0 -117
- data/lib/textus/ports/audit_subscriber.rb +0 -42
- data/lib/textus/ports/produce_on_write_subscriber.rb +0 -73
- data/lib/textus/produce/acquire/handler.rb +0 -29
- data/lib/textus/produce/acquire/intake.rb +0 -130
- data/lib/textus/produce/acquire/projection.rb +0 -127
- data/lib/textus/produce/acquire/serializer/json.rb +0 -31
- data/lib/textus/produce/acquire/serializer/text.rb +0 -16
- data/lib/textus/produce/acquire/serializer/yaml.rb +0 -31
- data/lib/textus/produce/acquire/serializer.rb +0 -17
- data/lib/textus/produce/events.rb +0 -36
- data/lib/textus/projection.rb +0 -103
- data/lib/textus/read/audit.rb +0 -130
- data/lib/textus/read/boot.rb +0 -26
- data/lib/textus/read/capabilities.rb +0 -70
- data/lib/textus/read/deps.rb +0 -38
- data/lib/textus/read/doctor.rb +0 -27
- data/lib/textus/read/freshness.rb +0 -152
- data/lib/textus/read/get.rb +0 -73
- data/lib/textus/read/jobs.rb +0 -31
- data/lib/textus/read/list.rb +0 -24
- data/lib/textus/read/published.rb +0 -22
- data/lib/textus/read/pulse.rb +0 -98
- data/lib/textus/read/rdeps.rb +0 -39
- data/lib/textus/read/rule_explain.rb +0 -96
- data/lib/textus/read/rule_list.rb +0 -54
- data/lib/textus/read/schema_envelope.rb +0 -25
- data/lib/textus/read/uid.rb +0 -29
- data/lib/textus/read/validate_all.rb +0 -36
- data/lib/textus/read/where.rb +0 -24
- data/lib/textus/role_scope.rb +0 -78
- data/lib/textus/write/accept.rb +0 -58
- data/lib/textus/write/enqueue.rb +0 -50
- data/lib/textus/write/key_delete.rb +0 -65
- data/lib/textus/write/key_mv.rb +0 -141
- data/lib/textus/write/propose.rb +0 -54
- data/lib/textus/write/put.rb +0 -74
- data/lib/textus/write/reject.rb +0 -68
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
require "time"
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
|
-
module
|
|
4
|
+
module Core
|
|
5
5
|
module Freshness
|
|
6
6
|
# The single currency evaluator (ADR 0099). Answers "is the stored data
|
|
7
|
-
# stale relative to its
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# (surfaced by the doctor generator_drift check; derived entries annotate
|
|
13
|
-
# fresh at read time because converge runs them reactively).
|
|
14
|
-
# Replaces Domain::IntakeStaleness and Domain::Staleness::GeneratorCheck and
|
|
15
|
-
# the inline copies in Read::Get / Read::Freshness.
|
|
7
|
+
# stale relative to its retention rule?" and detects generator drift for
|
|
8
|
+
# external entries.
|
|
9
|
+
# - retention rule TTL -> AGE signal: now - file_basis > ttl_seconds
|
|
10
|
+
# - external -> DRIFT signal: a source changed since generated.at
|
|
11
|
+
# (surfaced by the doctor generator_drift check).
|
|
16
12
|
class Evaluator
|
|
17
13
|
def initialize(manifest:, file_stat:, clock:)
|
|
18
14
|
@manifest = manifest
|
|
@@ -20,32 +16,28 @@ module Textus
|
|
|
20
16
|
@clock = clock
|
|
21
17
|
end
|
|
22
18
|
|
|
23
|
-
# Per-entry currency Verdict (
|
|
24
|
-
# entries are always fresh (retention is GC, not content currency).
|
|
19
|
+
# Per-entry currency Verdict driven by the retention rule TTL (if any).
|
|
25
20
|
def verdict(mentry)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
ttl = mentry.source.ttl_seconds
|
|
21
|
+
ttl = @manifest.rules.for(mentry.key).retention&.ttl_seconds
|
|
29
22
|
return fresh if ttl.nil?
|
|
30
23
|
|
|
31
|
-
stale = age_stale?(
|
|
24
|
+
stale = age_stale?(file_basis(mentry), ttl)
|
|
32
25
|
Verdict.build(stale: stale, reason: stale ? "ttl exceeded" : nil, fetching: false)
|
|
33
26
|
end
|
|
34
27
|
|
|
35
|
-
# Keys of
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def stale_intake_keys(prefix: nil, zone: nil)
|
|
39
|
-
@manifest.data.entries.select { |m| due?(m, prefix: prefix, zone: zone) }.map(&:key)
|
|
28
|
+
# Keys of entries past their retention rule TTL — the refresh produce scope.
|
|
29
|
+
def stale_keys(prefix: nil, lane: nil)
|
|
30
|
+
@manifest.data.entries.select { |m| due?(m, prefix: prefix, lane: lane) }.map(&:key)
|
|
40
31
|
end
|
|
41
32
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
33
|
+
alias stale_intake_keys stale_keys
|
|
34
|
+
|
|
35
|
+
# File basis as a Time (or nil): file mtime when present, else nil.
|
|
36
|
+
def file_basis(mentry)
|
|
45
37
|
path = @manifest.resolver.resolve(mentry.key).path
|
|
46
38
|
return nil unless @file_stat.exists?(path)
|
|
47
39
|
|
|
48
|
-
|
|
40
|
+
@file_stat.mtime(path)
|
|
49
41
|
end
|
|
50
42
|
|
|
51
43
|
# Generator-drift rows for one entry (replaces Staleness::GeneratorCheck#
|
|
@@ -62,18 +54,17 @@ module Textus
|
|
|
62
54
|
|
|
63
55
|
def fresh = Verdict.build(stale: false, reason: nil, fetching: false)
|
|
64
56
|
|
|
65
|
-
def due?(mentry, prefix:,
|
|
66
|
-
return false
|
|
67
|
-
return false if zone && mentry.zone != zone
|
|
57
|
+
def due?(mentry, prefix:, lane:)
|
|
58
|
+
return false if lane && mentry.lane != lane
|
|
68
59
|
return false if prefix && !mentry.key.start_with?(prefix)
|
|
69
60
|
|
|
70
|
-
ttl = mentry.
|
|
71
|
-
return false if ttl.nil?
|
|
61
|
+
ttl = @manifest.rules.for(mentry.key).retention&.ttl_seconds
|
|
62
|
+
return false if ttl.nil?
|
|
72
63
|
|
|
73
64
|
path = @manifest.resolver.resolve(mentry.key).path
|
|
74
65
|
return true unless @file_stat.exists?(path)
|
|
75
66
|
|
|
76
|
-
age_stale?(
|
|
67
|
+
age_stale?(file_basis(mentry), ttl)
|
|
77
68
|
end
|
|
78
69
|
|
|
79
70
|
# The one age comparison. A never-recorded entry (nil basis) is stale.
|
|
@@ -83,16 +74,9 @@ module Textus
|
|
|
83
74
|
(@clock.now - basis).to_i > ttl
|
|
84
75
|
end
|
|
85
76
|
|
|
86
|
-
def last_fetched_at(mentry, path)
|
|
87
|
-
meta = Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"]
|
|
88
|
-
Time.parse(meta["last_fetched_at"].to_s) if meta && meta["last_fetched_at"]
|
|
89
|
-
rescue StandardError
|
|
90
|
-
nil
|
|
91
|
-
end
|
|
92
|
-
|
|
93
77
|
# --- generator drift (lifted from Staleness::GeneratorCheck) ---
|
|
94
78
|
|
|
95
|
-
def drift_applicable?(mentry) = mentry.
|
|
79
|
+
def drift_applicable?(mentry) = mentry.external?
|
|
96
80
|
|
|
97
81
|
def drift_reason(mentry, path)
|
|
98
82
|
return "derived entry has never been generated" unless @file_stat.exists?(path)
|
|
@@ -108,7 +92,7 @@ module Textus
|
|
|
108
92
|
end
|
|
109
93
|
|
|
110
94
|
def generated_at_of(mentry, path)
|
|
111
|
-
|
|
95
|
+
Format.for(mentry.format).parse(@file_stat.read(path), path: path)["_meta"].dig("generated", "at")
|
|
112
96
|
end
|
|
113
97
|
|
|
114
98
|
def parse_time(str)
|
|
@@ -158,7 +142,7 @@ module Textus
|
|
|
158
142
|
def file?(fpath) = !@file_stat.directory?(fpath) && @file_stat.exists?(fpath)
|
|
159
143
|
|
|
160
144
|
def drift_row(mentry, path, reason)
|
|
161
|
-
{ "key" => mentry.key, "path" => path, "generator" => mentry.
|
|
145
|
+
{ "key" => mentry.key, "path" => path, "generator" => mentry.source.command, "reason" => reason }
|
|
162
146
|
end
|
|
163
147
|
end
|
|
164
148
|
end
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
|
-
module
|
|
4
|
+
module Core
|
|
5
5
|
module Freshness
|
|
6
6
|
# Value object describing the freshness annotation attached to an Envelope
|
|
7
|
-
# after a currency evaluation (ADR 0099 — was
|
|
7
|
+
# after a currency evaluation (ADR 0099 — was Core::Freshness).
|
|
8
8
|
#
|
|
9
9
|
# Note on wire format: `#to_h_for_wire` is intentionally narrower than the
|
|
10
10
|
# full field set. It emits the legacy keys ("stale", "stale_reason",
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
|
-
module
|
|
4
|
+
module Core
|
|
5
5
|
# Currency — "is the stored data stale relative to its source?" (ADR 0099).
|
|
6
6
|
# The home of the single Freshness evaluator and its Verdict value object.
|
|
7
|
-
# Distinct from
|
|
7
|
+
# Distinct from Core::Retention (GC dueness, Q2).
|
|
8
8
|
module Freshness
|
|
9
9
|
end
|
|
10
10
|
end
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
require "time"
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
|
-
module
|
|
4
|
+
module Core
|
|
5
5
|
module Retention
|
|
6
6
|
# Retention sweep reporter (ADR 0093/0099). Which entries are past their
|
|
7
7
|
# `retention:` ttl and the destructive action that applies. Age basis: file
|
|
8
|
-
# mtime. Only drop/archive. Renamed off the
|
|
9
|
-
#
|
|
8
|
+
# mtime. Only drop/archive. Renamed off the Core::Retention vs
|
|
9
|
+
# Manifest::Policy::Retention collision (ADR 0099).
|
|
10
10
|
class Sweep
|
|
11
11
|
def self.expired?(ttl_seconds:, mtime:, now:)
|
|
12
12
|
return false if ttl_seconds.nil? || mtime.nil?
|
|
@@ -20,16 +20,16 @@ module Textus
|
|
|
20
20
|
@clock = clock
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
def call(prefix: nil,
|
|
23
|
+
def call(prefix: nil, lane: nil)
|
|
24
24
|
@manifest.data.entries
|
|
25
|
-
.select { |m| matches?(m, prefix: prefix,
|
|
25
|
+
.select { |m| matches?(m, prefix: prefix, lane: lane) }
|
|
26
26
|
.flat_map { |m| rows_for(m) }
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
private
|
|
30
30
|
|
|
31
|
-
def matches?(mentry, prefix:,
|
|
32
|
-
return false if
|
|
31
|
+
def matches?(mentry, prefix:, lane:)
|
|
32
|
+
return false if lane && mentry.lane != lane
|
|
33
33
|
return false if prefix && !Textus::Key::Matching.matches_prefix?(
|
|
34
34
|
mentry.key, prefix, nested: mentry.is_a?(Textus::Manifest::Entry::Nested)
|
|
35
35
|
)
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
|
-
module
|
|
4
|
+
module Core
|
|
5
5
|
# Retention — "is the entry old enough to retire?" (Q2, ADR 0093/0099).
|
|
6
6
|
# GC dueness, orthogonal to Freshness (content currency). The reporter is
|
|
7
|
-
#
|
|
7
|
+
# Core::Retention::Sweep; the manifest rule policy is Manifest::Policy::Retention.
|
|
8
8
|
module Retention
|
|
9
9
|
end
|
|
10
10
|
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
require "digest"
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
|
-
module
|
|
4
|
+
module Core
|
|
5
5
|
# Pure value object representing a published-file sentinel. Holds the
|
|
6
6
|
# recorded target path, source path, sha256 checksum, and publish mode.
|
|
7
7
|
# Has no filesystem I/O — path layout and persistence live in
|
|
@@ -8,7 +8,7 @@ module Textus
|
|
|
8
8
|
# verb reported.
|
|
9
9
|
class GeneratorDrift < Check
|
|
10
10
|
def call
|
|
11
|
-
gen = Textus::
|
|
11
|
+
gen = Textus::Core::Freshness::Evaluator.new(
|
|
12
12
|
manifest: manifest,
|
|
13
13
|
file_stat: Textus::Ports::Storage::FileStat.new,
|
|
14
14
|
clock: Textus::Ports::Clock.new,
|
|
@@ -8,7 +8,7 @@ module Textus
|
|
|
8
8
|
next unless entry.nested?
|
|
9
9
|
next if entry.publish_mode.keyless? # publish_tree files are opaque payload, never keys (ADR 0047)
|
|
10
10
|
|
|
11
|
-
base = File.join(root,
|
|
11
|
+
base = File.join(root, Textus::Key::Path.normalize_relative_path(entry.path))
|
|
12
12
|
next unless File.directory?(base)
|
|
13
13
|
|
|
14
14
|
check_all_paths(entry, base, out)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Doctor
|
|
3
|
+
class Check
|
|
4
|
+
class NotebookSources < Check
|
|
5
|
+
def call
|
|
6
|
+
issues = []
|
|
7
|
+
manifest.resolver.enumerate.each do |row|
|
|
8
|
+
next unless row[:key].start_with?("notebook.notes.")
|
|
9
|
+
next unless row[:path] && File.exist?(row[:path])
|
|
10
|
+
|
|
11
|
+
sources = parse_sources(row[:path])
|
|
12
|
+
sources.each do |raw_key|
|
|
13
|
+
next if raw_entry_exists?(raw_key)
|
|
14
|
+
|
|
15
|
+
issues << {
|
|
16
|
+
"code" => "notebook.source_missing",
|
|
17
|
+
"level" => "warning",
|
|
18
|
+
"subject" => row[:key],
|
|
19
|
+
"message" => "notebook entry '#{row[:key]}' references raw key '#{raw_key}' " \
|
|
20
|
+
"which does not exist in the store",
|
|
21
|
+
"fix" => "re-ingest the source: textus ingest ..., or remove the stale sources: entry",
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
issues
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def parse_sources(path)
|
|
31
|
+
content = File.read(path)
|
|
32
|
+
match = content.match(/\A---\n(.*?)\n---/m)
|
|
33
|
+
return [] unless match
|
|
34
|
+
|
|
35
|
+
front = YAML.safe_load(match[1])
|
|
36
|
+
Array(front&.dig("sources"))
|
|
37
|
+
rescue StandardError
|
|
38
|
+
[]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def raw_entry_exists?(key)
|
|
42
|
+
path = manifest.resolver.resolve(key).path
|
|
43
|
+
File.exist?(path)
|
|
44
|
+
rescue Textus::UnknownKey, Textus::Error
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -8,10 +8,10 @@ module Textus
|
|
|
8
8
|
# corruption (the accept gate already refuses them).
|
|
9
9
|
class ProposalTargets < Check
|
|
10
10
|
def call
|
|
11
|
-
queue = manifest.policy.
|
|
11
|
+
queue = manifest.policy.queue_lane
|
|
12
12
|
return [] unless queue
|
|
13
13
|
|
|
14
|
-
dispatch(:list,
|
|
14
|
+
dispatch(:list, lane: queue).filter_map { |row| issue_for(row["key"]) }
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
private
|
|
@@ -20,7 +20,7 @@ module Textus
|
|
|
20
20
|
target = dispatch(:get, key).meta&.dig("proposal", "target_key")
|
|
21
21
|
return nil if target.nil? # not a proposal entry — skip
|
|
22
22
|
|
|
23
|
-
zone = manifest.resolver.resolve(target).entry.
|
|
23
|
+
zone = manifest.resolver.resolve(target).entry.lane
|
|
24
24
|
return nil if manifest.policy.declared_kind(zone.to_s) == :canon
|
|
25
25
|
|
|
26
26
|
{
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Doctor
|
|
3
|
+
class Check
|
|
4
|
+
class RawAssetPaths < Check
|
|
5
|
+
def call
|
|
6
|
+
raw_lane = manifest.policy.lanes_of_kind(:raw).first
|
|
7
|
+
return [] unless raw_lane
|
|
8
|
+
|
|
9
|
+
issues = []
|
|
10
|
+
manifest.resolver.enumerate.each do |row|
|
|
11
|
+
next unless row[:key].start_with?("raw.")
|
|
12
|
+
next unless row[:path] && File.exist?(row[:path])
|
|
13
|
+
|
|
14
|
+
raw_content = load_content(row[:path])
|
|
15
|
+
next unless raw_content.is_a?(Hash)
|
|
16
|
+
|
|
17
|
+
asset = raw_content["asset"]
|
|
18
|
+
next unless asset.is_a?(String) && !asset.empty?
|
|
19
|
+
|
|
20
|
+
asset_path = find_asset_path(asset)
|
|
21
|
+
next if File.exist?(asset_path)
|
|
22
|
+
|
|
23
|
+
issues << {
|
|
24
|
+
"code" => "raw_asset.missing_file",
|
|
25
|
+
"level" => "error",
|
|
26
|
+
"subject" => row[:key],
|
|
27
|
+
"message" => "raw entry '#{row[:key]}' references asset '#{asset}' " \
|
|
28
|
+
"which does not exist at #{asset_path}",
|
|
29
|
+
"fix" => "re-ingest the asset: textus key-delete #{row[:key]}, then textus ingest",
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
issues
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def load_content(path)
|
|
38
|
+
require "yaml"
|
|
39
|
+
YAML.safe_load_file(path)
|
|
40
|
+
rescue StandardError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def find_asset_path(asset_rel)
|
|
45
|
+
File.join(root, "assets", asset_rel)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -2,7 +2,7 @@ module Textus
|
|
|
2
2
|
module Doctor
|
|
3
3
|
class Check
|
|
4
4
|
# Flags entries whose key is matched by two or more rule blocks of the
|
|
5
|
-
# SAME specificity in the same slot (lifecycle /
|
|
5
|
+
# SAME specificity in the same slot (lifecycle / handler_permit /
|
|
6
6
|
# guard / materialize). Ties are non-deterministic in the parser's pick step, so
|
|
7
7
|
# they're a configuration smell — surface them.
|
|
8
8
|
class RuleAmbiguity < Check
|
|
@@ -26,7 +26,7 @@ module Textus
|
|
|
26
26
|
carriers = matches.select { |b| b.public_send(slot) }
|
|
27
27
|
return [] if carriers.length < 2
|
|
28
28
|
|
|
29
|
-
by_specificity = carriers.group_by { |b| Textus::
|
|
29
|
+
by_specificity = carriers.group_by { |b| Textus::Manifest::Policy::Matcher.specificity(b.match) }
|
|
30
30
|
tied = by_specificity.values.select { |group| group.length > 1 }
|
|
31
31
|
tied.map { |group| issue_for(mentry, slot, group) }
|
|
32
32
|
end
|
|
@@ -3,8 +3,14 @@ module Textus
|
|
|
3
3
|
class Check
|
|
4
4
|
class SchemaViolations < Check
|
|
5
5
|
def call
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
result = Textus::Doctor::Validator.new(
|
|
7
|
+
reader: ->(key, ctnr, c) { Textus::Action::Get.new(key: key).call(container: ctnr, call: c) },
|
|
8
|
+
manifest: @container.manifest,
|
|
9
|
+
audit_log: @container.audit_log,
|
|
10
|
+
schema_for: ->(name) { @container.schemas.fetch_or_nil(name) },
|
|
11
|
+
).call(container: @container, call: Textus::Call.build(role: Textus::Role::DEFAULT))
|
|
12
|
+
|
|
13
|
+
result["violations"].map do |v|
|
|
8
14
|
fix = v["expected"] &&
|
|
9
15
|
"field '#{v["field"]}' should be written by '#{v["expected"]}' (last writer: #{v["last_writer"]})"
|
|
10
16
|
{
|
data/lib/textus/doctor/check.rb
CHANGED
|
@@ -14,8 +14,9 @@ module Textus
|
|
|
14
14
|
.downcase
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
def initialize(container)
|
|
17
|
+
def initialize(container, role: Textus::Role::DEFAULT)
|
|
18
18
|
@container = container
|
|
19
|
+
@role = role
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
def call
|
|
@@ -26,16 +27,17 @@ module Textus
|
|
|
26
27
|
|
|
27
28
|
def root = @container.root
|
|
28
29
|
def manifest = @container.manifest
|
|
29
|
-
def rpc = @container.rpc
|
|
30
30
|
|
|
31
|
-
# Dispatch a verb through
|
|
31
|
+
# Dispatch a verb through Gate.
|
|
32
32
|
def dispatch(verb, *args, **kwargs)
|
|
33
|
-
Textus::
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
)
|
|
33
|
+
klass = Textus::Action::VERBS[verb]
|
|
34
|
+
spec = klass.contract if klass.respond_to?(:contract?) && klass.contract?
|
|
35
|
+
inputs = spec ? Textus::Contract::Binder.inputs_from_ordered(spec, args, kwargs) : kwargs
|
|
36
|
+
cmd_class = Textus::Gate::VERB_COMMAND.fetch(verb)
|
|
37
|
+
merged = inputs.merge(role: @role)
|
|
38
|
+
filled = cmd_class.members.to_h { |m| [m, merged.key?(m) ? merged[m] : nil] }
|
|
39
|
+
cmd = cmd_class.new(**filled)
|
|
40
|
+
@container.gate.dispatch(cmd)
|
|
39
41
|
end
|
|
40
42
|
end
|
|
41
43
|
end
|
|
@@ -1,27 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Textus
|
|
2
|
-
module
|
|
4
|
+
module Doctor
|
|
3
5
|
class Validator
|
|
4
6
|
def initialize(reader:, manifest:, audit_log:, schema_for:)
|
|
5
|
-
@reader
|
|
6
|
-
@manifest
|
|
7
|
+
@reader = reader
|
|
8
|
+
@manifest = manifest
|
|
7
9
|
@audit_log = audit_log
|
|
8
10
|
@schema_for = schema_for
|
|
9
11
|
end
|
|
10
12
|
|
|
11
|
-
def call
|
|
13
|
+
def call(container:, call:)
|
|
14
|
+
@container = container
|
|
15
|
+
@call = call
|
|
12
16
|
violations = []
|
|
13
17
|
check_content_violations(violations)
|
|
14
18
|
check_role_authority_violations(violations)
|
|
15
|
-
{ "protocol" => PROTOCOL, "ok" => violations.empty?, "violations" => violations }
|
|
19
|
+
{ "protocol" => Textus::PROTOCOL, "ok" => violations.empty?, "violations" => violations }
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
private
|
|
19
23
|
|
|
20
24
|
def check_content_violations(violations)
|
|
21
25
|
@manifest.resolver.enumerate.each do |row|
|
|
22
|
-
key
|
|
26
|
+
key = row[:key]
|
|
23
27
|
mentry = row[:manifest_entry]
|
|
24
|
-
env
|
|
28
|
+
env = fetch_envelope(key, violations) or next
|
|
25
29
|
schema = mentry.schema && @schema_for.call(mentry.schema)
|
|
26
30
|
next unless schema
|
|
27
31
|
|
|
@@ -42,7 +46,7 @@ module Textus
|
|
|
42
46
|
next unless schema
|
|
43
47
|
|
|
44
48
|
env = begin
|
|
45
|
-
@reader.
|
|
49
|
+
@reader.call(row[:key], @container, @call)
|
|
46
50
|
rescue StandardError
|
|
47
51
|
next
|
|
48
52
|
end
|
|
@@ -54,19 +58,24 @@ module Textus
|
|
|
54
58
|
last_writer = @audit_log.last_writer_for(key)
|
|
55
59
|
return if last_writer.nil?
|
|
56
60
|
|
|
57
|
-
last_writer_is_authority =
|
|
58
|
-
|
|
61
|
+
last_writer_is_authority =
|
|
62
|
+
@manifest.policy.roles_with_capability("author").include?(last_writer)
|
|
59
63
|
env.meta.each_key do |field|
|
|
60
64
|
owner = schema.maintained_by(field)
|
|
61
65
|
next if owner.nil? || last_writer == owner || last_writer_is_authority
|
|
62
66
|
|
|
63
|
-
violations << {
|
|
64
|
-
|
|
67
|
+
violations << {
|
|
68
|
+
"key" => key,
|
|
69
|
+
"code" => "role_authority",
|
|
70
|
+
"field" => field,
|
|
71
|
+
"expected" => owner,
|
|
72
|
+
"last_writer" => last_writer,
|
|
73
|
+
}
|
|
65
74
|
end
|
|
66
75
|
end
|
|
67
76
|
|
|
68
77
|
def fetch_envelope(key, violations)
|
|
69
|
-
@reader.
|
|
78
|
+
@reader.call(key, @container, @call)
|
|
70
79
|
rescue Textus::Error => e
|
|
71
80
|
violations << { "key" => key, "code" => e.code, "message" => e.message }
|
|
72
81
|
nil
|
data/lib/textus/doctor.rb
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
require "timeout"
|
|
2
|
-
|
|
3
1
|
module Textus
|
|
4
2
|
# Health check for a Textus store. Returns a JSON-friendly Hash envelope
|
|
5
3
|
# with an `issues` array and a summary. Each issue is a Hash with
|
|
6
4
|
# `code`, `level`, `subject`, `message`, and optionally `fix`.
|
|
7
5
|
module Doctor
|
|
8
6
|
LEVELS = %w[error warning info].freeze
|
|
9
|
-
DOCTOR_CHECK_TIMEOUT_SECONDS = 2
|
|
10
7
|
|
|
11
8
|
CHECKS = [
|
|
12
9
|
Check::ProtocolVersion,
|
|
@@ -14,26 +11,25 @@ module Textus
|
|
|
14
11
|
Check::Schemas,
|
|
15
12
|
Check::SchemaParseError,
|
|
16
13
|
Check::Templates,
|
|
17
|
-
Check::Hooks,
|
|
18
|
-
Check::IntakeRegistration,
|
|
19
14
|
Check::IllegalKeys,
|
|
20
15
|
Check::Sentinels,
|
|
21
16
|
Check::AuditLog,
|
|
22
17
|
Check::UnownedSchemaFields,
|
|
23
18
|
Check::SchemaViolations,
|
|
24
19
|
Check::RuleAmbiguity,
|
|
25
|
-
Check::HandlerAllowlist,
|
|
26
20
|
Check::OrphanedPublishTargets,
|
|
27
21
|
Check::PublishTreeIndexOverlap,
|
|
28
22
|
Check::ProposalTargets,
|
|
29
23
|
Check::GeneratorDrift,
|
|
24
|
+
Check::RawAssetPaths,
|
|
25
|
+
Check::NotebookSources,
|
|
30
26
|
].freeze
|
|
31
27
|
|
|
32
28
|
ALL_CHECKS = CHECKS.map(&:name_key).freeze
|
|
33
29
|
|
|
34
30
|
module_function
|
|
35
31
|
|
|
36
|
-
def build(container:, checks: nil)
|
|
32
|
+
def build(container:, checks: nil, role: Textus::Role::DEFAULT)
|
|
37
33
|
selected_keys = checks ? Array(checks).map(&:to_s) : ALL_CHECKS
|
|
38
34
|
unknown = selected_keys - ALL_CHECKS
|
|
39
35
|
unless unknown.empty?
|
|
@@ -43,8 +39,7 @@ module Textus
|
|
|
43
39
|
end
|
|
44
40
|
|
|
45
41
|
selected = CHECKS.select { |c| selected_keys.include?(c.name_key) }
|
|
46
|
-
issues = selected.flat_map { |c| c.new(container).call }
|
|
47
|
-
issues.concat(run_registered_checks(container))
|
|
42
|
+
issues = selected.flat_map { |c| c.new(container, role:).call }
|
|
48
43
|
|
|
49
44
|
summary = LEVELS.to_h { |l| [l, issues.count { |i| i["level"] == l }] }
|
|
50
45
|
{
|
|
@@ -54,40 +49,5 @@ module Textus
|
|
|
54
49
|
"summary" => summary,
|
|
55
50
|
}
|
|
56
51
|
end
|
|
57
|
-
|
|
58
|
-
def run_registered_checks(container)
|
|
59
|
-
container.rpc.names(:validate).flat_map { |name| invoke_registered_check(container, name) }
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def invoke_registered_check(container, name)
|
|
63
|
-
result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) do
|
|
64
|
-
container.rpc.invoke(:validate, name, caps: container)
|
|
65
|
-
end
|
|
66
|
-
return result.map { |h| h.transform_keys(&:to_s) } if result.is_a?(Array)
|
|
67
|
-
|
|
68
|
-
[fail_issue(name, code: "doctor_check.bad_return",
|
|
69
|
-
message: "doctor_check '#{name}' returned #{result.class} (expected Array)",
|
|
70
|
-
fix: "return an array of issue hashes from the doctor_check block")]
|
|
71
|
-
rescue Timeout::Error
|
|
72
|
-
[fail_issue(name, code: "doctor_check.timeout",
|
|
73
|
-
message: "doctor_check '#{name}' exceeded #{DOCTOR_CHECK_TIMEOUT_SECONDS}s",
|
|
74
|
-
fix: "shorten the check or split it into smaller checks")]
|
|
75
|
-
rescue StandardError => e
|
|
76
|
-
[fail_issue(name, code: "doctor_check.failed",
|
|
77
|
-
message: "#{e.class}: #{e.message}",
|
|
78
|
-
fix: "fix the :validate hook in .textus/hooks/")]
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def fail_issue(name, code:, message:, fix:)
|
|
82
|
-
{
|
|
83
|
-
"code" => code,
|
|
84
|
-
"level" => "error",
|
|
85
|
-
"subject" => name.to_s,
|
|
86
|
-
"message" => message,
|
|
87
|
-
"fix" => fix,
|
|
88
|
-
}
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
private_class_method :run_registered_checks, :invoke_registered_check, :fail_issue
|
|
92
52
|
end
|
|
93
53
|
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Envelope
|
|
3
|
+
# Read-only counterpart to EnvelopeWriter. Resolves a key, reads the
|
|
4
|
+
# bytes, parses them via the format strategy, and hands back an
|
|
5
|
+
# Envelope. Used by Mv (pre-move inspection) and by EnvelopeWriter
|
|
6
|
+
# (existing-uid lookup for the uid-preservation step in #put).
|
|
7
|
+
#
|
|
8
|
+
# No audit, no events, no permission checks — those live one layer up.
|
|
9
|
+
class Reader
|
|
10
|
+
def self.from(container:)
|
|
11
|
+
new(file_store: container.file_store, manifest: container.manifest)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(file_store:, manifest:)
|
|
15
|
+
@file_store = file_store
|
|
16
|
+
@manifest = manifest
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def read(key)
|
|
20
|
+
res = @manifest.resolver.resolve(key)
|
|
21
|
+
path = res.path
|
|
22
|
+
return nil unless @file_store.exists?(path)
|
|
23
|
+
|
|
24
|
+
mentry = res.entry
|
|
25
|
+
raw = @file_store.read(path)
|
|
26
|
+
parsed = Format.for(mentry.format).parse(raw, path: path)
|
|
27
|
+
Textus::Envelope.build(
|
|
28
|
+
key: key, mentry: mentry, path: path,
|
|
29
|
+
meta: parsed["_meta"], body: parsed["body"],
|
|
30
|
+
etag: Etag.for_bytes(raw), content: parsed["content"]
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def existing_uid(key)
|
|
35
|
+
env = read(key)
|
|
36
|
+
env&.uid
|
|
37
|
+
rescue StandardError
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def exists?(key)
|
|
42
|
+
@file_store.exists?(@manifest.resolver.resolve(key).path)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|