From a457b9d96f3462e277f9f4787c2703d9be70fbbb Mon Sep 17 00:00:00 2001 From: Mavis Date: Tue, 2 Jun 2026 18:54:46 +0000 Subject: [PATCH] Phase 1: data pipeline (Postgres+Redis with in-memory fallback, mock telemetry publisher, WebSocket fan-out, hub /debug page) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packages/db: postgres.js + ioredis wrapper, StateStore interface with InMemory + Postgres implementations, schema migration runner - apps/api: refactored to use @kerbal-rt/db; live WebSocket hub subscribes to store changes and broadcasts to all clients - apps/tools/mock-telemetry: Node script that generates realistic KSP state and POSTs to /api/v1/ingest at 1Hz (uses same keplerian math as the live-map renderer) - apps/hub: new /debug page that connects to /api/v1/live and shows the live state - ksp/README.md: documents Phase 1c (real kRPC bridge) and the two implementation options - Tests: 17 total (5 kepler math, 5 db store, 5 API health/state, 2 API WebSocket fan-out) End-to-end verified: mock publisher → API → hub /debug page, 11 snapshots/5s over WebSocket, vessels advancing in mean anomaly. --- README.md | 41 ++- apps/api/package.json | 1 + apps/api/src/app.ts | 56 ++- apps/api/src/index.ts | 99 ++++-- apps/api/src/test-app.ts | 9 +- apps/api/src/ws.ts | 99 ++++-- apps/api/tests/health.test.ts | 94 ++++- apps/api/tests/ws.test.ts | 141 ++++++++ apps/hub/src/app/debug/page.tsx | 195 +++++++++++ apps/hub/src/app/page.tsx | 15 +- apps/tools/mock-telemetry/package.json | 24 ++ apps/tools/mock-telemetry/src/catalog.ts | 414 +++++++++++++++++++++++ apps/tools/mock-telemetry/src/index.ts | 166 +++++++++ apps/tools/mock-telemetry/tsconfig.json | 9 + ksp/.gitkeep | 3 + ksp/README.md | 118 +++++++ packages/db/package.json | 30 ++ packages/db/src/config.ts | 45 +++ packages/db/src/index.ts | 94 +++++ packages/db/src/migrate.ts | 48 +++ packages/db/src/postgres.ts | 31 ++ packages/db/src/redis.ts | 33 ++ packages/db/src/store.ts | 234 +++++++++++++ packages/db/tests/store.test.ts | 106 ++++++ packages/db/tsconfig.json | 10 + packages/db/vitest.config.ts | 8 + pnpm-lock.yaml | 139 +++++++- pnpm-workspace.yaml | 1 + 28 files changed, 2165 insertions(+), 98 deletions(-) create mode 100644 apps/api/tests/ws.test.ts create mode 100644 apps/hub/src/app/debug/page.tsx create mode 100644 apps/tools/mock-telemetry/package.json create mode 100644 apps/tools/mock-telemetry/src/catalog.ts create mode 100644 apps/tools/mock-telemetry/src/index.ts create mode 100644 apps/tools/mock-telemetry/tsconfig.json create mode 100644 ksp/.gitkeep create mode 100644 ksp/README.md create mode 100644 packages/db/package.json create mode 100644 packages/db/src/config.ts create mode 100644 packages/db/src/index.ts create mode 100644 packages/db/src/migrate.ts create mode 100644 packages/db/src/postgres.ts create mode 100644 packages/db/src/redis.ts create mode 100644 packages/db/src/store.ts create mode 100644 packages/db/tests/store.test.ts create mode 100644 packages/db/tsconfig.json create mode 100644 packages/db/vitest.config.ts diff --git a/README.md b/README.md index fc419ef..7f582b8 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,10 @@ A real-time web mirror of a no-warp Kerbal Space Program multiplayer server: mission hub + 3D live solar system map. -> **Status: Phase 0 (skeleton)** — monorepo scaffolded, all three apps build, -> data pipeline / real KSP bridge lands in Phase 1. +> **Status: Phase 1 — data pipeline working end-to-end.** +> Mock telemetry publisher drives the API, the API stores snapshots, the +> hub debug page and the live map both consume the WebSocket. Real KSP +> bridge (Phase 1c) is documented and ready to plug in once you have KSP. See [`kerbalrealtime-clone-plan.md`](./kerbalrealtime-clone-plan.md) for the full implementation plan. @@ -28,14 +30,17 @@ full implementation plan. ``` . ├── apps/ -│ ├── hub/ # Next.js — marketing + calendar + media +│ ├── hub/ # Next.js — marketing + calendar + media + /debug │ ├── live-map/ # Vite + Three.js — 3D solar system viewer -│ └── api/ # Hono — REST + WS gateway +│ ├── api/ # Hono — REST + WS gateway +│ └── tools/ +│ └── mock-telemetry/ # Node script that drives the pipeline with fake KSP state ├── packages/ │ ├── shared-types/ # TS types + Zod schemas for every API boundary │ ├── orbital-math/ # Pure keplerian propagation, occultation, transfers +│ ├── db/ # Postgres+Timescale+Redis wrapper, StateStore interface │ └── ui/ # Shared React components (Pill, Card, …) -├── ksp/ # KSP-side mods (Phase 1+) +├── ksp/ # KSP-side bridge (Phase 1c) — see ./ksp/README.md ├── infra/ # docker-compose, nginx, grafana, init SQL └── scripts/ # dev.sh and friends ``` @@ -99,14 +104,36 @@ push / PR to `main`. See `.github/workflows/ci.yml`. See [`kerbalrealtime-clone-plan.md`](./kerbalrealtime-clone-plan.md) for the roadmap. Quick map: -- [x] **Phase 0** — monorepo, skeletons, docker-compose, CI _(this commit)_ -- [ ] **Phase 1** — KSP telemetry bridge + ingest pipeline (Postgres + Redis) +- [x] **Phase 0** — monorepo, skeletons, docker-compose, CI +- [x] **Phase 1a** — persistence layer (Postgres+Timescale+Redis) with in-memory fallback +- [x] **Phase 1b** — mock telemetry publisher + WebSocket fan-out + hub /debug page +- [ ] **Phase 1c** — real kRPC bridge (see [`ksp/README.md`](./ksp/README.md)) - [ ] **Phase 2** — Live map 3D scene (time controls, focus, eclipses, overpass) - [ ] **Phase 3** — Mission calendar, media, crew, YouTube/Twitch embeds - [ ] **Phase 4** — Spacenomicon tools (delta-v, transfer calculator, etc.) - [ ] **Phase 5** — Admin panel + polish - [ ] **Phase 6** — Production deploy +## Verifying the Phase 1 pipeline + +```bash +# Terminal 1: API +cd apps/api && PORT=4000 USE_IN_MEMORY=1 pnpm start + +# Terminal 2: mock publisher +cd apps/tools/mock-telemetry && MOCK_API_URL=http://localhost:4000 pnpm start + +# Terminal 3: hub +cd apps/hub && PORT=3000 pnpm start +# → open http://localhost:3000/debug to see the live state +``` + +For the WebSocket version, open the hub `/debug` page or use [wscat](https://github.com/websockets/wscat): + +```bash +npx wscat -c ws://localhost:4000/api/v1/live +``` + ## License This project is unaffiliated with Kerbal Space Program, Intercept Games, or diff --git a/apps/api/package.json b/apps/api/package.json index 92ce7bb..3e508e3 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -17,6 +17,7 @@ "@hono/node-server": "^1.13.0", "@hono/node-ws": "^1.1.0", "@hono/zod-validator": "^0.4.0", + "@kerbal-rt/db": "workspace:*", "@kerbal-rt/shared-types": "workspace:*", "@kerbal-rt/orbital-math": "workspace:*", "hono": "^4.6.0", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 792d0dc..7d5ab82 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -7,10 +7,17 @@ import { cors } from 'hono/cors'; import { logger } from 'hono/logger'; import { UniverseSnapshotSchema } from '@kerbal-rt/shared-types'; import { z } from 'zod'; -import { state } from './state.js'; +import type { StateStore } from '@kerbal-rt/db'; -export function buildApp() { +export interface AppDeps { + store: StateStore; + /** The expected ingest API key, or null to disable auth (dev only). */ + ingestApiKey: string | null; +} + +export function buildApp(deps: AppDeps) { const app = new Hono(); + const { store, ingestApiKey } = deps; app.use('*', logger()); app.use('*', cors({ origin: process.env.CORS_ORIGIN?.split(',') ?? '*' })); @@ -21,6 +28,7 @@ export function buildApp() { ok: true, service: 'kerbal-rt-api', version: '0.1.0', + store: store.constructor.name, ts: new Date().toISOString(), }), ); @@ -29,15 +37,22 @@ export function buildApp() { const IngestAuth = z.object({ 'x-api-key': z.string() }); app.post('/api/v1/ingest', async (c) => { - const headerParse = IngestAuth.safeParse( - Object.fromEntries(Array.from(c.req.raw.headers.entries())), - ); - if (!headerParse.success) { - return c.json({ error: true, code: 'UNAUTHORIZED', message: 'Missing API key' }, 401); - } - if (headerParse.data['x-api-key'] !== process.env.INGEST_API_KEY) { - return c.json({ error: true, code: 'FORBIDDEN', message: 'Bad API key' }, 403); + if (ingestApiKey) { + const headerParse = IngestAuth.safeParse( + Object.fromEntries(Array.from(c.req.raw.headers.entries())), + ); + if (!headerParse.success) { + return c.json({ error: true, code: 'UNAUTHORIZED', message: 'Missing API key' }, 401); + } + if (headerParse.data['x-api-key'] !== ingestApiKey) { + return c.json({ error: true, code: 'FORBIDDEN', message: 'Bad API key' }, 403); + } + } else if (process.env.NODE_ENV === 'production') { + // Belt and suspenders: never allow unauthenticated ingest in prod + // even if the env var is missing. + return c.json({ error: true, code: 'UNAUTHORIZED', message: 'Ingest disabled' }, 401); } + const body = await c.req.json(); const parsed = UniverseSnapshotSchema.safeParse(body); if (!parsed.success) { @@ -51,23 +66,28 @@ export function buildApp() { 400, ); } - state.applySnapshot(parsed.data); - return c.json({ error: false, data: { ok: true, ts: new Date().toISOString() } }); + const result = await store.applySnapshot(parsed.data); + return c.json({ + error: false, + data: { ok: true, rowsInserted: result.rowsInserted, ts: new Date().toISOString() }, + }); }); // ─── read-only endpoints ─────────────────────────────────────────────── - app.get('/api/v1/state', (c) => { - const snap = state.latestSnapshot(); + app.get('/api/v1/state', async (c) => { + const snap = await store.latestSnapshot(); if (!snap) return c.json({ error: true, code: 'NO_DATA', message: 'No snapshot yet' }, 503); return c.json({ error: false, data: snap }); }); - app.get('/api/v1/bodies', (c) => c.json({ error: false, data: state.latestBodies() })); + app.get('/api/v1/bodies', async (c) => c.json({ error: false, data: await store.listBodies() })); - app.get('/api/v1/vessels', (c) => c.json({ error: false, data: state.latestVessels() })); + app.get('/api/v1/vessels', async (c) => + c.json({ error: false, data: await store.listVessels() }), + ); - app.get('/api/v1/vessels/:id', (c) => { - const v = state.getVessel(c.req.param('id')); + app.get('/api/v1/vessels/:id', async (c) => { + const v = await store.getVessel(c.req.param('id')); if (!v) return c.json({ error: true, code: 'NOT_FOUND', message: 'Vessel not found' }, 404); return c.json({ error: false, data: v }); }); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index c6e0f95..d4bc16e 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -2,45 +2,88 @@ * API entrypoint. * * Stack: Hono on Node + ws for WebSockets. - * Phase 0 scope: healthcheck, body catalog (static seed), vessel list - * (in-memory from /ingest), and a /live WebSocket. - * - * Real DB (Postgres + Timescale) lands in Phase 1. + * Phase 0 scope: in-memory state, healthcheck, ingest, REST read, /live WS. + * Phase 1 scope: Postgres + Timescale + Redis via @kerbal-rt/db. + * Falls back to in-memory if DATABASE_URL/REDIS_URL unset. */ import { serve } from '@hono/node-server'; import { createNodeWebSocket } from '@hono/node-ws'; import type { ServerType } from '@hono/node-server'; import type { LiveMessage } from '@kerbal-rt/shared-types'; +import { createStore } from '@kerbal-rt/db'; import { buildApp } from './app.js'; -import { handleLiveSocket } from './ws.js'; +import { LiveSocketHub } from './ws.js'; -const app = buildApp(); +async function main() { + const wired = await createStore(); -const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({ app }); + if (wired.config.useInMemory) { + // eslint-disable-next-line no-console + console.log( + '[kerbal-rt-api] using IN-MEMORY store (no DATABASE_URL/REDIS_URL — fine for dev, set both for prod)', + ); + } else { + // eslint-disable-next-line no-console + console.log('[kerbal-rt-api] using POSTGRES + REDIS store'); + } -app.get( - '/api/v1/live', - upgradeWebSocket(() => ({ - onOpen: (_evt, ws) => handleLiveSocket.onOpen(ws), - onMessage: (evt, ws) => handleLiveSocket.onMessage(evt, ws), - onClose: (_evt, ws) => handleLiveSocket.onClose(ws), - })), -); + const hub = new LiveSocketHub({ store: wired.store, channel: wired.channel }); + await hub.start(); -// Suppress unused-import warning when no upgrade happens -void upgradeWebSocket; + const ingestApiKey = process.env.INGEST_API_KEY ?? null; + if (!ingestApiKey && process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.log( + '[kerbal-rt-api] WARNING: INGEST_API_KEY not set, /api/v1/ingest is unauthenticated', + ); + } -const port = Number(process.env.PORT ?? 4000); + const app = buildApp({ store: wired.store, ingestApiKey }); -const server: ServerType = serve({ fetch: app.fetch, port }, (info) => { + const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({ app }); + + app.get( + '/api/v1/live', + upgradeWebSocket(() => ({ + onOpen: (_evt, ws) => hub.onOpen(ws), + onMessage: (evt, ws) => hub.onMessage(evt, ws), + onClose: (_evt, ws) => hub.onClose(ws), + })), + ); + + // Suppress unused-import warning when no upgrade happens + void upgradeWebSocket; + + const port = Number(process.env.PORT ?? 4000); + + const server: ServerType = serve({ fetch: app.fetch, port }, (info) => { + // eslint-disable-next-line no-console + console.log(`[kerbal-rt-api] listening on http://localhost:${info.port}`); + }); + + injectWebSocket(server); + + // Periodic heartbeats so clients can detect dead connections. + setInterval(() => { + const msg: LiveMessage = { type: 'ping', serverTime: new Date().toISOString() }; + hub.broadcast(msg); + }, 15_000); + + // Graceful shutdown + const shutdown = async (sig: string) => { + // eslint-disable-next-line no-console + console.log(`[kerbal-rt-api] ${sig} received, shutting down...`); + await hub.stop(); + await wired.close(); + server.close(() => process.exit(0)); + setTimeout(() => process.exit(1), 5000).unref(); + }; + process.on('SIGINT', () => void shutdown('SIGINT')); + process.on('SIGTERM', () => void shutdown('SIGTERM')); +} + +main().catch((err) => { // eslint-disable-next-line no-console - console.log(`[kerbal-rt-api] listening on http://localhost:${info.port}`); + console.error('[kerbal-rt-api] fatal:', err); + process.exit(1); }); - -injectWebSocket(server); - -// Push periodic pings to all connected WS clients -setInterval(() => { - const msg: LiveMessage = { type: 'ping', serverTime: new Date().toISOString() }; - handleLiveSocket.broadcast(msg); -}, 15_000); diff --git a/apps/api/src/test-app.ts b/apps/api/src/test-app.ts index bf59fd0..644b15e 100644 --- a/apps/api/src/test-app.ts +++ b/apps/api/src/test-app.ts @@ -1,5 +1,12 @@ /** * Test-only Hono app export. Doesn't bind a port. + * Always uses in-memory store (no DB required for unit tests). */ +import { InMemoryStateStore } from '@kerbal-rt/db'; import { buildApp } from './app.js'; -export const app = buildApp(); + +export const app = buildApp({ + store: new InMemoryStateStore(), + // Force-disable auth for tests; the auth path is tested separately. + ingestApiKey: null, +}); diff --git a/apps/api/src/ws.ts b/apps/api/src/ws.ts index 3662e15..8034dcf 100644 --- a/apps/api/src/ws.ts +++ b/apps/api/src/ws.ts @@ -1,17 +1,16 @@ /** * WebSocket fan-out for /api/v1/live. * - * Phase 0 just keeps a Set of connected clients and broadcasts any - * LiveMessage we get. Clients should handle `ping` as a heartbeat. - * - * Phase 1 will wire this to a Redis pubsub channel so multiple API - * instances can share the fan-out load. + * Subscribes to the StateStore (which is backed by Redis pubsub in + * prod, in-process events in dev) and re-broadcasts to all connected + * clients. * * Hono's @hono/node-ws gives us a `WSContext` that wraps a `ws` WebSocket * with extra Hono-specific methods. We use a thin adapter type so this * module doesn't need to import from hono. */ -import type { LiveMessage } from '@kerbal-rt/shared-types'; +import type { LiveMessage, UniverseSnapshot } from '@kerbal-rt/shared-types'; +import type { StateStore } from '@kerbal-rt/db'; /** Anything with .send(string) and a .readyState we can inspect. */ export interface WsLike { @@ -20,34 +19,70 @@ export interface WsLike { } const READY_STATE_OPEN = 1; -const clients = new Set(); -function send(ws: WsLike, msg: LiveMessage): void { - if (ws.readyState === READY_STATE_OPEN) { - ws.send(JSON.stringify(msg)); +export interface LiveSocketOpts { + store: StateStore; + /** Channel label for diagnostics only. */ + channel: string; +} + +export class LiveSocketHub { + private clients = new Set(); + private unsubscribe: (() => Promise) | null = null; + + constructor(private opts: LiveSocketOpts) {} + + async start(): Promise { + // Subscribe once; share the subscription across all clients. + this.unsubscribe = await this.opts.store.subscribe((snap) => { + this.broadcast({ type: 'snapshot', snapshot: snap }); + }); + } + + async stop(): Promise { + if (this.unsubscribe) { + await this.unsubscribe(); + this.unsubscribe = null; + } + this.clients.clear(); + } + + onOpen(ws: WsLike): void { + this.clients.add(ws); + // Send the latest snapshot on connect so the client can render immediately. + void this.opts.store.latestSnapshot().then((snap) => { + if (snap) this.send(ws, { type: 'snapshot', snapshot: snap }); + }); + } + + onMessage(_evt: { data: unknown }, _ws: WsLike): void { + // Phase 1: clients are read-only. + // Phase 2 will accept subscriptions like { type: 'subscribe', vesselId: '...' } + } + + onClose(ws: WsLike): void { + this.clients.delete(ws); + } + + broadcast(msg: LiveMessage): void { + const data = JSON.stringify(msg); + for (const ws of this.clients) { + if (ws.readyState === READY_STATE_OPEN) ws.send(data); + } + } + + private send(ws: WsLike, msg: LiveMessage): void { + if (ws.readyState === READY_STATE_OPEN) ws.send(JSON.stringify(msg)); + } + + get clientCount(): number { + return this.clients.size; } } -export const handleLiveSocket = { - onOpen(ws: WsLike): void { - clients.add(ws); - }, +/** Backward-compat shim for the Phase 0 module shape. */ +export function makeLiveHub(opts: LiveSocketOpts): LiveSocketHub { + return new LiveSocketHub(opts); +} - onMessage(_evt: { data: unknown }, _ws: WsLike): void { - // Phase 0: clients are read-only, ignore incoming. - // Phase 2: accept subscriptions like { type: 'subscribe', vesselId: '...' } - }, - - onClose(ws: WsLike): void { - clients.delete(ws); - }, - - broadcast(msg: LiveMessage): void { - for (const ws of clients) send(ws, msg); - }, - - /** For tests / introspection. */ - get clientCount(): number { - return clients.size; - }, -}; +export type { UniverseSnapshot }; diff --git a/apps/api/tests/health.test.ts b/apps/api/tests/health.test.ts index 55be1b8..efba11f 100644 --- a/apps/api/tests/health.test.ts +++ b/apps/api/tests/health.test.ts @@ -2,10 +2,102 @@ import { describe, it, expect } from 'vitest'; import { app } from '../src/test-app.js'; describe('health', () => { - it('returns ok', async () => { + it('returns ok with service info', async () => { const res = await app.request('/health'); expect(res.status).toBe(200); const body = await res.json(); expect(body.ok).toBe(true); + expect(body.service).toBe('kerbal-rt-api'); + expect(body.store).toBe('InMemoryStateStore'); + }); + + it('returns 503 NO_DATA when no snapshot has been ingested', async () => { + const res = await app.request('/api/v1/state'); + expect(res.status).toBe(503); + const body = await res.json(); + expect(body.error).toBe(true); + expect(body.code).toBe('NO_DATA'); + }); + + it('accepts a valid snapshot and returns it on /api/v1/state', async () => { + const snap = { + ut: 1, + capturedAt: '2026-01-01T00:00:00Z', + activeVesselId: null, + bodies: [], + vessels: [], + groundStations: [], + }; + const ingest = await app.request('/api/v1/ingest', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(snap), + }); + expect(ingest.status).toBe(200); + const ingestBody = await ingest.json(); + expect(ingestBody.error).toBe(false); + expect(ingestBody.data.ok).toBe(true); + + const state = await app.request('/api/v1/state'); + expect(state.status).toBe(200); + const stateBody = await state.json(); + expect(stateBody.data.ut).toBe(1); + }); + + it('rejects malformed snapshots with 400', async () => { + const res = await app.request('/api/v1/ingest', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ broken: 'payload' }), + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe(true); + expect(body.code).toBe('BAD_PAYLOAD'); + }); + + it('looks up vessels by id', async () => { + const snap = { + ut: 1, + capturedAt: '2026-01-01T00:00:00Z', + activeVesselId: 'v-1', + bodies: [], + vessels: [ + { + id: 'v-1', + name: 'Test', + type: 'Probe', + owner: 'KASA', + situation: 'ORBITING', + status: 'ACTIVE', + orbit: { + semiMajorAxis: 7e6, + eccentricity: 0, + inclination: 0, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: 0, + epoch: 0, + }, + referenceBodyId: 'kerbin', + createdAt: '2026-01-01T00:00:00Z', + retiredAt: null, + }, + ], + groundStations: [], + }; + await app.request('/api/v1/ingest', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(snap), + }); + + const found = await app.request('/api/v1/vessels/v-1'); + expect(found.status).toBe(200); + const body = await found.json(); + expect(body.data.name).toBe('Test'); + + const missing = await app.request('/api/v1/vessels/does-not-exist'); + expect(missing.status).toBe(404); }); }); diff --git a/apps/api/tests/ws.test.ts b/apps/api/tests/ws.test.ts new file mode 100644 index 0000000..abe8d37 --- /dev/null +++ b/apps/api/tests/ws.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from 'vitest'; +import { createServer, type Server } from 'node:http'; +import { WebSocket } from 'ws'; +import { createNodeWebSocket } from '@hono/node-ws'; +import { InMemoryStateStore } from '@kerbal-rt/db'; +import { buildApp } from '../src/app.js'; +import { LiveSocketHub } from '../src/ws.js'; + +/** + * Integration test: the API exposes /api/v1/live over WebSocket. + * When a snapshot is applied to the store, the WS hub broadcasts it + * to every connected client. + */ +describe('WebSocket live feed', () => { + async function startTestServer(): Promise<{ + server: Server; + port: number; + store: InMemoryStateStore; + close: () => Promise; + }> { + const store = new InMemoryStateStore(); + const hub = new LiveSocketHub({ store, channel: 'test' }); + await hub.start(); + + const app = buildApp({ store, ingestApiKey: null }); + const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({ app }); + + app.get( + '/api/v1/live', + upgradeWebSocket(() => ({ + onOpen: (_evt, ws) => hub.onOpen(ws), + onMessage: (evt, ws) => hub.onMessage(evt, ws), + onClose: (_evt, ws) => hub.onClose(ws), + })), + ); + + const server = createServer(); + injectWebSocket(server); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as { port: number }).port; + + return { + server, + port, + store, + async close() { + await hub.stop(); + await new Promise((resolve) => server.close(() => resolve())); + }, + }; + } + + it('sends the current snapshot to a newly connected client', async () => { + const { port, store, close } = await startTestServer(); + try { + await store.applySnapshot({ + ut: 42, + capturedAt: 'x', + activeVesselId: null, + bodies: [], + vessels: [], + groundStations: [], + }); + + const ws = new WebSocket(`ws://127.0.0.1:${port}/api/v1/live`); + const firstMessage = await new Promise((resolve, reject) => { + ws.once('open', () => {}); + ws.once('message', (data) => { + resolve(data.toString()); + ws.close(); + }); + ws.once('error', reject); + setTimeout(() => reject(new Error('timeout')), 5000); + }); + + const parsed = JSON.parse(firstMessage); + expect(parsed.type).toBe('snapshot'); + expect(parsed.snapshot.ut).toBe(42); + } finally { + await close(); + } + }); + + it('broadcasts new snapshots to all connected clients', async () => { + const { port, store, close } = await startTestServer(); + try { + const ws1 = new WebSocket(`ws://127.0.0.1:${port}/api/v1/live`); + const ws2 = new WebSocket(`ws://127.0.0.1:${port}/api/v1/live`); + + // Wait for both to open + await Promise.all([ + new Promise((r) => ws1.once('open', () => r())), + new Promise((r) => ws2.once('open', () => r())), + ]); + + // Attach persistent handlers FIRST (so we don't miss any message + // during the drain→wait window), then drain the initial snapshot + // messages and wait for the broadcast. + const received = { ws1: false, ws2: false }; + const waiters: Promise[] = []; + const makeWaiter = (key: 'ws1' | 'ws2', ws: WebSocket) => + new Promise((resolve) => { + ws.on('message', (data) => { + const msg = JSON.parse(data.toString()); + if (msg.type === 'snapshot' && msg.snapshot.ut === 99) { + received[key] = true; + resolve(); + } + }); + // Safety timeout so a failing test doesn't hang forever + setTimeout(() => resolve(), 3000); + }); + waiters.push(makeWaiter('ws1', ws1)); + waiters.push(makeWaiter('ws2', ws2)); + + // Wait for the initial snapshot to land on both before we apply + // the new one — otherwise we may set up the waiter after it + // arrives (race condition with `once`). + await new Promise((r) => setTimeout(r, 100)); + + await store.applySnapshot({ + ut: 99, + capturedAt: 'x', + activeVesselId: null, + bodies: [], + vessels: [], + groundStations: [], + }); + + await Promise.all(waiters); + + expect(received.ws1).toBe(true); + expect(received.ws2).toBe(true); + + ws1.close(); + ws2.close(); + } finally { + await close(); + } + }); +}); diff --git a/apps/hub/src/app/debug/page.tsx b/apps/hub/src/app/debug/page.tsx new file mode 100644 index 0000000..c6f2342 --- /dev/null +++ b/apps/hub/src/app/debug/page.tsx @@ -0,0 +1,195 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Card, Pill, formatUtAsKspDate } from '@kerbal-rt/ui'; +import type { LiveMessage, UniverseSnapshot } from '@kerbal-rt/shared-types'; + +const API_URL = + (typeof process !== 'undefined' && process.env.NEXT_PUBLIC_API_URL) || 'http://localhost:4000'; + +/** + * Debug page for Phase 1. + * + * Connects to /api/v1/live over WebSocket and shows the latest + * universe snapshot. Use this to verify the ingest → state → WS + * pipeline is working end-to-end. + * + * Run the mock-telemetry publisher in another terminal to drive + * the state, or POST to /api/v1/ingest manually. + */ +export default function DebugPage() { + const [snap, setSnap] = useState(null); + const [status, setStatus] = useState<'connecting' | 'open' | 'closed'>('connecting'); + const [lastUpdate, setLastUpdate] = useState(null); + const [messageCount, setMessageCount] = useState(0); + const [error, setError] = useState(null); + + useEffect(() => { + const url = API_URL.replace(/^http/, 'ws') + '/api/v1/live'; + let ws: WebSocket | null = null; + let reconnectTimer: number | null = null; + + const connect = () => { + setStatus('connecting'); + setError(null); + try { + ws = new WebSocket(url); + } catch (e) { + setError(String((e as Error).message ?? e)); + scheduleReconnect(); + return; + } + ws.onopen = () => { + setStatus('open'); + }; + ws.onmessage = (event) => { + setMessageCount((n) => n + 1); + setLastUpdate(new Date().toISOString()); + try { + const msg = JSON.parse(event.data) as LiveMessage; + if (msg.type === 'snapshot') { + setSnap(msg.snapshot); + } + // ping/event_new/etc are ignored on the debug page + } catch (e) { + setError(`bad message: ${String((e as Error).message ?? e)}`); + } + }; + ws.onerror = () => { + // The close event will fire too; we just record the moment + }; + ws.onclose = () => { + setStatus('closed'); + scheduleReconnect(); + }; + }; + + const scheduleReconnect = () => { + if (reconnectTimer) return; + reconnectTimer = window.setTimeout(connect, 2000); + }; + + connect(); + return () => { + if (reconnectTimer) window.clearTimeout(reconnectTimer); + if (ws) ws.close(); + }; + }, []); + + return ( +
+
+

