textus 0.54.2 → 0.55.2
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 +38 -1
- data/README.md +17 -10
- data/SPEC.md +39 -11
- data/docs/architecture/README.md +23 -11
- data/docs/reference/conventions.md +5 -2
- data/exe/textus +1 -1
- data/lib/textus/boot.rb +64 -93
- data/lib/textus/dispatch/binder.rb +48 -0
- data/lib/textus/dispatch/contracts.rb +63 -0
- data/lib/textus/dispatch/handler_registry.rb +21 -0
- data/lib/textus/dispatch/middleware/audit_index.rb +51 -0
- data/lib/textus/dispatch/middleware/auth.rb +40 -0
- data/lib/textus/dispatch/middleware/base.rb +26 -0
- data/lib/textus/dispatch/middleware/binder.rb +20 -0
- data/lib/textus/dispatch/middleware/cascade.rb +53 -0
- data/lib/textus/dispatch/pipeline.rb +35 -0
- data/lib/textus/doctor/check/audit_log.rb +2 -2
- data/lib/textus/doctor/check/generator_drift.rb +4 -4
- 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/{notebook_sources.rb → scratchpad_sources.rb} +10 -5
- 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 +9 -10
- data/lib/textus/doctor.rb +2 -2
- data/lib/textus/errors.rb +8 -0
- data/lib/textus/format/base.rb +34 -10
- data/lib/textus/format/json.rb +5 -27
- data/lib/textus/format/markdown.rb +5 -27
- data/lib/textus/format/text.rb +5 -4
- data/lib/textus/format/yaml.rb +30 -27
- data/lib/textus/format.rb +22 -1
- data/lib/textus/handlers/maintenance/boot_store.rb +15 -0
- data/lib/textus/handlers/maintenance/doctor_store.rb +15 -0
- data/lib/textus/handlers/maintenance/drain_store.rb +21 -0
- data/lib/textus/handlers/maintenance/ingest_entry.rb +159 -0
- data/lib/textus/handlers/maintenance/jobs_action.rb +21 -0
- data/lib/textus/handlers/maintenance/published_entries.rb +17 -0
- data/lib/textus/handlers/maintenance/rule_explain.rb +77 -0
- data/lib/textus/handlers/maintenance/rule_lint.rb +54 -0
- data/lib/textus/handlers/maintenance/rule_list.rb +32 -0
- data/lib/textus/handlers/maintenance/schema_envelope.rb +19 -0
- data/lib/textus/handlers/read/audit_entries.rb +48 -0
- data/lib/textus/handlers/read/blame_entry.rb +71 -0
- data/lib/textus/handlers/read/deps_entry.rb +17 -0
- data/lib/textus/handlers/read/get_entry.rb +68 -0
- data/lib/textus/handlers/read/list_keys.rb +36 -0
- data/lib/textus/handlers/read/pulse_entries.rb +66 -0
- data/lib/textus/handlers/read/rdeps_entry.rb +21 -0
- data/lib/textus/handlers/read/uid_entry.rb +18 -0
- data/lib/textus/handlers/read/where_entry.rb +18 -0
- data/lib/textus/handlers/write/accept_proposal.rb +39 -0
- data/lib/textus/handlers/write/data_mv.rb +55 -0
- data/lib/textus/handlers/write/delete_key.rb +17 -0
- data/lib/textus/handlers/write/enqueue_job.rb +27 -0
- data/lib/textus/handlers/write/key_delete_prefix.rb +32 -0
- data/lib/textus/handlers/write/key_mv_prefix.rb +45 -0
- data/lib/textus/handlers/write/move_key.rb +80 -0
- data/lib/textus/handlers/write/propose_entry.rb +29 -0
- data/lib/textus/handlers/write/put_entry.rb +29 -0
- data/lib/textus/handlers/write/reject_proposal.rb +29 -0
- data/lib/textus/init.rb +5 -7
- data/lib/textus/jobs.rb +3 -9
- data/lib/textus/manifest/capabilities.rb +4 -4
- data/lib/textus/manifest/entry/base.rb +3 -3
- data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
- data/lib/textus/manifest/entry/publish/to_paths.rb +2 -2
- data/lib/textus/manifest/policy/predicates/author_held.rb +22 -0
- data/lib/textus/manifest/policy/predicates/etag_match.rb +18 -0
- data/lib/textus/manifest/policy/predicates/fresh_within.rb +13 -0
- data/lib/textus/manifest/policy/predicates/lane_deletable_by.rb +31 -0
- data/lib/textus/manifest/policy/predicates/lane_writable_by.rb +23 -0
- data/lib/textus/manifest/policy/predicates/raw_lane_ingest_only.rb +25 -0
- data/lib/textus/manifest/policy/predicates/raw_write_once.rb +24 -0
- data/lib/textus/manifest/policy/predicates/schema_valid.rb +41 -0
- data/lib/textus/manifest/policy/predicates/target_is_canon.rb +20 -0
- data/lib/textus/manifest/policy/predicates.rb +54 -0
- data/lib/textus/manifest/policy/retention.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/orchestration.rb +55 -0
- data/lib/textus/{ports → port}/audit_log.rb +46 -6
- 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/{core → port}/sentinel.rb +1 -6
- data/lib/textus/{ports → port}/sentinel_store.rb +4 -4
- data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
- data/lib/textus/port/storage/file_store.rb +49 -0
- data/lib/textus/port/storage/interface.rb +17 -0
- data/lib/textus/port/store.rb +149 -0
- data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
- data/lib/textus/produce/engine.rb +1 -11
- data/lib/textus/produce/publisher.rb +21 -0
- data/lib/textus/schema/registry.rb +42 -0
- data/lib/textus/schema/tools.rb +7 -10
- data/lib/textus/store/container.rb +173 -0
- data/lib/textus/store/cursor.rb +26 -0
- data/lib/textus/store/entry/reader.rb +47 -0
- data/lib/textus/store/entry/writer.rb +219 -0
- data/lib/textus/store/envelope/meta.rb +61 -0
- data/lib/textus/store/freshness/drift_detector.rb +93 -0
- data/lib/textus/store/freshness/evaluator.rb +20 -0
- data/lib/textus/store/freshness/ttl_evaluator.rb +57 -0
- data/lib/textus/{core → store}/freshness/verdict.rb +1 -11
- data/lib/textus/store/freshness.rb +8 -0
- data/lib/textus/store/index/builder.rb +76 -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 +124 -0
- data/lib/textus/store/jobs/queue.rb +162 -0
- data/lib/textus/store/jobs/registry.rb +19 -0
- data/lib/textus/store/jobs/retention/base.rb +52 -0
- data/lib/textus/store/jobs/retention/sweep.rb +55 -0
- data/lib/textus/store/jobs/retention.rb +8 -0
- data/lib/textus/store/jobs/sweep.rb +21 -0
- data/lib/textus/store/jobs/worker.rb +64 -0
- data/lib/textus/store/layout.rb +97 -0
- data/lib/textus/store.rb +63 -32
- 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 +14 -68
- data/lib/textus/surface/cli/sources.rb +41 -0
- data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +5 -6
- data/lib/textus/{surfaces → surface}/cli/verb/get.rb +7 -5
- 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/surface/cli/verb/put.rb +27 -0
- 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/surface/mcp/catalog.rb +58 -0
- data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -11
- data/lib/textus/surface/mcp/projector.rb +20 -0
- data/lib/textus/{surfaces → surface}/mcp/server.rb +24 -35
- data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
- 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/{core → value}/duration.rb +1 -4
- data/lib/textus/value/envelope.rb +90 -0
- data/lib/textus/value/etag.rb +39 -0
- data/lib/textus/value/payload.rb +7 -0
- data/lib/textus/value/result.rb +46 -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/verb_registry.rb +417 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus/workflow/loader.rb +4 -4
- data/lib/textus/workflow/runner.rb +11 -33
- data/lib/textus.rb +4 -69
- metadata +142 -105
- data/lib/textus/action/accept.rb +0 -44
- data/lib/textus/action/audit.rb +0 -131
- data/lib/textus/action/base.rb +0 -42
- data/lib/textus/action/blame.rb +0 -97
- data/lib/textus/action/boot.rb +0 -17
- data/lib/textus/action/data_mv.rb +0 -69
- data/lib/textus/action/deps.rb +0 -34
- data/lib/textus/action/doctor.rb +0 -24
- data/lib/textus/action/drain.rb +0 -39
- data/lib/textus/action/enqueue.rb +0 -53
- data/lib/textus/action/get.rb +0 -78
- data/lib/textus/action/ingest.rb +0 -203
- data/lib/textus/action/jobs.rb +0 -36
- data/lib/textus/action/key_delete.rb +0 -36
- data/lib/textus/action/key_delete_prefix.rb +0 -44
- data/lib/textus/action/key_mv.rb +0 -129
- data/lib/textus/action/key_mv_prefix.rb +0 -57
- data/lib/textus/action/list.rb +0 -42
- data/lib/textus/action/propose.rb +0 -52
- data/lib/textus/action/published.rb +0 -24
- data/lib/textus/action/pulse.rb +0 -56
- data/lib/textus/action/put.rb +0 -52
- data/lib/textus/action/rdeps.rb +0 -39
- data/lib/textus/action/reject.rb +0 -37
- data/lib/textus/action/rule_explain.rb +0 -91
- data/lib/textus/action/rule_lint.rb +0 -68
- data/lib/textus/action/rule_list.rb +0 -42
- data/lib/textus/action/schema_envelope.rb +0 -29
- data/lib/textus/action/uid.rb +0 -33
- data/lib/textus/action/where.rb +0 -36
- 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/contract.rb +0 -174
- data/lib/textus/core/freshness/evaluator.rb +0 -150
- data/lib/textus/core/freshness.rb +0 -11
- data/lib/textus/core/retention/sweep.rb +0 -57
- data/lib/textus/core/retention.rb +0 -11
- 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/gate/auth.rb +0 -227
- data/lib/textus/gate.rb +0 -116
- 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/ports/storage/file_store.rb +0 -26
- data/lib/textus/role.rb +0 -36
- data/lib/textus/schemas.rb +0 -54
- data/lib/textus/session.rb +0 -35
- data/lib/textus/surfaces/cli/verb/put.rb +0 -30
- data/lib/textus/surfaces/mcp/catalog.rb +0 -111
- data/lib/textus/surfaces/role_scope.rb +0 -34
- data/lib/textus/types.rb +0 -15
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
require "time"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Core
|
|
5
|
-
module Freshness
|
|
6
|
-
# The single currency evaluator (ADR 0099). Answers "is the stored data
|
|
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).
|
|
12
|
-
class Evaluator
|
|
13
|
-
def initialize(manifest:, file_stat:, clock:)
|
|
14
|
-
@manifest = manifest
|
|
15
|
-
@file_stat = file_stat
|
|
16
|
-
@clock = clock
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
# Per-entry currency Verdict driven by the retention rule TTL (if any).
|
|
20
|
-
def verdict(mentry)
|
|
21
|
-
ttl = @manifest.rules.for(mentry.key).retention&.ttl_seconds
|
|
22
|
-
return fresh if ttl.nil?
|
|
23
|
-
|
|
24
|
-
stale = age_stale?(file_basis(mentry), ttl)
|
|
25
|
-
Verdict.build(stale: stale, reason: stale ? "ttl exceeded" : nil, fetching: false)
|
|
26
|
-
end
|
|
27
|
-
|
|
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)
|
|
31
|
-
end
|
|
32
|
-
|
|
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)
|
|
37
|
-
path = @manifest.resolver.resolve(mentry.key).path
|
|
38
|
-
return nil unless @file_stat.exists?(path)
|
|
39
|
-
|
|
40
|
-
@file_stat.mtime(path)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# Generator-drift rows for one entry (replaces Staleness::GeneratorCheck#
|
|
44
|
-
# rows_for) — consumed by the doctor generator_drift check.
|
|
45
|
-
def drift_rows(mentry)
|
|
46
|
-
return [] unless drift_applicable?(mentry)
|
|
47
|
-
|
|
48
|
-
path = Textus::Key::Path.resolve(@manifest.data, mentry)
|
|
49
|
-
reason = drift_reason(mentry, path)
|
|
50
|
-
reason ? [drift_row(mentry, path, reason)] : []
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
private
|
|
54
|
-
|
|
55
|
-
def fresh = Verdict.build(stale: false, reason: nil, fetching: false)
|
|
56
|
-
|
|
57
|
-
def due?(mentry, prefix:, lane:)
|
|
58
|
-
return false if lane && mentry.lane != lane
|
|
59
|
-
return false if prefix && !mentry.key.start_with?(prefix)
|
|
60
|
-
|
|
61
|
-
ttl = @manifest.rules.for(mentry.key).retention&.ttl_seconds
|
|
62
|
-
return false if ttl.nil?
|
|
63
|
-
|
|
64
|
-
path = @manifest.resolver.resolve(mentry.key).path
|
|
65
|
-
return true unless @file_stat.exists?(path)
|
|
66
|
-
|
|
67
|
-
age_stale?(file_basis(mentry), ttl)
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
# The one age comparison. A never-recorded entry (nil basis) is stale.
|
|
71
|
-
def age_stale?(basis, ttl)
|
|
72
|
-
return true if basis.nil?
|
|
73
|
-
|
|
74
|
-
(@clock.now - basis).to_i > ttl
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
# --- generator drift (lifted from Staleness::GeneratorCheck) ---
|
|
78
|
-
|
|
79
|
-
def drift_applicable?(mentry) = mentry.external?
|
|
80
|
-
|
|
81
|
-
def drift_reason(mentry, path)
|
|
82
|
-
return "derived entry has never been generated" unless @file_stat.exists?(path)
|
|
83
|
-
|
|
84
|
-
generated_at = generated_at_of(mentry, path)
|
|
85
|
-
return "missing generated.at frontmatter" unless generated_at
|
|
86
|
-
|
|
87
|
-
gen_time = parse_time(generated_at)
|
|
88
|
-
return "unparseable generated.at: #{generated_at.inspect}" unless gen_time
|
|
89
|
-
|
|
90
|
-
offender = newest_source_after(mentry.source, gen_time)
|
|
91
|
-
"source '#{offender}' modified after generated.at" if offender
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def generated_at_of(mentry, path)
|
|
95
|
-
Format.for(mentry.format).parse(@file_stat.read(path), path: path)["_meta"].dig("generated", "at")
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def parse_time(str)
|
|
99
|
-
Time.parse(str.to_s)
|
|
100
|
-
rescue StandardError
|
|
101
|
-
nil
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def newest_source_after(external_src, gen_time)
|
|
105
|
-
Array(external_src.sources).each do |src|
|
|
106
|
-
offender = check_source(src, gen_time)
|
|
107
|
-
return offender if offender
|
|
108
|
-
end
|
|
109
|
-
nil
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def check_source(src, gen_time)
|
|
113
|
-
if src.match?(/\A[a-z0-9.][a-z0-9._-]*\z/) && !src.include?("/")
|
|
114
|
-
@manifest.resolver.enumerate(prefix: src).each do |row|
|
|
115
|
-
return src if @file_stat.mtime(row[:path]) > gen_time
|
|
116
|
-
end
|
|
117
|
-
nil
|
|
118
|
-
else
|
|
119
|
-
check_filesystem_source(src, gen_time)
|
|
120
|
-
end
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
def check_filesystem_source(src, gen_time)
|
|
124
|
-
abs = absolutize_source(src)
|
|
125
|
-
if @file_stat.directory?(abs)
|
|
126
|
-
dir_has_newer_file?(abs, gen_time) ? src : nil
|
|
127
|
-
elsif @file_stat.exists?(abs) && @file_stat.mtime(abs) > gen_time
|
|
128
|
-
src
|
|
129
|
-
end
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
def absolutize_source(src)
|
|
133
|
-
File.absolute_path?(src) ? src : File.join(File.dirname(@manifest.data.root), src)
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
def dir_has_newer_file?(abs, gen_time)
|
|
137
|
-
@file_stat.glob(File.join(abs, "**", "*")).any? do |fpath|
|
|
138
|
-
file?(fpath) && @file_stat.mtime(fpath) > gen_time
|
|
139
|
-
end
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def file?(fpath) = !@file_stat.directory?(fpath) && @file_stat.exists?(fpath)
|
|
143
|
-
|
|
144
|
-
def drift_row(mentry, path, reason)
|
|
145
|
-
{ "key" => mentry.key, "path" => path, "generator" => mentry.source.command, "reason" => reason }
|
|
146
|
-
end
|
|
147
|
-
end
|
|
148
|
-
end
|
|
149
|
-
end
|
|
150
|
-
end
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Core
|
|
5
|
-
# Currency — "is the stored data stale relative to its source?" (ADR 0099).
|
|
6
|
-
# The home of the single Freshness evaluator and its Verdict value object.
|
|
7
|
-
# Distinct from Core::Retention (GC dueness, Q2).
|
|
8
|
-
module Freshness
|
|
9
|
-
end
|
|
10
|
-
end
|
|
11
|
-
end
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
require "time"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Core
|
|
5
|
-
module Retention
|
|
6
|
-
# Retention sweep reporter (ADR 0093/0099). Which entries are past their
|
|
7
|
-
# `retention:` ttl and the destructive action that applies. Age basis: file
|
|
8
|
-
# mtime. Only drop/archive. Renamed off the Core::Retention vs
|
|
9
|
-
# Manifest::Policy::Retention collision (ADR 0099).
|
|
10
|
-
class Sweep
|
|
11
|
-
def self.expired?(ttl_seconds:, mtime:, now:)
|
|
12
|
-
return false if ttl_seconds.nil? || mtime.nil?
|
|
13
|
-
|
|
14
|
-
(now - mtime).to_i > ttl_seconds
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def initialize(manifest:, file_stat:, clock:)
|
|
18
|
-
@manifest = manifest
|
|
19
|
-
@file_stat = file_stat
|
|
20
|
-
@clock = clock
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def call(prefix: nil, lane: nil)
|
|
24
|
-
@manifest.data.entries
|
|
25
|
-
.select { |m| matches?(m, prefix: prefix, lane: lane) }
|
|
26
|
-
.flat_map { |m| rows_for(m) }
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
private
|
|
30
|
-
|
|
31
|
-
def matches?(mentry, prefix:, lane:)
|
|
32
|
-
return false if lane && mentry.lane != lane
|
|
33
|
-
return false if prefix && !Textus::Key::Matching.matches_prefix?(
|
|
34
|
-
mentry.key, prefix, nested: mentry.is_a?(Textus::Manifest::Entry::Nested)
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
true
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def rows_for(mentry)
|
|
41
|
-
policy = @manifest.rules.for(mentry.key).retention
|
|
42
|
-
return [] if policy.nil?
|
|
43
|
-
|
|
44
|
-
@manifest.resolver.enumerate(prefix: mentry.key).filter_map do |row|
|
|
45
|
-
path = row[:path]
|
|
46
|
-
next unless @file_stat.exists?(path)
|
|
47
|
-
next unless self.class.expired?(
|
|
48
|
-
ttl_seconds: policy.ttl_seconds, mtime: @file_stat.mtime(path), now: @clock.now,
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
{ "key" => row[:key], "path" => path, "action" => policy.action.to_s }
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Core
|
|
5
|
-
# Retention — "is the entry old enough to retire?" (Q2, ADR 0093/0099).
|
|
6
|
-
# GC dueness, orthogonal to Freshness (content currency). The reporter is
|
|
7
|
-
# Core::Retention::Sweep; the manifest rule policy is Manifest::Policy::Retention.
|
|
8
|
-
module Retention
|
|
9
|
-
end
|
|
10
|
-
end
|
|
11
|
-
end
|
data/lib/textus/cursor_store.rb
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
require "fileutils"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
# Per-role cursor cache under <root>/.state/cursors/<role>. A convenience so
|
|
5
|
-
# `textus pulse` (no --since) means "since I last looked". Gitignored;
|
|
6
|
-
# losing it just re-emits recent deltas, never corrupts the store. ADR 0036/0038.
|
|
7
|
-
class CursorStore
|
|
8
|
-
def initialize(root:, role:)
|
|
9
|
-
@path = Textus::Layout.cursor(root, role)
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
def read
|
|
13
|
-
Integer(File.read(@path).strip)
|
|
14
|
-
rescue Errno::ENOENT, ArgumentError
|
|
15
|
-
0
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def write(seq)
|
|
19
|
-
FileUtils.mkdir_p(File.dirname(@path))
|
|
20
|
-
File.write(@path, seq.to_s)
|
|
21
|
-
seq
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
end
|
|
@@ -1,46 +0,0 @@
|
|
|
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
|
|
@@ -1,209 +0,0 @@
|
|
|
1
|
-
require "fileutils"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
class Envelope
|
|
5
|
-
# Owns the write pipeline (validate, serialize, etag-check, write, audit).
|
|
6
|
-
# Talks to ports (FileStore, Schemas, AuditLog, Manifest) and an
|
|
7
|
-
# Reader for the existing-uid lookup.
|
|
8
|
-
#
|
|
9
|
-
# Invariant: every public method's final action is @audit_log.append(...).
|
|
10
|
-
#
|
|
11
|
-
# No permission check, no event firing — those belong to the caller
|
|
12
|
-
# (Write::Put / ::Delete / ::Mv).
|
|
13
|
-
class Writer
|
|
14
|
-
Payload = Data.define(:meta, :body, :content)
|
|
15
|
-
|
|
16
|
-
def self.from(container:, call:)
|
|
17
|
-
new(
|
|
18
|
-
file_store: container.file_store, manifest: container.manifest,
|
|
19
|
-
schemas: container.schemas, audit_log: container.audit_log,
|
|
20
|
-
call: call, reader: Reader.from(container: container)
|
|
21
|
-
)
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def initialize(file_store:, manifest:, schemas:, audit_log:, call:, reader:)
|
|
25
|
-
@file_store = file_store
|
|
26
|
-
@manifest = manifest
|
|
27
|
-
@schemas = schemas
|
|
28
|
-
@audit_log = audit_log
|
|
29
|
-
@call = call
|
|
30
|
-
@reader = reader
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def put(key, mentry:, payload:, if_etag: nil)
|
|
34
|
-
path = resolve_path(key)
|
|
35
|
-
meta, content = prepare_uid(mentry, payload, key)
|
|
36
|
-
bytes, eff_meta, eff_body, eff_content = serialize_entry(mentry, path, meta, payload, content)
|
|
37
|
-
enforce_name_match!(path, eff_meta, mentry.format)
|
|
38
|
-
validate_schema(mentry, eff_meta, eff_content)
|
|
39
|
-
etag_before = check_etag!(path, key, if_etag)
|
|
40
|
-
write_bytes(path, bytes)
|
|
41
|
-
envelope = build_envelope(key, mentry, path, eff_meta, eff_body, eff_content)
|
|
42
|
-
audit_put(key, etag_before, envelope.etag)
|
|
43
|
-
envelope
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def delete(key, mentry: nil, if_etag: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
47
|
-
# `mentry:` is accepted for symmetry with `put` / `move` and to
|
|
48
|
-
# leave room for future format-specific delete hooks; no field
|
|
49
|
-
# on it is needed today.
|
|
50
|
-
path = @manifest.resolver.resolve(key).path
|
|
51
|
-
raise UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)) unless @file_store.exists?(path)
|
|
52
|
-
|
|
53
|
-
etag_before = @file_store.etag(path)
|
|
54
|
-
raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before
|
|
55
|
-
|
|
56
|
-
@file_store.delete(path)
|
|
57
|
-
prune_empty_parents(path)
|
|
58
|
-
@audit_log.append(
|
|
59
|
-
role: @call.role, verb: "key_delete", key: key,
|
|
60
|
-
etag_before: etag_before, etag_after: nil,
|
|
61
|
-
extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
|
|
62
|
-
)
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def move(from_key:, to_key:, new_mentry:, if_etag: nil)
|
|
66
|
-
from_path = @manifest.resolver.resolve(from_key).path
|
|
67
|
-
to_path = @manifest.resolver.resolve(to_key).path
|
|
68
|
-
raise UnknownKey.new(from_key, suggestions: @manifest.resolver.suggestions_for(from_key)) unless @file_store.exists?(from_path)
|
|
69
|
-
|
|
70
|
-
etag_before = @file_store.etag(from_path)
|
|
71
|
-
raise EtagMismatch.new(from_key, if_etag, etag_before) if if_etag && if_etag != etag_before
|
|
72
|
-
|
|
73
|
-
FileUtils.mkdir_p(File.dirname(to_path))
|
|
74
|
-
FileUtils.mv(from_path, to_path)
|
|
75
|
-
prune_empty_parents(from_path)
|
|
76
|
-
basename = to_key.split(".").last
|
|
77
|
-
Format.for(new_mentry.format).rewrite_name(to_path, basename)
|
|
78
|
-
etag_after = Etag.for_file(to_path)
|
|
79
|
-
|
|
80
|
-
raw = @file_store.read(to_path)
|
|
81
|
-
parsed = Format.for(new_mentry.format).parse(raw, path: to_path)
|
|
82
|
-
envelope = Textus::Envelope.build(
|
|
83
|
-
key: to_key, mentry: new_mentry, path: to_path,
|
|
84
|
-
meta: parsed["_meta"], body: parsed["body"],
|
|
85
|
-
etag: etag_after, content: parsed["content"]
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
extras = {
|
|
89
|
-
"from_key" => from_key, "to_key" => to_key,
|
|
90
|
-
"from_path" => from_path, "to_path" => to_path,
|
|
91
|
-
"uid" => envelope.uid
|
|
92
|
-
}
|
|
93
|
-
extras["correlation_id"] = @call.correlation_id if @call.correlation_id
|
|
94
|
-
|
|
95
|
-
@audit_log.append(
|
|
96
|
-
role: @call.role, verb: "key_mv", key: to_key,
|
|
97
|
-
etag_before: etag_before, etag_after: etag_after,
|
|
98
|
-
extras: extras
|
|
99
|
-
)
|
|
100
|
-
|
|
101
|
-
envelope
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
private
|
|
105
|
-
|
|
106
|
-
# After a file leaves a directory (delete or move-source), remove any
|
|
107
|
-
# now-empty parent dirs so bulk move/delete doesn't accrue orphan dirs
|
|
108
|
-
# (F3 of #161). Floored at the entry's *zone directory* — a zone is a
|
|
109
|
-
# declared, first-class container, so its own dir is preserved even when
|
|
110
|
-
# momentarily empty; only the sub-dirs the bulk op carved out are
|
|
111
|
-
# pruned. Stops at the first non-empty ancestor, so a dir holding a
|
|
112
|
-
# `.gitkeep` or sibling entries survives. Best-effort: a lost race or a
|
|
113
|
-
# non-empty dir is silently fine, never fatal to the write.
|
|
114
|
-
def prune_empty_parents(path)
|
|
115
|
-
floor = zone_floor(path)
|
|
116
|
-
return unless floor
|
|
117
|
-
|
|
118
|
-
dir = File.dirname(path)
|
|
119
|
-
while dir.start_with?("#{floor}/") && Dir.empty?(dir)
|
|
120
|
-
Dir.rmdir(dir)
|
|
121
|
-
dir = File.dirname(dir)
|
|
122
|
-
end
|
|
123
|
-
rescue SystemCallError
|
|
124
|
-
nil
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
# The zone directory under which `path` lives (`<root>/zones/<zone>`),
|
|
128
|
-
# or nil if `path` is not under the store's zones tree.
|
|
129
|
-
def zone_floor(path)
|
|
130
|
-
zones_root = File.join(@manifest.data.root, "data")
|
|
131
|
-
prefix = "#{zones_root}/"
|
|
132
|
-
return nil unless path.start_with?(prefix)
|
|
133
|
-
|
|
134
|
-
zone_seg = path.delete_prefix(prefix).split("/").first
|
|
135
|
-
zone_seg && File.join(zones_root, zone_seg)
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
def ensure_uid(format, meta, content, existing_uid)
|
|
139
|
-
Textus::Format.for(format).inject_uid(meta, content, existing_uid)
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def enforce_name_match!(path, meta, format)
|
|
143
|
-
Textus::Format.for(format).enforce_name_match!(path, meta)
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def serialize_for_put(mentry:, path:, meta:, body:, content:)
|
|
147
|
-
Textus::Format.for(mentry.format).serialize_for_put(
|
|
148
|
-
meta: meta, body: body, content: content, path: path,
|
|
149
|
-
)
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def resolve_path(key)
|
|
153
|
-
@manifest.resolver.resolve(key).path
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
def prepare_uid(mentry, payload, key)
|
|
157
|
-
meta = payload.meta || {}
|
|
158
|
-
existing_uid = @reader.existing_uid(key)
|
|
159
|
-
ensure_uid(mentry.format, meta, payload.content, existing_uid)
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
def serialize_entry(mentry, path, meta, payload, content)
|
|
163
|
-
serialize_for_put(
|
|
164
|
-
mentry: mentry, path: path,
|
|
165
|
-
meta: meta, body: payload.body, content: content
|
|
166
|
-
)
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
def validate_schema(mentry, eff_meta, eff_content)
|
|
170
|
-
schema = @schemas.fetch_or_nil(mentry.schema)
|
|
171
|
-
return unless schema
|
|
172
|
-
|
|
173
|
-
Format.for(mentry.format).validate_against(
|
|
174
|
-
schema,
|
|
175
|
-
{ "_meta" => eff_meta, "content" => eff_content },
|
|
176
|
-
)
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
def check_etag!(path, key, if_etag)
|
|
180
|
-
etag_before = @file_store.exists?(path) ? @file_store.etag(path) : nil
|
|
181
|
-
raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && (etag_before != if_etag)
|
|
182
|
-
|
|
183
|
-
etag_before
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
def write_bytes(path, bytes)
|
|
187
|
-
@file_store.write(path, bytes)
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
def build_envelope(key, mentry, path, eff_meta, eff_body, eff_content)
|
|
191
|
-
Textus::Envelope.build(
|
|
192
|
-
key: key, mentry: mentry, path: path,
|
|
193
|
-
meta: eff_meta, body: eff_body,
|
|
194
|
-
etag: Etag.for_bytes(@file_store.read(path)),
|
|
195
|
-
content: eff_content
|
|
196
|
-
)
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
def audit_put(key, etag_before, etag_after)
|
|
200
|
-
extras = @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
|
|
201
|
-
@audit_log.append(
|
|
202
|
-
role: @call.role, verb: "put", key: key,
|
|
203
|
-
etag_before: etag_before, etag_after: etag_after,
|
|
204
|
-
extras: extras
|
|
205
|
-
)
|
|
206
|
-
end
|
|
207
|
-
end
|
|
208
|
-
end
|
|
209
|
-
end
|
data/lib/textus/envelope.rb
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "dry-struct"
|
|
4
|
-
|
|
5
|
-
module Textus
|
|
6
|
-
class Envelope < Dry::Struct
|
|
7
|
-
attribute :protocol, Types::String
|
|
8
|
-
attribute :key, Types::String
|
|
9
|
-
attribute :lane, Types::String
|
|
10
|
-
attribute :owner, Types::String.optional
|
|
11
|
-
attribute :path, Types::String
|
|
12
|
-
attribute :format, Types::FormatName
|
|
13
|
-
attribute :etag, Types::String
|
|
14
|
-
attribute :uid, Types::String.optional
|
|
15
|
-
attribute :schema_ref, Types::String.optional
|
|
16
|
-
attribute :meta, Types::Hash.default({}.freeze)
|
|
17
|
-
attribute :body, Types::String.optional
|
|
18
|
-
attribute :content, Types::Any.optional
|
|
19
|
-
attribute :freshness, Types::Any.optional
|
|
20
|
-
|
|
21
|
-
# rubocop:disable Metrics/ParameterLists
|
|
22
|
-
def self.build(key:, mentry:, path:, meta:, body:, etag:, content: nil, freshness: nil)
|
|
23
|
-
# rubocop:enable Metrics/ParameterLists
|
|
24
|
-
new(
|
|
25
|
-
protocol: Textus::PROTOCOL,
|
|
26
|
-
key: key,
|
|
27
|
-
lane: mentry.lane,
|
|
28
|
-
owner: mentry.owner,
|
|
29
|
-
path: path,
|
|
30
|
-
format: mentry.format,
|
|
31
|
-
uid: extract_uid(meta),
|
|
32
|
-
etag: etag,
|
|
33
|
-
schema_ref: mentry.schema,
|
|
34
|
-
meta: meta,
|
|
35
|
-
body: body,
|
|
36
|
-
content: content,
|
|
37
|
-
freshness: freshness,
|
|
38
|
-
)
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def self.extract_uid(meta)
|
|
42
|
-
v = meta.is_a?(Hash) ? meta["uid"] : nil
|
|
43
|
-
v.is_a?(String) ? v : nil
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def with(**attrs) = self.class.new(to_h.merge(attrs))
|
|
47
|
-
|
|
48
|
-
def to_h_for_wire
|
|
49
|
-
h = {
|
|
50
|
-
"protocol" => protocol,
|
|
51
|
-
"key" => key,
|
|
52
|
-
"lane" => lane,
|
|
53
|
-
"owner" => owner,
|
|
54
|
-
"path" => path,
|
|
55
|
-
"format" => format,
|
|
56
|
-
"_meta" => meta,
|
|
57
|
-
"body" => body,
|
|
58
|
-
"etag" => etag,
|
|
59
|
-
"schema_ref" => schema_ref,
|
|
60
|
-
"uid" => uid,
|
|
61
|
-
}
|
|
62
|
-
h["content"] = content unless content.nil?
|
|
63
|
-
freshness&.to_h_for_wire&.each { |k, v| h[k] = v }
|
|
64
|
-
h
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def stale?
|
|
68
|
-
return false if freshness.nil?
|
|
69
|
-
|
|
70
|
-
freshness.stale == true
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def fetching?
|
|
74
|
-
return false if freshness.nil?
|
|
75
|
-
|
|
76
|
-
freshness.fetching == true
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
end
|
data/lib/textus/etag.rb
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
require "digest"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Etag
|
|
5
|
-
def self.for_bytes(bytes)
|
|
6
|
-
"sha256:#{Digest::SHA256.hexdigest(bytes)}"
|
|
7
|
-
end
|
|
8
|
-
|
|
9
|
-
def self.for_file(path)
|
|
10
|
-
for_bytes(File.binread(path))
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
# The fingerprint of everything an agent's boot orientation depends on:
|
|
14
|
-
# the manifest PLUS the executable contract — hooks and schemas. A
|
|
15
|
-
# mid-session edit to any of these makes the cached orientation stale, so
|
|
16
|
-
# the session must re-boot (ADR 0074). The composite is one digest over the
|
|
17
|
-
# sorted per-file listing, so it is order-stable.
|
|
18
|
-
def self.for_contract(root)
|
|
19
|
-
listing = contract_files(root).map do |path|
|
|
20
|
-
rel = path.delete_prefix(root).delete_prefix("/")
|
|
21
|
-
"#{rel}:#{for_file(path)}"
|
|
22
|
-
end.join("\n")
|
|
23
|
-
for_bytes(listing)
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
# manifest.yaml, then every hook and schema file. Dir.glob already returns
|
|
27
|
-
# sorted paths (Ruby 3.0+), keeping the digest independent of FS order.
|
|
28
|
-
def self.contract_files(root)
|
|
29
|
-
[
|
|
30
|
-
File.join(root, "manifest.yaml"),
|
|
31
|
-
*Dir.glob(File.join(root, "hooks", "**", "*.rb")),
|
|
32
|
-
*Dir.glob(File.join(root, "schemas", "**", "*")).select { |f| File.file?(f) },
|
|
33
|
-
]
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
end
|