textus 0.15.0 → 0.18.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.
Files changed (120) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +14 -14
  3. data/CHANGELOG.md +313 -0
  4. data/README.md +13 -9
  5. data/SPEC.md +13 -10
  6. data/docs/conventions.md +2 -2
  7. data/lib/textus/application/context.rb +24 -0
  8. data/lib/textus/application/reads/audit.rb +1 -1
  9. data/lib/textus/application/reads/blame.rb +3 -1
  10. data/lib/textus/application/reads/deps.rb +1 -1
  11. data/lib/textus/application/reads/freshness.rb +12 -3
  12. data/lib/textus/application/reads/get.rb +32 -8
  13. data/lib/textus/application/reads/get_or_refresh.rb +5 -5
  14. data/lib/textus/application/reads/list.rb +3 -1
  15. data/lib/textus/application/reads/published.rb +1 -1
  16. data/lib/textus/application/reads/rdeps.rb +1 -1
  17. data/lib/textus/application/reads/schema_envelope.rb +3 -1
  18. data/lib/textus/application/reads/stale.rb +1 -1
  19. data/lib/textus/application/reads/uid.rb +1 -1
  20. data/lib/textus/application/reads/validate_all.rb +6 -1
  21. data/lib/textus/application/reads/validator.rb +84 -0
  22. data/lib/textus/application/reads/where.rb +4 -1
  23. data/lib/textus/application/refresh/all.rb +8 -1
  24. data/lib/textus/application/refresh/orchestrator.rb +2 -3
  25. data/lib/textus/application/refresh/worker.rb +18 -15
  26. data/lib/textus/application/writes/accept.rb +12 -12
  27. data/lib/textus/application/writes/build.rb +3 -4
  28. data/lib/textus/application/writes/delete.rb +10 -15
  29. data/lib/textus/application/writes/envelope_io.rb +106 -0
  30. data/lib/textus/application/writes/mv.rb +25 -27
  31. data/lib/textus/application/writes/publish.rb +8 -9
  32. data/lib/textus/application/writes/put.rb +12 -16
  33. data/lib/textus/application/writes/reject.rb +10 -10
  34. data/lib/textus/builder/pipeline.rb +2 -2
  35. data/lib/textus/cli/group/hook.rb +1 -3
  36. data/lib/textus/cli/group/key.rb +1 -4
  37. data/lib/textus/cli/group/refresh.rb +1 -2
  38. data/lib/textus/cli/group/rule.rb +1 -3
  39. data/lib/textus/cli/group/schema.rb +1 -5
  40. data/lib/textus/cli/group.rb +12 -16
  41. data/lib/textus/cli/verb/accept.rb +3 -1
  42. data/lib/textus/cli/verb/audit.rb +3 -1
  43. data/lib/textus/cli/verb/blame.rb +3 -1
  44. data/lib/textus/cli/verb/build.rb +4 -2
  45. data/lib/textus/cli/verb/delete.rb +3 -1
  46. data/lib/textus/cli/verb/deps.rb +3 -1
  47. data/lib/textus/cli/verb/doctor.rb +2 -0
  48. data/lib/textus/cli/verb/freshness.rb +3 -1
  49. data/lib/textus/cli/verb/get.rb +3 -1
  50. data/lib/textus/cli/verb/hook_run.rb +3 -0
  51. data/lib/textus/cli/verb/hooks.rb +3 -0
  52. data/lib/textus/cli/verb/init.rb +2 -0
  53. data/lib/textus/cli/verb/intro.rb +2 -0
  54. data/lib/textus/cli/verb/key_normalize.rb +3 -0
  55. data/lib/textus/cli/verb/list.rb +3 -1
  56. data/lib/textus/cli/verb/mv.rb +4 -1
  57. data/lib/textus/cli/verb/published.rb +3 -1
  58. data/lib/textus/cli/verb/put.rb +3 -1
  59. data/lib/textus/cli/verb/rdeps.rb +3 -1
  60. data/lib/textus/cli/verb/refresh.rb +1 -1
  61. data/lib/textus/cli/verb/refresh_stale.rb +3 -0
  62. data/lib/textus/cli/verb/reject.rb +3 -1
  63. data/lib/textus/cli/verb/rule_explain.rb +4 -1
  64. data/lib/textus/cli/verb/rule_list.rb +3 -0
  65. data/lib/textus/cli/verb/schema.rb +4 -1
  66. data/lib/textus/cli/verb/schema_diff.rb +3 -0
  67. data/lib/textus/cli/verb/schema_init.rb +3 -0
  68. data/lib/textus/cli/verb/schema_migrate.rb +3 -0
  69. data/lib/textus/cli/verb/uid.rb +4 -1
  70. data/lib/textus/cli/verb/where.rb +3 -1
  71. data/lib/textus/cli/verb.rb +30 -0
  72. data/lib/textus/cli.rb +18 -27
  73. data/lib/textus/doctor/check/audit_log.rb +1 -1
  74. data/lib/textus/doctor/check/hooks.rb +3 -1
  75. data/lib/textus/doctor/check/intake_registration.rb +3 -3
  76. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  77. data/lib/textus/doctor/check/sentinels.rb +2 -2
  78. data/lib/textus/domain/freshness/evaluator.rb +1 -1
  79. data/lib/textus/domain/freshness/policy.rb +1 -1
  80. data/lib/textus/domain/freshness/verdict.rb +1 -1
  81. data/lib/textus/domain/freshness.rb +40 -0
  82. data/lib/textus/domain/policy/predicates/schema_valid.rb +2 -2
  83. data/lib/textus/{store → domain}/sentinel.rb +1 -1
  84. data/lib/textus/{store → domain}/staleness/generator_check.rb +1 -1
  85. data/lib/textus/{store → domain}/staleness/intake_check.rb +1 -1
  86. data/lib/textus/{store → domain}/staleness.rb +1 -1
  87. data/lib/textus/entry/json.rb +1 -1
  88. data/lib/textus/entry/markdown.rb +1 -1
  89. data/lib/textus/entry/yaml.rb +1 -1
  90. data/lib/textus/envelope.rb +7 -3
  91. data/lib/textus/errors.rb +19 -0
  92. data/lib/textus/hooks/builtin.rb +6 -6
  93. data/lib/textus/hooks/dispatcher.rb +17 -9
  94. data/lib/textus/hooks/loader.rb +20 -17
  95. data/lib/textus/hooks/registry.rb +4 -0
  96. data/lib/textus/{store → infra}/audit_log.rb +1 -1
  97. data/lib/textus/infra/audit_subscriber.rb +43 -0
  98. data/lib/textus/infra/publisher.rb +3 -3
  99. data/lib/textus/infra/storage/file_store.rb +26 -0
  100. data/lib/textus/init.rb +11 -9
  101. data/lib/textus/manifest/resolution.rb +5 -0
  102. data/lib/textus/manifest.rb +4 -3
  103. data/lib/textus/migrate_keys.rb +1 -1
  104. data/lib/textus/operations.rb +83 -16
  105. data/lib/textus/projection.rb +2 -2
  106. data/lib/textus/refresh.rb +1 -1
  107. data/lib/textus/schema/tools.rb +5 -5
  108. data/lib/textus/schemas.rb +46 -0
  109. data/lib/textus/store.rb +12 -49
  110. data/lib/textus/uid.rb +18 -0
  111. data/lib/textus/version.rb +1 -1
  112. data/lib/textus.rb +17 -1
  113. metadata +14 -13
  114. data/lib/textus/hooks/dsl.rb +0 -11
  115. data/lib/textus/operations/reads.rb +0 -56
  116. data/lib/textus/operations/refresh.rb +0 -27
  117. data/lib/textus/operations/writes.rb +0 -21
  118. data/lib/textus/store/reader.rb +0 -69
  119. data/lib/textus/store/validator.rb +0 -82
  120. data/lib/textus/store/writer.rb +0 -102
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Infra
5
+ # Writes an "event_error" audit row when a user hook raises during
6
+ # Hooks::Dispatcher publish. Attached at Store boot.
7
+ #
8
+ # Integration: uses Hooks::Dispatcher#on_error callback (chosen over a
9
+ # synthetic :hook_error event because the dispatcher already owns the
10
+ # rescue and the failure is a dispatcher-internal concern, not a domain
11
+ # event subscribers should be able to filter by key glob).
12
+ #
13
+ # NOTE (0.16 scope): lifecycle audit rows for verb: "put" / "delete" /
14
+ # "rename" are still written directly by Store::Writer and
15
+ # Application::Writes::Mv. Moving those into this subscriber requires
16
+ # event payloads to carry etag_before/etag_after across many write paths;
17
+ # that is properly a 0.18 port-extraction concern.
18
+ class AuditSubscriber
19
+ def initialize(audit_log)
20
+ @audit_log = audit_log
21
+ end
22
+
23
+ def attach(bus)
24
+ bus.on_error do |event:, hook:, key:, kwargs:, error:|
25
+ record_error(event: event, hook: hook, key: key, kwargs: kwargs, error: error)
26
+ end
27
+ self
28
+ end
29
+
30
+ private
31
+
32
+ def record_error(event:, hook:, key:, kwargs:, error:)
33
+ extras = { "event" => event.to_s, "hook" => hook.to_s, "error" => "#{error.class}: #{error.message}" }
34
+ extras["target_key"] = kwargs[:target_key] if kwargs.key?(:target_key)
35
+ extras["pending_key"] = kwargs[:pending_key] if kwargs.key?(:pending_key)
36
+ @audit_log.append(
37
+ role: "runner", verb: "event_error", key: key,
38
+ etag_before: nil, etag_after: nil, extras: extras
39
+ )
40
+ end
41
+ end
42
+ end
43
+ end
@@ -6,7 +6,7 @@ module Textus
6
6
  # Publish = copy + sentinel. The in-store file is already the consumer-shaped
