Class: LittleGhost::SessionStores::Filesystem

Inherits:
LittleGhost::SessionStore show all
Defined in:
lib/little_ghost/session_stores/filesystem.rb

Overview

Filesystem preserves LittleGhost sessions across process restarts in an application-controlled directory. Use it for durable local development, a single-host service, or processes that share a suitable filesystem.

store = LittleGhost::SessionStores::Filesystem.new(
root: "/var/lib/customer_support/sessions"
)

Configure the resulting store through Configuration#session_store, or pass it directly when opening a Session. Calls for the same session wait for one writer, including when separate Ruby processes share the root.

Warning: The root contains readable session data and is not encrypted. Its complete path must be application-controlled: anyone able to read it can read session data, and anyone able to replace it can alter sessions.

Session data is stored as ordinary JSON with canonical String keys. A value outside that boundary raises ProtocolError without replacing the previous snapshot. Shared roots require filesystem support for file locking and atomic rename.

Constant Summary collapse

FORMAT_VERSION =

:nodoc:

1

Instance Method Summary collapse

Methods inherited from LittleGhost::SessionStore

#project_conversation, #synchronize, #with_operation_context

Constructor Details

#initialize(root:) ⇒ Filesystem

Creates a store rooted at root and creates the directory when needed.

root must be a private, non-symlinked directory. The application owns the complete path and must not let an untrusted request choose it. Raises ArgumentError when the root does not meet those requirements.

Raises:

  • (ArgumentError)


40
41
42
43
44
45
46
47
# File 'lib/little_ghost/session_stores/filesystem.rb', line 40

def initialize(root:)
  super()
  @root = File.expand_path(String(root))
  raise ArgumentError, "root must not be empty" if root.to_s.empty?

  FileUtils.mkdir_p(@root, mode: 0o700)
  validate_root!
end

Instance Method Details

#append(id, messages:, state:, metadata:, expected_count:, actor_id: nil) ⇒ Object

Atomically appends sanitized messages and returns the updated snapshot.

expected_count must match the stored history length. state and metadata must meet this store's JSON boundary. Raises ProtocolError when another writer changed the session or the snapshot cannot be read or written. Raises Error when actor_id does not match the session.



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/little_ghost/session_stores/filesystem.rb', line 68

def append(id, messages:, state:, metadata:, expected_count:, actor_id: nil)
  messages = persistable_messages(messages)
  state = canonical_map(state)
   = canonical_map()
  with_lock(id) do
    current = read_snapshot(id)
    validate_actor!(current, actor_id) if current
    current ||= empty_snapshot(actor_id)
    unless current.fetch(:messages).length == expected_count
      raise ProtocolError, "Session changed while it was being updated"
    end

    snapshot = {
      actor_digest: current.fetch(:actor_digest),
      messages: [*current.fetch(:messages), *messages].freeze,
      state:,
      metadata:
    }.freeze
    write_snapshot(id, snapshot)
    public_snapshot(snapshot)
  end
end

#load(id, actor_id: nil) ⇒ Object

Returns the stored snapshot for id, or nil before the first write.

actor_id must match the actor that created an existing session. Raises Error for an actor mismatch and ProtocolError for an invalid or unsafe persisted snapshot.



54
55
56
57
58
59
60
# File 'lib/little_ghost/session_stores/filesystem.rb', line 54

def load(id, actor_id: nil)
  with_lock(id) do
    snapshot = read_snapshot(id)
    validate_actor!(snapshot, actor_id) if snapshot
    snapshot
  end
end

#replace(id, messages:, state:, metadata:, actor_id: nil) ⇒ Object

Atomically replaces the complete persisted snapshot and returns it.

state, metadata, and actor_id follow the same requirements as #append.



95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/little_ghost/session_stores/filesystem.rb', line 95

def replace(id, messages:, state:, metadata:, actor_id: nil)
  messages = persistable_messages(messages)
  state = canonical_map(state)
   = canonical_map()
  with_lock(id) do
    current = read_snapshot(id)
    validate_actor!(current, actor_id) if current
    snapshot = {
      actor_digest: current ? current.fetch(:actor_digest) : actor_digest(actor_id),
      messages: messages.freeze,
      state:,
      metadata:
    }.freeze
    write_snapshot(id, snapshot)
    public_snapshot(snapshot)
  end
end