textus 0.20.2 → 0.26.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 (175) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +148 -45
  3. data/CHANGELOG.md +194 -0
  4. data/README.md +8 -5
  5. data/SPEC.md +54 -15
  6. data/docs/conventions.md +10 -0
  7. data/lib/textus/application/caps.rb +49 -0
  8. data/lib/textus/application/context.rb +2 -2
  9. data/lib/textus/application/envelope/reader.rb +44 -0
  10. data/lib/textus/application/{writes/envelope_io.rb → envelope/writer.rb} +24 -50
  11. data/lib/textus/application/maintenance/key_delete_prefix.rb +44 -0
  12. data/lib/textus/application/maintenance/key_mv_prefix.rb +57 -0
  13. data/lib/textus/application/maintenance/migrate.rb +59 -0
  14. data/lib/textus/application/maintenance/rule_lint.rb +65 -0
  15. data/lib/textus/application/maintenance/zone_mv.rb +60 -0
  16. data/lib/textus/application/maintenance.rb +17 -0
  17. data/lib/textus/application/projection.rb +12 -10
  18. data/lib/textus/application/read/audit.rb +106 -0
  19. data/lib/textus/application/read/blame.rb +91 -0
  20. data/lib/textus/application/read/deps.rb +34 -0
  21. data/lib/textus/application/read/freshness.rb +110 -0
  22. data/lib/textus/application/read/get.rb +75 -0
  23. data/lib/textus/application/read/get_or_refresh.rb +63 -0
  24. data/lib/textus/application/read/list.rb +25 -0
  25. data/lib/textus/application/read/policy_explain.rb +47 -0
  26. data/lib/textus/application/read/published.rb +25 -0
  27. data/lib/textus/application/read/pulse.rb +101 -0
  28. data/lib/textus/application/read/rdeps.rb +35 -0
  29. data/lib/textus/application/read/schema_envelope.rb +26 -0
  30. data/lib/textus/application/read/stale.rb +23 -0
  31. data/lib/textus/application/read/uid.rb +30 -0
  32. data/lib/textus/application/read/validate_all.rb +32 -0
  33. data/lib/textus/application/{reads → read}/validator.rb +2 -2
  34. data/lib/textus/application/read/where.rb +26 -0
  35. data/lib/textus/application/use_case.rb +22 -0
  36. data/lib/textus/application/write/accept.rb +102 -0
  37. data/lib/textus/application/{writes → write}/authority_gate.rb +3 -3
  38. data/lib/textus/application/write/delete.rb +45 -0
  39. data/lib/textus/application/{writes → write}/materializer.rb +14 -15
  40. data/lib/textus/application/write/mv.rb +118 -0
  41. data/lib/textus/application/write/publish.rb +96 -0
  42. data/lib/textus/application/write/put.rb +49 -0
  43. data/lib/textus/application/write/refresh_all.rb +63 -0
  44. data/lib/textus/application/{refresh/orchestrator.rb → write/refresh_orchestrator.rb} +32 -8
  45. data/lib/textus/application/write/refresh_worker.rb +134 -0
  46. data/lib/textus/application/write/reject.rb +62 -0
  47. data/lib/textus/{intro.rb → boot.rb} +49 -29
  48. data/lib/textus/builder/pipeline.rb +5 -5
  49. data/lib/textus/cli/group/mcp.rb +9 -0
  50. data/lib/textus/cli/group/zone.rb +9 -0
  51. data/lib/textus/cli/verb/accept.rb +1 -1
  52. data/lib/textus/cli/verb/audit.rb +4 -2
  53. data/lib/textus/cli/verb/blame.rb +1 -1
  54. data/lib/textus/cli/verb/boot.rb +13 -0
  55. data/lib/textus/cli/verb/build.rb +2 -2
  56. data/lib/textus/cli/verb/delete.rb +1 -1
  57. data/lib/textus/cli/verb/deps.rb +1 -1
  58. data/lib/textus/cli/verb/doctor.rb +1 -1
  59. data/lib/textus/cli/verb/freshness.rb +1 -1
  60. data/lib/textus/cli/verb/get.rb +1 -1
  61. data/lib/textus/cli/verb/hook_run.rb +3 -4
  62. data/lib/textus/cli/verb/hooks.rb +11 -14
  63. data/lib/textus/cli/verb/key_delete.rb +24 -0
  64. data/lib/textus/cli/verb/list.rb +1 -1
  65. data/lib/textus/cli/verb/mcp_serve.rb +17 -0
  66. data/lib/textus/cli/verb/migrate.rb +18 -0
  67. data/lib/textus/cli/verb/mv.rb +11 -3
  68. data/lib/textus/cli/verb/published.rb +1 -1
  69. data/lib/textus/cli/verb/pulse.rb +17 -0
  70. data/lib/textus/cli/verb/put.rb +8 -6
  71. data/lib/textus/cli/verb/rdeps.rb +1 -1
  72. data/lib/textus/cli/verb/refresh.rb +1 -1
  73. data/lib/textus/cli/verb/refresh_stale.rb +1 -1
  74. data/lib/textus/cli/verb/reject.rb +1 -1
  75. data/lib/textus/cli/verb/rule_explain.rb +1 -1
  76. data/lib/textus/cli/verb/rule_lint.rb +18 -0
  77. data/lib/textus/cli/verb/schema.rb +1 -1
  78. data/lib/textus/cli/verb/uid.rb +1 -1
  79. data/lib/textus/cli/verb/where.rb +1 -1
  80. data/lib/textus/cli/verb/zone_mv.rb +19 -0
  81. data/lib/textus/cli/verb.rb +4 -4
  82. data/lib/textus/cli.rb +1 -1
  83. data/lib/textus/doctor/check/audit_log.rb +2 -2
  84. data/lib/textus/doctor/check/handler_allowlist.rb +2 -2
  85. data/lib/textus/doctor/check/hooks.rb +4 -3
  86. data/lib/textus/doctor/check/illegal_keys.rb +2 -2
  87. data/lib/textus/doctor/check/intake_registration.rb +2 -2
  88. data/lib/textus/doctor/check/manifest_files.rb +2 -2
  89. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  90. data/lib/textus/doctor/check/refresh_locks.rb +2 -2
  91. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  92. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  93. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  94. data/lib/textus/doctor/check/schemas.rb +2 -2
  95. data/lib/textus/doctor/check/sentinels.rb +2 -2
  96. data/lib/textus/doctor/check/templates.rb +2 -2
  97. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  98. data/lib/textus/doctor/check.rb +5 -3
  99. data/lib/textus/doctor.rb +24 -27
  100. data/lib/textus/domain/authorizer.rb +4 -4
  101. data/lib/textus/{application → domain}/policy/predicates/accept_authority_signed.rb +2 -2
  102. data/lib/textus/{application → domain}/policy/predicates/schema_valid.rb +1 -1
  103. data/lib/textus/{application → domain}/policy/promotion.rb +1 -1
  104. data/lib/textus/domain/staleness/generator_check.rb +2 -2
  105. data/lib/textus/domain/staleness/intake_check.rb +2 -2
  106. data/lib/textus/domain/staleness.rb +1 -1
  107. data/lib/textus/errors.rb +16 -0
  108. data/lib/textus/hooks/builtin.rb +14 -14
  109. data/lib/textus/hooks/context.rb +13 -13
  110. data/lib/textus/hooks/error_log.rb +32 -0
  111. data/lib/textus/hooks/{bus.rb → event_bus.rb} +41 -53
  112. data/lib/textus/hooks/loader.rb +29 -3
  113. data/lib/textus/hooks/rpc_registry.rb +77 -0
  114. data/lib/textus/infra/audit_log.rb +126 -16
  115. data/lib/textus/infra/audit_subscriber.rb +6 -7
  116. data/lib/textus/infra/refresh/detached.rb +1 -1
  117. data/lib/textus/key/path.rb +7 -3
  118. data/lib/textus/manifest/data.rb +78 -0
  119. data/lib/textus/manifest/entry/base.rb +44 -7
  120. data/lib/textus/manifest/entry/derived.rb +41 -6
  121. data/lib/textus/manifest/entry/intake.rb +15 -3
  122. data/lib/textus/manifest/entry/leaf.rb +6 -5
  123. data/lib/textus/manifest/entry/nested.rb +42 -3
  124. data/lib/textus/manifest/entry/parser.rb +8 -44
  125. data/lib/textus/manifest/entry/validators/events.rb +2 -2
  126. data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -4
  127. data/lib/textus/manifest/entry/validators/index_filename.rb +2 -1
  128. data/lib/textus/manifest/entry/validators/inject_boot.rb +19 -0
  129. data/lib/textus/manifest/entry/validators/publish_each.rb +4 -3
  130. data/lib/textus/manifest/entry/validators.rb +1 -1
  131. data/lib/textus/manifest/entry.rb +3 -0
  132. data/lib/textus/manifest/policy.rb +48 -0
  133. data/lib/textus/manifest/resolver.rb +18 -18
  134. data/lib/textus/manifest/rules.rb +1 -1
  135. data/lib/textus/manifest/schema.rb +20 -6
  136. data/lib/textus/manifest.rb +53 -101
  137. data/lib/textus/mcp/errors.rb +32 -0
  138. data/lib/textus/mcp/server.rb +127 -0
  139. data/lib/textus/mcp/session.rb +31 -0
  140. data/lib/textus/mcp/tool_schemas.rb +71 -0
  141. data/lib/textus/mcp/tools.rb +129 -0
  142. data/lib/textus/mcp.rb +6 -0
  143. data/lib/textus/schema/tools.rb +14 -10
  144. data/lib/textus/session.rb +84 -0
  145. data/lib/textus/store.rb +17 -8
  146. data/lib/textus/version.rb +1 -1
  147. data/lib/textus.rb +8 -1
  148. metadata +65 -38
  149. data/lib/textus/application/reads/audit.rb +0 -69
  150. data/lib/textus/application/reads/blame.rb +0 -82
  151. data/lib/textus/application/reads/deps.rb +0 -26
  152. data/lib/textus/application/reads/freshness.rb +0 -88
  153. data/lib/textus/application/reads/get.rb +0 -67
  154. data/lib/textus/application/reads/get_or_refresh.rb +0 -51
  155. data/lib/textus/application/reads/list.rb +0 -17
  156. data/lib/textus/application/reads/policy_explain.rb +0 -39
  157. data/lib/textus/application/reads/published.rb +0 -17
  158. data/lib/textus/application/reads/rdeps.rb +0 -27
  159. data/lib/textus/application/reads/schema_envelope.rb +0 -18
  160. data/lib/textus/application/reads/stale.rb +0 -15
  161. data/lib/textus/application/reads/uid.rb +0 -23
  162. data/lib/textus/application/reads/validate_all.rb +0 -24
  163. data/lib/textus/application/reads/where.rb +0 -18
  164. data/lib/textus/application/refresh/all.rb +0 -52
  165. data/lib/textus/application/refresh/worker.rb +0 -116
  166. data/lib/textus/application/writes/accept.rb +0 -89
  167. data/lib/textus/application/writes/delete.rb +0 -33
  168. data/lib/textus/application/writes/mv.rb +0 -105
  169. data/lib/textus/application/writes/publish.rb +0 -162
  170. data/lib/textus/application/writes/put.rb +0 -37
  171. data/lib/textus/application/writes/reject.rb +0 -50
  172. data/lib/textus/cli/verb/intro.rb +0 -13
  173. data/lib/textus/infra/event_bus.rb +0 -27
  174. data/lib/textus/manifest/entry/validators/inject_intro.rb +0 -21
  175. data/lib/textus/operations.rb +0 -169
