Merge pull request 'Phase 1: data pipeline (Postgres+Redis with in-memory fallback, mock telemetry publisher, WebSocket fan-out, hub /debug page)' (#1) from phase-1 into main
CI / Lint, typecheck, test, build (push) Failing after 9s
CI / Lint, typecheck, test, build (push) Failing after 9s
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
+38
-18
@@ -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 });
|
||||
});
|
||||
|
||||
+71
-28
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
+67
-32
@@ -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<WsLike>();
|
||||
|
||||
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<WsLike>();
|
||||
private unsubscribe: (() => Promise<void>) | null = null;
|
||||
|
||||
constructor(private opts: LiveSocketOpts) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
// 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<void> {
|
||||
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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void>;
|
||||
}> {
|
||||
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<void>((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<void>((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<string>((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<void>((r) => ws1.once('open', () => r())),
|
||||
new Promise<void>((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<void>[] = [];
|
||||
const makeWaiter = (key: 'ws1' | 'ws2', ws: WebSocket) =>
|
||||
new Promise<void>((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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<UniverseSnapshot | null>(null);
|
||||
const [status, setStatus] = useState<'connecting' | 'open' | 'closed'>('connecting');
|
||||
const [lastUpdate, setLastUpdate] = useState<string | null>(null);
|
||||
const [messageCount, setMessageCount] = useState(0);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<main style={{ maxWidth: 1100, margin: '0 auto', padding: '2rem 1rem' }}>
|
||||
<header
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1.5rem' }}
|
||||
>
|
||||
<h1 style={{ fontSize: '1.5rem', margin: 0 }}>Debug — Live State</h1>
|
||||
<Pill tone={status === 'open' ? 'success' : status === 'connecting' ? 'warn' : 'danger'}>
|
||||
WS {status}
|
||||
</Pill>
|
||||
<Pill tone="neutral">{messageCount} msgs</Pill>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<Card title="Errors">
|
||||
<p style={{ color: 'var(--pill-danger, #e44)' }}>{error}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div style={{ height: '1rem' }} />
|
||||
|
||||
<Card title="Connection">
|
||||
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
|
||||
<li>API: {API_URL}</li>
|
||||
<li>WebSocket: {API_URL.replace(/^http/, 'ws')}/api/v1/live</li>
|
||||
<li>Status: {status}</li>
|
||||
<li>Last update: {lastUpdate ?? '—'}</li>
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<div style={{ height: '1rem' }} />
|
||||
|
||||
{!snap ? (
|
||||
<Card title="No snapshot yet">
|
||||
<p>No snapshot received. Make sure:</p>
|
||||
<ol>
|
||||
<li>The API is running at {API_URL}</li>
|
||||
<li>
|
||||
Either the mock-telemetry publisher is running, or you've POSTed to{' '}
|
||||
<code>/api/v1/ingest</code>
|
||||
</li>
|
||||
</ol>
|
||||
<p>
|
||||
Try the mock publisher from the repo root:
|
||||
<br />
|
||||
<code>pnpm --filter @kerbal-rt/mock-telemetry start</code>
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Card title="Snapshot summary">
|
||||
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
|
||||
<li>UT: {formatUtAsKspDate(snap.ut)}</li>
|
||||
<li>Captured at: {snap.capturedAt}</li>
|
||||
<li>Bodies: {snap.bodies.length}</li>
|
||||
<li>Vessels: {snap.vessels.length}</li>
|
||||
<li>Ground stations: {snap.groundStations.length}</li>
|
||||
<li>Active vessel: {snap.activeVesselId ?? '—'}</li>
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<div style={{ height: '1rem' }} />
|
||||
|
||||
<Card title={`Vessels (${snap.vessels.length})`}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--card-border)' }}>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Owner</th>
|
||||
<th>Situation</th>
|
||||
<th>Body</th>
|
||||
<th>sma</th>
|
||||
<th>ecc</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{snap.vessels.map((v) => (
|
||||
<tr key={v.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
|
||||
<td>
|
||||
<code>{v.id}</code>
|
||||
</td>
|
||||
<td>{v.name}</td>
|
||||
<td>{v.owner ?? '—'}</td>
|
||||
<td>{v.situation}</td>
|
||||
<td>{v.referenceBodyId}</td>
|
||||
<td>{(v.orbit.semiMajorAxis / 1000).toFixed(0)} km</td>
|
||||
<td>{v.orbit.eccentricity.toFixed(3)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
|
||||
<div style={{ height: '1rem' }} />
|
||||
|
||||
<Card title={`Bodies (${snap.bodies.length})`}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
{snap.bodies.map((b) => (
|
||||
<span
|
||||
key={b.id}
|
||||
style={{
|
||||
padding: '0.25rem 0.5rem',
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
borderRadius: 4,
|
||||
fontSize: '0.8rem',
|
||||
}}
|
||||
>
|
||||
{b.name} <span style={{ opacity: 0.5 }}>({b.kind})</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,12 @@ export default function HomePage() {
|
||||
<Card title="Quick links">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/live-map">Live map</a> (scaffolded in the same Vite app)
|
||||
<a href="/debug">Debug page</a> — live state from the WebSocket (Phase 1)
|
||||
</li>
|
||||
<li>
|
||||
<a href="http://localhost:3001" target="_blank" rel="noreferrer">
|
||||
Live map (3D)
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="http://localhost:4000/health" target="_blank" rel="noreferrer">
|
||||
@@ -30,8 +35,12 @@ export default function HomePage() {
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com" target="_blank" rel="noreferrer">
|
||||
Repo
|
||||
<a
|
||||
href="https://gitea.arnike.ru/Arnike/KSP-MissionControl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Repo on Gitea
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
@@ -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<void> {
|
||||
const headers: Record<string, string> = { '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<void> {
|
||||
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<void> {
|
||||
// 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);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -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.
|
||||
+118
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
const SNAPSHOT_CHANNEL = 'kerbal-rt:snapshots';
|
||||
|
||||
export async function createStore(env: NodeJS.ProcessEnv = process.env): Promise<WiredStore> {
|
||||
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(() => {})]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
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(() => {})]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<UniverseSnapshot | null>;
|
||||
|
||||
/** List celestial bodies (catalog is small, kept in memory). */
|
||||
listBodies(): Promise<CelestialBody[]>;
|
||||
|
||||
/** List currently-known vessels. */
|
||||
listVessels(): Promise<Vessel[]>;
|
||||
|
||||
/** Look up a single vessel by ID. */
|
||||
getVessel(id: string): Promise<Vessel | null>;
|
||||
|
||||
/**
|
||||
* 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<void>>;
|
||||
}
|
||||
|
||||
// ─── In-memory implementation ──────────────────────────────────────────────
|
||||
|
||||
export class InMemoryStateStore implements StateStore {
|
||||
private snapshot: UniverseSnapshot | null = null;
|
||||
private vesselsById = new Map<string, Vessel>();
|
||||
private bodiesById = new Map<string, CelestialBody>();
|
||||
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<void>;
|
||||
/** Function to subscribe to snapshots over Redis. */
|
||||
redisSubscribe: (handler: (payload: string) => void) => Promise<() => Promise<void>>;
|
||||
}
|
||||
|
||||
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<UniverseSnapshot | null> {
|
||||
// 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<CelestialBody[]> {
|
||||
// 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<Vessel[]> {
|
||||
const snap = await this.latestSnapshot();
|
||||
return snap?.vessels ?? [];
|
||||
}
|
||||
|
||||
async getVessel(id: string): Promise<Vessel | null> {
|
||||
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;
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
Generated
+131
-8
@@ -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:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
packages:
|
||||
- 'apps/*'
|
||||
- 'apps/**'
|
||||
- 'packages/*'
|
||||
|
||||
Reference in New Issue
Block a user