feat: initialize ingest-api with Koa and telemetry handling
- Create ingest-api with Koa framework for handling device telemetry. - Implement telemetry ingestion with MongoDB, Redis, and PostgreSQL. - Add API endpoints for telemetry and health checks. - Set up testing with Vitest for unit and end-to-end tests. - Configure Prettier and ESLint for code formatting and linting. - Create Docker Compose setup for PostgreSQL, MongoDB, and Redis services. - Establish contracts package for shared Zod schemas and TypeScript types. - Implement React frontend with device status and greeting functionalities. - Add VS Code setup documentation for onboarding developers.
This commit is contained in:
1 parent
534fa6dc51
commit
7923ba1b8d
70 files changed
+3085
-3434
No files matched your search
@@ -0,0 +1,3 @@
|
||||
# crud-api
|
||||
|
||||
Express + Drizzle. User-facing HTTP. Reads `device_current_state` from Postgres and optional Redis presence. Does not accept device telemetry.
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/crates/oxc_linter/src/rules.rs",
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-floating-promises": "warn"
|
||||
},
|
||||
"env": {
|
||||
"node": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "crud-api",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"start": "tsx src/main.ts",
|
||||
"start:dev": "tsx watch src/main.ts",
|
||||
"start:debug": "tsx watch --inspect=9229 src/main.ts",
|
||||
"lint": "oxlint src/ test/",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "vitest run --config ./vitest.config.e2e.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"contracts": "workspace:*",
|
||||
"cors": "^2",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"express": "^4",
|
||||
"ioredis": "^5",
|
||||
"pg": "^8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2",
|
||||
"@types/express": "^4",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/pg": "^8",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"oxlint": "^1.58.0",
|
||||
"supertest": "^7.0.0",
|
||||
"tsx": "^4",
|
||||
"typescript": "^6.0.2",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import request from 'supertest';
|
||||
import { emptyStatus, toDeviceStatus } from './status.js';
|
||||
import { createApp } from './app.js';
|
||||
import { createMemoryStores } from './stores.js';
|
||||
|
||||
describe('crud-api', () => {
|
||||
const app = createApp(createMemoryStores());
|
||||
|
||||
it('returns Hello World on GET /', async () => {
|
||||
await request(app).get('/').expect(200).expect('Hello World!');
|
||||
});
|
||||
|
||||
it('returns a greeting on GET /greeting', async () => {
|
||||
await request(app)
|
||||
.get('/greeting')
|
||||
.query({ name: 'Marco' })
|
||||
.expect(200)
|
||||
.expect({ message: 'Hello, Marco' });
|
||||
});
|
||||
|
||||
it('rejects an empty greeting name', async () => {
|
||||
await request(app).get('/greeting').query({ name: '' }).expect(400);
|
||||
});
|
||||
|
||||
it('returns missing status when the device is unknown', async () => {
|
||||
const response = await request(app).get('/devices/dev-1/status').expect(200);
|
||||
expect(response.body.postgres.source).toBe('missing');
|
||||
expect(response.body.redis.online).toBe(false);
|
||||
});
|
||||
|
||||
it('returns postgres snapshot from the store', async () => {
|
||||
const lastSeen = new Date();
|
||||
const appWithSeed = createApp(
|
||||
createMemoryStores({
|
||||
'dev-1': {
|
||||
postgres: toDeviceStatus({
|
||||
deviceId: 'dev-1',
|
||||
lastSeen,
|
||||
payload: { temp: 21 },
|
||||
source: 'postgres',
|
||||
}),
|
||||
redis: emptyStatus('dev-1').redis,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await request(appWithSeed).get('/devices/dev-1/status').expect(200);
|
||||
expect(response.body.postgres.online).toBe(true);
|
||||
expect(response.body.postgres.payload).toEqual({ temp: 21 });
|
||||
expect(response.body.postgres.source).toBe('postgres');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import cors from 'cors';
|
||||
import {
|
||||
deviceStatusPairSchema,
|
||||
greetingOutputSchema,
|
||||
greetingQuerySchema,
|
||||
} from 'contracts';
|
||||
import express from 'express';
|
||||
import { createDeviceStores, type DeviceStores } from './stores.js';
|
||||
|
||||
export function createApp(stores: DeviceStores = createDeviceStores()): express.Express {
|
||||
const app = express();
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.get('/', (_req, res) => {
|
||||
res.type('text/plain').send('Hello World!');
|
||||
});
|
||||
|
||||
app.get('/greeting', (req, res) => {
|
||||
const parsed = greetingQuerySchema.safeParse({
|
||||
name: typeof req.query.name === 'string' ? req.query.name : '',
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(greetingOutputSchema.parse({ message: `Hello, ${parsed.data.name}` }));
|
||||
});
|
||||
|
||||
app.get('/devices/:id/status', async (req, res) => {
|
||||
const deviceId = req.params.id;
|
||||
const pair = deviceStatusPairSchema.parse(await stores.readStatus(deviceId));
|
||||
res.json(pair);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const deviceCurrentState = pgTable('device_current_state', {
|
||||
deviceId: text('device_id').primaryKey(),
|
||||
payload: jsonb('payload').$type<Record<string, unknown>>().notNull(),
|
||||
lastSeen: timestamp('last_seen', { withTimezone: true }).notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(),
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export const env = {
|
||||
databaseUrl: process.env.DATABASE_URL ?? 'postgres://app:app@127.0.0.1:5432/app',
|
||||
redisUrl: process.env.REDIS_URL ?? 'redis://127.0.0.1:6379',
|
||||
port: Number(process.env.CRUD_PORT ?? process.env.PORT ?? 3000),
|
||||
host: process.env.HOST ?? '0.0.0.0',
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createApp } from './app.js';
|
||||
import { env } from './env.js';
|
||||
import { createDeviceStores } from './stores.js';
|
||||
|
||||
const stores = createDeviceStores();
|
||||
const app = createApp(stores);
|
||||
|
||||
const server = app.listen(env.port, env.host, () => {
|
||||
console.log(`crud-api listening on http://${env.host}:${env.port}`);
|
||||
});
|
||||
|
||||
async function shutdown() {
|
||||
server.close();
|
||||
await stores.close();
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
void shutdown().then(() => process.exit(0));
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
void shutdown().then(() => process.exit(0));
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
PRESENCE_TTL_SECONDS,
|
||||
type DeviceStatus,
|
||||
type DeviceStatusPair,
|
||||
} from 'contracts';
|
||||
|
||||
export function redisKey(deviceId: string): string {
|
||||
return `device:${deviceId}`;
|
||||
}
|
||||
|
||||
export function toDeviceStatus(input: {
|
||||
deviceId: string;
|
||||
lastSeen: Date | null;
|
||||
payload: Record<string, unknown>;
|
||||
source: DeviceStatus['source'];
|
||||
now?: Date;
|
||||
}): DeviceStatus {
|
||||
const now = input.now ?? new Date();
|
||||
const ageMs =
|
||||
input.lastSeen === null ? null : Math.max(0, now.getTime() - input.lastSeen.getTime());
|
||||
const withinTtl = ageMs !== null && ageMs <= PRESENCE_TTL_SECONDS * 1000;
|
||||
const missing = input.lastSeen === null;
|
||||
|
||||
return {
|
||||
deviceId: input.deviceId,
|
||||
lastSeen: input.lastSeen?.toISOString() ?? null,
|
||||
payload: input.payload,
|
||||
online: !missing && withinTtl,
|
||||
source: missing ? 'missing' : input.source,
|
||||
ageMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function emptyStatus(deviceId: string): DeviceStatusPair {
|
||||
return {
|
||||
postgres: toDeviceStatus({
|
||||
deviceId,
|
||||
lastSeen: null,
|
||||
payload: {},
|
||||
source: 'postgres',
|
||||
}),
|
||||
redis: toDeviceStatus({
|
||||
deviceId,
|
||||
lastSeen: null,
|
||||
payload: {},
|
||||
source: 'redis',
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import Redis from 'ioredis';
|
||||
import pg from 'pg';
|
||||
import type { DeviceStatusPair } from 'contracts';
|
||||
import { env } from './env.js';
|
||||
import { deviceCurrentState } from './db/schema.js';
|
||||
import { emptyStatus, redisKey, toDeviceStatus } from './status.js';
|
||||
|
||||
export type DeviceStores = {
|
||||
readStatus(deviceId: string): Promise<DeviceStatusPair>;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
type RedisSnapshot = {
|
||||
lastSeen: string;
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function createDeviceStores(): DeviceStores {
|
||||
const pool = new pg.Pool({ connectionString: env.databaseUrl });
|
||||
const db = drizzle(pool);
|
||||
const redis = new Redis(env.redisUrl, { maxRetriesPerRequest: 1, lazyConnect: true });
|
||||
|
||||
return {
|
||||
async readStatus(deviceId: string): Promise<DeviceStatusPair> {
|
||||
const pair = emptyStatus(deviceId);
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(deviceCurrentState)
|
||||
.where(eq(deviceCurrentState.deviceId, deviceId))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (row) {
|
||||
pair.postgres = toDeviceStatus({
|
||||
deviceId,
|
||||
lastSeen: row.lastSeen,
|
||||
payload: row.payload,
|
||||
source: 'postgres',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('postgres status read failed', error);
|
||||
}
|
||||
|
||||
try {
|
||||
if (redis.status === 'wait') {
|
||||
await redis.connect();
|
||||
}
|
||||
const raw = await redis.get(redisKey(deviceId));
|
||||
if (raw) {
|
||||
const snapshot = JSON.parse(raw) as RedisSnapshot;
|
||||
pair.redis = toDeviceStatus({
|
||||
deviceId,
|
||||
lastSeen: new Date(snapshot.lastSeen),
|
||||
payload: snapshot.payload ?? {},
|
||||
source: 'redis',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('redis status read failed', error);
|
||||
}
|
||||
|
||||
return pair;
|
||||
},
|
||||
|
||||
async close() {
|
||||
await Promise.allSettled([pool.end(), redis.quit()]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createMemoryStores(
|
||||
seed: Partial<Record<string, DeviceStatusPair>> = {},
|
||||
): DeviceStores {
|
||||
return {
|
||||
async readStatus(deviceId: string) {
|
||||
return seed[deviceId] ?? emptyStatus(deviceId);
|
||||
},
|
||||
async close() {
|
||||
return;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../src/app.js';
|
||||
import { createMemoryStores } from '../src/stores.js';
|
||||
|
||||
describe('crud-api (e2e)', () => {
|
||||
const app = createApp(createMemoryStores());
|
||||
|
||||
it('GET /greeting', async () => {
|
||||
await request(app)
|
||||
.get('/greeting')
|
||||
.query({ name: 'Marco' })
|
||||
.expect(200)
|
||||
.expect({ message: 'Hello, Marco' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"resolvePackageJsonExports": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2023",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"types": ["vitest/globals", "node"]
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
root: './',
|
||||
include: ['**/*.e2e-spec.ts'],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
root: './',
|
||||
include: ['**/*.spec.ts'],
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user