Source code for vibesketch.server.store

"""The server's documents: in memory, revisioned, optionally backed by a file.

Human and agent edit the same document through the revision counter
(roadmap, section 3.2): every write names the revision it read, and a stale
write gets a conflict and must re-read. A document opened from a file on the
command line (``vibe-sketch serve scheme.vsk``) can be saved back to that
file, and only to it: the HTTP API never takes a filesystem path.
"""

from __future__ import annotations

import secrets
import threading
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

from vibesketch.document.io import digest_of, save
from vibesketch.document.model import Document

__all__ = ["Conflict", "Entry", "NotFound", "Store"]


[docs] class NotFound(KeyError): """No document with that id."""
[docs] class Conflict(RuntimeError): """The write named a revision that is no longer current.""" def __init__(self, current: int, given: int) -> None: super().__init__(f"revision {given} is stale; the document is at revision {current}") self.current = current self.given = given
[docs] @dataclass class Entry: id: str document: Document #: The file it was opened from and saves to, if any. path: Path | None = None #: The revision last written to ``path``. saved_revision: int | None = None warnings: list[str] = field(default_factory=list) #: What the QVF job container it was opened from says about its calculation. result: dict[str, Any] | None = None #: The file's bytes as last read or written (:func:`~vibesketch.document.io.digest_of`), #: so a save never replaces a change made outside vibe-sketch. digest: str | None = None
[docs] @property def dirty(self) -> bool: return self.path is not None and self.saved_revision != self.document.revision
[docs] class Store: def __init__(self) -> None: self._entries: dict[str, Entry] = {} self._lock = threading.Lock() #: The document the browser opens first, when the server was started with one. self.default_id: str | None = None
[docs] def add(self, document: Document, *, path: Path | None = None) -> Entry: with self._lock: identifier = secrets.token_hex(4) while identifier in self._entries: identifier = secrets.token_hex(4) entry = Entry(identifier, document, path, document.revision if path else None) if path is not None: entry.digest = digest_of(path) self._entries[identifier] = entry return entry
[docs] def get(self, identifier: str) -> Entry: with self._lock: try: return self._entries[identifier] except KeyError: raise NotFound(identifier) from None
[docs] def entries(self) -> list[Entry]: with self._lock: return list(self._entries.values())
[docs] def replace( self, identifier: str, document: Document, *, expected: int, warnings: list[str] | None = None, ) -> Entry: """Store ``document`` as the next revision, if ``expected`` is current.""" with self._lock: try: entry = self._entries[identifier] except KeyError: raise NotFound(identifier) from None current = entry.document.revision if expected != current: raise Conflict(current, expected) entry.document = document.model_copy(update={"revision": current + 1}) entry.warnings = list(warnings or []) return entry
[docs] def remove(self, identifier: str) -> None: with self._lock: if self._entries.pop(identifier, None) is None: raise NotFound(identifier) if self.default_id == identifier: self.default_id = None
[docs] def save(self, identifier: str) -> Entry: """Write the document back to the file it came from. Refused with :class:`~vibesketch.document.io.SaveRefused` (a ``ValueError``) when the file changed since it was opened or last saved. """ with self._lock: try: entry = self._entries[identifier] except KeyError: raise NotFound(identifier) from None if entry.path is None: raise ValueError("this document has no file; download it instead") save(entry.document, entry.path, expect=entry.digest) entry.digest = digest_of(entry.path) entry.saved_revision = entry.document.revision return entry