textus 0.52.0 → 0.54.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/CHANGELOG.md +59 -0
- data/README.md +62 -54
- data/SPEC.md +64 -192
- data/docs/architecture/README.md +157 -156
- data/exe/textus +1 -1
- data/lib/textus/action/accept.rb +44 -0
- data/lib/textus/action/audit.rb +131 -0
- data/lib/textus/action/base.rb +42 -0
- data/lib/textus/{read → action}/blame.rb +28 -22
- data/lib/textus/action/boot.rb +17 -0
- data/lib/textus/action/data_mv.rb +69 -0
- data/lib/textus/action/deps.rb +34 -0
- data/lib/textus/action/doctor.rb +24 -0
- data/lib/textus/action/drain.rb +39 -0
- data/lib/textus/action/enqueue.rb +53 -0
- data/lib/textus/action/get.rb +78 -0
- data/lib/textus/action/ingest.rb +135 -0
- data/lib/textus/action/jobs.rb +36 -0
- data/lib/textus/action/key_delete.rb +36 -0
- data/lib/textus/action/key_delete_prefix.rb +44 -0
- data/lib/textus/action/key_mv.rb +129 -0
- data/lib/textus/action/key_mv_prefix.rb +57 -0
- data/lib/textus/action/list.rb +42 -0
- data/lib/textus/action/propose.rb +52 -0
- data/lib/textus/action/published.rb +24 -0
- data/lib/textus/action/pulse.rb +56 -0
- data/lib/textus/action/put.rb +52 -0
- data/lib/textus/action/rdeps.rb +39 -0
- data/lib/textus/action/reject.rb +37 -0
- data/lib/textus/action/rule_explain.rb +91 -0
- data/lib/textus/action/rule_lint.rb +68 -0
- data/lib/textus/action/rule_list.rb +42 -0
- data/lib/textus/action/schema_envelope.rb +29 -0
- data/lib/textus/action/uid.rb +33 -0
- data/lib/textus/action/where.rb +36 -0
- data/lib/textus/action/write_verb.rb +44 -0
- data/lib/textus/boot.rb +64 -132
- data/lib/textus/command.rb +41 -0
- data/lib/textus/container.rb +1 -1
- data/lib/textus/{domain → core}/duration.rb +1 -1
- data/lib/textus/{domain → core}/freshness/evaluator.rb +25 -41
- data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
- data/lib/textus/{domain → core}/freshness.rb +2 -2
- data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
- data/lib/textus/{domain → core}/retention.rb +2 -2
- data/lib/textus/{domain → core}/sentinel.rb +1 -1
- data/lib/textus/doctor/check/generator_drift.rb +1 -1
- data/lib/textus/doctor/check/illegal_keys.rb +1 -1
- data/lib/textus/doctor/check/notebook_sources.rb +50 -0
- data/lib/textus/doctor/check/proposal_targets.rb +3 -3
- data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
- data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
- data/lib/textus/doctor/check/schema_violations.rb +8 -2
- data/lib/textus/doctor/check.rb +11 -9
- data/lib/textus/{read → doctor}/validator.rb +22 -13
- data/lib/textus/doctor.rb +4 -44
- data/lib/textus/envelope/reader.rb +46 -0
- data/lib/textus/envelope/writer.rb +209 -0
- data/lib/textus/envelope.rb +5 -3
- data/lib/textus/errors.rb +25 -13
- data/lib/textus/{entry → format}/base.rb +1 -6
- data/lib/textus/{entry → format}/json.rb +1 -4
- data/lib/textus/{entry → format}/markdown.rb +2 -6
- data/lib/textus/{entry → format}/text.rb +2 -6
- data/lib/textus/{entry → format}/yaml.rb +1 -4
- data/lib/textus/{entry.rb → format.rb} +9 -10
- data/lib/textus/gate/auth.rb +227 -0
- data/lib/textus/gate.rb +116 -0
- data/lib/textus/init.rb +59 -105
- data/lib/textus/jobs/base.rb +23 -0
- data/lib/textus/jobs/materialize.rb +20 -0
- data/lib/textus/jobs/plan.rb +9 -0
- data/lib/textus/jobs/planner.rb +101 -0
- data/lib/textus/jobs/retention.rb +48 -0
- data/lib/textus/jobs/sweep.rb +27 -0
- data/lib/textus/jobs/worker.rb +67 -0
- data/lib/textus/jobs.rb +15 -0
- data/lib/textus/key/path.rb +10 -3
- data/lib/textus/layout.rb +13 -0
- data/lib/textus/manifest/data.rb +14 -14
- data/lib/textus/manifest/entry/base.rb +14 -25
- data/lib/textus/manifest/entry/parser.rb +31 -14
- data/lib/textus/manifest/entry/produced.rb +13 -33
- data/lib/textus/manifest/entry/publish/mode.rb +1 -1
- data/lib/textus/manifest/entry/publish/to_paths.rb +4 -3
- data/lib/textus/manifest/entry/publish.rb +0 -12
- data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
- data/lib/textus/manifest/entry/validators.rb +0 -1
- data/lib/textus/{domain → manifest}/policy/matcher.rb +2 -2
- data/lib/textus/{domain → manifest}/policy/publish_target.rb +2 -2
- data/lib/textus/manifest/policy/react.rb +30 -0
- data/lib/textus/{domain → manifest}/policy/retention.rb +3 -3
- data/lib/textus/manifest/policy/source.rb +30 -0
- data/lib/textus/manifest/policy.rb +38 -53
- data/lib/textus/manifest/resolver.rb +5 -4
- data/lib/textus/manifest/rules.rb +4 -4
- data/lib/textus/manifest/schema/keys.rb +20 -28
- data/lib/textus/manifest/schema/validator.rb +27 -33
- data/lib/textus/manifest/schema/vocabulary.rb +2 -1
- data/lib/textus/manifest/schema.rb +2 -2
- data/lib/textus/manifest.rb +2 -2
- data/lib/textus/ports/audit_log.rb +31 -5
- data/lib/textus/{domain/jobs → ports/job_store}/job.rb +19 -12
- data/lib/textus/ports/{queue.rb → job_store.rb} +7 -14
- data/lib/textus/ports/sentinel_store.rb +2 -2
- data/lib/textus/ports/watcher_lock.rb +48 -0
- data/lib/textus/produce/engine.rb +25 -70
- data/lib/textus/produce/render.rb +2 -7
- data/lib/textus/schema/tools.rb +4 -3
- data/lib/textus/session.rb +6 -3
- data/lib/textus/store.rb +12 -19
- data/lib/textus/surfaces/cli/group/data.rb +11 -0
- data/lib/textus/surfaces/cli/group/key.rb +11 -0
- data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
- data/lib/textus/surfaces/cli/group/rule.rb +11 -0
- data/lib/textus/surfaces/cli/group/schema.rb +11 -0
- data/lib/textus/surfaces/cli/group.rb +50 -0
- data/lib/textus/surfaces/cli/runner.rb +236 -0
- data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
- data/lib/textus/surfaces/cli/verb/get.rb +21 -0
- data/lib/textus/surfaces/cli/verb/init.rb +20 -0
- data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
- data/lib/textus/surfaces/cli/verb/put.rb +30 -0
- data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
- data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
- data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
- data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
- data/lib/textus/surfaces/cli/verb.rb +111 -0
- data/lib/textus/surfaces/cli.rb +148 -0
- data/lib/textus/surfaces/mcp/catalog.rb +99 -0
- data/lib/textus/surfaces/mcp/errors.rb +25 -0
- data/lib/textus/surfaces/mcp/routing.rb +51 -0
- data/lib/textus/surfaces/mcp/server.rb +184 -0
- data/lib/textus/surfaces/mcp/session.rb +9 -0
- data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
- data/lib/textus/surfaces/mcp.rb +8 -0
- data/lib/textus/surfaces/role_scope.rb +34 -0
- data/lib/textus/surfaces/watcher.rb +38 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus/workflow/collector.rb +27 -0
- data/lib/textus/workflow/context.rb +5 -0
- data/lib/textus/workflow/dsl.rb +44 -0
- data/lib/textus/workflow/loader.rb +17 -0
- data/lib/textus/workflow/pattern.rb +18 -0
- data/lib/textus/workflow/registry.rb +21 -0
- data/lib/textus/workflow/runner.rb +84 -0
- data/lib/textus/workflow.rb +28 -0
- data/lib/textus.rb +75 -22
- metadata +118 -132
- data/lib/textus/cli/group/hook.rb +0 -9
- data/lib/textus/cli/group/key.rb +0 -9
- data/lib/textus/cli/group/mcp.rb +0 -9
- data/lib/textus/cli/group/rule.rb +0 -9
- data/lib/textus/cli/group/schema.rb +0 -9
- data/lib/textus/cli/group/zone.rb +0 -9
- data/lib/textus/cli/group.rb +0 -48
- data/lib/textus/cli/runner.rb +0 -193
- data/lib/textus/cli/verb/doctor.rb +0 -17
- data/lib/textus/cli/verb/get.rb +0 -18
- data/lib/textus/cli/verb/hook_run.rb +0 -48
- data/lib/textus/cli/verb/hooks.rb +0 -50
- data/lib/textus/cli/verb/init.rb +0 -18
- data/lib/textus/cli/verb/mcp_serve.rb +0 -22
- data/lib/textus/cli/verb/put.rb +0 -30
- data/lib/textus/cli/verb/schema_diff.rb +0 -15
- data/lib/textus/cli/verb/schema_init.rb +0 -19
- data/lib/textus/cli/verb/schema_migrate.rb +0 -19
- data/lib/textus/cli/verb/serve.rb +0 -19
- data/lib/textus/cli/verb.rb +0 -116
- data/lib/textus/cli.rb +0 -138
- data/lib/textus/dispatcher.rb +0 -54
- data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
- data/lib/textus/doctor/check/hooks.rb +0 -32
- data/lib/textus/doctor/check/intake_registration.rb +0 -46
- data/lib/textus/domain/action.rb +0 -9
- data/lib/textus/domain/jobs/registry.rb +0 -37
- data/lib/textus/domain/permission.rb +0 -7
- data/lib/textus/domain/policy/base_guards.rb +0 -25
- data/lib/textus/domain/policy/evaluation.rb +0 -15
- data/lib/textus/domain/policy/guard.rb +0 -35
- data/lib/textus/domain/policy/guard_factory.rb +0 -40
- data/lib/textus/domain/policy/handler_allowlist.rb +0 -17
- data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
- data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
- data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
- data/lib/textus/domain/policy/predicates/registry.rb +0 -39
- data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
- data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
- data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
- data/lib/textus/domain/policy/source.rb +0 -73
- data/lib/textus/envelope/io/reader.rb +0 -48
- data/lib/textus/envelope/io/writer.rb +0 -182
- data/lib/textus/hooks/builtin.rb +0 -70
- data/lib/textus/hooks/catalog.rb +0 -37
- data/lib/textus/hooks/context.rb +0 -72
- data/lib/textus/hooks/error_log.rb +0 -32
- data/lib/textus/hooks/event_bus.rb +0 -114
- data/lib/textus/hooks/fire_report.rb +0 -23
- data/lib/textus/hooks/loader.rb +0 -54
- data/lib/textus/hooks/rpc_registry.rb +0 -43
- data/lib/textus/hooks/signature.rb +0 -31
- data/lib/textus/init/templates/machine_intake.rb +0 -45
- data/lib/textus/init/templates/orientation_reducer.rb +0 -17
- data/lib/textus/jobs/handlers.rb +0 -62
- data/lib/textus/jobs/scheduler.rb +0 -36
- data/lib/textus/jobs/seeder.rb +0 -57
- data/lib/textus/maintenance/drain.rb +0 -42
- data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
- data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
- data/lib/textus/maintenance/retention/apply.rb +0 -52
- data/lib/textus/maintenance/rule_lint.rb +0 -66
- data/lib/textus/maintenance/serve.rb +0 -30
- data/lib/textus/maintenance/worker.rb +0 -74
- data/lib/textus/maintenance/zone_mv.rb +0 -64
- data/lib/textus/maintenance.rb +0 -15
- data/lib/textus/manifest/entry/validators/events.rb +0 -22
- data/lib/textus/mcp/catalog.rb +0 -70
- data/lib/textus/mcp/errors.rb +0 -32
- data/lib/textus/mcp/server.rb +0 -138
- data/lib/textus/mcp/session.rb +0 -7
- data/lib/textus/mcp/tool_schemas.rb +0 -15
- data/lib/textus/mcp.rb +0 -6
- data/lib/textus/mustache.rb +0 -117
- data/lib/textus/ports/audit_subscriber.rb +0 -42
- data/lib/textus/ports/produce_on_write_subscriber.rb +0 -73
- data/lib/textus/produce/acquire/handler.rb +0 -29
- data/lib/textus/produce/acquire/intake.rb +0 -130
- data/lib/textus/produce/acquire/projection.rb +0 -127
- data/lib/textus/produce/acquire/serializer/json.rb +0 -31
- data/lib/textus/produce/acquire/serializer/text.rb +0 -16
- data/lib/textus/produce/acquire/serializer/yaml.rb +0 -31
- data/lib/textus/produce/acquire/serializer.rb +0 -17
- data/lib/textus/produce/events.rb +0 -36
- data/lib/textus/projection.rb +0 -103
- data/lib/textus/read/audit.rb +0 -130
- data/lib/textus/read/boot.rb +0 -26
- data/lib/textus/read/capabilities.rb +0 -70
- data/lib/textus/read/deps.rb +0 -38
- data/lib/textus/read/doctor.rb +0 -27
- data/lib/textus/read/freshness.rb +0 -152
- data/lib/textus/read/get.rb +0 -73
- data/lib/textus/read/jobs.rb +0 -31
- data/lib/textus/read/list.rb +0 -24
- data/lib/textus/read/published.rb +0 -22
- data/lib/textus/read/pulse.rb +0 -98
- data/lib/textus/read/rdeps.rb +0 -39
- data/lib/textus/read/rule_explain.rb +0 -96
- data/lib/textus/read/rule_list.rb +0 -54
- data/lib/textus/read/schema_envelope.rb +0 -25
- data/lib/textus/read/uid.rb +0 -29
- data/lib/textus/read/validate_all.rb +0 -36
- data/lib/textus/read/where.rb +0 -24
- data/lib/textus/role_scope.rb +0 -78
- data/lib/textus/write/accept.rb +0 -58
- data/lib/textus/write/enqueue.rb +0 -50
- data/lib/textus/write/key_delete.rb +0 -65
- data/lib/textus/write/key_mv.rb +0 -141
- data/lib/textus/write/propose.rb +0 -54
- data/lib/textus/write/put.rb +0 -74
- data/lib/textus/write/reject.rb +0 -68
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
class Envelope
|
|
5
|
+
# Owns the write pipeline (validate, serialize, etag-check, write, audit).
|
|
6
|
+
# Talks to ports (FileStore, Schemas, AuditLog, Manifest) and an
|
|
7
|
+
# Reader for the existing-uid lookup.
|
|
8
|
+
#
|
|
9
|
+
# Invariant: every public method's final action is @audit_log.append(...).
|
|
10
|
+
#
|
|
11
|
+
# No permission check, no event firing — those belong to the caller
|
|
12
|
+
# (Write::Put / ::Delete / ::Mv).
|
|
13
|
+
class Writer
|
|
14
|
+
Payload = Data.define(:meta, :body, :content)
|
|
15
|
+
|
|
16
|
+
def self.from(container:, call:)
|
|
17
|
+
new(
|
|
18
|
+
file_store: container.file_store, manifest: container.manifest,
|
|
19
|
+
schemas: container.schemas, audit_log: container.audit_log,
|
|
20
|
+
call: call, reader: Reader.from(container: container)
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(file_store:, manifest:, schemas:, audit_log:, call:, reader:)
|
|
25
|
+
@file_store = file_store
|
|
26
|
+
@manifest = manifest
|
|
27
|
+
@schemas = schemas
|
|
28
|
+
@audit_log = audit_log
|
|
29
|
+
@call = call
|
|
30
|
+
@reader = reader
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def put(key, mentry:, payload:, if_etag: nil)
|
|
34
|
+
path = resolve_path(key)
|
|
35
|
+
meta, content = prepare_uid(mentry, payload, key)
|
|
36
|
+
bytes, eff_meta, eff_body, eff_content = serialize_entry(mentry, path, meta, payload, content)
|
|
37
|
+
enforce_name_match!(path, eff_meta, mentry.format)
|
|
38
|
+
validate_schema(mentry, eff_meta, eff_content)
|
|
39
|
+
etag_before = check_etag!(path, key, if_etag)
|
|
40
|
+
write_bytes(path, bytes)
|
|
41
|
+
envelope = build_envelope(key, mentry, path, eff_meta, eff_body, eff_content)
|
|
42
|
+
audit_put(key, etag_before, envelope.etag)
|
|
43
|
+
envelope
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def delete(key, mentry: nil, if_etag: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
47
|
+
# `mentry:` is accepted for symmetry with `put` / `move` and to
|
|
48
|
+
# leave room for future format-specific delete hooks; no field
|
|
49
|
+
# on it is needed today.
|
|
50
|
+
path = @manifest.resolver.resolve(key).path
|
|
51
|
+
raise UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)) unless @file_store.exists?(path)
|
|
52
|
+
|
|
53
|
+
etag_before = @file_store.etag(path)
|
|
54
|
+
raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before
|
|
55
|
+
|
|
56
|
+
@file_store.delete(path)
|
|
57
|
+
prune_empty_parents(path)
|
|
58
|
+
@audit_log.append(
|
|
59
|
+
role: @call.role, verb: "key_delete", key: key,
|
|
60
|
+
etag_before: etag_before, etag_after: nil,
|
|
61
|
+
extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def move(from_key:, to_key:, new_mentry:, if_etag: nil)
|
|
66
|
+
from_path = @manifest.resolver.resolve(from_key).path
|
|
67
|
+
to_path = @manifest.resolver.resolve(to_key).path
|
|
68
|
+
raise UnknownKey.new(from_key, suggestions: @manifest.resolver.suggestions_for(from_key)) unless @file_store.exists?(from_path)
|
|
69
|
+
|
|
70
|
+
etag_before = @file_store.etag(from_path)
|
|
71
|
+
raise EtagMismatch.new(from_key, if_etag, etag_before) if if_etag && if_etag != etag_before
|
|
72
|
+
|
|
73
|
+
FileUtils.mkdir_p(File.dirname(to_path))
|
|
74
|
+
FileUtils.mv(from_path, to_path)
|
|
75
|
+
prune_empty_parents(from_path)
|
|
76
|
+
basename = to_key.split(".").last
|
|
77
|
+
Format.for(new_mentry.format).rewrite_name(to_path, basename)
|
|
78
|
+
etag_after = Etag.for_file(to_path)
|
|
79
|
+
|
|
80
|
+
raw = @file_store.read(to_path)
|
|
81
|
+
parsed = Format.for(new_mentry.format).parse(raw, path: to_path)
|
|
82
|
+
envelope = Textus::Envelope.build(
|
|
83
|
+
key: to_key, mentry: new_mentry, path: to_path,
|
|
84
|
+
meta: parsed["_meta"], body: parsed["body"],
|
|
85
|
+
etag: etag_after, content: parsed["content"]
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
extras = {
|
|
89
|
+
"from_key" => from_key, "to_key" => to_key,
|
|
90
|
+
"from_path" => from_path, "to_path" => to_path,
|
|
91
|
+
"uid" => envelope.uid
|
|
92
|
+
}
|
|
93
|
+
extras["correlation_id"] = @call.correlation_id if @call.correlation_id
|
|
94
|
+
|
|
95
|
+
@audit_log.append(
|
|
96
|
+
role: @call.role, verb: "key_mv", key: to_key,
|
|
97
|
+
etag_before: etag_before, etag_after: etag_after,
|
|
98
|
+
extras: extras
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
envelope
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# After a file leaves a directory (delete or move-source), remove any
|
|
107
|
+
# now-empty parent dirs so bulk move/delete doesn't accrue orphan dirs
|
|
108
|
+
# (F3 of #161). Floored at the entry's *zone directory* — a zone is a
|
|
109
|
+
# declared, first-class container, so its own dir is preserved even when
|
|
110
|
+
# momentarily empty; only the sub-dirs the bulk op carved out are
|
|
111
|
+
# pruned. Stops at the first non-empty ancestor, so a dir holding a
|
|
112
|
+
# `.gitkeep` or sibling entries survives. Best-effort: a lost race or a
|
|
113
|
+
# non-empty dir is silently fine, never fatal to the write.
|
|
114
|
+
def prune_empty_parents(path)
|
|
115
|
+
floor = zone_floor(path)
|
|
116
|
+
return unless floor
|
|
117
|
+
|
|
118
|
+
dir = File.dirname(path)
|
|
119
|
+
while dir.start_with?("#{floor}/") && Dir.empty?(dir)
|
|
120
|
+
Dir.rmdir(dir)
|
|
121
|
+
dir = File.dirname(dir)
|
|
122
|
+
end
|
|
123
|
+
rescue SystemCallError
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The zone directory under which `path` lives (`<root>/zones/<zone>`),
|
|
128
|
+
# or nil if `path` is not under the store's zones tree.
|
|
129
|
+
def zone_floor(path)
|
|
130
|
+
zones_root = File.join(@manifest.data.root, "data")
|
|
131
|
+
prefix = "#{zones_root}/"
|
|
132
|
+
return nil unless path.start_with?(prefix)
|
|
133
|
+
|
|
134
|
+
zone_seg = path.delete_prefix(prefix).split("/").first
|
|
135
|
+
zone_seg && File.join(zones_root, zone_seg)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def ensure_uid(format, meta, content, existing_uid)
|
|
139
|
+
Textus::Format.for(format).inject_uid(meta, content, existing_uid)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def enforce_name_match!(path, meta, format)
|
|
143
|
+
Textus::Format.for(format).enforce_name_match!(path, meta)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def serialize_for_put(mentry:, path:, meta:, body:, content:)
|
|
147
|
+
Textus::Format.for(mentry.format).serialize_for_put(
|
|
148
|
+
meta: meta, body: body, content: content, path: path,
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def resolve_path(key)
|
|
153
|
+
@manifest.resolver.resolve(key).path
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def prepare_uid(mentry, payload, key)
|
|
157
|
+
meta = payload.meta || {}
|
|
158
|
+
existing_uid = @reader.existing_uid(key)
|
|
159
|
+
ensure_uid(mentry.format, meta, payload.content, existing_uid)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def serialize_entry(mentry, path, meta, payload, content)
|
|
163
|
+
serialize_for_put(
|
|
164
|
+
mentry: mentry, path: path,
|
|
165
|
+
meta: meta, body: payload.body, content: content
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def validate_schema(mentry, eff_meta, eff_content)
|
|
170
|
+
schema = @schemas.fetch_or_nil(mentry.schema)
|
|
171
|
+
return unless schema
|
|
172
|
+
|
|
173
|
+
Format.for(mentry.format).validate_against(
|
|
174
|
+
schema,
|
|
175
|
+
{ "_meta" => eff_meta, "content" => eff_content },
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def check_etag!(path, key, if_etag)
|
|
180
|
+
etag_before = @file_store.exists?(path) ? @file_store.etag(path) : nil
|
|
181
|
+
raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && (etag_before != if_etag)
|
|
182
|
+
|
|
183
|
+
etag_before
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def write_bytes(path, bytes)
|
|
187
|
+
@file_store.write(path, bytes)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def build_envelope(key, mentry, path, eff_meta, eff_body, eff_content)
|
|
191
|
+
Textus::Envelope.build(
|
|
192
|
+
key: key, mentry: mentry, path: path,
|
|
193
|
+
meta: eff_meta, body: eff_body,
|
|
194
|
+
etag: Etag.for_bytes(@file_store.read(path)),
|
|
195
|
+
content: eff_content
|
|
196
|
+
)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def audit_put(key, etag_before, etag_after)
|
|
200
|
+
extras = @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
|
|
201
|
+
@audit_log.append(
|
|
202
|
+
role: @call.role, verb: "put", key: key,
|
|
203
|
+
etag_before: etag_before, etag_after: etag_after,
|
|
204
|
+
extras: extras
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
data/lib/textus/envelope.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
Envelope = Data.define(
|
|
5
|
-
:protocol, :key, :
|
|
5
|
+
:protocol, :key, :lane, :owner, :path, :format,
|
|
6
6
|
:uid, :etag, :schema_ref, :meta, :body, :content, :freshness
|
|
7
7
|
) do
|
|
8
8
|
# rubocop:disable Metrics/ParameterLists
|
|
@@ -11,7 +11,7 @@ module Textus
|
|
|
11
11
|
new(
|
|
12
12
|
protocol: Textus::PROTOCOL,
|
|
13
13
|
key: key,
|
|
14
|
-
|
|
14
|
+
lane: mentry.lane,
|
|
15
15
|
owner: mentry.owner,
|
|
16
16
|
path: path,
|
|
17
17
|
format: mentry.format,
|
|
@@ -34,7 +34,7 @@ module Textus
|
|
|
34
34
|
h = {
|
|
35
35
|
"protocol" => protocol,
|
|
36
36
|
"key" => key,
|
|
37
|
-
"
|
|
37
|
+
"lane" => lane,
|
|
38
38
|
"owner" => owner,
|
|
39
39
|
"path" => path,
|
|
40
40
|
"format" => format,
|
|
@@ -49,6 +49,8 @@ module Textus
|
|
|
49
49
|
h
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
alias_method :zone, :lane
|
|
53
|
+
|
|
52
54
|
def stale?
|
|
53
55
|
return false if freshness.nil?
|
|
54
56
|
|
data/lib/textus/errors.rb
CHANGED
|
@@ -1,24 +1,32 @@
|
|
|
1
1
|
module Textus
|
|
2
|
+
ErrorInfo = Data.define(:details, :exit_code, :hint) do
|
|
3
|
+
def self.for(details: {}, exit_code: 1, hint: nil)
|
|
4
|
+
new(details: details, exit_code: exit_code, hint: hint)
|
|
5
|
+
end
|
|
6
|
+
end
|
|
7
|
+
|
|
2
8
|
class Error < StandardError
|
|
3
|
-
attr_reader :code
|
|
9
|
+
attr_reader :code
|
|
4
10
|
|
|
5
|
-
def initialize(code, message, details: {}, exit_code: 1, hint: nil)
|
|
11
|
+
def initialize(code, message, info: nil, details: {}, exit_code: 1, hint: nil)
|
|
6
12
|
super(message)
|
|
7
13
|
@code = code
|
|
8
|
-
@
|
|
9
|
-
@exit_code = exit_code
|
|
10
|
-
@hint = hint
|
|
14
|
+
@info = info || ErrorInfo.for(details:, exit_code:, hint:)
|
|
11
15
|
end
|
|
12
16
|
|
|
17
|
+
def details = @info.details
|
|
18
|
+
def exit_code = @info.exit_code
|
|
19
|
+
def hint = @info.hint
|
|
20
|
+
|
|
13
21
|
def to_envelope
|
|
14
22
|
env = {
|
|
15
23
|
"protocol" => Textus::PROTOCOL,
|
|
16
24
|
"ok" => false,
|
|
17
25
|
"code" => @code,
|
|
18
26
|
"message" => message,
|
|
19
|
-
"details" =>
|
|
27
|
+
"details" => details,
|
|
20
28
|
}
|
|
21
|
-
env["hint"] =
|
|
29
|
+
env["hint"] = hint if hint
|
|
22
30
|
env
|
|
23
31
|
end
|
|
24
32
|
end
|
|
@@ -97,7 +105,7 @@ module Textus
|
|
|
97
105
|
else
|
|
98
106
|
"no declared role"
|
|
99
107
|
end
|
|
100
|
-
details = { "key" => k, "
|
|
108
|
+
details = { "key" => k, "lane" => z }
|
|
101
109
|
details["verb"] = verb if verb
|
|
102
110
|
details["holders"] = holders if holders
|
|
103
111
|
super(
|
|
@@ -150,10 +158,6 @@ module Textus
|
|
|
150
158
|
end
|
|
151
159
|
end
|
|
152
160
|
|
|
153
|
-
class InvalidProjection < Error
|
|
154
|
-
def initialize(m) = super("invalid_projection", m)
|
|
155
|
-
end
|
|
156
|
-
|
|
157
161
|
class TemplateError < Error
|
|
158
162
|
def initialize(m, template_name: nil)
|
|
159
163
|
hint =
|
|
@@ -177,7 +181,7 @@ module Textus
|
|
|
177
181
|
class PublishError < Error
|
|
178
182
|
def initialize(m, target: nil)
|
|
179
183
|
hint =
|
|
180
|
-
("file at #{target} wasn't published by textus; back it up and delete it, or move it under .textus/
|
|
184
|
+
("file at #{target} wasn't published by textus; back it up and delete it, or move it under .textus/data/" if target)
|
|
181
185
|
super("publish_error", m, details: target ? { "target" => target } : {}, hint: hint)
|
|
182
186
|
end
|
|
183
187
|
end
|
|
@@ -201,6 +205,14 @@ module Textus
|
|
|
201
205
|
end
|
|
202
206
|
end
|
|
203
207
|
|
|
208
|
+
class ContractDrift < Error
|
|
209
|
+
JSONRPC_CODE = -32_001
|
|
210
|
+
|
|
211
|
+
def initialize(message, details: {})
|
|
212
|
+
super("contract_drift", message, details: details)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
204
216
|
class FlagRenamed < Error
|
|
205
217
|
def initialize(old_flag, new_flag)
|
|
206
218
|
super(
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
module
|
|
3
|
-
# Abstract base for entry format strategies. Each concrete strategy
|
|
4
|
-
# owns parsing, serialization, file-extension claims, and schema
|
|
5
|
-
# validation for entries declared with its format.
|
|
2
|
+
module Format
|
|
6
3
|
class Base
|
|
7
4
|
def self.parse(_raw, path: nil)
|
|
8
5
|
_ = path
|
|
@@ -20,8 +17,6 @@ module Textus
|
|
|
20
17
|
raise NotImplementedError.new("#{name}.extensions not implemented")
|
|
21
18
|
end
|
|
22
19
|
|
|
23
|
-
# Default: validate the meta hash. Overridden by formats that put the
|
|
24
|
-
# validatable payload elsewhere (json/yaml put it under "content").
|
|
25
20
|
def self.validate_against(schema, parsed)
|
|
26
21
|
schema.validate!(parsed["_meta"] || {})
|
|
27
22
|
end
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
require "json"
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
|
-
module
|
|
5
|
-
# JSON entry storage. Top-level must be an object so we can carry _meta.
|
|
4
|
+
module Format
|
|
6
5
|
class Json < Base
|
|
7
6
|
def self.parse(raw, path: nil)
|
|
8
7
|
raw = raw.dup.force_encoding(Encoding::UTF_8)
|
|
@@ -23,7 +22,6 @@ module Textus
|
|
|
23
22
|
|
|
24
23
|
def self.serialize(meta:, body:, content: nil)
|
|
25
24
|
if content.is_a?(Hash)
|
|
26
|
-
# Re-inject _meta as the first key so on-disk shape is stable.
|
|
27
25
|
on_disk = meta && !meta.empty? ? { "_meta" => meta }.merge(content) : content
|
|
28
26
|
out = ::JSON.pretty_generate(on_disk)
|
|
29
27
|
out += "\n" unless out.end_with?("\n")
|
|
@@ -61,7 +59,6 @@ module Textus
|
|
|
61
59
|
end
|
|
62
60
|
end
|
|
63
61
|
|
|
64
|
-
# Mutating filesystem op; returns true if a write happened.
|
|
65
62
|
def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
|
|
66
63
|
raw = File.binread(path)
|
|
67
64
|
parsed = parse(raw, path: path)
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
require "yaml"
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
|
-
module
|
|
5
|
-
# Markdown with YAML frontmatter. Original Entry implementation.
|
|
4
|
+
module Format
|
|
6
5
|
class Markdown < Base
|
|
7
6
|
def self.parse(raw, path: nil)
|
|
8
7
|
raw = raw.dup.force_encoding(Encoding::UTF_8)
|
|
@@ -26,7 +25,7 @@ module Textus
|
|
|
26
25
|
end
|
|
27
26
|
|
|
28
27
|
def self.serialize(meta:, body:, content: nil)
|
|
29
|
-
_ = content
|
|
28
|
+
_ = content
|
|
30
29
|
fm_yaml = meta.empty? ? "" : ::YAML.dump(meta).sub(/\A---\n/, "")
|
|
31
30
|
body = body.to_s
|
|
32
31
|
body += "\n" unless body.empty? || body.end_with?("\n")
|
|
@@ -44,9 +43,6 @@ module Textus
|
|
|
44
43
|
[bytes, meta, body.to_s, nil]
|
|
45
44
|
end
|
|
46
45
|
|
|
47
|
-
# Mutating filesystem op; returns true if a write happened (boolean is
|
|
48
|
-
# informational, not a predicate). Rubocop's predicate-name heuristic
|
|
49
|
-
# disabled here on purpose.
|
|
50
46
|
def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
|
|
51
47
|
raw = File.binread(path)
|
|
52
48
|
parsed = parse(raw, path: path)
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
module
|
|
3
|
-
# Plain-text entry storage. No frontmatter or structured content.
|
|
2
|
+
module Format
|
|
4
3
|
class Text < Base
|
|
5
4
|
def self.parse(raw, path: nil)
|
|
6
5
|
raw = raw.dup.force_encoding(Encoding::UTF_8)
|
|
@@ -25,9 +24,7 @@ module Textus
|
|
|
25
24
|
[meta, content]
|
|
26
25
|
end
|
|
27
26
|
|
|
28
|
-
def self.enforce_name_match!(_path, _meta)
|
|
29
|
-
# text has no meta home; no-op
|
|
30
|
-
end
|
|
27
|
+
def self.enforce_name_match!(_path, _meta); end
|
|
31
28
|
|
|
32
29
|
def self.serialize_for_put(meta:, body:, content:, path:)
|
|
33
30
|
_ = path
|
|
@@ -36,7 +33,6 @@ module Textus
|
|
|
36
33
|
[bytes, meta, body.to_s, nil]
|
|
37
34
|
end
|
|
38
35
|
|
|
39
|
-
# No-op; text has no meta. Returns false (never writes).
|
|
40
36
|
def self.rewrite_name(_path, _basename) # rubocop:disable Naming/PredicateMethod
|
|
41
37
|
false
|
|
42
38
|
end
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
require "yaml"
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
|
-
module
|
|
5
|
-
# YAML entry storage. Top-level must be a mapping so we can carry _meta.
|
|
4
|
+
module Format
|
|
6
5
|
class Yaml < Base
|
|
7
6
|
def self.parse(raw, path: nil)
|
|
8
7
|
raw = raw.dup.force_encoding(Encoding::UTF_8)
|
|
@@ -23,7 +22,6 @@ module Textus
|
|
|
23
22
|
|
|
24
23
|
def self.serialize(meta:, body:, content: nil)
|
|
25
24
|
if content.is_a?(Hash)
|
|
26
|
-
# Re-inject _meta as the first key so on-disk shape is stable.
|
|
27
25
|
on_disk = meta && !meta.empty? ? { "_meta" => meta }.merge(content) : content
|
|
28
26
|
::YAML.dump(on_disk).sub(/\A---\n/, "")
|
|
29
27
|
elsif body && !body.to_s.empty?
|
|
@@ -59,7 +57,6 @@ module Textus
|
|
|
59
57
|
end
|
|
60
58
|
end
|
|
61
59
|
|
|
62
|
-
# Mutating filesystem op; returns true if a write happened.
|
|
63
60
|
def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
|
|
64
61
|
raw = File.binread(path)
|
|
65
62
|
parsed = parse(raw, path: path)
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
|
|
3
|
-
module Entry
|
|
2
|
+
module Format
|
|
4
3
|
SEP = "---".freeze
|
|
5
4
|
|
|
6
5
|
STRATEGIES = {
|
|
7
|
-
"markdown" => Markdown,
|
|
8
|
-
"json" => Json,
|
|
9
|
-
"yaml" => Yaml,
|
|
10
|
-
"text" => Text,
|
|
6
|
+
"markdown" => -> { Format::Markdown },
|
|
7
|
+
"json" => -> { Format::Json },
|
|
8
|
+
"yaml" => -> { Format::Yaml },
|
|
9
|
+
"text" => -> { Format::Text },
|
|
11
10
|
}.freeze
|
|
12
11
|
|
|
13
12
|
EXT_TO_FORMAT = {
|
|
@@ -18,8 +17,8 @@ module Textus
|
|
|
18
17
|
".txt" => "text",
|
|
19
18
|
}.freeze
|
|
20
19
|
|
|
21
|
-
def self.
|
|
22
|
-
STRATEGIES.fetch(format.to_s) { raise UsageError.new("unknown entry format: #{format.inspect}") }
|
|
20
|
+
def self.for(format)
|
|
21
|
+
STRATEGIES.fetch(format.to_s) { raise Textus::UsageError.new("unknown entry format: #{format.inspect}") }.call
|
|
23
22
|
end
|
|
24
23
|
|
|
25
24
|
def self.infer_from_extension(ext)
|
|
@@ -31,11 +30,11 @@ module Textus
|
|
|
31
30
|
end
|
|
32
31
|
|
|
33
32
|
def self.parse(raw, path: nil, format: "markdown")
|
|
34
|
-
|
|
33
|
+
Format.for(format).parse(raw, path: path)
|
|
35
34
|
end
|
|
36
35
|
|
|
37
36
|
def self.serialize(meta: {}, body: "", content: nil, format: "markdown")
|
|
38
|
-
|
|
37
|
+
Format.for(format).serialize(meta: meta, body: body, content: content)
|
|
39
38
|
end
|
|
40
39
|
end
|
|
41
40
|
end
|