Class: Textus::Store

Inherits:
Object
  • Object
show all
Defined in:
lib/textus/store.rb,
lib/textus/store/mover.rb,
lib/textus/store/events.rb,
lib/textus/store/staleness.rb,
lib/textus/store/validator.rb

Defined Under Namespace

Classes: Events, Mover, Staleness, Validator

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(root) ⇒ Store

Returns a new instance of Store.



39
40
41
42
43
44
45
# File 'lib/textus/store.rb', line 39

def initialize(root)
  @root = File.expand_path(root)
  @manifest = Manifest.load(@root)
  @registry = ExtensionRegistry.new
  @schemas = {}
  load_extensions
end

Instance Attribute Details

#manifestObject (readonly)

Returns the value of attribute manifest.



6
7
8
# File 'lib/textus/store.rb', line 6

def manifest
  @manifest
end

#registryObject (readonly)

Returns the value of attribute registry.



6
7
8
# File 'lib/textus/store.rb', line 6

def registry
  @registry
end

#rootObject (readonly)

Returns the value of attribute root.



6
7
8
# File 'lib/textus/store.rb', line 6

def root
  @root
end

Class Method Details

.discover(start_dir = Dir.pwd, root: nil) ⇒ Object

Raises:



15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# File 'lib/textus/store.rb', line 15

def self.discover(start_dir = Dir.pwd, root: nil)
  explicit = root || ENV.fetch("TEXTUS_ROOT", nil)
  return discover_explicit(explicit) if explicit

  dir = File.expand_path(start_dir)
  loop do
    candidate = File.join(dir, ".textus")
    return new(candidate) if File.directory?(candidate) && File.exist?(File.join(candidate, "manifest.yaml"))

    parent = File.dirname(dir)
    break if parent == dir

    dir = parent
  end
  raise IoError.new("no .textus directory found from #{start_dir}")
end

.mint_uidObject

A Textus UID: 16 lowercase hex chars (SecureRandom.hex(8)). Not a UUID —short on purpose. Random enough for collision-never-in-practice within a single store.



11
12
13
# File 'lib/textus/store.rb', line 11

def self.mint_uid
  SecureRandom.hex(8)
end

Instance Method Details

#accept(key, as:) ⇒ Object



188
189
190
# File 'lib/textus/store.rb', line 188

def accept(key, as:)
  Proposal.accept(self, key, as: as)
end

#audit_logObject



218
219
220
# File 'lib/textus/store.rb', line 218

def audit_log
  @audit_log ||= AuditLog.new(@root)
end

#delete(key, if_etag: nil, as: Role::DEFAULT, suppress_events: false) ⇒ Object

Raises:



169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/textus/store.rb', line 169

def delete(key, if_etag: nil, as: Role::DEFAULT, suppress_events: false)
  mentry, path, = @manifest.resolve(key)
  writers = @manifest.zone_writers(mentry.zone)
  raise WriteForbidden.new(key, mentry.zone, writers: writers) unless writers.include?(as)
  raise UnknownKey.new(key, suggestions: @manifest.suggestions_for(key)) unless File.exist?(path)

  etag_before = Etag.for_file(path)
  raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before

  File.delete(path)
  audit_log.append(role: as, verb: "delete", key: key, etag_before: etag_before, etag_after: nil)
  fire_event(:delete, key: key) unless suppress_events
  { "protocol" => PROTOCOL, "ok" => true, "key" => key, "deleted" => true }
end

#deps(key) ⇒ Object



192
# File 'lib/textus/store.rb', line 192

def deps(key)      = Dependencies.deps_of(@manifest, key)

#fire_event(event) ⇒ Object



184
185
186
# File 'lib/textus/store.rb', line 184

def fire_event(event, **)
  Events.new(self).call(event, **)
end

#get(key) ⇒ Object

Raises:



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

def get(key)
  mentry, path, = @manifest.resolve(key)
  raise UnknownKey.new(key, suggestions: @manifest.suggestions_for(key)) unless File.exist?(path)

  raw = File.binread(path)
  parsed = Entry.for_format(mentry.format).parse(raw, path: path)
  meta = parsed["_meta"]
  content = parsed["content"]
  enforce_name_match!(path, meta, mentry.format)
  schema = schema_for(mentry.schema)
  if schema
    case mentry.format
    when "markdown" then schema.validate!(meta)
    when "json", "yaml" then schema.validate!(content || {})
      # text: schema forbidden by manifest validation
    end
  end
  build_envelope(key, mentry, path, meta, parsed["body"], Etag.for_bytes(raw), content: content)
end

#list(prefix: nil, zone: nil) ⇒ Object



105
106
107
108
109
110
111
112
113
114
115
# File 'lib/textus/store.rb', line 105

def list(prefix: nil, zone: nil)
  rows = @manifest.enumerate(prefix: prefix)
  rows = rows.select { |r| r[:manifest_entry].zone == zone } if zone
  rows.map do |row|
    {
      "key" => row[:key],
      "zone" => row[:manifest_entry].zone,
      "path" => row[:path],
    }
  end