data/docs/conventions.md CHANGED
@@ -126,6 +126,16 @@ Build always uses the pure path; injecting refresh into materialization caused t
126
126
 
127
127
  For multi-writer environments, **always pass `if_etag`** on `put`. The gem treats etag-less writes as last-writer-wins on purpose (single-writer scripts, fresh-file creation), but anything resembling a daemon or a long-running agent should round-trip the etag.
128
128
 
129
+ ## Application layering
130
+
131
+ The application layer is organised around three patterns — `Manifest` as a composition record, capability slices (`Caps`) handed to use cases, and a split envelope reader/writer. See [ADR 0018](architecture/decisions/0018-manifest-carving.md), [ADR 0017](architecture/decisions/0017-envelope-io-split.md), [ADR 0020](architecture/decisions/0020-capability-records.md), and [ADR 0021](architecture/decisions/0021-session-and-module-use-cases.md).
132
+
133
+ - **`Manifest` is a composition record** (`Data.define(:data, :resolver, :policy, :rules)`). Reach individual concerns through the field accessors: `manifest.data.entries`, `manifest.policy.permission_for(zone)`, `manifest.resolver.resolve(key)`, `manifest.rules.for(key)`. (The legacy top-level shims were removed in 0.26.0.)
134
+ - **Application use cases take `session:`, `ctx:`, `caps:`** and are registered with `Application::UseCase.register(:verb, mod, caps: :read|:write)`. Each use case is a module with a `self.call(...)` entry point and a nested `class Impl` for state. `Caps` is a `Data.define` slice of the Store (`ReadCaps`, `WriteCaps`, `HookCaps`) — use cases pull only what they need into ivars; nobody passes the raw `Store` around the application layer.
135
+ - **Write path is split**: `Application::Envelope::Reader` owns read/parse (existing-uid lookup, raw read, parse), and `Application::Envelope::Writer` owns put/delete/move + the audit-append invariant (every public method's final action is `@audit_log.append(...)`).
136
+
137
+ The user-facing CLI surface, the wire envelope shape, and the protocol version (`textus/3`) are unchanged.
138
+
129
139
  ## Pairing with other tools
130
140
 
131
141
  - **MCP servers**: a thin server that exposes `textus get` and `textus put` as tools is the recommended way to give Claude/agents access. Don't bake MCP into this gem.
@@ -0,0 +1,49 @@
1
+ module Textus
2
+ module Application
3
+ # Capability records: role-scoped slices of the Store handed to use cases.
4
+ # Zeitwerk maps this file to Textus::Application::Caps; the three
5
+ # concrete cap types are also promoted to the Application namespace for
6
+ # concise reference (Application::ReadCaps, etc.).
7
+ module Caps
8
+ ReadCaps = Data.define(:manifest, :file_store, :schemas, :root, :audit_log, :events)
9
+
10
+ WriteCaps = Data.define(
11
+ :manifest, :file_store, :schemas, :root,
12
+ :audit_log, :events, :authorizer
13
+ ) do
14
+ def read
15
+ ReadCaps.new(
16
+ manifest: manifest, file_store: file_store, schemas: schemas, root: root,
17
+ audit_log: audit_log, events: events
18
+ )
19
+ end
20
+ end
21
+
22
+ HookCaps = Data.define(:events, :rpc, :manifest, :root)
23
+ end
24
+
25
+ # Promote to Application namespace for concise reference.
26
+ ReadCaps = Caps::ReadCaps
27
+ WriteCaps = Caps::WriteCaps
28
+ HookCaps = Caps::HookCaps
29
+
30
+ def self.caps_from_store(store)
31
+ read = ReadCaps.new(
32
+ manifest: store.manifest, file_store: store.file_store,
33
+ schemas: store.schemas, root: store.root,
34
+ audit_log: store.audit_log, events: store.events
35
+ )
36
+ write = WriteCaps.new(
37
+ manifest: store.manifest, file_store: store.file_store,
38
+ schemas: store.schemas, root: store.root,
39
+ audit_log: store.audit_log, events: store.events,
40
+ authorizer: Textus::Domain::Authorizer.new(manifest: store.manifest)
41
+ )
42
+ hook = HookCaps.new(
43
+ events: store.events, rpc: store.rpc,
44
+ manifest: store.manifest, root: store.root
45
+ )
46
+ [read, write, hook]
47
+ end
48
+ end
49
+ end
@@ -7,8 +7,8 @@ module Textus
7
7
  # writes should be suppressed (dry_run).
8
8
  #
9
9
  # Collaborators (manifest, file_store, bus, audit log, authorizer) are
10
- # never read from Context — use cases declare them as explicit
11
- # constructor ports, and Operations wires them in from the Store.
10
+ # never read from Context — use cases pull them from a Caps record
11
+ # (Read/Write/Hook) that Session derives from the Store.
12
12
  Context = Data.define(:role, :correlation_id, :now, :dry_run) do
13
13
  def self.build(role:, correlation_id: nil, now: nil, dry_run: false)
14
14
  new(
@@ -0,0 +1,44 @@
1
+ module Textus
2
+ module Application
3
+ module Envelope
4
+ # Read-only counterpart to EnvelopeWriter. Resolves a key, reads the
5
+ # bytes, parses them via the format strategy, and hands back an
6
+ # Envelope. Used by Mv (pre-move inspection) and by EnvelopeWriter
7
+ # (existing-uid lookup for the uid-preservation step in #put).
8
+ #
9
+ # No audit, no events, no permission checks — those live one layer up.
10
+ class Reader
11
+ def initialize(file_store:, manifest:)
12
+ @file_store = file_store
13
+ @manifest = manifest
14
+ end
15
+
16
+ def read(key)
17
+ res = @manifest.resolver.resolve(key)
18
+ path = res.path
19
+ return nil unless @file_store.exists?(path)
20
+
21
+ mentry = res.entry
22
+ raw = @file_store.read(path)
23
+ parsed = Entry.for_format(mentry.format).parse(raw, path: path)
24
+ Textus::Envelope.build(
25
+ key: key, mentry: mentry, path: path,
26
+ meta: parsed["_meta"], body: parsed["body"],
27
+ etag: Etag.for_bytes(raw), content: parsed["content"]
28
+ )
29
+ end
30
+
31
+ def existing_uid(key)
32
+ env = read(key)
33
+ env&.uid
34
+ rescue StandardError
35
+ nil
36
+ end
37
+
38
+ def exists?(key)
39
+ @file_store.exists?(@manifest.resolver.resolve(key).path)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -2,55 +2,37 @@ require "fileutils"
2
2
 
3
3
  module Textus
4
4
  module Application
5
- module Writes
6
- # Owns the write pipeline (validate, serialize, etag-check, write, audit)
7
- # extracted from Store::Writer. Talks to ports (FileStore, Schemas,
8
- # AuditLog, Manifest) instead of File/FileUtils and Store directly.
5
+ module Envelope
6
+ # Owns the write pipeline (validate, serialize, etag-check, write, audit).
7
+ # Talks to ports (FileStore, Schemas, AuditLog, Manifest) and an
8
+ # Reader for the existing-uid lookup.
9
+ #
10
+ # Invariant: every public method's final action is @audit_log.append(...).
9
11
  #
10
12
  # No permission check, no event firing — those belong to the caller
11
- # (Application::Writes::Put / ::Delete / ::Mv).
12
- class EnvelopeIO
13
+ # (Application::Write::Put / ::Delete / ::Mv).
14
+ class Writer
13
15
  Payload = Data.define(:meta, :body, :content)
14
16
 
15
- def initialize(file_store:, manifest:, schemas:, audit_log:, ctx:)
17
+ def initialize(file_store:, manifest:, schemas:, audit_log:, ctx:, reader:)
16
18
  @file_store = file_store
17
19
  @manifest = manifest
18
20
  @schemas = schemas
19
21
  @audit_log = audit_log
20
22
  @ctx = ctx
23
+ @reader = reader
21
24
  end
22
25
 
23
- def exists?(path) = @file_store.exists?(path)
24
-
25
- # Reads an envelope by key, returning nil when absent. Used by Mv
26
- # to inspect pre-move state (UID presence, content surfacing) so
27
- # the move pipeline can consolidate I/O in one place.
28
- def read_envelope(key)
29
- res = @manifest.resolver.resolve(key)
30
- path = res.path
31
- return nil unless @file_store.exists?(path)
32
-
33
- mentry = res.entry
34
- raw = @file_store.read(path)
35
- parsed = Entry.for_format(mentry.format).parse(raw, path: path)
36
- Envelope.build(
37
- key: key, mentry: mentry, path: path,
38
- meta: parsed["_meta"], body: parsed["body"],
39
- etag: Etag.for_bytes(raw), content: parsed["content"]
40
- )
41
- end
42
-
43
- def write(key, mentry:, payload:, if_etag: nil)
26
+ def put(key, mentry:, payload:, if_etag: nil)
44
27
  path = @manifest.resolver.resolve(key).path
45
28
 
46
29
  meta = payload.meta || {}
47
- strategy = Entry.for_format(mentry.format)
48
30
 
49
- existing_uid = existing_uid_for(mentry, path)
31
+ existing_uid = @reader.existing_uid(key)
50
32
  meta, content = ensure_uid(mentry.format, meta, payload.content, existing_uid)
51
33
 
52
34
  bytes, eff_meta, eff_body, eff_content = serialize_for_put(
53
- mentry: mentry, path: path, strategy: strategy,
35
+ mentry: mentry, path: path,
54
36
  meta: meta, body: payload.body, content: content
55
37
  )
56
38
 
@@ -69,19 +51,22 @@ module Textus
69
51
 
70
52
  @file_store.write(path, bytes)
71
53
  etag_after = Etag.for_bytes(bytes)
54
+ envelope = Textus::Envelope.build(
55
+ key: key, mentry: mentry, path: path,
56
+ meta: eff_meta, body: eff_body, etag: etag_after, content: eff_content
57
+ )
72
58
  @audit_log.append(
73
59
  role: @ctx.role, verb: "put", key: key,
74
60
  etag_before: etag_before, etag_after: etag_after,
75
61
  extras: @ctx.correlation_id ? { "correlation_id" => @ctx.correlation_id } : nil
76
62
  )
77
- Envelope.build(
78
- key: key, mentry: mentry, path: path,
79
- meta: eff_meta, body: eff_body, etag: etag_after, content: eff_content
80
- )
63
+ envelope
81
64
  end
82
65
 
83
- def delete(key, mentry:, if_etag: nil)
84
- _ = mentry
66
+ def delete(key, mentry: nil, if_etag: nil) # rubocop:disable Lint/UnusedMethodArgument
67
+ # `mentry:` is accepted for symmetry with `put` / `move` and to
68
+ # leave room for future format-specific delete hooks; no field
69
+ # on it is needed today.
85
70
  path = @manifest.resolver.resolve(key).path
86
71
  raise UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)) unless @file_store.exists?(path)
