textus 0.2.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.
@@ -0,0 +1,80 @@
1
+ require "time"
2
+ require "timeout"
3
+
4
+ module Textus
5
+ class Projection
6
+ MAX_LIMIT = 1000
7
+ REDUCER_TIMEOUT_SECONDS = 2
8
+
9
+ def initialize(store, spec)
10
+ @store = store
11
+ @spec = spec || {}
12
+ @limit = (@spec["limit"] || MAX_LIMIT).to_i
13
+ raise InvalidProjection.new("limit #{@limit} exceeds max #{MAX_LIMIT}") if @limit > MAX_LIMIT
14
+ end
15
+
16
+ def run
17
+ keys = collect_keys
18
+ explicit_pluck = !@spec["pluck"].nil? && @spec["pluck"] != "*"
19
+ rows = keys.map do |key|
20
+ env = @store.get(key)
21
+ row = pluck(env["frontmatter"], env["body"])
22
+ explicit_pluck ? row : row.merge("_key" => key)
23
+ end
24
+ reduced = apply_reducer(rows)
25
+ # Reducers may return either an Array of rows (legacy / templated builds)
26
+ # or a Hash that becomes the structured-format payload base. In the Hash
27
+ # case, downstream sort/limit/position markers don't apply, and the
28
+ # builder owns `_meta.generated_at` so we don't stamp it here.
29
+ return reduced if reduced.is_a?(Hash)
30
+
31
+ rows = reduced
32
+ rows = sort(rows)
33
+ rows = rows.first(@limit)
34
+ mark_positions(rows)
35
+ { "entries" => rows, "count" => rows.length, "generated_at" => Time.now.utc.iso8601 }
36
+ end
37
+
38
+ private
39
+
40
+ def apply_reducer(rows)
41
+ name = @spec["reducer"] or return rows
42
+ callable = @store.registry.reducer(name)
43
+ Timeout.timeout(REDUCER_TIMEOUT_SECONDS) do
44
+ callable.call(rows: rows, config: @spec["reducer_config"] || {})
45
+ end
46
+ rescue Timeout::Error
47
+ raise UsageError.new("reducer '#{name}' exceeded #{REDUCER_TIMEOUT_SECONDS}s timeout")
48
+ end
49
+
50
+ def collect_keys
51
+ prefixes = Array(@spec["select"])
52
+ prefixes.flat_map { |p| @store.list(prefix: p).map { |row| row["key"] } }.uniq
53
+ end
54
+
55
+ def pluck(frontmatter, _body)
56
+ fields = @spec["pluck"]
57
+ if fields.nil? || fields == "*"
58
+ frontmatter
59
+ else
60
+ Array(fields).each_with_object({}) { |f, h| h[f] = frontmatter[f] if frontmatter.key?(f) }
61
+ end
62
+ end
63
+
64
+ # Adds `_first`, `_last`, and `_index` markers so templates can emit
65
+ # delimiters (e.g. JSON commas) via {{^_last}},{{/_last}}.
66
+ def mark_positions(rows)
67
+ last_idx = rows.length - 1
68
+ rows.each_with_index do |row, i|
69
+ row["_index"] = i
70
+ row["_first"] = i.zero?
71
+ row["_last"] = (i == last_idx)
72
+ end
73
+ end
74
+
75
+ def sort(rows)
76
+ sb = @spec["sort_by"] or return rows
77
+ rows.sort_by { |r| r[sb].to_s }
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,27 @@
1
+ module Textus
2
+ module Proposal
3
+ def self.accept(store, pending_key, as:)
4
+ raise ProposalError.new("only human role can accept proposals; got '#{as}'") unless as == "human"
5
+
6
+ env = store.get(pending_key)
7
+ proposal = env["frontmatter"]["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_fm = env["frontmatter"]["frontmatter"] || {}
14
+ target_body = env["body"]
15
+ store.put(target, frontmatter: target_fm, 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, pending_key: pending_key, target_key: target)
24
+ { "protocol" => PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action }
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,71 @@
1
+ require "json"
2
+ require "digest"
3
+ require "fileutils"
4
+
5
+ module Textus
6
+ # Publishes built artifacts from the store to repo-relative consumer paths.
7
+ # Publish = copy + sentinel. The in-store file is already the consumer-shaped
8
+ # artifact; no parsing or stripping. Sentinels live under
9
+ # `<store_root>/sentinels/` and mirror the target's repo-relative layout so
10
+ # consumer directories aren't polluted with `.textus-managed.json` siblings.
11
+ module Publisher
12
+ SENTINEL_SUFFIX = ".textus-managed.json".freeze
13
+ SENTINEL_DIR = "sentinels".freeze
14
+
15
+ def self.publish(source:, target:, store_root:)
16
+ FileUtils.mkdir_p(File.dirname(target))
17
+ refuse_if_unmanaged(target, store_root)
18
+ File.delete(target) if File.symlink?(target)
19
+ FileUtils.cp(source, target)
20
+ write_sentinel(target, store_root: store_root, source: source)
21
+ cleanup_legacy_sentinel(target)
22
+ end
23
+
24
+ def self.refuse_if_unmanaged(target, store_root)
25
+ return unless File.exist?(target) || File.symlink?(target)
26
+ return if managed?(target, store_root)
27
+
28
+ raise PublishError.new("refusing to clobber unmanaged file at #{target}", target: target)
29
+ end
30
+
31
+ def self.managed?(target, store_root)
32
+ File.exist?(sentinel_path(target, store_root)) || File.exist?(legacy_sentinel_path(target))
33
+ end
34
+
35
+ def self.write_sentinel(target, store_root:, source:)
36
+ path = sentinel_path(target, store_root)
37
+ FileUtils.mkdir_p(File.dirname(path))
38
+ File.write(path, JSON.generate(
39
+ "source" => source,
40
+ "target" => target,
41
+ "sha256" => Digest::SHA256.hexdigest(File.binread(target)),
42
+ "mode" => "copy",
43
+ ))
44
+ end
45
+
46
+ # Sentinel layout: <store_root>/sentinels/<target_rel_to_repo>.textus-managed.json
47
+ # The full target extension is preserved so a marketplace.json and
48
+ # marketplace.yaml don't collide.
49
+ def self.sentinel_path(target, store_root)
50
+ repo_root = File.dirname(store_root)
51
+ rel = relative_to(target, repo_root) || File.basename(target)
52
+ File.join(store_root, SENTINEL_DIR, rel + SENTINEL_SUFFIX)
53
+ end
54
+
55
+ def self.legacy_sentinel_path(target)
56
+ target + SENTINEL_SUFFIX
57
+ end
58
+
59
+ def self.cleanup_legacy_sentinel(target)
60
+ FileUtils.rm_f(legacy_sentinel_path(target))
61
+ end
62
+
63
+ def self.relative_to(path, base)
64
+ path = File.expand_path(path)
65
+ base = File.expand_path(base)
66
+ return nil unless path.start_with?(base + File::SEPARATOR)
67
+
68
+ path[(base.length + 1)..]
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,75 @@
1
+ require "timeout"
2
+
3
+ module Textus
4
+ module Refresh
5
+ FETCHER_TIMEOUT_SECONDS = 2
6
+
7
+ def self.call(store, key, as:)
8
+ mentry, path, = store.manifest.resolve(key)
9
+ raise UsageError.new("no fetcher declared for '#{key}'") unless mentry.fetcher
10
+
11
+ before_etag = File.exist?(path) ? Etag.for_file(path) : nil
12
+ callable = store.registry.fetcher(mentry.fetcher)
13
+ view = StoreView.new(store)
14
+ result =
15
+ begin
16
+ Timeout.timeout(FETCHER_TIMEOUT_SECONDS) { callable.call(config: mentry.fetcher_config, store: view) }
17
+ rescue Timeout::Error
18
+ raise UsageError.new("fetcher '#{mentry.fetcher}' exceeded #{FETCHER_TIMEOUT_SECONDS}s timeout")
19
+ rescue Textus::Error
20
+ raise
21
+ rescue StandardError => e
22
+ raise UsageError.new("fetcher '#{mentry.fetcher}' raised: #{e.class}: #{e.message}")
23
+ end
24
+
25
+ normalized = normalize_fetcher_result(result, format: mentry.format)
26
+ envelope = store.put(
27
+ key,
28
+ frontmatter: normalized[:frontmatter],
29
+ body: normalized[:body],
30
+ content: normalized[:content],
31
+ as: as,
32
+ suppress_events: true,
33
+ )
34
+
35
+ change = if before_etag.nil?
36
+ :created
37
+ elsif envelope["etag"] == before_etag
38
+ :unchanged
39
+ else
40
+ :updated
41
+ end
42
+ store.fire_event(:refresh, key: key, envelope: envelope, change: change) unless change == :unchanged
43
+ envelope
44
+ end
45
+
46
+ # Normalize the three accepted fetcher return shapes into the store's
47
+ # internal {frontmatter, body, content} representation. See plan-1.2 §7.
48
+ def self.normalize_fetcher_result(res, format:)
49
+ res = res.transform_keys(&:to_s) if res.is_a?(Hash)
50
+ res ||= {}
51
+ fm = res["frontmatter"]
52
+ body = res["body"]
53
+ content = res["content"]
54
+
55
+ case format
56
+ when "markdown"
57
+ { frontmatter: fm || {}, body: body.to_s, content: nil }
58
+ when "text"
59
+ { frontmatter: {}, body: body.to_s, content: nil }
60
+ when "json", "yaml"
61
+ if !content.nil?
62
+ meta = content.is_a?(Hash) && content["_meta"].is_a?(Hash) ? content["_meta"] : {}
63
+ { frontmatter: meta, body: nil, content: content }
64
+ elsif !body.nil?
65
+ # Store#put will re-parse and validate the bytes.
66
+ { frontmatter: {}, body: body.to_s, content: nil }
67
+ else
68
+ raise UsageError.new("fetcher for #{format} returned neither content nor body")
69
+ end
70
+ else
71
+ raise UsageError.new("unknown format #{format.inspect}")
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,20 @@
1
+ module Textus
2
+ module Role
3
+ PATTERN = /\A[a-z][a-z0-9_-]*\z/
4
+ DEFAULT = "human".freeze
5
+
6
+ def self.resolve(root:, flag: nil, env: ENV)
7
+ candidate = flag || env["TEXTUS_ROLE"] || read_file(root) || DEFAULT
8
+ raise InvalidRole.new(candidate) unless candidate.match?(PATTERN)
9
+
10
+ candidate
11
+ end
12
+
13
+ def self.read_file(root)
14
+ path = File.join(root, "role")
15
+ return nil unless File.exist?(path)
16
+
17
+ File.read(path).strip.then { |s| s.empty? ? nil : s }
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,90 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ class Schema
5
+ attr_reader :name, :required, :optional, :fields, :raw
6
+
7
+ def self.load(path)
8
+ raw = YAML.safe_load_file(path, permitted_classes: [Symbol, Date, Time], aliases: false)
9
+ new(raw)
10
+ end
11
+
12
+ def initialize(raw)
13
+ @raw = raw || {}
14
+ @name = @raw["name"]
15
+ @required = Array(@raw["required"])
16
+ @optional = Array(@raw["optional"])
17
+ @fields = @raw["fields"] || {}
18
+ end
19
+
20
+ def to_h
21
+ @raw
22
+ end
23
+
24
+ def maintained_by(field)
25
+ meta = @fields[field] or return nil
26
+ meta["maintained_by"]
27
+ end
28
+
29
+ def evolution
30
+ raw = @raw["evolution"] || {}
31
+ raw.each_with_object({}) do |(k, v), h|
32
+ h[k] = v.is_a?(Date) || v.is_a?(Time) ? v.to_s : v
33
+ end
34
+ end
35
+
36
+ # Returns nil on success; raises SchemaViolation on hard failure.
37
+ # Unknown fields produce warnings, returned as a String[] alongside.
38
+ def validate!(frontmatter)
39
+ missing = @required - frontmatter.keys
40
+ raise SchemaViolation.new("missing" => missing) unless missing.empty?
41
+
42
+ known = (@required + @optional).uniq
43
+ frontmatter.each do |k, v|
44
+ next unless @fields.key?(k)
45
+
46
+ check_type!(k, v, @fields[k])
47
+ end
48
+
49
+ warnings = frontmatter.keys - known
50
+ warnings.map { |w| "unknown field: #{w}" }
51
+ end
52
+
53
+ private
54
+
55
+ def check_type!(field, value, spec) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
56
+ type = spec["type"]
57
+ case type
58
+ when "string"
59
+ bad!(field, "expected string") unless value.is_a?(String)
60
+ if (max = spec["max"]) && value.bytesize > max
61
+ bad!(field, "exceeds max #{max}")
62
+ end
63
+ when "number"
64
+ bad!(field, "expected number") unless value.is_a?(Numeric)
65
+ when "boolean"
66
+ bad!(field, "expected boolean") unless [true, false].include?(value)
67
+ when "enum"
68
+ values = Array(spec["values"])
69
+ bad!(field, "not in enum #{values.inspect}") unless values.include?(value)
70
+ when "array"
71
+ bad!(field, "expected array") unless value.is_a?(Array)
72
+ if (items = spec["items"])
73
+ value.each_with_index { |v, i| check_type!("#{field}[#{i}]", v, items) }
74
+ end
75
+ when "object"
76
+ bad!(field, "expected object") unless value.is_a?(Hash)
77
+ if (sub = spec["fields"])
78
+ sub.each { |fk, fspec| check_type!("#{field}.#{fk}", value[fk], fspec) if value.key?(fk) }
79
+ end
80
+ when nil
81
+ # untyped — no check
82
+ # unknown type spec — vendor extension; ignore
83
+ end
84
+ end
85
+
86
+ def bad!(field, reason)
87
+ raise SchemaViolation.new("field" => field, "reason" => reason)
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,87 @@
1
+ require "yaml"
2
+ require "fileutils"
3
+
4
+ module Textus
5
+ module SchemaTools
6
+ # textus schema-init NAME --from=KEY → infer YAML schema from an entry's frontmatter
7
+ def self.init(store, name:, from:)
8
+ env = store.get(from)
9
+ fm = env["frontmatter"]
10
+ schema = {
11
+ "name" => name,
12
+ "required" => fm.keys,
13
+ "optional" => [],
14
+ "fields" => fm.each_with_object({}) { |(k, v), h| h[k] = { "type" => infer_type(v) } },
15
+ }
16
+ FileUtils.mkdir_p(File.join(store.root, "schemas"))
17
+ target = File.join(store.root, "schemas", "#{name}.yaml")
18
+ File.write(target, YAML.dump(schema))
19
+ { "protocol" => PROTOCOL, "schema_name" => name, "path" => target }
20
+ end
21
+
22
+ # textus schema-diff NAME → list keys whose frontmatter violates the schema
23
+ def self.diff(store, name:)
24
+ schema = load_schema(store, name)
25
+ drift = []
26
+ store.manifest.enumerate.each do |row|
27
+ env = store.get(row[:key])
28
+ begin
29
+ schema.validate!(env["frontmatter"])
30
+ rescue SchemaViolation => e
31
+ drift << { "key" => row[:key], "details" => e.details }
32
+ end
33
+ end
34
+ { "protocol" => PROTOCOL, "schema_name" => name, "drift" => drift }
35
+ end
36
+
37
+ # textus schema-migrate NAME --rename=OLD:NEW → rewrites frontmatter across affected entries
38
+ # If --rename is omitted, falls back to schema.evolution.migrate_from.
39
+ def self.migrate(store, name:, rename: nil)
40
+ renames =
41
+ if rename
42
+ old_field, new_field = rename.split(":", 2)
43
+ raise UsageError.new("--rename=OLD:NEW") unless old_field && new_field && !new_field.empty?
44
+
45
+ { old_field => new_field }
46
+ else
47
+ load_schema(store, name).evolution["migrate_from"] || {}
48
+ end
49
+ raise UsageError.new("schema-migrate needs --rename=OLD:NEW or schema.evolution.migrate_from") if renames.empty?
50
+
51
+ touched = []
52
+ store.manifest.enumerate.each do |row|
53
+ env = store.get(row[:key])
54
+ fm = env["frontmatter"]
55
+ changed = false
56
+ renames.each do |old, new|
57
+ if fm.key?(old)
58
+ fm[new] = fm.delete(old)
59
+ changed = true
60
+ end
61
+ end
62
+ next unless changed
63
+
64
+ store.put(row[:key], frontmatter: fm, body: env["body"], as: "human")
65
+ touched << row[:key]
66
+ end
67
+ { "protocol" => PROTOCOL, "migrated" => touched, "renames" => renames }
68
+ end
69
+
70
+ def self.infer_type(value)
71
+ case value
72
+ when String then "string"
73
+ when Numeric then "number"
74
+ when true, false then "boolean"
75
+ when Array then "array"
76
+ when Hash then "object"
77
+ else "string"
78
+ end
79
+ end
80
+
81
+ def self.load_schema(store, name)
82
+ store.schema_for(name)
83
+ rescue IoError
84
+ raise UsageError.new("schema not found: #{name}")
85
+ end
86
+ end
87
+ end