7
7
  # artifact; no parsing or stripping.
8
8
  #
9
- # Sentinel I/O is delegated to Store::Sentinel. Sentinels live under
9
+ # Sentinel I/O is delegated to Textus::Domain::Sentinel. Sentinels live under
10
10
  # `<store_root>/sentinels/` and mirror the target's repo-relative layout so
11
11
  # consumer directories aren't polluted with `.textus-managed.json` siblings.
12
12
  module Publisher
@@ -15,7 +15,7 @@ module Textus
15
15
  refuse_if_unmanaged(target, store_root)
16
16
  File.delete(target) if File.symlink?(target)
17
17
  FileUtils.cp(source, target)
18
- Store::Sentinel.write!(target: target, source: source, store_root: store_root)
18
+ Textus::Domain::Sentinel.write!(target: target, source: source, store_root: store_root)
19
19
  end
20
20
 
21
21
  def self.refuse_if_unmanaged(target, store_root)
@@ -26,7 +26,7 @@ module Textus
26
26
  end
27
27
 
28
28
  def self.managed?(target, store_root)
29
- File.exist?(Store::Sentinel.sentinel_path(target, store_root))
29
+ File.exist?(Textus::Domain::Sentinel.sentinel_path(target, store_root))
30
30
  end
31
31
  end
