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
+3083 -3432

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
DATABASE_URL=postgres://app:app@127.0.0.1:5432/app
MONGO_URL=mongodb://127.0.0.1:27017/app
REDIS_URL=redis://127.0.0.1:6379
CRUD_PORT=3000
INGEST_PORT=3001
VITE_API_BASE_URL=http://127.0.0.1:3000
VITE_INGEST_BASE_URL=http://127.0.0.1:3001
+14 -6
View File
@@ -2,16 +2,24 @@
"version": "0.2.0",
"configurations": [
{
"name": "API: Attach Nest (9229)",
"name": "CRUD: Attach Express (9229)",
"type": "node",
"request": "attach",
"port": 9229,
"restart": true,
"preLaunchTask": "api:debug",
"preLaunchTask": "crud:debug",
"cwd": "${workspaceFolder}",
"skipFiles": [
"<node_internals>/**"
]
"skipFiles": ["<node_internals>/**"]
},
{
"name": "Ingest: Attach Koa (9230)",
"type": "node",
"request": "attach",
"port": 9230,
"restart": true,
"preLaunchTask": "ingest:debug",
"cwd": "${workspaceFolder}",
"skipFiles": ["<node_internals>/**"]
},
{
"name": "Web: Chrome (Vite 5173)",
@@ -26,7 +34,7 @@
{
"name": "Full Stack Debug",
"configurations": [
"API: Attach Nest (9229)",
"CRUD: Attach Express (9229)",
"Web: Chrome (Vite 5173)"
]
}
+22 -4
View File
@@ -2,9 +2,9 @@
"version": "2.0.0",
"tasks": [
{
"label": "api:debug",
"label": "crud:debug",
"type": "shell",
"command": "yarn workspace api start:debug",
"command": "yarn workspace crud-api start:debug",
"isBackground": true,
"problemMatcher": {
"owner": "custom",
@@ -14,8 +14,26 @@
},
"background": {
"activeOnStart": true,
"beginsPattern": "Starting compilation in watch mode",
"endsPattern": "Watching for file changes"
"beginsPattern": "^.*$",
"endsPattern": "(Debugger listening|crud-api listening)"
}
}
},
{
"label": "ingest:debug",
"type": "shell",
"command": "yarn workspace ingest-api start:debug",
"isBackground": true,
"problemMatcher": {
"owner": "custom",
"fileLocation": "absolute",
"pattern": {
"regexp": "^(?!x)x$"
},
"background": {
"activeOnStart": true,
"beginsPattern": "^.*$",
"endsPattern": "(Debugger listening|ingest-api listening)"
}
}
},
+27 -12
View File
@@ -1,20 +1,35 @@
# marco-trest-test
Internal onboarding workspace for learning and testing the private `trest` package.
Onboarding template: device ingest (Koa) vs user CRUD (Express), shared Zod contracts, Compose for Postgres / Mongo / Redis.
## Start Here
## Layout
1. Read [docs/trest-onboarding.md](docs/trest-onboarding.md) for full setup instructions.
2. Follow [docs/trest-test-plan.md](docs/trest-test-plan.md) to validate installation and basic usage.
3. If you are new to Yarn, read [docs/yarn-basics-cheatsheet.md](docs/yarn-basics-cheatsheet.md).
4. If you come from Express + SQL background, read [docs/express-sql-to-monorepo-guide.md](docs/express-sql-to-monorepo-guide.md).
```text
packages/contracts Zod only
apps/ingest-api Koa POST /telemetry
apps/crud-api Express GET /greeting GET /devices/:id/status
apps/web React + Redux + axios + Tailwind
```
## Goal
Read [docs/architecture.md](docs/architecture.md) for the mermaid diagrams and the dual-write warning.
By the end of this onboarding, you should be able to:
## Start
- Access the private repository in Gitea.
- Install `trest` in a local sandbox project.
- Run a small smoke test and a few behavior checks.
- Document what worked, what failed, and open questions for the team.
```bash
docker compose up -d
yarn install
yarn dev:crud
yarn dev:ingest
yarn dev:web
```
- UI: http://127.0.0.1:5173/devices
- Greeting: http://127.0.0.1:5173/greeting
- Express: http://127.0.0.1:3000/greeting?name=Marco
- Ingest: `POST http://127.0.0.1:3001/telemetry`
Frontend-only install (no Mongo/Koa):
```bash
yarn workspaces focus web
```
-114
View File
@@ -1,114 +0,0 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ yarn install
```
## Compile and run the project
```bash
# development
$ yarn run start
# watch mode
$ yarn run start:dev
# production mode
$ yarn run start:prod
```
## Run tests
```bash
# unit tests
$ yarn run test
# e2e tests
$ yarn run test:e2e
# test coverage
$ yarn run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ yarn install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Observability
In production applications, observability is essential for understanding how your system behaves, detecting issues early, and maintaining reliable performance.
[NestJS Observe](https://observe.nestjs.com) automatically instruments your NestJS application, giving you deep visibility into your system with minimal setup:
- **Distributed tracing:** Follow requests across services and understand how they flow through your system.
- **Waterfall analysis:** Visualize request execution and identify slow operations, bottlenecks, and unexpected delays.
- **Performance analysis:** Analyze application performance in real time and quickly pinpoint areas that need optimization.
- **Metrics:** Track key application and infrastructure metrics to understand system health and performance trends.
- **Logging:** Centralize and correlate logs with traces and other telemetry to make debugging easier.
- **Error tracking:** Detect errors quickly and investigate their root causes with the surrounding context.
- **SLA monitoring:** Track service-level objectives and identify when your application is approaching or exceeding defined thresholds.
- **Alarms and alerts:** Set up alerts for critical errors, performance degradation, SLA violations, and other anomalies so your team can react quickly.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Auto-instrument your application with [NestJS Observer](https://observer.nestjs.com). Distributed tracing, metrics, and logging made easy. Error tracking and performance monitoring for your NestJS applications.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
-8
View File
@@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
-48
View File
@@ -1,48 +0,0 @@
{
"name": "api",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"type": "module",
"scripts": {
"build": "nest build",
"deploy": "nest deploy",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "oxlint src/ test/",
"test": "vitest run",
"test:watch": "vitest",
"test:cov": "vitest run --coverage",
"test:debug": "vitest --inspect-brk --no-file-parallelism",
"test:e2e": "vitest run --config ./vitest.config.e2e.ts"
},
"dependencies": {
"@nestjs/common": "^12.0.1",
"@nestjs/core": "^12.0.1",
"@nestjs/platform-fastify": "^12.0.1",
"fastify": "^5.12.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@nestjs/cli": "^12.0.0",
"@nestjs/mau": "^0.2.6",
"@nestjs/schematics": "^12.0.0",
"@nestjs/testing": "^12.0.1",
"@types/node": "^24.0.0",
"@types/supertest": "^7.0.0",
"@vitest/coverage-v8": "^4.1.2",
"oxlint": "^1.58.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"typescript": "^6.0.2",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.1.2"
}
}
-22
View File
@@ -1,22 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller.js';
import { AppService } from './app.service.js';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});
-12
View File
@@ -1,12 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service.js';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}
-10
View File
@@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller.js';
import { AppService } from './app.service.js';
@Module({
imports: [],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
-8
View File
@@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}
-12
View File
@@ -1,12 +0,0 @@
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { AppModule } from './app.module.js';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
);
await app.listen(process.env.PORT ?? 3000, '0.0.0.0');
}
await bootstrap();
-29
View File
@@ -1,29 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module.js';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
afterEach(async () => {
await app.close();
});
});
-13
View File
@@ -1,13 +0,0 @@
import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
// Resolves the path aliases declared in tsconfig.json, including the ones
// added by `nest g library`.
plugins: [tsconfigPaths()],
test: {
globals: true,
root: './',
include: ['**/*.spec.ts'],
},
});
+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.
File renamed without changes.
+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' });
});
});
@@ -1,7 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src"
"rootDir": "./src",
"types": ["node"]
},
"include": ["src"],
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
@@ -7,8 +7,6 @@
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
@@ -16,7 +14,7 @@
"incremental": true,
"skipLibCheck": true,
"strict": true,
"strictPropertyInitialization": false,
"types": ["vitest/globals", "node"]
}
},
"include": ["src", "test"]
}
@@ -1,8 +1,6 @@
import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
globals: true,
root: './',
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
root: './',
include: ['**/*.spec.ts'],
},
});
File renamed without changes.
+3
View File
@@ -0,0 +1,3 @@
# ingest-api
Koa device ingest. `POST /telemetry` writes raw events to Mongo, a TTL key to Redis, and upserts `device_current_state` in Postgres. Dual-write is a demo shortcut — production should derive Redis/Postgres from a queue.
+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
}
}
+40
View File
@@ -0,0 +1,40 @@
{
"name": "ingest-api",
"version": "0.0.1",
"description": "Koa device ingest API",
"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=9230 src/main.ts",
"lint": "oxlint src/ test/",
"test": "vitest run",
"test:e2e": "vitest run --config ./vitest.config.e2e.ts"
},
"dependencies": {
"@koa/cors": "^5.0.0",
"@koa/router": "^12",
"contracts": "workspace:*",
"ioredis": "^5",
"koa": "^2",
"koa-body": "^6",
"mongodb": "^6",
"pg": "^8"
},
"devDependencies": {
"@types/koa": "^2",
"@types/koa__cors": "^5.0.1",
"@types/koa__router": "^12",
"@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"
}
}
+38
View File
@@ -0,0 +1,38 @@
import request from 'supertest';
import type { TelemetryIngest } from 'contracts';
import { createApp } from './app.js';
import { createMemoryWriter } from './stores.js';
describe('ingest-api', () => {
const writes: TelemetryIngest[] = [];
const app = createApp(createMemoryWriter(writes));
it('GET /', async () => {
await request(app.callback()).get('/').expect(200).expect('ingest-api');
});
it('accepts telemetry', async () => {
writes.length = 0;
const body = {
deviceId: 'dev-1',
sentAt: new Date().toISOString(),
payload: { temp: 21 },
};
await request(app.callback())
.post('/telemetry')
.send(body)
.expect(200)
.expect({
accepted: true,
writes: { mongo: true, redis: true, postgres: true },
});
expect(writes).toHaveLength(1);
expect(writes[0]?.deviceId).toBe('dev-1');
});
it('rejects invalid telemetry', async () => {
await request(app.callback()).post('/telemetry').send({ deviceId: '' }).expect(400);
});
});
+39
View File
@@ -0,0 +1,39 @@
import cors from '@koa/cors';
import Router from '@koa/router';
import { telemetryIngestSchema } from 'contracts';
import Koa from 'koa';
import { koaBody } from 'koa-body';
import { createTelemetryWriter, type TelemetryWriter } from './stores.js';
export function createApp(writer: TelemetryWriter = createTelemetryWriter()): Koa {
const app = new Koa();
const router = new Router();
app.use(cors());
app.use(koaBody());
router.get('/', (ctx) => {
ctx.body = 'ingest-api';
});
router.get('/health', (ctx) => {
ctx.body = { ok: true };
});
router.post('/telemetry', async (ctx) => {
const parsed = telemetryIngestSchema.safeParse(ctx.request.body);
if (!parsed.success) {
ctx.status = 400;
ctx.body = { error: parsed.error.flatten() };
return;
}
const writes = await writer.write(parsed.data);
ctx.body = { accepted: true, writes };
});
app.use(router.routes());
app.use(router.allowedMethods());
return app;
}
+7
View File
@@ -0,0 +1,7 @@
export const env = {
databaseUrl: process.env.DATABASE_URL ?? 'postgres://app:app@127.0.0.1:5432/app',
mongoUrl: process.env.MONGO_URL ?? 'mongodb://127.0.0.1:27017/app',
redisUrl: process.env.REDIS_URL ?? 'redis://127.0.0.1:6379',
port: Number(process.env.INGEST_PORT ?? process.env.PORT ?? 3001),
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 { createTelemetryWriter } from './stores.js';
const writer = createTelemetryWriter();
const app = createApp(writer);
const server = app.listen(env.port, env.host, () => {
console.log(`ingest-api listening on http://${env.host}:${env.port}`);
});
async function shutdown() {
server.close();
await writer.close();
}
process.on('SIGINT', () => {
void shutdown().then(() => process.exit(0));
});
process.on('SIGTERM', () => {
void shutdown().then(() => process.exit(0));
});
+100
View File
@@ -0,0 +1,100 @@
import { PRESENCE_TTL_SECONDS, type TelemetryIngest } from 'contracts';
import Redis from 'ioredis';
import { MongoClient } from 'mongodb';
import pg from 'pg';
import { env } from './env.js';
export type WriteReport = {
mongo: boolean;
redis: boolean;
postgres: boolean;
};
export type TelemetryWriter = {
write(event: TelemetryIngest): Promise<WriteReport>;
close(): Promise<void>;
};
function redisKey(deviceId: string): string {
return `device:${deviceId}`;
}
export function createTelemetryWriter(): TelemetryWriter {
const mongo = new MongoClient(env.mongoUrl);
const redis = new Redis(env.redisUrl, { maxRetriesPerRequest: 1, lazyConnect: true });
const pool = new pg.Pool({ connectionString: env.databaseUrl });
let mongoReady: Promise<MongoClient> | null = null;
function mongoClient(): Promise<MongoClient> {
mongoReady ??= mongo.connect();
return mongoReady;
}
return {
async write(event: TelemetryIngest): Promise<WriteReport> {
const report: WriteReport = { mongo: false, redis: false, postgres: false };
const lastSeen = new Date(event.sentAt);
try {
const client = await mongoClient();
await client.db().collection('telemetry').insertOne({
deviceId: event.deviceId,
sentAt: lastSeen,
payload: event.payload,
ingestedAt: new Date(),
});
report.mongo = true;
} catch (error) {
console.error('mongo ingest write failed', error);
}
try {
if (redis.status === 'wait') {
await redis.connect();
}
await redis.set(
redisKey(event.deviceId),
JSON.stringify({ lastSeen: lastSeen.toISOString(), payload: event.payload }),
'EX',
PRESENCE_TTL_SECONDS,
);
report.redis = true;
} catch (error) {
console.error('redis ingest write failed', error);
}
try {
await pool.query(
`INSERT INTO device_current_state (device_id, payload, last_seen, updated_at)
VALUES ($1, $2::jsonb, $3, NOW())
ON CONFLICT (device_id) DO UPDATE SET
payload = EXCLUDED.payload,
last_seen = EXCLUDED.last_seen,
updated_at = NOW()`,
[event.deviceId, JSON.stringify(event.payload), lastSeen],
);
report.postgres = true;
} catch (error) {
console.error('postgres ingest write failed', error);
}
return report;
},
async close() {
await Promise.allSettled([mongo.close(), redis.quit(), pool.end()]);
},
};
}
export function createMemoryWriter(writes: TelemetryIngest[] = []): TelemetryWriter {
return {
async write(event: TelemetryIngest) {
writes.push(event);
return { mongo: true, redis: true, postgres: true };
},
async close() {
return;
},
};
}
+18
View File
@@ -0,0 +1,18 @@
import request from 'supertest';
import { createApp } from '../src/app.js';
import { createMemoryWriter } from '../src/stores.js';
describe('ingest-api (e2e)', () => {
const app = createApp(createMemoryWriter());
it('POST /telemetry', async () => {
await request(app.callback())
.post('/telemetry')
.send({
deviceId: 'dev-1',
sentAt: new Date().toISOString(),
payload: { temp: 21 },
})
.expect(200);
});
});
+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'],
},
});
+9 -3
View File
@@ -10,13 +10,18 @@
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^6.3.4",
"antd": "^6.6.2",
"@reduxjs/toolkit": "^2.12.0",
"axios": "^1.20.0",
"contracts": "workspace:*",
"react": "^19.2.8",
"react-dom": "^19.2.8"
"react-dom": "^19.2.8",
"react-redux": "^9.3.0",
"react-router-dom": "^7.18.3",
"zod": "^3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
@@ -25,6 +30,7 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.4",
"globals": "^17.11.0",
"tailwindcss": "^4",
"typescript": "~6.0.2",
"typescript-eslint": "^8.67.0",
"vite": "^8.2.2"
+28 -115
View File
@@ -1,122 +1,35 @@
import { useState } from 'react'
import heroImg from './assets/hero.png'
import reactLogo from './assets/react.svg'
import viteLogo from './assets/vite.svg'
import './App.css'
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
import { DevicesPage } from './pages/DevicesPage'
import { GreetingPage } from './pages/GreetingPage'
function App() {
const [count, setCount] = useState(0)
const linkClass = ({ isActive }: { isActive: boolean }) =>
`rounded-md px-3 py-1.5 text-sm ${
isActive ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-200'
}`
export default function App() {
return (
<>
<section id="center">
<div className="hero">
<img src={heroImg} className="base" width="170" height="179" alt="" />
<img src={reactLogo} className="framework" alt="React logo" />
<img src={viteLogo} className="vite" alt="Vite logo" />
<div className="min-h-screen bg-slate-100">
<header className="border-b border-slate-200 bg-white">
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
<span className="font-semibold text-slate-900">marco-trest-test</span>
<nav className="flex gap-2">
<NavLink to="/greeting" className={linkClass}>
Greeting
</NavLink>
<NavLink to="/devices" className={linkClass}>
Devices
</NavLink>
</nav>
</div>
<div>
<h1>Get started</h1>
<p>
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
</p>
</header>
<main className="mx-auto max-w-3xl px-4 py-8">
<Routes>
<Route path="/" element={<Navigate to="/devices" replace />} />
<Route path="/greeting" element={<GreetingPage />} />
<Route path="/devices" element={<DevicesPage />} />
</Routes>
</main>
</div>
<button
type="button"
className="counter"
onClick={() => setCount((count) => count + 1)}
>
Count is {count}
</button>
</section>
<div className="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
</>
)
}
export default App
+17
View File
@@ -0,0 +1,17 @@
import axios from 'axios'
export const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL ?? 'http://127.0.0.1:3000',
headers: {
Accept: 'application/json',
},
})
/** Demo only — production UI must not call ingest-api. */
export const ingestApi = axios.create({
baseURL: import.meta.env.VITE_INGEST_BASE_URL ?? 'http://127.0.0.1:3001',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
})
+1 -111
View File
@@ -1,111 +1 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
@import 'tailwindcss';
+8 -1
View File
@@ -1,10 +1,17 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import { Provider } from 'react-redux'
import { BrowserRouter } from 'react-router-dom'
import App from './App.tsx'
import { store } from './store'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
</StrictMode>,
)
+104
View File
@@ -0,0 +1,104 @@
import { useEffect } from 'react'
import type { DeviceStatus } from 'contracts'
import { PRESENCE_TTL_SECONDS } from 'contracts'
import { fetchDeviceStatus, pingDevice } from '../store/device-slice'
import { useAppDispatch, useAppSelector } from '../store/hooks'
function StatusCard({ title, status }: { title: string; status: DeviceStatus | undefined }) {
if (!status) {
return (
<article className="rounded-xl border border-slate-200 bg-white p-4">
<h2 className="font-semibold text-slate-900">{title}</h2>
<p className="mt-2 text-sm text-slate-500">No data yet.</p>
</article>
)
}
return (
<article className="rounded-xl border border-slate-200 bg-white p-4">
<h2 className="font-semibold text-slate-900">{title}</h2>
<dl className="mt-3 space-y-1 text-sm text-slate-700">
<div className="flex justify-between gap-4">
<dt>online</dt>
<dd className={status.online ? 'font-medium text-emerald-700' : 'text-slate-500'}>
{status.online ? 'yes' : 'no'}
</dd>
</div>
<div className="flex justify-between gap-4">
<dt>source</dt>
<dd>{status.source}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>age</dt>
<dd>{status.ageMs === null ? '—' : `${status.ageMs} ms`}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>lastSeen</dt>
<dd className="truncate">{status.lastSeen ?? '—'}</dd>
</div>
<div>
<dt>payload</dt>
<dd className="mt-1 overflow-auto rounded bg-slate-50 p-2 font-mono text-xs">
{JSON.stringify(status.payload)}
</dd>
</div>
</dl>
</article>
)
}
export function DevicesPage() {
const dispatch = useAppDispatch()
const { deviceId, pair, pinging, error } = useAppSelector((state) => state.device)
useEffect(() => {
void dispatch(fetchDeviceStatus(deviceId))
const timer = window.setInterval(() => {
void dispatch(fetchDeviceStatus(deviceId))
}, 1000)
return () => window.clearInterval(timer)
}, [dispatch, deviceId])
return (
<section className="space-y-6">
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<h1 className="text-xl font-semibold text-slate-900">Device status</h1>
<p className="mt-2 text-sm text-slate-600">
Product UI reads Express → Postgres. Redis is only a TTL presence copy. After{' '}
{PRESENCE_TTL_SECONDS}s without a ping, Redis goes empty; Postgres keeps the last row.
</p>
<p className="mt-2 text-xs font-medium uppercase tracking-wide text-amber-700">
Ping device talks to ingest-api — demo only. Production UI never calls the device API.
</p>
<button
type="button"
disabled={pinging}
onClick={() => void dispatch(pingDevice(deviceId))}
className="mt-4 rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
>
{pinging ? 'Pinging…' : `Ping ${deviceId}`}
</button>
{error ? (
<p className="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>
) : null}
</div>
<div className="overflow-x-auto rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
<p className="mb-2 font-medium text-slate-900">Data flow</p>
<pre className="font-mono text-xs leading-6">{`Device ping → Koa ingest :3001
→ Mongo (raw event)
→ Redis SET device:id EX ${PRESENCE_TTL_SECONDS}s
→ Postgres UPSERT device_current_state
React poll 1s → Express crud :3000
→ SELECT postgres snapshot ← near-real-time product path
→ GET redis key ← optional presence`}</pre>
</div>
<div className="grid gap-4 md:grid-cols-2">
<StatusCard title="Postgres (Express product path)" status={pair?.postgres} />
<StatusCard title="Redis (TTL presence)" status={pair?.redis} />
</div>
</section>
)
}
+48
View File
@@ -0,0 +1,48 @@
import { fetchGreeting, setName } from '../store/greeting-slice'
import { useAppDispatch, useAppSelector } from '../store/hooks'
export function GreetingPage() {
const dispatch = useAppDispatch()
const { name, message, status, error } = useAppSelector((state) => state.greeting)
const loading = status === 'loading'
return (
<section className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<h1 className="text-xl font-semibold text-slate-900">Greeting</h1>
<p className="mt-2 text-sm text-slate-600">
User lane: axios → Express GET /greeting on crud-api. Schemas come from the contracts
package.
</p>
<form
className="mt-6 space-y-4"
onSubmit={(event) => {
event.preventDefault()
void dispatch(fetchGreeting(name))
}}
>
<label className="block text-sm font-medium text-slate-700">
Name
<input
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
value={name}
onChange={(event) => dispatch(setName(event.target.value))}
autoComplete="nickname"
/>
</label>
<button
type="submit"
disabled={loading}
className="rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
>
{loading ? 'Fetching…' : 'Fetch greeting'}
</button>
</form>
{error ? (
<p className="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>
) : null}
{message ? (
<p className="mt-4 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{message}</p>
) : null}
</section>
)
}
+92
View File
@@ -0,0 +1,92 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import {
DEMO_DEVICE_ID,
deviceStatusPairSchema,
telemetryIngestSchema,
type DeviceStatusPair,
} from 'contracts'
import { api, ingestApi } from '../api/client'
import { toErrorMessage } from './greeting-slice'
export type DeviceState = {
deviceId: string
pair: DeviceStatusPair | null
status: 'idle' | 'loading' | 'succeeded' | 'failed'
pinging: boolean
error: string | null
lastPingAt: string | null
}
const initialState: DeviceState = {
deviceId: DEMO_DEVICE_ID,
pair: null,
status: 'idle',
pinging: false,
error: null,
lastPingAt: null,
}
export const fetchDeviceStatus = createAsyncThunk<
DeviceStatusPair,
string,
{ rejectValue: string }
>('device/fetchStatus', async (deviceId, { rejectWithValue }) => {
try {
const response = await api.get(`/devices/${deviceId}/status`)
return deviceStatusPairSchema.parse(response.data)
} catch (error) {
return rejectWithValue(toErrorMessage(error))
}
})
export const pingDevice = createAsyncThunk<void, string, { rejectValue: string }>(
'device/ping',
async (deviceId, { rejectWithValue, dispatch }) => {
try {
const body = telemetryIngestSchema.parse({
deviceId,
sentAt: new Date().toISOString(),
payload: { temp: 20 + Math.round(Math.random() * 5), demo: true },
})
await ingestApi.post('/telemetry', body)
await dispatch(fetchDeviceStatus(deviceId)).unwrap()
} catch (error) {
return rejectWithValue(toErrorMessage(error))
}
},
)
const deviceSlice = createSlice({
name: 'device',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchDeviceStatus.pending, (state) => {
state.status = 'loading'
state.error = null
})
.addCase(fetchDeviceStatus.fulfilled, (state, action) => {
state.status = 'succeeded'
state.pair = action.payload
})
.addCase(fetchDeviceStatus.rejected, (state, action) => {
state.status = 'failed'
state.error = action.payload ?? action.error.message ?? 'Request failed'
})
.addCase(pingDevice.pending, (state) => {
state.pinging = true
state.error = null
})
.addCase(pingDevice.fulfilled, (state) => {
state.pinging = false
state.lastPingAt = new Date().toISOString()
})
.addCase(pingDevice.rejected, (state, action) => {
state.pinging = false
state.error = action.payload ?? action.error.message ?? 'Ping failed'
})
},
})
export default deviceSlice.reducer
+92
View File
@@ -0,0 +1,92 @@
import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit'
import { greetingOutputSchema, greetingQuerySchema, type GreetingOutput } from 'contracts'
import { isAxiosError } from 'axios'
import { ZodError } from 'zod'
import { api } from '../api/client'
export type GreetingStatus = 'idle' | 'loading' | 'succeeded' | 'failed'
export type GreetingState = {
name: string
message: string | null
status: GreetingStatus
error: string | null
}
const initialState: GreetingState = {
name: 'Marco',
message: null,
status: 'idle',
error: null,
}
export function toErrorMessage(error: unknown): string {
if (error instanceof ZodError) {
return error.issues.map((issue) => issue.message).join(', ')
}
if (isAxiosError(error)) {
const data = error.response?.data
if (typeof data === 'string' && data.length > 0) {
return data
}
if (data && typeof data === 'object' && 'message' in data) {
const message = (data as { message: unknown }).message
if (typeof message === 'string' && message.length > 0) {
return message
}
}
return error.message
}
if (error instanceof Error) {
return error.message
}
return 'Request failed'
}
export const fetchGreeting = createAsyncThunk<GreetingOutput, string, { rejectValue: string }>(
'greeting/fetch',
async (name, { rejectWithValue }) => {
try {
const query = greetingQuerySchema.parse({ name })
const response = await api.get('/greeting', { params: query })
return greetingOutputSchema.parse(response.data)
} catch (error) {
return rejectWithValue(toErrorMessage(error))
}
},
)
const greetingSlice = createSlice({
name: 'greeting',
initialState,
reducers: {
setName(state, action: PayloadAction<string>) {
state.name = action.payload
},
},
extraReducers: (builder) => {
builder
.addCase(fetchGreeting.pending, (state) => {
state.status = 'loading'
state.error = null
})
.addCase(fetchGreeting.fulfilled, (state, action) => {
state.status = 'succeeded'
state.message = action.payload.message
state.error = null
})
.addCase(fetchGreeting.rejected, (state, action) => {
state.status = 'failed'
state.message = null
state.error = action.payload ?? action.error.message ?? 'Request failed'
})
},
})
export const { setName } = greetingSlice.actions
export default greetingSlice.reducer
+5
View File
@@ -0,0 +1,5 @@
import { useDispatch, useSelector } from 'react-redux'
import type { AppDispatch, RootState } from './index'
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
+13
View File
@@ -0,0 +1,13 @@
import { configureStore } from '@reduxjs/toolkit'
import deviceReducer from './device-slice'
import greetingReducer from './greeting-slice'
export const store = configureStore({
reducer: {
greeting: greetingReducer,
device: deviceReducer,
},
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
+7 -2
View File
@@ -1,7 +1,12 @@
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [react(), tailwindcss()],
server: {
fs: {
allow: ['..'],
},
},
})
+36
View File
@@ -0,0 +1,36 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
ports:
- '5432:5432'
volumes:
- ./infra/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U app -d app']
interval: 3s
timeout: 5s
retries: 20
mongo:
image: mongo:7
ports:
- '27017:27017'
healthcheck:
test: ['CMD', 'mongosh', '--quiet', '--eval', "db.adminCommand('ping').ok"]
interval: 5s
timeout: 5s
retries: 20
redis:
image: redis:7-alpine
ports:
- '6379:6379'
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 3s
timeout: 5s
retries: 20
+93
View File
@@ -0,0 +1,93 @@
# Architecture
Copy this layout for later device + dashboard projects.
## Lanes
- **Device lane:** `apps/ingest-api` (Koa) — firmware / gateways POST JSON.
- **User lane:** `apps/crud-api` (Express) — React, humans, billing later.
- **Shared types:** `packages/contracts` (Zod only). No Koa, Express, Mongo, or trest.
Frontend people can run `yarn workspaces focus web` and do not need the private trest registry.
## Data flow
```mermaid
flowchart LR
subgraph devices [Device lane]
D[Device / demo ping]
I[Koa ingest-api :3001]
D -->|POST /telemetry JSON| I
end
subgraph stores [Stores]
M[(MongoDB raw telemetry)]
R[(Redis hot key TTL)]
P[(PostgreSQL device_current_state)]
A[(PostgreSQL analytics — not in this repo)]
end
subgraph humans [User lane]
W[React web :5173]
C[Express crud-api :3000]
W -->|axios GET /greeting GET /devices/:id/status| C
end
I -->|insert event| M
I -->|SET device:id EX 15s| R
I -->|UPSERT current row| P
M -.->|ETL cron later| A
C -->|SELECT snapshot| P
C -->|GET device:id| R
```
Near-real-time product path (Postgres + Express is enough at this scale):
```mermaid
sequenceDiagram
participant D as Device
participant I as ingest-api Koa
participant PG as Postgres
participant C as crud-api Express
participant W as React
D->>I: POST /telemetry
I->>PG: UPSERT device_current_state
W->>C: GET /devices/dev-1/status poll 1s
C->>PG: SELECT by device_id
C->>W: lastSeen payload source postgres
```
Redis is compared on the Devices page. It is not required for the UI to look live.
## Demo dual-write (do not copy blindly)
Ingest writes Mongo first, then Redis and Postgres best-effort. A 200 can still mean Redis or Postgres missed. Production should derive those from a queue. This handler is a teaching shortcut.
Redis TTL expiry means “no recent ping.” Redis restart looks like everyone is offline. Postgres still has `device_current_state`.
Ingest may only upsert that snapshot table. CRUD owns greeting / future users and orgs.
Hourly Mongo → Postgres analytics is the dotted ETL box. Not implemented.
## Ports
| Service | Port |
| ---------- | ----- |
| crud-api | 3000 |
| ingest-api | 3001 |
| web | 5173 |
| postgres | 5432 |
| mongo | 27017 |
| redis | 6379 |
## Run
```bash
docker compose up -d
yarn install
yarn dev:crud
yarn dev:ingest
yarn dev:web
```
Open `http://127.0.0.1:5173/devices`, click **Ping dev-1**, watch Postgres and Redis columns. Wait 15s without pinging: Redis goes offline, Postgres keeps the last row.
+13 -28
View File
@@ -1,20 +1,18 @@
# From Express + SQL to Monorepo with Nest Fastify, Vite React, and trest
# From Express + SQL to Monorepo with Koa, Vite React, and trest
This guide is for developers who already know Express APIs and SQL, and are new to Yarn workspaces, NestJS, and private package usage.
This guide is for developers who already know Express APIs and SQL, and are new to Yarn workspaces, Koa, and private package usage.
## 1) What You Are Building
Target architecture:
- Backend API: NestJS on Fastify
- Backend API: Koa + trest
- Frontend: Vite + React + Ant Design
- Shared/private integration: trest in an isolated adapter package
- Monorepo manager: Yarn workspaces
Why this shape:
- Keep your main backend on a scalable Fastify stack.
- Evaluate trest without coupling everything to Koa ecosystem packages.
- Keep the HTTP API on Koa so it matches trest's router.
- Keep one repo and one lockfile for easier onboarding.
## 2) Mental Map (Old to New)
@@ -159,32 +157,22 @@ Checkpoint:
- Vite app runs and Ant Design components render.
### Step F: Introduce trest Safely
### Step F: Introduce trest on the API
Recommended approach:
- Keep trest usage in a dedicated package, for example packages/trest-adapter.
- Expose only framework-agnostic functions from that adapter.
- Avoid leaking Koa-specific types into app-wide contracts.
Starter commands:
Install trest and its Koa peers in `apps/api`:
```bash
mkdir -p packages/trest-adapter
cd packages/trest-adapter
yarn init -y
yarn add @jig-software/trest koa @koa/router koa-body path-to-regexp zod bson
yarn workspace api add @jig-software/trest koa @koa/router koa-body path-to-regexp zod bson
```
Checkpoint:
- Importing trest from adapter works.
- `GET /greeting?name=Marco` returns a JSON greeting from the Koa app.
### Step G: Wire API and Web
- Add one simple API endpoint in Nest.
- Call endpoint from React page.
- Render response using an Ant Design component.
- Call `/greeting` from a React page.
- Render the response using an Ant Design component.
Checkpoint:
@@ -197,8 +185,6 @@ marco-trest-test/
apps/
api/
web/
packages/
trest-adapter/
docs/
package.json
yarn.lock
@@ -209,11 +195,10 @@ marco-trest-test/
Use these rules to avoid confusion:
1. Put backend runtime dependencies in apps/api.
1. Put Koa, trest, and other backend runtime dependencies in apps/api.
2. Put frontend runtime dependencies in apps/web.
3. Put trest boundary dependencies in packages/trest-adapter.
4. Keep root dependencies minimal and mostly tooling.
5. Run installs from root unless targeting one workspace intentionally.
3. Keep root dependencies minimal and mostly tooling.
4. Run installs from root unless targeting one workspace intentionally.
## 8) Troubleshooting You Are Likely to Hit
+149
View File
@@ -0,0 +1,149 @@
# VS Code Setup Guide
This guide is for developers onboarding to this monorepo with Yarn workspaces, Nest API, and Vite web.
## Goals
- Open the workspace with correct TypeScript resolution.
- Run API and web locally from VS Code.
- Use one-click debug profiles.
- Fix common red-squiggle and startup issues quickly.
## 1) Prerequisites
- Node.js installed
- Corepack enabled
- Yarn available
Quick checks:
```bash
node -v
corepack --version
yarn -v
```
## 2) Open the Correct Folder
Open the repository root folder in VS Code, not a subfolder.
Expected top-level folders:
- apps
- packages
- docs
- .vscode
## 3) Install Dependencies from Root
Run in the root folder:
```bash
yarn install
```
Why: workspace metadata and PnP dependency graph are generated at root.
## 4) Configure Yarn SDK for VS Code
Run:
```bash
yarn dlx @yarnpkg/sdks vscode
```
Then in VS Code:
1. Open Command Palette
2. Run TypeScript: Select TypeScript Version
3. Choose Use Workspace Version
4. Run TypeScript: Restart TS Server
This fixes most false errors like cannot find module react or asset files.
## 5) Verify Workspace Settings
The workspace should contain these settings:
- typescript.tsdk points to .yarn/sdks/typescript/lib
- typescript.enablePromptUseWorkspaceTsdk is true
If missing, rerun the SDK command in section 4.
## 6) Start and Debug from VS Code
Use Run and Debug with these profiles:
- API: Attach Nest (9229)
- Web: Chrome (Vite 5173)
- Full Stack Debug
They rely on background tasks:
- api:debug
- web:dev
## 7) Manual Run Commands (Fallback)
From repo root:
```bash
yarn workspace api start:dev
yarn workspace web dev --host 0.0.0.0 --port 5173
```
## 8) Common Issues and Fixes
### A) Package for api not found
Cause: workspace name changed but graph not refreshed.
Fix:
```bash
yarn install
yarn workspaces list --json
```
### B) Cannot find module react, css, svg in editor
Cause: VS Code TypeScript server not using workspace SDK.
Fix:
1. Run yarn dlx @yarnpkg/sdks vscode
2. Select Use Workspace Version
3. Restart TS Server
4. Reload window if needed
### C) Peer dependency warnings after installing trest stack
Cause: trest and Koa peers installed in wrong workspace, usually web.
Fix:
- Keep trest and Koa in apps/api
- Keep web dependencies frontend-only
### D) Nest scaffold fails with schematics resolution
Use non-interactive fallback:
```bash
CI=1 npx -y @nestjs/cli new apps/api --skip-git --package-manager yarn --strict
```
## 9) Workspace Dependency Rules
- apps/api contains Koa, trest, and other backend runtime dependencies.
- apps/web contains frontend runtime dependencies.
- root should mostly contain workspace-level tooling.
## 10) Day-1 Validation Checklist
- yarn install completes from root
- yarn workspaces list shows root, api, web
- API starts locally
- Web starts locally
- Full Stack Debug launches successfully
- No persistent false TypeScript module errors in editor
+33
View File
@@ -0,0 +1,33 @@
# Fresh Grad Checklist for VS Code Setup
Use this in order. Do not skip steps.
## 10-Minute Quick Path
1. Open repo root in VS Code
2. Run yarn install at root
3. Run yarn dlx @yarnpkg/sdks vscode
4. Select TypeScript workspace version
5. Restart TypeScript server
6. Run API debug profile
7. Run Web debug profile
8. Open Full Stack Debug
## If Something Breaks
1. Rerun yarn install
2. Check workspace names with yarn workspaces list --json
3. Ensure api name in apps/api/package.json is correct
4. Confirm .vscode settings include typescript.tsdk
5. Reload VS Code window
## What to Ask for Help With
- Private registry token access issues
- Workspace naming mismatches
- Dependency added to wrong workspace
- Debug profile not attaching on port 9229
## Golden Rule
When in doubt, run commands from repo root first, then target a specific workspace.
+6
View File
@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS device_current_state (
device_id TEXT PRIMARY KEY,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
last_seen TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
+6 -8
View File
@@ -6,14 +6,12 @@
"apps/*",
"packages/*"
],
"dependencies": {
"@jig-software/trest": "^2.0.0-beta",
"@koa/router": "^12",
"bson": "^5",
"koa": "^2",
"koa-body": "^6",
"path-to-regexp": "^6",
"zod": "^3"
"scripts": {
"compose:up": "docker compose up -d",
"compose:down": "docker compose down",
"dev:crud": "yarn workspace crud-api start:dev",
"dev:ingest": "yarn workspace ingest-api start:dev",
"dev:web": "yarn workspace web dev --host 127.0.0.1 --port 5173"
},
"devDependencies": {
"typescript": "5.9.2"
+5
View File
@@ -0,0 +1,5 @@
# contracts
Zod schemas and TypeScript types shared by ingest-api, crud-api, and web.
This package depends on **zod only**. Do not add Koa, Express, Mongo, or trest here — the frontend workspace should stay installable with `yarn workspaces focus web`.
+12
View File
@@ -0,0 +1,12 @@
{
"name": "contracts",
"private": true,
"version": "0.0.1",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"zod": "^3"
}
}
+20
View File
@@ -0,0 +1,20 @@
import { z } from 'zod';
export const deviceStatusSourceSchema = z.enum(['postgres', 'redis', 'missing']);
export const deviceStatusSchema = z.object({
deviceId: z.string().min(1),
lastSeen: z.string().datetime().nullable(),
payload: z.record(z.unknown()),
online: z.boolean(),
source: deviceStatusSourceSchema,
ageMs: z.number().int().nonnegative().nullable(),
});
export const deviceStatusPairSchema = z.object({
postgres: deviceStatusSchema,
redis: deviceStatusSchema,
});
export type DeviceStatus = z.infer<typeof deviceStatusSchema>;
export type DeviceStatusPair = z.infer<typeof deviceStatusPairSchema>;
+12
View File
@@ -0,0 +1,12 @@
import { z } from 'zod';
export const greetingQuerySchema = z.object({
name: z.string().min(1),
});
export const greetingOutputSchema = z.object({
message: z.string(),
});
export type GreetingQuery = z.infer<typeof greetingQuerySchema>;
export type GreetingOutput = z.infer<typeof greetingOutputSchema>;
+4
View File
@@ -0,0 +1,4 @@
export * from './device-status.js';
export * from './greeting.js';
export * from './presence.js';
export * from './telemetry.js';
+4
View File
@@ -0,0 +1,4 @@
/** Redis TTL and Postgres "online" window for the demo. */
export const PRESENCE_TTL_SECONDS = 15;
export const DEMO_DEVICE_ID = 'dev-1';
+11
View File
@@ -0,0 +1,11 @@
import { z } from 'zod';
export const telemetryPayloadSchema = z.record(z.unknown());
export const telemetryIngestSchema = z.object({
deviceId: z.string().min(1),
sentAt: z.string().datetime(),
payload: telemetryPayloadSchema,
});
export type TelemetryIngest = z.infer<typeof telemetryIngestSchema>;
+1531 -2859
View File
File diff suppressed because it is too large. Load diff