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:
marco.chan committed 2026-09-03 10:13:02 +08:00
1 parent 534fa6dc51
commit 7923ba1b8d
70 files changed
+3085 -3434

No files matched your search

+3
View File
@@ -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.
+10
View File
@@ -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
}
}
+36
View File
@@ -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"
}
}
+52
View File
@@ -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');
});
});
+40
View File
@@ -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;
}
+8
View File
@@ -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(),
});
+6
View File
@@ -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',
};
+22
View File
@@ -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));
});
+49
View File
@@ -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',
}),
};
}
+86
View File
@@ -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;
},
};
}
+15
View File
@@ -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' });
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"types": ["node"]
},
"include": ["src"],
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+20
View File
@@ -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"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
root: './',
include: ['**/*.e2e-spec.ts'],
},
});
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
root: './',
include: ['**/*.spec.ts'],
},
});