32
32
  end
@@ -0,0 +1,26 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ module Infra
5
+ module Storage
6
+ # Pure filesystem I/O port. Wraps File/FileUtils/Etag with no knowledge
7
+ # of envelopes, entries, schemas, or audit.
8
+ class FileStore
9
+ def read(path) = File.binread(path)
10
+
11
+ def write(path, bytes)
12
+ FileUtils.mkdir_p(File.dirname(path))
13
+ File.binwrite(path, bytes)
14
+ end
15
+
16
+ # Raises Errno::ENOENT if absent — mirrors File.delete and matches the
17
+ # semantics used by Store::Writer (which guards with File.exist? first).
18
+ def delete(path) = File.delete(path)
19
+
20
+ def exists?(path) = File.exist?(path)
21
+
22
+ def etag(path) = Etag.for_file(path)
23
+ end
24
+ end
25
+ end
26
+ end
data/lib/textus/init.rb CHANGED
@@ -28,17 +28,19 @@ module Textus
28
28
  ## DSL
29
29
 
30
30
  ```ruby
31
- Textus.on(:resolve_intake, :my_source) do |config:, args:, **|
32
- { _meta: { "last_refreshed_at" => Time.now.utc.iso8601 }, body: "…" }
33
- end
31
+ Textus.hook do |reg|
32
+ reg.on(:resolve_intake, :my_source) do |config:, args:, **|
33
+ { _meta: { "last_refreshed_at" => Time.now.utc.iso8601 }, body: "…" }
34
+ end
34
35
 
35
- Textus.on(:transform_rows, :my_source) { |rows:, **| rows.map { |r| r.merge(processed: true) } }
36
- Textus.on(:validate, :my_check) { |store:, **| [] }
37
- Textus.on(:entry_put, :my_listener, keys: ["working.*"]) { |key:, envelope:, **| }
36
+ reg.on(:transform_rows, :my_source) { |rows:, **| rows.map { |r| r.merge(processed: true) } }
37
+ reg.on(:validate, :my_check) { |store:, **| [] }
38
+ reg.on(:entry_put, :my_listener, keys: ["working.*"]) { |key:, envelope:, **| }
38
39
 
39
- # Run a side-effect every time textus writes a file to your repo:
40
- Textus.on(:file_published, :notify) do |key:, target:, **|
41
- warn "wrote \#{target} (from \#{key})"
40
+ # Run a side-effect every time textus writes a file to your repo:
41
+ reg.on(:file_published, :notify) do |key:, target:, **|
42
+ warn "wrote \#{target} (from \#{key})"
43
+ end
42
44
  end
43
45
  ```
44
46
 
@@ -0,0 +1,5 @@
1
+ module Textus
2
+ class Manifest
3
+ Resolution = Data.define(:entry, :path, :remaining)
4
+ end
5
+ end
@@ -1,5 +1,6 @@
1
1
  require "yaml"
