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",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "API: Attach Nest (9229)",
|
"name": "CRUD: Attach Express (9229)",
|
||||||
"type": "node",
|
"type": "node",
|
||||||
"request": "attach",
|
"request": "attach",
|
||||||
"port": 9229,
|
"port": 9229,
|
||||||
"restart": true,
|
"restart": true,
|
||||||
"preLaunchTask": "api:debug",
|
"preLaunchTask": "crud:debug",
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"skipFiles": [
|
"skipFiles": ["<node_internals>/**"]
|
||||||
"<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)",
|
"name": "Web: Chrome (Vite 5173)",
|
||||||
@@ -26,7 +34,7 @@
|
|||||||
{
|
{
|
||||||
"name": "Full Stack Debug",
|
"name": "Full Stack Debug",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
"API: Attach Nest (9229)",
|
"CRUD: Attach Express (9229)",
|
||||||
"Web: Chrome (Vite 5173)"
|
"Web: Chrome (Vite 5173)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+22
-4
@@ -2,9 +2,9 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{
|
{
|
||||||
"label": "api:debug",
|
"label": "crud:debug",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "yarn workspace api start:debug",
|
"command": "yarn workspace crud-api start:debug",
|
||||||
"isBackground": true,
|
"isBackground": true,
|
||||||
"problemMatcher": {
|
"problemMatcher": {
|
||||||
"owner": "custom",
|
"owner": "custom",
|
||||||
@@ -14,8 +14,26 @@
|
|||||||
},
|
},
|
||||||
"background": {
|
"background": {
|
||||||
"activeOnStart": true,
|
"activeOnStart": true,
|
||||||
"beginsPattern": "Starting compilation in watch mode",
|
"beginsPattern": "^.*$",
|
||||||
"endsPattern": "Watching for file changes"
|
"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
|
# 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.
|
```text
|
||||||
2. Follow [docs/trest-test-plan.md](docs/trest-test-plan.md) to validate installation and basic usage.
|
packages/contracts Zod only
|
||||||
3. If you are new to Yarn, read [docs/yarn-basics-cheatsheet.md](docs/yarn-basics-cheatsheet.md).
|
apps/ingest-api Koa POST /telemetry
|
||||||
4. If you come from Express + SQL background, read [docs/express-sql-to-monorepo-guide.md](docs/express-sql-to-monorepo-guide.md).
|
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.
|
```bash
|
||||||
- Install `trest` in a local sandbox project.
|
docker compose up -d
|
||||||
- Run a small smoke test and a few behavior checks.
|
yarn install
|
||||||
- Document what worked, what failed, and open questions for the team.
|
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",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"rootDir": "./src"
|
"rootDir": "./src",
|
||||||
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
@@ -7,8 +7,6 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
"emitDecoratorMetadata": true,
|
|
||||||
"experimentalDecorators": true,
|
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"target": "ES2023",
|
"target": "ES2023",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
@@ -16,7 +14,7 @@
|
|||||||
"incremental": true,
|
"incremental": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"strictPropertyInitialization": false,
|
|
||||||
"types": ["vitest/globals", "node"]
|
"types": ["vitest/globals", "node"]
|
||||||
}
|
},
|
||||||
|
"include": ["src", "test"]
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [tsconfigPaths()],
|
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
root: './',
|
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"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^6.3.4",
|
"@reduxjs/toolkit": "^2.12.0",
|
||||||
"antd": "^6.6.2",
|
"axios": "^1.20.0",
|
||||||
|
"contracts": "workspace:*",
|
||||||
"react": "^19.2.8",
|
"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": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@tailwindcss/vite": "^4",
|
||||||
"@types/node": "^24.13.3",
|
"@types/node": "^24.13.3",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.4",
|
||||||
@@ -25,6 +30,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.4",
|
"eslint-plugin-react-refresh": "^0.5.4",
|
||||||
"globals": "^17.11.0",
|
"globals": "^17.11.0",
|
||||||
|
"tailwindcss": "^4",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.67.0",
|
"typescript-eslint": "^8.67.0",
|
||||||
"vite": "^8.2.2"
|
"vite": "^8.2.2"
|
||||||
|
|||||||
+29
-116
@@ -1,122 +1,35 @@
|
|||||||
import { useState } from 'react'
|
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
|
||||||
import heroImg from './assets/hero.png'
|
import { DevicesPage } from './pages/DevicesPage'
|
||||||
import reactLogo from './assets/react.svg'
|
import { GreetingPage } from './pages/GreetingPage'
|
||||||
import viteLogo from './assets/vite.svg'
|
|
||||||
import './App.css'
|
|
||||||
|
|
||||||
function App() {
|
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
||||||
const [count, setCount] = useState(0)
|
`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 (
|
return (
|
||||||
<>
|
<div className="min-h-screen bg-slate-100">
|
||||||
<section id="center">
|
<header className="border-b border-slate-200 bg-white">
|
||||||
<div className="hero">
|
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
|
||||||
<img src={heroImg} className="base" width="170" height="179" alt="" />
|
<span className="font-semibold text-slate-900">marco-trest-test</span>
|
||||||
<img src={reactLogo} className="framework" alt="React logo" />
|
<nav className="flex gap-2">
|
||||||
<img src={viteLogo} className="vite" alt="Vite logo" />
|
<NavLink to="/greeting" className={linkClass}>
|
||||||
|
Greeting
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/devices" className={linkClass}>
|
||||||
|
Devices
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</header>
|
||||||
<h1>Get started</h1>
|
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||||
<p>
|
<Routes>
|
||||||
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
|
<Route path="/" element={<Navigate to="/devices" replace />} />
|
||||||
</p>
|
<Route path="/greeting" element={<GreetingPage />} />
|
||||||
</div>
|
<Route path="/devices" element={<DevicesPage />} />
|
||||||
<button
|
</Routes>
|
||||||
type="button"
|
</main>
|
||||||
className="counter"
|
</div>
|
||||||
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
|
|
||||||
@@ -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 {
|
@import 'tailwindcss';
|
||||||
--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);
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
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 App from './App.tsx'
|
||||||
|
import { store } from './store'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<Provider store={store}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</Provider>
|
||||||
</StrictMode>,
|
</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 react from '@vitejs/plugin-react'
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
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
|
## 1) What You Are Building
|
||||||
|
|
||||||
Target architecture:
|
Target architecture:
|
||||||
|
|
||||||
- Backend API: NestJS on Fastify
|
- Backend API: Koa + trest
|
||||||
- Frontend: Vite + React + Ant Design
|
- Frontend: Vite + React + Ant Design
|
||||||
- Shared/private integration: trest in an isolated adapter package
|
|
||||||
- Monorepo manager: Yarn workspaces
|
- Monorepo manager: Yarn workspaces
|
||||||
|
|
||||||
Why this shape:
|
Why this shape:
|
||||||
|
|
||||||
- Keep your main backend on a scalable Fastify stack.
|
- Keep the HTTP API on Koa so it matches trest's router.
|
||||||
- Evaluate trest without coupling everything to Koa ecosystem packages.
|
|
||||||
- Keep one repo and one lockfile for easier onboarding.
|
- Keep one repo and one lockfile for easier onboarding.
|
||||||
|
|
||||||
## 2) Mental Map (Old to New)
|
## 2) Mental Map (Old to New)
|
||||||
@@ -159,32 +157,22 @@ Checkpoint:
|
|||||||
|
|
||||||
- Vite app runs and Ant Design components render.
|
- Vite app runs and Ant Design components render.
|
||||||
|
|
||||||
### Step F: Introduce trest Safely
|
### Step F: Introduce trest on the API
|
||||||
|
|
||||||
Recommended approach:
|
Install trest and its Koa peers in `apps/api`:
|
||||||
|
|
||||||
- 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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p packages/trest-adapter
|
yarn workspace api add @jig-software/trest koa @koa/router koa-body path-to-regexp zod bson
|
||||||
cd packages/trest-adapter
|
|
||||||
yarn init -y
|
|
||||||
yarn add @jig-software/trest koa @koa/router koa-body path-to-regexp zod bson
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Checkpoint:
|
Checkpoint:
|
||||||
|
|
||||||
- Importing trest from adapter works.
|
- `GET /greeting?name=Marco` returns a JSON greeting from the Koa app.
|
||||||
|
|
||||||
### Step G: Wire API and Web
|
### Step G: Wire API and Web
|
||||||
|
|
||||||
- Add one simple API endpoint in Nest.
|
- Call `/greeting` from a React page.
|
||||||
- Call endpoint from React page.
|
- Render the response using an Ant Design component.
|
||||||
- Render response using an Ant Design component.
|
|
||||||
|
|
||||||
Checkpoint:
|
Checkpoint:
|
||||||
|
|
||||||
@@ -197,8 +185,6 @@ marco-trest-test/
|
|||||||
apps/
|
apps/
|
||||||
api/
|
api/
|
||||||
web/
|
web/
|
||||||
packages/
|
|
||||||
trest-adapter/
|
|
||||||
docs/
|
docs/
|
||||||
package.json
|
package.json
|
||||||
yarn.lock
|
yarn.lock
|
||||||
@@ -209,11 +195,10 @@ marco-trest-test/
|
|||||||
|
|
||||||
Use these rules to avoid confusion:
|
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.
|
2. Put frontend runtime dependencies in apps/web.
|
||||||
3. Put trest boundary dependencies in packages/trest-adapter.
|
3. Keep root dependencies minimal and mostly tooling.
|
||||||
4. Keep root dependencies minimal and mostly tooling.
|
4. Run installs from root unless targeting one workspace intentionally.
|
||||||
5. Run installs from root unless targeting one workspace intentionally.
|
|
||||||
|
|
||||||
## 8) Troubleshooting You Are Likely to Hit
|
## 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/*",
|
"apps/*",
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"scripts": {
|
||||||
"@jig-software/trest": "^2.0.0-beta",
|
"compose:up": "docker compose up -d",
|
||||||
"@koa/router": "^12",
|
"compose:down": "docker compose down",
|
||||||
"bson": "^5",
|
"dev:crud": "yarn workspace crud-api start:dev",
|
||||||
"koa": "^2",
|
"dev:ingest": "yarn workspace ingest-api start:dev",
|
||||||
"koa-body": "^6",
|
"dev:web": "yarn workspace web dev --host 127.0.0.1 --port 5173"
|
||||||
"path-to-regexp": "^6",
|
|
||||||
"zod": "^3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "5.9.2"
|
"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