Class: Textus::Envelope::IO::Writer

Inherits:
Object
  • Object
show all
Defined in:
lib/textus/envelope/io/writer.rb

Overview

Owns the write pipeline (validate, serialize, etag-check, write, audit). Talks to ports (FileStore, Schemas, AuditLog, Manifest) and an Reader for the existing-uid lookup.

Invariant: every public method’s final action is @audit_log.append(…).

No permission check, no event firing — those belong to the caller (Write::Put / ::Delete / ::Mv).

Defined Under Namespace

Classes: Payload

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(file_store:, manifest:, schemas:, audit_log:, call:, reader:) ⇒ Writer

Returns a new instance of Writer.



25
26
27
28
29
30
31
32
# File 'lib/textus/envelope/io/writer.rb', line 25

def initialize(file_store:, manifest:, schemas:, audit_log:, call:, reader:)
  @file_store = file_store
  @manifest   = manifest
  @schemas    = schemas
  @audit_log  = audit_log
  @call       = call
  @reader     = reader
end

Class Method Details

.from(container:, call:) ⇒ Object



17
18
19
20
21
22
23
# File 'lib/textus/envelope/io/writer.rb', line 17

def self.from(container:, call:)
  new(
    file_store: container.file_store, manifest: container.manifest,
    schemas: container.schemas, audit_log: container.audit_log,
    call: call, reader: Reader.from(container: container)
  )
end

Instance Method Details

#delete(key, mentry: nil, if_etag: nil) ⇒ Object

rubocop:disable Lint/UnusedMethodArgument

Raises:



74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/textus/envelope/io/writer.rb', line 74

def delete(key, mentry: nil, if_etag: nil) # rubocop:disable Lint/UnusedMethodArgument
  # `mentry:` is accepted for symmetry with `put` / `move` and to
  # leave room for future format-specific delete hooks; no field
  # on it is needed today.
  path = @manifest.resolver.resolve(key).path
  raise UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)) unless @file_store.exists?(path)

  etag_before = @file_store.etag(path)
  raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before

  @file_store.delete(path)
  prune_empty_parents(path)
  @audit_log.append(
    role: @call.role, verb: "delete", key: key,
    etag_before: etag_before, etag_after: nil,
    extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
  )
end

#move(from_key:, to_key:, new_mentry:, if_etag: nil) ⇒ Object

Raises:



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/textus/envelope/io/writer.rb', line 93

def move(from_key:, to_key:, new_mentry:, if_etag: nil)
  from_path = @manifest.resolver.resolve(from_key).path
  to_path   = @manifest.resolver.resolve(to_key).path
  raise UnknownKey.new(from_key, suggestions: @manifest.resolver.suggestions_for(from_key)) unless @file_store.exists?(from_path)

  etag_before = @file_store.etag(from_path)
  raise EtagMismatch.new(from_key, if_etag, etag_before) if if_etag && if_etag != etag_before

  FileUtils.mkdir_p(File.dirname(to_path))
  FileUtils.mv(from_path, to_path)
  prune_empty_parents(from_path)
  basename = to_key.split(".").last
  Entry.for_format(new_mentry.format).rewrite_name(to_path, basename)
  etag_after = Etag.for_file(to_path)

  raw = @file_store.read(to_path)
  parsed = Entry.for_format(new_mentry.format).parse(raw, path: to_path)
  envelope = Textus::Envelope.build(
    key: to_key, mentry: new_mentry, path: to_path,
    meta: parsed["_meta"], body: parsed["body"],
    etag: etag_after, content: parsed["content"]
  )

  extras = {
    "from_key" => from_key, "to_key" => to_key,
    "from_path" => from_path, "to_path" => to_path,
    "uid" => envelope.uid
  }
  extras["correlation_id"] = @call.correlation_id if @call.correlation_id

  @audit_log.append(
    role: @call.role, verb: "mv", key: to_key,
    etag_before: etag_before, etag_after: etag_after,
    extras: extras
  )

  envelope
end

#put(key, mentry:, payload:, if_etag: nil) ⇒ Object

Raises:



34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/textus/envelope/io/writer.rb', line 34

def put(key, mentry:, payload:, if_etag: nil)
  path = @manifest.resolver.resolve(key).path

  meta = payload.meta || {}

  existing_uid = @reader.existing_uid(key)
  meta, content = ensure_uid(mentry.format, meta, payload.content, existing_uid)

  bytes, eff_meta, eff_body, eff_content = serialize_for_put(
    mentry: mentry, path: path,
    meta: meta, body: payload.body, content: content
  )

  enforce_name_match!(path, eff_meta, mentry.format)

  schema = @schemas.fetch_or_nil(mentry.schema)
  if schema
    Entry.for_format(mentry.format).validate_against(
      schema,
      { "_meta" => eff_meta, "content" => eff_content },
    )
  end

  etag_before = @file_store.exists?(path) ? @file_store.etag(path) : nil
  raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && (etag_before != if_etag)

  @file_store.write(path, bytes)
  etag_after = Etag.for_bytes(bytes)
  envelope = Textus::Envelope.build(
    key: key, mentry: mentry, path: path,
    meta: eff_meta, body: eff_body, etag: etag_after, content: eff_content
  )
  @audit_log.append(
    role: @call.role, verb: "put", key: key,
    etag_before: etag_before, etag_after: etag_after,
    extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
  )
  envelope
end