2
2
  require_relative "manifest/schema"
3
+ require_relative "manifest/resolution"
3
4
 
4
5
  module Textus
5
6
  class Manifest
@@ -86,7 +87,7 @@ module Textus
86
87
  rules.for(key)
87
88
  end
88
89
 
89
- # Returns [Manifest::Entry, resolved_path, remaining_segments]
90
+ # Returns a Resolution(entry:, path:, remaining:) value object.
90
91
  def resolve(key)
91
92
  validate_key!(key)
92
93
  segments = key.split(".")
@@ -101,7 +102,7 @@ module Textus
101
102
  remaining = segments[esegs.length..]
102
103
  if remaining.empty?
103
104
  path = resolve_leaf_path(entry)
104
- [entry, path, []]
105
+ Resolution.new(entry: entry, path: path, remaining: [])
105
106
  else
106
107
  raise UnknownKey.new(key, suggestions: suggestions_for(key)) unless entry.nested
107
108
 
@@ -111,7 +112,7 @@ module Textus
111
112
  primary_ext = Textus::Entry.for_format(entry.format).extensions.first
112
113
  File.join(@root, "zones", entry.path, *remaining) + primary_ext
113
114
  end
114
- [entry, path, remaining]
115
+ Resolution.new(entry: entry, path: path, remaining: remaining)
115
116
  end
116
117
  end
117
118
 
@@ -112,7 +112,7 @@ module Textus
112
112
  # ------------------------------------------------------------------
113
113
 
114
114
  def apply!(store, renames)
115
- audit = Store::AuditLog.new(store.root)
115
+ audit = Textus::Infra::AuditLog.new(store.root)
116
116
  renames.each do |r|
117
117
  # Bottom-up order means a child's ancestors haven't moved yet, so
118
118
  # `from`/`to` are valid as-recorded. The audit `key` reflects the
@@ -1,14 +1,13 @@
1
1
  module Textus
2
2
  # Single canonical entrypoint for invoking application use-cases against a
3
- # store. Mirrors the directory structure under `lib/textus/application/`:
3
+ # store. Public surface is flat one method per use case:
4
4
  #
5
5
  # ops = Textus::Operations.for(store, role: "agent")
6
- # ops.writes.put.call(key, body: "...")
7
- # ops.reads.get.call(key) # pure read
8
- # ops.reads.get_or_refresh.call(key) # read + refresh-on-stale
9
- # ops.refresh.worker.call(key)
10
- #
11
- # Replaces the prior `Textus::Composition` module (deleted in v0.12.2).
6
+ # ops.put(key, body: "...")
7
+ # ops.get(key) # pure read
8
+ # ops.get_or_refresh(key) # read + refresh-on-stale
9
+ # ops.refresh(key) # synchronous worker refresh
10
+ # ops.refresh_all(prefix: ..., zone: ...)
12
11
  class Operations
13
12
  def self.for(store, role: Role::DEFAULT, correlation_id: nil, dry_run: false)
