Spaces:
Sleeping
Sleeping
Deploy GitHub commit 302c19c3bb59a508ad8f0b239bf0fca864f37f70
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +13 -0
- .env.example +8 -0
- .github/workflows/deploy-hf.yml +41 -0
- .github/workflows/validation.yml +38 -0
- .gitignore +12 -0
- .prettierignore +6 -0
- Dockerfile +40 -0
- README.md +92 -5
- apps/server/package.json +28 -0
- apps/server/src/app.ts +71 -0
- apps/server/src/config/environment.ts +54 -0
- apps/server/src/config/environment.unit.test.ts +8 -0
- apps/server/src/database/create-database.ts +16 -0
- apps/server/src/database/database.ts +9 -0
- apps/server/src/database/sqlite-system-database.ts +63 -0
- apps/server/src/http/routes.integration.test.ts +79 -0
- apps/server/src/index.ts +37 -0
- apps/server/src/storage/filesystem-object-storage.ts +55 -0
- apps/server/src/storage/filesystem-object-storage.unit.test.ts +30 -0
- apps/server/src/storage/object-storage.ts +8 -0
- apps/server/src/system/health-service.ts +10 -0
- apps/server/src/system/health-service.unit.test.ts +16 -0
- apps/server/src/system/readiness-service.ts +24 -0
- apps/server/src/system/version-service.ts +16 -0
- apps/server/tsconfig.build.json +9 -0
- apps/server/tsconfig.json +7 -0
- apps/web/index.html +14 -0
- apps/web/package.json +30 -0
- apps/web/src/App.test.tsx +56 -0
- apps/web/src/App.tsx +208 -0
- apps/web/src/main.tsx +11 -0
- apps/web/src/styles.css +229 -0
- apps/web/src/test/setup.ts +5 -0
- apps/web/tsconfig.json +8 -0
- apps/web/vite.config.ts +14 -0
- docs/architecture/DEPLOYMENT.md +21 -0
- docs/architecture/FOUNDATION.md +29 -0
- docs/architecture/LOCAL_DEVELOPMENT.md +22 -0
- docs/decisions/0001-modular-monolith.md +16 -0
- docs/decisions/0002-single-hf-docker-space.md +16 -0
- docs/decisions/0003-storage-abstraction.md +16 -0
- docs/decisions/0004-database-abstraction.md +16 -0
- eslint.config.js +47 -0
- package-lock.json +0 -0
- package.json +39 -0
- packages/contracts/package.json +21 -0
- packages/contracts/src/index.ts +32 -0
- packages/contracts/tsconfig.build.json +4 -0
- packages/contracts/tsconfig.json +5 -0
- packages/design-system/package.json +26 -0
.dockerignore
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.github
|
| 3 |
+
.env
|
| 4 |
+
.env.*
|
| 5 |
+
!.env.example
|
| 6 |
+
node_modules
|
| 7 |
+
**/dist
|
| 8 |
+
coverage
|
| 9 |
+
playwright-report
|
| 10 |
+
test-results
|
| 11 |
+
.data
|
| 12 |
+
.tmp
|
| 13 |
+
*.log
|
.env.example
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
NODE_ENV=development
|
| 2 |
+
PORT=7860
|
| 3 |
+
DATA_DIR=/data
|
| 4 |
+
DATABASE_URL=file:/data/database/oloka-dev.db
|
| 5 |
+
APP_VERSION=0.1.0
|
| 6 |
+
GIT_COMMIT_SHA=local
|
| 7 |
+
BUILD_TIMESTAMP=development
|
| 8 |
+
LOG_LEVEL=info
|
.github/workflows/deploy-hf.yml
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Deploy development Space
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [develop]
|
| 6 |
+
|
| 7 |
+
permissions:
|
| 8 |
+
contents: read
|
| 9 |
+
|
| 10 |
+
jobs:
|
| 11 |
+
validation:
|
| 12 |
+
uses: ./.github/workflows/validation.yml
|
| 13 |
+
|
| 14 |
+
deploy:
|
| 15 |
+
needs: validation
|
| 16 |
+
runs-on: ubuntu-latest
|
| 17 |
+
timeout-minutes: 15
|
| 18 |
+
steps:
|
| 19 |
+
- uses: actions/checkout@v4
|
| 20 |
+
with:
|
| 21 |
+
fetch-depth: 0
|
| 22 |
+
- name: Install Hugging Face Hub client
|
| 23 |
+
run: python -m pip install --disable-pip-version-check huggingface-hub==1.23.0
|
| 24 |
+
- name: Sync validated commit to Hugging Face
|
| 25 |
+
env:
|
| 26 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 27 |
+
run: |
|
| 28 |
+
test -n "$HF_TOKEN"
|
| 29 |
+
python - <<'PY'
|
| 30 |
+
import os
|
| 31 |
+
from huggingface_hub import HfApi
|
| 32 |
+
|
| 33 |
+
HfApi(token=os.environ["HF_TOKEN"]).upload_folder(
|
| 34 |
+
repo_id="phucsd/Oloka-Video-Dev",
|
| 35 |
+
repo_type="space",
|
| 36 |
+
folder_path=".",
|
| 37 |
+
ignore_patterns=[".git/**", ".git"],
|
| 38 |
+
delete_patterns="*",
|
| 39 |
+
commit_message=f"Deploy GitHub commit {os.environ['GITHUB_SHA']}",
|
| 40 |
+
)
|
| 41 |
+
PY
|
.github/workflows/validation.yml
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Validation
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
pull_request:
|
| 5 |
+
push:
|
| 6 |
+
branches: [main]
|
| 7 |
+
workflow_call:
|
| 8 |
+
|
| 9 |
+
permissions:
|
| 10 |
+
contents: read
|
| 11 |
+
|
| 12 |
+
jobs:
|
| 13 |
+
validate:
|
| 14 |
+
runs-on: ubuntu-latest
|
| 15 |
+
timeout-minutes: 25
|
| 16 |
+
steps:
|
| 17 |
+
- uses: actions/checkout@v4
|
| 18 |
+
- uses: actions/setup-node@v4
|
| 19 |
+
with:
|
| 20 |
+
node-version: 22
|
| 21 |
+
cache: npm
|
| 22 |
+
- run: npm ci
|
| 23 |
+
- run: npm run format:check
|
| 24 |
+
- run: npm run lint
|
| 25 |
+
- run: npm run typecheck
|
| 26 |
+
- run: npm run test:unit
|
| 27 |
+
- run: npm run test:integration
|
| 28 |
+
- run: npm run test:frontend
|
| 29 |
+
- run: npm run build
|
| 30 |
+
- run: npx playwright install --with-deps chromium
|
| 31 |
+
- run: npm run test:e2e
|
| 32 |
+
- name: Build production image
|
| 33 |
+
run: |
|
| 34 |
+
docker build \
|
| 35 |
+
--build-arg APP_VERSION=0.1.0 \
|
| 36 |
+
--build-arg GIT_COMMIT_SHA=${GITHUB_SHA} \
|
| 37 |
+
--build-arg BUILD_TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
| 38 |
+
-t oloka-video:${GITHUB_SHA} .
|
.gitignore
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules/
|
| 2 |
+
dist/
|
| 3 |
+
coverage/
|
| 4 |
+
.env
|
| 5 |
+
.env.*
|
| 6 |
+
!.env.example
|
| 7 |
+
.data/
|
| 8 |
+
.tmp/
|
| 9 |
+
playwright-report/
|
| 10 |
+
test-results/
|
| 11 |
+
*.log
|
| 12 |
+
.DS_Store
|
.prettierignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
dist
|
| 3 |
+
coverage
|
| 4 |
+
playwright-report
|
| 5 |
+
test-results
|
| 6 |
+
package-lock.json
|
Dockerfile
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1.7
|
| 2 |
+
FROM node:22-bookworm-slim AS dependencies
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
COPY package.json package-lock.json ./
|
| 5 |
+
COPY apps/server/package.json apps/server/package.json
|
| 6 |
+
COPY apps/web/package.json apps/web/package.json
|
| 7 |
+
COPY packages/contracts/package.json packages/contracts/package.json
|
| 8 |
+
COPY packages/design-system/package.json packages/design-system/package.json
|
| 9 |
+
RUN npm ci
|
| 10 |
+
|
| 11 |
+
FROM dependencies AS build
|
| 12 |
+
COPY . .
|
| 13 |
+
RUN npm run build
|
| 14 |
+
RUN npm prune --omit=dev
|
| 15 |
+
|
| 16 |
+
FROM node:22-bookworm-slim AS runtime
|
| 17 |
+
ARG APP_VERSION=0.1.0
|
| 18 |
+
ARG GIT_COMMIT_SHA=unknown
|
| 19 |
+
ARG BUILD_TIMESTAMP=unknown
|
| 20 |
+
ENV NODE_ENV=production \
|
| 21 |
+
PORT=7860 \
|
| 22 |
+
DATA_DIR=/data \
|
| 23 |
+
DATABASE_URL=file:/data/database/oloka-dev.db \
|
| 24 |
+
APP_VERSION=${APP_VERSION} \
|
| 25 |
+
GIT_COMMIT_SHA=${GIT_COMMIT_SHA} \
|
| 26 |
+
BUILD_TIMESTAMP=${BUILD_TIMESTAMP} \
|
| 27 |
+
LOG_LEVEL=info
|
| 28 |
+
WORKDIR /app
|
| 29 |
+
RUN mkdir -p /data/database && chown -R 1000:1000 /data /app
|
| 30 |
+
COPY --from=build --chown=1000:1000 /app/package.json /app/package-lock.json ./
|
| 31 |
+
COPY --from=build --chown=1000:1000 /app/node_modules ./node_modules
|
| 32 |
+
COPY --from=build --chown=1000:1000 /app/apps/server/dist ./apps/server/dist
|
| 33 |
+
COPY --from=build --chown=1000:1000 /app/apps/web/dist ./apps/web/dist
|
| 34 |
+
COPY --from=build --chown=1000:1000 /app/packages/contracts/package.json ./packages/contracts/package.json
|
| 35 |
+
COPY --from=build --chown=1000:1000 /app/packages/contracts/dist ./packages/contracts/dist
|
| 36 |
+
USER 1000:1000
|
| 37 |
+
EXPOSE 7860
|
| 38 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
| 39 |
+
CMD ["node", "-e", "fetch('http://127.0.0.1:7860/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
| 40 |
+
CMD ["node", "apps/server/dist/index.js"]
|
README.md
CHANGED
|
@@ -1,10 +1,97 @@
|
|
| 1 |
---
|
| 2 |
-
title: Oloka Video
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Oloka Video
|
| 3 |
+
emoji: 🎬
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: gray
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# Oloka Video
|
| 12 |
+
|
| 13 |
+
Oloka Video is a greenfield TypeScript modular monolith. This foundation hosts a React frontend and a Fastify API in one Docker image on port `7860`, with explicit database, storage, contract, and design-system boundaries.
|
| 14 |
+
|
| 15 |
+
## Repository layout
|
| 16 |
+
|
| 17 |
+
- `apps/web` — React status experience built by Vite.
|
| 18 |
+
- `apps/server` — Fastify API and production static-file host.
|
| 19 |
+
- `packages/contracts` — shared Zod API schemas and TypeScript types.
|
| 20 |
+
- `packages/design-system` — design tokens and reusable UI primitives.
|
| 21 |
+
- `docs/architecture` — architecture and operations guides.
|
| 22 |
+
- `docs/decisions` — architecture decision records.
|
| 23 |
+
|
| 24 |
+
## Run locally
|
| 25 |
+
|
| 26 |
+
Requirements: Node.js 22+, npm 10+, and a writable data directory.
|
| 27 |
+
|
| 28 |
+
```bash
|
| 29 |
+
npm ci
|
| 30 |
+
npm run dev
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
The API listens on `http://localhost:7860`; Vite serves the local frontend on `http://localhost:5173` and proxies `/api` to the backend. On Windows, override the Linux online defaults:
|
| 34 |
+
|
| 35 |
+
```powershell
|
| 36 |
+
$env:DATA_DIR = "$PWD\.data"
|
| 37 |
+
$env:DATABASE_URL = "file:$($PWD.Path.Replace('\','/'))/.data/database/oloka-dev.db"
|
| 38 |
+
npm run dev
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
Run all local validation:
|
| 42 |
+
|
| 43 |
+
```bash
|
| 44 |
+
npm run format:check
|
| 45 |
+
npm run lint
|
| 46 |
+
npm run typecheck
|
| 47 |
+
npm test
|
| 48 |
+
npm run build
|
| 49 |
+
npm run test:e2e
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
## Build and run Docker
|
| 53 |
+
|
| 54 |
+
```bash
|
| 55 |
+
docker build \
|
| 56 |
+
--build-arg APP_VERSION=0.1.0 \
|
| 57 |
+
--build-arg GIT_COMMIT_SHA=$(git rev-parse HEAD) \
|
| 58 |
+
--build-arg BUILD_TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
| 59 |
+
-t oloka-video:dev .
|
| 60 |
+
|
| 61 |
+
docker run --rm -p 7860:7860 -v oloka-video-data:/data oloka-video:dev
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
The named volume is mounted at `/data`; the development SQLite database is created at `/data/database/oloka-dev.db`. The process runs as UID `1000`.
|
| 65 |
+
|
| 66 |
+
## Verify the service
|
| 67 |
+
|
| 68 |
+
```bash
|
| 69 |
+
curl http://localhost:7860/api/health
|
| 70 |
+
curl http://localhost:7860/api/ready
|
| 71 |
+
curl http://localhost:7860/api/version
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
`health` only confirms that the application process responds. `ready` performs live database and filesystem read/write checks. `version` exposes immutable build identity.
|
| 75 |
+
|
| 76 |
+
## Configure Hugging Face
|
| 77 |
+
|
| 78 |
+
1. Create the Docker Space `phucsd/Oloka-Video-Dev` and select Docker SDK.
|
| 79 |
+
2. Configure persistent storage so Hugging Face mounts it at `/data`.
|
| 80 |
+
3. In the GitHub repository, open **Settings → Secrets and variables → Actions** and add a repository secret named `HF_TOKEN`. Use a Hugging Face token with write access to the target Space.
|
| 81 |
+
4. Push a validated change to `develop`. `.github/workflows/deploy-hf.yml` runs the full validation workflow and only pushes to the Space after it succeeds.
|
| 82 |
+
5. Open the Space **Logs** tab to view Docker build logs and structured runtime logs. The public application is available at `https://phucsd-oloka-video-dev.hf.space` after the Space reaches `RUNNING`.
|
| 83 |
+
|
| 84 |
+
No token belongs in `.env`, Git history, Docker build arguments, or the Space repository.
|
| 85 |
+
|
| 86 |
+
## Roll back a deployment
|
| 87 |
+
|
| 88 |
+
Find the last known-good commit in GitHub Actions or `GET /api/version`, then create a revert commit on `develop` and push it. The same validation gate redeploys that source state:
|
| 89 |
+
|
| 90 |
+
```bash
|
| 91 |
+
git revert <bad-commit-sha>
|
| 92 |
+
git push origin develop
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
Avoid force-resetting shared GitHub history. If the Space itself is unhealthy, pause it while investigating and restart after a validated rollback is available.
|
| 96 |
+
|
| 97 |
+
See [foundation architecture](docs/architecture/FOUNDATION.md), [deployment](docs/architecture/DEPLOYMENT.md), and [local development](docs/architecture/LOCAL_DEVELOPMENT.md) for deeper operational details.
|
apps/server/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "@oloka/server",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"type": "module",
|
| 6 |
+
"main": "dist/index.js",
|
| 7 |
+
"scripts": {
|
| 8 |
+
"predev": "npm run build -w @oloka/contracts",
|
| 9 |
+
"build": "tsc -p tsconfig.build.json",
|
| 10 |
+
"dev": "tsx watch src/index.ts",
|
| 11 |
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
| 12 |
+
"test": "vitest run",
|
| 13 |
+
"pretest": "npm run build -w @oloka/contracts",
|
| 14 |
+
"test:unit": "npm run build -w @oloka/contracts && vitest run --exclude **/*.integration.test.ts",
|
| 15 |
+
"test:integration": "npm run build -w @oloka/contracts && vitest run src/http/routes.integration.test.ts"
|
| 16 |
+
},
|
| 17 |
+
"dependencies": {
|
| 18 |
+
"@fastify/static": "10.1.2",
|
| 19 |
+
"@oloka/contracts": "0.1.0",
|
| 20 |
+
"fastify": "5.11.2",
|
| 21 |
+
"zod": "4.0.17"
|
| 22 |
+
},
|
| 23 |
+
"devDependencies": {
|
| 24 |
+
"@types/node": "24.2.1",
|
| 25 |
+
"tsx": "4.20.3",
|
| 26 |
+
"vitest": "3.2.7"
|
| 27 |
+
}
|
| 28 |
+
}
|
apps/server/src/app.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fastifyStatic from "@fastify/static";
|
| 2 |
+
import {
|
| 3 |
+
healthResponseSchema,
|
| 4 |
+
readyResponseSchema,
|
| 5 |
+
versionResponseSchema,
|
| 6 |
+
} from "@oloka/contracts";
|
| 7 |
+
import Fastify, { type FastifyInstance } from "fastify";
|
| 8 |
+
import { resolve } from "node:path";
|
| 9 |
+
import type { AppEnvironment } from "./config/environment.js";
|
| 10 |
+
import { createDatabase } from "./database/create-database.js";
|
| 11 |
+
import { FilesystemObjectStorage } from "./storage/filesystem-object-storage.js";
|
| 12 |
+
import { HealthService } from "./system/health-service.js";
|
| 13 |
+
import { ReadinessService } from "./system/readiness-service.js";
|
| 14 |
+
import { VersionService } from "./system/version-service.js";
|
| 15 |
+
|
| 16 |
+
export interface BuildApplicationOptions {
|
| 17 |
+
environment: AppEnvironment;
|
| 18 |
+
serveFrontend?: boolean;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export async function buildApplication(
|
| 22 |
+
options: BuildApplicationOptions,
|
| 23 |
+
): Promise<FastifyInstance> {
|
| 24 |
+
const { environment } = options;
|
| 25 |
+
const app = Fastify({
|
| 26 |
+
logger:
|
| 27 |
+
environment.logLevel === "silent"
|
| 28 |
+
? false
|
| 29 |
+
: { level: environment.logLevel },
|
| 30 |
+
});
|
| 31 |
+
const database = await createDatabase(environment.databaseUrl);
|
| 32 |
+
database.migrate();
|
| 33 |
+
const storage = new FilesystemObjectStorage(environment.dataDir);
|
| 34 |
+
const initialStorageState = await storage.checkReadiness();
|
| 35 |
+
if (initialStorageState.status !== "ready") {
|
| 36 |
+
await database.close();
|
| 37 |
+
throw new Error(
|
| 38 |
+
`Persistent storage startup check failed: ${initialStorageState.message ?? "unknown error"}`,
|
| 39 |
+
);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
app.get("/api/health", () =>
|
| 43 |
+
healthResponseSchema.parse(new HealthService().getHealth()),
|
| 44 |
+
);
|
| 45 |
+
const readinessService = new ReadinessService(database, storage);
|
| 46 |
+
app.get("/api/ready", async (_request, reply) => {
|
| 47 |
+
const result = readyResponseSchema.parse(
|
| 48 |
+
await readinessService.getReadiness(),
|
| 49 |
+
);
|
| 50 |
+
return reply.code(result.status === "ready" ? 200 : 503).send(result);
|
| 51 |
+
});
|
| 52 |
+
app.get("/api/version", () =>
|
| 53 |
+
versionResponseSchema.parse(new VersionService(environment).getVersion()),
|
| 54 |
+
);
|
| 55 |
+
|
| 56 |
+
if (options.serveFrontend !== false) {
|
| 57 |
+
const webRoot = resolve(process.cwd(), "apps/web/dist");
|
| 58 |
+
await app.register(fastifyStatic, { root: webRoot, wildcard: false });
|
| 59 |
+
app.setNotFoundHandler(async (request, reply) => {
|
| 60 |
+
if (request.url.startsWith("/api/"))
|
| 61 |
+
return reply.code(404).send({ error: "Not Found" });
|
| 62 |
+
return reply.sendFile("index.html");
|
| 63 |
+
});
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
app.addHook("onClose", async () => {
|
| 67 |
+
await Promise.all([storage.close(), database.close()]);
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
return app;
|
| 71 |
+
}
|
apps/server/src/config/environment.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { z } from "zod";
|
| 2 |
+
|
| 3 |
+
const environmentSchema = z.object({
|
| 4 |
+
NODE_ENV: z
|
| 5 |
+
.enum(["development", "test", "production"])
|
| 6 |
+
.default("development"),
|
| 7 |
+
PORT: z.coerce.number().int().min(1).max(65_535).default(7860),
|
| 8 |
+
DATA_DIR: z.string().min(1).default("/data"),
|
| 9 |
+
DATABASE_URL: z.string().min(1).optional(),
|
| 10 |
+
APP_VERSION: z
|
| 11 |
+
.string()
|
| 12 |
+
.regex(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/)
|
| 13 |
+
.default("0.1.0"),
|
| 14 |
+
GIT_COMMIT_SHA: z.string().min(1).default("local"),
|
| 15 |
+
BUILD_TIMESTAMP: z.string().min(1).default("development"),
|
| 16 |
+
LOG_LEVEL: z
|
| 17 |
+
.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"])
|
| 18 |
+
.default("info"),
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
export interface AppEnvironment {
|
| 22 |
+
nodeEnv: "development" | "test" | "production";
|
| 23 |
+
port: number;
|
| 24 |
+
dataDir: string;
|
| 25 |
+
databaseUrl: string;
|
| 26 |
+
appVersion: string;
|
| 27 |
+
gitCommitSha: string;
|
| 28 |
+
buildTimestamp: string;
|
| 29 |
+
logLevel: "fatal" | "error" | "warn" | "info" | "debug" | "trace" | "silent";
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export function parseEnvironment(input: NodeJS.ProcessEnv): AppEnvironment {
|
| 33 |
+
const parsed = environmentSchema.parse(input);
|
| 34 |
+
const databaseUrl =
|
| 35 |
+
parsed.DATABASE_URL ?? `file:${parsed.DATA_DIR}/database/oloka-dev.db`;
|
| 36 |
+
|
| 37 |
+
if (
|
| 38 |
+
!databaseUrl.startsWith("file:") &&
|
| 39 |
+
!databaseUrl.startsWith("postgresql://")
|
| 40 |
+
) {
|
| 41 |
+
throw new Error("DATABASE_URL must use file: or postgresql://");
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
return {
|
| 45 |
+
nodeEnv: parsed.NODE_ENV,
|
| 46 |
+
port: parsed.PORT,
|
| 47 |
+
dataDir: parsed.DATA_DIR,
|
| 48 |
+
databaseUrl,
|
| 49 |
+
appVersion: parsed.APP_VERSION,
|
| 50 |
+
gitCommitSha: parsed.GIT_COMMIT_SHA,
|
| 51 |
+
buildTimestamp: parsed.BUILD_TIMESTAMP,
|
| 52 |
+
logLevel: parsed.LOG_LEVEL,
|
| 53 |
+
};
|
| 54 |
+
}
|
apps/server/src/config/environment.unit.test.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "vitest";
|
| 2 |
+
import { parseEnvironment } from "./environment.js";
|
| 3 |
+
|
| 4 |
+
describe("parseEnvironment", () => {
|
| 5 |
+
it("rejects an invalid public port", () => {
|
| 6 |
+
expect(() => parseEnvironment({ PORT: "70000" })).toThrow(/PORT/);
|
| 7 |
+
});
|
| 8 |
+
});
|
apps/server/src/database/create-database.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { SystemDatabase } from "./database.js";
|
| 2 |
+
import { SqliteSystemDatabase } from "./sqlite-system-database.js";
|
| 3 |
+
|
| 4 |
+
export async function createDatabase(
|
| 5 |
+
databaseUrl: string,
|
| 6 |
+
): Promise<SystemDatabase> {
|
| 7 |
+
if (databaseUrl.startsWith("file:")) {
|
| 8 |
+
return SqliteSystemDatabase.connect(databaseUrl);
|
| 9 |
+
}
|
| 10 |
+
if (databaseUrl.startsWith("postgresql://")) {
|
| 11 |
+
throw new Error(
|
| 12 |
+
"PostgreSQL adapter is not enabled in the online development foundation",
|
| 13 |
+
);
|
| 14 |
+
}
|
| 15 |
+
throw new Error("Unsupported DATABASE_URL");
|
| 16 |
+
}
|
apps/server/src/database/database.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ReadyResponse } from "@oloka/contracts";
|
| 2 |
+
|
| 3 |
+
export type DatabaseReadiness = ReadyResponse["checks"]["database"];
|
| 4 |
+
|
| 5 |
+
export interface SystemDatabase {
|
| 6 |
+
migrate(): void;
|
| 7 |
+
checkReadiness(): Promise<DatabaseReadiness>;
|
| 8 |
+
close(): Promise<void>;
|
| 9 |
+
}
|
apps/server/src/database/sqlite-system-database.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { mkdir } from "node:fs/promises";
|
| 2 |
+
import { dirname } from "node:path";
|
| 3 |
+
import { DatabaseSync } from "node:sqlite";
|
| 4 |
+
import { fileURLToPath } from "node:url";
|
| 5 |
+
import type { DatabaseReadiness, SystemDatabase } from "./database.js";
|
| 6 |
+
|
| 7 |
+
export class SqliteSystemDatabase implements SystemDatabase {
|
| 8 |
+
private constructor(private readonly database: DatabaseSync) {}
|
| 9 |
+
|
| 10 |
+
static async connect(databaseUrl: string): Promise<SqliteSystemDatabase> {
|
| 11 |
+
if (!databaseUrl.startsWith("file:")) {
|
| 12 |
+
throw new Error("The SQLite adapter requires a file: DATABASE_URL");
|
| 13 |
+
}
|
| 14 |
+
const databasePath = fileURLToPath(databaseUrl);
|
| 15 |
+
await mkdir(dirname(databasePath), { recursive: true });
|
| 16 |
+
return new SqliteSystemDatabase(new DatabaseSync(databasePath));
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
migrate(): void {
|
| 20 |
+
this.database.exec(`
|
| 21 |
+
PRAGMA journal_mode = WAL;
|
| 22 |
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
| 23 |
+
version INTEGER PRIMARY KEY,
|
| 24 |
+
name TEXT NOT NULL,
|
| 25 |
+
applied_at TEXT NOT NULL
|
| 26 |
+
);
|
| 27 |
+
CREATE TABLE IF NOT EXISTS system_metadata (
|
| 28 |
+
key TEXT PRIMARY KEY,
|
| 29 |
+
value TEXT NOT NULL,
|
| 30 |
+
updated_at TEXT NOT NULL
|
| 31 |
+
);
|
| 32 |
+
INSERT OR IGNORE INTO schema_migrations (version, name, applied_at)
|
| 33 |
+
VALUES (1, 'foundation_system_tables', datetime('now'));
|
| 34 |
+
`);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
checkReadiness(): Promise<DatabaseReadiness> {
|
| 38 |
+
try {
|
| 39 |
+
const result = this.database.prepare("SELECT 1 AS healthy").get() as
|
| 40 |
+
| { healthy?: number }
|
| 41 |
+
| undefined;
|
| 42 |
+
return Promise.resolve(
|
| 43 |
+
result?.healthy === 1
|
| 44 |
+
? { status: "ready" }
|
| 45 |
+
: {
|
| 46 |
+
status: "not_ready",
|
| 47 |
+
message: "Database health query returned an unexpected result",
|
| 48 |
+
},
|
| 49 |
+
);
|
| 50 |
+
} catch (error) {
|
| 51 |
+
return Promise.resolve({
|
| 52 |
+
status: "not_ready",
|
| 53 |
+
message:
|
| 54 |
+
error instanceof Error ? error.message : "Unknown database error",
|
| 55 |
+
});
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
close(): Promise<void> {
|
| 60 |
+
this.database.close();
|
| 61 |
+
return Promise.resolve();
|
| 62 |
+
}
|
| 63 |
+
}
|
apps/server/src/http/routes.integration.test.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { mkdtemp, rm } from "node:fs/promises";
|
| 2 |
+
import { tmpdir } from "node:os";
|
| 3 |
+
import { join } from "node:path";
|
| 4 |
+
import { pathToFileURL } from "node:url";
|
| 5 |
+
import { afterEach, describe, expect, it } from "vitest";
|
| 6 |
+
import { buildApplication } from "../app.js";
|
| 7 |
+
import { parseEnvironment } from "../config/environment.js";
|
| 8 |
+
|
| 9 |
+
const temporaryDirectories: string[] = [];
|
| 10 |
+
|
| 11 |
+
afterEach(async () => {
|
| 12 |
+
await Promise.all(
|
| 13 |
+
temporaryDirectories
|
| 14 |
+
.splice(0)
|
| 15 |
+
.map((path) => rm(path, { recursive: true, force: true })),
|
| 16 |
+
);
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
async function createTestApplication() {
|
| 20 |
+
const dataDir = await mkdtemp(join(tmpdir(), "oloka-api-"));
|
| 21 |
+
temporaryDirectories.push(dataDir);
|
| 22 |
+
return buildApplication({
|
| 23 |
+
environment: parseEnvironment({
|
| 24 |
+
NODE_ENV: "test",
|
| 25 |
+
DATA_DIR: dataDir,
|
| 26 |
+
DATABASE_URL: pathToFileURL(join(dataDir, "database", "test.db")).href,
|
| 27 |
+
APP_VERSION: "1.2.3",
|
| 28 |
+
GIT_COMMIT_SHA: "abc123",
|
| 29 |
+
BUILD_TIMESTAMP: "2026-08-05T01:00:00.000Z",
|
| 30 |
+
LOG_LEVEL: "silent",
|
| 31 |
+
}),
|
| 32 |
+
serveFrontend: false,
|
| 33 |
+
});
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
describe("system API", () => {
|
| 37 |
+
it("GET /api/health confirms the process is running", async () => {
|
| 38 |
+
const app = await createTestApplication();
|
| 39 |
+
const response = await app.inject({ method: "GET", url: "/api/health" });
|
| 40 |
+
|
| 41 |
+
expect(response.statusCode).toBe(200);
|
| 42 |
+
expect(response.json()).toMatchObject({ status: "ok" });
|
| 43 |
+
|
| 44 |
+
await app.close();
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
it("GET /api/ready checks the database, storage, and configuration", async () => {
|
| 48 |
+
const app = await createTestApplication();
|
| 49 |
+
const response = await app.inject({ method: "GET", url: "/api/ready" });
|
| 50 |
+
|
| 51 |
+
expect(response.statusCode).toBe(200);
|
| 52 |
+
expect(response.json()).toEqual({
|
| 53 |
+
status: "ready",
|
| 54 |
+
checks: {
|
| 55 |
+
database: { status: "ready" },
|
| 56 |
+
storage: { status: "ready" },
|
| 57 |
+
configuration: { status: "ready" },
|
| 58 |
+
},
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
await app.close();
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
it("GET /api/version returns immutable build metadata", async () => {
|
| 65 |
+
const app = await createTestApplication();
|
| 66 |
+
const response = await app.inject({ method: "GET", url: "/api/version" });
|
| 67 |
+
|
| 68 |
+
expect(response.statusCode).toBe(200);
|
| 69 |
+
expect(response.json()).toEqual({
|
| 70 |
+
name: "Oloka Video",
|
| 71 |
+
version: "1.2.3",
|
| 72 |
+
environment: "test",
|
| 73 |
+
gitCommitSha: "abc123",
|
| 74 |
+
buildTimestamp: "2026-08-05T01:00:00.000Z",
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
await app.close();
|
| 78 |
+
});
|
| 79 |
+
});
|
apps/server/src/index.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { buildApplication } from "./app.js";
|
| 2 |
+
import { parseEnvironment } from "./config/environment.js";
|
| 3 |
+
|
| 4 |
+
async function start(): Promise<void> {
|
| 5 |
+
const environment = parseEnvironment(process.env);
|
| 6 |
+
const app = await buildApplication({ environment });
|
| 7 |
+
let shuttingDown = false;
|
| 8 |
+
|
| 9 |
+
const shutdown = async (signal: NodeJS.Signals) => {
|
| 10 |
+
if (shuttingDown) return;
|
| 11 |
+
shuttingDown = true;
|
| 12 |
+
app.log.info({ signal }, "graceful shutdown started");
|
| 13 |
+
await app.close();
|
| 14 |
+
process.exitCode = 0;
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
process.once("SIGTERM", () => void shutdown("SIGTERM"));
|
| 18 |
+
process.once("SIGINT", () => void shutdown("SIGINT"));
|
| 19 |
+
|
| 20 |
+
await app.listen({ host: "0.0.0.0", port: environment.port });
|
| 21 |
+
app.log.info(
|
| 22 |
+
{
|
| 23 |
+
port: environment.port,
|
| 24 |
+
environment: environment.nodeEnv,
|
| 25 |
+
version: environment.appVersion,
|
| 26 |
+
gitCommitSha: environment.gitCommitSha,
|
| 27 |
+
},
|
| 28 |
+
"Oloka Video is listening",
|
| 29 |
+
);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
start().catch((error: unknown) => {
|
| 33 |
+
process.stderr.write(
|
| 34 |
+
`${JSON.stringify({ level: "fatal", message: "startup failed", error: error instanceof Error ? error.message : String(error) })}\n`,
|
| 35 |
+
);
|
| 36 |
+
process.exitCode = 1;
|
| 37 |
+
});
|
apps/server/src/storage/filesystem-object-storage.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { mkdir, open, unlink } from "node:fs/promises";
|
| 2 |
+
import { join } from "node:path";
|
| 3 |
+
import type { ObjectStorage, ReadinessResult } from "./object-storage.js";
|
| 4 |
+
|
| 5 |
+
export class FilesystemObjectStorage implements ObjectStorage {
|
| 6 |
+
readonly #probePath: string;
|
| 7 |
+
#readinessQueue: Promise<ReadinessResult> = Promise.resolve({
|
| 8 |
+
status: "ready",
|
| 9 |
+
});
|
| 10 |
+
|
| 11 |
+
constructor(private readonly rootPath: string) {
|
| 12 |
+
this.#probePath = join(rootPath, ".oloka-readiness");
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
checkReadiness(): Promise<ReadinessResult> {
|
| 16 |
+
this.#readinessQueue = this.#readinessQueue.then(
|
| 17 |
+
() => this.runReadinessCheck(),
|
| 18 |
+
() => this.runReadinessCheck(),
|
| 19 |
+
);
|
| 20 |
+
return this.#readinessQueue;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
async close(): Promise<void> {
|
| 24 |
+
await this.#readinessQueue.catch(() => undefined);
|
| 25 |
+
await unlink(this.#probePath).catch((error: NodeJS.ErrnoException) => {
|
| 26 |
+
if (error.code !== "ENOENT") throw error;
|
| 27 |
+
});
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
private async runReadinessCheck(): Promise<ReadinessResult> {
|
| 31 |
+
try {
|
| 32 |
+
await mkdir(this.rootPath, { recursive: true });
|
| 33 |
+
const handle = await open(this.#probePath, "w+");
|
| 34 |
+
try {
|
| 35 |
+
const marker = Buffer.from("1");
|
| 36 |
+
await handle.write(marker, 0, marker.length, 0);
|
| 37 |
+
await handle.sync();
|
| 38 |
+
const verification = Buffer.alloc(1);
|
| 39 |
+
const result = await handle.read(verification, 0, 1, 0);
|
| 40 |
+
if (result.bytesRead !== 1 || verification.toString() !== "1") {
|
| 41 |
+
throw new Error("Storage write verification failed");
|
| 42 |
+
}
|
| 43 |
+
} finally {
|
| 44 |
+
await handle.close();
|
| 45 |
+
}
|
| 46 |
+
return { status: "ready" };
|
| 47 |
+
} catch (error) {
|
| 48 |
+
return {
|
| 49 |
+
status: "not_ready",
|
| 50 |
+
message:
|
| 51 |
+
error instanceof Error ? error.message : "Unknown storage error",
|
| 52 |
+
};
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
}
|
apps/server/src/storage/filesystem-object-storage.unit.test.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
| 2 |
+
import { tmpdir } from "node:os";
|
| 3 |
+
import { join } from "node:path";
|
| 4 |
+
import { afterEach, describe, expect, it } from "vitest";
|
| 5 |
+
import { FilesystemObjectStorage } from "./filesystem-object-storage.js";
|
| 6 |
+
|
| 7 |
+
const temporaryDirectories: string[] = [];
|
| 8 |
+
|
| 9 |
+
afterEach(async () => {
|
| 10 |
+
await Promise.all(
|
| 11 |
+
temporaryDirectories
|
| 12 |
+
.splice(0)
|
| 13 |
+
.map((path) => rm(path, { recursive: true, force: true })),
|
| 14 |
+
);
|
| 15 |
+
});
|
| 16 |
+
|
| 17 |
+
describe("FilesystemObjectStorage", () => {
|
| 18 |
+
it("proves the configured storage is writable and cleans up its fixed probe", async () => {
|
| 19 |
+
const root = await mkdtemp(join(tmpdir(), "oloka-storage-"));
|
| 20 |
+
temporaryDirectories.push(root);
|
| 21 |
+
const storage = new FilesystemObjectStorage(root);
|
| 22 |
+
|
| 23 |
+
await expect(storage.checkReadiness()).resolves.toEqual({
|
| 24 |
+
status: "ready",
|
| 25 |
+
});
|
| 26 |
+
await storage.close();
|
| 27 |
+
|
| 28 |
+
expect(await readdir(root)).toEqual([]);
|
| 29 |
+
});
|
| 30 |
+
});
|
apps/server/src/storage/object-storage.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ReadyResponse } from "@oloka/contracts";
|
| 2 |
+
|
| 3 |
+
export type ReadinessResult = ReadyResponse["checks"]["storage"];
|
| 4 |
+
|
| 5 |
+
export interface ObjectStorage {
|
| 6 |
+
checkReadiness(): Promise<ReadinessResult>;
|
| 7 |
+
close(): Promise<void>;
|
| 8 |
+
}
|
apps/server/src/system/health-service.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { HealthResponse } from "@oloka/contracts";
|
| 2 |
+
|
| 3 |
+
export class HealthService {
|
| 4 |
+
getHealth(): HealthResponse {
|
| 5 |
+
return {
|
| 6 |
+
status: "ok",
|
| 7 |
+
timestamp: new Date().toISOString(),
|
| 8 |
+
};
|
| 9 |
+
}
|
| 10 |
+
}
|
apps/server/src/system/health-service.unit.test.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it, vi } from "vitest";
|
| 2 |
+
import { HealthService } from "./health-service.js";
|
| 3 |
+
|
| 4 |
+
describe("HealthService", () => {
|
| 5 |
+
it("reports process health with the current timestamp", () => {
|
| 6 |
+
vi.useFakeTimers();
|
| 7 |
+
vi.setSystemTime(new Date("2026-08-05T01:00:00.000Z"));
|
| 8 |
+
|
| 9 |
+
expect(new HealthService().getHealth()).toEqual({
|
| 10 |
+
status: "ok",
|
| 11 |
+
timestamp: "2026-08-05T01:00:00.000Z",
|
| 12 |
+
});
|
| 13 |
+
|
| 14 |
+
vi.useRealTimers();
|
| 15 |
+
});
|
| 16 |
+
});
|
apps/server/src/system/readiness-service.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ReadyResponse } from "@oloka/contracts";
|
| 2 |
+
import type { SystemDatabase } from "../database/database.js";
|
| 3 |
+
import type { ObjectStorage } from "../storage/object-storage.js";
|
| 4 |
+
|
| 5 |
+
export class ReadinessService {
|
| 6 |
+
constructor(
|
| 7 |
+
private readonly database: SystemDatabase,
|
| 8 |
+
private readonly storage: ObjectStorage,
|
| 9 |
+
) {}
|
| 10 |
+
|
| 11 |
+
async getReadiness(): Promise<ReadyResponse> {
|
| 12 |
+
const [database, storage] = await Promise.all([
|
| 13 |
+
this.database.checkReadiness(),
|
| 14 |
+
this.storage.checkReadiness(),
|
| 15 |
+
]);
|
| 16 |
+
const configuration = { status: "ready" as const };
|
| 17 |
+
const status =
|
| 18 |
+
database.status === "ready" && storage.status === "ready"
|
| 19 |
+
? "ready"
|
| 20 |
+
: "not_ready";
|
| 21 |
+
|
| 22 |
+
return { status, checks: { database, storage, configuration } };
|
| 23 |
+
}
|
| 24 |
+
}
|
apps/server/src/system/version-service.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { VersionResponse } from "@oloka/contracts";
|
| 2 |
+
import type { AppEnvironment } from "../config/environment.js";
|
| 3 |
+
|
| 4 |
+
export class VersionService {
|
| 5 |
+
constructor(private readonly environment: AppEnvironment) {}
|
| 6 |
+
|
| 7 |
+
getVersion(): VersionResponse {
|
| 8 |
+
return {
|
| 9 |
+
name: "Oloka Video",
|
| 10 |
+
version: this.environment.appVersion,
|
| 11 |
+
environment: this.environment.nodeEnv,
|
| 12 |
+
gitCommitSha: this.environment.gitCommitSha,
|
| 13 |
+
buildTimestamp: this.environment.buildTimestamp,
|
| 14 |
+
};
|
| 15 |
+
}
|
| 16 |
+
}
|
apps/server/tsconfig.build.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"extends": "./tsconfig.json",
|
| 3 |
+
"compilerOptions": {
|
| 4 |
+
"outDir": "dist",
|
| 5 |
+
"rootDir": "src",
|
| 6 |
+
"sourceMap": true
|
| 7 |
+
},
|
| 8 |
+
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
| 9 |
+
}
|
apps/server/tsconfig.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"extends": "../../tsconfig.base.json",
|
| 3 |
+
"compilerOptions": {
|
| 4 |
+
"types": ["node", "vitest/globals"]
|
| 5 |
+
},
|
| 6 |
+
"include": ["src"]
|
| 7 |
+
}
|
apps/web/index.html
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<meta name="theme-color" content="#f4f5ef" />
|
| 7 |
+
<meta name="description" content="Oloka Video online foundation status." />
|
| 8 |
+
<title>Oloka Video</title>
|
| 9 |
+
</head>
|
| 10 |
+
<body>
|
| 11 |
+
<div id="root"></div>
|
| 12 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 13 |
+
</body>
|
| 14 |
+
</html>
|
apps/web/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "@oloka/web",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"predev": "npm run build -w @oloka/contracts && npm run build -w @oloka/design-system",
|
| 8 |
+
"dev": "vite --host 0.0.0.0 --port 5173",
|
| 9 |
+
"build": "vite build",
|
| 10 |
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
| 11 |
+
"pretest": "npm run build -w @oloka/contracts && npm run build -w @oloka/design-system",
|
| 12 |
+
"test": "vitest run"
|
| 13 |
+
},
|
| 14 |
+
"dependencies": {
|
| 15 |
+
"@oloka/contracts": "0.1.0",
|
| 16 |
+
"@oloka/design-system": "0.1.0",
|
| 17 |
+
"react": "19.1.1",
|
| 18 |
+
"react-dom": "19.1.1"
|
| 19 |
+
},
|
| 20 |
+
"devDependencies": {
|
| 21 |
+
"@testing-library/jest-dom": "6.6.4",
|
| 22 |
+
"@testing-library/react": "16.3.0",
|
| 23 |
+
"@types/react": "19.1.10",
|
| 24 |
+
"@types/react-dom": "19.1.7",
|
| 25 |
+
"@vitejs/plugin-react": "4.7.0",
|
| 26 |
+
"jsdom": "26.1.0",
|
| 27 |
+
"vite": "7.3.6",
|
| 28 |
+
"vitest": "3.2.7"
|
| 29 |
+
}
|
| 30 |
+
}
|
apps/web/src/App.test.tsx
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { render, screen } from "@testing-library/react";
|
| 2 |
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
| 3 |
+
import { App } from "./App";
|
| 4 |
+
|
| 5 |
+
describe("App", () => {
|
| 6 |
+
beforeEach(() => vi.restoreAllMocks());
|
| 7 |
+
|
| 8 |
+
it("renders connected state from the real API responses", async () => {
|
| 9 |
+
vi.spyOn(globalThis, "fetch")
|
| 10 |
+
.mockResolvedValueOnce(
|
| 11 |
+
new Response(
|
| 12 |
+
JSON.stringify({ status: "ok", timestamp: "2026-08-05T00:00:00Z" }),
|
| 13 |
+
),
|
| 14 |
+
)
|
| 15 |
+
.mockResolvedValueOnce(
|
| 16 |
+
new Response(
|
| 17 |
+
JSON.stringify({
|
| 18 |
+
status: "ready",
|
| 19 |
+
checks: {
|
| 20 |
+
database: { status: "ready" },
|
| 21 |
+
storage: { status: "ready" },
|
| 22 |
+
configuration: { status: "ready" },
|
| 23 |
+
},
|
| 24 |
+
}),
|
| 25 |
+
),
|
| 26 |
+
)
|
| 27 |
+
.mockResolvedValueOnce(
|
| 28 |
+
new Response(
|
| 29 |
+
JSON.stringify({
|
| 30 |
+
name: "Oloka Video",
|
| 31 |
+
version: "1.2.3",
|
| 32 |
+
environment: "development",
|
| 33 |
+
gitCommitSha: "abc123",
|
| 34 |
+
buildTimestamp: "2026-08-05T00:00:00Z",
|
| 35 |
+
}),
|
| 36 |
+
),
|
| 37 |
+
);
|
| 38 |
+
|
| 39 |
+
render(<App />);
|
| 40 |
+
|
| 41 |
+
expect(await screen.findByText("Connected")).toBeInTheDocument();
|
| 42 |
+
expect(screen.getByText("1.2.3")).toBeInTheDocument();
|
| 43 |
+
expect(screen.getByText("abc123")).toBeInTheDocument();
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
it("renders an error state when an API request fails", async () => {
|
| 47 |
+
vi.spyOn(globalThis, "fetch").mockRejectedValue(
|
| 48 |
+
new Error("Network unavailable"),
|
| 49 |
+
);
|
| 50 |
+
|
| 51 |
+
render(<App />);
|
| 52 |
+
|
| 53 |
+
expect(await screen.findByText("Connection error")).toBeInTheDocument();
|
| 54 |
+
expect(screen.getByText("Network unavailable")).toBeInTheDocument();
|
| 55 |
+
});
|
| 56 |
+
});
|
apps/web/src/App.tsx
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
healthResponseSchema,
|
| 3 |
+
readyResponseSchema,
|
| 4 |
+
versionResponseSchema,
|
| 5 |
+
type ReadyResponse,
|
| 6 |
+
type VersionResponse,
|
| 7 |
+
} from "@oloka/contracts";
|
| 8 |
+
import {
|
| 9 |
+
DefinitionRow,
|
| 10 |
+
StatusPill,
|
| 11 |
+
Surface,
|
| 12 |
+
type StatusTone,
|
| 13 |
+
} from "@oloka/design-system";
|
| 14 |
+
import { useEffect, useState } from "react";
|
| 15 |
+
|
| 16 |
+
type DashboardState =
|
| 17 |
+
| { kind: "loading" }
|
| 18 |
+
| { kind: "connected"; ready: ReadyResponse; version: VersionResponse }
|
| 19 |
+
| { kind: "error"; message: string };
|
| 20 |
+
|
| 21 |
+
async function getJson(path: string): Promise<unknown> {
|
| 22 |
+
const response = await fetch(path, {
|
| 23 |
+
headers: { accept: "application/json" },
|
| 24 |
+
});
|
| 25 |
+
if (!response.ok) throw new Error(`${path} returned ${response.status}`);
|
| 26 |
+
return response.json();
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
async function loadSystemState(): Promise<
|
| 30 |
+
Extract<DashboardState, { kind: "connected" }>
|
| 31 |
+
> {
|
| 32 |
+
const [healthData, readyData, versionData] = await Promise.all([
|
| 33 |
+
getJson("/api/health"),
|
| 34 |
+
getJson("/api/ready"),
|
| 35 |
+
getJson("/api/version"),
|
| 36 |
+
]);
|
| 37 |
+
healthResponseSchema.parse(healthData);
|
| 38 |
+
return {
|
| 39 |
+
kind: "connected",
|
| 40 |
+
ready: readyResponseSchema.parse(readyData),
|
| 41 |
+
version: versionResponseSchema.parse(versionData),
|
| 42 |
+
};
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function ReadinessBadge({ value }: { value: "ready" | "not_ready" }) {
|
| 46 |
+
return (
|
| 47 |
+
<StatusPill tone={value === "ready" ? "success" : "danger"}>
|
| 48 |
+
{value === "ready" ? "Ready" : "Not ready"}
|
| 49 |
+
</StatusPill>
|
| 50 |
+
);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export function App() {
|
| 54 |
+
const [state, setState] = useState<DashboardState>({ kind: "loading" });
|
| 55 |
+
|
| 56 |
+
useEffect(() => {
|
| 57 |
+
let active = true;
|
| 58 |
+
loadSystemState()
|
| 59 |
+
.then((result) => active && setState(result))
|
| 60 |
+
.catch((error: unknown) => {
|
| 61 |
+
if (active)
|
| 62 |
+
setState({
|
| 63 |
+
kind: "error",
|
| 64 |
+
message:
|
| 65 |
+
error instanceof Error
|
| 66 |
+
? error.message
|
| 67 |
+
: "Unknown connection error",
|
| 68 |
+
});
|
| 69 |
+
});
|
| 70 |
+
return () => {
|
| 71 |
+
active = false;
|
| 72 |
+
};
|
| 73 |
+
}, []);
|
| 74 |
+
|
| 75 |
+
const connectionTone: StatusTone =
|
| 76 |
+
state.kind === "connected"
|
| 77 |
+
? "success"
|
| 78 |
+
: state.kind === "error"
|
| 79 |
+
? "danger"
|
| 80 |
+
: "neutral";
|
| 81 |
+
const connectionLabel =
|
| 82 |
+
state.kind === "connected"
|
| 83 |
+
? "Connected"
|
| 84 |
+
: state.kind === "error"
|
| 85 |
+
? "Connection error"
|
| 86 |
+
: "Connecting";
|
| 87 |
+
|
| 88 |
+
return (
|
| 89 |
+
<main className="page-shell">
|
| 90 |
+
<header className="topbar">
|
| 91 |
+
<a className="brand" href="/" aria-label="Oloka Video home">
|
| 92 |
+
<span className="brand-mark">O</span>
|
| 93 |
+
<span>Oloka Video</span>
|
| 94 |
+
</a>
|
| 95 |
+
<span className="environment-label">Online foundation</span>
|
| 96 |
+
</header>
|
| 97 |
+
|
| 98 |
+
<div className="content">
|
| 99 |
+
<section className="hero" aria-labelledby="page-title">
|
| 100 |
+
<div>
|
| 101 |
+
<p className="eyebrow">System overview</p>
|
| 102 |
+
<h1 id="page-title">A dependable start for every frame.</h1>
|
| 103 |
+
<p className="hero-copy">
|
| 104 |
+
Live infrastructure status for the Oloka Video development
|
| 105 |
+
environment.
|
| 106 |
+
</p>
|
| 107 |
+
</div>
|
| 108 |
+
<StatusPill tone={connectionTone}>{connectionLabel}</StatusPill>
|
| 109 |
+
</section>
|
| 110 |
+
|
| 111 |
+
{state.kind === "loading" && (
|
| 112 |
+
<Surface
|
| 113 |
+
className="state-panel loading-panel"
|
| 114 |
+
aria-live="polite"
|
| 115 |
+
aria-busy="true"
|
| 116 |
+
>
|
| 117 |
+
<span className="loading-line" />
|
| 118 |
+
<div>
|
| 119 |
+
<strong>Connecting to Oloka Video</strong>
|
| 120 |
+
<p>Checking the API and persistent services.</p>
|
| 121 |
+
</div>
|
| 122 |
+
</Surface>
|
| 123 |
+
)}
|
| 124 |
+
|
| 125 |
+
{state.kind === "error" && (
|
| 126 |
+
<Surface className="state-panel error-panel" role="alert">
|
| 127 |
+
<span className="state-symbol">!</span>
|
| 128 |
+
<div>
|
| 129 |
+
<strong>We could not reach the foundation services.</strong>
|
| 130 |
+
<p>{state.message}</p>
|
| 131 |
+
</div>
|
| 132 |
+
</Surface>
|
| 133 |
+
)}
|
| 134 |
+
|
| 135 |
+
{state.kind === "connected" && (
|
| 136 |
+
<div className="dashboard-grid">
|
| 137 |
+
<Surface className="status-card">
|
| 138 |
+
<div className="card-heading">
|
| 139 |
+
<div>
|
| 140 |
+
<p className="eyebrow">Runtime</p>
|
| 141 |
+
<h2>Service health</h2>
|
| 142 |
+
</div>
|
| 143 |
+
<StatusPill tone="success">API online</StatusPill>
|
| 144 |
+
</div>
|
| 145 |
+
<dl>
|
| 146 |
+
<DefinitionRow
|
| 147 |
+
label="Database"
|
| 148 |
+
value={
|
| 149 |
+
<ReadinessBadge
|
| 150 |
+
value={state.ready.checks.database.status}
|
| 151 |
+
/>
|
| 152 |
+
}
|
| 153 |
+
/>
|
| 154 |
+
<DefinitionRow
|
| 155 |
+
label="Persistent storage"
|
| 156 |
+
value={
|
| 157 |
+
<ReadinessBadge value={state.ready.checks.storage.status} />
|
| 158 |
+
}
|
| 159 |
+
/>
|
| 160 |
+
<DefinitionRow
|
| 161 |
+
label="Configuration"
|
| 162 |
+
value={
|
| 163 |
+
<ReadinessBadge
|
| 164 |
+
value={state.ready.checks.configuration.status}
|
| 165 |
+
/>
|
| 166 |
+
}
|
| 167 |
+
/>
|
| 168 |
+
</dl>
|
| 169 |
+
</Surface>
|
| 170 |
+
|
| 171 |
+
<Surface className="status-card build-card">
|
| 172 |
+
<div className="card-heading">
|
| 173 |
+
<div>
|
| 174 |
+
<p className="eyebrow">Release</p>
|
| 175 |
+
<h2>Build identity</h2>
|
| 176 |
+
</div>
|
| 177 |
+
<span className="version-chip">v{state.version.version}</span>
|
| 178 |
+
</div>
|
| 179 |
+
<dl>
|
| 180 |
+
<DefinitionRow
|
| 181 |
+
label="Application version"
|
| 182 |
+
value={state.version.version}
|
| 183 |
+
/>
|
| 184 |
+
<DefinitionRow
|
| 185 |
+
label="Git commit"
|
| 186 |
+
value={<code>{state.version.gitCommitSha}</code>}
|
| 187 |
+
/>
|
| 188 |
+
<DefinitionRow
|
| 189 |
+
label="Environment"
|
| 190 |
+
value={state.version.environment}
|
| 191 |
+
/>
|
| 192 |
+
<DefinitionRow
|
| 193 |
+
label="Built"
|
| 194 |
+
value={state.version.buildTimestamp}
|
| 195 |
+
/>
|
| 196 |
+
</dl>
|
| 197 |
+
</Surface>
|
| 198 |
+
</div>
|
| 199 |
+
)}
|
| 200 |
+
</div>
|
| 201 |
+
|
| 202 |
+
<footer>
|
| 203 |
+
<span>Oloka Video</span>
|
| 204 |
+
<span>Foundation status updates on every load.</span>
|
| 205 |
+
</footer>
|
| 206 |
+
</main>
|
| 207 |
+
);
|
| 208 |
+
}
|
apps/web/src/main.tsx
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { StrictMode } from "react";
|
| 2 |
+
import { createRoot } from "react-dom/client";
|
| 3 |
+
import "@oloka/design-system/tokens.css";
|
| 4 |
+
import "./styles.css";
|
| 5 |
+
import { App } from "./App";
|
| 6 |
+
|
| 7 |
+
createRoot(document.getElementById("root")!).render(
|
| 8 |
+
<StrictMode>
|
| 9 |
+
<App />
|
| 10 |
+
</StrictMode>,
|
| 11 |
+
);
|
apps/web/src/styles.css
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
* {
|
| 2 |
+
box-sizing: border-box;
|
| 3 |
+
}
|
| 4 |
+
html {
|
| 5 |
+
background: var(--color-canvas);
|
| 6 |
+
color: var(--color-ink);
|
| 7 |
+
font-family: var(--font-sans);
|
| 8 |
+
}
|
| 9 |
+
body {
|
| 10 |
+
margin: 0;
|
| 11 |
+
min-width: 320px;
|
| 12 |
+
}
|
| 13 |
+
button,
|
| 14 |
+
input {
|
| 15 |
+
font: inherit;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
.page-shell {
|
| 19 |
+
min-height: 100vh;
|
| 20 |
+
position: relative;
|
| 21 |
+
}
|
| 22 |
+
.page-shell::before {
|
| 23 |
+
background: linear-gradient(135deg, rgba(11, 104, 91, 0.08), transparent 55%);
|
| 24 |
+
border-radius: 50%;
|
| 25 |
+
content: "";
|
| 26 |
+
height: 32rem;
|
| 27 |
+
position: fixed;
|
| 28 |
+
right: -14rem;
|
| 29 |
+
top: -17rem;
|
| 30 |
+
width: 32rem;
|
| 31 |
+
z-index: -1;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.topbar,
|
| 35 |
+
footer {
|
| 36 |
+
align-items: center;
|
| 37 |
+
display: flex;
|
| 38 |
+
justify-content: space-between;
|
| 39 |
+
margin: 0 auto;
|
| 40 |
+
max-width: 1180px;
|
| 41 |
+
padding: 1.4rem 2rem;
|
| 42 |
+
}
|
| 43 |
+
.topbar {
|
| 44 |
+
border-bottom: 1px solid var(--color-line);
|
| 45 |
+
}
|
| 46 |
+
.brand {
|
| 47 |
+
align-items: center;
|
| 48 |
+
color: var(--color-ink);
|
| 49 |
+
display: flex;
|
| 50 |
+
font-weight: 750;
|
| 51 |
+
gap: 0.72rem;
|
| 52 |
+
text-decoration: none;
|
| 53 |
+
}
|
| 54 |
+
.brand-mark {
|
| 55 |
+
align-items: center;
|
| 56 |
+
background: var(--color-brand);
|
| 57 |
+
border-radius: 10px;
|
| 58 |
+
color: white;
|
| 59 |
+
display: inline-flex;
|
| 60 |
+
height: 2rem;
|
| 61 |
+
justify-content: center;
|
| 62 |
+
width: 2rem;
|
| 63 |
+
}
|
| 64 |
+
.environment-label {
|
| 65 |
+
color: var(--color-muted);
|
| 66 |
+
font-size: 0.82rem;
|
| 67 |
+
font-weight: 650;
|
| 68 |
+
text-transform: uppercase;
|
| 69 |
+
letter-spacing: 0.08em;
|
| 70 |
+
}
|
| 71 |
+
.content {
|
| 72 |
+
margin: 0 auto;
|
| 73 |
+
max-width: 1180px;
|
| 74 |
+
padding: 6.5rem 2rem 5rem;
|
| 75 |
+
}
|
| 76 |
+
.hero {
|
| 77 |
+
align-items: flex-end;
|
| 78 |
+
display: flex;
|
| 79 |
+
gap: 2rem;
|
| 80 |
+
justify-content: space-between;
|
| 81 |
+
margin-bottom: 3.2rem;
|
| 82 |
+
}
|
| 83 |
+
.eyebrow {
|
| 84 |
+
color: var(--color-brand);
|
| 85 |
+
font-size: 0.76rem;
|
| 86 |
+
font-weight: 800;
|
| 87 |
+
letter-spacing: 0.13em;
|
| 88 |
+
margin: 0 0 0.7rem;
|
| 89 |
+
text-transform: uppercase;
|
| 90 |
+
}
|
| 91 |
+
h1 {
|
| 92 |
+
font-size: clamp(2.55rem, 6vw, 5.2rem);
|
| 93 |
+
letter-spacing: -0.055em;
|
| 94 |
+
line-height: 0.98;
|
| 95 |
+
margin: 0;
|
| 96 |
+
max-width: 820px;
|
| 97 |
+
}
|
| 98 |
+
.hero-copy {
|
| 99 |
+
color: var(--color-muted);
|
| 100 |
+
font-size: 1.05rem;
|
| 101 |
+
line-height: 1.7;
|
| 102 |
+
margin: 1.4rem 0 0;
|
| 103 |
+
max-width: 590px;
|
| 104 |
+
}
|
| 105 |
+
.dashboard-grid {
|
| 106 |
+
display: grid;
|
| 107 |
+
gap: 1.25rem;
|
| 108 |
+
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
|
| 109 |
+
}
|
| 110 |
+
.status-card {
|
| 111 |
+
box-shadow: var(--shadow-card);
|
| 112 |
+
padding: 1.7rem 1.8rem 0.5rem;
|
| 113 |
+
}
|
| 114 |
+
.card-heading {
|
| 115 |
+
align-items: flex-start;
|
| 116 |
+
display: flex;
|
| 117 |
+
justify-content: space-between;
|
| 118 |
+
margin-bottom: 1.5rem;
|
| 119 |
+
}
|
| 120 |
+
.card-heading h2 {
|
| 121 |
+
font-size: 1.45rem;
|
| 122 |
+
letter-spacing: -0.025em;
|
| 123 |
+
margin: 0;
|
| 124 |
+
}
|
| 125 |
+
.card-heading .eyebrow {
|
| 126 |
+
margin-bottom: 0.35rem;
|
| 127 |
+
}
|
| 128 |
+
.status-card dl {
|
| 129 |
+
margin: 0;
|
| 130 |
+
}
|
| 131 |
+
.version-chip {
|
| 132 |
+
background: var(--color-brand-deep);
|
| 133 |
+
border-radius: var(--radius-sm);
|
| 134 |
+
color: white;
|
| 135 |
+
font-family: ui-monospace, monospace;
|
| 136 |
+
font-size: 0.82rem;
|
| 137 |
+
padding: 0.5rem 0.65rem;
|
| 138 |
+
}
|
| 139 |
+
code {
|
| 140 |
+
background: var(--color-neutral-soft);
|
| 141 |
+
border-radius: 6px;
|
| 142 |
+
font-size: 0.8rem;
|
| 143 |
+
padding: 0.3rem 0.45rem;
|
| 144 |
+
word-break: break-all;
|
| 145 |
+
}
|
| 146 |
+
.state-panel {
|
| 147 |
+
align-items: center;
|
| 148 |
+
box-shadow: var(--shadow-card);
|
| 149 |
+
display: flex;
|
| 150 |
+
gap: 1rem;
|
| 151 |
+
min-height: 190px;
|
| 152 |
+
padding: 2rem;
|
| 153 |
+
}
|
| 154 |
+
.state-panel p {
|
| 155 |
+
color: var(--color-muted);
|
| 156 |
+
margin: 0.45rem 0 0;
|
| 157 |
+
}
|
| 158 |
+
.loading-line {
|
| 159 |
+
animation: pulse 1.2s ease-in-out infinite;
|
| 160 |
+
background: var(--color-brand);
|
| 161 |
+
border-radius: 999px;
|
| 162 |
+
height: 3rem;
|
| 163 |
+
opacity: 0.5;
|
| 164 |
+
width: 0.3rem;
|
| 165 |
+
}
|
| 166 |
+
.error-panel {
|
| 167 |
+
border-color: #e9c7c1;
|
| 168 |
+
}
|
| 169 |
+
.state-symbol {
|
| 170 |
+
align-items: center;
|
| 171 |
+
background: var(--color-danger-soft);
|
| 172 |
+
border-radius: 50%;
|
| 173 |
+
color: var(--color-danger);
|
| 174 |
+
display: flex;
|
| 175 |
+
font-weight: 800;
|
| 176 |
+
height: 2.5rem;
|
| 177 |
+
justify-content: center;
|
| 178 |
+
width: 2.5rem;
|
| 179 |
+
}
|
| 180 |
+
footer {
|
| 181 |
+
border-top: 1px solid var(--color-line);
|
| 182 |
+
color: var(--color-muted);
|
| 183 |
+
font-size: 0.8rem;
|
| 184 |
+
}
|
| 185 |
+
footer span:first-child {
|
| 186 |
+
color: var(--color-ink);
|
| 187 |
+
font-weight: 750;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
@keyframes pulse {
|
| 191 |
+
50% {
|
| 192 |
+
opacity: 1;
|
| 193 |
+
transform: scaleY(1.15);
|
| 194 |
+
}
|
| 195 |
+
}
|
| 196 |
+
@media (prefers-reduced-motion: reduce) {
|
| 197 |
+
.loading-line {
|
| 198 |
+
animation: none;
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
@media (max-width: 760px) {
|
| 202 |
+
.content {
|
| 203 |
+
padding-top: 4.2rem;
|
| 204 |
+
}
|
| 205 |
+
.hero {
|
| 206 |
+
align-items: flex-start;
|
| 207 |
+
flex-direction: column;
|
| 208 |
+
}
|
| 209 |
+
.dashboard-grid {
|
| 210 |
+
grid-template-columns: 1fr;
|
| 211 |
+
}
|
| 212 |
+
.environment-label,
|
| 213 |
+
footer span:last-child {
|
| 214 |
+
display: none;
|
| 215 |
+
}
|
| 216 |
+
.topbar,
|
| 217 |
+
footer,
|
| 218 |
+
.content {
|
| 219 |
+
padding-left: 1.15rem;
|
| 220 |
+
padding-right: 1.15rem;
|
| 221 |
+
}
|
| 222 |
+
.status-card {
|
| 223 |
+
padding-left: 1.2rem;
|
| 224 |
+
padding-right: 1.2rem;
|
| 225 |
+
}
|
| 226 |
+
.card-heading {
|
| 227 |
+
gap: 1rem;
|
| 228 |
+
}
|
| 229 |
+
}
|
apps/web/src/test/setup.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import "@testing-library/jest-dom/vitest";
|
| 2 |
+
import { afterEach } from "vitest";
|
| 3 |
+
import { cleanup } from "@testing-library/react";
|
| 4 |
+
|
| 5 |
+
afterEach(() => cleanup());
|
apps/web/tsconfig.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"extends": "../../tsconfig.base.json",
|
| 3 |
+
"compilerOptions": {
|
| 4 |
+
"jsx": "react-jsx",
|
| 5 |
+
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"]
|
| 6 |
+
},
|
| 7 |
+
"include": ["src", "vite.config.ts"]
|
| 8 |
+
}
|
apps/web/vite.config.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import react from "@vitejs/plugin-react";
|
| 2 |
+
import { defineConfig } from "vite";
|
| 3 |
+
|
| 4 |
+
export default defineConfig({
|
| 5 |
+
plugins: [react()],
|
| 6 |
+
server: {
|
| 7 |
+
proxy: { "/api": "http://localhost:7860" },
|
| 8 |
+
},
|
| 9 |
+
test: {
|
| 10 |
+
environment: "jsdom",
|
| 11 |
+
setupFiles: "./src/test/setup.ts",
|
| 12 |
+
css: true,
|
| 13 |
+
},
|
| 14 |
+
});
|
docs/architecture/DEPLOYMENT.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deployment
|
| 2 |
+
|
| 3 |
+
## Build artifact
|
| 4 |
+
|
| 5 |
+
The multi-stage Docker build installs the locked dependency graph, compiles contracts, design system, web, and server, then prunes development dependencies. The runtime stage contains only production dependencies and compiled artifacts. It runs as UID/GID `1000`, exposes `7860`, and includes an application health check.
|
| 6 |
+
|
| 7 |
+
Build identity enters the image through `APP_VERSION`, `GIT_COMMIT_SHA`, and `BUILD_TIMESTAMP` build arguments. Runtime configuration can override ordinary environment values, but production operators should keep build identity immutable.
|
| 8 |
+
|
| 9 |
+
## GitHub to Hugging Face
|
| 10 |
+
|
| 11 |
+
The `develop` deployment workflow invokes the reusable validation workflow first. Formatting, lint, typecheck, unit, integration, frontend, production, browser, and Docker checks must all pass. The deployment job then pushes the exact validated Git commit to `phucsd/Oloka-Video-Dev` using the masked `HF_TOKEN` secret.
|
| 12 |
+
|
| 13 |
+
The Space reads Docker metadata from the YAML header in `README.md`. Persistent Hugging Face storage must be attached at `/data`; replacing that path with ephemeral storage loses SQLite state after rebuilds.
|
| 14 |
+
|
| 15 |
+
## Observability
|
| 16 |
+
|
| 17 |
+
Fastify emits JSON logs to standard output. Use the Space Logs tab for build and container logs. Diagnose deployment layers separately: GitHub validation, repository sync, Docker build, container start, `/api/health`, then `/api/ready`.
|
| 18 |
+
|
| 19 |
+
## Rollback
|
| 20 |
+
|
| 21 |
+
Revert the faulty GitHub commit on `develop`. A new workflow run validates and deploys the revert. Confirm `/api/version` returns the expected rollback SHA and then confirm `/api/ready` reports every dependency ready.
|
docs/architecture/FOUNDATION.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Foundation architecture
|
| 2 |
+
|
| 3 |
+
## Purpose
|
| 4 |
+
|
| 5 |
+
This repository establishes the smallest online-safe base for Oloka Video. It deliberately contains no authentication, project management, uploads, AI integrations, editor, or rendering pipeline.
|
| 6 |
+
|
| 7 |
+
## Module boundaries
|
| 8 |
+
|
| 9 |
+
The browser depends on `@oloka/contracts` and `@oloka/design-system`. The server depends on `@oloka/contracts`, application services, and infrastructure adapters. Routes translate HTTP to application services; they do not access SQLite or the filesystem directly.
|
| 10 |
+
|
| 11 |
+
The database boundary is `SystemDatabase`. `SqliteSystemDatabase` is the online-development adapter. A PostgreSQL adapter can later implement the same contract without changing system services or routes.
|
| 12 |
+
|
| 13 |
+
The persistent-data boundary is `ObjectStorage`. `FilesystemObjectStorage` owns all filesystem readiness work beneath `DATA_DIR`; future object-store implementations can replace it without changing HTTP handlers.
|
| 14 |
+
|
| 15 |
+
## Request flow
|
| 16 |
+
|
| 17 |
+
1. Configuration is parsed and validated before the listener opens.
|
| 18 |
+
2. The database directory is created and migrations are applied idempotently.
|
| 19 |
+
3. Persistent storage completes a real read/write startup probe.
|
| 20 |
+
4. Fastify exposes system endpoints and serves the compiled React application.
|
| 21 |
+
5. The frontend fetches all three system endpoints and validates responses with shared schemas.
|
| 22 |
+
|
| 23 |
+
## Readiness semantics
|
| 24 |
+
|
| 25 |
+
`/api/health` is a liveness check only. `/api/ready` executes `SELECT 1`, checks storage through a fixed `.oloka-readiness` probe, and reports validated configuration. The probe is overwritten rather than multiplied and removed during graceful shutdown. A failed component returns HTTP `503` with per-component status.
|
| 26 |
+
|
| 27 |
+
## Persistence schema
|
| 28 |
+
|
| 29 |
+
Migration 1 creates `schema_migrations` and `system_metadata`. There are no product-domain tables in the foundation phase. SQLite uses WAL mode for safer online-development concurrency.
|
docs/architecture/LOCAL_DEVELOPMENT.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Local development
|
| 2 |
+
|
| 3 |
+
## Setup
|
| 4 |
+
|
| 5 |
+
Install Node.js 22 or later and run `npm ci`. Copy `.env.example` only when a local environment file is useful; never add `.env` to Git. Windows developers should set `DATA_DIR` and `DATABASE_URL` to a writable repository-local path because `/data` is the online Linux default.
|
| 6 |
+
|
| 7 |
+
`npm run dev` starts Fastify on `7860` and Vite on `5173`. Vite proxies API requests to Fastify. Production and browser checks use the single-port compiled build.
|
| 8 |
+
|
| 9 |
+
## Quality commands
|
| 10 |
+
|
| 11 |
+
- `npm run format:check` verifies formatting.
|
| 12 |
+
- `npm run lint` applies typed ESLint rules.
|
| 13 |
+
- `npm run typecheck` checks every workspace and tool configuration.
|
| 14 |
+
- `npm run test:unit` verifies environment, health, and storage behavior.
|
| 15 |
+
- `npm run test:integration` exercises all system endpoints through Fastify injection.
|
| 16 |
+
- `npm run test:frontend` exercises connected and error states.
|
| 17 |
+
- `npm run build` produces all production artifacts.
|
| 18 |
+
- `npm run test:e2e` starts the compiled server and checks the page in Chromium.
|
| 19 |
+
|
| 20 |
+
## Data cleanup
|
| 21 |
+
|
| 22 |
+
Repository-local `.data` and `.tmp` directories are ignored. Stop the server before removing a SQLite development database, especially on Windows where open files remain locked. Do not delete a mounted production `/data` volume as part of routine development.
|
docs/decisions/0001-modular-monolith.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ADR 0001: TypeScript modular monolith
|
| 2 |
+
|
| 3 |
+
- Status: Accepted
|
| 4 |
+
- Date: 2026-08-05
|
| 5 |
+
|
| 6 |
+
## Context
|
| 7 |
+
|
| 8 |
+
Oloka Video needs a small deployable base while preserving explicit frontend, backend, contract, and UI boundaries.
|
| 9 |
+
|
| 10 |
+
## Decision
|
| 11 |
+
|
| 12 |
+
Use npm workspaces with `apps/web`, `apps/server`, `packages/contracts`, and `packages/design-system`. Build one deployable process without combining source modules.
|
| 13 |
+
|
| 14 |
+
## Consequences
|
| 15 |
+
|
| 16 |
+
One image and one runtime simplify operations. Workspace contracts prevent accidental coupling and leave modules separable if scale later justifies it.
|
docs/decisions/0002-single-hf-docker-space.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ADR 0002: Single Hugging Face Docker Space
|
| 2 |
+
|
| 3 |
+
- Status: Accepted
|
| 4 |
+
- Date: 2026-08-05
|
| 5 |
+
|
| 6 |
+
## Context
|
| 7 |
+
|
| 8 |
+
The development environment needs to be online from the first commit with minimal operational surface.
|
| 9 |
+
|
| 10 |
+
## Decision
|
| 11 |
+
|
| 12 |
+
Serve the compiled React application and API from one Fastify process on public port `7860` in `phucsd/Oloka-Video-Dev`.
|
| 13 |
+
|
| 14 |
+
## Consequences
|
| 15 |
+
|
| 16 |
+
Frontend and API share an origin, eliminating CORS configuration and coordinating deployment. Independent scaling is deferred until product usage requires it.
|
docs/decisions/0003-storage-abstraction.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ADR 0003: Persistent storage abstraction
|
| 2 |
+
|
| 3 |
+
- Status: Accepted
|
| 4 |
+
- Date: 2026-08-05
|
| 5 |
+
|
| 6 |
+
## Context
|
| 7 |
+
|
| 8 |
+
Online development uses Hugging Face persistent disk, while future production may use object storage.
|
| 9 |
+
|
| 10 |
+
## Decision
|
| 11 |
+
|
| 12 |
+
Application services depend on `ObjectStorage`. The first adapter uses the filesystem below `/data` and owns its safe read/write readiness probe.
|
| 13 |
+
|
| 14 |
+
## Consequences
|
| 15 |
+
|
| 16 |
+
Routes contain no direct filesystem operations. A cloud object-store adapter can replace the filesystem adapter without changing HTTP contracts.
|
docs/decisions/0004-database-abstraction.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ADR 0004: Database abstraction
|
| 2 |
+
|
| 3 |
+
- Status: Accepted
|
| 4 |
+
- Date: 2026-08-05
|
| 5 |
+
|
| 6 |
+
## Context
|
| 7 |
+
|
| 8 |
+
SQLite minimizes online-development infrastructure, while expected production workloads may require PostgreSQL.
|
| 9 |
+
|
| 10 |
+
## Decision
|
| 11 |
+
|
| 12 |
+
System services depend on `SystemDatabase`. Use Node's SQLite adapter for development and reserve `postgresql://` URLs for a future PostgreSQL implementation.
|
| 13 |
+
|
| 14 |
+
## Consequences
|
| 15 |
+
|
| 16 |
+
Business and HTTP code do not know the active database engine. PostgreSQL activation requires a new adapter and focused contract tests, not route rewrites.
|
eslint.config.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import eslint from "@eslint/js";
|
| 2 |
+
import prettier from "eslint-config-prettier";
|
| 3 |
+
import globals from "globals";
|
| 4 |
+
import tseslint from "typescript-eslint";
|
| 5 |
+
|
| 6 |
+
export default tseslint.config(
|
| 7 |
+
{
|
| 8 |
+
ignores: [
|
| 9 |
+
"**/dist/**",
|
| 10 |
+
"**/coverage/**",
|
| 11 |
+
"playwright-report/**",
|
| 12 |
+
"test-results/**",
|
| 13 |
+
],
|
| 14 |
+
},
|
| 15 |
+
eslint.configs.recommended,
|
| 16 |
+
...tseslint.configs.recommendedTypeChecked.map((config) => ({
|
| 17 |
+
...config,
|
| 18 |
+
files: ["**/*.ts", "**/*.tsx"],
|
| 19 |
+
})),
|
| 20 |
+
prettier,
|
| 21 |
+
{
|
| 22 |
+
files: ["**/*.ts", "**/*.tsx"],
|
| 23 |
+
languageOptions: {
|
| 24 |
+
parserOptions: {
|
| 25 |
+
projectService: {
|
| 26 |
+
allowDefaultProject: ["playwright.config.ts", "tests/e2e/*.ts"],
|
| 27 |
+
},
|
| 28 |
+
tsconfigRootDir: import.meta.dirname,
|
| 29 |
+
},
|
| 30 |
+
},
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
files: ["apps/server/**/*.ts", "*.ts", "*.js"],
|
| 34 |
+
languageOptions: { globals: globals.node },
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
files: ["apps/web/**/*.ts", "apps/web/**/*.tsx"],
|
| 38 |
+
languageOptions: { globals: globals.browser },
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
files: ["**/*.test.ts", "**/*.test.tsx", "tests/**/*.ts"],
|
| 42 |
+
rules: {
|
| 43 |
+
"@typescript-eslint/no-unsafe-assignment": "off",
|
| 44 |
+
"@typescript-eslint/no-unsafe-member-access": "off",
|
| 45 |
+
},
|
| 46 |
+
},
|
| 47 |
+
);
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "oloka-video",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"description": "The online foundation for Oloka Video.",
|
| 6 |
+
"type": "module",
|
| 7 |
+
"workspaces": [
|
| 8 |
+
"apps/*",
|
| 9 |
+
"packages/*"
|
| 10 |
+
],
|
| 11 |
+
"scripts": {
|
| 12 |
+
"build": "npm run build -w @oloka/contracts && npm run build -w @oloka/design-system && npm run build -w @oloka/web && npm run build -w @oloka/server",
|
| 13 |
+
"dev": "concurrently -n server,web -c blue,green \"npm run dev -w @oloka/server\" \"npm run dev -w @oloka/web\"",
|
| 14 |
+
"format": "prettier --write .",
|
| 15 |
+
"format:check": "prettier --check .",
|
| 16 |
+
"lint": "eslint . --max-warnings=0",
|
| 17 |
+
"typecheck": "npm run typecheck -w @oloka/contracts && npm run typecheck -w @oloka/design-system && npm run typecheck -w @oloka/server && npm run typecheck -w @oloka/web && tsc -p tsconfig.tools.json --noEmit",
|
| 18 |
+
"test": "npm run test -w @oloka/server && npm run test -w @oloka/web",
|
| 19 |
+
"test:unit": "npm run test:unit -w @oloka/server",
|
| 20 |
+
"test:integration": "npm run test:integration -w @oloka/server",
|
| 21 |
+
"test:frontend": "npm run test -w @oloka/web",
|
| 22 |
+
"test:e2e": "playwright test",
|
| 23 |
+
"start": "node apps/server/dist/index.js"
|
| 24 |
+
},
|
| 25 |
+
"engines": {
|
| 26 |
+
"node": ">=22.0.0"
|
| 27 |
+
},
|
| 28 |
+
"devDependencies": {
|
| 29 |
+
"@eslint/js": "9.32.0",
|
| 30 |
+
"@playwright/test": "1.62.1",
|
| 31 |
+
"concurrently": "9.2.0",
|
| 32 |
+
"eslint": "9.32.0",
|
| 33 |
+
"eslint-config-prettier": "10.1.8",
|
| 34 |
+
"globals": "16.3.0",
|
| 35 |
+
"prettier": "3.6.2",
|
| 36 |
+
"typescript": "5.9.2",
|
| 37 |
+
"typescript-eslint": "8.39.0"
|
| 38 |
+
}
|
| 39 |
+
}
|
packages/contracts/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "@oloka/contracts",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"type": "module",
|
| 6 |
+
"main": "./dist/index.js",
|
| 7 |
+
"types": "./src/index.ts",
|
| 8 |
+
"exports": {
|
| 9 |
+
".": {
|
| 10 |
+
"types": "./src/index.ts",
|
| 11 |
+
"import": "./dist/index.js"
|
| 12 |
+
}
|
| 13 |
+
},
|
| 14 |
+
"scripts": {
|
| 15 |
+
"build": "tsc -p tsconfig.build.json",
|
| 16 |
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
| 17 |
+
},
|
| 18 |
+
"dependencies": {
|
| 19 |
+
"zod": "4.0.17"
|
| 20 |
+
}
|
| 21 |
+
}
|
packages/contracts/src/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { z } from "zod";
|
| 2 |
+
|
| 3 |
+
export const healthResponseSchema = z.object({
|
| 4 |
+
status: z.literal("ok"),
|
| 5 |
+
timestamp: z.string(),
|
| 6 |
+
});
|
| 7 |
+
|
| 8 |
+
export const componentReadinessSchema = z.object({
|
| 9 |
+
status: z.enum(["ready", "not_ready"]),
|
| 10 |
+
message: z.string().optional(),
|
| 11 |
+
});
|
| 12 |
+
|
| 13 |
+
export const readyResponseSchema = z.object({
|
| 14 |
+
status: z.enum(["ready", "not_ready"]),
|
| 15 |
+
checks: z.object({
|
| 16 |
+
database: componentReadinessSchema,
|
| 17 |
+
storage: componentReadinessSchema,
|
| 18 |
+
configuration: componentReadinessSchema,
|
| 19 |
+
}),
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
export const versionResponseSchema = z.object({
|
| 23 |
+
name: z.literal("Oloka Video"),
|
| 24 |
+
version: z.string(),
|
| 25 |
+
environment: z.enum(["development", "test", "production"]),
|
| 26 |
+
gitCommitSha: z.string(),
|
| 27 |
+
buildTimestamp: z.string(),
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
export type HealthResponse = z.infer<typeof healthResponseSchema>;
|
| 31 |
+
export type ReadyResponse = z.infer<typeof readyResponseSchema>;
|
| 32 |
+
export type VersionResponse = z.infer<typeof versionResponseSchema>;
|
packages/contracts/tsconfig.build.json
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"extends": "./tsconfig.json",
|
| 3 |
+
"compilerOptions": { "declaration": true, "outDir": "dist" }
|
| 4 |
+
}
|
packages/contracts/tsconfig.json
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"extends": "../../tsconfig.base.json",
|
| 3 |
+
"compilerOptions": { "rootDir": "src" },
|
| 4 |
+
"include": ["src"]
|
| 5 |
+
}
|
packages/design-system/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "@oloka/design-system",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"type": "module",
|
| 6 |
+
"main": "./dist/index.js",
|
| 7 |
+
"types": "./src/index.tsx",
|
| 8 |
+
"exports": {
|
| 9 |
+
".": {
|
| 10 |
+
"types": "./src/index.tsx",
|
| 11 |
+
"import": "./dist/index.js"
|
| 12 |
+
},
|
| 13 |
+
"./tokens.css": "./src/tokens.css"
|
| 14 |
+
},
|
| 15 |
+
"scripts": {
|
| 16 |
+
"build": "tsc -p tsconfig.build.json",
|
| 17 |
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
| 18 |
+
},
|
| 19 |
+
"peerDependencies": {
|
| 20 |
+
"react": ">=19"
|
| 21 |
+
},
|
| 22 |
+
"devDependencies": {
|
| 23 |
+
"@types/react": "19.1.10",
|
| 24 |
+
"react": "19.1.1"
|
| 25 |
+
}
|
| 26 |
+
}
|