87
72
 
@@ -112,7 +97,7 @@ module Textus
112
97
 
113
98
  raw = @file_store.read(to_path)
114
99
  parsed = Entry.for_format(new_mentry.format).parse(raw, path: to_path)
115
- envelope = Envelope.build(
100
+ envelope = Textus::Envelope.build(
116
101
  key: to_key, mentry: new_mentry, path: to_path,
117
102
  meta: parsed["_meta"], body: parsed["body"],
118
103
  etag: etag_after, content: parsed["content"]
@@ -136,16 +121,6 @@ module Textus
136
121
 
137
122
  private
138
123
 
139
- def existing_uid_for(mentry, path)
140
- return nil unless @file_store.exists?(path)
141
-
142
- raw = @file_store.read(path)
143
- parsed = Entry.for_format(mentry.format).parse(raw, path: path)
144
- Envelope.extract_uid(parsed["_meta"])
145
- rescue StandardError
146
- nil
147
- end
148
-
149
124
  def ensure_uid(format, meta, content, existing_uid)
150
125
  Textus::Entry.for_format(format).inject_uid(meta, content, existing_uid)
151
126
  end
@@ -154,8 +129,7 @@ module Textus
154
129
  Textus::Entry.for_format(format).enforce_name_match!(path, meta)
155
130
  end
156
131
 
157
- def serialize_for_put(mentry:, path:, strategy:, meta:, body:, content:)
158
- _ = strategy
132
+ def serialize_for_put(mentry:, path:, meta:, body:, content:)
159
133
  Textus::Entry.for_format(mentry.format).serialize_for_put(
160
134
  meta: meta, body: body, content: content, path: path,
161
135
  )
@@ -0,0 +1,44 @@
1
+ module Textus
2
+ module Application
3
+ module Maintenance
4
+ # Bulk-delete every leaf key under `prefix`.
5
+ module KeyDeletePrefix
6
+ def self.call(*, session:, ctx:, caps:, **)
7
+ Impl.new(ctx: ctx, caps: caps, session: session).call(*, **)
8
+ end
9
+
10
+ class Impl
11
+ def initialize(ctx:, caps:, session:)
12
+ @ctx = ctx
13
+ @caps = caps
14
+ @session = session
15
+ end
16
+
17
+ def call(prefix:, dry_run: false)
18
+ raise UsageError.new("prefix required") if prefix.nil? || prefix.empty?
19
+
20
+ leaves = Read::List::Impl.new(caps: @caps)
21
+ .call(prefix: prefix)
22
+ .map { |r| r.is_a?(Hash) ? (r["key"] || r[:key]) : r }
23
+
24
+ warnings = leaves.empty? ? ["no keys under #{prefix}"] : []
25
+ steps = leaves.map { |k| { "op" => "delete", "key" => k } }
26
+
27
+ plan = Plan.new(steps: steps, warnings: warnings)
28
+ return plan if dry_run
29
+
30
+ steps.each do |s|
31
+ Textus::Application::Write::Delete.call(
32
+ s["key"],
33
+ session: @session, ctx: @ctx, caps: @session.write_caps,
34
+ )
35
+ end
36
+ plan
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ Textus::Application::UseCase.register(:key_delete_prefix, Textus::Application::Maintenance::KeyDeletePrefix, caps: :write)
@@ -0,0 +1,57 @@
1
+ module Textus
2
+ module Application
3
+ module Maintenance
4
+ # Bulk-rename every leaf key under `from_prefix` to `to_prefix`.
5
+ # Calls Write::Mv directly for each entry — emits one audit row per file moved.
6
+ module KeyMvPrefix
7
+ def self.call(*, session:, ctx:, caps:, **)
8
+ Impl.new(ctx: ctx, caps: caps, session: session).call(*, **)
9
+ end
10
+
11
+ class Impl
12
+ def initialize(ctx:, caps:, session:)
13
+ @ctx = ctx
14
+ @caps = caps
15
+ @session = session
16
+ end
17
+
18
+ def call(from_prefix:, to_prefix:, dry_run: false)
19
+ raise UsageError.new("from_prefix and to_prefix required") if from_prefix.nil? || to_prefix.nil?
20
+
21
+ leaves = list_leaves_under(from_prefix)
22
+ warnings = []
23
+ warnings << "no keys under #{from_prefix}" if leaves.empty?
24
+
25
+ steps = leaves.map do |old_key|
26
+ tail = old_key.delete_prefix("#{from_prefix}.")
27
+ new_key = "#{to_prefix}.#{tail}"
28
+ { "op" => "mv", "from" => old_key, "to" => new_key }
29
+ end
30
+
31
+ plan = Plan.new(steps: steps, warnings: warnings)
32
+ return plan if dry_run
33
+
34
+ steps.each do |s|
35
+ Textus::Application::Write::Mv.call(
36
+ s["from"], s["to"],
37
+ session: @session, ctx: @ctx, caps: @session.write_caps,
38
+ dry_run: false
39
+ )
40
+ end
41
+ plan
42
+ end
43
+
44
+ private
45
+
46
+ def list_leaves_under(prefix)
47
+ Read::List::Impl.new(caps: @caps)
48
+ .call(prefix: prefix)
49
+ .map { |row| row.is_a?(Hash) ? (row["key"] || row[:key]) : row }
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ Textus::Application::UseCase.register(:key_mv_prefix, Textus::Application::Maintenance::KeyMvPrefix, caps: :write)
@@ -0,0 +1,59 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ module Application
5
+ module Maintenance
6
+ # Loads a YAML migration plan and dispatches each op to the
7
+ # appropriate Maintenance use case. Concatenates resulting Plans.
8
+ module Migrate
9
+ def self.call(*, session:, ctx:, caps:, **)
10
+ Impl.new(ctx: ctx, caps: caps, session: session).call(*, **)
11
+ end
12
+
13
+ class Impl
14
+ def initialize(ctx:, caps:, session:)
15
+ @ctx = ctx
16
+ @caps = caps
17
+ @session = session
18
+ end
19
+
20
+ def call(plan_yaml:, dry_run: false)
21
+ raw = YAML.safe_load(plan_yaml, permitted_classes: [Symbol], aliases: false)
22
+ raise UsageError.new("migration plan must be a YAML mapping") unless raw.is_a?(Hash)
23
+
24
+ ops = Array(raw["operations"])
25
+ all_steps = []
26
+ warnings = []
27
+
28
+ ops.each do |op_hash|
29
+ op_name = op_hash["op"]
30
+ sub_plan = invoke_op(op_name, op_hash, dry_run: dry_run)
31
+ all_steps.concat(sub_plan.steps)
32
+ warnings.concat(sub_plan.warnings)
33
+ end
34
+
35
+ Plan.new(steps: all_steps, warnings: warnings)
36
+ end
37
+
38
+ private
39
+
40
+ def invoke_op(op_name, op_hash, dry_run:)
41
+ kwargs = op_hash.except("op").transform_keys(&:to_sym).merge(dry_run: dry_run)
42
+ case op_name
43
+ when "key_mv_prefix"
44
+ KeyMvPrefix.call(session: @session, ctx: @ctx, caps: @caps, **kwargs)
45
+ when "key_delete_prefix"
46
+ KeyDeletePrefix.call(session: @session, ctx: @ctx, caps: @caps, **kwargs)
47
+ when "zone_mv"
48
+ ZoneMv.call(session: @session, ctx: @ctx, caps: @caps, **kwargs)
49
+ else
50
+ raise UsageError.new("unknown op: #{op_name}")
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ Textus::Application::UseCase.register(:migrate, Textus::Application::Maintenance::Migrate, caps: :write)
@@ -0,0 +1,65 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ module Application
5
+ module Maintenance
6
+ # Compare the live manifest's `rules:` block against a candidate
7
+ # YAML string. Returns a Plan describing rule additions/removals/
8
+ # changes. Does NOT write anything.
9
+ module RuleLint
10
+ def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
11
+ Impl.new(ctx: ctx, caps: caps).call(*, **)
12
+ end
13
+
14
+ class Impl
15
+ def initialize(ctx:, caps:)
16
+ @ctx = ctx
17
+ @root = caps.root
18
+ end
19
+
20
+ def call(candidate_yaml:)
21
+ live_rules = current_rules
22
+ candidate_rules = parse_candidate(candidate_yaml)
23
+
24
+ live_by_match = live_rules.to_h { |r| [r["match"], r] }
25
+ candidate_by_match = candidate_rules.to_h { |r| [r["match"], r] }
26
+
27
+ steps = (candidate_by_match.keys - live_by_match.keys).map do |m|
28
+ { "op" => "add_rule", "match" => m, "rule" => candidate_by_match[m] }
29
+ end
30
+ (live_by_match.keys - candidate_by_match.keys).each do |m|
31
+ steps << { "op" => "remove_rule", "match" => m }
32
+ end
33
+ (live_by_match.keys & candidate_by_match.keys).each do |m|
34
+ next if live_by_match[m] == candidate_by_match[m]
35
+
36
+ steps << { "op" => "change_rule", "match" => m,
37
+ "from" => live_by_match[m], "to" => candidate_by_match[m] }
38
+ end
39
+
40
+ Plan.new(steps: steps, warnings: [])
41
+ end
42
+
43
+ private
44
+
45
+ def current_rules
46
+ raw = YAML.safe_load_file(File.join(@root, "manifest.yaml"),
47
+ permitted_classes: [Symbol], aliases: false)
48
+ Array(raw["rules"])
49
+ end
50
+
51
+ def parse_candidate(yaml_text)
52
+ raw = YAML.safe_load(yaml_text, permitted_classes: [Symbol], aliases: false)
53
+ raise UsageError.new("candidate is not a YAML mapping") unless raw.is_a?(Hash)
54
+
55
+ Array(raw["rules"])
56
+ rescue Psych::Exception => e
57
+ raise UsageError.new("candidate YAML parse error: #{e.message}")
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+
65
+ Textus::Application::UseCase.register(:rule_lint, Textus::Application::Maintenance::RuleLint, caps: :read)
@@ -0,0 +1,60 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ module Application
5
+ module Maintenance
6
+ # Rename a zone — rewrites the manifest's zones[] entry, rewrites
7
+ # the `zone:` field on every entry under the old zone, and moves
8
+ # every file from zones/<old>/ to zones/<new>/.
9
+ module ZoneMv
10
+ def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
11
+ Impl.new(ctx: ctx, caps: caps).call(*, **)
12
+ end
13
+
14
+ class Impl
15
+ def initialize(ctx:, caps:)
16
+ @ctx = ctx
17
+ @manifest = caps.manifest
18
+ @root = caps.root
19
+ end
20
+
21
+ def call(from:, to:, dry_run: false)
22
+ raise UsageError.new("from and to required") if from.nil? || to.nil? || from.empty? || to.empty?
23
+ raise UsageError.new("zone '#{from}' not declared") unless @manifest.data.zones.key?(from)
24
+
25
+ dest_dir = File.join(@root, "zones", to)
26
+ raise UsageError.new("destination 'zones/#{to}' already exists") if File.exist?(dest_dir)
27
+
28
+ affected_keys = @manifest.data.entries.select { |e| e.zone == from }.map(&:key)
29
+
30
+ steps = [{ "op" => "rename_zone", "from" => from, "to" => to }]
31
+ steps += affected_keys.map { |k| { "op" => "mv", "from" => k, "to" => "#{to}#{k[from.length..]}" } }
32
+
33
+ plan = Plan.new(steps: steps, warnings: [])
34
+ return plan if dry_run
35
+
36
+ rewrite_manifest!(from, to)
37
+ FileUtils.mv(File.join(@root, "zones", from), dest_dir)
38
+ plan
39
+ end
40
+
41
+ private
42
+
43
+ def rewrite_manifest!(from, to)
44
+ path = File.join(@root, "manifest.yaml")
45
+ raw = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false)
46
+ raw["zones"].each { |z| z["name"] = to if z["name"] == from }
47
+ raw["entries"].each do |e|
48
+ e["zone"] = to if e["zone"] == from
49
+ e["key"] = e["key"].sub(/\A#{Regexp.escape(from)}(\.|\z)/, "#{to}\\1")
50
+ e["path"] = e["path"].sub(%r{\A#{Regexp.escape(from)}(/|\z)}, "#{to}\\1")
51
+ end
52
+ File.write(path, YAML.dump(raw))
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+
60
+ Textus::Application::UseCase.register(:zone_mv, Textus::Application::Maintenance::ZoneMv, caps: :write)
@@ -0,0 +1,17 @@
1
+ module Textus
2
+ module Application
3
+ # Bulk and structural changes to a textus store. Each use case returns
4
+ # a Plan when called with dry_run: true, and applies the plan when
5
+ # called with dry_run: false.
6
+ module Maintenance
7
+ # A Plan is a JSON-shaped preview. Steps are op-tagged hashes the
8
+ # use case knows how to apply. Warnings are strings surfaced to
9
+ # the operator (skipped keys, ambiguities).
10
+ Plan = Data.define(:steps, :warnings) do
11
+ def to_h
12
+ { "steps" => steps, "warnings" => warnings }
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -11,14 +11,14 @@ module Textus
11
11
  # semantics: pure read (`ops.get`) for materialization paths;
12
12
  # `ops.get_or_refresh` if you want refresh-on-stale.
13
13
  # `lister` — a callable `->(prefix:) { [ { "key" => ... }, ... ] }`.
14
- # `transform_resolver` — a callable `->(name) { callable_or_raise }`.
15
- # `transform_context` — `Application::Context` handed to the transform reducer.
16
- def initialize(reader:, spec:, lister:, transform_resolver:, transform_context:)
17
- @reader = reader
18
- @spec = spec || {}
19
- @lister = lister
20
- @transform_resolver = transform_resolver
21
- @transform_context = transform_context
14
+ # `rpc` — a `Hooks::RpcRegistry` used to dispatch `transform_rows` callables.
15
+ # `transform_context` — capability object handed to transform reducers as `caps:`.
16
+ def initialize(reader:, spec:, lister:, rpc:, transform_context:)
17
+ @reader = reader
18
+ @spec = spec || {}
19
+ @lister = lister
20
+ @rpc = rpc
21
+ @transform_context = transform_context
22
22
  @limit = (@spec["limit"] || MAX_LIMIT).to_i
23
23
  raise InvalidProjection.new("limit #{@limit} exceeds max #{MAX_LIMIT}") if @limit > MAX_LIMIT
24
24
  end
@@ -49,9 +49,11 @@ module Textus
49
49
 
50
50
  def apply_reducer(rows)
51
51
  name = @spec["transform"] or return rows
52
- callable = @transform_resolver.call(name)
53
52
  Timeout.timeout(REDUCER_TIMEOUT_SECONDS) do
54
- callable.call(store: @transform_context, rows: rows, config: @spec["transform_config"] || {})
53
+ @rpc.invoke(:transform_rows, name,
54
+ caps: @transform_context,
55
+ rows: rows,
56
+ config: @spec["transform_config"] || {})
55
57
  end
56
58
  rescue Timeout::Error
57
59
  raise UsageError.new("transform_rows '#{name}' exceeded #{REDUCER_TIMEOUT_SECONDS}s timeout")