techbro 0.0.0-stage → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +453 -2
- package/assets/baseline-rules.yml +60 -0
- package/assets/caller-workflow.yml +26 -0
- package/assets/review-action.yml +27 -0
- package/assets/review-config.json +4 -0
- package/assets/review-workflow.yml +443 -0
- package/assets/review.mjs +721 -0
- package/bin/techbro.js +9 -0
- package/package.json +23 -4
- package/src/cli.js +690 -0
- package/src/cluster.js +134 -0
- package/src/config.js +35 -0
- package/src/doctor.js +125 -0
- package/src/github.js +152 -0
- package/src/init.js +167 -0
- package/src/kube-project.js +468 -0
- package/src/kube.js +317 -0
- package/src/managed.js +63 -0
- package/src/project.js +202 -0
- package/src/review.js +165 -0
- package/src/secret.js +80 -0
- package/src/setup.js +91 -0
- package/src/team-workflow.js +106 -0
- package/src/team.js +145 -0
- package/src/templates.js +44 -0
- package/src/upgrade.js +94 -0
- package/templates/_default/.agents/skills/README.md +36 -0
- package/templates/_default/.agents/skills/database-migrations/SKILL.md +72 -0
- package/templates/_default/.agents/skills/frontend-feature/SKILL.md +65 -0
- package/templates/_default/.agents/skills/release-safe-change/SKILL.md +75 -0
- package/templates/_default/.agents/skills/rust-backend-feature/SKILL.md +68 -0
- package/templates/_default/.editorconfig +15 -0
- package/templates/_default/.env.example +9 -0
- package/templates/_default/.github/workflows/ci.yml +60 -0
- package/templates/_default/.nvmrc +1 -0
- package/templates/_default/Cargo.lock +1834 -0
- package/templates/_default/Cargo.toml +3 -0
- package/templates/_default/README.md +257 -0
- package/templates/_default/TEMPLATE.md +29 -0
- package/templates/_default/_dockerignore +8 -0
- package/templates/_default/_gitignore +8 -0
- package/templates/_default/apps/backend/Cargo.toml +22 -0
- package/templates/_default/apps/backend/Dockerfile +34 -0
- package/templates/_default/apps/backend/README.md +13 -0
- package/templates/_default/apps/backend/src/config.rs +111 -0
- package/templates/_default/apps/backend/src/controllers/health.rs +36 -0
- package/templates/_default/apps/backend/src/controllers/items.rs +79 -0
- package/templates/_default/apps/backend/src/controllers/mod.rs +2 -0
- package/templates/_default/apps/backend/src/error.rs +115 -0
- package/templates/_default/apps/backend/src/lib.rs +7 -0
- package/templates/_default/apps/backend/src/main.rs +71 -0
- package/templates/_default/apps/backend/src/models/item.rs +94 -0
- package/templates/_default/apps/backend/src/models/mod.rs +1 -0
- package/templates/_default/apps/backend/src/routes.rs +94 -0
- package/templates/_default/apps/backend/src/state.rs +12 -0
- package/templates/_default/apps/backend/src/views/items.rs +18 -0
- package/templates/_default/apps/backend/src/views/mod.rs +1 -0
- package/templates/_default/apps/backend/tests/api.rs +234 -0
- package/templates/_default/apps/frontend/.env.example +2 -0
- package/templates/_default/apps/frontend/.prettierignore +3 -0
- package/templates/_default/apps/frontend/.prettierrc +1 -0
- package/templates/_default/apps/frontend/Dockerfile +15 -0
- package/templates/_default/apps/frontend/README.md +39 -0
- package/templates/_default/apps/frontend/_dockerignore +3 -0
- package/templates/_default/apps/frontend/_gitignore +24 -0
- package/templates/_default/apps/frontend/components.json +25 -0
- package/templates/_default/apps/frontend/eslint.config.js +37 -0
- package/templates/_default/apps/frontend/index.html +13 -0
- package/templates/_default/apps/frontend/jsconfig.json +7 -0
- package/templates/_default/apps/frontend/nginx.conf +30 -0
- package/templates/_default/apps/frontend/package-lock.json +4338 -0
- package/templates/_default/apps/frontend/package.json +49 -0
- package/templates/_default/apps/frontend/public/favicon.svg +1 -0
- package/templates/_default/apps/frontend/src/App.jsx +11 -0
- package/templates/_default/apps/frontend/src/App.test.jsx +40 -0
- package/templates/_default/apps/frontend/src/api/client.js +50 -0
- package/templates/_default/apps/frontend/src/api/client.test.js +70 -0
- package/templates/_default/apps/frontend/src/api/items.js +26 -0
- package/templates/_default/apps/frontend/src/api/system.js +11 -0
- package/templates/_default/apps/frontend/src/components/ItemList.jsx +20 -0
- package/templates/_default/apps/frontend/src/components/ItemList.test.jsx +18 -0
- package/templates/_default/apps/frontend/src/components/Layout.jsx +35 -0
- package/templates/_default/apps/frontend/src/components/StatusBadge.jsx +22 -0
- package/templates/_default/apps/frontend/src/components/StepList.jsx +19 -0
- package/templates/_default/apps/frontend/src/components/ui/button.jsx +52 -0
- package/templates/_default/apps/frontend/src/components/ui/card.jsx +83 -0
- package/templates/_default/apps/frontend/src/components/ui/input.jsx +19 -0
- package/templates/_default/apps/frontend/src/hooks/useBackendStatus.js +33 -0
- package/templates/_default/apps/frontend/src/hooks/useItems.js +47 -0
- package/templates/_default/apps/frontend/src/index.css +130 -0
- package/templates/_default/apps/frontend/src/lib/config.js +2 -0
- package/templates/_default/apps/frontend/src/lib/utils.js +6 -0
- package/templates/_default/apps/frontend/src/main.jsx +11 -0
- package/templates/_default/apps/frontend/src/models/items.js +24 -0
- package/templates/_default/apps/frontend/src/models/items.test.js +24 -0
- package/templates/_default/apps/frontend/src/pages/ItemsPage.jsx +41 -0
- package/templates/_default/apps/frontend/src/pages/WelcomePage.jsx +156 -0
- package/templates/_default/apps/frontend/src/pages/WelcomePage.test.jsx +55 -0
- package/templates/_default/apps/frontend/src/routes.jsx +17 -0
- package/templates/_default/apps/frontend/src/shadcn.css +655 -0
- package/templates/_default/apps/frontend/src/test/setup.js +1 -0
- package/templates/_default/apps/frontend/vite.config.js +25 -0
- package/templates/_default/database/Dockerfile +9 -0
- package/templates/_default/database/README.md +15 -0
- package/templates/_default/database/migrations/postgres/20261008120000_create_items.sql +5 -0
- package/templates/_default/database/migrations/sqlite/20261008120000_create_items.sql +5 -0
- package/templates/_default/docker-compose.prod.yml +53 -0
- package/templates/_default/docker-compose.yml +69 -0
- package/templates/_default/docs/api/README.md +41 -0
- package/templates/_default/docs/api/openapi.yaml +152 -0
- package/templates/_default/docs/decisions/0001-adopt-engineering-convention.md +13 -0
- package/templates/_default/docs/decisions/README.md +6 -0
- package/templates/_default/docs/deployment.md +46 -0
- package/templates/_default/docs/docker.md +63 -0
- package/templates/_default/docs/getting-started.md +214 -0
- package/templates/_default/package.json +15 -0
- package/templates/_default/rust-toolchain.toml +3 -0
- package/templates/_default/scripts/check +10 -0
- package/templates/_default/scripts/db-migrate +25 -0
- package/templates/_default/scripts/install-hooks +5 -0
- package/templates/_default/scripts/pre-push +3 -0
- package/templates/_default/scripts/setup +26 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Example only: replace credentials with injected secrets and use your registry images.
|
|
2
|
+
services:
|
|
3
|
+
postgres:
|
|
4
|
+
image: postgres:17-alpine
|
|
5
|
+
environment:
|
|
6
|
+
POSTGRES_USER: app
|
|
7
|
+
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
|
8
|
+
POSTGRES_DB: app
|
|
9
|
+
volumes:
|
|
10
|
+
- pgdata:/var/lib/postgresql/data
|
|
11
|
+
healthcheck:
|
|
12
|
+
test: ["CMD-SHELL", "pg_isready -U app -d app"]
|
|
13
|
+
interval: 5s
|
|
14
|
+
timeout: 3s
|
|
15
|
+
retries: 10
|
|
16
|
+
|
|
17
|
+
migrate:
|
|
18
|
+
build: ./database
|
|
19
|
+
environment:
|
|
20
|
+
DATABASE_URL: postgres://app:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/app
|
|
21
|
+
depends_on:
|
|
22
|
+
postgres:
|
|
23
|
+
condition: service_healthy
|
|
24
|
+
restart: "no"
|
|
25
|
+
|
|
26
|
+
backend:
|
|
27
|
+
build:
|
|
28
|
+
context: .
|
|
29
|
+
dockerfile: apps/backend/Dockerfile
|
|
30
|
+
args:
|
|
31
|
+
BUILD_SHA: ${BUILD_SHA:-dev}
|
|
32
|
+
environment:
|
|
33
|
+
APP_NAME: my-app
|
|
34
|
+
APP_PORT: "8080"
|
|
35
|
+
DATABASE_URL: postgres://app:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/app
|
|
36
|
+
RUST_LOG: info
|
|
37
|
+
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost}
|
|
38
|
+
depends_on:
|
|
39
|
+
migrate:
|
|
40
|
+
condition: service_completed_successfully
|
|
41
|
+
stop_grace_period: 30s
|
|
42
|
+
restart: unless-stopped
|
|
43
|
+
|
|
44
|
+
frontend:
|
|
45
|
+
build: ./apps/frontend
|
|
46
|
+
depends_on:
|
|
47
|
+
- backend
|
|
48
|
+
ports:
|
|
49
|
+
- "80:8080"
|
|
50
|
+
restart: unless-stopped
|
|
51
|
+
|
|
52
|
+
volumes:
|
|
53
|
+
pgdata:
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Development stack. Production example: docker-compose.prod.yml
|
|
2
|
+
services:
|
|
3
|
+
postgres:
|
|
4
|
+
image: postgres:17-alpine
|
|
5
|
+
environment:
|
|
6
|
+
POSTGRES_USER: app
|
|
7
|
+
POSTGRES_PASSWORD: app
|
|
8
|
+
POSTGRES_DB: app
|
|
9
|
+
volumes:
|
|
10
|
+
- pgdata:/var/lib/postgresql/data
|
|
11
|
+
ports:
|
|
12
|
+
- "5432:5432"
|
|
13
|
+
healthcheck:
|
|
14
|
+
test: ["CMD-SHELL", "pg_isready -U app -d app"]
|
|
15
|
+
interval: 5s
|
|
16
|
+
timeout: 3s
|
|
17
|
+
retries: 10
|
|
18
|
+
|
|
19
|
+
migrate:
|
|
20
|
+
build: ./database
|
|
21
|
+
environment:
|
|
22
|
+
DATABASE_URL: postgres://app:app@postgres:5432/app
|
|
23
|
+
volumes:
|
|
24
|
+
- ./database/migrations/postgres:/migrations:ro
|
|
25
|
+
depends_on:
|
|
26
|
+
postgres:
|
|
27
|
+
condition: service_healthy
|
|
28
|
+
restart: "no"
|
|
29
|
+
|
|
30
|
+
backend:
|
|
31
|
+
build:
|
|
32
|
+
context: .
|
|
33
|
+
dockerfile: apps/backend/Dockerfile
|
|
34
|
+
target: dev
|
|
35
|
+
working_dir: /app
|
|
36
|
+
command: cargo watch -x 'run -p backend'
|
|
37
|
+
volumes:
|
|
38
|
+
- .:/app
|
|
39
|
+
- cargo-registry:/usr/local/cargo/registry
|
|
40
|
+
- backend-target:/app/target
|
|
41
|
+
environment:
|
|
42
|
+
APP_NAME: my-app
|
|
43
|
+
APP_PORT: "8080"
|
|
44
|
+
DATABASE_URL: postgres://app:app@postgres:5432/app
|
|
45
|
+
RUST_LOG: info
|
|
46
|
+
CORS_ALLOWED_ORIGINS: http://localhost:5173
|
|
47
|
+
depends_on:
|
|
48
|
+
migrate:
|
|
49
|
+
condition: service_completed_successfully
|
|
50
|
+
ports:
|
|
51
|
+
- "8080:8080"
|
|
52
|
+
|
|
53
|
+
frontend:
|
|
54
|
+
image: node:22-alpine
|
|
55
|
+
working_dir: /app
|
|
56
|
+
command: sh -c "npm ci && npm run dev -- --host"
|
|
57
|
+
environment:
|
|
58
|
+
VITE_API_BASE_URL: http://localhost:8080
|
|
59
|
+
WATCH_POLL: "1"
|
|
60
|
+
volumes:
|
|
61
|
+
- ./apps/frontend:/app
|
|
62
|
+
- /app/node_modules
|
|
63
|
+
ports:
|
|
64
|
+
- "5173:5173"
|
|
65
|
+
|
|
66
|
+
volumes:
|
|
67
|
+
pgdata:
|
|
68
|
+
cargo-registry:
|
|
69
|
+
backend-target:
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# API contract
|
|
2
|
+
|
|
3
|
+
Machine-readable contract: [`openapi.yaml`](openapi.yaml). JSON uses `camelCase`; timestamps are
|
|
4
|
+
UTC ISO 8601; ids are integers. Every response carries an `x-request-id` header.
|
|
5
|
+
|
|
6
|
+
Every response also carries a separate `x-trace-id` correlation header, including errors
|
|
7
|
+
and CORS preflights. Send a valid UUID to have it echoed; absent or invalid values are
|
|
8
|
+
replaced with a generated UUID. `apiFetch` generates a fresh UUID per call unless the
|
|
9
|
+
caller explicitly supplies the header (object, tuple array, or `Headers`). `ApiError.traceId`
|
|
10
|
+
reads the response header; the error body's `requestId` contract is unchanged.
|
|
11
|
+
These IDs are for correlation, never authentication. Configured CORS origins may send
|
|
12
|
+
`x-trace-id` and read it on responses; origin/method restrictions remain unchanged.
|
|
13
|
+
|
|
14
|
+
## Errors
|
|
15
|
+
```json
|
|
16
|
+
{ "error": { "code": "VALIDATION_ERROR", "message": "Invalid input",
|
|
17
|
+
"details": [{ "field": "name", "message": "Must not be empty" }] },
|
|
18
|
+
"requestId": "3f1c..." }
|
|
19
|
+
```
|
|
20
|
+
Codes: `VALIDATION_ERROR` (400), `NOT_FOUND` (404), `SERVICE_UNAVAILABLE` (503), `DRAINING` (503), `INTERNAL_ERROR` (500).
|
|
21
|
+
|
|
22
|
+
## GET /health
|
|
23
|
+
`200` `{ "status": "ok", "version": "0.1.0", "build": "abc123" }` - liveness only. `build` is the
|
|
24
|
+
`BUILD_SHA` set at image build time (`dev` if unset); use it to verify which deployment answers.
|
|
25
|
+
|
|
26
|
+
## GET /ready
|
|
27
|
+
`200` `{ "status": "ready" }`; `503` error shape when the database check fails (2s timeout), or code `DRAINING` after SIGTERM.
|
|
28
|
+
|
|
29
|
+
## GET /api/v1/items?page=1&perPage=20
|
|
30
|
+
`perPage` max 100.
|
|
31
|
+
```json
|
|
32
|
+
{ "items": [{ "id": 1, "name": "widget", "createdAt": "2026-10-08T12:00:00.000Z" }],
|
|
33
|
+
"pagination": { "page": 1, "perPage": 20, "total": 1 } }
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## POST /api/v1/items
|
|
37
|
+
Request `{ "name": "widget" }` (trimmed, 1–200 chars). `201` returns the item
|
|
38
|
+
`{ "id": 1, "name": "widget", "createdAt": "..." }`. `400` on invalid input.
|
|
39
|
+
|
|
40
|
+
## GET /api/v1/items/{id}
|
|
41
|
+
`200` item object; `404` `NOT_FOUND`; `400` if `id` is not an integer.
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
openapi: 3.0.3
|
|
2
|
+
info:
|
|
3
|
+
title: Project Template API
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
paths:
|
|
6
|
+
/health:
|
|
7
|
+
parameters:
|
|
8
|
+
- { $ref: "#/components/parameters/TraceId" }
|
|
9
|
+
get:
|
|
10
|
+
summary: Liveness
|
|
11
|
+
responses:
|
|
12
|
+
"200":
|
|
13
|
+
description: OK
|
|
14
|
+
headers:
|
|
15
|
+
x-trace-id: { $ref: "#/components/headers/TraceId" }
|
|
16
|
+
content:
|
|
17
|
+
application/json:
|
|
18
|
+
schema:
|
|
19
|
+
type: object
|
|
20
|
+
properties:
|
|
21
|
+
status: { type: string, example: ok }
|
|
22
|
+
version: { type: string, example: 0.1.0 }
|
|
23
|
+
build: { type: string, example: abc123 }
|
|
24
|
+
/ready:
|
|
25
|
+
parameters:
|
|
26
|
+
- { $ref: "#/components/parameters/TraceId" }
|
|
27
|
+
get:
|
|
28
|
+
summary: Readiness (database check)
|
|
29
|
+
responses:
|
|
30
|
+
"200":
|
|
31
|
+
description: Ready
|
|
32
|
+
headers:
|
|
33
|
+
x-trace-id: { $ref: "#/components/headers/TraceId" }
|
|
34
|
+
content:
|
|
35
|
+
application/json:
|
|
36
|
+
schema:
|
|
37
|
+
type: object
|
|
38
|
+
properties:
|
|
39
|
+
status: { type: string, example: ready }
|
|
40
|
+
"503": { $ref: "#/components/responses/Error" }
|
|
41
|
+
/api/v1/items:
|
|
42
|
+
parameters:
|
|
43
|
+
- { $ref: "#/components/parameters/TraceId" }
|
|
44
|
+
get:
|
|
45
|
+
summary: List items
|
|
46
|
+
parameters:
|
|
47
|
+
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
|
48
|
+
- { name: perPage, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
|
|
49
|
+
responses:
|
|
50
|
+
"200":
|
|
51
|
+
description: Page of items
|
|
52
|
+
headers:
|
|
53
|
+
x-trace-id: { $ref: "#/components/headers/TraceId" }
|
|
54
|
+
content:
|
|
55
|
+
application/json:
|
|
56
|
+
schema:
|
|
57
|
+
type: object
|
|
58
|
+
required: [items, pagination]
|
|
59
|
+
properties:
|
|
60
|
+
items:
|
|
61
|
+
type: array
|
|
62
|
+
items: { $ref: "#/components/schemas/Item" }
|
|
63
|
+
pagination:
|
|
64
|
+
type: object
|
|
65
|
+
required: [page, perPage, total]
|
|
66
|
+
properties:
|
|
67
|
+
page: { type: integer }
|
|
68
|
+
perPage: { type: integer }
|
|
69
|
+
total: { type: integer }
|
|
70
|
+
"400": { $ref: "#/components/responses/Error" }
|
|
71
|
+
post:
|
|
72
|
+
summary: Create item
|
|
73
|
+
requestBody:
|
|
74
|
+
required: true
|
|
75
|
+
content:
|
|
76
|
+
application/json:
|
|
77
|
+
schema:
|
|
78
|
+
type: object
|
|
79
|
+
required: [name]
|
|
80
|
+
properties:
|
|
81
|
+
name: { type: string, minLength: 1, maxLength: 200 }
|
|
82
|
+
responses:
|
|
83
|
+
"201":
|
|
84
|
+
description: Created
|
|
85
|
+
headers:
|
|
86
|
+
x-trace-id: { $ref: "#/components/headers/TraceId" }
|
|
87
|
+
content:
|
|
88
|
+
application/json:
|
|
89
|
+
schema: { $ref: "#/components/schemas/Item" }
|
|
90
|
+
"400": { $ref: "#/components/responses/Error" }
|
|
91
|
+
/api/v1/items/{id}:
|
|
92
|
+
parameters:
|
|
93
|
+
- { $ref: "#/components/parameters/TraceId" }
|
|
94
|
+
get:
|
|
95
|
+
summary: Get item
|
|
96
|
+
parameters:
|
|
97
|
+
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
|
|
98
|
+
responses:
|
|
99
|
+
"200":
|
|
100
|
+
description: Item
|
|
101
|
+
headers:
|
|
102
|
+
x-trace-id: { $ref: "#/components/headers/TraceId" }
|
|
103
|
+
content:
|
|
104
|
+
application/json:
|
|
105
|
+
schema: { $ref: "#/components/schemas/Item" }
|
|
106
|
+
"400": { $ref: "#/components/responses/Error" }
|
|
107
|
+
"404": { $ref: "#/components/responses/Error" }
|
|
108
|
+
components:
|
|
109
|
+
parameters:
|
|
110
|
+
TraceId:
|
|
111
|
+
name: x-trace-id
|
|
112
|
+
in: header
|
|
113
|
+
required: false
|
|
114
|
+
description: Optional correlation UUID, never authentication. Invalid or absent values are replaced; valid values are echoed. Independent of x-request-id and error requestId.
|
|
115
|
+
schema: { type: string, format: uuid }
|
|
116
|
+
headers:
|
|
117
|
+
TraceId:
|
|
118
|
+
description: Correlation UUID, including on errors. Exposed to configured CORS origins.
|
|
119
|
+
schema: { type: string, format: uuid }
|
|
120
|
+
schemas:
|
|
121
|
+
Item:
|
|
122
|
+
type: object
|
|
123
|
+
required: [id, name, createdAt]
|
|
124
|
+
properties:
|
|
125
|
+
id: { type: integer, format: int64 }
|
|
126
|
+
name: { type: string }
|
|
127
|
+
createdAt: { type: string, format: date-time }
|
|
128
|
+
responses:
|
|
129
|
+
Error:
|
|
130
|
+
description: Error
|
|
131
|
+
headers:
|
|
132
|
+
x-trace-id: { $ref: "#/components/headers/TraceId" }
|
|
133
|
+
content:
|
|
134
|
+
application/json:
|
|
135
|
+
schema:
|
|
136
|
+
type: object
|
|
137
|
+
required: [error, requestId]
|
|
138
|
+
properties:
|
|
139
|
+
error:
|
|
140
|
+
type: object
|
|
141
|
+
required: [code, message]
|
|
142
|
+
properties:
|
|
143
|
+
code: { type: string }
|
|
144
|
+
message: { type: string }
|
|
145
|
+
details:
|
|
146
|
+
type: array
|
|
147
|
+
items:
|
|
148
|
+
type: object
|
|
149
|
+
properties:
|
|
150
|
+
field: { type: string }
|
|
151
|
+
message: { type: string }
|
|
152
|
+
requestId: { type: string }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# 0001. Adopt the Engineering Convention
|
|
2
|
+
|
|
3
|
+
Status: Accepted (2026-10-08)
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
New projects need a consistent stack, structure, and checks.
|
|
7
|
+
|
|
8
|
+
## Decision
|
|
9
|
+
Follow the Engineering Convention in the root `README.md`: React + Vite frontend, Rust/Axum/SQLx
|
|
10
|
+
backend, SQLite locally, PostgreSQL in shared environments, independent SQL migrations.
|
|
11
|
+
|
|
12
|
+
## Consequences
|
|
13
|
+
Deviations require an approved exception recorded in this directory.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Decision records
|
|
2
|
+
|
|
3
|
+
Record significant decisions and approved exceptions to the convention as ADRs.
|
|
4
|
+
|
|
5
|
+
File name: `NNNN-short-title.md`. Sections: Status, Context, Decision, Consequences.
|
|
6
|
+
Statuses: Proposed, Accepted, Superseded by NNNN. Do not rewrite accepted records; supersede them.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Deployment: blue/green zero-downtime
|
|
2
|
+
|
|
3
|
+
Hosting specifics (load balancer, orchestrator, cloud) are outside the convention. This page
|
|
4
|
+
describes what the code guarantees and a generic cut-over procedure.
|
|
5
|
+
|
|
6
|
+
## What the code guarantees
|
|
7
|
+
|
|
8
|
+
- Stateless process: no local files or in-memory session state. All state lives in the database.
|
|
9
|
+
- Configuration comes only from environment variables (see `.env.example`).
|
|
10
|
+
- `GET /health` is liveness. It never touches the database and returns
|
|
11
|
+
`{"status":"ok","version":"...","build":"..."}`.
|
|
12
|
+
- `GET /ready` is readiness. It runs `SELECT 1` (2s timeout) and returns 503 when the database
|
|
13
|
+
is unreachable or when the process is draining (error code `DRAINING`).
|
|
14
|
+
- `build` is the `BUILD_SHA` build arg (`dev` if unset). Compare it across colors to confirm
|
|
15
|
+
which release serves traffic.
|
|
16
|
+
- SQLite is for local development only. Do not run several instances against a SQLite file.
|
|
17
|
+
|
|
18
|
+
## SIGTERM drain sequence
|
|
19
|
+
|
|
20
|
+
1. SIGTERM (or Ctrl-C) arrives. Log: `shutdown signal received, draining`.
|
|
21
|
+
2. The draining flag is set; `/ready` returns 503 `DRAINING`. Load balancers stop routing.
|
|
22
|
+
3. The process waits `SHUTDOWN_DRAIN_SECONDS` (default 5) with the listener still open.
|
|
23
|
+
4. The listener stops accepting; in-flight requests finish.
|
|
24
|
+
5. The pool closes. Log: `shutdown complete`. Exit code 0.
|
|
25
|
+
|
|
26
|
+
Timing: total stop time = drain seconds + longest in-flight request. Keep
|
|
27
|
+
`stop_grace_period` (30s in `docker-compose.prod.yml`) larger than that sum, and set the drain
|
|
28
|
+
seconds at least as long as your load balancer's probe interval times its failure threshold.
|
|
29
|
+
|
|
30
|
+
## Migrations
|
|
31
|
+
|
|
32
|
+
Migrations run separately, before traffic switches (`migrate` service or `scripts/db-migrate`).
|
|
33
|
+
Both colors share one database during cut-over, so every migration must be backward
|
|
34
|
+
compatible with the old release: expand (add), deploy, then contract (remove) in a later release.
|
|
35
|
+
See `database/README.md`. Never edit an applied migration.
|
|
36
|
+
|
|
37
|
+
## Cut-over steps
|
|
38
|
+
|
|
39
|
+
1. Run migrations against the target database (expand-only changes).
|
|
40
|
+
2. Start green (new build, `BUILD_SHA=<commit>`) alongside blue.
|
|
41
|
+
3. Wait until green `/ready` returns 200, and `/health` shows the expected `build`.
|
|
42
|
+
4. Switch traffic from blue to green at the load balancer.
|
|
43
|
+
5. Watch logs and error rates for green.
|
|
44
|
+
6. Send SIGTERM to blue and let it drain and exit.
|
|
45
|
+
7. Rollback: switch traffic back to blue while it is still running (before step 6), or start
|
|
46
|
+
the previous build again. Database changes stay backward compatible, so no schema rollback.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Docker
|
|
2
|
+
|
|
3
|
+
## Development stack
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm run docker:dev # docker compose up --build
|
|
7
|
+
npm run docker:down
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Services: `postgres` (17, user/password/db `app`, port 5432), `migrate` (one-shot sqlx CLI),
|
|
11
|
+
`backend` (http://localhost:8080), `frontend` (Vite dev server, http://localhost:5173, source
|
|
12
|
+
bind-mounted). Ports 8080 and 5173 must be free. The browser calls the backend directly via
|
|
13
|
+
`VITE_API_BASE_URL=http://localhost:8080`; CORS allows `http://localhost:5173`.
|
|
14
|
+
|
|
15
|
+
## Hot reload
|
|
16
|
+
|
|
17
|
+
- Backend: the `dev` stage of `apps/backend/Dockerfile` adds cargo-watch. The workspace is
|
|
18
|
+
bind-mounted at `/app`; `cargo-registry` and `backend-target` named volumes keep builds fast.
|
|
19
|
+
Saving a Rust file rebuilds and restarts the server (first start compiles everything).
|
|
20
|
+
- Frontend: Vite HMR on the bind mount. `WATCH_POLL=1` makes `vite.config.js` use polling,
|
|
21
|
+
which is needed for bind mounts on macOS.
|
|
22
|
+
|
|
23
|
+
## Production images
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
docker build -f apps/backend/Dockerfile -t backend . # context: template root
|
|
27
|
+
docker build -t frontend apps/frontend # context: apps/frontend
|
|
28
|
+
docker build -t migrate database # context: database
|
|
29
|
+
# or: npm run docker:build (backend and frontend)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- Backend: multi-stage Rust build with a cached dependency layer, runs as non-root user `app`
|
|
33
|
+
on port 8080. The base image tag must match `rust-toolchain.toml`.
|
|
34
|
+
- Frontend: Vite build served by `nginx-unprivileged` on port 8080. Build args:
|
|
35
|
+
`VITE_API_BASE_URL` (default empty, same origin) and `VITE_APP_NAME`.
|
|
36
|
+
- Migrate: sqlx CLI with `database/migrations/postgres` baked in.
|
|
37
|
+
|
|
38
|
+
`docker-compose.prod.yml` is an example that runs these images with PostgreSQL. It needs
|
|
39
|
+
`POSTGRES_PASSWORD` and optionally `CORS_ALLOWED_ORIGINS`; the frontend is published on port 80
|
|
40
|
+
and the backend is not published.
|
|
41
|
+
|
|
42
|
+
## Environment variables
|
|
43
|
+
|
|
44
|
+
Backend: `APP_NAME`, `APP_PORT`, `DATABASE_URL`, `RUST_LOG`, `CORS_ALLOWED_ORIGINS`.
|
|
45
|
+
Migrate: `DATABASE_URL`.
|
|
46
|
+
|
|
47
|
+
## SPA fallback and proxying
|
|
48
|
+
|
|
49
|
+
`apps/frontend/nginx.conf` serves `index.html` for unknown paths (`try_files ... /index.html`),
|
|
50
|
+
so client-side routes such as `/items` work on reload. `/api/`, `/health` and `/ready` are
|
|
51
|
+
proxied to `http://backend:8080`, so the production frontend can use same-origin API calls.
|
|
52
|
+
|
|
53
|
+
## Migrations
|
|
54
|
+
|
|
55
|
+
The app never migrates. The `migrate` service runs `sqlx database create && sqlx migrate run`
|
|
56
|
+
and the backend waits for it to complete successfully. To run migrations manually use
|
|
57
|
+
`scripts/db-migrate postgres|sqlite`.
|
|
58
|
+
|
|
59
|
+
## Switching DATABASE_URL
|
|
60
|
+
|
|
61
|
+
Change `DATABASE_URL` in the compose file (for `migrate` and `backend`) or `.env`, for example
|
|
62
|
+
`postgres://user:password@host:5432/dbname`. The migrate image only carries PostgreSQL
|
|
63
|
+
migrations; SQLite is for local development outside Docker.
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# Getting started
|
|
2
|
+
|
|
3
|
+
A walkthrough for a new developer right after cloning. The conventions behind it are in `README.md`.
|
|
4
|
+
|
|
5
|
+
## 1. Setup
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
scripts/setup # creates .env, npm ci, cargo fetch
|
|
9
|
+
cargo install sqlx-cli --no-default-features --features sqlite,postgres # once per machine
|
|
10
|
+
scripts/db-migrate sqlite # creates local.db and applies database/migrations/sqlite
|
|
11
|
+
cargo install cargo-watch # once per machine, backend hot reload
|
|
12
|
+
cargo watch -x 'run -p backend' # http://localhost:8080, restarts on source changes
|
|
13
|
+
npm --prefix apps/frontend run dev # http://localhost:5173, Vite HMR
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Hot reload: Vite updates the frontend in the browser without a refresh. `cargo watch` rebuilds
|
|
17
|
+
and restarts the backend on every save (`cargo run -p backend` works too, without reload). Inside
|
|
18
|
+
Docker, `docker compose up` does the same for both apps; see `docs/docker.md`.
|
|
19
|
+
|
|
20
|
+
Open http://localhost:5173. The Welcome page shows backend status (`/health` and `/ready`), the
|
|
21
|
+
steps of the sample feature, and a link to the Items page (`/items`) with a form to add an item.
|
|
22
|
+
The Vite dev server proxies `/api`, `/health` and `/ready` to the backend on port 8080
|
|
23
|
+
(`apps/frontend/vite.config.js`). The curl examples in section 4 do the same from a terminal.
|
|
24
|
+
|
|
25
|
+
Run all checks with `scripts/check`. Optional: `scripts/install-hooks` runs them before every push.
|
|
26
|
+
|
|
27
|
+
## 2. The sample feature, end to end
|
|
28
|
+
|
|
29
|
+
`items` goes through every layer. Excerpts are from the real files.
|
|
30
|
+
|
|
31
|
+
### Migrations (`database/migrations/<engine>/20261008120000_create_items.sql`)
|
|
32
|
+
|
|
33
|
+
```sql
|
|
34
|
+
-- postgres
|
|
35
|
+
CREATE TABLE items (
|
|
36
|
+
id BIGSERIAL PRIMARY KEY,
|
|
37
|
+
name TEXT NOT NULL,
|
|
38
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
-- sqlite
|
|
42
|
+
CREATE TABLE items (
|
|
43
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
44
|
+
name TEXT NOT NULL,
|
|
45
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
46
|
+
);
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Same logical schema, different types: SQLite stores the timestamp as ISO 8601 text.
|
|
50
|
+
|
|
51
|
+
### Model (`apps/backend/src/models/item.rs`)
|
|
52
|
+
|
|
53
|
+
```rust
|
|
54
|
+
pub fn validate_name(raw: &str) -> Result<String, AppError> {
|
|
55
|
+
let name = raw.trim();
|
|
56
|
+
if name.is_empty() {
|
|
57
|
+
return Err(AppError::validation("name", "Must not be empty"));
|
|
58
|
+
}
|
|
59
|
+
...
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// CAST keeps created_at decodable as text on both SQLite and PostgreSQL.
|
|
63
|
+
pub async fn find(pool: &AnyPool, id: i64) -> Result<Option<Item>, sqlx::Error> {
|
|
64
|
+
sqlx::query_as::<_, Item>(concat!("SELECT ", columns!(), " FROM items WHERE id = $1"))
|
|
65
|
+
.bind(id)
|
|
66
|
+
.fetch_optional(pool)
|
|
67
|
+
.await
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
SQL is explicit and parameterized. sqlx 0.9 requires static SQL strings, hence `concat!`.
|
|
72
|
+
|
|
73
|
+
### Controller (`apps/backend/src/controllers/items.rs`)
|
|
74
|
+
|
|
75
|
+
```rust
|
|
76
|
+
pub async fn get(
|
|
77
|
+
State(state): State<AppState>,
|
|
78
|
+
id: Result<Path<i64>, PathRejection>,
|
|
79
|
+
) -> Result<Json<Item>, AppError> {
|
|
80
|
+
let Path(id) = id.map_err(|_| AppError::validation("id", "Must be an integer"))?;
|
|
81
|
+
item::find(&state.pool, id)
|
|
82
|
+
.await?
|
|
83
|
+
.map(Json)
|
|
84
|
+
.ok_or(AppError::NotFound)
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Handlers stay thin: parse input, call the model, map to a response.
|
|
89
|
+
|
|
90
|
+
### Routes (`apps/backend/src/routes.rs`)
|
|
91
|
+
|
|
92
|
+
```rust
|
|
93
|
+
.route("/health", get(health::health))
|
|
94
|
+
.route("/ready", get(health::ready))
|
|
95
|
+
.route("/api/v1/items", get(items::list).post(items::create))
|
|
96
|
+
.route("/api/v1/items/{id}", get(items::get))
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Errors (`apps/backend/src/error.rs`)
|
|
100
|
+
|
|
101
|
+
`AppError` implements `IntoResponse` and produces the convention's shape:
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{ "error": { "code": "NOT_FOUND", "message": "Resource not found" }, "requestId": "..." }
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Database and internal errors are logged with `tracing::error!` and returned as a generic
|
|
108
|
+
`INTERNAL_ERROR`; internals are never exposed.
|
|
109
|
+
|
|
110
|
+
### Frontend API client (`apps/frontend/src/api/items.js`)
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
export function listItems({ page = 1, perPage = 20 } = {}) {
|
|
114
|
+
const query = new URLSearchParams({ page: String(page), perPage: String(perPage) });
|
|
115
|
+
return apiFetch(`/api/v1/items?${query}`);
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Hook (`apps/frontend/src/hooks/useItems.js`)
|
|
120
|
+
|
|
121
|
+
```js
|
|
122
|
+
const create = useCallback(async (name) => {
|
|
123
|
+
const check = validateItemName(name);
|
|
124
|
+
if (!check.ok) {
|
|
125
|
+
setError(check.message);
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
const created = await createItem({ name: name.trim() });
|
|
129
|
+
setItems((current) => [created, ...current]);
|
|
130
|
+
return true;
|
|
131
|
+
}, []);
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Client validation (`src/models/items.js`) is for usability only; the backend is authoritative.
|
|
135
|
+
|
|
136
|
+
### Page (`apps/frontend/src/pages/ItemsPage.jsx`)
|
|
137
|
+
|
|
138
|
+
```jsx
|
|
139
|
+
export default function ItemsPage() {
|
|
140
|
+
const { items, loading, error, create } = useItems();
|
|
141
|
+
...
|
|
142
|
+
{loading ? <p className="text-sm">Loading...</p> : <ItemList items={items} />}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## 3. Add your own feature
|
|
146
|
+
|
|
147
|
+
Schema and API contract changes need tech-team approval before implementation (convention
|
|
148
|
+
sections 8, 9 and 15).
|
|
149
|
+
|
|
150
|
+
1. Migration: add `<YYYYMMDDHHMMSS>_<description>.sql` to both `database/migrations/postgres/`
|
|
151
|
+
and `database/migrations/sqlite/`. Never edit a migration already applied to a shared
|
|
152
|
+
environment; add a corrective one.
|
|
153
|
+
2. Model: `apps/backend/src/models/<name>.rs` (types, validation, SQL), register in `models/mod.rs`.
|
|
154
|
+
3. View: response types in `apps/backend/src/views/` if the list/response shape differs.
|
|
155
|
+
4. Controller: `apps/backend/src/controllers/<name>.rs`, register in `controllers/mod.rs`.
|
|
156
|
+
5. Route: add to `router` in `apps/backend/src/routes.rs`.
|
|
157
|
+
6. API docs: update `docs/api/README.md` and `docs/api/openapi.yaml`.
|
|
158
|
+
7. Frontend: `src/api/<name>.js`, `src/models/<name>.js`, `src/hooks/use<Name>.js`,
|
|
159
|
+
`src/pages/<Name>Page.jsx`; wire the page in `App.jsx`. Add `/something` to the Vite proxy if
|
|
160
|
+
it is not under `/api`.
|
|
161
|
+
8. Tests: model unit tests, an integration test in `apps/backend/tests/api.rs`, and Vitest tests.
|
|
162
|
+
Verify Postgres-bound SQL against a real PostgreSQL.
|
|
163
|
+
9. Run `scripts/check`.
|
|
164
|
+
|
|
165
|
+
## 4. Curl examples
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
curl -i localhost:8080/health
|
|
169
|
+
# 200 {"status":"ok","version":"0.1.0","build":"dev"}
|
|
170
|
+
|
|
171
|
+
curl -i localhost:8080/ready
|
|
172
|
+
# 200 {"status":"ready"} (503 error shape if the database check fails)
|
|
173
|
+
|
|
174
|
+
curl -i -X POST localhost:8080/api/v1/items \
|
|
175
|
+
-H 'content-type: application/json' -d '{"name":"widget"}'
|
|
176
|
+
# 201 {"id":1,"name":"widget","createdAt":"2026-10-08T12:00:00.000Z"}
|
|
177
|
+
|
|
178
|
+
curl 'localhost:8080/api/v1/items?page=1&perPage=20'
|
|
179
|
+
# 200 {"items":[{"id":1,"name":"widget","createdAt":"..."}],
|
|
180
|
+
# "pagination":{"page":1,"perPage":20,"total":1}}
|
|
181
|
+
|
|
182
|
+
curl localhost:8080/api/v1/items/1
|
|
183
|
+
# 200 {"id":1,"name":"widget","createdAt":"..."}
|
|
184
|
+
|
|
185
|
+
curl -i -X POST localhost:8080/api/v1/items \
|
|
186
|
+
-H 'content-type: application/json' -d '{"name":" "}'
|
|
187
|
+
# 400 {"error":{"code":"VALIDATION_ERROR","message":"Invalid input",
|
|
188
|
+
# "details":[{"field":"name","message":"Must not be empty"}]},"requestId":"..."}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Every response includes an `x-request-id` header; send your own to propagate one.
|
|
192
|
+
It also includes a separate `x-trace-id`: `apiFetch` generates a fresh UUID for each call
|
|
193
|
+
unless explicitly supplied. The backend echoes valid UUIDs or replaces absent/invalid
|
|
194
|
+
values, including on errors. JSON request spans contain both `request_id` and `trace_id`;
|
|
195
|
+
search logs by the response trace header (or `ApiError.traceId`) without logging payloads.
|
|
196
|
+
Neither ID is authentication; error JSON still uses only the existing `requestId` field.
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
curl -i localhost:8080/health -H 'x-trace-id: 5d632031-f1ac-4a8f-8837-b4b2bef63596'
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## 5. Removing the sample
|
|
203
|
+
|
|
204
|
+
Delete:
|
|
205
|
+
|
|
206
|
+
- `database/migrations/*/20261008120000_create_items.sql` (only before it is applied anywhere
|
|
207
|
+
shared; otherwise add a drop migration)
|
|
208
|
+
- `apps/backend/src/models/item.rs`, `controllers/items.rs`, `views/items.rs` and their `mod.rs`
|
|
209
|
+
lines; the `/api/v1/items` routes in `routes.rs`
|
|
210
|
+
- the items tests in `apps/backend/tests/api.rs` (keep the health/ready test; `app()` applies
|
|
211
|
+
the migration, so point it at your own)
|
|
212
|
+
- `apps/frontend/src/api/items.js`, `hooks/useItems.js`, `models/items.js` (+ test),
|
|
213
|
+
`pages/ItemsPage.jsx`, `components/ItemList.jsx` (+ test); update `App.jsx`
|
|
214
|
+
- the items sections of `docs/api/README.md` and `docs/api/openapi.yaml`
|