add storage foundation contracts
This commit is contained in:
parent
0ca0bac9bf
commit
5381c997e2
6 changed files with 208 additions and 0 deletions
17
docs/009-storage-foundation.md
Normal file
17
docs/009-storage-foundation.md
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# 009 Storage foundation
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- v1 storage slice needs workspace, chat and file flows before durable DB
|
||||||
|
- trusted caller passes `user_id`, and one workspace belongs to one user
|
||||||
|
- chat content must live outside sandbox lifecycle and survive sandbox restart
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
- metadata repositories are in-memory for the first storage slice
|
||||||
|
- `Workspace`, `Chat` and `ChatFile` are first-class domain entities
|
||||||
|
- filesystem access stays behind storage ports in outer layers
|
||||||
|
- sandbox later integrates through chat metadata and storage ports, not raw path math in usecases
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- metadata is lost on restart in this phase
|
||||||
|
- storage usecases and HTTP API can be built before durable persistence
|
||||||
|
- later durable metadata can replace in-memory adapters behind the same ports
|
||||||
17
docs/010-chat-history-policy.md
Normal file
17
docs/010-chat-history-policy.md
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# 010 Chat history policy
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- v1 keeps chat history in filesystem, not in central DB
|
||||||
|
- chat metadata must not depend on parsing history content
|
||||||
|
- each chat already maps to an isolated working directory
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
- each chat owns one `history.md` inside its chat directory
|
||||||
|
- `history.md` is created with chat layout initialization
|
||||||
|
- chat metadata stores identity and lifecycle fields separately from history content
|
||||||
|
- history read and write stay behind storage ports in outer layers
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- history survives sandbox restart with chat storage
|
||||||
|
- metadata and content evolve independently
|
||||||
|
- later migration to another history backend can keep the same chat identity model
|
||||||
35
domain/chat.py
Normal file
35
domain/chat.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
HISTORY_FILE_NAME = 'history.md'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ChatAttachmentName:
|
||||||
|
value: str
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.value or self.value in {'.', '..'}:
|
||||||
|
raise ValueError('invalid attachment name')
|
||||||
|
if '/' in self.value or '\\' in self.value:
|
||||||
|
raise ValueError('invalid attachment name')
|
||||||
|
if self.value == HISTORY_FILE_NAME:
|
||||||
|
raise ValueError('reserved attachment name')
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Chat:
|
||||||
|
chat_id: UUID
|
||||||
|
workspace_id: UUID
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ChatFile:
|
||||||
|
file_id: UUID
|
||||||
|
chat_id: UUID
|
||||||
|
name: ChatAttachmentName
|
||||||
|
content_type: str | None
|
||||||
|
size_bytes: int
|
||||||
|
created_at: datetime
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
class DomainError(Exception):
|
class DomainError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -18,6 +21,48 @@ class UserConflictError(UserError):
|
||||||
self.email = email
|
self.email = email
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceError(DomainError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceNotFoundError(WorkspaceError):
|
||||||
|
def __init__(self, workspace_id: UUID) -> None:
|
||||||
|
super().__init__('workspace_not_found')
|
||||||
|
self.workspace_id = workspace_id
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceQuotaExceededError(WorkspaceError):
|
||||||
|
def __init__(self, workspace_id: UUID) -> None:
|
||||||
|
super().__init__('workspace_quota_exceeded')
|
||||||
|
self.workspace_id = workspace_id
|
||||||
|
|
||||||
|
|
||||||
|
class ChatError(DomainError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ChatNotFoundError(ChatError):
|
||||||
|
def __init__(self, chat_id: UUID) -> None:
|
||||||
|
super().__init__('chat_not_found')
|
||||||
|
self.chat_id = chat_id
|
||||||
|
|
||||||
|
|
||||||
|
class ChatHasActiveSandboxError(ChatError):
|
||||||
|
def __init__(self, chat_id: UUID) -> None:
|
||||||
|
super().__init__('chat_has_active_sandbox')
|
||||||
|
self.chat_id = chat_id
|
||||||
|
|
||||||
|
|
||||||
|
class ChatFileError(DomainError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ChatFileNotFoundError(ChatFileError):
|
||||||
|
def __init__(self, file_id: UUID) -> None:
|
||||||
|
super().__init__('chat_file_not_found')
|
||||||
|
self.file_id = file_id
|
||||||
|
|
||||||
|
|
||||||
class SandboxError(DomainError):
|
class SandboxError(DomainError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
||||||
17
domain/workspace.py
Normal file
17
domain/workspace.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Workspace:
|
||||||
|
workspace_id: UUID
|
||||||
|
user_id: UUID
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class WorkspaceUsage:
|
||||||
|
workspace_id: UUID
|
||||||
|
used_bytes: int
|
||||||
|
quota_bytes: int
|
||||||
|
|
@ -4,8 +4,10 @@ from types import TracebackType
|
||||||
from typing import Protocol, TypeAlias
|
from typing import Protocol, TypeAlias
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
from domain.chat import Chat, ChatAttachmentName, ChatFile
|
||||||
from domain.sandbox import SandboxSession
|
from domain.sandbox import SandboxSession
|
||||||
from domain.user import User
|
from domain.user import User
|
||||||
|
from domain.workspace import Workspace, WorkspaceUsage
|
||||||
|
|
||||||
AttrValue: TypeAlias = str | int | float | bool
|
AttrValue: TypeAlias = str | int | float | bool
|
||||||
Attrs: TypeAlias = Mapping[str, AttrValue]
|
Attrs: TypeAlias = Mapping[str, AttrValue]
|
||||||
|
|
@ -19,6 +21,81 @@ class UserRepository(Protocol):
|
||||||
def save(self, user: User) -> None: ...
|
def save(self, user: User) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceRepository(Protocol):
|
||||||
|
def get(self, workspace_id: UUID) -> Workspace | None: ...
|
||||||
|
|
||||||
|
def get_by_user_id(self, user_id: UUID) -> Workspace | None: ...
|
||||||
|
|
||||||
|
def save(self, workspace: Workspace) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ChatRepository(Protocol):
|
||||||
|
def get(self, chat_id: UUID) -> Chat | None: ...
|
||||||
|
|
||||||
|
def list_by_workspace_id(self, workspace_id: UUID) -> list[Chat]: ...
|
||||||
|
|
||||||
|
def save(self, chat: Chat) -> None: ...
|
||||||
|
|
||||||
|
def delete(self, chat_id: UUID) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ChatFileRepository(Protocol):
|
||||||
|
def get(self, file_id: UUID) -> ChatFile | None: ...
|
||||||
|
|
||||||
|
def get_by_chat_id_and_name(
|
||||||
|
self,
|
||||||
|
chat_id: UUID,
|
||||||
|
name: ChatAttachmentName,
|
||||||
|
) -> ChatFile | None: ...
|
||||||
|
|
||||||
|
def list_by_chat_id(self, chat_id: UUID) -> list[ChatFile]: ...
|
||||||
|
|
||||||
|
def save(self, chat_file: ChatFile) -> None: ...
|
||||||
|
|
||||||
|
def delete(self, file_id: UUID) -> None: ...
|
||||||
|
|
||||||
|
def delete_by_chat_id(self, chat_id: UUID) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ChatStorage(Protocol):
|
||||||
|
def ensure_chat(self, chat: Chat) -> None: ...
|
||||||
|
|
||||||
|
def read_history(self, chat: Chat) -> str: ...
|
||||||
|
|
||||||
|
def write_history(self, chat: Chat, content: str) -> None: ...
|
||||||
|
|
||||||
|
def delete_chat(self, chat: Chat) -> None: ...
|
||||||
|
|
||||||
|
def write_attachment(
|
||||||
|
self,
|
||||||
|
chat: Chat,
|
||||||
|
file_name: ChatAttachmentName,
|
||||||
|
content: bytes,
|
||||||
|
) -> int: ...
|
||||||
|
|
||||||
|
def read_attachment(self, chat: Chat, file_name: ChatAttachmentName) -> bytes: ...
|
||||||
|
|
||||||
|
def delete_attachment(
|
||||||
|
self,
|
||||||
|
chat: Chat,
|
||||||
|
file_name: ChatAttachmentName,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
def clear_attachments(self, chat: Chat) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class StorageUsageReader(Protocol):
|
||||||
|
def get_workspace_usage(
|
||||||
|
self,
|
||||||
|
workspace: Workspace,
|
||||||
|
chats: list[Chat],
|
||||||
|
) -> WorkspaceUsage: ...
|
||||||
|
|
||||||
|
|
||||||
|
class IdGenerator(Protocol):
|
||||||
|
def new(self) -> UUID: ...
|
||||||
|
|
||||||
|
|
||||||
class SandboxSessionRepository(Protocol):
|
class SandboxSessionRepository(Protocol):
|
||||||
def get_active_by_chat_id(self, chat_id: UUID) -> SandboxSession | None: ...
|
def get_active_by_chat_id(self, chat_id: UUID) -> SandboxSession | None: ...
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue