textus 0.9.2 → 0.10.1
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/ARCHITECTURE.md +74 -0
- data/CHANGELOG.md +123 -0
- data/README.md +4 -4
- data/SPEC.md +8 -8
- data/docs/conventions.md +1 -1
- data/lib/textus/application/context.rb +0 -24
- data/lib/textus/application/refresh/orchestrator.rb +3 -2
- data/lib/textus/application/refresh/worker.rb +1 -1
- data/lib/textus/application/writes/accept.rb +3 -2
- data/lib/textus/application/writes/build.rb +101 -9
- data/lib/textus/application/writes/delete.rb +1 -2
- data/lib/textus/application/writes/put.rb +1 -2
- 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/verb/accept.rb +1 -2
- data/lib/textus/cli/verb/audit.rb +1 -2
- data/lib/textus/cli/verb/blame.rb +1 -2
- data/lib/textus/cli/verb/delete.rb +1 -2
- data/lib/textus/cli/verb/freshness.rb +1 -2
- data/lib/textus/cli/verb/get.rb +1 -2
- data/lib/textus/cli/verb/hook_run.rb +1 -1
- data/lib/textus/cli/verb/mv.rb +1 -2
- data/lib/textus/cli/verb/policy_explain.rb +1 -2
- data/lib/textus/cli/verb/put.rb +5 -4
- data/lib/textus/cli/verb/refresh.rb +1 -2
- data/lib/textus/cli/verb/refresh_stale.rb +1 -2
- data/lib/textus/cli/verb/reject.rb +1 -2
- data/lib/textus/cli/verb.rb +14 -0
- data/lib/textus/composition.rb +1 -0
- data/lib/textus/doctor.rb +3 -1
- data/lib/textus/intro.rb +0 -5
- data/lib/textus/manifest/entry.rb +21 -4
- data/lib/textus/manifest.rb +0 -11
- data/lib/textus/projection.rb +1 -1
- data/lib/textus/refresh.rb +1 -2
- data/lib/textus/store/staleness.rb +1 -1
- data/lib/textus/store/writer.rb +1 -1
- data/lib/textus/store.rb +1 -1
- data/lib/textus/version.rb +1 -1
- metadata +2 -5
- data/docs/architecture.md +0 -129
- data/lib/textus/builder.rb +0 -99
- data/lib/textus/publisher.rb +0 -6
- data/lib/textus/store/view.rb +0 -18
|
@@ -6,8 +6,7 @@ module Textus
|
|
|
6
6
|
option :zone, "--zone=Z"
|
|
7
7
|
|
|
8
8
|
def call(store)
|
|
9
|
-
|
|
10
|
-
ctx = Textus::Composition.context(store, role: role)
|
|
9
|
+
ctx = context_for(store)
|
|
11
10
|
rows = Textus::Composition.freshness(ctx).call(prefix: prefix, zone: zone)
|
|
12
11
|
emit({ "verb" => "freshness", "rows" => rows })
|
|
13
12
|
end
|
data/lib/textus/cli/verb/get.rb
CHANGED
|
@@ -6,8 +6,7 @@ module Textus
|
|
|
6
6
|
|
|
7
7
|
def call(store)
|
|
8
8
|
key = positional.shift or raise UsageError.new("get requires a key")
|
|
9
|
-
|
|
10
|
-
ctx = Textus::Composition.context(store, role: role)
|
|
9
|
+
ctx = context_for(store)
|
|
11
10
|
result = Textus::Composition.reads_get(ctx).call(key)
|
|
12
11
|
raise Textus::UnknownKey.new(key, suggestions: store.manifest.suggestions_for(key)) if result.nil?
|
|
13
12
|
|
|
@@ -24,7 +24,7 @@ module Textus
|
|
|
24
24
|
|
|
25
25
|
role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
|
|
26
26
|
callable = store.registry.rpc_callable(:intake, name)
|
|
27
|
-
view =
|
|
27
|
+
view = Application::Context.new(store: store, role: role)
|
|
28
28
|
|
|
29
29
|
begin
|
|
30
30
|
Timeout.timeout(Textus::Application::Refresh::Worker::FETCH_TIMEOUT_SECONDS) do
|
data/lib/textus/cli/verb/mv.rb
CHANGED
|
@@ -8,8 +8,7 @@ module Textus
|
|
|
8
8
|
def call(store)
|
|
9
9
|
old_key = positional.shift or raise UsageError.new("mv requires <old-key> <new-key>")
|
|
10
10
|
new_key = positional.shift or raise UsageError.new("mv requires <old-key> <new-key>")
|
|
11
|
-
|
|
12
|
-
emit(store.mv(old_key, new_key, as: role, dry_run: dry_run || false))
|
|
11
|
+
emit(store.mv(old_key, new_key, as: resolved_role(store), dry_run: dry_run || false))
|
|
13
12
|
end
|
|
14
13
|
end
|
|
15
14
|
end
|
|
@@ -4,8 +4,7 @@ module Textus
|
|
|
4
4
|
class PolicyExplain < Verb
|
|
5
5
|
def call(store)
|
|
6
6
|
key = positional.shift or raise UsageError.new("policy explain requires a KEY")
|
|
7
|
-
|
|
8
|
-
ctx = Textus::Composition.context(store, role: role)
|
|
7
|
+
ctx = context_for(store)
|
|
9
8
|
result = Textus::Composition.policy_explain(ctx).call(key: key)
|
|
10
9
|
emit({ "verb" => "policy_explain" }.merge(result.transform_keys(&:to_s)))
|
|
11
10
|
end
|
data/lib/textus/cli/verb/put.rb
CHANGED
|
@@ -10,7 +10,7 @@ module Textus
|
|
|
10
10
|
key = positional.shift or raise UsageError.new("put requires a key")
|
|
11
11
|
raise UsageError.new("put requires --stdin in v1") unless use_stdin
|
|
12
12
|
|
|
13
|
-
role =
|
|
13
|
+
role = resolved_role(store)
|
|
14
14
|
|
|
15
15
|
raw = @stdin.read
|
|
16
16
|
payload =
|
|
@@ -19,7 +19,8 @@ module Textus
|
|
|
19
19
|
result =
|
|
20
20
|
begin
|
|
21
21
|
Timeout.timeout(Textus::Application::Refresh::Worker::FETCH_TIMEOUT_SECONDS) do
|
|
22
|
-
callable.call(config: { "bytes" => raw },
|
|
22
|
+
callable.call(config: { "bytes" => raw },
|
|
23
|
+
store: Textus::Application::Context.new(store: store, role: role), args: {})
|
|
23
24
|
end
|
|
24
25
|
rescue Timeout::Error
|
|
25
26
|
raise UsageError.new(
|
|
@@ -32,14 +33,14 @@ module Textus
|
|
|
32
33
|
"name" => basename,
|
|
33
34
|
"last_refreshed_at" => Time.now.utc.iso8601,
|
|
34
35
|
"fetched_with" => fetch_name,
|
|
35
|
-
}.merge(result[:_meta] || result["_meta"] ||
|
|
36
|
+
}.merge(result[:_meta] || result["_meta"] || {}),
|
|
36
37
|
"body" => result[:body] || result["body"] || "",
|
|
37
38
|
}
|
|
38
39
|
else
|
|
39
40
|
JSON.parse(raw)
|
|
40
41
|
end
|
|
41
42
|
|
|
42
|
-
meta = payload["_meta"] ||
|
|
43
|
+
meta = payload["_meta"] || {}
|
|
43
44
|
body = payload["body"] || ""
|
|
44
45
|
if_etag = payload["if_etag"]
|
|
45
46
|
ctx = Textus::Composition.context(store, role: role)
|
|
@@ -6,8 +6,7 @@ module Textus
|
|
|
6
6
|
|
|
7
7
|
def call(store)
|
|
8
8
|
key = positional.shift or raise UsageError.new("refresh requires a key")
|
|
9
|
-
|
|
10
|
-
ctx = Textus::Composition.context(store, role: role)
|
|
9
|
+
ctx = context_for(store)
|
|
11
10
|
emit(Textus::Composition.refresh_worker(ctx).run(key))
|
|
12
11
|
end
|
|
13
12
|
end
|
|
@@ -7,8 +7,7 @@ module Textus
|
|
|
7
7
|
option :as_flag, "--as=ROLE"
|
|
8
8
|
|
|
9
9
|
def call(store)
|
|
10
|
-
|
|
11
|
-
ctx = Textus::Composition.context(store, role: role)
|
|
10
|
+
ctx = context_for(store)
|
|
12
11
|
result = Textus::Application::Refresh::All.call(ctx, prefix: prefix, zone: zone)
|
|
13
12
|
emit(result)
|
|
14
13
|
exit(1) unless result["ok"]
|
|
@@ -6,8 +6,7 @@ module Textus
|
|
|
6
6
|
|
|
7
7
|
def call(store)
|
|
8
8
|
key = positional.shift or raise UsageError.new("reject requires a key")
|
|
9
|
-
|
|
10
|
-
emit(store.reject(key, as: role))
|
|
9
|
+
emit(store.reject(key, as: resolved_role(store)))
|
|
11
10
|
end
|
|
12
11
|
end
|
|
13
12
|
end
|
data/lib/textus/cli/verb.rb
CHANGED
|
@@ -57,6 +57,20 @@ module Textus
|
|
|
57
57
|
@stdout.puts(JSON.generate(payload))
|
|
58
58
|
exit_code
|
|
59
59
|
end
|
|
60
|
+
|
|
61
|
+
# Resolves the active role for this invocation. Honors the verb's
|
|
62
|
+
# `--as` flag if declared, then TEXTUS_ROLE, then the project default.
|
|
63
|
+
def resolved_role(store)
|
|
64
|
+
flag = respond_to?(:as_flag) ? as_flag : nil
|
|
65
|
+
Role.resolve(flag: flag, env: ENV, root: store.root)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Returns an Application::Context bound to the resolved role.
|
|
69
|
+
# Convenience for verbs whose only pre-call boilerplate is
|
|
70
|
+
# resolving the role and wrapping it in a context.
|
|
71
|
+
def context_for(store)
|
|
72
|
+
Textus::Composition.context(store, role: resolved_role(store))
|
|
73
|
+
end
|
|
60
74
|
end
|
|
61
75
|
end
|
|
62
76
|
end
|
data/lib/textus/composition.rb
CHANGED
data/lib/textus/doctor.rb
CHANGED
|
@@ -52,7 +52,7 @@ module Textus
|
|
|
52
52
|
|
|
53
53
|
def run_registered_checks(store)
|
|
54
54
|
out = []
|
|
55
|
-
view =
|
|
55
|
+
view = Application::Context.new(store: store, role: "human")
|
|
56
56
|
store.registry.rpc_names(:check).each do |name|
|
|
57
57
|
callable = store.registry.rpc_callable(:check, name)
|
|
58
58
|
begin
|
|
@@ -86,5 +86,7 @@ module Textus
|
|
|
86
86
|
"fix" => fix,
|
|
87
87
|
}
|
|
88
88
|
end
|
|
89
|
+
|
|
90
|
+
private_class_method :run_registered_checks, :fail_issue
|
|
89
91
|
end
|
|
90
92
|
end
|
data/lib/textus/intro.rb
CHANGED
|
@@ -16,11 +16,6 @@ module Textus
|
|
|
16
16
|
"inbox" => "declared external inputs; script-refreshed via actions",
|
|
17
17
|
"review" => "AI proposals awaiting human accept",
|
|
18
18
|
"output" => "build-computed outputs; never hand-edited",
|
|
19
|
-
# legacy 0.9.1 zone names — kept so intro still annotates pre-rename stores
|
|
20
|
-
"canon" => "slow-changing identity; human-only writes",
|
|
21
|
-
"intake" => "declared external inputs; script-refreshed via actions",
|
|
22
|
-
"pending" => "AI proposals awaiting human accept",
|
|
23
|
-
"derived" => "build-computed outputs; never hand-edited",
|
|
24
19
|
}.freeze
|
|
25
20
|
|
|
26
21
|
WRITE_FLOWS = {
|
|
@@ -56,15 +56,32 @@ module Textus
|
|
|
56
56
|
@publish_each.gsub(PUBLISH_EACH_VAR_RE) { vars.fetch(::Regexp.last_match(1)) }
|
|
57
57
|
end
|
|
58
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?`.
|
|
59
73
|
def derived?
|
|
60
|
-
|
|
61
|
-
writers.include?("build")
|
|
62
|
-
rescue UsageError => e
|
|
63
|
-
raise UsageError.new("entry '#{@key}': #{e.message}")
|
|
74
|
+
in_generator_zone?
|
|
64
75
|
end
|
|
65
76
|
|
|
66
77
|
private
|
|
67
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
|
+
|
|
68
85
|
def validate_inject_intro!
|
|
69
86
|
return unless @inject_intro
|
|
70
87
|
|
data/lib/textus/manifest.rb
CHANGED
|
@@ -136,17 +136,6 @@ module Textus
|
|
|
136
136
|
end
|
|
137
137
|
# rubocop:enable Metrics/AbcSize
|
|
138
138
|
|
|
139
|
-
# Validates all declared entry keys; raises UsageError listing all offenders.
|
|
140
|
-
def validate_keys!
|
|
141
|
-
offenders = []
|
|
142
|
-
@entries.each do |entry|
|
|
143
|
-
validate_key!(entry.key)
|
|
144
|
-
rescue UsageError => e
|
|
145
|
-
offenders << e.message
|
|
146
|
-
end
|
|
147
|
-
raise UsageError.new("invalid manifest keys: #{offenders.join("; ")}") unless offenders.empty?
|
|
148
|
-
end
|
|
149
|
-
|
|
150
139
|
def validate_key!(key)
|
|
151
140
|
raise UsageError.new("empty key") if key.nil? || key.empty?
|
|
152
141
|
|
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/refresh.rb
CHANGED
|
@@ -15,8 +15,7 @@ module Textus
|
|
|
15
15
|
def self.normalize_action_result(res, format:)
|
|
16
16
|
res = res.transform_keys(&:to_s) if res.is_a?(Hash)
|
|
17
17
|
res ||= {}
|
|
18
|
-
|
|
19
|
-
meta_val = res["_meta"] || res["frontmatter"]
|
|
18
|
+
meta_val = res["_meta"]
|
|
20
19
|
body = res["body"]
|
|
21
20
|
content = res["content"]
|
|
22
21
|
|
data/lib/textus/store/writer.rb
CHANGED
|
@@ -152,7 +152,7 @@ module Textus
|
|
|
152
152
|
raise ProposalError.new("only human role can reject proposals; got '#{as}'") unless as == "human"
|
|
153
153
|
|
|
154
154
|
mentry, = @store.manifest.resolve(pending_key)
|
|
155
|
-
raise ProposalError.new("reject: '#{pending_key}' is not a
|
|
155
|
+
raise ProposalError.new("reject: '#{pending_key}' is not in a proposal zone (zone=#{mentry.zone})") unless mentry.in_proposal_zone?
|
|
156
156
|
|
|
157
157
|
env = @store.get(pending_key)
|
|
158
158
|
proposal = env.dig("_meta", "proposal") or
|
data/lib/textus/store.rb
CHANGED
data/lib/textus/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: textus
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.10.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Patrick
|
|
@@ -102,10 +102,10 @@ executables:
|
|
|
102
102
|
extensions: []
|
|
103
103
|
extra_rdoc_files: []
|
|
104
104
|
files:
|
|
105
|
+
- ARCHITECTURE.md
|
|
105
106
|
- CHANGELOG.md
|
|
106
107
|
- README.md
|
|
107
108
|
- SPEC.md
|
|
108
|
-
- docs/architecture.md
|
|
109
109
|
- docs/conventions.md
|
|
110
110
|
- exe/textus
|
|
111
111
|
- lib/textus.rb
|
|
@@ -123,7 +123,6 @@ files:
|
|
|
123
123
|
- lib/textus/application/writes/delete.rb
|
|
124
124
|
- lib/textus/application/writes/publish.rb
|
|
125
125
|
- lib/textus/application/writes/put.rb
|
|
126
|
-
- lib/textus/builder.rb
|
|
127
126
|
- lib/textus/builder/pipeline.rb
|
|
128
127
|
- lib/textus/builder/renderer.rb
|
|
129
128
|
- lib/textus/builder/renderer/json.rb
|
|
@@ -226,7 +225,6 @@ files:
|
|
|
226
225
|
- lib/textus/mustache.rb
|
|
227
226
|
- lib/textus/projection.rb
|
|
228
227
|
- lib/textus/proposal.rb
|
|
229
|
-
- lib/textus/publisher.rb
|
|
230
228
|
- lib/textus/refresh.rb
|
|
231
229
|
- lib/textus/role.rb
|
|
232
230
|
- lib/textus/schema.rb
|
|
@@ -237,7 +235,6 @@ files:
|
|
|
237
235
|
- lib/textus/store/reader.rb
|
|
238
236
|
- lib/textus/store/staleness.rb
|
|
239
237
|
- lib/textus/store/validator.rb
|
|
240
|
-
- lib/textus/store/view.rb
|
|
241
238
|
- lib/textus/store/writer.rb
|
|
242
239
|
- lib/textus/version.rb
|
|
243
240
|
homepage: https://github.com/patrick204nqh/textus
|
data/docs/architecture.md
DELETED
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
# Architecture
|
|
2
|
-
|
|
3
|
-
How the reference Ruby implementation is organized. The wire protocol itself lives in [`../SPEC.md`](../SPEC.md); this document covers *how* the gem implements that spec.
|
|
4
|
-
|
|
5
|
-
The codebase is a flat graph of small modules under one CLI dispatcher, not a strict pyramid. The clusters below describe what each module exists for and which other modules it talks to.
|
|
6
|
-
|
|
7
|
-
## At a glance
|
|
8
|
-
|
|
9
|
-
```
|
|
10
|
-
exe/textus → Textus::CLI ──┬──► Store (facade — delegates to Reader/Writer/Mover)
|
|
11
|
-
│ ├──► Store::Reader (get/list/where/uid/deps/published/stale/validate_all)
|
|
12
|
-
│ ├──► Store::Writer (put/delete/accept)
|
|
13
|
-
│ ├──► Store::Mover (mv)
|
|
14
|
-
│ └──► Hooks::Dispatcher (lifecycle publish/subscribe)
|
|
15
|
-
├──► Builder (build verb — Pipeline + per-format renderers)
|
|
16
|
-
├──► Refresh (refresh verb)
|
|
17
|
-
├──► Doctor (doctor verb)
|
|
18
|
-
├──► Init (init verb)
|
|
19
|
-
├──► Intro (intro verb)
|
|
20
|
-
├──► MigrateKeys (key migrate, key mv verbs)
|
|
21
|
-
├──► Schema::Tools (schema init/diff/migrate verbs)
|
|
22
|
-
├──► Store::View (read-only projection over Store::Reader)
|
|
23
|
-
└──► Role (role gate)
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
CLI is the single entry point. It parses argv and dispatches each verb to whichever module owns that capability — there is no single mediator below CLI.
|
|
27
|
-
|
|
28
|
-
## Module clusters
|
|
29
|
-
|
|
30
|
-
### 1. Request path — core read/write verbs
|
|
31
|
-
|
|
32
|
-
`Store` is a thin facade (~110 LOC) that holds `Manifest`, `Hooks::Registry`, `Hooks::Dispatcher`, and the lazy `Store::AuditLog`, then delegates verbs to a small set of focused collaborators:
|
|
33
|
-
|
|
34
|
-
- **`Store::Reader`** — owns `get`, `list`, `where`, `uid`, `deps`, `rdeps`, `published`, `schema_envelope`, `validate_all`. The only module that reads working-store entry files. (`freshness` lives in `Application::Reads::Freshness` since 0.9.2; the legacy `stale` shim was removed.)
|
|
35
|
-
- **`Store::Writer`** — owns `put`, `delete`, `accept`. Handles serialization, uid minting, etag check, role gate, audit append, and event publication. The only module that writes working-store entry files.
|
|
36
|
-
- **`Store::Mover`** — owns `mv` (same-zone rename) with uid preservation and one audit row.
|
|
37
|
-
- **`Store::Validator`** / **`Store::Staleness`** — back the `validate_all` / `freshness` reads. Take explicit collaborators (`reader:`, `manifest:`, `audit_log:`, `schema_for:`) instead of the full store.
|
|
38
|
-
|
|
39
|
-
Shared value modules and primitives consumed by Reader/Writer/Mover:
|
|
40
|
-
|
|
41
|
-
- **`Textus::Key::Path`** — `Key::Path.resolve(manifest, mentry)` returns the absolute leaf path for a manifest entry. Single source for zone-path construction; used by `Manifest`, `Staleness`, `Builder`, and Writer.
|
|
42
|
-
- **`Textus::Envelope`** — `Envelope.build(...)` returns the canonical envelope hash (protocol, key, zone, owner, path, format, `_meta`, body, etag, schema_ref, uid, optional content). Single source for envelope shape across `get` and `put`.
|
|
43
|
-
- **`Manifest`** — parses `.textus/manifest.yaml`; resolves a dotted key to a path via longest-prefix match. `nested: true` entries treat unmatched suffix segments as `/`-joined subdirs, with `.md` appended. Resolution is path-only; existence is the verb's concern.
|
|
44
|
-
- **`Schema`** — loads YAML schema files; validates frontmatter shape and surfaces unknown-key warnings (the §6 forward-compat rule).
|
|
45
|
-
- **`Entry`** + format adapters (`entry/markdown.rb`, `entry/text.rb`, `entry/json.rb`, `entry/yaml.rb`) — splits raw bytes on `---\n`, feeds the YAML chunk to `YAML.safe_load` (no aliases, restricted classes). The frontmatter `name:` field is enforced against the file basename in Reader/Writer (on read and on write) — mismatch raises `bad_frontmatter`.
|
|
46
|
-
- **`Etag`** — `sha256:<hex>` over raw file bytes. `put` accepts optional `if_etag:`; mismatch raises `etag_mismatch`. No locking, no temp-file-and-rename — v1 leaves stronger guarantees to v1.x.
|
|
47
|
-
- **`Role`** — agent-vs-human gate. Writer checks `Manifest::Entry#zone_writers` before doing anything else; otherwise raises `write_forbidden`.
|
|
48
|
-
- **`Store::AuditLog`** — append-only NDJSON; every successful write emits one line.
|
|
49
|
-
- **`Proposal`** — `accept` verb flow for promoting a pending entry into its target zone.
|
|
50
|
-
- **`Dependencies`** — `deps`/`rdeps`/`published` verb backing; walks manifest declarations.
|
|
51
|
-
|
|
52
|
-
### 2. Build / publish pipeline
|
|
53
|
-
|
|
54
|
-
Separate from the request path. Owns derived-entry materialization and byte-copy publish. `Builder` orchestrates per-entry materialization through `Builder::Pipeline`, which runs an ordered step list and dispatches the rendering step to one of four format-specific renderers. Adding a new output format is a single-file change under `lib/textus/builder/renderer/`.
|
|
55
|
-
|
|
56
|
-
```
|
|
57
|
-
Builder ──► Pipeline ──► LoadSources ──► Project ──► Render (per-format) ──► Write ──► Publisher ──► (sentinel)
|
|
58
|
-
│
|
|
59
|
-
└──► Renderer::{Markdown, Text, Json, Yaml}
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
- **`Builder`** — iterates `zone: derived` entries, hands each to `Pipeline.run`, then handles `Publisher` copy-out and fires the `:build` event. Holds no format-specific logic.
|
|
63
|
-
- **`Builder::Pipeline`** — `Pipeline.run(store:, mentry:, template_loader:)` is the orchestrator: runs the projection, merges `intro` if `inject_intro: true`, dispatches to the matching renderer, writes the bytes to the derived path.
|
|
64
|
-
- **`Builder::InjectMeta`** — builds the `_meta` block (`generated_at`, `from`, `template`, `reduce`) and threads it onto JSON/YAML content as the first key per SPEC §6 ordering.
|
|
65
|
-
- **`Builder::Renderer::{Markdown,Text,Json,Yaml}`** — one class per format, inheriting `Builder::Renderer`. Receives a template-loader lambda and `(mentry:, data:)`; returns rendered bytes. Markdown/Text always require a template; JSON/YAML optionally accept one (otherwise default-shape the projection rows).
|
|
66
|
-
- **`Projection`** — collects rows from manifest-declared source keys, applies optional reducer, sorts and positions. Pure data shaping.
|
|
67
|
-
- **`Mustache`** — minimal mustache renderer for templates in `.textus/templates/`.
|
|
68
|
-
- **`Publisher`** — byte-copy from store path to external target path. Refuses to overwrite unmanaged targets; writes a sentinel in `.textus/sentinels/` to track managed targets.
|
|
69
|
-
|
|
70
|
-
### 3. Extension surface
|
|
71
|
-
|
|
72
|
-
Declared in the manifest, loaded on demand, dispatched by `Store` and `Refresh`.
|
|
73
|
-
|
|
74
|
-
- **`Hooks::Registry`** — loads one `.rb` per hook from `.textus/hooks/`, registers callables under their `(event, name)`. Single source of truth via the `EVENTS` table (rpc vs pubsub, arg shape, failure semantics). For pub-sub events it also forwards registrations to the `Hooks::Dispatcher`.
|
|
75
|
-
- **`Hooks::Dispatcher`** — first-class pub/sub for lifecycle events (`:put`, `:delete`, `:refresh`, `:build`, `:accept`, `:publish`, `:mv`, `:reject`, `:loaded`). Owns the 2-second per-handler timeout and the audit-on-failure middleware (raising handlers do not abort the write; they produce an `event_error` audit row). Embedded callers can `store.dispatcher.subscribe(:put, :name) { ... }` outside `.textus/hooks/`.
|
|
76
|
-
- **`Hooks::Builtin`** — ships built-in `:fetch` hooks (e.g. json, csv, ical-events, rss) available without user-supplied hooks.
|
|
77
|
-
- **`Refresh`** — `refresh` verb: looks up the `:fetch` hook for a key, invokes it, normalizes the result by declared format, writes through `Store::Writer` with an etag check.
|
|
78
|
-
|
|
79
|
-
### 4. Operational tooling
|
|
80
|
-
|
|
81
|
-
First-class CLI verbs that don't fit the read/write/build axes. Read-mostly; side modules off CLI.
|
|
82
|
-
|
|
83
|
-
- **`Doctor`** — `doctor` verb: orchestrator that runs 9 builtin checks under `Doctor::Check::*`. Talks to Manifest/Schema/Entry/Hooks::Registry directly.
|
|
84
|
-
- **`Doctor::Check`** — explicit base class for doctor checks. Each of the 9 builtin checks is its own file under `lib/textus/doctor/check/`.
|
|
85
|
-
- **`MigrateKeys`** — `key migrate` and `key mv` verbs; computes renames against the manifest.
|
|
86
|
-
- **`Schema::Tools`** — `schema init`, `schema diff`, `schema migrate` verbs.
|
|
87
|
-
- **`Init`** — `init` verb: scaffolds `.textus/` with the five zone directories, baseline schemas, empty audit log, starter manifest.
|
|
88
|
-
- **`Intro`** — `intro` verb: emits the human/agent-facing onboarding payload.
|
|
89
|
-
- **`Store::View`** — read-only projection over `Store::Reader` for hook code that should not mutate.
|
|
90
|
-
- **`Key::Distance`** — Levenshtein-ish suggestion for `did-you-mean` on unknown keys.
|
|
91
|
-
|
|
92
|
-
### 5. Primitives
|
|
93
|
-
|
|
94
|
-
- **`Errors`** — `Textus::Error` subclasses, each carrying a stable `code`, a `details` hash, and an `exit_code`. `CLI` catches them at the top level and emits the §8 error envelope on stdout. In `--format=json` mode, errors are **never** written to stderr — agents read stdout.
|
|
95
|
-
- **`version`** — gem semver string (independent of the wire protocol `textus/2`).
|
|
96
|
-
|
|
97
|
-
## Invariants
|
|
98
|
-
|
|
99
|
-
- **CLI is the only entry point.** No public API surface guarantees outside the verbs CLI exposes.
|
|
100
|
-
- **Manifest is pure.** Reads at load, no mutation.
|
|
101
|
-
- **Store::Writer is the only module that writes to working-store entry files.** Reader reads them; Mover moves them within a zone. Init, MigrateKeys, Publisher, Builder, AuditLog write to **other** parts of `.textus/` (scaffolding, sentinels, audit log, derived targets) — they do not edit existing entry files behind the Store facade's back.
|
|
102
|
-
- **`name:` frontmatter matches file basename.** Enforced on read and write.
|
|
103
|
-
- **Zone semantics live in the manifest, not in directory names.** A project may rename `state/` to anything; the manifest declares which zone each entry belongs to.
|
|
104
|
-
- **`freshness` does not execute anything.** It walks every entry, matches it against the top-level `policies:` block, and returns each entry's verdict (`fresh|stale|never_refreshed|no_policy`). Build runners execute. This is the §5.1 "dataflow oracle, not executor" boundary.
|
|
105
|
-
|
|
106
|
-
## Policy resolution
|
|
107
|
-
|
|
108
|
-
Top-level `policies:` are parsed into a `Manifest::Policies` collection. Resolution is by key, slot-aware, most-specific-wins:
|
|
109
|
-
|
|
110
|
-
```
|
|
111
|
-
Manifest#policies_for(key)
|
|
112
|
-
└─► Manifest::Policies#for(key)
|
|
113
|
-
├─► Policy::Matcher (specificity ranking)
|
|
114
|
-
└─► returns PolicySet { refresh, handler_allowlist, promote, retention }
|
|
115
|
-
│
|
|
116
|
-
▼
|
|
117
|
-
consumers: Refresh::Worker
|
|
118
|
-
Doctor checks
|
|
119
|
-
Reads::PolicyExplain
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
Two blocks at the same specificity filling the same slot for the same key is a manifest error reported by `doctor` (`policy_ambiguity`). Custom-named zones see no special handling — policies match against the full key.
|
|
123
|
-
|
|
124
|
-
## What this implementation deliberately leaves out
|
|
125
|
-
|
|
126
|
-
- **No process spawning.** Even `stale` does not execute. Build runners do that.
|
|
127
|
-
- **No transport.** No HTTP server, no socket, no MCP server in this gem. Those are downstream wrappers (see [`./conventions.md`](./conventions.md)).
|
|
128
|
-
- **No indexes.** Listing walks the filesystem each time. Premature optimisation for v1.
|
|
129
|
-
- **No locking.** Etag is advisory; concurrent writers can still race. Left to v1.x (§14 open question).
|
data/lib/textus/builder.rb
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
require "fileutils"
|
|
2
|
-
|
|
3
|
-
# As of 0.9.1, Textus::Application::Writes::Build is the preferred public
|
|
4
|
-
# entry point. This class remains as the implementation home of materialization
|
|
5
|
-
# and projection logic; full extraction is deferred to 0.10.0.
|
|
6
|
-
module Textus
|
|
7
|
-
class Builder
|
|
8
|
-
def initialize(store)
|
|
9
|
-
@store = store
|
|
10
|
-
@manifest = store.manifest
|
|
11
|
-
@root = store.root
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def build(prefix: nil)
|
|
15
|
-
built = []
|
|
16
|
-
@manifest.entries.each do |mentry|
|
|
17
|
-
next unless derived_zone?(mentry)
|
|
18
|
-
next unless mentry.projection || mentry.template
|
|
19
|
-
next if prefix && !mentry.key.start_with?(prefix)
|
|
20
|
-
|
|
21
|
-
result = materialize(mentry)
|
|
22
|
-
built << result
|
|
23
|
-
end
|
|
24
|
-
published_leaves = publish_leaves(prefix: prefix)
|
|
25
|
-
{ "protocol" => Textus::PROTOCOL, "built" => built, "published_leaves" => published_leaves }
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
private
|
|
29
|
-
|
|
30
|
-
def publish_leaves(prefix: nil)
|
|
31
|
-
repo_root = File.dirname(@root)
|
|
32
|
-
out = []
|
|
33
|
-
@manifest.entries.each do |mentry|
|
|
34
|
-
next unless mentry.nested && mentry.publish_each
|
|
35
|
-
next if prefix && !mentry.key.start_with?(prefix) && !prefix.start_with?("#{mentry.key}.")
|
|
36
|
-
|
|
37
|
-
@manifest.enumerate(prefix: mentry.key).each do |row|
|
|
38
|
-
next unless row[:manifest_entry].equal?(mentry)
|
|
39
|
-
next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
|
|
40
|
-
|
|
41
|
-
out << publish_leaf(mentry, row, repo_root)
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
out
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def publish_leaf(mentry, row, repo_root)
|
|
48
|
-
target_rel = mentry.publish_target_for(row[:key])
|
|
49
|
-
target_abs = File.expand_path(File.join(repo_root, target_rel))
|
|
50
|
-
unless target_abs.start_with?(File.expand_path(repo_root) + File::SEPARATOR)
|
|
51
|
-
raise PublishError.new(
|
|
52
|
-
"entry '#{mentry.key}': publish_each target '#{target_rel}' for key '#{row[:key]}' escapes repo root",
|
|
53
|
-
)
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
Textus::Infra::Publisher.publish(source: row[:path], target: target_abs, store_root: @root)
|
|
57
|
-
@store.fire_event(:published, key: row[:key], envelope: @store.get(row[:key]),
|
|
58
|
-
source: row[:path], target: target_abs)
|
|
59
|
-
{ "key" => row[:key], "source" => row[:path], "target" => target_abs }
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def derived_zone?(mentry)
|
|
63
|
-
writers = @manifest.zone_writers(mentry.zone)
|
|
64
|
-
writers.include?("build")
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def materialize(mentry)
|
|
68
|
-
target_path = Pipeline.run(
|
|
69
|
-
store: @store,
|
|
70
|
-
mentry: mentry,
|
|
71
|
-
template_loader: ->(name) { read_template(name) },
|
|
72
|
-
)
|
|
73
|
-
publish_and_fire(mentry, target_path)
|
|
74
|
-
{ "key" => mentry.key, "path" => target_path, "published_to" => mentry.publish_to }
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def read_template(name)
|
|
78
|
-
tpl_path = File.join(@root, "templates", name)
|
|
79
|
-
raise TemplateError.new("template not found: #{tpl_path}", template_name: name) unless File.exist?(tpl_path)
|
|
80
|
-
|
|
81
|
-
File.read(tpl_path)
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def publish_and_fire(mentry, target_path)
|
|
85
|
-
envelope = @store.get(mentry.key)
|
|
86
|
-
repo_root = File.dirname(@root)
|
|
87
|
-
|
|
88
|
-
mentry.publish_to.each do |rel|
|
|
89
|
-
target_abs = File.join(repo_root, rel)
|
|
90
|
-
Textus::Infra::Publisher.publish(source: target_path, target: target_abs, store_root: @root)
|
|
91
|
-
@store.fire_event(:published, key: mentry.key, envelope: envelope,
|
|
92
|
-
source: target_path, target: target_abs)
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
@store.fire_event(:built, key: mentry.key, envelope: envelope,
|
|
96
|
-
sources: Array(mentry.projection&.fetch("select", nil)).compact)
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
end
|
data/lib/textus/publisher.rb
DELETED
data/lib/textus/store/view.rb
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
class Store
|
|
3
|
-
# Deprecated as of 0.9.1: use Textus::Application::Context instead.
|
|
4
|
-
# Removal scheduled for 0.10.0.
|
|
5
|
-
class View
|
|
6
|
-
def self.new(store, writable: false, as: nil)
|
|
7
|
-
unless @warned_once
|
|
8
|
-
warn "[textus] Store::View is deprecated; use Application::Context (will be removed in 0.10.0)"
|
|
9
|
-
@warned_once = true
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
raise UsageError.new("writable Store::View requires an as: role") if writable && (as.nil? || as.to_s.empty?)
|
|
13
|
-
|
|
14
|
-
Textus::Application::Context.new(store: store, role: as || "human")
|
|
15
|
-
end
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
|
-
end
|