Class: Textus::Application::Writes::EnvelopeIO

Inherits:
Object
  • Object
show all
Defined in:
lib/textus/application/writes/envelope_io.rb

Overview

Owns the write pipeline (validate, serialize, etag-check, write, audit) extracted from Store::Writer. Talks to ports (FileStore, Schemas, AuditLog, Manifest) instead of File/FileUtils and Store directly.

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

Defined Under Namespace

Classes: Payload

Instance Method Summary collapse

Constructor Details

#initialize(file_store:, manifest:, schemas:, audit_log:, ctx:) ⇒ EnvelopeIO

Returns a new instance of EnvelopeIO.



15
16
17
18
19
20
21
# File 'lib/textus/application/writes/envelope_io.rb', line 15

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

Instance Method Details

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

Raises:



83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/textus/application/writes/envelope_io.rb', line 83

def delete(key, mentry:, if_etag: nil)
  _ = mentry
  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)
  @audit_log.append(
    role: @ctx.role, verb: "delete", key: key,
    etag_before: etag_before, etag_after: nil,
    extras: @ctx.correlation_id ? { "correlation_id" => @ctx.correlation_id } : nil
  )
end

#exists?(path) ⇒ Boolean

Returns:

  • (Boolean)


23
# File 'lib/textus/application/writes/envelope_io.rb', line 23

def exists?(path) = @file_store.exists?(path)

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

Raises:



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
131
132
133
134
135
# File 'lib/textus/application/writes/envelope_io.rb', line 99

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)
  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 = 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"] = @ctx.correlation_id if @ctx.correlation_id

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

  envelope
end

#read_envelope(key) ⇒ Object

Reads an envelope by key, returning nil when absent. Used by Mv to inspect pre-move state (UID presence, content surfacing) so the move pipeline can consolidate I/O in one place.



28
29
30
31
32
33
34
35
36
37
38
39
40
41
# File 'lib/textus/application/writes/envelope_io.rb', line 28

def read_envelope(key)
  res = @manifest.resolver.resolve(key)
  path = res.path
  return nil unless @file_store.exists?(path)

  mentry = res.entry
  raw = @file_store.read(path)
  parsed = Entry.for_format(mentry.format).parse(raw, path: path)
  Envelope.build(
    key: key, mentry: mentry, path: path,
    meta: parsed["_meta"], body: parsed["body"],
    etag: Etag.for_bytes(raw), content: parsed["content"]
  )
end

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

Raises:



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
73
74
75
76
77
78
79
80
81
# File 'lib/textus/application/writes/envelope_io.rb', line 43

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

  meta = payload.meta || {}
  strategy = Entry.for_format(mentry.format)

  existing_uid = existing_uid_for(mentry, path)
  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, strategy: strategy,
    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)
  @audit_log.append(
    role: @ctx.role, verb: "put", key: key,
    etag_before: etag_before, etag_after: etag_after,
    extras: @ctx.correlation_id ? { "correlation_id" => @ctx.correlation_id } : nil
  )
  Envelope.build(
    key: key, mentry: mentry, path: path,
    meta: eff_meta, body: eff_body, etag: etag_after, content: eff_content
  )
end