textus 0.55.1 → 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 +1 -1
- data/README.md +9 -9
- data/SPEC.md +14 -13
- data/docs/architecture/README.md +3 -3
- data/docs/reference/conventions.md +5 -2
- data/lib/textus/boot.rb +64 -85
- data/lib/textus/{gate → dispatch}/binder.rb +8 -10
- 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 +1 -1
- data/lib/textus/doctor/check/generator_drift.rb +2 -2
- data/lib/textus/doctor/check/orphaned_publish_targets.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/{notebook_sources.rb → scratchpad_sources.rb} +10 -5
- data/lib/textus/doctor/check/sentinels.rb +1 -1
- data/lib/textus/doctor/check.rb +8 -6
- data/lib/textus/doctor.rb +1 -1
- data/lib/textus/errors.rb +2 -0
- data/lib/textus/format/base.rb +36 -8
- data/lib/textus/format/json.rb +0 -21
- data/lib/textus/format/markdown.rb +0 -21
- data/lib/textus/format/yaml.rb +0 -21
- data/lib/textus/format.rb +16 -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 -5
- data/lib/textus/manifest/capabilities.rb +1 -1
- data/lib/textus/manifest/entry/base.rb +3 -3
- data/lib/textus/manifest/entry/publish/to_paths.rb +1 -1
- 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/orchestration.rb +55 -0
- data/lib/textus/port/audit_log.rb +6 -6
- data/lib/textus/port/build_lock.rb +1 -1
- data/lib/textus/{core → port}/sentinel.rb +1 -6
- data/lib/textus/port/sentinel_store.rb +3 -3
- data/lib/textus/port/storage/file_store.rb +23 -0
- data/lib/textus/port/storage/interface.rb +17 -0
- data/lib/textus/port/store.rb +58 -2
- data/lib/textus/port/watcher_lock.rb +2 -2
- 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 +3 -10
- data/lib/textus/store/container.rb +140 -10
- data/lib/textus/store/cursor.rb +1 -1
- data/lib/textus/store/{envelope → entry}/reader.rb +8 -4
- data/lib/textus/store/{envelope → entry}/writer.rb +53 -29
- 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 +5 -3
- data/lib/textus/store/jobs/planner.rb +27 -7
- data/lib/textus/store/jobs/queue.rb +9 -1
- 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 +1 -43
- data/lib/textus/store/jobs/sweep.rb +2 -2
- data/lib/textus/store/{geometry.rb → layout.rb} +19 -3
- data/lib/textus/store.rb +53 -30
- data/lib/textus/surface/cli/runner.rb +8 -9
- data/lib/textus/surface/cli/verb/doctor.rb +3 -2
- data/lib/textus/surface/cli/verb/get.rb +5 -3
- data/lib/textus/surface/cli/verb/put.rb +5 -3
- data/lib/textus/surface/mcp/catalog.rb +26 -62
- data/lib/textus/surface/mcp/errors.rb +0 -10
- data/lib/textus/surface/mcp/projector.rb +20 -0
- data/lib/textus/surface/mcp/server.rb +20 -31
- data/lib/textus/{core → value}/duration.rb +1 -4
- data/lib/textus/value/envelope.rb +5 -4
- data/lib/textus/value/etag.rb +1 -1
- data/lib/textus/value/payload.rb +7 -0
- data/lib/textus/value/result.rb +36 -16
- data/lib/textus/verb_registry.rb +417 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus/workflow/loader.rb +1 -1
- data/lib/textus/workflow/runner.rb +10 -18
- data/lib/textus.rb +0 -64
- metadata +70 -70
- data/lib/textus/action/accept.rb +0 -46
- data/lib/textus/action/audit.rb +0 -94
- data/lib/textus/action/base.rb +0 -42
- data/lib/textus/action/blame.rb +0 -79
- data/lib/textus/action/boot.rb +0 -15
- data/lib/textus/action/data_mv.rb +0 -58
- data/lib/textus/action/deps.rb +0 -19
- data/lib/textus/action/doctor.rb +0 -17
- data/lib/textus/action/drain.rb +0 -31
- data/lib/textus/action/enqueue.rb +0 -37
- data/lib/textus/action/get.rb +0 -34
- data/lib/textus/action/ingest.rb +0 -199
- data/lib/textus/action/jobs.rb +0 -27
- data/lib/textus/action/key_delete.rb +0 -26
- data/lib/textus/action/key_delete_prefix.rb +0 -35
- data/lib/textus/action/key_mv.rb +0 -122
- data/lib/textus/action/key_mv_prefix.rb +0 -48
- data/lib/textus/action/list.rb +0 -28
- data/lib/textus/action/propose.rb +0 -42
- data/lib/textus/action/published.rb +0 -22
- data/lib/textus/action/pulse.rb +0 -49
- data/lib/textus/action/put.rb +0 -38
- data/lib/textus/action/rdeps.rb +0 -24
- data/lib/textus/action/reject.rb +0 -28
- data/lib/textus/action/rule_explain.rb +0 -81
- data/lib/textus/action/rule_lint.rb +0 -62
- data/lib/textus/action/rule_list.rb +0 -38
- data/lib/textus/action/schema_envelope.rb +0 -22
- data/lib/textus/action/uid.rb +0 -19
- data/lib/textus/action/where.rb +0 -21
- data/lib/textus/contract/arg.rb +0 -10
- data/lib/textus/contract/dsl.rb +0 -88
- data/lib/textus/contract/spec.rb +0 -25
- data/lib/textus/contract.rb +0 -12
- 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/format/shared.rb +0 -17
- data/lib/textus/gate/auth.rb +0 -212
- data/lib/textus/gate.rb +0 -92
- data/lib/textus/meta.rb +0 -54
- data/lib/textus/schemas.rb +0 -54
- data/lib/textus/store/compositor.rb +0 -34
- data/lib/textus/store/session.rb +0 -37
- data/lib/textus/surface/projector.rb +0 -27
- data/lib/textus/surface/role_scope.rb +0 -34
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
module Textus
|
|
4
2
|
class Store
|
|
5
3
|
class Container
|
|
6
|
-
Infrastructure = Data.define(:file_store, :schemas, :audit_log, :job_store, :
|
|
7
|
-
Coordination = Data.define(:manifest, :workflows, :
|
|
4
|
+
Infrastructure = Data.define(:file_store, :schemas, :audit_log, :job_store, :layout)
|
|
5
|
+
Coordination = Data.define(:manifest, :workflows, :pipeline)
|
|
8
6
|
|
|
9
7
|
def self.attribute_names
|
|
10
8
|
@attribute_names ||= [:root] + Infrastructure.members + Coordination.members
|
|
@@ -15,10 +13,10 @@ module Textus
|
|
|
15
13
|
@coord = coord
|
|
16
14
|
end
|
|
17
15
|
|
|
18
|
-
attr_reader :infra, :coord
|
|
16
|
+
attr_reader :infra, :coord, :pipeline, :reader, :writer
|
|
19
17
|
|
|
20
18
|
def root
|
|
21
|
-
@infra.
|
|
19
|
+
@infra.layout.root
|
|
22
20
|
end
|
|
23
21
|
|
|
24
22
|
Infrastructure.members.each do |name|
|
|
@@ -29,15 +27,147 @@ module Textus
|
|
|
29
27
|
define_method(name) { @coord.public_send(name) }
|
|
30
28
|
end
|
|
31
29
|
|
|
32
|
-
def
|
|
33
|
-
@
|
|
30
|
+
def wire!(pipeline:, reader:, writer:)
|
|
31
|
+
@pipeline = pipeline
|
|
32
|
+
@reader = reader
|
|
33
|
+
@writer = writer
|
|
34
|
+
@coord = Coordination.new(
|
|
34
35
|
manifest: @coord.manifest,
|
|
35
36
|
workflows: @coord.workflows,
|
|
36
|
-
|
|
37
|
-
compositor:,
|
|
37
|
+
pipeline: pipeline,
|
|
38
38
|
)
|
|
39
39
|
self
|
|
40
40
|
end
|
|
41
|
+
|
|
42
|
+
def self.build(infra, coord_seed)
|
|
43
|
+
coord = Coordination.new(
|
|
44
|
+
manifest: coord_seed.manifest,
|
|
45
|
+
workflows: coord_seed.workflows,
|
|
46
|
+
pipeline: nil,
|
|
47
|
+
)
|
|
48
|
+
container = new(infra, coord)
|
|
49
|
+
pipeline = build_pipeline(container)
|
|
50
|
+
reader = Textus::Store::Entry::Reader.from(container: container)
|
|
51
|
+
writer = create_writer_factory(container)
|
|
52
|
+
container.wire!(pipeline: pipeline, reader: reader, writer: writer)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.orchestration_for(container)
|
|
56
|
+
Orchestration.new(
|
|
57
|
+
list_keys: Handlers::Read::ListKeys.new(manifest: container.manifest, job_store: container.job_store),
|
|
58
|
+
move_key: Handlers::Write::MoveKey.new(container: container, manifest: container.manifest),
|
|
59
|
+
delete_key: Handlers::Write::DeleteKey.new(container: container),
|
|
60
|
+
audit_entries: Handlers::Read::AuditEntries.new(manifest: container.manifest, audit_log: container.audit_log),
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.build_pipeline(container) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
65
|
+
registry = Dispatch::HandlerRegistry.new
|
|
66
|
+
fe = freshness_evaluator(container)
|
|
67
|
+
orch = orchestration_for(container)
|
|
68
|
+
|
|
69
|
+
registry.register(Dispatch::Contracts::GetEntry,
|
|
70
|
+
Handlers::Read::GetEntry.new(container: container, freshness_evaluator: fe))
|
|
71
|
+
registry.register(Dispatch::Contracts::PutEntry,
|
|
72
|
+
Handlers::Write::PutEntry.new(container: container))
|
|
73
|
+
registry.register(Dispatch::Contracts::ListKeys,
|
|
74
|
+
Handlers::Read::ListKeys.new(manifest: container.manifest, job_store: container.job_store))
|
|
75
|
+
registry.register(Dispatch::Contracts::DeleteKey,
|
|
76
|
+
Handlers::Write::DeleteKey.new(container: container))
|
|
77
|
+
registry.register(Dispatch::Contracts::MoveKey,
|
|
78
|
+
Handlers::Write::MoveKey.new(container: container, manifest: container.manifest))
|
|
79
|
+
registry.register(Dispatch::Contracts::ProposeEntry,
|
|
80
|
+
Handlers::Write::ProposeEntry.new(container: container))
|
|
81
|
+
registry.register(Dispatch::Contracts::AcceptProposal,
|
|
82
|
+
Handlers::Write::AcceptProposal.new(container: container))
|
|
83
|
+
registry.register(Dispatch::Contracts::RejectProposal,
|
|
84
|
+
Handlers::Write::RejectProposal.new(container: container))
|
|
85
|
+
registry.register(Dispatch::Contracts::EnqueueJob,
|
|
86
|
+
Handlers::Write::EnqueueJob.new(job_store: container.job_store))
|
|
87
|
+
registry.register(Dispatch::Contracts::WhereEntry,
|
|
88
|
+
Handlers::Read::WhereEntry.new(manifest: container.manifest))
|
|
89
|
+
registry.register(Dispatch::Contracts::UidEntry,
|
|
90
|
+
Handlers::Read::UidEntry.new(container: container))
|
|
91
|
+
registry.register(Dispatch::Contracts::DepsEntry,
|
|
92
|
+
Handlers::Read::DepsEntry.new(manifest: container.manifest))
|
|
93
|
+
registry.register(Dispatch::Contracts::RdepsEntry,
|
|
94
|
+
Handlers::Read::RdepsEntry.new(manifest: container.manifest))
|
|
95
|
+
registry.register(Dispatch::Contracts::BootStore,
|
|
96
|
+
Handlers::Maintenance::BootStore.new(container: container))
|
|
97
|
+
registry.register(Dispatch::Contracts::DoctorStore,
|
|
98
|
+
Handlers::Maintenance::DoctorStore.new(container: container))
|
|
99
|
+
registry.register(Dispatch::Contracts::PublishedEntries,
|
|
100
|
+
Handlers::Maintenance::PublishedEntries.new(manifest: container.manifest))
|
|
101
|
+
registry.register(Dispatch::Contracts::RuleExplain,
|
|
102
|
+
Handlers::Maintenance::RuleExplain.new(manifest: container.manifest))
|
|
103
|
+
registry.register(Dispatch::Contracts::RuleList,
|
|
104
|
+
Handlers::Maintenance::RuleList.new(manifest: container.manifest))
|
|
105
|
+
registry.register(Dispatch::Contracts::SchemaEnvelope,
|
|
106
|
+
Handlers::Maintenance::SchemaEnvelope.new(manifest: container.manifest, schemas: container.schemas))
|
|
107
|
+
registry.register(Dispatch::Contracts::DrainStore,
|
|
108
|
+
Handlers::Maintenance::DrainStore.new(container: container, job_store: container.job_store))
|
|
109
|
+
registry.register(Dispatch::Contracts::IngestEntry,
|
|
110
|
+
Handlers::Maintenance::IngestEntry.new(container: container))
|
|
111
|
+
registry.register(Dispatch::Contracts::JobsAction,
|
|
112
|
+
Handlers::Maintenance::JobsAction.new(job_store: container.job_store))
|
|
113
|
+
registry.register(Dispatch::Contracts::RuleLint,
|
|
114
|
+
Handlers::Maintenance::RuleLint.new(manifest: container.manifest))
|
|
115
|
+
registry.register(Dispatch::Contracts::DataMv,
|
|
116
|
+
Handlers::Write::DataMv.new(container: container))
|
|
117
|
+
registry.register(Dispatch::Contracts::AuditEntries,
|
|
118
|
+
Handlers::Read::AuditEntries.new(manifest: container.manifest, audit_log: container.audit_log))
|
|
119
|
+
registry.register(Dispatch::Contracts::PulseEntries,
|
|
120
|
+
Handlers::Read::PulseEntries.new(
|
|
121
|
+
manifest: container.manifest,
|
|
122
|
+
audit_log: container.audit_log,
|
|
123
|
+
file_store: container.file_store,
|
|
124
|
+
job_store: container.job_store,
|
|
125
|
+
orchestration: orch,
|
|
126
|
+
))
|
|
127
|
+
registry.register(Dispatch::Contracts::BlameEntry,
|
|
128
|
+
Handlers::Read::BlameEntry.new(manifest: container.manifest, orchestration: orch))
|
|
129
|
+
registry.register(Dispatch::Contracts::KeyMvPrefix,
|
|
130
|
+
Handlers::Write::KeyMvPrefix.new(orchestration: orch))
|
|
131
|
+
registry.register(Dispatch::Contracts::KeyDeletePrefix,
|
|
132
|
+
Handlers::Write::KeyDeletePrefix.new(orchestration: orch))
|
|
133
|
+
|
|
134
|
+
Dispatch::Pipeline.new(
|
|
135
|
+
registry: registry,
|
|
136
|
+
container: container,
|
|
137
|
+
middleware: [
|
|
138
|
+
Dispatch::Middleware::Binder.new,
|
|
139
|
+
Dispatch::Middleware::Auth.new,
|
|
140
|
+
Dispatch::Middleware::AuditIndex.new(
|
|
141
|
+
job_store: container.job_store,
|
|
142
|
+
audit_log: container.audit_log,
|
|
143
|
+
),
|
|
144
|
+
Dispatch::Middleware::Cascade.new,
|
|
145
|
+
],
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
private_class_method :build_pipeline
|
|
149
|
+
|
|
150
|
+
def self.create_writer_factory(container)
|
|
151
|
+
lambda do |call|
|
|
152
|
+
Textus::Store::Entry::Writer.new(
|
|
153
|
+
file_store: container.file_store,
|
|
154
|
+
manifest: container.manifest,
|
|
155
|
+
schemas: container.schemas,
|
|
156
|
+
audit_log: container.audit_log,
|
|
157
|
+
call: call,
|
|
158
|
+
reader: container.reader,
|
|
159
|
+
layout: container.layout,
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def self.freshness_evaluator(container)
|
|
165
|
+
Store::Freshness::TtlEvaluator.new(
|
|
166
|
+
manifest: container.manifest,
|
|
167
|
+
file_stat: Textus::Port::Storage::FileStat.new,
|
|
168
|
+
clock: Textus::Port::Clock.new,
|
|
169
|
+
)
|
|
170
|
+
end
|
|
41
171
|
end
|
|
42
172
|
end
|
|
43
173
|
end
|
data/lib/textus/store/cursor.rb
CHANGED
|
@@ -7,7 +7,7 @@ module Textus
|
|
|
7
7
|
# losing it just re-emits recent deltas, never corrupts the store. ADR 0036/0038.
|
|
8
8
|
class Cursor
|
|
9
9
|
def initialize(root:, role:)
|
|
10
|
-
@path = Store::
|
|
10
|
+
@path = Store::Layout.new(root).cursor_path(role)
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def read
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class Store
|
|
3
|
-
module
|
|
3
|
+
module Entry
|
|
4
4
|
# Read-only counterpart to EnvelopeWriter. Resolves a key, reads the
|
|
5
5
|
# bytes, parses them via the format strategy, and hands back an
|
|
6
6
|
# Envelope. Used by Mv (pre-move inspection) and by EnvelopeWriter
|
|
@@ -9,14 +9,18 @@ module Textus
|
|
|
9
9
|
# No audit, no events, no permission checks — those live one layer up.
|
|
10
10
|
class Reader
|
|
11
11
|
def self.from(container:)
|
|
12
|
+
# Prefer a cached reader on the container (injection point) for
|
|
13
|
+
# tests and alternative runtimes. Fall back to constructing one.
|
|
14
|
+
return container.reader if container.respond_to?(:reader) && container.reader
|
|
15
|
+
|
|
12
16
|
new(file_store: container.file_store, manifest: container.manifest,
|
|
13
|
-
|
|
17
|
+
layout: container.layout)
|
|
14
18
|
end
|
|
15
19
|
|
|
16
|
-
def initialize(file_store:, manifest:,
|
|
20
|
+
def initialize(file_store:, manifest:, layout:)
|
|
17
21
|
@file_store = file_store
|
|
18
22
|
@manifest = manifest
|
|
19
|
-
@
|
|
23
|
+
@layout = layout
|
|
20
24
|
end
|
|
21
25
|
|
|
22
26
|
def read(key)
|
|
@@ -2,7 +2,7 @@ require "fileutils"
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
class Store
|
|
5
|
-
module
|
|
5
|
+
module Entry
|
|
6
6
|
# Owns the write pipeline (validate, serialize, etag-check, write, audit).
|
|
7
7
|
# Talks to ports (FileStore, Schemas, AuditLog, Manifest) and an
|
|
8
8
|
# Reader for the existing-uid lookup.
|
|
@@ -12,43 +12,47 @@ module Textus
|
|
|
12
12
|
# No permission check, no event firing — those belong to the caller
|
|
13
13
|
# (Write::Put / ::Delete / ::Mv).
|
|
14
14
|
class Writer
|
|
15
|
-
Payload = Data.define(:meta, :body, :content)
|
|
16
|
-
|
|
17
15
|
def self.from(container:, call:)
|
|
16
|
+
# If the container exposes a writer factory (in tests we set this),
|
|
17
|
+
# use it. Otherwise, construct a fresh Writer.
|
|
18
|
+
return container.writer.call(call) if container.respond_to?(:writer) && container.writer
|
|
19
|
+
|
|
18
20
|
new(
|
|
19
21
|
file_store: container.file_store, manifest: container.manifest,
|
|
20
22
|
schemas: container.schemas, audit_log: container.audit_log,
|
|
21
23
|
call: call, reader: Reader.from(container: container),
|
|
22
|
-
|
|
24
|
+
layout: container.layout
|
|
23
25
|
)
|
|
24
26
|
end
|
|
25
27
|
|
|
26
|
-
def initialize(file_store:, manifest:, schemas:, audit_log:, call:, reader:,
|
|
28
|
+
def initialize(file_store:, manifest:, schemas:, audit_log:, call:, reader:, layout:)
|
|
27
29
|
@file_store = file_store
|
|
28
30
|
@manifest = manifest
|
|
29
31
|
@schemas = schemas
|
|
30
32
|
@audit_log = audit_log
|
|
31
33
|
@call = call
|
|
32
34
|
@reader = reader
|
|
33
|
-
@
|
|
35
|
+
@layout = layout
|
|
34
36
|
end
|
|
35
37
|
|
|
36
38
|
def put(key, mentry:, payload:, if_etag: nil)
|
|
37
|
-
path
|
|
38
|
-
meta
|
|
39
|
+
path = resolve_path(key)
|
|
40
|
+
meta = payload.meta || {}
|
|
39
41
|
content = payload.content
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
|
|
43
|
+
existing_env = read_existing(key)
|
|
44
|
+
existing_meta = existing_env ? existing_env.meta : {}
|
|
45
|
+
meta, content = inject_meta(meta, content, existing_meta, mentry.format)
|
|
46
|
+
|
|
43
47
|
bytes, eff_meta, eff_body, eff_content = serialize_entry(mentry, path, meta, payload, content)
|
|
48
|
+
|
|
44
49
|
enforce_name_match!(path, eff_meta, mentry.format)
|
|
45
50
|
validate_schema(mentry, eff_meta, eff_content)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
mentry.lane,
|
|
49
|
-
)
|
|
51
|
+
validate_raw(eff_meta, eff_content, mentry.lane, mentry.format)
|
|
52
|
+
|
|
50
53
|
etag_before = check_etag!(path, key, if_etag)
|
|
51
54
|
write_bytes(path, bytes)
|
|
55
|
+
|
|
52
56
|
envelope = build_envelope(key, mentry, path, eff_meta, eff_body, eff_content, bytes)
|
|
53
57
|
audit_put(key, etag_before, envelope.etag)
|
|
54
58
|
envelope
|
|
@@ -81,8 +85,7 @@ module Textus
|
|
|
81
85
|
etag_before = @file_store.etag(from_path)
|
|
82
86
|
raise EtagMismatch.new(from_key, if_etag, etag_before) if if_etag && if_etag != etag_before
|
|
83
87
|
|
|
84
|
-
|
|
85
|
-
FileUtils.mv(from_path, to_path)
|
|
88
|
+
@file_store.mv(from_path, to_path)
|
|
86
89
|
prune_empty_parents(from_path)
|
|
87
90
|
basename = to_key.split(".").last
|
|
88
91
|
Format.for(new_mentry.format).rewrite_name(to_path, basename)
|
|
@@ -117,39 +120,53 @@ module Textus
|
|
|
117
120
|
# `.gitkeep` or sibling entries survives. Best-effort: a lost race or a
|
|
118
121
|
# non-empty dir is silently fine, never fatal to the write.
|
|
119
122
|
def prune_empty_parents(path)
|
|
120
|
-
floor = @
|
|
123
|
+
floor = @layout.lane_floor(path)
|
|
121
124
|
return unless floor
|
|
122
125
|
|
|
123
126
|
dir = File.dirname(path)
|
|
124
|
-
while dir.start_with?("#{floor}/") &&
|
|
125
|
-
|
|
127
|
+
while dir.start_with?("#{floor}/") && @file_store.dir_empty?(dir)
|
|
128
|
+
@file_store.rmdir(dir)
|
|
126
129
|
dir = File.dirname(dir)
|
|
127
130
|
end
|
|
128
131
|
rescue SystemCallError
|
|
129
132
|
nil
|
|
130
133
|
end
|
|
131
134
|
|
|
132
|
-
def
|
|
133
|
-
|
|
135
|
+
def read_existing(key)
|
|
136
|
+
@reader.read(key)
|
|
134
137
|
end
|
|
135
138
|
|
|
136
|
-
def
|
|
137
|
-
|
|
138
|
-
meta
|
|
139
|
+
def inject_meta(meta, content, existing_meta, format)
|
|
140
|
+
Envelope::Meta.inject_all(
|
|
141
|
+
meta, content, existing_meta,
|
|
142
|
+
format: format,
|
|
143
|
+
etag_for: method(:resolve_source_etag)
|
|
139
144
|
)
|
|
140
145
|
end
|
|
141
146
|
|
|
147
|
+
def resolve_source_etag(key)
|
|
148
|
+
path = @manifest.resolver.resolve(key).path
|
|
149
|
+
return nil unless @file_store.exists?(path)
|
|
150
|
+
|
|
151
|
+
Value::Etag.for_file(path)
|
|
152
|
+
rescue Textus::Error
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
142
156
|
def resolve_path(key)
|
|
143
157
|
@manifest.resolver.resolve(key).path
|
|
144
158
|
end
|
|
145
159
|
|
|
146
160
|
def serialize_entry(mentry, path, meta, payload, content)
|
|
147
|
-
serialize_for_put(
|
|
148
|
-
|
|
149
|
-
meta: meta, body: payload.body, content: content
|
|
161
|
+
Textus::Format.for(mentry.format).serialize_for_put(
|
|
162
|
+
meta: meta, body: payload.body, content: content, path: path,
|
|
150
163
|
)
|
|
151
164
|
end
|
|
152
165
|
|
|
166
|
+
def enforce_name_match!(path, meta, format)
|
|
167
|
+
Textus::Format.for(format).enforce_name_match!(path, meta)
|
|
168
|
+
end
|
|
169
|
+
|
|
153
170
|
def validate_schema(mentry, eff_meta, eff_content)
|
|
154
171
|
schema = @schemas.fetch_or_nil(mentry.schema)
|
|
155
172
|
return unless schema
|
|
@@ -160,6 +177,13 @@ module Textus
|
|
|
160
177
|
)
|
|
161
178
|
end
|
|
162
179
|
|
|
180
|
+
def validate_raw(eff_meta, eff_content, lane, format)
|
|
181
|
+
Textus::Format.for(format).validate_raw_entry!(
|
|
182
|
+
{ "_meta" => eff_meta, "content" => eff_content },
|
|
183
|
+
lane,
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
|
|
163
187
|
def check_etag!(path, key, if_etag)
|
|
164
188
|
etag_before = @file_store.exists?(path) ? @file_store.etag(path) : nil
|
|
165
189
|
raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && (etag_before != if_etag)
|
|
@@ -171,7 +195,7 @@ module Textus
|
|
|
171
195
|
@file_store.write(path, bytes)
|
|
172
196
|
end
|
|
173
197
|
|
|
174
|
-
def build_envelope(key, mentry, path, eff_meta, eff_body, eff_content, bytes = nil)
|
|
198
|
+
def build_envelope(key, mentry, path, eff_meta, eff_body, eff_content, bytes = nil)
|
|
175
199
|
raw = bytes || @file_store.read(path)
|
|
176
200
|
Textus::Value::Envelope.build(
|
|
177
201
|
key: key, mentry: mentry, path: path,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
class Store
|
|
5
|
+
module Envelope
|
|
6
|
+
module Meta
|
|
7
|
+
NO_META_FORMATS = %w[text].freeze
|
|
8
|
+
|
|
9
|
+
FIELDS = {
|
|
10
|
+
"uid" => {
|
|
11
|
+
inject: lambda { |meta, content, existing_meta, **_opts|
|
|
12
|
+
m = meta.is_a?(Hash) ? meta.dup : {}
|
|
13
|
+
existing = existing_meta.is_a?(Hash) ? existing_meta["uid"] : nil
|
|
14
|
+
m["uid"] = existing || Textus::Value::Uid.mint unless m["uid"].is_a?(String) && !m["uid"].empty?
|
|
15
|
+
[m, content]
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
"sources" => {
|
|
19
|
+
inject: lambda { |meta, content, existing_meta, etag_for: nil|
|
|
20
|
+
m = meta.is_a?(Hash) ? meta.dup : {}
|
|
21
|
+
existing = existing_meta.is_a?(Hash) ? existing_meta["sources"] : nil
|
|
22
|
+
|
|
23
|
+
if m.key?("sources")
|
|
24
|
+
raise Textus::BadContent.new(nil, "_meta.sources must be an array") unless m["sources"].is_a?(Array)
|
|
25
|
+
|
|
26
|
+
m["sources"] = m["sources"].map { |s| Meta.normalize_source!(s, etag_for) }
|
|
27
|
+
elsif existing.is_a?(Array) && !existing.empty?
|
|
28
|
+
m["sources"] = existing
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
[m, content]
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
def self.inject_all(meta, content, existing_meta = {}, format: nil, etag_for: nil)
|
|
37
|
+
return [meta, content] if NO_META_FORMATS.include?(format)
|
|
38
|
+
|
|
39
|
+
FIELDS.each_value do |field|
|
|
40
|
+
meta, content = field[:inject].call(meta, content, existing_meta, etag_for: etag_for)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
[meta, content]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.normalize_source!(src, etag_for)
|
|
47
|
+
key = case src
|
|
48
|
+
when String then src
|
|
49
|
+
when Hash then src["key"]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
raise Textus::BadContent.new(nil, "each source must be a string key or { key: } object") unless key.is_a?(String)
|
|
53
|
+
raise Textus::BadContent.new(nil, "each source key must be a non-empty string") if key.empty?
|
|
54
|
+
|
|
55
|
+
etag = etag_for&.call(key)
|
|
56
|
+
etag ? { "key" => key, "etag" => etag } : { "key" => key }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Textus
|
|
6
|
+
class Store
|
|
7
|
+
module Freshness
|
|
8
|
+
class DriftDetector
|
|
9
|
+
def initialize(manifest:, file_stat:, clock:)
|
|
10
|
+
@manifest = manifest
|
|
11
|
+
@file_stat = file_stat
|
|
12
|
+
@clock = clock
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def drift_rows(mentry)
|
|
16
|
+
return [] unless mentry.external?
|
|
17
|
+
|
|
18
|
+
path = Textus::Key::Path.resolve(@manifest.data, mentry)
|
|
19
|
+
reason = drift_reason(mentry, path)
|
|
20
|
+
reason ? [drift_row(mentry, path, reason)] : []
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def drift_reason(mentry, path)
|
|
26
|
+
return "derived entry has never been generated" unless @file_stat.exists?(path)
|
|
27
|
+
|
|
28
|
+
generated_at = generated_at_of(mentry, path)
|
|
29
|
+
return "missing generated.at frontmatter" unless generated_at
|
|
30
|
+
|
|
31
|
+
gen_time = parse_time(generated_at)
|
|
32
|
+
return "unparseable generated.at: #{generated_at.inspect}" unless gen_time
|
|
33
|
+
|
|
34
|
+
offender = newest_source_after(mentry.source, gen_time)
|
|
35
|
+
"source '#{offender}' modified after generated.at" if offender
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def generated_at_of(mentry, path)
|
|
39
|
+
Textus::Format.for(mentry.format).parse(@file_stat.read(path), path: path)["_meta"]
|
|
40
|
+
.dig("generated", "at")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def parse_time(str)
|
|
44
|
+
Time.parse(str.to_s)
|
|
45
|
+
rescue StandardError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def newest_source_after(external_src, gen_time)
|
|
50
|
+
Array(external_src.sources).each do |src|
|
|
51
|
+
offender = check_source(src, gen_time)
|
|
52
|
+
return offender if offender
|
|
53
|
+
end
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def check_source(src, gen_time)
|
|
58
|
+
if src.match?(/\A[a-z0-9.][a-z0-9._-]*\z/) && !src.include?("/")
|
|
59
|
+
@manifest.resolver.enumerate(prefix: src).each do |row|
|
|
60
|
+
return src if @file_stat.mtime(row[:path]) > gen_time
|
|
61
|
+
end
|
|
62
|
+
nil
|
|
63
|
+
else
|
|
64
|
+
check_filesystem_source(src, gen_time)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def check_filesystem_source(src, gen_time)
|
|
69
|
+
abs = absolutize_source(src)
|
|
70
|
+
if @file_stat.directory?(abs)
|
|
71
|
+
dir_has_newer_file?(abs, gen_time) ? src : nil
|
|
72
|
+
elsif @file_stat.exists?(abs) && @file_stat.mtime(abs) > gen_time
|
|
73
|
+
src
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def absolutize_source(src)
|
|
78
|
+
File.absolute_path?(src) ? src : File.join(File.dirname(@manifest.data.root), src)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def dir_has_newer_file?(abs, gen_time)
|
|
82
|
+
@file_stat.glob(File.join(abs, "**", "*")).any? do |fpath|
|
|
83
|
+
!@file_stat.directory?(fpath) && @file_stat.exists?(fpath) && @file_stat.mtime(fpath) > gen_time
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def drift_row(mentry, path, reason)
|
|
88
|
+
{ "key" => mentry.key, "path" => path, "generator" => mentry.source.command, "reason" => reason }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
class Store
|
|
5
|
+
module Freshness
|
|
6
|
+
# Thin facade delegating to the focused TtlEvaluator and DriftDetector.
|
|
7
|
+
# Prefer using TtlEvaluator or DriftDetector directly.
|
|
8
|
+
class Evaluator
|
|
9
|
+
def initialize(manifest:, file_stat:, clock:)
|
|
10
|
+
@ttl = TtlEvaluator.new(manifest: manifest, file_stat: file_stat, clock: clock)
|
|
11
|
+
@drift = DriftDetector.new(manifest: manifest, file_stat: file_stat, clock: clock)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def verdict(mentry) = @ttl.verdict(mentry)
|
|
15
|
+
def stale_keys(**) = @ttl.stale_keys(**)
|
|
16
|
+
def drift_rows(mentry) = @drift.drift_rows(mentry)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
class Store
|
|
5
|
+
module Freshness
|
|
6
|
+
class TtlEvaluator
|
|
7
|
+
def initialize(manifest:, file_stat:, clock:)
|
|
8
|
+
@manifest = manifest
|
|
9
|
+
@file_stat = file_stat
|
|
10
|
+
@clock = clock
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def verdict(mentry)
|
|
14
|
+
ttl = @manifest.rules.for(mentry.key).retention&.ttl_seconds
|
|
15
|
+
return fresh if ttl.nil?
|
|
16
|
+
|
|
17
|
+
stale = age_stale?(file_basis(mentry), ttl)
|
|
18
|
+
Verdict.build(stale: stale, reason: stale ? "ttl exceeded" : nil, fetching: false)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def stale_keys(prefix: nil, lane: nil)
|
|
22
|
+
@manifest.data.entries.select { |m| due?(m, prefix: prefix, lane: lane) }.map(&:key)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def fresh = Verdict.build(stale: false, reason: nil, fetching: false)
|
|
28
|
+
|
|
29
|
+
def file_basis(mentry)
|
|
30
|
+
path = @manifest.resolver.resolve(mentry.key).path
|
|
31
|
+
return nil unless @file_stat.exists?(path)
|
|
32
|
+
|
|
33
|
+
@file_stat.mtime(path)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def due?(mentry, prefix:, lane:)
|
|
37
|
+
return false if lane && mentry.lane != lane
|
|
38
|
+
return false if prefix && !mentry.key.start_with?(prefix)
|
|
39
|
+
|
|
40
|
+
ttl = @manifest.rules.for(mentry.key).retention&.ttl_seconds
|
|
41
|
+
return false if ttl.nil?
|
|
42
|
+
|
|
43
|
+
path = @manifest.resolver.resolve(mentry.key).path
|
|
44
|
+
return true unless @file_stat.exists?(path)
|
|
45
|
+
|
|
46
|
+
age_stale?(file_basis(mentry), ttl)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def age_stale?(basis, ttl)
|
|
50
|
+
return true if basis.nil?
|
|
51
|
+
|
|
52
|
+
(@clock.now - basis).to_i > ttl
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -1,16 +1,6 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
module Textus
|
|
4
|
-
|
|
2
|
+
class Store
|
|
5
3
|
module Freshness
|
|
6
|
-
# Value object describing the freshness annotation attached to an Envelope
|
|
7
|
-
# after a currency evaluation (ADR 0099 — was Core::Freshness).
|
|
8
|
-
#
|
|
9
|
-
# Note on wire format: `#to_h_for_wire` is intentionally narrower than the
|
|
10
|
-
# full field set. It emits the legacy keys ("stale", "stale_reason",
|
|
11
|
-
# "fetching", and "fetch_error" when present) so the CLI JSON wire stays
|
|
12
|
-
# byte-identical with textus/4. The gem-side fields `checked_at` and
|
|
13
|
-
# `ttl_remaining_ms` are NOT emitted on the wire.
|
|
14
4
|
Verdict = Data.define(
|
|
15
5
|
:stale, :fetching, :reason, :fetch_error, :checked_at, :ttl_remaining_ms
|
|
16
6
|
) do
|
|
@@ -19,9 +19,10 @@ module Textus
|
|
|
19
19
|
@store.execute("DELETE FROM entries")
|
|
20
20
|
rows.each do |data|
|
|
21
21
|
@store.execute(
|
|
22
|
-
"INSERT INTO entries (key, lane, format, etag, content, extra, indexed_at)
|
|
23
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
24
|
-
[data[:key], data[:lane], data[:format], data[:etag],
|
|
22
|
+
"INSERT INTO entries (key, lane, format, etag, content, extra, indexed_at, schema_ref)
|
|
23
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
24
|
+
[data[:key], data[:lane], data[:format], data[:etag],
|
|
25
|
+
data[:content], data[:extra], now_iso, data[:schema_ref]],
|
|
25
26
|
)
|
|
26
27
|
end
|
|
27
28
|
@store.execute("INSERT INTO entries_fts(entries_fts) VALUES('rebuild')")
|
|
@@ -46,6 +47,7 @@ module Textus
|
|
|
46
47
|
etag: Textus::Value::Etag.for_bytes(raw),
|
|
47
48
|
content: content_text(parsed),
|
|
48
49
|
extra: extra_json(parsed),
|
|
50
|
+
schema_ref: entry.schema,
|
|
49
51
|
}
|
|
50
52
|
end
|
|
51
53
|
|