Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Build context is the repo root (see `packages/backend/docker-compose.yml`).
# Keep the layer that copies workspace sources small and deterministic.

# Dependency installs — always reinstalled inside the image.
**/node_modules

# Build outputs — regenerated inside the image.
**/dist
**/.vite

# VCS / tooling.
.git
.gitignore
**/.DS_Store

# Editor / scratch areas.
.idea
.vscode
**/.idea
**/.vscode

# Local env and logs.
**/.env
**/.env.*
!**/.env.example
**/*.log
**/npm-debug.log*
**/pnpm-debug.log*

# Test + CI caches.
**/coverage
**/.nyc_output
**/.eslintcache
**/.turbo
**/.cache

# Docker itself.
**/Dockerfile
**/docker-compose.yml
**/.dockerignore

# Only the backend needs its sources in the image; skip the web package to
# keep the backend image small and cache-friendly.
packages/web/src
packages/web/public
packages/web/vite.config.ts
packages/web/tsconfig.json
packages/web/scripts

# Local uploads directory — bind-mounted at runtime instead.
packages/backend/uploads

# Yahoo quote disk cache — re-populated at runtime.
packages/backend/.yahoo-cache.json
43 changes: 43 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: CI

on:
push:
branches: [main]
pull_request:

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18-alpine
env:
POSTGRES_DB: fire
POSTGRES_USER: fire
POSTGRES_PASSWORD: fire
# Tests hard-code `localhost:5433` (see `test/global-setup.ts`), so
# expose the container port on the runner host at 5433.
ports:
- 5433:5432
options: >-
--health-cmd="pg_isready -U fire -d fire"
--health-interval=5s
--health-timeout=3s
--health-retries=10
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version-file: .tool-versions
cache: pnpm
- run: pnpm install --frozen-lockfile
# Run the cheap checks first so a failure short-circuits the job before
# the (slower) backend test suite runs against the Postgres service.
- run: pnpm typecheck
- run: pnpm exec eslint .
- run: pnpm --filter backend test
1 change: 1 addition & 0 deletions .lintstagedrc.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"*.ts": "eslint --fix",
"*.tsx": "eslint --fix",
"*.sql": "prettier --write"
}
1 change: 1 addition & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
nodejs 24.13.0
26 changes: 12 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,50 +35,48 @@ A personal net-worth tracker. Records assets, investments, and month-by-month ca
## Dev setup

### Prerequisites
- Node ≥ 22
- Node ≥ 24 (see `.tool-versions`)
- `pnpm` (repo pins a version via `packageManager`)
- Docker (for Postgres + Jaeger)
- Docker (runs Postgres, Jaeger, and the backend)

### First-time bootstrap

```bash
pnpm install
```

Start the dependent services (Postgres and Jaeger):
Start the backing services (Postgres, Jaeger) and the backend itself:

```bash
cd packages/backend
docker compose up -d
```

Create a `.env` in `packages/backend`:
This builds and runs the backend in a container against the containerised Postgres and Jaeger. The backend's source tree is mounted from the host, so `vite-node` hot-reloads on file changes without rebuilding the image.

```
DATABASE_URL=postgres://fire:fire@localhost:5433/fire
UPLOADS_DIR=./uploads
```

Apply migrations:
Apply migrations (against the containerised Postgres, exposed on `localhost:5433`):

```bash
pnpm --filter backend db:migrate
DATABASE_URL=postgres://fire:fire@localhost:5433/fire \
pnpm --filter backend db:migrate
```

### Running

Backend (`:4000`, GraphQL at `/graphql`):
The backend (`:4000`, GraphQL at `/graphql`) runs inside docker compose. Tail its logs with:

```bash
pnpm --filter backend dev
docker compose -f packages/backend/docker-compose.yml logs -f backend
```

Web app (`:4001`):
Web app (`:4001`) runs on the host:

```bash
pnpm --filter web dev
```

> Running the backend on the host instead of in docker is still possible (`pnpm --filter backend dev`) — just stop the container first and point `DATABASE_URL` at `localhost:5433`. The `OTEL_*` defaults in `src/env.ts` already target the containerised Jaeger.

With both running, regenerate the frontend's typed GraphQL from the backend's live schema whenever resolvers change:

```bash
Expand Down
12 changes: 12 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ export default tseslint.config(
},
js.configs.recommended,
...tseslint.configs.recommended,
{
// `.mjs` entry points (notably `packages/backend/otel.mjs`) run as Node
// scripts, not through the TS compiler, so their globals aren't in the
// default set.
files: ["**/*.mjs"],
languageOptions: {
globals: {
process: "readonly",
console: "readonly",
},
},
},
{
plugins: { "simple-import-sort": simpleImportSort },
rules: {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"private": true,
"packageManager": "pnpm@10.31.0",
"engines": {
"node": ">=22"
"node": ">=24"
},
"scripts": {
"typecheck": "pnpm -r typecheck"
Expand Down
32 changes: 32 additions & 0 deletions packages/backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Production-oriented image for the backend. In dev, `docker-compose.yml`
# mounts the host source tree over `/app/packages/backend` and overrides
# the default command to `pnpm dev`, so the same image serves both modes.
#
# Build context is the repo root (see `docker-compose.yml`) so we can copy
# workspace manifests in the right layout for `pnpm install`.
FROM node:24-alpine

RUN apk add --no-cache libc6-compat \
&& corepack enable \
&& corepack prepare pnpm@10.31.0 --activate

WORKDIR /app

# Copy manifests first so the dependency install layer stays cached across
# source-only changes.
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/backend/package.json packages/backend/
COPY packages/web/package.json packages/web/

RUN pnpm install --frozen-lockfile

# Copy the rest of the backend sources so the image is self-contained in
# prod. In dev, docker-compose mounts the host tree over this — that mount
# takes priority, so these layers are effectively ignored at runtime.
COPY packages/backend ./packages/backend

WORKDIR /app/packages/backend

EXPOSE 4000

CMD ["pnpm", "start"]
31 changes: 31 additions & 0 deletions packages/backend/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,36 @@ services:
- "4318:4318" # OTLP/HTTP receiver (matches backend's OTEL_EXPORTER_OTLP_ENDPOINT default)
- "4317:4317" # OTLP/gRPC receiver

backend:
build:
# Build context is the repo root so the Dockerfile can copy workspace
# manifests (pnpm-workspace.yaml, root package.json, per-package package.json).
context: ../..
dockerfile: packages/backend/Dockerfile
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
jaeger:
condition: service_started
environment:
NODE_ENV: development
DATABASE_URL: postgres://fire:fire@postgres:5432/fire
UPLOADS_DIR: /app/packages/backend/uploads
# Traces go to the Jaeger OTLP/HTTP receiver on the docker network.
OTEL_ENABLED: "true"
OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4318
command: pnpm dev
ports:
- "4000:4000"
volumes:
# Mount the backend package over the image so `vite-node` picks up host
# edits without rebuilding. Anonymous volumes on the two `node_modules`
# paths preserve the image-baked installs (built for linux/musl) from
# being shadowed by the host's (which are built for the host's arch).
- ./:/app/packages/backend
- /app/node_modules
- /app/packages/backend/node_modules

volumes:
postgres-data:
2 changes: 2 additions & 0 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"grats": "grats --tsconfig tsconfig.grats.json",
"codegen": "run-p grats db:generate-schema && gql.tada generate-output",
"dev": "NODE_ENV=development NODE_OPTIONS=\"--import=./otel.mjs\" vite",
"start": "NODE_ENV=production NODE_OPTIONS=\"--import=./otel.mjs\" vite-node src/index.ts",
"typecheck": "pnpm grats && tsc --noEmit",
"test": "vitest",
"db:generate": "drizzle-kit generate",
Expand All @@ -26,6 +27,7 @@
"@opentelemetry/resources": "^2.7.0",
"@opentelemetry/sdk-node": "^0.215.0",
"@opentelemetry/semantic-conventions": "^1.40.0",
"dataloader": "^2.2.3",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.45.2",
"fastify": "^5.8.5",
Expand Down
19 changes: 14 additions & 5 deletions packages/backend/src/__generated__/schema.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 7 additions & 5 deletions packages/backend/src/graphql/investments/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { db } from "@/db";
import { Investments } from "@/db/schema/investments";
import { readOrRefresh } from "@/tasks/yahoo";

import type { Context } from "../context";
import type { DateTime } from "../date-time";
import { assertCurrencyCode, Money } from "../money";
import {
Expand Down Expand Up @@ -120,8 +121,8 @@ export class Investment {
}

/** Most recent split-adjusted unit price known for this investment. `null` if no prices have been recorded yet. @gqlField */
async unitPriceCached(): Promise<Money | null> {
const s = await loadInvestmentStats(this.id);
async unitPriceCached(ctx: Context): Promise<Money | null> {
const s = await loadInvestmentStats(ctx, this.id);
if (s.priceLatest === null) return null;
return Money.fromMinorDenomination(s.priceLatest, s.currency);
}
Expand All @@ -138,8 +139,8 @@ export class Investment {
}

/** Holdings, cost basis, and gain/loss aggregated across every wrapper. @gqlField */
async position(): Promise<InvestmentPosition> {
const s = await loadInvestmentStats(this.id);
async position(ctx: Context): Promise<InvestmentPosition> {
const s = await loadInvestmentStats(ctx, this.id);
return new InvestmentPosition(s);
}

Expand Down Expand Up @@ -259,6 +260,7 @@ function parseSortInput(sort: InvestmentSort | null | undefined): {
* @gqlAnnotate semanticNonNull
*/
export async function investments(
ctx: Context,
first?: Int | null,
after?: ID | null,
sort?: InvestmentSort | null,
Expand All @@ -283,7 +285,7 @@ export async function investments(
}))
: await Promise.all(
rows.map(async (row) => {
const s = await loadInvestmentStats(row.id);
const s = await loadInvestmentStats(ctx, row.id);
const totalValue =
s.priceLatest === null ? null : s.unitsHeld * s.priceLatest;
const totalGain =
Expand Down
Loading
Loading