14
13
  ctx = Application::Context.new(
@@ -26,20 +25,88 @@ module Textus
26
25
  @ctx = ctx
27
26
  end
28
27
 
29
- def writes
30
- @writes ||= Writes.new(@ctx)
31
- end
28
+ def with_role(role) = self.class.new(@ctx.with_role(role))
29
+
30
+ # writes
31
+ def put(...) = put_op.call(...)
32
+ def delete(...) = delete_op.call(...)
33
+ def mv(...) = mv_op.call(...)
34
+ def accept(...) = accept_op.call(...)
35
+ def reject(...) = reject_op.call(...)
36
+ def build(...) = build_op.call(...)
37
+ def publish(...) = publish_op.call(...)
38
+
39
+ # reads
40
+ def get(...) = get_op.call(...)
41
+ def get_or_refresh(...) = get_or_refresh_op.call(...)
42
+ def list(...) = list_op.call(...)
43
+ def where(...) = where_op.call(...)
44
+ def uid(...) = uid_op.call(...)
45
+ def schema_envelope(...) = schema_envelope_op.call(...)
46
+ def deps(...) = deps_op.call(...)
47
+ def rdeps(...) = rdeps_op.call(...)
48
+ def published(...) = published_op.call(...)
49
+ def stale(...) = stale_op.call(...)
50
+ def audit(...) = audit_op.call(...)
51
+ def blame(...) = blame_op.call(...)
52
+ def policy_explain(...) = policy_explain_op.call(...)
53
+ def freshness(...) = freshness_op.call(...)
54
+ def validate_all(...) = validate_all_op.call(...)
55
+
56
+ # refresh
57
+ def refresh(key) = refresh_worker_op.run(key)
58
+ def refresh_all(**) = Application::Refresh::All.call(@ctx, **)
59
+
60
+ private
32
61
 
33
- def reads
34
- @reads ||= Reads.new(@ctx)
62
+ def envelope_io
63
+ @envelope_io ||= Application::Writes::EnvelopeIO.new(
64
+ file_store: @ctx.file_store,
65
+ manifest: @ctx.manifest,
66
+ schemas: @ctx.schemas,
67
+ audit_log: @ctx.audit_log,
68
+ ctx: @ctx,
69
+ )
35
70
  end
36
71
 
37
- def refresh
38
- @refresh ||= Refresh.new(@ctx)
72
+ def put_op = @put_op ||= Application::Writes::Put.new(ctx: @ctx, envelope_io: envelope_io)
73
+ def delete_op = @delete_op ||= Application::Writes::Delete.new(ctx: @ctx, envelope_io: envelope_io)
74
+ def mv_op = @mv_op ||= Application::Writes::Mv.new(ctx: @ctx, envelope_io: envelope_io)
75
+ def accept_op = @accept_op ||= Application::Writes::Accept.new(ctx: @ctx, envelope_io: envelope_io)
76
+ def reject_op = @reject_op ||= Application::Writes::Reject.new(ctx: @ctx, envelope_io: envelope_io)
77
+ def build_op = @build_op ||= Application::Writes::Build.new(ctx: @ctx)
78
+ def publish_op = @publish_op ||= Application::Writes::Publish.new(ctx: @ctx)
79
+
80
+ def get_op = @get_op ||= Application::Reads::Get.new(ctx: @ctx) # rubocop:disable Naming/AccessorMethodName
81
+
82
+ def get_or_refresh_op # rubocop:disable Naming/AccessorMethodName
83
+ @get_or_refresh_op ||= Application::Reads::GetOrRefresh.new(ctx: @ctx, get: get_op,
84
+ orchestrator: orchestrator_op)
39
85
  end
40
86
 
41
- def with_role(role)
42
- self.class.new(@ctx.with_role(role))
87
+ def list_op = @list_op ||= Application::Reads::List.new(ctx: @ctx)
88
+ def where_op = @where_op ||= Application::Reads::Where.new(ctx: @ctx)
89
+ def uid_op = @uid_op ||= Application::Reads::Uid.new(ctx: @ctx)
90
+ def schema_envelope_op = @schema_envelope_op ||= Application::Reads::SchemaEnvelope.new(ctx: @ctx)
91
+ def deps_op = @deps_op ||= Application::Reads::Deps.new(ctx: @ctx)
92
+ def rdeps_op = @rdeps_op ||= Application::Reads::Rdeps.new(ctx: @ctx)
93
+ def published_op = @published_op ||= Application::Reads::Published.new(ctx: @ctx)
94
+ def stale_op = @stale_op ||= Application::Reads::Stale.new(ctx: @ctx)
95
+ def audit_op = @audit_op ||= Application::Reads::Audit.new(ctx: @ctx)
96
+ def blame_op = @blame_op ||= Application::Reads::Blame.new(ctx: @ctx)
97
+ def policy_explain_op = @policy_explain_op ||= Application::Reads::PolicyExplain.new(ctx: @ctx)
98
+ def freshness_op = @freshness_op ||= Application::Reads::Freshness.new(ctx: @ctx)
99
+ def validate_all_op = @validate_all_op ||= Application::Reads::ValidateAll.new(ctx: @ctx)
100
+
101
+ def refresh_worker_op = @refresh_worker_op ||= Application::Refresh::Worker.new(ctx: @ctx, envelope_io: envelope_io)
102
+
103
+ def orchestrator_op
104
+ @orchestrator_op ||= Application::Refresh::Orchestrator.new(
105
+ worker: refresh_worker_op,
106
+ store_root: @ctx.store.root,
107
+ store: @ctx.store,
108
+ role: @ctx.role,
109
+ )
43
110
  end
44
111
  end
45
112
  end
@@ -7,8 +7,8 @@ module Textus
7
7
  REDUCER_TIMEOUT_SECONDS = 2
8
8
 
9
9
  # `reader` — a callable `->(key) { envelope_or_nil }`. Caller picks
10
- # semantics: pure read (`ops.reads.get`) for materialization paths;
11
- # `ops.reads.get_or_refresh` if you want refresh-on-stale.
10
+ # semantics: pure read (`ops.get`) for materialization paths;
11
+ # `ops.get_or_refresh` if you want refresh-on-stale.
12
12
  # `lister` — a callable `->(prefix:) { [ { "key" => ... }, ... ] }`.
13
13
  # `transform_resolver` — a callable `->(name) { callable_or_raise }`.
14
14
  # `transform_context` — `Application::Context` handed to the transform reducer.
@@ -1,7 +1,7 @@
1
1
  module Textus
2
2
  module Refresh
3
3
  def self.call(store, key, as:)
4
- Textus::Operations.for(store, role: as).refresh.worker.run(key)
4
+ Textus::Operations.for(store, role: as).refresh(key)
5
5
  end
6
6
 
7
7
  def self.refresh_stale(store, prefix: nil, zone: nil, as: "runner")
@@ -6,7 +6,7 @@ module Textus
6
6
  module Tools
7
7
  # textus schema init NAME --from=KEY → infer YAML schema from an entry's frontmatter
8
8
  def self.init(store, name:, from:)
9
- env = Textus::Operations.for(store).reads.get.call(from)
9
+ env = Textus::Operations.for(store).get(from)
10
10
  meta = env.meta
11
11
  schema = {
12
12
  "name" => name,
@@ -25,7 +25,7 @@ module Textus
25
25
  schema = load_schema(store, name)
26
26
  drift = []
27
27
  store.manifest.enumerate.each do |row|
28
- env = Textus::Operations.for(store).reads.get.call(row[:key])
28
+ env = Textus::Operations.for(store).get(row[:key])
29
29
  begin
30
30
  schema.validate!(env.meta)
31
31
  rescue SchemaViolation => e
@@ -52,7 +52,7 @@ module Textus
52
52
  ops = Textus::Operations.for(store, role: "human")
53
53
  touched = []
54
54
  store.manifest.enumerate.each do |row|
55
- env = ops.reads.get.call(row[:key])
55
+ env = ops.get(row[:key])
56
56
  meta = env.meta.dup
57
57
  changed = false
58
58
  renames.each do |old, new|
@@ -63,7 +63,7 @@ module Textus
63
63
  end
64
64
  next unless changed
65
65
 
66
- ops.writes.put.call(row[:key], meta: meta, body: env.body)
66
+ ops.put(row[:key], meta: meta, body: env.body)
67
67
  touched << row[:key]
68
68
  end
69
69
  { "protocol" => PROTOCOL, "migrated" => touched, "renames" => renames }
@@ -81,7 +81,7 @@ module Textus
81
81
  end
82
82
 
83
83
  def self.load_schema(store, name)
84
- store.schema_for(name)
84
+ store.schemas.fetch(name)
85
85
  rescue IoError
86
86
  raise UsageError.new("schema not found: #{name}")
87
87
  end
@@ -0,0 +1,46 @@
1
+ module Textus
2
+ # Eager-loading schema cache. Loads every *.yaml under +dir+ at construction.
3
+ # A missing directory is treated as "no schemas" (does not raise) to mirror
4
+ # the lazy behavior previously embedded in Store#schema_for.
5
+ class Schemas
6
+ def initialize(dir)
7
+ @dir = dir
8
+ @schemas = {}
9
+ load_all
10
+ end
11
+
12
+ def fetch(name)
13
+ @schemas[name] || raise(IoError.new("schema not found: #{File.join(@dir, "#{name}.yaml")}"))
14
+ end
15
+
16
+ # Only nil short-circuits. A missing-but-named schema still raises IoError.
17
+ def fetch_or_nil(name)
18
+ return nil if name.nil?
19
+
20
+ fetch(name)
21
+ end
22
+
23
+ def all
24
+ @schemas.values
25
+ end
26
+
27
+ private
28
+
29
+ def load_all
30
+ return unless File.directory?(@dir)
31
+
32
+ Dir.glob(File.join(@dir, "*.yaml")).each do |path|
33
+ name = File.basename(path, ".yaml")
34
+ begin
35
+ @schemas[name] = Schema.load(path)
36
+ rescue StandardError
37
+ # Tolerate broken schema files at construction time so the rest of
38
+ # the store remains loadable. Surfacing the failure is the job of
39
+ # Doctor::Check::SchemaParseError. Lookups via #fetch still raise
40
+ # IoError for the missing-but-named schema.
41
+ next
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
data/lib/textus/store.rb CHANGED
@@ -1,16 +1,8 @@
1
1
  require "fileutils"
2
- require "securerandom"
3
2
 
4
3
  module Textus
5
4
  class Store
6
- attr_reader :root, :manifest, :registry, :reader, :writer, :bus
7
-
8
- # A Textus UID: 16 lowercase hex chars (SecureRandom.hex(8)). Not a UUID —
9
- # short on purpose. Random enough for collision-never-in-practice within a
10
- # single store.
11
- def self.mint_uid
12
- SecureRandom.hex(8)
13
- end
5
+ attr_reader :root, :manifest, :schemas, :file_store, :audit_log, :bus, :registry
14
6
 
15
7
  def self.discover(start_dir = Dir.pwd, root: nil)
16
8
  explicit = root || ENV.fetch("TEXTUS_ROOT", nil)
@@ -37,46 +29,17 @@ module Textus
37
29
  end
38
30
 
39
31
  def initialize(root)
40
- @root = File.expand_path(root)
41
- @manifest = Manifest.load(@root)
42
- @bus = Hooks::Dispatcher.new(audit_log: audit_log)
43
- @registry = Hooks::Registry.new(dispatcher: @bus)
44
- @schemas = {}
45
- load_hooks
46
- @reader = Reader.new(self)
47
- @writer = Writer.new(self)
48
- @bus.publish(:store_loaded, store: Textus::Application::Context.system(self))
49
- end
50
-
51
- def load_hooks
52
- Textus.with_registry(@registry) do
53
- Hooks::Builtin.register_all
54
- dir = File.join(@root, "hooks")
55
- return unless File.directory?(dir)
56
-
57
- Dir.glob(File.join(dir, "**/*.rb")).sort.each do |f| # rubocop:disable Lint/RedundantDirGlobSort
58
- begin
59
- load(f)
60
- rescue StandardError, ScriptError => e
61
- raise UsageError.new("failed loading hook #{File.basename(f)}: #{e.class}: #{e.message}")
62
- end
63
- end
64
- end
65
- end
66
-
67
- def schema_for(name)
68
- return nil if name.nil?
69
-
70
- @schemas[name] ||= begin
71
- sp = File.join(@root, "schemas", "#{name}.yaml")
72
- raise IoError.new("schema not found: #{sp}") unless File.exist?(sp)
73
-
74
- Schema.load(sp)
75
- end
76
- end
77
-
78
- def audit_log
79
- @audit_log ||= Store::AuditLog.new(@root)
32
+ @root = File.expand_path(root)
33
+ @manifest = Manifest.load(@root)
34
+ @schemas = Schemas.new(File.join(@root, "schemas"))
35
+ @file_store = Infra::Storage::FileStore.new
36
+ @audit_log = Infra::AuditLog.new(@root)
37
+ @bus = Hooks::Dispatcher.new
38
+ @registry = Hooks::Registry.new(dispatcher: @bus)
39
+ Infra::AuditSubscriber.new(@audit_log).attach(@bus)
40
+ Hooks::Builtin.register_all(@registry)
41
+ Hooks::Loader.new(registry: @registry).load_dir(File.join(@root, "hooks"))
42
+ @bus.publish(:store_loaded, store: Application::Context.system(self))
80
43
  end
81
44
  end
82
45
  end
data/lib/textus/uid.rb ADDED
@@ -0,0 +1,18 @@
1
+ require "securerandom"
2
+
3
+ module Textus
4
+ # A Textus UID: 16 lowercase hex chars (SecureRandom.hex(8)). Not a UUID —
5
+ # short on purpose. Random enough for collision-never-in-practice within a
6
+ # single store.
7
+ module Uid
8
+ module_function
9
+
10
+ def mint
11
+ SecureRandom.hex(8)
12
+ end
13
+
14
+ def valid?(str)
15
+ str.is_a?(String) && str.match?(/\A[0-9a-f]{16}\z/)
16
+ end
17
+ end
18
+ end
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.15.0"
2
+ VERSION = "0.18.0"
3
3
  PROTOCOL = "textus/3"
4
4
  end
data/lib/textus.rb CHANGED
@@ -8,11 +8,27 @@ loader.inflector.inflect(
8
8
  "json" => "Json",
9
9
  "yaml" => "Yaml",
10
10
  "hook_dsl_scanner" => "HookDSLScanner",
11
+ "envelope_io" => "EnvelopeIO",
11
12
  )
12
13
  loader.ignore(File.expand_path("textus/errors.rb", __dir__))
13
14
  loader.setup
14
15
  loader.eager_load
15
16
 
16
17
  module Textus
17
- extend Hooks::Dsl
18
+ @hook_mutex = Mutex.new
19
+ @hook_blocks = []
20
+
21
+ def self.hook(&blk)
22
+ raise UsageError.new("hook block required") unless blk
23
+
24
+ @hook_mutex.synchronize { @hook_blocks << blk }
25
+ end
26
+
27
+ def self.drain_hook_blocks
28
+ @hook_mutex.synchronize do
29
+ blocks = @hook_blocks
30
+ @hook_blocks = []
31
+ blocks
32
+ end
33
+ end
18
34
  end
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.15.0
4
+ version: 0.18.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -124,6 +124,7 @@ files:
124
124
  - lib/textus/application/reads/stale.rb
125
125
  - lib/textus/application/reads/uid.rb
126
126
  - lib/textus/application/reads/validate_all.rb
127
+ - lib/textus/application/reads/validator.rb
127
128
  - lib/textus/application/reads/where.rb
128
129
  - lib/textus/application/refresh/all.rb
129
130
  - lib/textus/application/refresh/orchestrator.rb
@@ -131,6 +132,7 @@ files:
131
132
  - lib/textus/application/writes/accept.rb
132
133
  - lib/textus/application/writes/build.rb
133
134
  - lib/textus/application/writes/delete.rb
135
+ - lib/textus/application/writes/envelope_io.rb
134
136
  - lib/textus/application/writes/mv.rb
135
137
  - lib/textus/application/writes/publish.rb
136
138
  - lib/textus/application/writes/put.rb
@@ -198,6 +200,7 @@ files:
198
200
  - lib/textus/doctor/check/templates.rb
199
201
  - lib/textus/doctor/check/unowned_schema_fields.rb
200
202
  - lib/textus/domain/action.rb
203
+ - lib/textus/domain/freshness.rb
201
204
  - lib/textus/domain/freshness/evaluator.rb
202
205
  - lib/textus/domain/freshness/policy.rb
203
206
  - lib/textus/domain/freshness/verdict.rb
@@ -211,6 +214,10 @@ files:
211
214
  - lib/textus/domain/policy/promote.rb
212
215
  - lib/textus/domain/policy/promotion.rb
213
216
  - lib/textus/domain/policy/refresh.rb
217
+ - lib/textus/domain/sentinel.rb
218
+ - lib/textus/domain/staleness.rb
219
+ - lib/textus/domain/staleness/generator_check.rb
220
+ - lib/textus/domain/staleness/intake_check.rb
214
221
  - lib/textus/entry.rb
215
222
  - lib/textus/entry/base.rb
216
223
  - lib/textus/entry/json.rb
@@ -222,15 +229,17 @@ files:
222
229
  - lib/textus/etag.rb
223
230
  - lib/textus/hooks/builtin.rb
224
231
  - lib/textus/hooks/dispatcher.rb
225
- - lib/textus/hooks/dsl.rb
226
232
  - lib/textus/hooks/loader.rb
227
233
  - lib/textus/hooks/registry.rb
234
+ - lib/textus/infra/audit_log.rb
235
+ - lib/textus/infra/audit_subscriber.rb
228
236
  - lib/textus/infra/build_lock.rb
229
237
  - lib/textus/infra/clock.rb
230
238
  - lib/textus/infra/event_bus.rb
231
239
  - lib/textus/infra/publisher.rb
232
240
  - lib/textus/infra/refresh/detached.rb
233
241
  - lib/textus/infra/refresh/lock.rb
242
+ - lib/textus/infra/storage/file_store.rb
234
243
  - lib/textus/init.rb
235
244
  - lib/textus/intro.rb
236
245
  - lib/textus/key/distance.rb
@@ -245,28 +254,20 @@ files:
245
254
  - lib/textus/manifest/entry/validators/index_filename.rb
246
255
  - lib/textus/manifest/entry/validators/inject_intro.rb
247
256
  - lib/textus/manifest/entry/validators/publish_each.rb
257
+ - lib/textus/manifest/resolution.rb
248
258
  - lib/textus/manifest/rules.rb
249
259
  - lib/textus/manifest/schema.rb
250
260
  - lib/textus/migrate_keys.rb
251
261
  - lib/textus/mustache.rb
252
262
  - lib/textus/operations.rb
253
- - lib/textus/operations/reads.rb
254
- - lib/textus/operations/refresh.rb
255
- - lib/textus/operations/writes.rb
256
263
  - lib/textus/projection.rb
257
264
  - lib/textus/refresh.rb
258
265
  - lib/textus/role.rb
259
266
  - lib/textus/schema.rb
260
267
  - lib/textus/schema/tools.rb
268
+ - lib/textus/schemas.rb
261
269
  - lib/textus/store.rb
262
- - lib/textus/store/audit_log.rb
263
- - lib/textus/store/reader.rb
264
- - lib/textus/store/sentinel.rb
265
- - lib/textus/store/staleness.rb
266
- - lib/textus/store/staleness/generator_check.rb
267
- - lib/textus/store/staleness/intake_check.rb
268
- - lib/textus/store/validator.rb
269
- - lib/textus/store/writer.rb
270
+ - lib/textus/uid.rb
270
271
  - lib/textus/version.rb
271
272
  homepage: https://github.com/patrick204nqh/textus
272
273
  licenses:
@@ -1,11 +0,0 @@
1
- module Textus
2
- module Hooks
3
- module Dsl
4
- def on(event, name, **, &blk)
5
- raise UsageError.new("hook needs a block") unless blk
6
-
7
- Loader.current_registry.register(event, name, **, &blk)
8
- end
9
- end
10
- end
11
- end