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.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +148 -45
- data/CHANGELOG.md +194 -0
- data/README.md +8 -5
- data/SPEC.md +54 -15
- data/docs/conventions.md +10 -0
- data/lib/textus/application/caps.rb +49 -0
- data/lib/textus/application/context.rb +2 -2
- data/lib/textus/application/envelope/reader.rb +44 -0
- data/lib/textus/application/{writes/envelope_io.rb → envelope/writer.rb} +24 -50
- data/lib/textus/application/maintenance/key_delete_prefix.rb +44 -0
- data/lib/textus/application/maintenance/key_mv_prefix.rb +57 -0
- data/lib/textus/application/maintenance/migrate.rb +59 -0
- data/lib/textus/application/maintenance/rule_lint.rb +65 -0
- data/lib/textus/application/maintenance/zone_mv.rb +60 -0
- data/lib/textus/application/maintenance.rb +17 -0
- data/lib/textus/application/projection.rb +12 -10
- data/lib/textus/application/read/audit.rb +106 -0
- data/lib/textus/application/read/blame.rb +91 -0
- data/lib/textus/application/read/deps.rb +34 -0
- data/lib/textus/application/read/freshness.rb +110 -0
- data/lib/textus/application/read/get.rb +75 -0
- data/lib/textus/application/read/get_or_refresh.rb +63 -0
- data/lib/textus/application/read/list.rb +25 -0
- data/lib/textus/application/read/policy_explain.rb +47 -0
- data/lib/textus/application/read/published.rb +25 -0
- data/lib/textus/application/read/pulse.rb +101 -0
- data/lib/textus/application/read/rdeps.rb +35 -0
- data/lib/textus/application/read/schema_envelope.rb +26 -0
- data/lib/textus/application/read/stale.rb +23 -0
- data/lib/textus/application/read/uid.rb +30 -0
- data/lib/textus/application/read/validate_all.rb +32 -0
- data/lib/textus/application/{reads → read}/validator.rb +2 -2
- data/lib/textus/application/read/where.rb +26 -0
- data/lib/textus/application/use_case.rb +22 -0
- data/lib/textus/application/write/accept.rb +102 -0
- data/lib/textus/application/{writes → write}/authority_gate.rb +3 -3
- data/lib/textus/application/write/delete.rb +45 -0
- data/lib/textus/application/{writes → write}/materializer.rb +14 -15
- data/lib/textus/application/write/mv.rb +118 -0
- data/lib/textus/application/write/publish.rb +96 -0
- data/lib/textus/application/write/put.rb +49 -0
- data/lib/textus/application/write/refresh_all.rb +63 -0
- data/lib/textus/application/{refresh/orchestrator.rb → write/refresh_orchestrator.rb} +32 -8
- data/lib/textus/application/write/refresh_worker.rb +134 -0
- data/lib/textus/application/write/reject.rb +62 -0
- data/lib/textus/{intro.rb → boot.rb} +49 -29
- data/lib/textus/builder/pipeline.rb +5 -5
- data/lib/textus/cli/group/mcp.rb +9 -0
- data/lib/textus/cli/group/zone.rb +9 -0
- data/lib/textus/cli/verb/accept.rb +1 -1
- data/lib/textus/cli/verb/audit.rb +4 -2
- data/lib/textus/cli/verb/blame.rb +1 -1
- data/lib/textus/cli/verb/boot.rb +13 -0
- data/lib/textus/cli/verb/build.rb +2 -2
- data/lib/textus/cli/verb/delete.rb +1 -1
- data/lib/textus/cli/verb/deps.rb +1 -1
- data/lib/textus/cli/verb/doctor.rb +1 -1
- data/lib/textus/cli/verb/freshness.rb +1 -1
- data/lib/textus/cli/verb/get.rb +1 -1
- data/lib/textus/cli/verb/hook_run.rb +3 -4
- data/lib/textus/cli/verb/hooks.rb +11 -14
- data/lib/textus/cli/verb/key_delete.rb +24 -0
- data/lib/textus/cli/verb/list.rb +1 -1
- data/lib/textus/cli/verb/mcp_serve.rb +17 -0
- data/lib/textus/cli/verb/migrate.rb +18 -0
- data/lib/textus/cli/verb/mv.rb +11 -3
- data/lib/textus/cli/verb/published.rb +1 -1
- data/lib/textus/cli/verb/pulse.rb +17 -0
- data/lib/textus/cli/verb/put.rb +8 -6
- data/lib/textus/cli/verb/rdeps.rb +1 -1
- data/lib/textus/cli/verb/refresh.rb +1 -1
- data/lib/textus/cli/verb/refresh_stale.rb +1 -1
- data/lib/textus/cli/verb/reject.rb +1 -1
- data/lib/textus/cli/verb/rule_explain.rb +1 -1
- data/lib/textus/cli/verb/rule_lint.rb +18 -0
- data/lib/textus/cli/verb/schema.rb +1 -1
- data/lib/textus/cli/verb/uid.rb +1 -1
- data/lib/textus/cli/verb/where.rb +1 -1
- data/lib/textus/cli/verb/zone_mv.rb +19 -0
- data/lib/textus/cli/verb.rb +4 -4
- data/lib/textus/cli.rb +1 -1
- data/lib/textus/doctor/check/audit_log.rb +2 -2
- data/lib/textus/doctor/check/handler_allowlist.rb +2 -2
- data/lib/textus/doctor/check/hooks.rb +4 -3
- data/lib/textus/doctor/check/illegal_keys.rb +2 -2
- data/lib/textus/doctor/check/intake_registration.rb +2 -2
- data/lib/textus/doctor/check/manifest_files.rb +2 -2
- data/lib/textus/doctor/check/protocol_version.rb +2 -2
- data/lib/textus/doctor/check/refresh_locks.rb +2 -2
- data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
- data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/schemas.rb +2 -2
- data/lib/textus/doctor/check/sentinels.rb +2 -2
- data/lib/textus/doctor/check/templates.rb +2 -2
- data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
- data/lib/textus/doctor/check.rb +5 -3
- data/lib/textus/doctor.rb +24 -27
- data/lib/textus/domain/authorizer.rb +4 -4
- data/lib/textus/{application → domain}/policy/predicates/accept_authority_signed.rb +2 -2
- data/lib/textus/{application → domain}/policy/predicates/schema_valid.rb +1 -1
- data/lib/textus/{application → domain}/policy/promotion.rb +1 -1
- data/lib/textus/domain/staleness/generator_check.rb +2 -2
- data/lib/textus/domain/staleness/intake_check.rb +2 -2
- data/lib/textus/domain/staleness.rb +1 -1
- data/lib/textus/errors.rb +16 -0
- data/lib/textus/hooks/builtin.rb +14 -14
- data/lib/textus/hooks/context.rb +13 -13
- data/lib/textus/hooks/error_log.rb +32 -0
- data/lib/textus/hooks/{bus.rb → event_bus.rb} +41 -53
- data/lib/textus/hooks/loader.rb +29 -3
- data/lib/textus/hooks/rpc_registry.rb +77 -0
- data/lib/textus/infra/audit_log.rb +126 -16
- data/lib/textus/infra/audit_subscriber.rb +6 -7
- data/lib/textus/infra/refresh/detached.rb +1 -1
- data/lib/textus/key/path.rb +7 -3
- data/lib/textus/manifest/data.rb +78 -0
- data/lib/textus/manifest/entry/base.rb +44 -7
- data/lib/textus/manifest/entry/derived.rb +41 -6
- data/lib/textus/manifest/entry/intake.rb +15 -3
- data/lib/textus/manifest/entry/leaf.rb +6 -5
- data/lib/textus/manifest/entry/nested.rb +42 -3
- data/lib/textus/manifest/entry/parser.rb +8 -44
- data/lib/textus/manifest/entry/validators/events.rb +2 -2
- data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -4
- data/lib/textus/manifest/entry/validators/index_filename.rb +2 -1
- data/lib/textus/manifest/entry/validators/inject_boot.rb +19 -0
- data/lib/textus/manifest/entry/validators/publish_each.rb +4 -3
- data/lib/textus/manifest/entry/validators.rb +1 -1
- data/lib/textus/manifest/entry.rb +3 -0
- data/lib/textus/manifest/policy.rb +48 -0
- data/lib/textus/manifest/resolver.rb +18 -18
- data/lib/textus/manifest/rules.rb +1 -1
- data/lib/textus/manifest/schema.rb +20 -6
- data/lib/textus/manifest.rb +53 -101
- data/lib/textus/mcp/errors.rb +32 -0
- data/lib/textus/mcp/server.rb +127 -0
- data/lib/textus/mcp/session.rb +31 -0
- data/lib/textus/mcp/tool_schemas.rb +71 -0
- data/lib/textus/mcp/tools.rb +129 -0
- data/lib/textus/mcp.rb +6 -0
- data/lib/textus/schema/tools.rb +14 -10
- data/lib/textus/session.rb +84 -0
- data/lib/textus/store.rb +17 -8
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +8 -1
- metadata +65 -38
- data/lib/textus/application/reads/audit.rb +0 -69
- data/lib/textus/application/reads/blame.rb +0 -82
- data/lib/textus/application/reads/deps.rb +0 -26
- data/lib/textus/application/reads/freshness.rb +0 -88
- data/lib/textus/application/reads/get.rb +0 -67
- data/lib/textus/application/reads/get_or_refresh.rb +0 -51
- data/lib/textus/application/reads/list.rb +0 -17
- data/lib/textus/application/reads/policy_explain.rb +0 -39
- data/lib/textus/application/reads/published.rb +0 -17
- data/lib/textus/application/reads/rdeps.rb +0 -27
- data/lib/textus/application/reads/schema_envelope.rb +0 -18
- data/lib/textus/application/reads/stale.rb +0 -15
- data/lib/textus/application/reads/uid.rb +0 -23
- data/lib/textus/application/reads/validate_all.rb +0 -24
- data/lib/textus/application/reads/where.rb +0 -18
- data/lib/textus/application/refresh/all.rb +0 -52
- data/lib/textus/application/refresh/worker.rb +0 -116
- data/lib/textus/application/writes/accept.rb +0 -89
- data/lib/textus/application/writes/delete.rb +0 -33
- data/lib/textus/application/writes/mv.rb +0 -105
- data/lib/textus/application/writes/publish.rb +0 -162
- data/lib/textus/application/writes/put.rb +0 -37
- data/lib/textus/application/writes/reject.rb +0 -50
- data/lib/textus/cli/verb/intro.rb +0 -13
- data/lib/textus/infra/event_bus.rb +0 -27
- data/lib/textus/manifest/entry/validators/inject_intro.rb +0 -21
- 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
|
|
11
|
-
#
|
|
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
|
|
6
|
-
# Owns the write pipeline (validate, serialize, etag-check, write, audit)
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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::
|
|
12
|
-
class
|
|
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
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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
|
|
84
|
-
|
|
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:,
|
|
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
|
-
# `
|
|
15
|
-
# `transform_context` —
|
|
16
|
-
def initialize(reader:, spec:, lister:,
|
|
17
|
-
@reader
|
|
18
|
-
@spec
|
|
19
|
-
@lister
|
|
20
|
-
@
|
|
21
|
-
@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
|
-
|
|
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")
|