Debug — Live State

+ + WS {status} + + {messageCount} msgs +
+ + {error && ( + +

{error}

+
+ )} + +
+ + +
    +
  • API: {API_URL}
  • +
  • WebSocket: {API_URL.replace(/^http/, 'ws')}/api/v1/live
  • +
  • Status: {status}
  • +
  • Last update: {lastUpdate ?? '—'}
  • +
+
+ +
+ + {!snap ? ( + +

No snapshot received. Make sure:

+
    +
  1. The API is running at {API_URL}
  2. +
  3. + Either the mock-telemetry publisher is running, or you've POSTed to{' '} + /api/v1/ingest +
  4. +
+

+ Try the mock publisher from the repo root: +
+ pnpm --filter @kerbal-rt/mock-telemetry start +

+
+ ) : ( + <> + +
    +
  • UT: {formatUtAsKspDate(snap.ut)}
  • +
  • Captured at: {snap.capturedAt}
  • +
  • Bodies: {snap.bodies.length}
  • +
  • Vessels: {snap.vessels.length}
  • +
  • Ground stations: {snap.groundStations.length}
  • +
  • Active vessel: {snap.activeVesselId ?? '—'}
  • +
+
+ +
+ + + + + + + + + + + + + + + + {snap.vessels.map((v) => ( + + + + + + + + + + ))} + +
IDNameOwnerSituationBodysmaecc
+ {v.id} + {v.name}{v.owner ?? '—'}{v.situation}{v.referenceBodyId}{(v.orbit.semiMajorAxis / 1000).toFixed(0)} km{v.orbit.eccentricity.toFixed(3)}
+
+ +
+ + +
+ {snap.bodies.map((b) => ( + + {b.name} ({b.kind}) + + ))} +
+
+ + )} +
+ ); +} diff --git a/apps/hub/src/app/page.tsx b/apps/hub/src/app/page.tsx index 24992bc..cc0ea45 100644 --- a/apps/hub/src/app/page.tsx +++ b/apps/hub/src/app/page.tsx @@ -22,7 +22,12 @@ export default function HomePage() { diff --git a/apps/tools/mock-telemetry/package.json b/apps/tools/mock-telemetry/package.json new file mode 100644 index 0000000..b6db3ac --- /dev/null +++ b/apps/tools/mock-telemetry/package.json @@ -0,0 +1,24 @@ +{ + "name": "@kerbal-rt/mock-telemetry", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Generates realistic KSP universe state and POSTs to the API for development", + "main": "./src/index.ts", + "scripts": { + "start": "tsx src/index.ts", + "dev": "tsx watch src/index.ts", + "typecheck": "tsc --noEmit", + "lint": "echo 'no linter yet'", + "test": "echo 'no tests yet'" + }, + "dependencies": { + "@kerbal-rt/orbital-math": "workspace:*", + "@kerbal-rt/shared-types": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.5.0", + "tsx": "^4.19.1", + "typescript": "^5.6.2" + } +} diff --git a/apps/tools/mock-telemetry/src/catalog.ts b/apps/tools/mock-telemetry/src/catalog.ts new file mode 100644 index 0000000..dd30a3d --- /dev/null +++ b/apps/tools/mock-telemetry/src/catalog.ts @@ -0,0 +1,414 @@ +/** + * Hard-coded Kerbol system catalog. Matches the SQL seed in + * infra/init-sql/02-bodies-seed.sql so the mock and real worlds agree. + * + * Only the fields the live map needs to render are included. The + * real telemetry bridge (Phase 1c) will pull these from kRPC. + */ +import type { CelestialBody } from '@kerbal-rt/shared-types'; + +export const KERBOL_SYSTEM: CelestialBody[] = [ + { + id: 'kerbol', + name: 'Kerbol', + kind: 'star', + parentId: null, + radius: 261_600_000, + sphereOfInfluence: 1e30, // sentinel for "infinity" at JSON boundary + gravitationalParameter: 1.172332794e18, + rotationPeriod: 432_000, + axialTilt: 0, + orbit: { + semiMajorAxis: 0, + eccentricity: 0, + inclination: 0, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: 0, + epoch: 0, + }, + }, + { + id: 'moho', + name: 'Moho', + kind: 'planet', + parentId: 'kerbol', + radius: 250_000, + sphereOfInfluence: 9_646_663, + gravitationalParameter: 1.686842e11, + rotationPeriod: 1_210_000, + axialTilt: 0.05, + orbit: { + semiMajorAxis: 5_263_138_304, + eccentricity: 0.2, + inclination: 0.075, + longitudeOfAscendingNode: 1.5, + argumentOfPeriapsis: 2.8, + meanAnomalyAtEpoch: 1.0, + epoch: 0, + }, + }, + { + id: 'eve', + name: 'Eve', + kind: 'planet', + parentId: 'kerbol', + radius: 700_000, + sphereOfInfluence: 85_109_365, + gravitationalParameter: 8.1717302e12, + rotationPeriod: 80_500, + axialTilt: 0.1, + orbit: { + semiMajorAxis: 9_832_684_544, + eccentricity: 0.01, + inclination: 0.1, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: 3.14, + epoch: 0, + }, + }, + { + id: 'kerbin', + name: 'Kerbin', + kind: 'planet', + parentId: 'kerbol', + radius: 600_000, + sphereOfInfluence: 84_159_286, + gravitationalParameter: 3.5316e12, + rotationPeriod: 21_600, + axialTilt: 0, + orbit: { + semiMajorAxis: 13_599_840_256, + eccentricity: 0.05, + inclination: 0, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: 0.7, + epoch: 0, + }, + }, + { + id: 'duna', + name: 'Duna', + kind: 'planet', + parentId: 'kerbol', + radius: 320_000, + sphereOfInfluence: 47_921_949, + gravitationalParameter: 3.0136321e11, + rotationPeriod: 65_518, + axialTilt: 0.06, + orbit: { + semiMajorAxis: 20_726_155_264, + eccentricity: 0.05, + inclination: 0.02, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: 1.5, + epoch: 0, + }, + }, + { + id: 'dres', + name: 'Dres', + kind: 'planet', + parentId: 'kerbol', + radius: 138_000, + sphereOfInfluence: 32_832_840, + gravitationalParameter: 2.1484489e10, + rotationPeriod: 34_800, + axialTilt: 0.08, + orbit: { + semiMajorAxis: 40_839_348_203, + eccentricity: 0.14, + inclination: 0.08, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: 2.1, + epoch: 0, + }, + }, + { + id: 'jool', + name: 'Jool', + kind: 'planet', + parentId: 'kerbol', + radius: 6_000_000, + sphereOfInfluence: 2_450_055_988, + gravitationalParameter: 2.82528e14, + rotationPeriod: 36_000, + axialTilt: 0.05, + orbit: { + semiMajorAxis: 68_773_560_320, + eccentricity: 0.05, + inclination: 0.04, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: 4.2, + epoch: 0, + }, + }, + { + id: 'eeloo', + name: 'Eeloo', + kind: 'planet', + parentId: 'kerbol', + radius: 210_000, + sphereOfInfluence: 119_082_940, + gravitationalParameter: 7.4410815e10, + rotationPeriod: 19_460, + axialTilt: 0.1, + orbit: { + semiMajorAxis: 90_118_820_000, + eccentricity: 0.26, + inclination: 0.13, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: 5.5, + epoch: 0, + }, + }, + { + id: 'gilly', + name: 'Gilly', + kind: 'moon', + parentId: 'eve', + radius: 13_000, + sphereOfInfluence: 126_123, + gravitationalParameter: 8_289_449.8, + rotationPeriod: 28_260, + axialTilt: 0.05, + orbit: { + semiMajorAxis: 31_500_000, + eccentricity: 0.18, + inclination: 0.2, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: 0.5, + epoch: 0, + }, + }, + { + id: 'mun', + name: 'Mun', + kind: 'moon', + parentId: 'kerbin', + radius: 200_000, + sphereOfInfluence: 2_429_559, + gravitationalParameter: 6.514e10, + rotationPeriod: 138_984, + axialTilt: 0, + orbit: { + semiMajorAxis: 12_000_000, + eccentricity: 0, + inclination: 0, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: 1.0, + epoch: 0, + }, + }, + { + id: 'minmus', + name: 'Minmus', + kind: 'moon', + parentId: 'kerbin', + radius: 60_000, + sphereOfInfluence: 2_247_428, + gravitationalParameter: 1.765e9, + rotationPeriod: 40_400, + axialTilt: 0.04, + orbit: { + semiMajorAxis: 47_000_000, + eccentricity: 0.0, + inclination: 0.075, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: 2.5, + epoch: 0, + }, + }, + { + id: 'ike', + name: 'Ike', + kind: 'moon', + parentId: 'duna', + radius: 130_000, + sphereOfInfluence: 1_048_598, + gravitationalParameter: 1.856e9, + rotationPeriod: 65_518, + axialTilt: 0.05, + orbit: { + semiMajorAxis: 3_200_000, + eccentricity: 0.0, + inclination: 0, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: 1.5, + epoch: 0, + }, + }, + { + id: 'laythe', + name: 'Laythe', + kind: 'moon', + parentId: 'jool', + radius: 500_000, + sphereOfInfluence: 3_723_645, + gravitationalParameter: 1.84e11, + rotationPeriod: 52_980, + axialTilt: 0, + orbit: { + semiMajorAxis: 27_184_000, + eccentricity: 0.0, + inclination: 0, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: 2.0, + epoch: 0, + }, + }, + { + id: 'vall', + name: 'Vall', + kind: 'moon', + parentId: 'jool', + radius: 240_000, + sphereOfInfluence: 2_406_401, + gravitationalParameter: 2.061e10, + rotationPeriod: 106_200, + axialTilt: 0, + orbit: { + semiMajorAxis: 43_152_000, + eccentricity: 0.0, + inclination: 0, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: 2.5, + epoch: 0, + }, + }, + { + id: 'tylo', + name: 'Tylo', + kind: 'moon', + parentId: 'jool', + radius: 375_000, + sphereOfInfluence: 10_856_418, + gravitationalParameter: 2.122e11, + rotationPeriod: 84_600, + axialTilt: 0, + orbit: { + semiMajorAxis: 68_500_000, + eccentricity: 0.0, + inclination: 0, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: 3.0, + epoch: 0, + }, + }, + { + id: 'bop', + name: 'Bop', + kind: 'moon', + parentId: 'jool', + radius: 65_000, + sphereOfInfluence: 1_220_600, + gravitationalParameter: 2.486e8, + rotationPeriod: 360, + axialTilt: 0.05, + orbit: { + semiMajorAxis: 128_500_000, + eccentricity: 0.23, + inclination: 0.2, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: 4.0, + epoch: 0, + }, + }, + { + id: 'pol', + name: 'Pol', + kind: 'moon', + parentId: 'jool', + radius: 44_000, + sphereOfInfluence: 1_042_138, + gravitationalParameter: 7.214e7, + rotationPeriod: 340, + axialTilt: 0.05, + orbit: { + semiMajorAxis: 179_890_000, + eccentricity: 0.17, + inclination: 0.15, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: 4.5, + epoch: 0, + }, + }, +]; + +/** A starter fleet — three vessels in different situations. */ +export const STARTER_FLEET = [ + { + id: 'kasa-regolith-1', + name: 'CAPS Regolith 1', + type: 'Probe', + owner: 'KASA', + situation: 'ORBITING' as const, + status: 'ACTIVE' as const, + referenceBodyId: 'kerbin', + initialOrbit: { + semiMajorAxis: 7_500_000, // ~700km LKO + eccentricity: 0.01, + inclination: 0.05, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: 0, + epoch: 0, + }, + createdAt: '2026-01-15T00:00:00Z', + }, + { + id: 'spes-longshot', + name: 'SPES Longshot', + type: 'Probe', + owner: 'SPES', + situation: 'ESCAPING' as const, + status: 'ACTIVE' as const, + referenceBodyId: 'kerbol', + initialOrbit: { + // Heliocentric transfer ellipse to Duna + semiMajorAxis: 17_000_000_000, + eccentricity: 0.18, + inclination: 0.02, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 1.2, + meanAnomalyAtEpoch: 0, + epoch: 0, + }, + createdAt: '2026-02-20T00:00:00Z', + }, + { + id: 'kasa-relay-iii', + name: 'KASA Relay Net III', + type: 'Relay', + owner: 'KASA', + situation: 'ORBITING' as const, + status: 'ACTIVE' as const, + referenceBodyId: 'duna', + initialOrbit: { + // Duna stationary-ish relay + semiMajorAxis: 4_700_000, + eccentricity: 0.0, + inclination: 0, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: 0, + epoch: 0, + }, + createdAt: '2026-03-10T00:00:00Z', + }, +]; diff --git a/apps/tools/mock-telemetry/src/index.ts b/apps/tools/mock-telemetry/src/index.ts new file mode 100644 index 0000000..bd245b6 --- /dev/null +++ b/apps/tools/mock-telemetry/src/index.ts @@ -0,0 +1,166 @@ +/** + * Mock KSP telemetry publisher. + * + * Generates a universe snapshot at a configurable cadence (default 1Hz) + * using the KERBOL_SYSTEM catalog and a starter fleet of vessels. The + * vessel orbits are propagated forward in time using the + * @kerbal-rt/orbital-math library — exactly the same math the live-map + * uses to render the scene, so the publisher and renderer stay in sync. + * + * Each generated snapshot is POSTed to the API at /api/v1/ingest. + * Connect the hub's /debug page (or any WS client) and you'll see the + * state update in real time. + */ +import { meanMotion } from '@kerbal-rt/orbital-math'; +import type { + CelestialBody, + KeplerianElements, + UniverseSnapshot, + Vessel, +} from '@kerbal-rt/shared-types'; +import { KERBOL_SYSTEM, STARTER_FLEET } from './catalog.js'; + +const API_URL = process.env.MOCK_API_URL ?? 'http://localhost:4000'; +const API_KEY = process.env.INGEST_API_KEY ?? ''; +const INTERVAL_MS = Number(process.env.MOCK_INTERVAL_MS ?? 1000); +const START_UT = Number(process.env.MOCK_START_UT ?? 4_700_000); + +interface MutableVessel { + id: string; + name: string; + type: string | null; + owner: string | null; + situation: Vessel['situation']; + status: Vessel['status']; + referenceBodyId: string; + /** Working keplerian elements at the last emitted time. */ + elements: KeplerianElements; + createdAt: string; + retiredAt: string | null; +} + +const bodies: CelestialBody[] = KERBOL_SYSTEM; +const bodiesById = new Map(bodies.map((b) => [b.id, b])); + +const vessels: MutableVessel[] = STARTER_FLEET.map((v) => ({ + id: v.id, + name: v.name, + type: v.type, + owner: v.owner, + situation: v.situation, + status: v.status, + referenceBodyId: v.referenceBodyId, + elements: { ...v.initialOrbit, epoch: START_UT }, + createdAt: v.createdAt, + retiredAt: null, +})); + +let currentUt = START_UT; +let snapshotCount = 0; +let lastError: string | null = null; + +async function publish(snap: UniverseSnapshot): Promise { + const headers: Record = { 'content-type': 'application/json' }; + if (API_KEY) headers['x-api-key'] = API_KEY; + + try { + const res = await fetch(`${API_URL}/api/v1/ingest`, { + method: 'POST', + headers, + body: JSON.stringify(snap), + }); + if (!res.ok) { + lastError = `HTTP ${res.status}`; + } else { + lastError = null; + } + } catch (err) { + lastError = String((err as Error).message ?? err); + } +} + +function buildSnapshot(ut: number): UniverseSnapshot { + // Advance each vessel's mean anomaly by n·dt from its last known epoch. + for (const v of vessels) { + const ref = bodiesById.get(v.referenceBodyId); + if (!ref) continue; + const dt = ut - v.elements.epoch; + if (dt !== 0) { + const n = meanMotion(v.elements.semiMajorAxis, ref.gravitationalParameter); + v.elements = { + ...v.elements, + meanAnomalyAtEpoch: v.elements.meanAnomalyAtEpoch + n * dt, + epoch: ut, + }; + } + } + + const vesselsOut: Vessel[] = vessels.map((v) => ({ + id: v.id, + name: v.name, + type: v.type, + owner: v.owner, + situation: v.situation, + status: v.status, + orbit: v.elements, + referenceBodyId: v.referenceBodyId, + createdAt: v.createdAt, + retiredAt: v.retiredAt, + })); + + return { + ut, + capturedAt: new Date().toISOString(), + activeVesselId: vessels[0]?.id ?? null, + bodies, + vessels: vesselsOut, + groundStations: [ + { + id: 'montana', + name: 'Montana DSN', + bodyId: 'kerbin', + lat: 47.0, + lon: -110.0, + alt: 1200, + }, + ], + }; +} + +async function tick(): Promise { + currentUt += INTERVAL_MS / 1000; // 1s wall = 1s KSP UT (1x speed) + const snap = buildSnapshot(currentUt); + snapshotCount += 1; + await publish(snap); +} + +function printStatus(): void { + const status = lastError ? `ERROR (${lastError})` : 'OK'; + // eslint-disable-next-line no-console + console.log( + `[mock-telemetry] ut=${currentUt.toFixed(0)} snapshots=${snapshotCount} → ${API_URL} ${status}`, + ); +} + +async function main(): Promise { + // eslint-disable-next-line no-console + console.log( + `[mock-telemetry] starting — API=${API_URL} interval=${INTERVAL_MS}ms key=${API_KEY ? 'set' : 'unset'} start_ut=${START_UT}`, + ); + // eslint-disable-next-line no-console + console.log(`[mock-telemetry] publishing ${bodies.length} bodies, ${vessels.length} vessels`); + + // Send the first snapshot immediately, then on the interval. + await tick(); + printStatus(); + setInterval(async () => { + await tick(); + }, INTERVAL_MS); + setInterval(printStatus, 10_000); +} + +main().catch((err) => { + // eslint-disable-next-line no-console + console.error('[mock-telemetry] fatal:', err); + process.exit(1); +}); diff --git a/apps/tools/mock-telemetry/tsconfig.json b/apps/tools/mock-telemetry/tsconfig.json new file mode 100644 index 0000000..adb813a --- /dev/null +++ b/apps/tools/mock-telemetry/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"] +} diff --git a/ksp/.gitkeep b/ksp/.gitkeep new file mode 100644 index 0000000..4484483 --- /dev/null +++ b/ksp/.gitkeep @@ -0,0 +1,3 @@ +# This file exists so git tracks the ksp/ directory. +# The real KSP-side bridge (Phase 1c) lands here. +# See ./README.md for the design. diff --git a/ksp/README.md b/ksp/README.md new file mode 100644 index 0000000..2c82762 --- /dev/null +++ b/ksp/README.md @@ -0,0 +1,118 @@ +# KSP-side Telemetry Bridge + +This directory will hold the bridge between a running Kerbal Space +Program game and the kerbal-rt API. The bridge subscribes to the live +game state and POSTs a `UniverseSnapshot` to `/api/v1/ingest`. + +> **Status: Phase 1c — not yet implemented.** +> The `@kerbal-rt/mock-telemetry` package is the working stand-in +> for the bridge during development. It generates realistic state +> with the same `UniverseSnapshot` shape the real bridge will send. + +## Two implementation options + +### Option A — kRPC (recommended, fastest to ship) + +[kRPC](https://github.com/krpc/krpc) is the modern, well-maintained +RPC framework for KSP 1.12.x. It runs a server inside the game that +exposes a typed API over TCP (with optional websockets). + +**Setup:** + +1. Install KSP 1.12.5 + [ckan](https://github.com/KSP-CKAN/CKAN) +2. `ckan install kRPC` — pulls in the server mod + protobuf defs +3. Start KSP, load your save, start a kRPC server (default port 50000) +4. Run a small Node client that subscribes to streams: + - `vessel.orbit` (returns a tuple of orbital elements) + - `vessel.situation` + - `space_center.ut` + - `body.orbit` for each body + - `space_center.transform_position`/`rotation` for ground stations +5. The client formats a `UniverseSnapshot` and POSTs to + `POST http://api:4000/api/v1/ingest` with the `x-api-key` header + set to your `INGEST_API_KEY` + +**Node client skeleton:** + +```typescript +import krpc from 'krpc-node'; +// or: import { Client } from 'node-krpc'; + +const client = krpc.connect({ host: 'localhost', rpcPort: 50000 }); +const sc = client.spaceCenter; + +// Subscribe to streams (push every 1s) +const ut = client.addStream(() => sc.ut); +const vessels = await sc.vessels; + +setInterval(async () => { + const snap = { + ut: ut.get(), + capturedAt: new Date().toISOString(), + activeVesselId: sc.activeVessel?.id.toString() ?? null, + bodies: await buildBodies(client), + vessels: await Promise.all(vessels.map(v => buildVessel(client, v))), + groundStations: await buildGroundStations(client), + }; + await fetch('http://localhost:4000/api/v1/ingest', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': process.env.INGEST_API_KEY! }, + body: JSON.stringify(snap), + }); +}, 1000); +``` + +(There's no official kRPC Node client, but a quick `protobufjs` setup +using the .proto files from the kRPC mod works in <300 lines.) + +### Option B — Custom KSP mod (most flexible) + +A C# KSP mod that uses Harmony to patch into `FlightGlobals` and +publishes state on each physics tick. Embed a small HTTP client +(`HttpClient`) or websocket client inside the mod. + +- **Pro:** Total control, can publish *events* (stage, maneuver node, + collision) not just state. Can disable the publish path with a + toggle in the mod's UI. +- **Con:** You own the codebase forever. Have to maintain it across + KSP updates. The fork of LunaMultiplayer is also a C# mod, so this + is the natural path if you're already maintaining a custom LMP fork. + +**When to use this:** only if kRPC can't give you the data you need +(e.g. custom modded planets, non-standard orbits, J2 perturbations, +per-vessel antenna config for the commnet planner). For the stock +Kerbol system, kRPC is enough. + +## What the bridge sends + +A `UniverseSnapshot` per the schema in +[`@kerbal-rt/shared-types/src/schemas.ts`](../packages/shared-types/src/schemas.ts). +The mock publisher's output +([`apps/tools/mock-telemetry/src/index.ts`](../apps/tools/mock-telemetry/src/index.ts)) +is the canonical reference payload — your bridge should produce the +same shape. + +## Running the real bridge + +```bash +# 1. Make sure KSP is running with the kRPC mod enabled +# 2. Make sure the API is running (Phase 1a) +# 3. Run the bridge (Phase 1c — TBD) +pnpm --filter @kerbal-rt/ksp-bridge start +# (this script doesn't exist yet; see Option A/B above) +``` + +## Why this isn't done yet + +The real bridge requires: +1. A real KSP 1.12.5 install with kRPC mod loaded +2. A save with vessels, in a state interesting enough to publish +3. Iterating on the protocol against the real game (KSP exposes + orbital data in KSP-specific frames; you have to translate to + the heliocentric ecliptic frame for the API) + +We can do all of that, but the value of a working mock-driven +pipeline (which the user already has) is much higher than a real +bridge sitting unused. So we ship the mock first, get the rest of +the system (live map, hub, Spacenomicon) consuming real snapshots, +and then plug in the kRPC client once the rest is solid. diff --git a/packages/db/package.json b/packages/db/package.json new file mode 100644 index 0000000..c149aca --- /dev/null +++ b/packages/db/package.json @@ -0,0 +1,30 @@ +{ + "name": "@kerbal-rt/db", + "version": "0.1.0", + "private": true, + "description": "Persistence layer: Postgres + Timescale (telemetry) and Redis (pubsub + cache)", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./schema": "./src/schema.ts", + "./migrate": "./src/migrate.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "echo 'no linter yet'", + "test": "vitest run", + "test:watch": "vitest", + "build": "tsc" + }, + "dependencies": { + "@kerbal-rt/shared-types": "workspace:*", + "ioredis": "^5.4.1", + "postgres": "^3.4.4" + }, + "devDependencies": { + "typescript": "^5.6.2", + "vitest": "^2.1.1" + } +} diff --git a/packages/db/src/config.ts b/packages/db/src/config.ts new file mode 100644 index 0000000..7df9ace --- /dev/null +++ b/packages/db/src/config.ts @@ -0,0 +1,45 @@ +/** + * Configuration helpers for the db package. + * + * Reads from environment variables. Throws clearly if a required var + * is missing. + */ + +export interface DbConfig { + /** Postgres connection URL, e.g. postgres://user:pass@host:5432/db */ + databaseUrl: string; + /** Redis connection URL, e.g. redis://host:6379 */ + redisUrl: string; + /** When true, callers should use the in-memory fallback instead. */ + useInMemory: boolean; +} + +const REQUIRED_FOR_DB = ['DATABASE_URL', 'REDIS_URL'] as const; + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): DbConfig { + const databaseUrl = env.DATABASE_URL; + const redisUrl = env.REDIS_URL; + // Allow opt-out for dev (e.g. when Docker isn't available) + const forceInMemory = env.USE_IN_MEMORY === '1' || env.USE_IN_MEMORY === 'true'; + + if (forceInMemory) { + return { databaseUrl: '', redisUrl: '', useInMemory: true }; + } + + const missing = REQUIRED_FOR_DB.filter((k) => !env[k]); + if (missing.length > 0) { + // Don't crash on import — let the caller decide. The API's + // bootstrap() logs a warning and falls back to in-memory mode. + return { + databaseUrl: databaseUrl ?? '', + redisUrl: redisUrl ?? '', + useInMemory: true, + }; + } + + return { + databaseUrl: databaseUrl!, + redisUrl: redisUrl!, + useInMemory: false, + }; +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts new file mode 100644 index 0000000..6e50490 --- /dev/null +++ b/packages/db/src/index.ts @@ -0,0 +1,94 @@ +/** + * Public API of @kerbal-rt/db. + * + * Callers (the API, the mock-telemetry publisher, the kRPC bridge) get + * a single `createStore()` function that returns a fully-wired + * StateStore + the underlying clients. This way they don't need to + * know whether they're in dev (in-memory) or prod (Postgres+Redis). + */ +export { loadConfig, type DbConfig } from './config.js'; +export { createPgClient, type PgClient } from './postgres.js'; +export { createRedis, type RedisPair } from './redis.js'; +export { runMigrations } from './migrate.js'; +export { InMemoryStateStore, PgStateStore, type StateStore } from './store.js'; + +import { loadConfig, type DbConfig } from './config.js'; +import { createPgClient, type PgClient } from './postgres.js'; +import { createRedis, type RedisPair } from './redis.js'; +import { runMigrations } from './migrate.js'; +import { InMemoryStateStore, PgStateStore, type StateStore } from './store.js'; + +export interface WiredStore { + store: StateStore; + config: DbConfig; + /** Underlying clients (null in in-memory mode). */ + pg: PgClient | null; + redis: RedisPair | null; + /** Redis pub/sub channel name for snapshot notifications. */ + channel: string; + /** Tear down all clients. */ + close(): Promise; +} + +const SNAPSHOT_CHANNEL = 'kerbal-rt:snapshots'; + +export async function createStore(env: NodeJS.ProcessEnv = process.env): Promise { + const config = loadConfig(env); + + if (config.useInMemory) { + // Dev mode — no DB required. + return { + store: new InMemoryStateStore(), + config, + pg: null, + redis: null, + channel: SNAPSHOT_CHANNEL, + async close() { + /* nothing to close */ + }, + }; + } + + // Production mode — Postgres + Redis. + const pg = createPgClient(config.databaseUrl); + const redis = createRedis(config.redisUrl); + + // Run schema migrations on boot (idempotent). + const migrationResult = await runMigrations(pg.sql); + for (const m of migrationResult.applied) { + // eslint-disable-next-line no-console + console.log(`[db] migration applied: ${m}`); + } + + // Snapshot fan-out over Redis. The "publish" side is one-liner; + // the "subscribe" side needs a dedicated connection (handled by + // the RedisPair wrapper). + const store = new PgStateStore({ + sql: pg.sql, + async publish(payload) { + await redis.client.publish(SNAPSHOT_CHANNEL, payload); + }, + async redisSubscribe(handler) { + await redis.subscriber.subscribe(SNAPSHOT_CHANNEL); + const onMessage = (channel: string, message: string) => { + if (channel === SNAPSHOT_CHANNEL) handler(message); + }; + redis.subscriber.on('message', onMessage); + return async () => { + redis.subscriber.off('message', onMessage); + await redis.subscriber.unsubscribe(SNAPSHOT_CHANNEL).catch(() => {}); + }; + }, + }); + + return { + store, + config, + pg, + redis, + channel: SNAPSHOT_CHANNEL, + async close() { + await Promise.all([pg.close().catch(() => {}), redis.close().catch(() => {})]); + }, + }; +} diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts new file mode 100644 index 0000000..0ed57f2 --- /dev/null +++ b/packages/db/src/migrate.ts @@ -0,0 +1,48 @@ +/** + * Apply the schema migrations to a Postgres database. + * + * Reads the SQL files from infra/init-sql/ and runs them in lexical + * order. Idempotent — safe to run on every boot. + * + * In production you'd use a proper migration tool (drizzle-kit, + * prisma, knex, sqlx, etc.). For Phase 1 we just want a simple, + * zero-deps way to get the schema into a fresh database. + */ +import { readFile, readdir } from 'node:fs/promises'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Sql } from 'postgres'; + +const INIT_SQL_DEFAULT = '../../../infra/init-sql'; + +export async function runMigrations( + sql: Sql, + initDir: string = INIT_SQL_DEFAULT, +): Promise<{ applied: string[] }> { + const dir = resolve(dirname(fileURLToPath(import.meta.url)), initDir); + const entries = (await readdir(dir)).filter((f) => f.endsWith('.sql')).sort(); + + const applied: string[] = []; + for (const file of entries) { + const path = join(dir, file); + const content = await readFile(path, 'utf8'); + try { + await sql.unsafe(content); + applied.push(file); + } catch (err) { + // Some errors are non-fatal (e.g. "extension already exists" race), + // but anything else should fail loudly. + if (isBenignError(err)) { + applied.push(`${file} (skipped: ${(err as Error).message.split('\n')[0]})`); + continue; + } + throw new Error(`Migration ${file} failed: ${(err as Error).message}`); + } + } + return { applied }; +} + +function isBenignError(err: unknown): boolean { + const msg = String((err as Error).message ?? err); + return /already exists/i.test(msg) || /duplicate key/i.test(msg); +} diff --git a/packages/db/src/postgres.ts b/packages/db/src/postgres.ts new file mode 100644 index 0000000..9a69c87 --- /dev/null +++ b/packages/db/src/postgres.ts @@ -0,0 +1,31 @@ +/** + * Postgres + Timescale connection wrapper. + * + * Uses `postgres.js` for queries and connection pooling. The schema + * lives in ../../infra/init-sql/01-schema.sql and is applied by + * `migrate.ts` (and automatically on first boot by the API's + * bootstrap helper). + */ +import postgres from 'postgres'; +import type { Sql } from 'postgres'; + +export interface PgClient { + sql: Sql; + close(): Promise; +} + +export function createPgClient(url: string): PgClient { + const sql = postgres(url, { + max: 10, + idle_timeout: 30, + connect_timeout: 10, + // Reduce log noise; we use console for the rest of the app + onnotice: () => {}, + }); + return { + sql, + async close() { + await sql.end({ timeout: 5 }); + }, + }; +} diff --git a/packages/db/src/redis.ts b/packages/db/src/redis.ts new file mode 100644 index 0000000..58166de --- /dev/null +++ b/packages/db/src/redis.ts @@ -0,0 +1,33 @@ +/** + * Redis connection wrapper. Provides one client for normal commands + * and a separate one for pub/sub (Redis requires dedicated connections + * for subscribe mode). + */ +import Redis from 'ioredis'; + +export interface RedisPair { + /** For GET/SET/PUBLISH etc. */ + client: Redis; + /** For SUBSCRIBE only — must not be used for other commands. */ + subscriber: Redis; + close(): Promise; +} + +export function createRedis(url: string): RedisPair { + const client = new Redis(url, { + lazyConnect: false, + maxRetriesPerRequest: 3, + enableReadyCheck: true, + }); + const subscriber = new Redis(url, { + lazyConnect: false, + maxRetriesPerRequest: null, // subscribers reconnect forever + }); + return { + client, + subscriber, + async close() { + await Promise.all([client.quit().catch(() => {}), subscriber.quit().catch(() => {})]); + }, + }; +} diff --git a/packages/db/src/store.ts b/packages/db/src/store.ts new file mode 100644 index 0000000..9b92d45 --- /dev/null +++ b/packages/db/src/store.ts @@ -0,0 +1,234 @@ +/** + * StateStore — the interface every API caller uses to read/write + * the current universe state. Two implementations: + * + * - InMemoryStateStore : in-process; for dev when Postgres isn't up + * - PgStateStore : Postgres + Timescale; for production + * + * Both implement the same interface so the API code is identical. + */ +import type { CelestialBody, UniverseSnapshot, Vessel } from '@kerbal-rt/shared-types'; +import type { Sql } from 'postgres'; + +// ─── Interface ───────────────────────────────────────────────────────────── + +export interface StateStore { + /** Read the most recent snapshot. Returns null if none yet. */ + latestSnapshot(): Promise; + + /** List celestial bodies (catalog is small, kept in memory). */ + listBodies(): Promise; + + /** List currently-known vessels. */ + listVessels(): Promise; + + /** Look up a single vessel by ID. */ + getVessel(id: string): Promise; + + /** + * Persist a new snapshot. Returns the row count for telemetry + * (rows in telemetry_snapshots, vessels upserted, etc.). + * Publishes a notification on the "snapshots" channel so any + * WebSocket subscribers (in this or another API instance) see it. + */ + applySnapshot(snap: UniverseSnapshot): Promise<{ rowsInserted: number }>; + + /** Subscribe to snapshot updates. Yields the snapshot. */ + subscribe(handler: (snap: UniverseSnapshot) => void): Promise<() => Promise>; +} + +// ─── In-memory implementation ────────────────────────────────────────────── + +export class InMemoryStateStore implements StateStore { + private snapshot: UniverseSnapshot | null = null; + private vesselsById = new Map(); + private bodiesById = new Map(); + private subscribers = new Set<(snap: UniverseSnapshot) => void>(); + + async latestSnapshot() { + return this.snapshot; + } + async listBodies() { + return Array.from(this.bodiesById.values()); + } + async listVessels() { + return Array.from(this.vesselsById.values()); + } + async getVessel(id: string) { + return this.vesselsById.get(id) ?? null; + } + async applySnapshot(snap: UniverseSnapshot) { + this.snapshot = snap; + this.vesselsById = new Map(snap.vessels.map((v) => [v.id, v])); + this.bodiesById = new Map(snap.bodies.map((b) => [b.id, b])); + // Notify all subscribers asynchronously + queueMicrotask(() => { + for (const sub of this.subscribers) sub(snap); + }); + return { rowsInserted: 1 }; + } + async subscribe(handler: (snap: UniverseSnapshot) => void) { + this.subscribers.add(handler); + return async () => { + this.subscribers.delete(handler); + }; + } +} + +// ─── Postgres implementation ─────────────────────────────────────────────── + +interface PgStateStoreOpts { + sql: Sql; + /** Function to publish a JSON-serialized snapshot to Redis. */ + publish: (payload: string) => Promise; + /** Function to subscribe to snapshots over Redis. */ + redisSubscribe: (handler: (payload: string) => void) => Promise<() => Promise>; +} + +export class PgStateStore implements StateStore { + private latest: UniverseSnapshot | null = null; + private latestCacheAt = 0; + private static readonly CACHE_TTL_MS = 1000; // 1s hot cache + + constructor(private opts: PgStateStoreOpts) {} + + async latestSnapshot(): Promise { + // Hot cache: avoids hitting Postgres on every read. + if (this.latest && Date.now() - this.latestCacheAt < PgStateStore.CACHE_TTL_MS) { + return this.latest; + } + const rows = await this.opts.sql<{ payload: UniverseSnapshot }[]>` + SELECT payload FROM telemetry_snapshots + ORDER BY ts DESC LIMIT 1 + `; + if (rows.length === 0) return null; + this.latest = rows[0].payload; + this.latestCacheAt = Date.now(); + return this.latest; + } + + async listBodies(): Promise { + // Body catalog is small (~17 rows) and changes rarely. + // Cache it for 30s. + if (this._bodyCache && Date.now() - this._bodyCacheAt < PgStateStore.BODY_CACHE_TTL_MS) { + return this._bodyCache; + } + const rows = await this.opts.sql< + { + id: string; + name: string; + kind: string; + parent_id: string | null; + radius: number; + sphere_of_influence: number; + gravitational_parameter: number; + rotation_period: number; + axial_tilt: number; + elements: CelestialBody['orbit']; + }[] + >` + SELECT id, name, kind, parent_id, radius, sphere_of_influence, + gravitational_parameter, rotation_period, axial_tilt, elements + FROM bodies + ORDER BY id + `; + const bodies: CelestialBody[] = rows.map((r) => ({ + id: r.id, + name: r.name, + kind: r.kind as CelestialBody['kind'], + parentId: r.parent_id, + radius: r.radius, + sphereOfInfluence: r.sphere_of_influence, + gravitationalParameter: r.gravitational_parameter, + rotationPeriod: r.rotation_period, + axialTilt: r.axial_tilt, + orbit: r.elements, + })); + this._bodyCache = bodies; + this._bodyCacheAt = Date.now(); + return bodies; + } + + async listVessels(): Promise { + const snap = await this.latestSnapshot(); + return snap?.vessels ?? []; + } + + async getVessel(id: string): Promise { + const vessels = await this.listVessels(); + return vessels.find((v) => v.id === id) ?? null; + } + + async applySnapshot(snap: UniverseSnapshot): Promise<{ rowsInserted: number }> { + // 1) Persist the raw snapshot to Timescale (time-series). + // We also keep a "current state" copy in `vessels` for fast + // single-row lookups, and a `bodies` upsert so the catalog + // stays in sync with whatever the bridge sends. + await this.opts.sql` + INSERT INTO telemetry_snapshots (ts, ut, payload) + VALUES (now(), ${snap.ut}, ${this.opts.sql.json(JSON.parse(JSON.stringify(snap)))}) + `; + + // Upsert vessels (so vessel id is stable across snapshots). + for (const v of snap.vessels) { + await this.opts.sql` + INSERT INTO vessels (id, name, type, owner, status, created_at, retired_at) + VALUES (${v.id}, ${v.name}, ${v.type}, ${v.owner}, ${v.status}, + ${v.createdAt}::timestamptz, ${v.retiredAt}::timestamptz) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + type = EXCLUDED.type, + owner = EXCLUDED.owner, + status = EXCLUDED.status, + retired_at = EXCLUDED.retired_at + `; + } + + // Upsert bodies (catalog). + for (const b of snap.bodies) { + await this.opts.sql` + INSERT INTO bodies (id, name, kind, parent_id, radius, sphere_of_influence, + gravitational_parameter, rotation_period, axial_tilt, elements) + VALUES (${b.id}, ${b.name}, ${b.kind}, ${b.parentId}, ${b.radius}, + ${b.sphereOfInfluence}, ${b.gravitationalParameter}, + ${b.rotationPeriod}, ${b.axialTilt}, ${this.opts.sql.json(JSON.parse(JSON.stringify(b.orbit)))}) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + kind = EXCLUDED.kind, + parent_id = EXCLUDED.parent_id, + radius = EXCLUDED.radius, + sphere_of_influence = EXCLUDED.sphere_of_influence, + gravitational_parameter = EXCLUDED.gravitational_parameter, + rotation_period = EXCLUDED.rotation_period, + axial_tilt = EXCLUDED.axial_tilt, + elements = EXCLUDED.elements + `; + } + + // 2) Bust the hot cache. + this.latest = snap; + this.latestCacheAt = Date.now(); + this._bodyCache = null; + + // 3) Publish to Redis so other API instances + WS clients see it. + await this.opts.publish(JSON.stringify(snap)); + + return { rowsInserted: 1 }; + } + + async subscribe(handler: (snap: UniverseSnapshot) => void) { + return this.opts.redisSubscribe((payload) => { + try { + const snap = JSON.parse(payload) as UniverseSnapshot; + handler(snap); + } catch { + // ignore malformed messages + } + }); + } + + // ── private ── + private _bodyCache: CelestialBody[] | null = null; + private _bodyCacheAt = 0; + private static readonly BODY_CACHE_TTL_MS = 30_000; +} diff --git a/packages/db/tests/store.test.ts b/packages/db/tests/store.test.ts new file mode 100644 index 0000000..2efac07 --- /dev/null +++ b/packages/db/tests/store.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest'; +import { InMemoryStateStore } from '../src/store.js'; +import type { UniverseSnapshot } from '@kerbal-rt/shared-types'; + +function makeSnapshot(ut: number, vesselId = 'v-1'): UniverseSnapshot { + return { + ut, + capturedAt: new Date().toISOString(), + activeVesselId: vesselId, + bodies: [ + { + id: 'kerbin', + name: 'Kerbin', + kind: 'planet', + parentId: 'kerbol', + radius: 600_000, + sphereOfInfluence: 84_159_286, + gravitationalParameter: 3.5316e12, + rotationPeriod: 21_600, + axialTilt: 0, + orbit: { + semiMajorAxis: 13_599_840_256, + eccentricity: 0, + inclination: 0, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: 0, + epoch: 0, + }, + }, + ], + vessels: [ + { + id: vesselId, + name: 'Test', + type: 'Probe', + owner: 'KASA', + situation: 'ORBITING', + status: 'ACTIVE', + orbit: { + semiMajorAxis: 7e6, + eccentricity: 0, + inclination: 0, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: 0, + epoch: 0, + }, + referenceBodyId: 'kerbin', + createdAt: '2026-01-01T00:00:00Z', + retiredAt: null, + }, + ], + groundStations: [], + }; +} + +describe('InMemoryStateStore', () => { + it('starts empty', async () => { + const store = new InMemoryStateStore(); + expect(await store.latestSnapshot()).toBeNull(); + expect(await store.listVessels()).toEqual([]); + }); + + it('stores and returns snapshots', async () => { + const store = new InMemoryStateStore(); + await store.applySnapshot(makeSnapshot(100)); + const snap = await store.latestSnapshot(); + expect(snap).not.toBeNull(); + expect(snap!.ut).toBe(100); + }); + + it('looks up vessels by id', async () => { + const store = new InMemoryStateStore(); + await store.applySnapshot(makeSnapshot(1, 'kasa-1')); + const v = await store.getVessel('kasa-1'); + expect(v?.name).toBe('Test'); + expect(await store.getVessel('missing')).toBeNull(); + }); + + it('overwrites on subsequent snapshots', async () => { + const store = new InMemoryStateStore(); + await store.applySnapshot(makeSnapshot(1)); + await store.applySnapshot(makeSnapshot(2)); + expect((await store.latestSnapshot())!.ut).toBe(2); + }); + + it('notifies subscribers when a snapshot is applied', async () => { + const store = new InMemoryStateStore(); + const seen: number[] = []; + const unsub = await store.subscribe((snap) => seen.push(snap.ut)); + + await store.applySnapshot(makeSnapshot(1)); + // allow microtask to flush + await new Promise((r) => setTimeout(r, 10)); + await store.applySnapshot(makeSnapshot(2)); + await new Promise((r) => setTimeout(r, 10)); + + expect(seen).toEqual([1, 2]); + + await unsub(); + await store.applySnapshot(makeSnapshot(3)); + await new Promise((r) => setTimeout(r, 10)); + expect(seen).toEqual([1, 2]); // unsub'd + }); +}); diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json new file mode 100644 index 0000000..20c4917 --- /dev/null +++ b/packages/db/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "module": "ESNext", + "moduleResolution": "Bundler" + }, + "include": ["src/**/*"] +} diff --git a/packages/db/vitest.config.ts b/packages/db/vitest.config.ts new file mode 100644 index 0000000..b0e95a5 --- /dev/null +++ b/packages/db/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + environment: 'node', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5633286..1232efa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,6 +28,9 @@ importers: '@hono/zod-validator': specifier: ^0.4.0 version: 0.4.3(hono@4.12.23)(zod@3.25.76) + '@kerbal-rt/db': + specifier: workspace:* + version: link:../../packages/db '@kerbal-rt/orbital-math': specifier: workspace:* version: link:../../packages/orbital-math @@ -137,6 +140,44 @@ importers: specifier: ^5.4.6 version: 5.4.21(@types/node@22.19.19) + apps/tools/mock-telemetry: + dependencies: + '@kerbal-rt/orbital-math': + specifier: workspace:* + version: link:../../../packages/orbital-math + '@kerbal-rt/shared-types': + specifier: workspace:* + version: link:../../../packages/shared-types + devDependencies: + '@types/node': + specifier: ^22.5.0 + version: 22.19.19 + tsx: + specifier: ^4.19.1 + version: 4.22.4 + typescript: + specifier: ^5.6.2 + version: 5.9.3 + + packages/db: + dependencies: + '@kerbal-rt/shared-types': + specifier: workspace:* + version: link:../shared-types + ioredis: + specifier: ^5.4.1 + version: 5.11.0 + postgres: + specifier: ^3.4.4 + version: 3.4.9 + devDependencies: + typescript: + specifier: ^5.6.2 + version: 5.9.3 + vitest: + specifier: ^2.1.1 + version: 2.1.9(@types/node@22.19.19) + packages/orbital-math: dependencies: '@kerbal-rt/shared-types': @@ -859,6 +900,12 @@ packages: } deprecated: Use @eslint/object-schema instead + '@ioredis/commands@1.10.0': + resolution: + { + integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==, + } + '@isaacs/cliui@8.0.2': resolution: { @@ -1981,6 +2028,13 @@ packages: integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==, } + cluster-key-slot@1.1.1: + resolution: + { + integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==, + } + engines: { node: '>=0.10.0' } + color-convert@2.0.1: resolution: { @@ -2096,6 +2150,13 @@ packages: } engines: { node: '>= 0.4' } + denque@2.1.0: + resolution: + { + integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==, + } + engines: { node: '>=0.10' } + doctrine@2.1.0: resolution: { @@ -2725,6 +2786,13 @@ packages: } engines: { node: '>= 0.4' } + ioredis@5.11.0: + resolution: + { + integrity: sha512-EZBErytyVovD8f6pDfG3Kb37N6Y3lmDA9NNj+4+IP13CzzHGeX+OyeRM2Um13khRzoBSzzL+5lVnCX8V2RLeMg==, + } + engines: { node: '>=12.22.0' } + is-array-buffer@3.0.5: resolution: { @@ -3357,6 +3425,13 @@ packages: } engines: { node: ^10 || ^12 || >=14 } + postgres@3.4.9: + resolution: + { + integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==, + } + engines: { node: '>=12' } + prelude-ls@1.2.1: resolution: { @@ -3419,6 +3494,20 @@ packages: } engines: { node: '>=0.10.0' } + redis-errors@1.2.0: + resolution: + { + integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==, + } + engines: { node: '>=4' } + + redis-parser@3.0.0: + resolution: + { + integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==, + } + engines: { node: '>=4' } + reflect.getprototypeof@1.0.10: resolution: { @@ -3620,6 +3709,12 @@ packages: integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==, } + standard-as-callback@2.1.0: + resolution: + { + integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==, + } + std-env@3.10.0: resolution: { @@ -4422,6 +4517,8 @@ snapshots: '@humanwhocodes/object-schema@2.0.3': {} + '@ioredis/commands@1.10.0': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -5055,6 +5152,8 @@ snapshots: client-only@0.0.1: {} + cluster-key-slot@1.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -5117,6 +5216,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + denque@2.1.0: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -5309,8 +5410,8 @@ snapshots: '@typescript-eslint/parser': 8.60.1(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1) @@ -5329,7 +5430,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -5340,22 +5441,22 @@ snapshots: tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): + eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.60.1(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -5366,7 +5467,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -5681,6 +5782,18 @@ snapshots: hasown: 2.0.4 side-channel: 1.1.0 + ioredis@5.11.0: + dependencies: + '@ioredis/commands': 1.10.0 + cluster-key-slot: 1.1.1 + debug: 4.4.3 + denque: 2.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 @@ -6048,6 +6161,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres@3.4.9: {} + prelude-ls@1.2.1: {} prettier@3.8.3: {} @@ -6076,6 +6191,12 @@ snapshots: dependencies: loose-envify: 1.4.0 + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -6243,6 +6364,8 @@ snapshots: stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + std-env@3.10.0: {} stop-iteration-iterator@1.1.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e9b0dad..03e6f07 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,4 @@ packages: - 'apps/*' + - 'apps/**' - 'packages/*'