diff --git a/.github/scripts/format_benchmarks.py b/.github/scripts/format_benchmarks.py new file mode 100644 index 0000000..bc17567 --- /dev/null +++ b/.github/scripts/format_benchmarks.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Format pytest-benchmark JSON output as a GitHub-Flavored Markdown table. + +Usage: + python3 format_benchmarks.py + +Writes the table to stdout so the caller can append it to +$GITHUB_STEP_SUMMARY. +""" + +import json +import sys + + +def main() -> None: + if len(sys.argv) < 2: + print("_No benchmark JSON path provided._") + sys.exit(0) + + path = sys.argv[1] + try: + with open(path) as fh: + data = json.load(fh) + except (OSError, json.JSONDecodeError) as exc: + print(f"_Could not read benchmark results: {exc}_") + sys.exit(0) + + benchmarks = data.get("benchmarks", []) + if not benchmarks: + print("_No benchmark entries found._") + sys.exit(0) + + col = 55 + # Header + print( + f"| {'Test':<{col}} " + f"| {'Min ms':>8} " + f"| {'Mean ms':>8} " + f"| {'Max ms':>8} " + f"| {'StdDev':>8} " + f"| {'Rounds':>6} |" + ) + # Separator + print( + f"|{'-'*(col+2)}" + f"|{'-'*10}" + f"|{'-'*10}" + f"|{'-'*10}" + f"|{'-'*10}" + f"|{'-'*8}|" + ) + for b in sorted(benchmarks, key=lambda x: x["stats"]["mean"]): + name = b["name"].replace("test_benchmark_", "")[:col] + s = b["stats"] + stddev = s.get("stddev", 0.0) + print( + f"| {name:<{col}} " + f"| {s['min']*1000:>8.2f} " + f"| {s['mean']*1000:>8.2f} " + f"| {s['max']*1000:>8.2f} " + f"| {stddev*1000:>8.2f} " + f"| {s['rounds']:>6} |" + ) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/deploy-kubernetes.yml b/.github/workflows/deploy-kubernetes.yml index 1dd64be..44127e2 100644 --- a/.github/workflows/deploy-kubernetes.yml +++ b/.github/workflows/deploy-kubernetes.yml @@ -377,6 +377,18 @@ jobs: run: | kubectl apply -k deploy/kubernetes/kustomize + # ── 10a. Force rollout restart so pods always pull the latest image ──────── + # + # `kubectl apply` is idempotent: when the image tag is unchanged (e.g. + # `:latest-main` / `:latest-proc`) Kubernetes will not schedule new pods + # on its own. `rollout restart` patches the pod-template annotation with + # the current timestamp, which always triggers a fresh rollout and ensures + # that nodes pull the newest image from the registry. + - name: Restart deployments to pull latest images + run: | + kubectl -n deltadatabase rollout restart deployment/main-worker + kubectl -n deltadatabase rollout restart deployment/proc-worker + # ── 11. Wait for rollout ────────────────────────────────────────────────── - name: Wait for rollout — main-worker run: kubectl -n deltadatabase rollout status deployment/main-worker --timeout=300s diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml new file mode 100644 index 0000000..8636beb --- /dev/null +++ b/.github/workflows/pr-test.yml @@ -0,0 +1,275 @@ +# PR Tests — Go unit tests · Docker deploy · Python E2E tests · Benchmarks +# +# Runs automatically on every pull request (any branch). +# +# Pipeline +# -------- +# 1. go-tests Run the full Go test suite with race detector and +# coverage report, publish results to the job summary. +# 2. deploy-and-e2e Build the all-in-one Docker image from the PR branch +# (local build only — never pushed to Docker Hub), start +# it as an isolated test container, run all Python E2E +# tests against the live container, collect benchmark +# results, publish a benchmark table to the job summary, +# then unconditionally tear the container down. +# +# This workflow never touches the production 'deltadatabase' Kubernetes +# namespace and does not require KUBE_CONFIG or any cluster credentials. +# Each run uses a unique container name and shared-filesystem path derived +# from the run ID so concurrent PR builds cannot collide. +# +# Tool versions are installed by the workflow itself — no pre-installed tools +# are assumed on the runner beyond Docker and a POSIX shell: +# - Go installed via actions/setup-go@v5 (version read from go.mod) +# - Python installed via actions/setup-python@v5 (3.x, latest patch) +# - Docker must be present on the self-hosted runner + +name: PR Tests — Go · Deploy · Python E2E + +on: + pull_request: + branches: ['**'] + +jobs: + + # ============================================================================ + # Job 1: Go unit tests + # ============================================================================ + go-tests: + name: Go unit tests + runs-on: self-hosted + + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + cache: true + + - name: Install C compiler (required by go test -race via CGO) + run: sudo apt-get install -y --no-install-recommends gcc + + - name: Run Go tests (race detector + coverage) + env: + CGO_ENABLED: '1' + run: | + go test \ + -race \ + -count=1 \ + -timeout 120s \ + -coverprofile=/tmp/coverage-${{ github.run_id }}.out \ + ./... \ + 2>&1 | tee /tmp/go-test-${{ github.run_id }}.txt + exit "${PIPESTATUS[0]}" + + - name: Publish Go test summary + if: always() + run: | + { + echo "## Go Unit Tests" + echo "" + if [ -f /tmp/coverage-${{ github.run_id }}.out ]; then + COVERAGE=$(go tool cover \ + -func=/tmp/coverage-${{ github.run_id }}.out \ + | tail -1 | awk '{print $3}') + echo "**Total coverage:** ${COVERAGE}" + echo "" + fi + echo '```' + cat /tmp/go-test-${{ github.run_id }}.txt + echo '```' + } >> "${GITHUB_STEP_SUMMARY}" + rm -f \ + /tmp/go-test-${{ github.run_id }}.txt \ + /tmp/coverage-${{ github.run_id }}.out + + # ============================================================================ + # Job 2: Build Docker image → Deploy → Python E2E tests → Benchmarks + # ============================================================================ + deploy-and-e2e: + name: Deploy (Docker) · Python E2E · Benchmarks + runs-on: self-hosted + needs: go-tests + + permissions: + contents: read + + env: + # Unique names so concurrent PR runs do not collide on the same host. + CI_CONTAINER: deltadatabase-pr-${{ github.run_id }} + CI_IMAGE: deltadatabase-pr-img:${{ github.run_id }} + # Shared filesystem mounted into the container so filesystem-dependent + # tests (test_encryption.py, test_data_integrity.py) can inspect files + # that the workers write to disk. + CI_SHARED_ROOT: /tmp/delta-ci-${{ github.run_id }} + # Admin key used only for this ephemeral test run. + CI_ADMIN_KEY: ci-test-key-${{ github.run_id }} + + steps: + # ── 2a. Source ──────────────────────────────────────────────────────────── + - name: Checkout + uses: actions/checkout@v4 + + # ── 2b. Set up Python ───────────────────────────────────────────────────── + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + # ── 2c. Build Docker image (local only — not pushed to Docker Hub) ──────── + - name: Build all-in-one Docker image + run: | + docker build \ + -f deploy/docker/Dockerfile.all-in-one \ + -t "${{ env.CI_IMAGE }}" \ + . + + # ── 2d. Prepare shared filesystem and launch container ─────────────────── + - name: Prepare shared filesystem + run: | + mkdir -p "${{ env.CI_SHARED_ROOT }}/db/files" + mkdir -p "${{ env.CI_SHARED_ROOT }}/db/templates" + # The container runs as an unprivileged 'delta' user (UID differs from + # the runner's UID). Make the volume world-writable so the container + # can create data files and advisory lock files inside the mount. + chmod -R 777 "${{ env.CI_SHARED_ROOT }}" + + - name: Start test container + run: | + docker run -d \ + --name "${{ env.CI_CONTAINER }}" \ + --publish 0:8080 \ + --publish 0:50051 \ + --publish 0:50052 \ + --env ADMIN_KEY="${{ env.CI_ADMIN_KEY }}" \ + --env SHARED_FS=/shared/db \ + --volume "${{ env.CI_SHARED_ROOT }}/db:/shared/db" \ + "${{ env.CI_IMAGE }}" + + - name: Discover container ports + id: ports + run: | + REST_PORT=$(docker port "${{ env.CI_CONTAINER }}" 8080/tcp | cut -d: -f2) + MAIN_PORT=$(docker port "${{ env.CI_CONTAINER }}" 50051/tcp | cut -d: -f2) + PROC_PORT=$(docker port "${{ env.CI_CONTAINER }}" 50052/tcp | cut -d: -f2) + echo "rest=${REST_PORT}" >> "${GITHUB_OUTPUT}" + echo "main=${MAIN_PORT}" >> "${GITHUB_OUTPUT}" + echo "proc=${PROC_PORT}" >> "${GITHUB_OUTPUT}" + echo "REST API : http://127.0.0.1:${REST_PORT}" + echo "Main gRPC : 127.0.0.1:${MAIN_PORT}" + echo "Proc gRPC : 127.0.0.1:${PROC_PORT}" + + # ── 2e. Wait for health ─────────────────────────────────────────────────── + - name: Wait for DeltaDatabase to be healthy + run: | + REST="http://127.0.0.1:${{ steps.ports.outputs.rest }}" + echo "Polling ${REST}/health ..." + for i in $(seq 1 60); do + if curl -sf "${REST}/health" > /dev/null 2>&1; then + echo "DeltaDatabase is healthy (attempt ${i})" + exit 0 + fi + sleep 2 + done + echo "::error::DeltaDatabase did not become healthy within 120 s" + docker logs "${{ env.CI_CONTAINER }}" 2>&1 | tail -50 + exit 1 + + # ── 2f. Python E2E tests ────────────────────────────────────────────────── + - name: Install C++ compiler (required to build grpcio from source) + run: sudo apt-get install -y --no-install-recommends g++ python3-dev + + - name: Install Python test dependencies + run: pip install -q -r tests/requirements.txt + + - name: Run Python E2E tests against deployed container + env: + # conftest.py live_server fixture reads these to skip spawning go run + # workers and connect directly to the running Docker container instead. + DELTADB_EXTERNAL_URL: http://127.0.0.1:${{ steps.ports.outputs.rest }} + DELTADB_EXTERNAL_GRPC_ADDR: 127.0.0.1:${{ steps.ports.outputs.main }} + DELTADB_EXTERNAL_PROC_GRPC_ADDR: 127.0.0.1:${{ steps.ports.outputs.proc }} + DELTADB_EXTERNAL_ADMIN_KEY: ${{ env.CI_ADMIN_KEY }} + # Point filesystem fixtures at the host-side volume mount so tests + # that inspect encrypted blobs on disk work correctly. + DELTADB_EXTERNAL_SHARED_FS: ${{ env.CI_SHARED_ROOT }} + run: | + python -m pytest tests/ \ + --benchmark-disable \ + --tb=short \ + -v \ + -p no:cacheprovider \ + 2>&1 | tee /tmp/e2e-${{ github.run_id }}.txt + exit "${PIPESTATUS[0]}" + + - name: Publish E2E test summary + if: always() + run: | + { + echo "## Python E2E Tests" + echo '```' + if [ -f /tmp/e2e-${{ github.run_id }}.txt ]; then + # Limit output to the last 500 lines so the summary stays within + # GitHub Actions' 1 MiB $GITHUB_STEP_SUMMARY size limit. + tail -n 500 /tmp/e2e-${{ github.run_id }}.txt + else + echo "_E2E tests did not run (dependency installation failed or tests were skipped)._" + fi + echo '```' + } >> "${GITHUB_STEP_SUMMARY}" + rm -f /tmp/e2e-${{ github.run_id }}.txt + + # ── 2g. Benchmarks ──────────────────────────────────────────────────────── + - name: Run benchmarks + if: success() + env: + DELTADB_EXTERNAL_URL: http://127.0.0.1:${{ steps.ports.outputs.rest }} + DELTADB_EXTERNAL_GRPC_ADDR: 127.0.0.1:${{ steps.ports.outputs.main }} + DELTADB_EXTERNAL_PROC_GRPC_ADDR: 127.0.0.1:${{ steps.ports.outputs.proc }} + DELTADB_EXTERNAL_ADMIN_KEY: ${{ env.CI_ADMIN_KEY }} + DELTADB_EXTERNAL_SHARED_FS: ${{ env.CI_SHARED_ROOT }} + run: | + python -m pytest tests/test_benchmarks.py \ + --benchmark-json=/tmp/bench-${{ github.run_id }}.json \ + --benchmark-columns=min,max,mean,stddev,rounds \ + --benchmark-sort=mean \ + -v \ + 2>&1 | tee /tmp/bench-out-${{ github.run_id }}.txt + exit "${PIPESTATUS[0]}" + + - name: Publish benchmark report + if: always() + run: | + { + echo "## Benchmark Results" + echo "" + BENCH_JSON="/tmp/bench-${{ github.run_id }}.json" + if [ -f "${BENCH_JSON}" ]; then + python3 .github/scripts/format_benchmarks.py "${BENCH_JSON}" + else + echo "_No benchmark data collected (benchmarks may have been skipped)._" + fi + } >> "${GITHUB_STEP_SUMMARY}" + rm -f \ + /tmp/bench-${{ github.run_id }}.json \ + /tmp/bench-out-${{ github.run_id }}.txt + + # ── 2h. Container logs on failure ───────────────────────────────────────── + - name: Show container logs on failure + if: failure() + run: docker logs "${{ env.CI_CONTAINER }}" 2>&1 | tail -100 + + # ── 2i. Tear down test deployment (always) ──────────────────────────────── + - name: Tear down test deployment + if: always() + run: | + docker stop "${{ env.CI_CONTAINER }}" 2>/dev/null || true + docker rm "${{ env.CI_CONTAINER }}" 2>/dev/null || true + docker rmi "${{ env.CI_IMAGE }}" 2>/dev/null || true + rm -rf "${{ env.CI_SHARED_ROOT }}" diff --git a/README.md b/README.md index 974d8fc..4477369 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,8 @@ ## Overview DeltaDatabase stores arbitrary JSON documents — called **entities** — inside -**schema-databases** identified by a `schema_id`. The schema IS the database — -there is no separate database namespace. Every entity is stored under its +**schemas** identified by a `schema_id`. The schema is the namespace — +there is no separate grouping above it. Every entity is stored under its `schema_id`, which also acts as the JSON Schema template identifier for validation. Every entity is: @@ -418,8 +418,8 @@ Requires `admin` permission. ### `PUT /entity/{schema_id}` -Create or update one or more entities in the schema-database identified by `schema_id`. -The schema IS the database — `schema_id` serves as both the storage namespace and the +Create or update one or more entities in the schema identified by `schema_id`. +The schema is the namespace — `schema_id` serves as both the storage namespace and the JSON Schema template used for validation (if a template with that ID exists). **Path parameter:** `schema_id` — schema identifier (e.g., `chat.v1`). @@ -468,7 +468,7 @@ Retrieve a single entity. ### `DELETE /entity/{schema_id}?key={entityKey}` -Delete a single entity by key from a schema-database. Requires `write` permission. +Delete a single entity by key from a schema. Requires `write` permission. **Path parameter:** `schema_id` — schema identifier. @@ -626,20 +626,20 @@ curl http://127.0.0.1:8080/schema/chat.v1 The web management UI exposes a **📋 Schemas** tab where you can list, load, and edit schemas through a form — no `curl` or file editing needed. -### Using a schema-database via the REST API +### Using a schema via the REST API The `schema_id` in the URL path is both the storage namespace and the schema validator. When a JSON Schema template with the given `schema_id` exists, each PUT is validated before storage. ```bash -# Store entities in the "chat.v1" schema-database +# Store entities in the "chat.v1" schema curl -X PUT http://127.0.0.1:8080/entity/chat.v1 \ -H "Authorization: Bearer $ADMIN_KEY" \ -H 'Content-Type: application/json' \ -d '{"session_001": {"messages": [{"role":"user","content":"Hello!"}]}}' -# Retrieve an entity from the "chat.v1" schema-database +# Retrieve an entity from the "chat.v1" schema curl "http://127.0.0.1:8080/entity/chat.v1?key=session_001" \ -H "Authorization: Bearer $ADMIN_KEY" ``` @@ -647,7 +647,7 @@ curl "http://127.0.0.1:8080/entity/chat.v1?key=session_001" \ ### Using a schema on a PUT (gRPC) When calling the gRPC `Process` RPC directly, set the `schema_id` field of -`ProcessRequest` to the desired schema-database identifier (e.g. `"chat.v1"`). +`ProcessRequest` to the desired schema identifier (e.g. `"chat.v1"`). The Processing Worker will: 1. Store the entity under that namespace. 2. Validate the payload against the `chat.v1` JSON Schema template (if it exists). @@ -761,7 +761,7 @@ http://127.0.0.1:8080/ ![Login](https://github.com/user-attachments/assets/bcba6cbc-61a1-4377-9b6f-455153edea53) ![Dashboard](https://github.com/user-attachments/assets/82004499-a0f7-49f5-9ee6-79c9ff893e2f) -![Databases](https://github.com/user-attachments/assets/afb838e5-2018-4417-b21f-41cbdb723b8a) +![Schemas](https://github.com/user-attachments/assets/afb838e5-2018-4417-b21f-41cbdb723b8a) **Pages:** @@ -769,10 +769,10 @@ http://127.0.0.1:8080/ |------|-------------| | **Login** | Beautiful sign-in card — enter your admin key, API key, or a dev-mode Client ID | | **Dashboard** | Live health status, worker counts, schema count, and cache statistics | -| **Schemas** | Dropdown + card grid of all schema-databases; click any card to explore its entities | +| **Schemas** | Dropdown + card grid of all schemas; click any card to explore its entities | | **Entities** | GET, PUT, and DELETE entities with a schema dropdown pre-populated from `GET /api/schemas` | | **Workers** | Table of all registered Processing Workers with status, key ID, last-seen, and tags | -| **Schemas** | List, load, create, and edit JSON Schema templates; export as Pydantic or TypeScript | +| **Templates** | List, load, create, and edit JSON Schema templates; export as Pydantic or TypeScript | | **API Keys** | Create and delete RBAC API keys (admin only) with permissions and optional expiry | | **Explorer** | Send arbitrary HTTP requests to any endpoint with quick-access buttons | @@ -780,7 +780,7 @@ http://127.0.0.1:8080/ | Endpoint | Description | |----------|-------------| -| `GET /api/schemas` | Returns a sorted list of schema-databases currently in the entity cache (requires `read`) | +| `GET /api/schemas` | Returns a sorted list of schemas currently in the entity cache (requires `read`) | | `GET /api/me` | Returns the caller's `client_id`, `permissions`, and `is_admin` flag | The UI is **fully responsive** — on mobile a hamburger menu opens the sidebar as an overlay. @@ -794,7 +794,7 @@ No additional installation is required — the UI is embedded directly in the The following examples demonstrate a complete **chat session backend** where: -* Each user session is stored under database `chatdb`. +* Each user session is stored in schema `chatdb`. * The entity key is the session ID (e.g., `session_001`). * The entity value is a JSON object containing the conversation history. @@ -814,7 +814,7 @@ import ( const ( baseURL = "http://127.0.0.1:8080" - database = "chatdb" + schema = "chatdb" ) // Message represents a single chat turn. @@ -860,7 +860,7 @@ func (c *ChatClient) doRequest(method, path string, body io.Reader) (*http.Respo // GetSession retrieves the conversation history for a session. // Returns an empty session if the session does not yet exist. func (c *ChatClient) GetSession(sessionID string) (Session, error) { - path := fmt.Sprintf("/entity/%s?key=%s", database, url.QueryEscape(sessionID)) + path := fmt.Sprintf("/entity/%s?key=%s", schema, url.QueryEscape(sessionID)) resp, err := c.doRequest(http.MethodGet, path, nil) if err != nil { return Session{}, err @@ -896,7 +896,7 @@ func (c *ChatClient) AppendMessage(sessionID string, msg Message) error { return err } - path := fmt.Sprintf("/entity/%s", database) + path := fmt.Sprintf("/entity/%s", schema) resp, err := c.doRequest(http.MethodPut, path, bytes.NewReader(payload)) if err != nil { return err @@ -977,7 +977,7 @@ import requests import json BASE_URL = "http://127.0.0.1:8080" -DATABASE = "chatdb" +SCHEMA = "chatdb" class DeltaChatClient: @@ -994,7 +994,7 @@ class DeltaChatClient: def get_session(self, session_id: str) -> dict: """Return the conversation dict; empty if session does not exist yet.""" resp = self.session.get( - f"{self.base_url}/entity/{DATABASE}", + f"{self.base_url}/entity/{SCHEMA}", params={"key": session_id}, timeout=10, ) @@ -1009,7 +1009,7 @@ class DeltaChatClient: data.setdefault("messages", []).append({"role": role, "content": content}) resp = self.session.put( - f"{self.base_url}/entity/{DATABASE}", + f"{self.base_url}/entity/{SCHEMA}", json={session_id: data}, timeout=10, ) @@ -1238,7 +1238,7 @@ for a complete Docker Compose and Kubernetes walkthrough. | Schema validation | JSON Schema draft-07 enforced before every write | | Log redaction | No plaintext entity data or key material is emitted in logs | | Token expiry | Worker tokens: 1 h (configurable). Client tokens: 24 h (configurable) | -| Path traversal | Entity keys, database names and schema IDs are validated to reject `/`, `\`, and `..` | +| Path traversal | Entity keys and schema IDs are validated to reject `/`, `\`, and `..` | | Request body limit | REST PUT/schema endpoints reject bodies larger than 1 MiB | | Admin endpoints | `GET /admin/workers` requires a valid Bearer token; `GET /admin/schemas` is public | | Write durability (FS) | `fdatasync` before atomic rename guarantees no data loss on worker crash | @@ -1327,7 +1327,7 @@ For full build instructions, prerequisites, and testing setup, see │ ├── main-worker/ # Main Worker entry point & server │ │ ├── main.go │ │ ├── server.go # gRPC + REST handler -│ │ ├── frontend.go # Embedded web UI + /api/login, /api/databases, /api/me +│ │ ├── frontend.go # Embedded web UI + /api/login, /api/schemas, /api/me │ │ └── static/ │ │ ├── index.html # Login page │ │ ├── app.html # Multi-page management SPA diff --git a/api/proto/worker.pb.go b/api/proto/worker.pb.go index ef4ea9e..8af12d0 100644 --- a/api/proto/worker.pb.go +++ b/api/proto/worker.pb.go @@ -67,7 +67,7 @@ func (x *SubscribeResponse) GetKeyId() string { // ProcessRequest requests a GET or PUT operation on an entity. // SchemaId serves as both the storage namespace and the JSON Schema identifier — -// the schema IS the database. +// the schema is the namespace for all its entities. type ProcessRequest struct { SchemaId string `json:"schema_id,omitempty"` EntityKey string `json:"entity_key,omitempty"` diff --git a/api/proto/worker_grpc.pb.go b/api/proto/worker_grpc.pb.go index 1e772ac..e0ca7f7 100644 --- a/api/proto/worker_grpc.pb.go +++ b/api/proto/worker_grpc.pb.go @@ -14,7 +14,7 @@ import ( type MainWorkerClient interface { // Subscribe registers a Processing Worker and returns a token and wrapped key material. Subscribe(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (*SubscribeResponse, error) - // Process performs internal GET/PUT operations for database entities. + // Process performs internal GET/PUT operations for schema entities. Process(ctx context.Context, in *ProcessRequest, opts ...grpc.CallOption) (*ProcessResponse, error) } @@ -49,7 +49,7 @@ func (c *mainWorkerClient) Process(ctx context.Context, in *ProcessRequest, opts type MainWorkerServer interface { // Subscribe registers a Processing Worker and returns a token and wrapped key material. Subscribe(context.Context, *SubscribeRequest) (*SubscribeResponse, error) - // Process performs internal GET/PUT operations for database entities. + // Process performs internal GET/PUT operations for schema entities. Process(context.Context, *ProcessRequest) (*ProcessResponse, error) } diff --git a/cmd/main-worker/static/app.html b/cmd/main-worker/static/app.html index 82d105e..15a8209 100644 --- a/cmd/main-worker/static/app.html +++ b/cmd/main-worker/static/app.html @@ -42,8 +42,8 @@ - + + @@ -197,7 +197,7 @@
📥 Get Entity
- +
@@ -214,10 +214,10 @@
- +
- +
@@ -248,15 +248,15 @@ - -