textus 0.8.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +329 -0
- data/README.md +50 -22
- data/SPEC.md +194 -63
- data/docs/architecture.md +22 -4
- data/docs/conventions.md +24 -17
- data/lib/textus/application/context.rb +44 -0
- data/lib/textus/application/reads/audit.rb +69 -0
- data/lib/textus/application/reads/blame.rb +79 -0
- data/lib/textus/application/reads/freshness.rb +77 -0
- data/lib/textus/application/reads/get.rb +62 -0
- data/lib/textus/application/reads/policy_explain.rb +39 -0
- data/lib/textus/application/refresh/all.rb +41 -0
- data/lib/textus/application/refresh/orchestrator.rb +69 -0
- data/lib/textus/application/refresh/worker.rb +79 -0
- data/lib/textus/application/writes/accept.rb +44 -0
- data/lib/textus/application/writes/build.rb +116 -0
- data/lib/textus/application/writes/delete.rb +36 -0
- data/lib/textus/application/writes/publish.rb +25 -0
- data/lib/textus/application/writes/put.rb +43 -0
- data/lib/textus/builder/pipeline.rb +1 -1
- data/lib/textus/builder/renderer/json.rb +1 -1
- data/lib/textus/builder/renderer/markdown.rb +1 -1
- data/lib/textus/builder/renderer/text.rb +1 -1
- data/lib/textus/builder/renderer/yaml.rb +1 -1
- data/lib/textus/builder/renderer.rb +1 -1
- data/lib/textus/cli/group/policy.rb +11 -0
- data/lib/textus/cli/verb/accept.rb +2 -2
- data/lib/textus/cli/verb/audit.rb +30 -0
- data/lib/textus/cli/verb/blame.rb +16 -0
- data/lib/textus/cli/verb/build.rb +2 -1
- data/lib/textus/cli/verb/delete.rb +2 -2
- data/lib/textus/cli/verb/freshness.rb +16 -0
- data/lib/textus/cli/verb/get.rb +7 -1
- data/lib/textus/cli/verb/hook_run.rb +4 -4
- data/lib/textus/cli/verb/mv.rb +1 -2
- data/lib/textus/cli/verb/policy_explain.rb +14 -0
- data/lib/textus/cli/verb/policy_list.rb +25 -0
- data/lib/textus/cli/verb/put.rb +10 -8
- data/lib/textus/cli/verb/refresh.rb +2 -2
- data/lib/textus/cli/verb/refresh_stale.rb +18 -0
- data/lib/textus/cli/verb/reject.rb +14 -0
- data/lib/textus/cli/verb.rb +14 -0
- data/lib/textus/cli.rb +16 -2
- data/lib/textus/composition.rb +72 -0
- data/lib/textus/doctor/check/handler_allowlist.rb +33 -0
- data/lib/textus/doctor/check/intake_registration.rb +46 -0
- data/lib/textus/doctor/check/legacy_intake_fields.rb +57 -0
- data/lib/textus/doctor/check/policy_ambiguity.rb +49 -0
- data/lib/textus/doctor.rb +7 -1
- data/lib/textus/domain/action.rb +9 -0
- data/lib/textus/domain/freshness/evaluator.rb +30 -0
- data/lib/textus/domain/freshness/policy.rb +18 -0
- data/lib/textus/domain/freshness/verdict.rb +12 -0
- data/lib/textus/domain/outcome.rb +10 -0
- data/lib/textus/domain/permission.rb +15 -0
- data/lib/textus/domain/policy/handler_allowlist.rb +17 -0
- data/lib/textus/domain/policy/matcher.rb +51 -0
- data/lib/textus/domain/policy/promote.rb +24 -0
- data/lib/textus/domain/policy/refresh.rb +48 -0
- data/lib/textus/domain/policy.rb +7 -0
- data/lib/textus/hooks/builtin.rb +5 -5
- data/lib/textus/hooks/dispatcher.rb +15 -1
- data/lib/textus/hooks/dsl.rb +18 -0
- data/lib/textus/hooks/registry.rb +12 -5
- data/lib/textus/infra/clock.rb +9 -0
- data/lib/textus/infra/event_bus.rb +27 -0
- data/lib/textus/infra/publisher.rb +73 -0
- data/lib/textus/infra/refresh/detached.rb +38 -0
- data/lib/textus/infra/refresh/lock.rb +44 -0
- data/lib/textus/init.rb +71 -28
- data/lib/textus/intro.rb +17 -14
- data/lib/textus/manifest/entry.rb +39 -13
- data/lib/textus/manifest/policies.rb +83 -0
- data/lib/textus/manifest.rb +30 -11
- data/lib/textus/projection.rb +1 -1
- data/lib/textus/proposal.rb +4 -21
- data/lib/textus/refresh.rb +9 -45
- data/lib/textus/store/mover.rb +14 -9
- data/lib/textus/store/reader.rb +10 -8
- data/lib/textus/store/staleness.rb +5 -17
- data/lib/textus/store/validator.rb +46 -20
- data/lib/textus/store/writer.rb +51 -14
- data/lib/textus/store.rb +30 -10
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +1 -0
- metadata +46 -5
- data/lib/textus/builder.rb +0 -86
- data/lib/textus/cli/verb/stale.rb +0 -14
- data/lib/textus/publisher.rb +0 -71
- data/lib/textus/store/view.rb +0 -29
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Infra
|
|
3
|
+
module Refresh
|
|
4
|
+
module Detached
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def supported?
|
|
8
|
+
Process.respond_to?(:fork)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def spawn(store_root:, key:)
|
|
12
|
+
return nil unless supported?
|
|
13
|
+
|
|
14
|
+
pid = Process.fork do
|
|
15
|
+
$stdin.close
|
|
16
|
+
$stdout.reopen(File::NULL, "w")
|
|
17
|
+
$stderr.reopen(File::NULL, "w")
|
|
18
|
+
|
|
19
|
+
lock = Textus::Infra::Refresh::Lock.new(root: store_root, key: key)
|
|
20
|
+
exit(0) unless lock.try_acquire
|
|
21
|
+
|
|
22
|
+
begin
|
|
23
|
+
store = Textus::Store.new(store_root)
|
|
24
|
+
Textus::Refresh.call(store, key, as: "script")
|
|
25
|
+
rescue StandardError
|
|
26
|
+
# Already logged via :refresh_failed; exit cleanly.
|
|
27
|
+
ensure
|
|
28
|
+
lock.release
|
|
29
|
+
exit(0)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
Process.detach(pid)
|
|
33
|
+
pid
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Infra
|
|
5
|
+
module Refresh
|
|
6
|
+
class Lock
|
|
7
|
+
def initialize(root:, key:)
|
|
8
|
+
@root = root
|
|
9
|
+
@key = key
|
|
10
|
+
@path = File.join(root, ".locks", "#{safe_key}.lock")
|
|
11
|
+
@file = nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def try_acquire # rubocop:disable Naming/PredicateMethod
|
|
15
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
16
|
+
@file = File.open(@path, File::RDWR | File::CREAT, 0o644)
|
|
17
|
+
acquired = @file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
18
|
+
unless acquired
|
|
19
|
+
@file.close
|
|
20
|
+
@file = nil
|
|
21
|
+
return false
|
|
22
|
+
end
|
|
23
|
+
@file.write(Process.pid.to_s)
|
|
24
|
+
@file.flush
|
|
25
|
+
true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def release
|
|
29
|
+
return unless @file
|
|
30
|
+
|
|
31
|
+
@file.flock(File::LOCK_UN)
|
|
32
|
+
@file.close
|
|
33
|
+
@file = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def safe_key
|
|
39
|
+
@key.to_s.gsub(/[^a-zA-Z0-9._-]/, "_")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
data/lib/textus/init.rb
CHANGED
|
@@ -2,21 +2,83 @@ require "fileutils"
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Init
|
|
5
|
-
ZONES = %w[
|
|
5
|
+
ZONES = %w[identity working inbox review output].freeze
|
|
6
6
|
|
|
7
7
|
DEFAULT_MANIFEST = <<~YAML
|
|
8
8
|
version: textus/2
|
|
9
9
|
zones:
|
|
10
|
-
- { name:
|
|
11
|
-
- { name: working,
|
|
12
|
-
- { name:
|
|
13
|
-
- { name:
|
|
14
|
-
- { name:
|
|
10
|
+
- { name: identity, writable_by: [human] }
|
|
11
|
+
- { name: working, writable_by: [human, ai, script] }
|
|
12
|
+
- { name: inbox, writable_by: [script] }
|
|
13
|
+
- { name: review, writable_by: [ai, human] }
|
|
14
|
+
- { name: output, writable_by: [build] }
|
|
15
15
|
entries:
|
|
16
|
-
- { key:
|
|
17
|
-
- { key: working.notes,
|
|
16
|
+
- { key: identity.self, path: identity/self.md, zone: identity, schema: null, owner: human:self }
|
|
17
|
+
- { key: working.notes, path: working/notes, zone: working, schema: null, owner: human:self, nested: true }
|
|
18
18
|
YAML
|
|
19
19
|
|
|
20
|
+
HOOKS_README = <<~MD
|
|
21
|
+
# Hooks
|
|
22
|
+
|
|
23
|
+
Drop one Ruby file per hook. All hooks register through one DSL.
|
|
24
|
+
Files anywhere under `.textus/hooks/` (including subdirectories) are loaded at
|
|
25
|
+
startup in alphabetical order by full path. Subdirectory names are organizational
|
|
26
|
+
only — the registered event and name come from the DSL call, not the file path.
|
|
27
|
+
|
|
28
|
+
## Per-event sugar (preferred)
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
Textus.intake(:my_source) do |config:, args:, **|
|
|
32
|
+
{ _meta: { "last_refreshed_at" => Time.now.utc.iso8601 }, body: "…" }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
Textus.reduce(:my_source) { |rows:, **| rows.map { |r| r.merge(processed: true) } }
|
|
36
|
+
Textus.check(:my_check) { |store:, **| { ok: true } }
|
|
37
|
+
Textus.put(:my_listener, keys: ["working.*"]) { |key:, envelope:, **| }
|
|
38
|
+
|
|
39
|
+
# Run a side-effect every time textus writes a file to your repo:
|
|
40
|
+
Textus.published(:notify) do |key:, target:, **|
|
|
41
|
+
warn "wrote \#{target} (from \#{key})"
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The intake handler above is paired with a manifest entry plus a
|
|
46
|
+
top-level `policies:` block for freshness (ttl/on_stale live in
|
|
47
|
+
policies, not in the entry):
|
|
48
|
+
|
|
49
|
+
```yaml
|
|
50
|
+
entries:
|
|
51
|
+
- key: inbox.foo
|
|
52
|
+
path: inbox/foo.md
|
|
53
|
+
zone: inbox
|
|
54
|
+
intake:
|
|
55
|
+
handler: my_source
|
|
56
|
+
|
|
57
|
+
policies:
|
|
58
|
+
- match: inbox.foo
|
|
59
|
+
refresh:
|
|
60
|
+
ttl: 10m
|
|
61
|
+
on_stale: timed_sync # warn | sync | timed_sync (default: warn)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Low-level primitive (always available)
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
Textus.hook(:intake, :name) { |store:, config:, args:| ... } # bring bytes in
|
|
68
|
+
Textus.hook(:reduce, :name) { |store:, rows:, config:| ... } # transform rows
|
|
69
|
+
Textus.hook(:check, :name) { |store:| ... } # doctor check
|
|
70
|
+
Textus.hook(:put, :name, keys: ["..."]) # lifecycle listener
|
|
71
|
+
{ |store:, key:, envelope:| ... }
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Events: :intake, :reduce, :check (rpc — return value used)
|
|
75
|
+
:put, :deleted, :refreshed, :built, :accepted, :published,
|
|
76
|
+
:mv, :reject, :loaded,
|
|
77
|
+
:refresh_began, :refresh_failed, :refresh_detached (pub-sub — return discarded)
|
|
78
|
+
|
|
79
|
+
See SPEC.md §5.10 for the full table.
|
|
80
|
+
MD
|
|
81
|
+
|
|
20
82
|
def self.run(target_root)
|
|
21
83
|
raise UsageError.new(".textus/ already exists at #{target_root}") if File.directory?(target_root)
|
|
22
84
|
|
|
@@ -28,26 +90,7 @@ module Textus
|
|
|
28
90
|
FileUtils.mkdir_p(dir)
|
|
29
91
|
File.write(File.join(dir, ".gitkeep"), "")
|
|
30
92
|
end
|
|
31
|
-
File.write(File.join(target_root, "hooks", "README.md"),
|
|
32
|
-
# Hooks
|
|
33
|
-
|
|
34
|
-
Drop one Ruby file per hook. All hooks register through one DSL.
|
|
35
|
-
Every handler receives `store:` as its first kwarg, then event-specific args.
|
|
36
|
-
|
|
37
|
-
```ruby
|
|
38
|
-
Textus.hook(:fetch, :name) { |store:, config:, args:| ... } # bring bytes in
|
|
39
|
-
Textus.hook(:reduce, :name) { |store:, rows:, config:| ... } # transform rows
|
|
40
|
-
Textus.hook(:check, :name) { |store:| ... } # doctor check
|
|
41
|
-
Textus.hook(:put, :name, keys: ["..."]) # lifecycle listener
|
|
42
|
-
{ |store:, key:, envelope:| ... }
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
Events: :fetch, :reduce, :check (rpc — return value used)
|
|
46
|
-
:put, :delete, :refresh, :build, :accept (pub-sub — return discarded)
|
|
47
|
-
|
|
48
|
-
See SPEC.md §5.10 for the full table.
|
|
49
|
-
MD
|
|
50
|
-
|
|
93
|
+
File.write(File.join(target_root, "hooks", "README.md"), HOOKS_README)
|
|
51
94
|
File.write(File.join(target_root, "manifest.yaml"), DEFAULT_MANIFEST)
|
|
52
95
|
{ "protocol" => PROTOCOL, "initialized" => target_root }
|
|
53
96
|
end
|
data/lib/textus/intro.rb
CHANGED
|
@@ -11,19 +11,19 @@ module Textus
|
|
|
11
11
|
# Conventional zone purposes. Unknown zones (declared in the manifest
|
|
12
12
|
# but not listed here) get no `purpose` field.
|
|
13
13
|
ZONE_PURPOSES = {
|
|
14
|
-
"
|
|
14
|
+
"identity" => "slow-changing identity; human-only writes",
|
|
15
15
|
"working" => "active project state; humans, AI, and scripts share this surface",
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
16
|
+
"inbox" => "declared external inputs; script-refreshed via actions",
|
|
17
|
+
"review" => "AI proposals awaiting human accept",
|
|
18
|
+
"output" => "build-computed outputs; never hand-edited",
|
|
19
19
|
}.freeze
|
|
20
20
|
|
|
21
21
|
WRITE_FLOWS = {
|
|
22
|
-
"human" => "edit files in
|
|
23
|
-
"ai" => "propose changes by writing '
|
|
22
|
+
"human" => "edit files in identity/working zones, then 'textus put KEY --as=human'",
|
|
23
|
+
"ai" => "propose changes by writing 'review.*' entries with --as=ai and a 'proposal:' frontmatter block; " \
|
|
24
24
|
"a human runs 'textus accept' to apply",
|
|
25
|
-
"script" => "refresh
|
|
26
|
-
"build" => "'textus build' computes
|
|
25
|
+
"script" => "refresh inbox entries with 'textus refresh KEY --as=script' (uses the entry's declared action)",
|
|
26
|
+
"build" => "'textus build' computes output entries from projections; output files are never hand-edited",
|
|
27
27
|
}.freeze
|
|
28
28
|
|
|
29
29
|
# The CLI verb catalog. Truth lives here; do not derive dynamically.
|
|
@@ -36,13 +36,16 @@ module Textus
|
|
|
36
36
|
{ "name" => "where", "summary" => "resolve a key to its zone and path without reading" },
|
|
37
37
|
{ "name" => "schema", "summary" => "field shape for a key family" },
|
|
38
38
|
{ "name" => "put", "summary" => "write an entry; --as=<role>, --stdin payload" },
|
|
39
|
-
{ "name" => "accept", "summary" => "apply a
|
|
39
|
+
{ "name" => "accept", "summary" => "apply a review.* proposal; --as=human only" },
|
|
40
40
|
{ "name" => "key", "summary" => "key operations: 'key mv', 'key uid', 'key migrate'" },
|
|
41
41
|
{ "name" => "delete", "summary" => "delete an entry; --as=<role>" },
|
|
42
|
-
{ "name" => "build", "summary" => "materialize
|
|
43
|
-
{ "name" => "refresh", "summary" => "run an action for an
|
|
44
|
-
{ "name" => "
|
|
45
|
-
{ "name" => "
|
|
42
|
+
{ "name" => "build", "summary" => "materialize output entries; publish_to and publish_each fan out copies" },
|
|
43
|
+
{ "name" => "refresh", "summary" => "run an action for an inbox entry" },
|
|
44
|
+
{ "name" => "freshness", "summary" => "per-entry freshness report (status, age, ttl, on_stale)" },
|
|
45
|
+
{ "name" => "audit", "summary" => "query .textus/audit.log with filters (key, role, since, correlation-id, ...)" },
|
|
46
|
+
{ "name" => "blame", "summary" => "audit rows for one key joined with git commit metadata" },
|
|
47
|
+
{ "name" => "policy", "summary" => "inspect effective policies: 'policy list', 'policy explain KEY'" },
|
|
48
|
+
{ "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
|
|
46
49
|
{ "name" => "hook",
|
|
47
50
|
"summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
|
|
48
51
|
].freeze
|
|
@@ -80,7 +83,7 @@ module Textus
|
|
|
80
83
|
"owner" => e.owner,
|
|
81
84
|
"format" => e.format,
|
|
82
85
|
"derived" => derived,
|
|
83
|
-
"intake" => !e.
|
|
86
|
+
"intake" => !e.intake_handler.nil?,
|
|
84
87
|
"publish_to" => Array(e.publish_to),
|
|
85
88
|
"publish_each" => e.publish_each,
|
|
86
89
|
}
|
|
@@ -5,8 +5,9 @@ module Textus
|
|
|
5
5
|
PUBLISH_EACH_VAR_RE = /\{([a-z]+)\}/
|
|
6
6
|
|
|
7
7
|
attr_reader :key, :path, :zone, :schema, :owner, :nested, :generator, :raw, :format,
|
|
8
|
-
:projection, :template, :publish_to, :publish_each,
|
|
9
|
-
:
|
|
8
|
+
:projection, :template, :publish_to, :publish_each,
|
|
9
|
+
:intake_handler, :intake_config,
|
|
10
|
+
:events, :inject_intro
|
|
10
11
|
|
|
11
12
|
def initialize(manifest, raw)
|
|
12
13
|
@manifest = manifest
|
|
@@ -27,7 +28,9 @@ module Textus
|
|
|
27
28
|
@format = resolve_format!(raw["format"])
|
|
28
29
|
|
|
29
30
|
validate_events!
|
|
30
|
-
|
|
31
|
+
raise UsageError.new("entry '#{@key}': 'source:' key renamed to 'intake:' in 0.9") if raw.key?("source")
|
|
32
|
+
|
|
33
|
+
parse_intake!(raw["intake"])
|
|
31
34
|
reject_legacy_projection_keys!
|
|
32
35
|
validate_format_matrix!
|
|
33
36
|
validate_publish_each!
|
|
@@ -53,15 +56,32 @@ module Textus
|
|
|
53
56
|
@publish_each.gsub(PUBLISH_EACH_VAR_RE) { vars.fetch(::Regexp.last_match(1)) }
|
|
54
57
|
end
|
|
55
58
|
|
|
59
|
+
# Signal-based zone-kind predicates: derive the "kind" of a zone from its
|
|
60
|
+
# writable_by signals rather than its literal name. This keeps detection
|
|
61
|
+
# working when users rename the default zones (canon/intake/pending/derived
|
|
62
|
+
# → identity/inbox/review/output, etc.).
|
|
63
|
+
def in_generator_zone?
|
|
64
|
+
zone_writers.include?("build")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def in_proposal_zone?
|
|
68
|
+
zone_writers.include?("ai")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Legacy alias for in_generator_zone?. Retained because internal validation
|
|
72
|
+
# callers (and external tools) read more naturally as `derived?`.
|
|
56
73
|
def derived?
|
|
57
|
-
|
|
58
|
-
writers.include?("build")
|
|
59
|
-
rescue UsageError => e
|
|
60
|
-
raise UsageError.new("entry '#{@key}': #{e.message}")
|
|
74
|
+
in_generator_zone?
|
|
61
75
|
end
|
|
62
76
|
|
|
63
77
|
private
|
|
64
78
|
|
|
79
|
+
def zone_writers
|
|
80
|
+
@manifest.zone_writers(@zone)
|
|
81
|
+
rescue UsageError => e
|
|
82
|
+
raise UsageError.new("entry '#{@key}': #{e.message}")
|
|
83
|
+
end
|
|
84
|
+
|
|
65
85
|
def validate_inject_intro!
|
|
66
86
|
return unless @inject_intro
|
|
67
87
|
|
|
@@ -168,13 +188,19 @@ module Textus
|
|
|
168
188
|
end
|
|
169
189
|
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
170
190
|
|
|
171
|
-
def
|
|
172
|
-
|
|
173
|
-
|
|
191
|
+
def parse_intake!(src)
|
|
192
|
+
raise UsageError.new("entry '#{@key}': source.fetch renamed to intake.handler in 0.9") if src.is_a?(Hash) && src.key?("fetch")
|
|
193
|
+
|
|
194
|
+
if src.is_a?(Hash) && (src.key?("ttl") || src.key?("on_stale") || src.key?("sync_budget_ms"))
|
|
195
|
+
raise UsageError.new(
|
|
196
|
+
"entry '#{@key}': intake.ttl/intake.on_stale/intake.sync_budget_ms removed in 0.9.2 — " \
|
|
197
|
+
"move into a top-level policies: block (see CHANGELOG migration recipe).",
|
|
198
|
+
)
|
|
199
|
+
end
|
|
174
200
|
|
|
175
|
-
|
|
176
|
-
@
|
|
177
|
-
@
|
|
201
|
+
src ||= {}
|
|
202
|
+
@intake_handler = src["handler"]
|
|
203
|
+
@intake_config = src["config"] || {}
|
|
178
204
|
end
|
|
179
205
|
|
|
180
206
|
def reject_legacy_projection_keys!
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Policies
|
|
4
|
+
PolicySet = Data.define(:refresh, :handler_allowlist, :promote, :retention)
|
|
5
|
+
EMPTY_SET = PolicySet.new(refresh: nil, handler_allowlist: nil, promote: nil, retention: nil)
|
|
6
|
+
|
|
7
|
+
def self.parse(raw)
|
|
8
|
+
new(Array(raw).map { |b| Block.new(b) })
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(blocks)
|
|
12
|
+
@blocks = blocks
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_reader :blocks
|
|
16
|
+
|
|
17
|
+
def for(key)
|
|
18
|
+
slots = { refresh: [], handler_allowlist: [], promote: [], retention: [] }
|
|
19
|
+
@blocks.each do |b|
|
|
20
|
+
next unless Textus::Domain::Policy::Matcher.matches?(b.match, key)
|
|
21
|
+
|
|
22
|
+
slots.each_key { |slot| slots[slot] << b if b.public_send(slot) }
|
|
23
|
+
end
|
|
24
|
+
PolicySet.new(
|
|
25
|
+
refresh: pick(slots[:refresh], :refresh, key),
|
|
26
|
+
handler_allowlist: pick(slots[:handler_allowlist], :handler_allowlist, key),
|
|
27
|
+
promote: pick(slots[:promote], :promote, key),
|
|
28
|
+
retention: pick(slots[:retention], :retention, key),
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def explain(key)
|
|
33
|
+
@blocks.select { |b| Textus::Domain::Policy::Matcher.matches?(b.match, key) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def pick(blocks, slot, key)
|
|
39
|
+
return nil if blocks.empty?
|
|
40
|
+
|
|
41
|
+
globs = blocks.map(&:match)
|
|
42
|
+
winning = Textus::Domain::Policy::Matcher.pick_most_specific(globs, key: key)
|
|
43
|
+
blocks.find { |b| b.match == winning }&.public_send(slot)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class Block
|
|
47
|
+
attr_reader :match, :refresh, :handler_allowlist, :promote, :retention
|
|
48
|
+
|
|
49
|
+
def initialize(raw)
|
|
50
|
+
@match = raw["match"] or raise Textus::UsageError.new("policy block missing match:")
|
|
51
|
+
@refresh = parse_refresh(raw["refresh"])
|
|
52
|
+
@handler_allowlist = parse_handler_allowlist(raw["handler_allowlist"])
|
|
53
|
+
@promote = parse_promote(raw["promote_requires"])
|
|
54
|
+
@retention = raw["retention"] # reserved — passthrough only
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def parse_refresh(h)
|
|
60
|
+
return nil if h.nil?
|
|
61
|
+
|
|
62
|
+
Textus::Domain::Policy::Refresh.new(
|
|
63
|
+
ttl: h["ttl"],
|
|
64
|
+
on_stale: h["on_stale"] || "warn",
|
|
65
|
+
sync_budget_ms: h["sync_budget_ms"],
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def parse_handler_allowlist(arr)
|
|
70
|
+
return nil if arr.nil?
|
|
71
|
+
|
|
72
|
+
Textus::Domain::Policy::HandlerAllowlist.new(handlers: arr)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def parse_promote(arr)
|
|
76
|
+
return nil if arr.nil?
|
|
77
|
+
|
|
78
|
+
Textus::Domain::Policy::Promote.new(requires: arr)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
data/lib/textus/manifest.rb
CHANGED
|
@@ -20,6 +20,14 @@ module Textus
|
|
|
20
20
|
zones[zone_name] or raise UsageError.new("undeclared zone '#{zone_name}'")
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
def permission_for(zone_name)
|
|
24
|
+
Textus::Domain::Permission.new(
|
|
25
|
+
zone: zone_name,
|
|
26
|
+
writable_by: zone_writers(zone_name),
|
|
27
|
+
readable_by: :all,
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
23
31
|
def self.load(root)
|
|
24
32
|
manifest_path = File.join(root, "manifest.yaml")
|
|
25
33
|
raise IoError.new("manifest not found: #{manifest_path}") unless File.exist?(manifest_path)
|
|
@@ -42,10 +50,19 @@ module Textus
|
|
|
42
50
|
@raw = raw
|
|
43
51
|
raise BadFrontmatter.new(File.join(root, "manifest.yaml"), "manifest must declare zones:") if Array(raw["zones"]).empty?
|
|
44
52
|
|
|
53
|
+
reject_legacy_entry_intake_policy!(Array(raw["entries"]))
|
|
45
54
|
@entries = Array(raw["entries"]).map { |e| Manifest::Entry.new(self, e) }
|
|
46
55
|
validate_declared_keys!
|
|
47
56
|
end
|
|
48
57
|
|
|
58
|
+
def policies
|
|
59
|
+
@policies ||= Textus::Manifest::Policies.parse(@raw["policies"] || [])
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def policies_for(key)
|
|
63
|
+
policies.for(key)
|
|
64
|
+
end
|
|
65
|
+
|
|
49
66
|
# Returns [Manifest::Entry, resolved_path, remaining_segments]
|
|
50
67
|
def resolve(key)
|
|
51
68
|
validate_key!(key)
|
|
@@ -119,17 +136,6 @@ module Textus
|
|
|
119
136
|
end
|
|
120
137
|
# rubocop:enable Metrics/AbcSize
|
|
121
138
|
|
|
122
|
-
# Validates all declared entry keys; raises UsageError listing all offenders.
|
|
123
|
-
def validate_keys!
|
|
124
|
-
offenders = []
|
|
125
|
-
@entries.each do |entry|
|
|
126
|
-
validate_key!(entry.key)
|
|
127
|
-
rescue UsageError => e
|
|
128
|
-
offenders << e.message
|
|
129
|
-
end
|
|
130
|
-
raise UsageError.new("invalid manifest keys: #{offenders.join("; ")}") unless offenders.empty?
|
|
131
|
-
end
|
|
132
|
-
|
|
133
139
|
def validate_key!(key)
|
|
134
140
|
raise UsageError.new("empty key") if key.nil? || key.empty?
|
|
135
141
|
|
|
@@ -149,6 +155,19 @@ module Textus
|
|
|
149
155
|
@entries.each { |e| validate_key!(e.key) }
|
|
150
156
|
end
|
|
151
157
|
|
|
158
|
+
def reject_legacy_entry_intake_policy!(raw_entries)
|
|
159
|
+
raw_entries.each do |re|
|
|
160
|
+
intake = re["intake"]
|
|
161
|
+
next unless intake.is_a?(Hash)
|
|
162
|
+
next unless intake.key?("ttl") || intake.key?("on_stale") || intake.key?("sync_budget_ms")
|
|
163
|
+
|
|
164
|
+
raise UsageError.new(
|
|
165
|
+
"entry '#{re["key"]}': intake.ttl/intake.on_stale/intake.sync_budget_ms removed in 0.9.2 — " \
|
|
166
|
+
"move into a top-level policies: block (see CHANGELOG migration recipe).",
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
152
171
|
def resolve_leaf_path(entry)
|
|
153
172
|
Textus::Key::Path.resolve(self, entry)
|
|
154
173
|
end
|
data/lib/textus/projection.rb
CHANGED
|
@@ -40,7 +40,7 @@ module Textus
|
|
|
40
40
|
def apply_reducer(rows)
|
|
41
41
|
name = @spec["reduce"] or return rows
|
|
42
42
|
callable = @store.registry.rpc_callable(:reduce, name)
|
|
43
|
-
view =
|
|
43
|
+
view = Application::Context.new(store: @store, role: "human")
|
|
44
44
|
Timeout.timeout(REDUCER_TIMEOUT_SECONDS) do
|
|
45
45
|
callable.call(store: view, rows: rows, config: @spec["reduce_config"] || {})
|
|
46
46
|
end
|
data/lib/textus/proposal.rb
CHANGED
|
@@ -1,27 +1,10 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Proposal
|
|
3
|
+
# Deprecated as of 0.9.1: use Textus::Application::Writes::Accept (via
|
|
4
|
+
# Textus::Composition.writes_accept).
|
|
3
5
|
def self.accept(store, pending_key, as:)
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
env = store.get(pending_key)
|
|
7
|
-
proposal = env["_meta"]["proposal"] or raise ProposalError.new("entry has no proposal block: #{pending_key}")
|
|
8
|
-
target = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
|
|
9
|
-
action = proposal["action"] || "put"
|
|
10
|
-
|
|
11
|
-
case action
|
|
12
|
-
when "put"
|
|
13
|
-
target_meta = env["_meta"]["frontmatter"] || {}
|
|
14
|
-
target_body = env["body"]
|
|
15
|
-
store.put(target, meta: target_meta, body: target_body, as: "human")
|
|
16
|
-
when "delete"
|
|
17
|
-
store.delete(target, as: "human")
|
|
18
|
-
else
|
|
19
|
-
raise ProposalError.new("unknown action: #{action}")
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
store.delete(pending_key, as: "human")
|
|
23
|
-
store.fire_event(:accept, key: pending_key, target_key: target)
|
|
24
|
-
{ "protocol" => PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action }
|
|
6
|
+
ctx = Textus::Composition.context(store, role: as)
|
|
7
|
+
Textus::Application::Writes::Accept.new(ctx: ctx, bus: store.bus).call(pending_key)
|
|
25
8
|
end
|
|
26
9
|
end
|
|
27
10
|
end
|
data/lib/textus/refresh.rb
CHANGED
|
@@ -1,57 +1,21 @@
|
|
|
1
|
-
require "timeout"
|
|
2
|
-
|
|
3
1
|
module Textus
|
|
4
2
|
module Refresh
|
|
5
|
-
FETCH_TIMEOUT_SECONDS = 2
|
|
6
|
-
|
|
7
3
|
def self.call(store, key, as:)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
before_etag = File.exist?(path) ? Etag.for_file(path) : nil
|
|
12
|
-
callable = store.registry.rpc_callable(:fetch, mentry.fetch)
|
|
13
|
-
view = Store::View.new(store, writable: true, as: as)
|
|
14
|
-
result =
|
|
15
|
-
begin
|
|
16
|
-
Timeout.timeout(FETCH_TIMEOUT_SECONDS) do
|
|
17
|
-
callable.call(store: view, config: mentry.fetch_config, args: {})
|
|
18
|
-
end
|
|
19
|
-
rescue Timeout::Error
|
|
20
|
-
raise UsageError.new("fetch '#{mentry.fetch}' exceeded #{FETCH_TIMEOUT_SECONDS}s timeout")
|
|
21
|
-
rescue Textus::Error
|
|
22
|
-
raise
|
|
23
|
-
rescue StandardError => e
|
|
24
|
-
raise UsageError.new("fetch '#{mentry.fetch}' raised: #{e.class}: #{e.message}")
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
normalized = normalize_action_result(result, format: mentry.format)
|
|
28
|
-
envelope = store.put(
|
|
29
|
-
key,
|
|
30
|
-
meta: normalized[:meta],
|
|
31
|
-
body: normalized[:body],
|
|
32
|
-
content: normalized[:content],
|
|
33
|
-
as: as,
|
|
34
|
-
suppress_events: true,
|
|
35
|
-
)
|
|
4
|
+
ctx = Textus::Composition.context(store, role: as)
|
|
5
|
+
Textus::Composition.refresh_worker(ctx).run(key)
|
|
6
|
+
end
|
|
36
7
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
:unchanged
|
|
41
|
-
else
|
|
42
|
-
:updated
|
|
43
|
-
end
|
|
44
|
-
store.fire_event(:refresh, key: key, envelope: envelope, change: change) unless change == :unchanged
|
|
45
|
-
envelope
|
|
8
|
+
def self.refresh_stale(store, prefix: nil, zone: nil, as: "script")
|
|
9
|
+
ctx = Textus::Composition.context(store, role: as)
|
|
10
|
+
Textus::Application::Refresh::All.call(ctx, prefix: prefix, zone: zone)
|
|
46
11
|
end
|
|
47
12
|
|
|
48
|
-
# Normalize the three accepted
|
|
13
|
+
# Normalize the three accepted intake return shapes into the store's
|
|
49
14
|
# internal {frontmatter, body, content} representation.
|
|
50
15
|
def self.normalize_action_result(res, format:)
|
|
51
16
|
res = res.transform_keys(&:to_s) if res.is_a?(Hash)
|
|
52
17
|
res ||= {}
|
|
53
|
-
|
|
54
|
-
meta_val = res["_meta"] || res["frontmatter"]
|
|
18
|
+
meta_val = res["_meta"]
|
|
55
19
|
body = res["body"]
|
|
56
20
|
content = res["content"]
|
|
57
21
|
|
|
@@ -66,7 +30,7 @@ module Textus
|
|
|
66
30
|
elsif !body.nil?
|
|
67
31
|
{ meta: {}, body: body.to_s, content: nil }
|
|
68
32
|
else
|
|
69
|
-
raise UsageError.new("
|
|
33
|
+
raise UsageError.new("intake for #{format} returned neither content nor body")
|
|
70
34
|
end
|
|
71
35
|
else
|
|
72
36
|
raise UsageError.new("unknown format #{format.inspect}")
|