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.
Files changed (122) hide show
  1. package/README.md +453 -2
  2. package/assets/baseline-rules.yml +60 -0
  3. package/assets/caller-workflow.yml +26 -0
  4. package/assets/review-action.yml +27 -0
  5. package/assets/review-config.json +4 -0
  6. package/assets/review-workflow.yml +443 -0
  7. package/assets/review.mjs +721 -0
  8. package/bin/techbro.js +9 -0
  9. package/package.json +23 -4
  10. package/src/cli.js +690 -0
  11. package/src/cluster.js +134 -0
  12. package/src/config.js +35 -0
  13. package/src/doctor.js +125 -0
  14. package/src/github.js +152 -0
  15. package/src/init.js +167 -0
  16. package/src/kube-project.js +468 -0
  17. package/src/kube.js +317 -0
  18. package/src/managed.js +63 -0
  19. package/src/project.js +202 -0
  20. package/src/review.js +165 -0
  21. package/src/secret.js +80 -0
  22. package/src/setup.js +91 -0
  23. package/src/team-workflow.js +106 -0
  24. package/src/team.js +145 -0
  25. package/src/templates.js +44 -0
  26. package/src/upgrade.js +94 -0
  27. package/templates/_default/.agents/skills/README.md +36 -0
  28. package/templates/_default/.agents/skills/database-migrations/SKILL.md +72 -0
  29. package/templates/_default/.agents/skills/frontend-feature/SKILL.md +65 -0
  30. package/templates/_default/.agents/skills/release-safe-change/SKILL.md +75 -0
  31. package/templates/_default/.agents/skills/rust-backend-feature/SKILL.md +68 -0
  32. package/templates/_default/.editorconfig +15 -0
  33. package/templates/_default/.env.example +9 -0
  34. package/templates/_default/.github/workflows/ci.yml +60 -0
  35. package/templates/_default/.nvmrc +1 -0
  36. package/templates/_default/Cargo.lock +1834 -0
  37. package/templates/_default/Cargo.toml +3 -0
  38. package/templates/_default/README.md +257 -0
  39. package/templates/_default/TEMPLATE.md +29 -0
  40. package/templates/_default/_dockerignore +8 -0
  41. package/templates/_default/_gitignore +8 -0
  42. package/templates/_default/apps/backend/Cargo.toml +22 -0
  43. package/templates/_default/apps/backend/Dockerfile +34 -0
  44. package/templates/_default/apps/backend/README.md +13 -0
  45. package/templates/_default/apps/backend/src/config.rs +111 -0
  46. package/templates/_default/apps/backend/src/controllers/health.rs +36 -0
  47. package/templates/_default/apps/backend/src/controllers/items.rs +79 -0
  48. package/templates/_default/apps/backend/src/controllers/mod.rs +2 -0
  49. package/templates/_default/apps/backend/src/error.rs +115 -0
  50. package/templates/_default/apps/backend/src/lib.rs +7 -0
  51. package/templates/_default/apps/backend/src/main.rs +71 -0
  52. package/templates/_default/apps/backend/src/models/item.rs +94 -0
  53. package/templates/_default/apps/backend/src/models/mod.rs +1 -0
  54. package/templates/_default/apps/backend/src/routes.rs +94 -0
  55. package/templates/_default/apps/backend/src/state.rs +12 -0
  56. package/templates/_default/apps/backend/src/views/items.rs +18 -0
  57. package/templates/_default/apps/backend/src/views/mod.rs +1 -0
  58. package/templates/_default/apps/backend/tests/api.rs +234 -0
  59. package/templates/_default/apps/frontend/.env.example +2 -0
  60. package/templates/_default/apps/frontend/.prettierignore +3 -0
  61. package/templates/_default/apps/frontend/.prettierrc +1 -0
  62. package/templates/_default/apps/frontend/Dockerfile +15 -0
  63. package/templates/_default/apps/frontend/README.md +39 -0
  64. package/templates/_default/apps/frontend/_dockerignore +3 -0
  65. package/templates/_default/apps/frontend/_gitignore +24 -0
  66. package/templates/_default/apps/frontend/components.json +25 -0
  67. package/templates/_default/apps/frontend/eslint.config.js +37 -0
  68. package/templates/_default/apps/frontend/index.html +13 -0
  69. package/templates/_default/apps/frontend/jsconfig.json +7 -0
  70. package/templates/_default/apps/frontend/nginx.conf +30 -0
  71. package/templates/_default/apps/frontend/package-lock.json +4338 -0
  72. package/templates/_default/apps/frontend/package.json +49 -0
  73. package/templates/_default/apps/frontend/public/favicon.svg +1 -0
  74. package/templates/_default/apps/frontend/src/App.jsx +11 -0
  75. package/templates/_default/apps/frontend/src/App.test.jsx +40 -0
  76. package/templates/_default/apps/frontend/src/api/client.js +50 -0
  77. package/templates/_default/apps/frontend/src/api/client.test.js +70 -0
  78. package/templates/_default/apps/frontend/src/api/items.js +26 -0
  79. package/templates/_default/apps/frontend/src/api/system.js +11 -0
  80. package/templates/_default/apps/frontend/src/components/ItemList.jsx +20 -0
  81. package/templates/_default/apps/frontend/src/components/ItemList.test.jsx +18 -0
  82. package/templates/_default/apps/frontend/src/components/Layout.jsx +35 -0
  83. package/templates/_default/apps/frontend/src/components/StatusBadge.jsx +22 -0
  84. package/templates/_default/apps/frontend/src/components/StepList.jsx +19 -0
  85. package/templates/_default/apps/frontend/src/components/ui/button.jsx +52 -0
  86. package/templates/_default/apps/frontend/src/components/ui/card.jsx +83 -0
  87. package/templates/_default/apps/frontend/src/components/ui/input.jsx +19 -0
  88. package/templates/_default/apps/frontend/src/hooks/useBackendStatus.js +33 -0
  89. package/templates/_default/apps/frontend/src/hooks/useItems.js +47 -0
  90. package/templates/_default/apps/frontend/src/index.css +130 -0
  91. package/templates/_default/apps/frontend/src/lib/config.js +2 -0
  92. package/templates/_default/apps/frontend/src/lib/utils.js +6 -0
  93. package/templates/_default/apps/frontend/src/main.jsx +11 -0
  94. package/templates/_default/apps/frontend/src/models/items.js +24 -0
  95. package/templates/_default/apps/frontend/src/models/items.test.js +24 -0
  96. package/templates/_default/apps/frontend/src/pages/ItemsPage.jsx +41 -0
  97. package/templates/_default/apps/frontend/src/pages/WelcomePage.jsx +156 -0
  98. package/templates/_default/apps/frontend/src/pages/WelcomePage.test.jsx +55 -0
  99. package/templates/_default/apps/frontend/src/routes.jsx +17 -0
  100. package/templates/_default/apps/frontend/src/shadcn.css +655 -0
  101. package/templates/_default/apps/frontend/src/test/setup.js +1 -0
  102. package/templates/_default/apps/frontend/vite.config.js +25 -0
  103. package/templates/_default/database/Dockerfile +9 -0
  104. package/templates/_default/database/README.md +15 -0
  105. package/templates/_default/database/migrations/postgres/20261008120000_create_items.sql +5 -0
  106. package/templates/_default/database/migrations/sqlite/20261008120000_create_items.sql +5 -0
  107. package/templates/_default/docker-compose.prod.yml +53 -0
  108. package/templates/_default/docker-compose.yml +69 -0
  109. package/templates/_default/docs/api/README.md +41 -0
  110. package/templates/_default/docs/api/openapi.yaml +152 -0
  111. package/templates/_default/docs/decisions/0001-adopt-engineering-convention.md +13 -0
  112. package/templates/_default/docs/decisions/README.md +6 -0
  113. package/templates/_default/docs/deployment.md +46 -0
  114. package/templates/_default/docs/docker.md +63 -0
  115. package/templates/_default/docs/getting-started.md +214 -0
  116. package/templates/_default/package.json +15 -0
  117. package/templates/_default/rust-toolchain.toml +3 -0
  118. package/templates/_default/scripts/check +10 -0
  119. package/templates/_default/scripts/db-migrate +25 -0
  120. package/templates/_default/scripts/install-hooks +5 -0
  121. package/templates/_default/scripts/pre-push +3 -0
  122. package/templates/_default/scripts/setup +26 -0
@@ -0,0 +1,5 @@
1
+ CREATE TABLE items (
2
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3
+ name TEXT NOT NULL,
4
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
5
+ );
@@ -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`