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,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
|
||||
Vendored
+14
-6
@@ -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)"
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+22
-4
@@ -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)"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](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).
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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!');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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'],
|
||||
},
|
||||
});
|
||||
@@ -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.
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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: './',
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
root: './',
|
||||
include: ['**/*.spec.ts'],
|
||||
},
|
||||
});
|
||||
File renamed without changes.
@@ -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.
|
||||
@@ -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,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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'],
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
+29
-116
@@ -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>
|
||||
</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>
|
||||
</>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -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
@@ -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';
|
||||
@@ -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>
|
||||
<App />
|
||||
<Provider store={store}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>()
|
||||
@@ -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
|
||||
@@ -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: ['..'],
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "contracts",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3"
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './device-status.js';
|
||||
export * from './greeting.js';
|
||||
export * from './presence.js';
|
||||
export * from './telemetry.js';
|
||||
@@ -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';
|
||||
@@ -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>;
|
||||
Reference in new issue
Block a user