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
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Policy
|
|
4
|
+
module Predicates
|
|
5
|
+
class LaneDeletableBy
|
|
6
|
+
def self.call(manifest:, actor:, action:, key:, schemas: nil, envelope: nil, extra: {})
|
|
7
|
+
return { pass: true } if key.nil?
|
|
8
|
+
|
|
9
|
+
mentry = manifest.resolver.resolve(key).entry
|
|
10
|
+
is_raw = manifest.policy.declared_kind(mentry.lane.to_s) == :raw
|
|
11
|
+
lane_verb = manifest.policy.verb_for_lane(mentry.lane.to_s)
|
|
12
|
+
caps = Set.new(manifest.data.role_caps.fetch(actor.to_s, []))
|
|
13
|
+
|
|
14
|
+
pass = if is_raw
|
|
15
|
+
caps.include?("author")
|
|
16
|
+
else
|
|
17
|
+
caps.include?(lane_verb.to_s) || caps.include?("author")
|
|
18
|
+
end
|
|
19
|
+
return { pass: true } if pass
|
|
20
|
+
|
|
21
|
+
extra_holders = is_raw ? ["author"] : [lane_verb.to_s, "author"]
|
|
22
|
+
holders = extra_holders.flat_map { |v| manifest.policy.roles_with_capability(v) }.uniq
|
|
23
|
+
{ pass: false, error: Textus::WriteForbidden.new(mentry.key, mentry.lane, verb: lane_verb, holders:) }
|
|
24
|
+
rescue Textus::UnknownKey
|
|
25
|
+
{ pass: true }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Policy
|
|
4
|
+
module Predicates
|
|
5
|
+
class LaneWritableBy
|
|
6
|
+
def self.call(manifest:, actor:, action:, key:, schemas: nil, envelope: nil, extra: {})
|
|
7
|
+
return { pass: true } if key.nil?
|
|
8
|
+
|
|
9
|
+
mentry = manifest.resolver.resolve(key).entry
|
|
10
|
+
lane_verb = manifest.policy.verb_for_lane(mentry.lane.to_s)
|
|
11
|
+
caps = Set.new(manifest.data.role_caps.fetch(actor.to_s, []))
|
|
12
|
+
return { pass: true } if caps.include?(lane_verb.to_s)
|
|
13
|
+
|
|
14
|
+
holders = manifest.policy.roles_with_capability(lane_verb.to_s)
|
|
15
|
+
{ pass: false, error: Textus::WriteForbidden.new(mentry.key, mentry.lane, verb: lane_verb, holders:) }
|
|
16
|
+
rescue Textus::UnknownKey
|
|
17
|
+
{ pass: true }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Policy
|
|
4
|
+
module Predicates
|
|
5
|
+
class RawLaneIngestOnly
|
|
6
|
+
def self.call(manifest:, actor:, action:, key:, schemas: nil, envelope: nil, extra: {})
|
|
7
|
+
return { pass: true } if key.nil?
|
|
8
|
+
|
|
9
|
+
mentry = manifest.resolver.resolve(key).entry
|
|
10
|
+
return { pass: true } unless manifest.policy.declared_kind(mentry.lane.to_s) == :raw
|
|
11
|
+
return { pass: true } if action == :ingest
|
|
12
|
+
|
|
13
|
+
{ pass: false, error: Textus::Error.new(
|
|
14
|
+
:raw_lane_ingest_only,
|
|
15
|
+
"raw lane '#{mentry.lane}' only accepts `textus ingest` — " \
|
|
16
|
+
"use that verb instead of '#{action}'",
|
|
17
|
+
) }
|
|
18
|
+
rescue Textus::UnknownKey
|
|
19
|
+
{ pass: true }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Policy
|
|
4
|
+
module Predicates
|
|
5
|
+
class RawWriteOnce
|
|
6
|
+
def self.call(manifest:, actor:, action:, key:, schemas: nil, envelope: nil, extra: {})
|
|
7
|
+
return { pass: true } if key.nil?
|
|
8
|
+
|
|
9
|
+
path = manifest.resolver.resolve(key).path
|
|
10
|
+
return { pass: true } unless File.exist?(path)
|
|
11
|
+
|
|
12
|
+
{ pass: false, error: Textus::Error.new(
|
|
13
|
+
:raw_write_once,
|
|
14
|
+
"raw entry '#{key}' already exists; " \
|
|
15
|
+
"delete it first (`textus key-delete #{key}`), then re-ingest",
|
|
16
|
+
) }
|
|
17
|
+
rescue Textus::UnknownKey
|
|
18
|
+
{ pass: true }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Policy
|
|
4
|
+
module Predicates
|
|
5
|
+
class SchemaValid
|
|
6
|
+
def self.call(manifest:, actor:, action:, key:, schemas: nil, envelope: nil, extra: {})
|
|
7
|
+
return { pass: true } unless envelope
|
|
8
|
+
return { pass: true } if key.nil?
|
|
9
|
+
|
|
10
|
+
mentry = manifest.resolver.resolve(key).entry
|
|
11
|
+
schema_ref = mentry.schema
|
|
12
|
+
return { pass: true } unless schema_ref
|
|
13
|
+
return { pass: true } unless schemas
|
|
14
|
+
|
|
15
|
+
schema = schemas.fetch_or_nil(schema_ref)
|
|
16
|
+
return { pass: true } unless schema
|
|
17
|
+
|
|
18
|
+
frontmatter = envelope.meta&.dig("_meta") || envelope.meta || {}
|
|
19
|
+
begin
|
|
20
|
+
schema.validate!(frontmatter)
|
|
21
|
+
{ pass: true }
|
|
22
|
+
rescue Textus::SchemaViolation => e
|
|
23
|
+
{ pass: false, reason: schema_reason(e) }
|
|
24
|
+
end
|
|
25
|
+
rescue Textus::UnknownKey
|
|
26
|
+
{ pass: true }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.schema_reason(err)
|
|
30
|
+
d = err.details
|
|
31
|
+
return err.message.dup unless d.is_a?(Hash)
|
|
32
|
+
return "missing required fields: #{Array(d["missing"]).join(", ")}" if d["missing"]
|
|
33
|
+
return "field '#{d["field"]}': #{d["reason"]}" if d["field"]
|
|
34
|
+
|
|
35
|
+
err.message.dup
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Policy
|
|
4
|
+
module Predicates
|
|
5
|
+
class TargetIsCanon
|
|
6
|
+
def self.call(manifest:, actor:, action:, key:, schemas: nil, envelope: nil, extra: {})
|
|
7
|
+
return { pass: true } if key.nil?
|
|
8
|
+
|
|
9
|
+
mentry = manifest.resolver.resolve(key).entry
|
|
10
|
+
kind = manifest.policy.declared_kind(mentry.lane.to_s)
|
|
11
|
+
pass = kind == :canon
|
|
12
|
+
{ pass:, reason: pass ? nil : "target lane '#{mentry.lane}' is not canon (kind: #{kind})" }
|
|
13
|
+
rescue Textus::UnknownKey
|
|
14
|
+
{ pass: true }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Policy
|
|
4
|
+
module Predicates
|
|
5
|
+
FLOOR = {
|
|
6
|
+
put: %w[lane_writable_by raw_lane_ingest_only],
|
|
7
|
+
key_delete: %w[lane_deletable_by],
|
|
8
|
+
key_mv: %w[lane_writable_by raw_lane_ingest_only],
|
|
9
|
+
accept: %w[author_held],
|
|
10
|
+
reject: %w[author_held],
|
|
11
|
+
propose: %w[lane_writable_by raw_lane_ingest_only],
|
|
12
|
+
key_mv_prefix: %w[lane_writable_by raw_lane_ingest_only],
|
|
13
|
+
key_delete_prefix: %w[lane_writable_by raw_lane_ingest_only],
|
|
14
|
+
ingest: %w[lane_writable_by raw_write_once],
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
CLASSES = {
|
|
18
|
+
"lane_writable_by" => "LaneWritableBy",
|
|
19
|
+
"author_held" => "AuthorHeld",
|
|
20
|
+
"target_is_canon" => "TargetIsCanon",
|
|
21
|
+
"etag_match" => "EtagMatch",
|
|
22
|
+
"schema_valid" => "SchemaValid",
|
|
23
|
+
"fresh_within" => "FreshWithin",
|
|
24
|
+
"raw_lane_ingest_only" => "RawLaneIngestOnly",
|
|
25
|
+
"raw_write_once" => "RawWriteOnce",
|
|
26
|
+
"lane_deletable_by" => "LaneDeletableBy",
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
def by_name(name)
|
|
32
|
+
short = CLASSES.fetch(name.to_s) do
|
|
33
|
+
raise Textus::UsageError.new("unknown predicate '#{name}'")
|
|
34
|
+
end
|
|
35
|
+
const_get(short)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def evaluate(manifest:, action:, actor:, key:, schemas: nil, envelope: nil, extra: {}, rule_predicates: [])
|
|
39
|
+
failures = []
|
|
40
|
+
(FLOOR.fetch(action, []) + rule_predicates).uniq.each do |pred_name|
|
|
41
|
+
result = by_name(pred_name).call(
|
|
42
|
+
manifest:, schemas:, actor:, action:, key:, envelope:, extra:,
|
|
43
|
+
)
|
|
44
|
+
next if result[:pass]
|
|
45
|
+
raise result[:error] if result[:error]
|
|
46
|
+
|
|
47
|
+
failures << [pred_name, result[:reason]]
|
|
48
|
+
end
|
|
49
|
+
raise Textus::GuardFailed.new(failures) unless failures.empty?
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -21,7 +21,7 @@ module Textus
|
|
|
21
21
|
raise Textus::BadManifest.new("retention action must be one of #{ACTIONS.join("|")}, got #{raw["action"].inspect}")
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
def ttl_seconds = Textus::
|
|
24
|
+
def ttl_seconds = Textus::Value::Duration.seconds(@ttl)
|
|
25
25
|
def destructive? = true
|
|
26
26
|
end
|
|
27
27
|
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Orchestration
|
|
3
|
+
ListKeysQuery = Data.define(:prefix, :lane)
|
|
4
|
+
MoveKeyCommand = Data.define(:old_key, :new_key, :if_etag, :dry_run)
|
|
5
|
+
DeleteKeyCommand = Data.define(:key, :if_etag)
|
|
6
|
+
AuditQuery = Data.define(:seq_since, :key, :lane, :role, :verb, :since, :correlation_id, :limit)
|
|
7
|
+
|
|
8
|
+
def initialize(list_keys:, move_key:, delete_key:, audit_entries:)
|
|
9
|
+
@list_keys = list_keys
|
|
10
|
+
@move_key = move_key
|
|
11
|
+
@delete_key = delete_key
|
|
12
|
+
@audit_entries = audit_entries
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def list_keys(prefix:, lane:, call:)
|
|
16
|
+
query = ListKeysQuery.new(prefix: prefix, lane: lane)
|
|
17
|
+
normalize(@list_keys.call(query, call), key: "rows")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def move_key(old_key:, new_key:, call:, if_etag: nil, dry_run: false)
|
|
21
|
+
command = MoveKeyCommand.new(old_key: old_key, new_key: new_key, if_etag: if_etag, dry_run: dry_run)
|
|
22
|
+
normalize(@move_key.call(command, call), key: "move")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def delete_key(key:, call:, if_etag: nil)
|
|
26
|
+
command = DeleteKeyCommand.new(key: key, if_etag: if_etag)
|
|
27
|
+
normalize(@delete_key.call(command, call), key: "delete")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# rubocop:disable Metrics/ParameterLists
|
|
31
|
+
def audit_entries(call:, seq_since: nil, key: nil, lane: nil, role: nil, verb: nil, since: nil, correlation_id: nil, limit: nil)
|
|
32
|
+
query = AuditQuery.new(
|
|
33
|
+
seq_since: seq_since,
|
|
34
|
+
key: key,
|
|
35
|
+
lane: lane,
|
|
36
|
+
role: role,
|
|
37
|
+
verb: verb,
|
|
38
|
+
since: since,
|
|
39
|
+
correlation_id: correlation_id,
|
|
40
|
+
limit: limit,
|
|
41
|
+
)
|
|
42
|
+
normalize(@audit_entries.call(query, call), key: "rows")
|
|
43
|
+
end
|
|
44
|
+
# rubocop:enable Metrics/ParameterLists
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def normalize(result, key:)
|
|
49
|
+
return result unless result.is_a?(Value::Result)
|
|
50
|
+
return result if result.failure?
|
|
51
|
+
|
|
52
|
+
Value::Result.success({ key => result.value })
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -14,9 +14,9 @@ module Textus
|
|
|
14
14
|
DEFAULT_MAX_SIZE = 10_485_760
|
|
15
15
|
DEFAULT_KEEP = 5
|
|
16
16
|
|
|
17
|
-
def initialize(root, max_size: DEFAULT_MAX_SIZE, keep: DEFAULT_KEEP)
|
|
18
|
-
@
|
|
19
|
-
@path =
|
|
17
|
+
def initialize(root = nil, layout: nil, max_size: DEFAULT_MAX_SIZE, keep: DEFAULT_KEEP)
|
|
18
|
+
@geometry = layout || Textus::Store::Layout.new(root)
|
|
19
|
+
@path = @geometry.audit_log_path
|
|
20
20
|
@max_size = max_size
|
|
21
21
|
@keep = keep
|
|
22
22
|
end
|
|
@@ -137,11 +137,11 @@ module Textus
|
|
|
137
137
|
end
|
|
138
138
|
|
|
139
139
|
def rotated(n)
|
|
140
|
-
|
|
140
|
+
@geometry.audit_rotated_log_path(n)
|
|
141
141
|
end
|
|
142
142
|
|
|
143
143
|
def rotated_meta(n)
|
|
144
|
-
|
|
144
|
+
@geometry.audit_rotated_meta_path(n)
|
|
145
145
|
end
|
|
146
146
|
|
|
147
147
|
# Caller holds the flock. Returns the highest seq across the active log,
|
|
@@ -248,7 +248,7 @@ module Textus
|
|
|
248
248
|
end
|
|
249
249
|
|
|
250
250
|
def all_log_files
|
|
251
|
-
rotated = Dir.glob(
|
|
251
|
+
rotated = Dir.glob(@geometry.audit_log_glob)
|
|
252
252
|
.reject { |path| path.end_with?(".meta.json") }
|
|
253
253
|
.sort_by { |path| -path.scan(/\d+$/).first.to_i }
|
|
254
254
|
active_log = File.exist?(@path) ? [@path] : []
|
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
require "digest"
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
|
-
module
|
|
5
|
-
# Pure value object representing a published-file sentinel. Holds the
|
|
6
|
-
# recorded target path, source path, sha256 checksum, and publish mode.
|
|
7
|
-
# Has no filesystem I/O — path layout and persistence live in
|
|
8
|
-
# Ports::SentinelStore; predicate methods accept a FileStat port for
|
|
9
|
-
# existence and content checks.
|
|
4
|
+
module Port
|
|
10
5
|
class Sentinel
|
|
11
6
|
attr_reader :target, :source, :sha256, :mode
|
|
12
7
|
|
|
@@ -26,7 +26,7 @@ module Textus
|
|
|
26
26
|
|
|
27
27
|
def load(path, repo_root)
|
|
28
28
|
raw = JSON.parse(File.read(path))
|
|
29
|
-
Textus::
|
|
29
|
+
Textus::Port::Sentinel.new(
|
|
30
30
|
target: absolutize(raw["target"], repo_root),
|
|
31
31
|
source: absolutize(raw["source"], repo_root),
|
|
32
32
|
sha256: raw["sha256"],
|
|
@@ -39,14 +39,14 @@ module Textus
|
|
|
39
39
|
def sentinel_path(target, store_root)
|
|
40
40
|
repo_root = File.dirname(store_root)
|
|
41
41
|
rel = relative_to(target, repo_root) || File.basename(target)
|
|
42
|
-
File.join(Textus::Store::
|
|
42
|
+
File.join(Textus::Store::Layout.new(store_root).sentinels_root, rel + SUFFIX)
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
# Absolute target paths of every sentinel recorded under `target_dir`.
|
|
46
46
|
def targets_under(target_dir, store_root)
|
|
47
47
|
repo_root = File.dirname(store_root)
|
|
48
48
|
rel = relative_to(target_dir, repo_root) or return []
|
|
49
|
-
root = Textus::Store::
|
|
49
|
+
root = Textus::Store::Layout.new(store_root).sentinels_root
|
|
50
50
|
sdir = File.join(root, rel)
|
|
51
51
|
return [] unless File.directory?(sdir)
|
|
52
52
|
|
|
@@ -6,6 +6,8 @@ module Textus
|
|
|
6
6
|
# Pure filesystem I/O port. Wraps File/FileUtils/Etag with no knowledge
|
|
7
7
|
# of envelopes, entries, schemas, or audit.
|
|
8
8
|
class FileStore
|
|
9
|
+
include Interface
|
|
10
|
+
|
|
9
11
|
def read(path) = File.binread(path)
|
|
10
12
|
|
|
11
13
|
def write(path, bytes)
|
|
@@ -20,6 +22,27 @@ module Textus
|
|
|
20
22
|
def exists?(path) = File.exist?(path)
|
|
21
23
|
|
|
22
24
|
def etag(path) = Value::Etag.for_file(path)
|
|
25
|
+
|
|
26
|
+
# Convenience filesystem ops so callers can go through the port
|
|
27
|
+
# instead of calling FileUtils/Dir directly. Keeps filesystem
|
|
28
|
+
# semantics in one place for easier testing and replacement.
|
|
29
|
+
def mkdir_p(path)
|
|
30
|
+
FileUtils.mkdir_p(path)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def mv(from_path, to_path)
|
|
34
|
+
FileUtils.mkdir_p(File.dirname(to_path))
|
|
35
|
+
FileUtils.mv(from_path, to_path)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def rmdir(path)
|
|
39
|
+
Dir.rmdir(path)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def dir_empty?(dir)
|
|
43
|
+
# Dir.empty? exists on modern Rubies; wrap for clarity
|
|
44
|
+
Dir.empty?(dir)
|
|
45
|
+
end
|
|
23
46
|
end
|
|
24
47
|
end
|
|
25
48
|
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Port
|
|
3
|
+
module Storage
|
|
4
|
+
module Interface
|
|
5
|
+
def read(path) = raise NotImplementedError
|
|
6
|
+
def write(path, bytes) = raise NotImplementedError
|
|
7
|
+
def delete(path) = raise NotImplementedError
|
|
8
|
+
def exists?(path) = raise NotImplementedError
|
|
9
|
+
def etag(path) = raise NotImplementedError
|
|
10
|
+
def mkdir_p(path) = raise NotImplementedError
|
|
11
|
+
def mv(from_path, to_path) = raise NotImplementedError
|
|
12
|
+
def rmdir(path) = raise NotImplementedError
|
|
13
|
+
def dir_empty?(dir) = raise NotImplementedError
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/textus/port/store.rb
CHANGED
|
@@ -11,8 +11,7 @@ module Textus
|
|
|
11
11
|
attr_reader :path, :connection
|
|
12
12
|
|
|
13
13
|
def initialize(root:)
|
|
14
|
-
@
|
|
15
|
-
@path = Textus::Store::Geometry.new(root).store_db_path
|
|
14
|
+
@path = Textus::Store::Layout.new(root).store_db_path
|
|
16
15
|
FileUtils.mkdir_p(File.dirname(@path))
|
|
17
16
|
@connection = SQLite3::Database.new(@path)
|
|
18
17
|
@connection.results_as_hash = true
|
|
@@ -61,10 +60,48 @@ module Textus
|
|
|
61
60
|
|
|
62
61
|
CREATE INDEX IF NOT EXISTS idx_jobs_state ON jobs(state);
|
|
63
62
|
CREATE INDEX IF NOT EXISTS idx_entries_lane ON entries(lane);
|
|
63
|
+
|
|
64
|
+
CREATE TABLE IF NOT EXISTS audit_events (
|
|
65
|
+
seq INTEGER PRIMARY KEY,
|
|
66
|
+
ts TEXT NOT NULL,
|
|
67
|
+
role TEXT NOT NULL,
|
|
68
|
+
verb TEXT NOT NULL,
|
|
69
|
+
key TEXT NOT NULL,
|
|
70
|
+
etag_before TEXT,
|
|
71
|
+
etag_after TEXT
|
|
72
|
+
) STRICT;
|
|
73
|
+
|
|
74
|
+
CREATE INDEX IF NOT EXISTS idx_audit_events_seq ON audit_events(seq);
|
|
64
75
|
SQL
|
|
76
|
+
# Idempotent migration: add schema_ref column if missing (existing stores).
|
|
77
|
+
execute("ALTER TABLE entries ADD COLUMN schema_ref TEXT") rescue nil # rubocop:disable Style/RescueModifier
|
|
65
78
|
self
|
|
66
79
|
end
|
|
67
80
|
|
|
81
|
+
def search_entries(q: nil, schema: nil, lane: nil, prefix: nil) # rubocop:disable Naming/MethodParameterName
|
|
82
|
+
return nil if q.nil? && schema.nil?
|
|
83
|
+
|
|
84
|
+
if q
|
|
85
|
+
fts_search(q: q, schema: schema, lane: lane, prefix: prefix)
|
|
86
|
+
else
|
|
87
|
+
schema_search(schema: schema, lane: lane, prefix: prefix)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def insert_audit_event(seq:, ts:, role:, verb:, key:, etag_before:, etag_after:) # rubocop:disable Naming/MethodParameterName
|
|
92
|
+
execute(
|
|
93
|
+
"INSERT OR IGNORE INTO audit_events (seq, ts, role, verb, key, etag_before, etag_after) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
94
|
+
[seq, ts, role, verb, key, etag_before, etag_after],
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def audit_events_since(seq:)
|
|
99
|
+
execute(
|
|
100
|
+
"SELECT seq, ts, role, verb, key, etag_before, etag_after FROM audit_events WHERE seq > ? ORDER BY seq",
|
|
101
|
+
[seq],
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
68
105
|
def transaction
|
|
69
106
|
connection.transaction
|
|
70
107
|
yield
|
|
@@ -88,6 +125,25 @@ module Textus
|
|
|
88
125
|
store&.close
|
|
89
126
|
end
|
|
90
127
|
private :connection
|
|
128
|
+
|
|
129
|
+
def fts_search(q:, schema:, lane:, prefix:) # rubocop:disable Naming/MethodParameterName
|
|
130
|
+
sql = "SELECT e.key, e.lane, e.schema_ref FROM entries e JOIN entries_fts fts ON e.rowid = fts.rowid WHERE entries_fts MATCH ?"
|
|
131
|
+
params = [q]
|
|
132
|
+
sql += " AND e.lane = ?" and params << lane if lane
|
|
133
|
+
sql += " AND e.schema_ref = ?" and params << schema if schema
|
|
134
|
+
sql += " AND (e.key = ? OR e.key LIKE ?)" and params.push(prefix, "#{prefix}.%") if prefix
|
|
135
|
+
execute(sql, params)
|
|
136
|
+
end
|
|
137
|
+
private :fts_search
|
|
138
|
+
|
|
139
|
+
def schema_search(schema:, lane:, prefix:)
|
|
140
|
+
sql = "SELECT key, lane, schema_ref FROM entries WHERE schema_ref = ?"
|
|
141
|
+
params = [schema]
|
|
142
|
+
sql += " AND lane = ?" and params << lane if lane
|
|
143
|
+
sql += " AND (key = ? OR key LIKE ?)" and params.push(prefix, "#{prefix}.%") if prefix
|
|
144
|
+
execute(sql, params)
|
|
145
|
+
end
|
|
146
|
+
private :schema_search
|
|
91
147
|
end
|
|
92
148
|
end
|
|
93
149
|
end
|
|
@@ -8,13 +8,13 @@ module Textus
|
|
|
8
8
|
# Process death releases the flock automatically.
|
|
9
9
|
class WatcherLock
|
|
10
10
|
def initialize(root)
|
|
11
|
-
@path = Textus::Store::
|
|
11
|
+
@path = Textus::Store::Layout.new(root).lock_path("watcher")
|
|
12
12
|
@file = nil
|
|
13
13
|
FileUtils.mkdir_p(File.dirname(@path))
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def self.running?(root)
|
|
17
|
-
path = Textus::Store::
|
|
17
|
+
path = Textus::Store::Layout.new(root).lock_path("watcher")
|
|
18
18
|
return false unless File.exist?(path)
|
|
19
19
|
|
|
20
20
|
File.open(path, "r+") do |file|
|
|
@@ -33,17 +33,7 @@ module Textus
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def publish_only(key)
|
|
36
|
-
|
|
37
|
-
return unless entry.publish_tree || !Array(entry.publish_to).empty?
|
|
38
|
-
|
|
39
|
-
reader = Textus::Store::Envelope::Reader.from(container: @container)
|
|
40
|
-
entry_path = @container.manifest.resolver.resolve(key).path
|
|
41
|
-
return unless entry.publish_tree || File.exist?(entry_path)
|
|
42
|
-
|
|
43
|
-
pctx = Textus::Manifest::Entry::Base::PublishContext.new(
|
|
44
|
-
container: @container, call: @call, reader: reader.method(:read),
|
|
45
|
-
)
|
|
46
|
-
entry.publish_via(pctx)
|
|
36
|
+
Textus::Produce::Publisher.call(container: @container, call: @call, key: key)
|
|
47
37
|
end
|
|
48
38
|
end
|
|
49
39
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Produce
|
|
3
|
+
module Publisher
|
|
4
|
+
def self.call(container:, call:, key:)
|
|
5
|
+
entry = container.manifest.resolver.resolve(key).entry
|
|
6
|
+
return unless entry.publish_tree || !Array(entry.publish_to).empty?
|
|
7
|
+
|
|
8
|
+
entry_path = container.manifest.resolver.resolve(key).path
|
|
9
|
+
return unless entry.publish_tree || container.file_store.exists?(entry_path)
|
|
10
|
+
|
|
11
|
+
reader = Textus::Store::Entry::Reader.from(container: container)
|
|
12
|
+
pctx = Textus::Manifest::Entry::Base::PublishContext.new(
|
|
13
|
+
container: container,
|
|
14
|
+
call: call,
|
|
15
|
+
reader: reader.method(:read),
|
|
16
|
+
)
|
|
17
|
+
entry.publish_via(pctx)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Schema
|
|
3
|
+
class Registry
|
|
4
|
+
def initialize(dir)
|
|
5
|
+
@dir = dir
|
|
6
|
+
@schemas = {}
|
|
7
|
+
load_all
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def fetch(name)
|
|
11
|
+
@schemas[name] || raise(IoError.new("schema not found: #{File.join(@dir, "#{name}.yaml")}"))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def fetch_or_nil(name)
|
|
15
|
+
return nil if name.nil?
|
|
16
|
+
|
|
17
|
+
fetch(name)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def all
|
|
21
|
+
@schemas.values
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def by_name
|
|
25
|
+
@schemas.dup
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def load_all
|
|
31
|
+
return unless File.directory?(@dir)
|
|
32
|
+
|
|
33
|
+
Dir.glob(File.join(@dir, "*.yaml")).each do |path|
|
|
34
|
+
name = File.basename(path, ".yaml")
|
|
35
|
+
@schemas[name] = Schema.load(path)
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
warn "textus: failed to load schema '#{name}' at #{path}: #{e.message}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/textus/schema/tools.rb
CHANGED
|
@@ -14,7 +14,7 @@ module Textus
|
|
|
14
14
|
"optional" => [],
|
|
15
15
|
"fields" => meta.each_with_object({}) { |(k, v), h| h[k] = { "type" => infer_type(v) } },
|
|
16
16
|
}
|
|
17
|
-
geom = Textus::Store::
|
|
17
|
+
geom = Textus::Store::Layout.new(store.root)
|
|
18
18
|
FileUtils.mkdir_p(geom.schemas_dir)
|
|
19
19
|
target = geom.schema_path(name)
|
|
20
20
|
File.write(target, YAML.dump(schema))
|
|
@@ -51,7 +51,7 @@ module Textus
|
|
|
51
51
|
raise UsageError.new("schema migrate needs --rename=OLD:NEW or schema.evolution.migrate_from") if renames.empty?
|
|
52
52
|
|
|
53
53
|
authority = accept_role_for(store)
|
|
54
|
-
ops = store.
|
|
54
|
+
ops = store.with_role(authority)
|
|
55
55
|
touched = []
|
|
56
56
|
store.manifest.resolver.enumerate.each do |row|
|
|
57
57
|
env = pure_get(store, authority, row[:key])
|
|
@@ -85,14 +85,7 @@ module Textus
|
|
|
85
85
|
# Orchestrator-free read: schema tooling must never trigger a fetch
|
|
86
86
|
# while inspecting/migrating entries (ADR 0062).
|
|
87
87
|
def self.pure_get(store, role, key)
|
|
88
|
-
|
|
89
|
-
Value::Result.unwrap(
|
|
90
|
-
Textus::Action::Get.call(
|
|
91
|
-
container: scope.container,
|
|
92
|
-
call: Textus::Value::Call.build(role: role),
|
|
93
|
-
key: key,
|
|
94
|
-
),
|
|
95
|
-
)
|
|
88
|
+
store.with_role(role).get(key)
|
|
96
89
|
end
|
|
97
90
|
|
|
98
91
|
def self.load_schema(store, name)
|