end

#load_extensionsObject



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/textus/store.rb', line 47

def load_extensions
  Textus.with_registry(@registry) do
    BuiltinActions.register_all
    dir = File.join(@root, "extensions")
    return unless File.directory?(dir)

    Dir.glob(File.join(dir, "*.rb")).sort.each do |f| # rubocop:disable Lint/RedundantDirGlobSort
      begin
        load(f)
      rescue StandardError, ScriptError => e
        raise UsageError.new("failed loading extension #{File.basename(f)}: #{e.class}: #{e.message}")
      end
    end
  end
end

#mv(old_key, new_key, as: Role::DEFAULT, dry_run: false) ⇒ Object

Move an entry from old_key to new_key within the same zone. Preserves uid (minting one first if absent), validates both keys against the manifest, refuses to clobber, and writes one mv audit row.



214
215
216
# File 'lib/textus/store.rb', line 214

def mv(old_key, new_key, as: Role::DEFAULT, dry_run: false)
  Mover.new(self).call(old_key, new_key, as: as, dry_run: dry_run)
end

#publishedObject



194
# File 'lib/textus/store.rb', line 194

def published      = Dependencies.published_of(@manifest)

#put(key, meta: nil, body: nil, content: nil, if_etag: nil, as: Role::DEFAULT, suppress_events: false) ⇒ Object

rubocop:disable Metrics/ParameterLists

Raises:



129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/textus/store.rb', line 129

def put(key, meta: nil, body: nil, content: nil, if_etag: nil, as: Role::DEFAULT, suppress_events: false)
  # rubocop:enable Metrics/ParameterLists
  @manifest.validate_key!(key)
  mentry, path, = @manifest.resolve(key)
  writers = @manifest.zone_writers(mentry.zone)
  raise WriteForbidden.new(key, mentry.zone, writers: writers) unless writers.include?(as)

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

  existing_uid = existing_uid_for(mentry, path)
  meta, content = ensure_uid(mentry.format, meta, content, existing_uid)

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

  enforce_name_match!(path, eff_meta, mentry.format)

  schema = schema_for(mentry.schema)
  if schema
    case mentry.format
    when "markdown" then schema.validate!(eff_meta)
    when "json", "yaml" then schema.validate!(eff_content || {})
    end
  end

  etag_before = File.exist?(path) ? Etag.for_file(path) : nil
  raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && (etag_before != if_etag)

  FileUtils.mkdir_p(File.dirname(path))
  File.binwrite(path, bytes)
  etag_after = Etag.for_bytes(bytes)
  audit_log.append(role: as, verb: "put", key: key, etag_before: etag_before, etag_after: etag_after)
  envelope = build_envelope(key, mentry, path, eff_meta, eff_body, etag_after, content: eff_content)
  fire_event(:put, key: key, envelope: envelope) unless suppress_events
  envelope
end

#rdeps(key) ⇒ Object



193
# File 'lib/textus/store.rb', line 193

def rdeps(key)     = Dependencies.rdeps_of(@manifest, key)

#schema_envelope(key) ⇒ Object



117
118
119
120
121
122
123
124
125
126
# File 'lib/textus/store.rb', line 117

def schema_envelope(key)
  mentry, = @manifest.resolve(key)
  schema = schema_for(mentry.schema)
  {
    "protocol" => PROTOCOL,
    "key" => key,
    "schema_ref" => mentry.schema,
    "schema" => schema&.to_h,
  }
end

#schema_for(name) ⇒ Object



63
64
65
66
67
68
69
70
71
72
# File 'lib/textus/store.rb', line 63

def schema_for(name)
  return nil if name.nil?

  @schemas[name] ||= begin
    sp = File.join(@root, "schemas", "#{name}.yaml")
    raise IoError.new("schema not found: #{sp}") unless File.exist?(sp)

    Schema.load(sp)
  end
end

#stale(prefix: nil, zone: nil) ⇒ Object



200
201
202
# File 'lib/textus/store.rb', line 200

def stale(prefix: nil, zone: nil)
  Staleness.new(self).call(prefix: prefix, zone: zone)
end

#uid(key) ⇒ Object

Returns the Textus UID for a key (or nil if the entry has none yet). Raises UnknownKey if the key doesn’t resolve to a real file.



206
207
208
209
# File 'lib/textus/store.rb', line 206

def uid(key)
  env = get(key)
  env["uid"]
end

#validate_allObject



196
197
198
# File 'lib/textus/store.rb', line 196

def validate_all
  Validator.new(self).call
end

#where(key) ⇒ Object



94
95
96
97
98
99
100
101
102
103
# File 'lib/textus/store.rb', line 94

def where(key)
  mentry, path, = @manifest.resolve(key)
  {
    "protocol" => PROTOCOL,
    "key" => key,
    "zone" => mentry.zone,
    "owner" => mentry.owner,
    "path" => path,
  }
end