Source code for genro_asgi.session.file_store

# Copyright 2025 Softwell S.r.l.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""FileSessionStore — one JSON file per session over a storage mount.

Like ``MemorySessionStore`` this store keeps live ``Session`` objects in memory
(so ``get`` returns the same instance and ``touch`` is honored) but mirrors each
to ``<mount>:<prefix>/<id>.json``, so a session survives a process restart or a
fresh store built on the SAME mount — the D22 survival line. The file carries
meta (created_at/last_access/ttl) and the avatar's identity/tags ONLY, keyed on
disk by the session id (the filename); the data Bag is VOLATILE — never
persisted — the SAME contract as ``dump``/``restore`` (full-data persistence is
a future decision). All I/O is synchronous through the Phase 1 storage nodes
(core 1b ratified: async callers wrap in ``server.run_sync()``).

``purge_expired`` reaps the sessions this process tracks in memory (removing
their files too) and is invoked opportunistically at ``create`` time — no
background task, those arrive in core 1e. An expired file the store has not
loaded is reaped lazily the next time ``get`` touches it (no plain-text
fallback, no silent skip on a corrupt file: a non-JSON payload raises).
"""

from __future__ import annotations

import json
import secrets
from typing import Any

from ..storage import LocalStorage, LocalStorageNode
from .avatar import Avatar
from .session import Session

__all__ = ["FileSessionStore"]


[docs] class FileSessionStore: """Session store persisting one JSON file per session on a storage mount.""" __slots__ = ("_storage", "_mount", "_prefix", "_default_ttl", "_sessions")
[docs] def __init__( self, storage: LocalStorage, mount: str = "site", prefix: str = "sessions", default_ttl: int = 3600, ) -> None: """Bind the store to a storage ``mount``/``prefix`` with a default TTL.""" self._storage = storage self._mount = mount self._prefix = prefix self._default_ttl = default_ttl self._sessions: dict[str, Session] = {}
# ── persistence helpers ──────────────────────────────────────────── def _node(self, session_id: str) -> LocalStorageNode: """The storage node backing ``session_id`` on the configured mount.""" return self._storage.node(f"{self._mount}:{self._prefix}/{session_id}.json") def _entry(self, session: Session) -> dict[str, Any]: """Serialize a session to the dump/on-disk record (meta + avatar, no data).""" avatar = session.avatar return { "meta": dict(session.meta), "avatar": ( {"identity": avatar.identity, "tags": list(avatar.tags)} if avatar is not None else None ), } def _session_from_entry(self, session_id: str, entry: dict[str, Any]) -> Session: """Rebuild a session from a dump/on-disk record (data Bag starts empty).""" meta = entry["meta"] avatar_data = entry.get("avatar") avatar = Avatar(avatar_data["identity"], avatar_data["tags"]) if avatar_data else None session = Session(session_id=session_id, avatar=avatar, ttl=meta["ttl"]) session.meta["created_at"] = meta["created_at"] session.meta["last_access"] = meta["last_access"] return session def _write(self, session: Session) -> None: """Persist a session's record to its file (encryption is the mount's concern).""" self._node(session.id).write_text(json.dumps(self._entry(session))) def _remove_file(self, session_id: str) -> None: """Delete a session's file if it exists (a no-op otherwise).""" node = self._node(session_id) if node.exists: node.delete() def _load(self, session_id: str) -> Session | None: """Load a session from disk, or ``None`` if no file; a non-JSON file raises.""" node = self._node(session_id) if not node.exists: return None return self._session_from_entry(session_id, json.loads(node.read_text())) # ── SessionStore contract ──────────────────────────────────────────
[docs] def get(self, session_id: str) -> Session | None: """Retrieve a session (cache first, then disk); expired ones are reaped.""" session = self._sessions.get(session_id) if session is None: session = self._load(session_id) if session is None: return None self._sessions[session_id] = session if session.is_expired(): del self._sessions[session_id] self._remove_file(session_id) return None session.touch() return session
[docs] def create(self, avatar: Avatar | None = None) -> Session: """Create and persist a session, purging expired ones opportunistically first.""" self.purge_expired() session_id = secrets.token_urlsafe(32) session = Session(session_id=session_id, avatar=avatar, ttl=self._default_ttl) self._sessions[session_id] = session self._write(session) return session
[docs] def save(self, session: Session) -> None: """Persist a session's current state to disk (the write-back seam).""" self._write(session)
[docs] def delete(self, session_id: str) -> None: """Remove a session from the cache and disk (a no-op if absent).""" self._sessions.pop(session_id, None) self._remove_file(session_id)
[docs] def purge_expired(self) -> int: """Drop every expired tracked session (and its file); return the count purged.""" expired = [sid for sid, session in self._sessions.items() if session.is_expired()] for sid in expired: del self._sessions[sid] self._remove_file(sid) return len(expired)
[docs] def dump(self) -> dict[str, Any]: """Serialize the tracked sessions (meta + avatar per session; never the data Bag).""" return {sid: self._entry(session) for sid, session in self._sessions.items()}
[docs] def restore(self, data: dict[str, Any]) -> None: """Restore non-expired sessions from ``dump()`` output (cached and persisted).""" for session_id, entry in data.items(): session = self._session_from_entry(session_id, entry) if not session.is_expired(): self._sessions[session_id] = session self._write(session)
if __name__ == "__main__": import tempfile base = tempfile.mkdtemp() store = FileSessionStore(LocalStorage(base_dir=base)) created = store.create(avatar=Avatar("alice", ["admin"])) created.data["k"] = "v" assert store.get(created.id) is created fresh = FileSessionStore(LocalStorage(base_dir=base)) survivor = fresh.get(created.id) assert survivor is not None and survivor is not created assert survivor.avatar is not None and survivor.avatar.identity == "alice" assert len(survivor.data) == 0 fresh.delete(created.id) assert FileSessionStore(LocalStorage(base_dir=base)).get(created.id) is None