Merge branch 'main' into spec/staging-environments

This commit is contained in:
Ullrich Schäfer 2026-04-15 01:01:05 +02:00 committed by GitHub
commit 448fa8b9b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 682 additions and 6 deletions

View file

@ -38,6 +38,13 @@ jobs:
run: pnpm audit --audit-level=high run: pnpm audit --audit-level=high
continue-on-error: true continue-on-error: true
dockerfile-check:
name: Dockerfile Package Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- run: bash scripts/check-dockerfiles.sh
typecheck: typecheck:
name: Typecheck name: Typecheck
runs-on: ubuntu-latest runs-on: ubuntu-latest

View file

@ -14,6 +14,7 @@ COPY packages/i18n/package.json packages/i18n/
COPY packages/api/package.json packages/api/ COPY packages/api/package.json packages/api/
COPY packages/map-core/package.json packages/map-core/ COPY packages/map-core/package.json packages/map-core/
COPY packages/db/package.json packages/db/ COPY packages/db/package.json packages/db/
COPY packages/jobs/package.json packages/jobs/
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
FROM base AS build FROM base AS build

View file

@ -20,6 +20,7 @@
"@simplewebauthn/server": "^13.3.0", "@simplewebauthn/server": "^13.3.0",
"@trails-cool/api": "workspace:*", "@trails-cool/api": "workspace:*",
"@trails-cool/db": "workspace:*", "@trails-cool/db": "workspace:*",
"@trails-cool/jobs": "workspace:*",
"@trails-cool/gpx": "workspace:*", "@trails-cool/gpx": "workspace:*",
"@trails-cool/i18n": "workspace:*", "@trails-cool/i18n": "workspace:*",
"@trails-cool/map": "workspace:*", "@trails-cool/map": "workspace:*",

View file

@ -4,6 +4,7 @@ import { createReadStream, statSync } from "node:fs";
import { join, extname, resolve } from "node:path"; import { join, extname, resolve } from "node:path";
import { logger } from "./app/lib/logger.server.ts"; import { logger } from "./app/lib/logger.server.ts";
import { httpRequestDuration, registry } from "./app/lib/metrics.server.ts"; import { httpRequestDuration, registry } from "./app/lib/metrics.server.ts";
import { createBoss, startWorker } from "@trails-cool/jobs";
import postgres from "postgres"; import postgres from "postgres";
const port = Number(process.env.PORT ?? 3000); const port = Number(process.env.PORT ?? 3000);
@ -98,4 +99,9 @@ server.listen(port, async () => {
// Seed first-party OAuth2 clients // Seed first-party OAuth2 clients
const { seedOAuthClient } = await import("./app/lib/oauth.server.ts"); const { seedOAuthClient } = await import("./app/lib/oauth.server.ts");
await seedOAuthClient("trails-cool-mobile", "trailscool://auth/callback", true); await seedOAuthClient("trails-cool-mobile", "trailscool://auth/callback", true);
// Start background job worker (no jobs yet — placeholder for Komoot import and federation)
const boss = createBoss(process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails");
await startWorker(boss, []);
logger.info("Background job worker started");
}); });

View file

@ -11,7 +11,10 @@ COPY packages/ui/package.json packages/ui/
COPY packages/map/package.json packages/map/ COPY packages/map/package.json packages/map/
COPY packages/gpx/package.json packages/gpx/ COPY packages/gpx/package.json packages/gpx/
COPY packages/i18n/package.json packages/i18n/ COPY packages/i18n/package.json packages/i18n/
COPY packages/api/package.json packages/api/
COPY packages/map-core/package.json packages/map-core/
COPY packages/db/package.json packages/db/ COPY packages/db/package.json packages/db/
COPY packages/jobs/package.json packages/jobs/
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
FROM base AS build FROM base AS build
@ -30,6 +33,7 @@ COPY --from=deps /app/apps/planner/node_modules ./apps/planner/node_modules
COPY --from=build /app/apps/planner/build ./apps/planner/build COPY --from=build /app/apps/planner/build ./apps/planner/build
COPY --from=build /app/apps/planner/server.ts ./apps/planner/server.ts COPY --from=build /app/apps/planner/server.ts ./apps/planner/server.ts
COPY --from=build /app/apps/planner/app/lib ./apps/planner/app/lib COPY --from=build /app/apps/planner/app/lib ./apps/planner/app/lib
COPY --from=build /app/apps/planner/app/jobs ./apps/planner/app/jobs
COPY --from=build /app/apps/planner/package.json ./apps/planner/package.json COPY --from=build /app/apps/planner/package.json ./apps/planner/package.json
COPY --from=build /app/packages ./packages COPY --from=build /app/packages ./packages

View file

@ -0,0 +1,30 @@
import { describe, it, expect, vi } from "vitest";
vi.mock("../lib/sessions.ts", () => ({
expireSessions: vi.fn().mockResolvedValue(5),
}));
vi.mock("../lib/logger.server.ts", () => ({
logger: { info: vi.fn() },
}));
describe("expireSessionsJob", () => {
it("calls expireSessions with 7-day TTL and returns count", async () => {
const { expireSessionsJob } = await import("./expire-sessions.ts");
const { expireSessions } = await import("../lib/sessions.ts");
const result = await expireSessionsJob.handler({ id: "test", name: "expire-sessions", data: {} } as never);
expect(expireSessions).toHaveBeenCalledWith(7);
expect(result).toEqual({ expiredCount: 5 });
});
it("has correct job configuration", async () => {
const { expireSessionsJob } = await import("./expire-sessions.ts");
expect(expireSessionsJob.name).toBe("expire-sessions");
expect(expireSessionsJob.cron).toBe("0 * * * *");
expect(expireSessionsJob.retryLimit).toBe(2);
expect(expireSessionsJob.expireInSeconds).toBe(60);
});
});

View file

@ -0,0 +1,15 @@
import type { JobDefinition } from "@trails-cool/jobs";
import { expireSessions } from "../lib/sessions.ts";
import { logger } from "../lib/logger.server.ts";
export const expireSessionsJob: JobDefinition = {
name: "expire-sessions",
cron: "0 * * * *",
retryLimit: 2,
expireInSeconds: 60,
async handler() {
const count = await expireSessions(7);
logger.info({ count }, "expired stale planner sessions");
return { expiredCount: count };
},
};

View file

@ -22,6 +22,7 @@
"@sentry/node": "catalog:", "@sentry/node": "catalog:",
"@sentry/react": "catalog:", "@sentry/react": "catalog:",
"@trails-cool/db": "workspace:*", "@trails-cool/db": "workspace:*",
"@trails-cool/jobs": "workspace:*",
"@trails-cool/gpx": "workspace:*", "@trails-cool/gpx": "workspace:*",
"@trails-cool/i18n": "workspace:*", "@trails-cool/i18n": "workspace:*",
"@trails-cool/map": "workspace:*", "@trails-cool/map": "workspace:*",

View file

@ -6,6 +6,8 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht
import { createReadStream, statSync } from "node:fs"; import { createReadStream, statSync } from "node:fs";
import { join, extname, resolve } from "node:path"; import { join, extname, resolve } from "node:path";
import { setupYjsWebSocket } from "./app/lib/yjs-server.ts"; import { setupYjsWebSocket } from "./app/lib/yjs-server.ts";
import { createBoss, startWorker } from "@trails-cool/jobs";
import { expireSessionsJob } from "./app/jobs/expire-sessions.ts";
import postgres from "postgres"; import postgres from "postgres";
const sentryEnvironment = process.env.CI ? "ci" : (process.env.NODE_ENV ?? "development"); const sentryEnvironment = process.env.CI ? "ci" : (process.env.NODE_ENV ?? "development");
@ -113,7 +115,11 @@ const server = createServer((req, res) => {
setupYjsWebSocket(server); setupYjsWebSocket(server);
server.listen(port, () => { server.listen(port, async () => {
logger.info({ port }, "Planner server listening"); logger.info({ port }, "Planner server listening");
logger.info({ port, path: "/sync/:sessionId" }, "Yjs WebSocket available"); logger.info({ port, path: "/sync/:sessionId" }, "Yjs WebSocket available");
const boss = createBoss(process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails");
await startWorker(boss, [expireSessionsJob]);
logger.info("Background job worker started");
}); });

View file

@ -479,6 +479,69 @@
"legendFormat": "in-flight" "legendFormat": "in-flight"
} }
] ]
},
{
"title": "Job Queue \u2014 Status",
"description": "Background job counts by state from pg-boss. Queries the pgboss.job table via the PostgreSQL datasource.",
"type": "table",
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 72
},
"targets": [
{
"datasource": {
"type": "postgres",
"uid": "postgres"
},
"rawSql": "SELECT state, count(*) AS count FROM pgboss.job GROUP BY state ORDER BY count DESC",
"format": "table"
}
]
},
{
"title": "Job Queue \u2014 Failed Jobs (last 24h)",
"description": "Recently failed background jobs with error details.",
"type": "table",
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 72
},
"targets": [
{
"datasource": {
"type": "postgres",
"uid": "postgres"
},
"rawSql": "SELECT name, state, createdon, completedon, output::text AS error FROM pgboss.job WHERE state = 'failed' AND completedon > now() - interval '24 hours' ORDER BY completedon DESC LIMIT 20",
"format": "table"
}
]
},
{
"title": "Job Queue \u2014 Completed/hour",
"description": "Background jobs completed per hour over the last 24 hours.",
"type": "timeseries",
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 80
},
"targets": [
{
"datasource": {
"type": "postgres",
"uid": "postgres"
},
"rawSql": "SELECT date_trunc('hour', completedon) AS time, name, count(*) AS completed FROM pgboss.job WHERE state = 'completed' AND completedon > now() - interval '24 hours' GROUP BY 1, 2 ORDER BY 1",
"format": "time_series"
}
]
} }
] ]
} }

View file

@ -152,6 +152,36 @@ groups:
annotations: annotations:
summary: "Crash signature detected in application logs" summary: "Crash signature detected in application logs"
- uid: background-job-failures
title: Background job failures
condition: C
noDataState: OK
data:
- refId: A
relativeTimeRange: { from: 3600, to: 0 }
datasourceUid: postgres
model:
rawSql: "SELECT count(*) AS failed FROM pgboss.job WHERE state = 'failed' AND completedon > now() - interval '1 hour'"
format: table
- refId: B
datasourceUid: __expr__
model:
type: reduce
expression: A
reducer: last
- refId: C
datasourceUid: __expr__
model:
type: threshold
expression: B
conditions:
- evaluator: { params: [0], type: gt }
operator: { type: and }
reducer: { type: last }
for: 0s
annotations:
summary: "Background jobs have failed in the last hour — check Grafana Service Health dashboard"
- uid: caddy-502-rate - uid: caddy-502-rate
title: Caddy 502 errors detected title: Caddy 502 errors detected
condition: B condition: B

View file

@ -31,5 +31,10 @@ BEGIN
GRANT SELECT ON ALL TABLES IN SCHEMA journal TO grafana_reader; GRANT SELECT ON ALL TABLES IN SCHEMA journal TO grafana_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA journal GRANT SELECT ON TABLES TO grafana_reader; ALTER DEFAULT PRIVILEGES IN SCHEMA journal GRANT SELECT ON TABLES TO grafana_reader;
END IF; END IF;
IF EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'pgboss') THEN
GRANT USAGE ON SCHEMA pgboss TO grafana_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA pgboss TO grafana_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA pgboss GRANT SELECT ON TABLES TO grafana_reader;
END IF;
END END
$$; $$;

View file

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-13

View file

@ -0,0 +1,50 @@
## Context
Background work in trails.cool currently has no execution framework. The planner has an `expireSessions` function that is never called. The Komoot import feature (#223) implements ad-hoc background processing with no retries or failure visibility. Both apps already connect to PostgreSQL, making a Postgres-backed job queue the natural choice — no Redis or additional infrastructure needed.
pg-boss is a mature Node.js job queue that stores jobs as PostgreSQL rows, supports cron scheduling, automatic retries with exponential backoff, and exposes job state through queryable tables. Since Grafana already has a PostgreSQL datasource, observability comes for free.
## Goals / Non-Goals
**Goals:**
- Provide a durable job execution framework for both apps using the existing PostgreSQL database
- Wire up planner session expiry as the first scheduled job
- Make job queue health visible in Grafana
- Establish patterns for adding future jobs (Komoot import, ActivityPub federation, email sending)
**Non-Goals:**
- Refactoring the Komoot import to use pg-boss (separate change, after #223 merges)
- Multi-worker scaling or horizontal partitioning — single worker per app is sufficient at current scale
- Custom job queue UI — Grafana dashboards are sufficient
## Decisions
### 1. pg-boss over Graphile Worker
**Choice**: pg-boss
**Rationale**: Built-in cron scheduling, richer job metadata (completion timestamps, retry counts, output storage), and dead letter queue. Graphile Worker is lighter but lacks cron and has less queryable state for Grafana dashboards.
**Alternative considered**: Graphile Worker — faster raw throughput, but we need scheduling and observability more than throughput.
### 2. Shared package `@trails-cool/jobs`
**Choice**: New shared package at `packages/jobs/` that wraps pg-boss initialization and exports job registration helpers.
**Rationale**: Both planner and journal need background jobs. A shared package avoids duplicating pg-boss setup, ensures consistent configuration (retry policies, monitoring interval), and provides a single place to register job types.
**Alternative considered**: Inline setup in each app's `server.ts` — simpler initially but leads to divergent configuration.
### 3. Worker lifecycle in server process
**Choice**: Start the pg-boss worker inside each app's `server.ts` after the HTTP server is listening.
**Rationale**: Keeps deployment simple — no separate worker process or container. pg-boss workers are lightweight (polling loop). The planner and journal already run as long-lived Node.js processes.
**Alternative considered**: Separate worker container — better isolation but doubles container count and adds deployment complexity for minimal benefit at current scale.
### 4. Schema isolation
**Choice**: Let pg-boss use its default `pgboss` schema in the `trails` database.
**Rationale**: pg-boss auto-creates and migrates its own schema. Keeping it separate from `planner` and `journal` schemas avoids any collision. Grant `grafana_reader` SELECT access for dashboards.
### 5. Session expiry job
**Choice**: Cron job running hourly that calls the existing `expireSessions(7)` function.
**Rationale**: 7-day TTL with hourly cleanup is generous enough that no active session gets reaped, and frequent enough that stale sessions don't accumulate. The existing function already handles cleanup correctly (deletes DB rows + removes Yjs docs from memory).
## Risks / Trade-offs
- **pg-boss schema migrations on upgrade**: pg-boss manages its own schema and runs migrations on startup. → Mitigation: Pin pg-boss version, test upgrades in dev before deploying.
- **Worker blocks event loop**: A misbehaving job handler could block the HTTP server. → Mitigation: Jobs should be I/O-bound (DB queries), not CPU-bound. Add a timeout to job handlers.
- **Single worker per app**: No parallelism for heavy workloads. → Mitigation: Sufficient for current scale. Can add concurrency options per queue or separate workers later if needed.
- **Database load from polling**: pg-boss polls for jobs. → Mitigation: Default polling interval (2s) is fine for our scale. The queries are indexed and lightweight.

View file

@ -0,0 +1,29 @@
## Why
Background work (session expiry, Komoot activity imports) currently has no durable execution mechanism. Session cleanup exists as dead code (`expireSessions`) that is never called, and the Komoot import in #223 rolls its own ad-hoc background processing without retries or failure handling. Adding pg-boss gives us a PostgreSQL-backed job queue with scheduling, retries, and observability through our existing Grafana stack — no new infrastructure required.
## What Changes
- Add `pg-boss` as a dependency to both the Planner and Journal apps
- Create a shared background worker setup in `@trails-cool/db` (or a new `@trails-cool/jobs` package) that initializes pg-boss with the existing `DATABASE_URL`
- Wire up **planner session expiry** as the first recurring job (hourly cron, 7-day TTL)
- Wire up **Komoot import processing** as a durable job with retries (replaces ad-hoc background processing in #223)
- Grant `grafana_reader` SELECT on the `pgboss` schema for dashboard visibility
- Add a Grafana dashboard panel for job queue health (queue depth, failed jobs, processing duration)
## Capabilities
### New Capabilities
- `background-jobs`: pg-boss job queue setup, worker lifecycle, job registration, cron scheduling, retry policies, and Grafana observability
### Modified Capabilities
- `planner-session`: Session expiry is now handled by a scheduled pg-boss job instead of being uncalled dead code
- `infrastructure`: PostgreSQL init script grants `grafana_reader` access to the `pgboss` schema; Grafana gets a job queue health panel
## Impact
- **Dependencies**: Adds `pg-boss` npm package
- **Database**: pg-boss auto-creates its schema (`pgboss`) in PostgreSQL on first run
- **Apps**: Both planner and journal server entry points start a pg-boss worker
- **Monitoring**: New Grafana panel on Service Health dashboard for job queue metrics
- **Komoot import (#223)**: Will be refactored to use pg-boss instead of ad-hoc background processing

View file

@ -0,0 +1,41 @@
## ADDED Requirements
### Requirement: Job queue initialization
The `@trails-cool/jobs` package SHALL initialize a pg-boss instance using the app's `DATABASE_URL` and export a function to start the worker.
#### Scenario: Worker starts with server
- **WHEN** the planner or journal server starts
- **THEN** pg-boss connects to PostgreSQL, creates/migrates the `pgboss` schema if needed, and begins polling for jobs
#### Scenario: Worker stops on shutdown
- **WHEN** the server process receives SIGTERM
- **THEN** pg-boss completes any in-progress jobs and stops gracefully before the process exits
### Requirement: Cron job scheduling
The system SHALL support registering recurring jobs with cron expressions.
#### Scenario: Register a cron job
- **WHEN** a job is registered with a cron expression (e.g., `0 * * * *` for hourly)
- **THEN** pg-boss creates a schedule that enqueues the job at the specified interval
#### Scenario: Cron job survives restart
- **WHEN** the server process restarts
- **THEN** existing cron schedules persist and continue firing without re-registration conflicts
### Requirement: Job retry policy
Jobs SHALL support configurable retry policies with exponential backoff.
#### Scenario: Transient failure retry
- **WHEN** a job handler throws an error
- **THEN** pg-boss retries the job up to the configured retry limit with exponential backoff
#### Scenario: Permanent failure
- **WHEN** a job exhausts all retries
- **THEN** the job moves to the `failed` state and remains queryable for debugging
### Requirement: Job handler timeout
Each job handler SHALL have a configurable execution timeout.
#### Scenario: Job exceeds timeout
- **WHEN** a job handler does not complete within its timeout
- **THEN** pg-boss marks the job as failed with a timeout error

View file

@ -0,0 +1,16 @@
## MODIFIED Requirements
### Requirement: Grafana database access
The `grafana_reader` PostgreSQL role SHALL have SELECT access to the `pgboss` schema for job queue observability.
#### Scenario: Grant access on deploy
- **WHEN** the infrastructure deploy runs
- **THEN** `grafana_reader` is granted `USAGE` on the `pgboss` schema and `SELECT` on all tables in it
### Requirement: Monitoring stack
The Grafana Service Health dashboard SHALL include a job queue health panel.
#### Scenario: Job queue panel displays metrics
- **WHEN** a user views the Service Health dashboard
- **THEN** they see a panel showing job queue depth, completed jobs per hour, and failed jobs
- **AND** failed jobs are highlighted for investigation

View file

@ -0,0 +1,18 @@
## MODIFIED Requirements
### Requirement: Session expiry
Open sessions with no activity for 7 days SHALL be automatically deleted by a scheduled background job.
#### Scenario: Stale session cleanup
- **WHEN** the hourly `expire-sessions` cron job runs
- **THEN** all sessions with `last_activity` older than 7 days are deleted from the database
- **AND** their Yjs documents are removed from memory
#### Scenario: Active session preserved
- **WHEN** the `expire-sessions` job runs
- **THEN** sessions with `last_activity` within the last 7 days are NOT deleted
#### Scenario: Cleanup is observable
- **WHEN** the `expire-sessions` job completes
- **THEN** the job output includes the count of expired sessions
- **AND** the result is visible in the Grafana job queue dashboard

View file

@ -0,0 +1,37 @@
## 1. Package Setup
- [x] 1.1 Create `packages/jobs/` package with `package.json`, `tsconfig.json`, and `pg-boss` dependency
- [x] 1.2 Add `@trails-cool/jobs` to planner and journal app dependencies in `pnpm-workspace.yaml`
- [x] 1.3 Run `pnpm install` and verify workspace resolution
## 2. Core Job Queue Module
- [x] 2.1 Create `packages/jobs/src/boss.ts` — initialize pg-boss with `DATABASE_URL`, export `createBoss()` factory
- [x] 2.2 Create `packages/jobs/src/worker.ts` — export `startWorker(boss, jobs)` that registers job handlers and starts processing
- [x] 2.3 Create `packages/jobs/src/types.ts` — export `JobDefinition` type (name, handler, cron?, retryLimit?, expireInSeconds?)
- [x] 2.4 Add graceful shutdown: listen for SIGTERM, call `boss.stop()` to complete in-progress jobs before exit
- [x] 2.5 Export public API from `packages/jobs/src/index.ts`
## 3. Planner Session Expiry Job
- [x] 3.1 Create `apps/planner/app/jobs/expire-sessions.ts` — job handler that calls `expireSessions(7)` and returns the count
- [x] 3.2 Register the job in planner's `server.ts` with cron `0 * * * *` (hourly), retryLimit 2, expireInSeconds 60
- [ ] 3.3 Verify job appears in `pgboss.schedule` table after planner starts (manual verification after dev stack is running)
- [x] 3.4 Write a test for the expire-sessions handler
## 4. Journal Worker Setup
- [x] 4.1 Add pg-boss worker startup to journal's `server.ts` (no jobs yet — placeholder for Komoot import and future federation jobs)
- [x] 4.2 Add graceful shutdown handling (handled by startWorker via SIGTERM/SIGINT listeners)
## 5. Infrastructure & Observability
- [x] 5.1 Add `GRANT USAGE ON SCHEMA pgboss TO grafana_reader` and `GRANT SELECT ON ALL TABLES IN SCHEMA pgboss TO grafana_reader` to `infrastructure/postgres/init-grafana-user.sql`
- [x] 5.2 Add a "Job Queue Health" panel to the Service Health Grafana dashboard — queue depth (`SELECT state, count(*) FROM pgboss.job GROUP BY state`), failed jobs, and completed jobs/hour
- [x] 5.3 Add a Grafana alert for failed background jobs (`SELECT count(*) FROM pgboss.job WHERE state = 'failed' AND completedon > now() - interval '1 hour'`)
## 6. Testing & Verification
- [x] 6.1 Run `pnpm typecheck` — all packages pass
- [x] 6.2 Run `pnpm test` — new and existing tests pass (63 planner tests, including 2 new expire-sessions tests)
- [ ] 6.3 Start dev stack with `pnpm dev:full`, verify pg-boss tables are created and expire-sessions schedule is registered (manual verification)

View file

@ -0,0 +1,21 @@
{
"name": "@trails-cool/jobs",
"version": "0.0.1",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"test": "vitest run",
"lint": "eslint .",
"typecheck": "tsc"
},
"dependencies": {
"pg-boss": "^10.3.1"
},
"devDependencies": {
"@types/node": "catalog:"
}
}

View file

@ -0,0 +1,5 @@
import PgBoss from "pg-boss";
export function createBoss(connectionString: string): PgBoss {
return new PgBoss({ connectionString });
}

View file

@ -0,0 +1,3 @@
export { createBoss } from "./boss.ts";
export { startWorker } from "./worker.ts";
export type { JobDefinition } from "./types.ts";

View file

@ -0,0 +1,9 @@
import type PgBoss from "pg-boss";
export interface JobDefinition<T = unknown> {
name: string;
handler: (jobs: PgBoss.Job<T>[]) => Promise<unknown>;
cron?: string;
retryLimit?: number;
expireInSeconds?: number;
}

View file

@ -0,0 +1,90 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { startWorker } from "./worker.ts";
import type { JobDefinition } from "./types.ts";
function createMockBoss() {
return {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
createQueue: vi.fn().mockResolvedValue(undefined),
schedule: vi.fn().mockResolvedValue(undefined),
work: vi.fn().mockResolvedValue("worker-id"),
};
}
beforeEach(() => {
vi.restoreAllMocks();
});
describe("startWorker", () => {
it("starts the boss instance", async () => {
const boss = createMockBoss();
await startWorker(boss as never, []);
expect(boss.start).toHaveBeenCalled();
});
it("creates queues before registering handlers", async () => {
const boss = createMockBoss();
const jobs: JobDefinition[] = [{ name: "test-job", handler: vi.fn() }];
await startWorker(boss as never, jobs);
expect(boss.createQueue).toHaveBeenCalledWith("test-job");
expect(boss.createQueue).toHaveBeenCalledBefore(boss.work);
});
it("registers job handlers", async () => {
const boss = createMockBoss();
const handler = vi.fn();
const jobs: JobDefinition[] = [{ name: "test-job", handler }];
await startWorker(boss as never, jobs);
expect(boss.work).toHaveBeenCalledWith("test-job", handler);
});
it("schedules cron jobs with options", async () => {
const boss = createMockBoss();
const handler = vi.fn();
const jobs: JobDefinition[] = [
{
name: "cron-job",
handler,
cron: "0 * * * *",
retryLimit: 3,
expireInSeconds: 120,
},
];
await startWorker(boss as never, jobs);
expect(boss.schedule).toHaveBeenCalledWith("cron-job", "0 * * * *", undefined, {
retryLimit: 3,
expireInSeconds: 120,
});
expect(boss.work).toHaveBeenCalledWith("cron-job", handler);
});
it("does not schedule non-cron jobs", async () => {
const boss = createMockBoss();
const jobs: JobDefinition[] = [{ name: "one-shot", handler: vi.fn() }];
await startWorker(boss as never, jobs);
expect(boss.schedule).not.toHaveBeenCalled();
expect(boss.work).toHaveBeenCalledWith("one-shot", jobs[0]!.handler);
});
it("registers multiple jobs", async () => {
const boss = createMockBoss();
const jobs: JobDefinition[] = [
{ name: "job-a", handler: vi.fn() },
{ name: "job-b", handler: vi.fn(), cron: "*/5 * * * *" },
];
await startWorker(boss as never, jobs);
expect(boss.work).toHaveBeenCalledTimes(2);
expect(boss.schedule).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,30 @@
import type PgBoss from "pg-boss";
import type { JobDefinition } from "./types.ts";
export async function startWorker(
boss: PgBoss,
jobs: JobDefinition[],
): Promise<void> {
await boss.start();
for (const job of jobs) {
await boss.createQueue(job.name);
if (job.cron) {
await boss.schedule(job.name, job.cron, undefined, {
retryLimit: job.retryLimit,
expireInSeconds: job.expireInSeconds,
});
}
await boss.work(job.name, job.handler);
}
const onShutdown = async () => {
await boss.stop({ graceful: true, timeout: 10_000 });
process.exit(0);
};
process.on("SIGTERM", onShutdown);
process.on("SIGINT", onShutdown);
}

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}

View file

@ -0,0 +1 @@
export { default } from "../../vitest.shared.ts";

113
pnpm-lock.yaml generated
View file

@ -121,7 +121,7 @@ importers:
version: 0.31.10 version: 0.31.10
drizzle-orm: drizzle-orm:
specifier: 'catalog:' specifier: 'catalog:'
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(expo-sqlite@55.0.15(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(postgres@3.4.9) version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(expo-sqlite@55.0.15(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(pg@8.20.0)(postgres@3.4.9)
drizzle-postgis: drizzle-postgis:
specifier: 'catalog:' specifier: 'catalog:'
version: 1.1.1 version: 1.1.1
@ -221,6 +221,9 @@ importers:
'@trails-cool/i18n': '@trails-cool/i18n':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/i18n version: link:../../packages/i18n
'@trails-cool/jobs':
specifier: workspace:*
version: link:../../packages/jobs
'@trails-cool/map': '@trails-cool/map':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/map version: link:../../packages/map
@ -232,7 +235,7 @@ importers:
version: link:../../packages/ui version: link:../../packages/ui
drizzle-orm: drizzle-orm:
specifier: 'catalog:' specifier: 'catalog:'
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(expo-sqlite@55.0.15(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(postgres@3.4.9) version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(expo-sqlite@55.0.15(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(pg@8.20.0)(postgres@3.4.9)
isbot: isbot:
specifier: ^5.1.37 specifier: ^5.1.37
version: 5.1.37 version: 5.1.37
@ -430,6 +433,9 @@ importers:
'@trails-cool/i18n': '@trails-cool/i18n':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/i18n version: link:../../packages/i18n
'@trails-cool/jobs':
specifier: workspace:*
version: link:../../packages/jobs
'@trails-cool/map': '@trails-cool/map':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/map version: link:../../packages/map
@ -447,7 +453,7 @@ importers:
version: 6.0.2 version: 6.0.2
drizzle-orm: drizzle-orm:
specifier: 'catalog:' specifier: 'catalog:'
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(expo-sqlite@55.0.15(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(postgres@3.4.9) version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(expo-sqlite@55.0.15(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(pg@8.20.0)(postgres@3.4.9)
isbot: isbot:
specifier: ^5.1.37 specifier: ^5.1.37
version: 5.1.37 version: 5.1.37
@ -529,7 +535,7 @@ importers:
dependencies: dependencies:
drizzle-orm: drizzle-orm:
specifier: 'catalog:' specifier: 'catalog:'
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(expo-sqlite@55.0.15(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(postgres@3.4.9) version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(expo-sqlite@55.0.15(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(pg@8.20.0)(postgres@3.4.9)
postgres: postgres:
specifier: 'catalog:' specifier: 'catalog:'
version: 3.4.9 version: 3.4.9
@ -563,6 +569,16 @@ importers:
specifier: '>=17' specifier: '>=17'
version: 17.0.2(i18next@26.0.4(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@5.9.3) version: 17.0.2(i18next@26.0.4(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@5.9.3)
packages/jobs:
dependencies:
pg-boss:
specifier: ^10.3.1
version: 10.4.2
devDependencies:
'@types/node':
specifier: 'catalog:'
version: 22.19.17
packages/map: packages/map:
dependencies: dependencies:
'@trails-cool/map-core': '@trails-cool/map-core':
@ -4147,6 +4163,10 @@ packages:
crelt@1.0.6: crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
cron-parser@4.9.0:
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
engines: {node: '>=12.0.0'}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -5630,6 +5650,10 @@ packages:
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
lz-string@1.5.0: lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true hasBin: true
@ -5992,10 +6016,25 @@ packages:
pathe@2.0.3: pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pg-boss@10.4.2:
resolution: {integrity: sha512-AttEWOtSzn53av8OnCMWEanwRBvjkZCE1y5nLrZnwvkkMnlZ5XpWDpZ7sKI/BYjvi2OVieMX37arD2ACgJ750w==}
engines: {node: '>=20'}
pg-cloudflare@1.3.0:
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
pg-connection-string@2.12.0:
resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==}
pg-int8@1.0.1: pg-int8@1.0.1:
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
engines: {node: '>=4.0.0'} engines: {node: '>=4.0.0'}
pg-pool@3.13.0:
resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==}
peerDependencies:
pg: '>=8.0'
pg-protocol@1.13.0: pg-protocol@1.13.0:
resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==}
@ -6003,6 +6042,18 @@ packages:
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
engines: {node: '>=4'} engines: {node: '>=4'}
pg@8.20.0:
resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==}
engines: {node: '>= 16.0.0'}
peerDependencies:
pg-native: '>=3.0.1'
peerDependenciesMeta:
pg-native:
optional: true
pgpass@1.0.5:
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@ -6473,6 +6524,10 @@ packages:
resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
serialize-error@8.1.0:
resolution: {integrity: sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==}
engines: {node: '>=10'}
serve-static@1.16.3: serve-static@1.16.3:
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@ -6840,6 +6895,10 @@ packages:
resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==}
engines: {node: '>=4'} engines: {node: '>=4'}
type-fest@0.20.2:
resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
engines: {node: '>=10'}
type-fest@0.21.3: type-fest@0.21.3:
resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -11327,6 +11386,10 @@ snapshots:
crelt@1.0.6: {} crelt@1.0.6: {}
cron-parser@4.9.0:
dependencies:
luxon: 3.7.2
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@ -11461,11 +11524,12 @@ snapshots:
esbuild: 0.25.12 esbuild: 0.25.12
tsx: 4.21.0 tsx: 4.21.0
drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(expo-sqlite@55.0.15(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(postgres@3.4.9): drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(expo-sqlite@55.0.15(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(pg@8.20.0)(postgres@3.4.9):
optionalDependencies: optionalDependencies:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
'@types/pg': 8.15.6 '@types/pg': 8.15.6
expo-sqlite: 55.0.15(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) expo-sqlite: 55.0.15(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)
pg: 8.20.0
postgres: 3.4.9 postgres: 3.4.9
drizzle-postgis@1.1.1: drizzle-postgis@1.1.1:
@ -13054,6 +13118,8 @@ snapshots:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
luxon@3.7.2: {}
lz-string@1.5.0: {} lz-string@1.5.0: {}
magic-string@0.30.21: magic-string@0.30.21:
@ -13482,8 +13548,25 @@ snapshots:
pathe@2.0.3: {} pathe@2.0.3: {}
pg-boss@10.4.2:
dependencies:
cron-parser: 4.9.0
pg: 8.20.0
serialize-error: 8.1.0
transitivePeerDependencies:
- pg-native
pg-cloudflare@1.3.0:
optional: true
pg-connection-string@2.12.0: {}
pg-int8@1.0.1: {} pg-int8@1.0.1: {}
pg-pool@3.13.0(pg@8.20.0):
dependencies:
pg: 8.20.0
pg-protocol@1.13.0: {} pg-protocol@1.13.0: {}
pg-types@2.2.0: pg-types@2.2.0:
@ -13494,6 +13577,20 @@ snapshots:
postgres-date: 1.0.7 postgres-date: 1.0.7
postgres-interval: 1.2.0 postgres-interval: 1.2.0
pg@8.20.0:
dependencies:
pg-connection-string: 2.12.0
pg-pool: 3.13.0(pg@8.20.0)
pg-protocol: 1.13.0
pg-types: 2.2.0
pgpass: 1.0.5
optionalDependencies:
pg-cloudflare: 1.3.0
pgpass@1.0.5:
dependencies:
split2: 4.2.0
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@2.3.2: {} picomatch@2.3.2: {}
@ -14032,6 +14129,10 @@ snapshots:
serialize-error@2.1.0: {} serialize-error@2.1.0: {}
serialize-error@8.1.0:
dependencies:
type-fest: 0.20.2
serve-static@1.16.3: serve-static@1.16.3:
dependencies: dependencies:
encodeurl: 2.0.0 encodeurl: 2.0.0
@ -14380,6 +14481,8 @@ snapshots:
type-detect@4.0.8: {} type-detect@4.0.8: {}
type-fest@0.20.2: {}
type-fest@0.21.3: {} type-fest@0.21.3: {}
type-fest@0.7.1: {} type-fest@0.7.1: {}

43
scripts/check-dockerfiles.sh Executable file
View file

@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Verify all workspace packages are listed in Dockerfiles.
# Run in CI to catch missing COPY lines when new packages are added.
set -euo pipefail
errors=0
for pkg_json in packages/*/package.json; do
pkg_dir=$(dirname "$pkg_json") # e.g. packages/jobs
pkg_name=$(basename "$pkg_dir") # e.g. jobs
for dockerfile in apps/*/Dockerfile; do
app=$(basename "$(dirname "$dockerfile")")
if ! grep -q "COPY ${pkg_dir}/package.json" "$dockerfile"; then
echo "ERROR: $dockerfile is missing COPY for $pkg_dir/package.json"
errors=$((errors + 1))
fi
done
done
# Check that COPY paths from the build stage exist in the source tree.
# These are app-specific paths (app/lib, app/jobs, etc.) that may not exist yet.
for dockerfile in apps/*/Dockerfile; do
app_dir=$(dirname "$dockerfile") # e.g. apps/journal
grep 'COPY --from=build /app/'"$app_dir"'/' "$dockerfile" | while read -r line; do
# Extract the source path from: COPY --from=build /app/apps/journal/app/jobs ./apps/journal/app/jobs
src_path=$(echo "$line" | sed 's|.*COPY --from=build /app/||; s| .*||')
# Skip build artifacts (created during docker build, not in source tree)
case "$src_path" in */build|*/build/*|*/node_modules|*/node_modules/*) continue ;; esac
if [ ! -e "$src_path" ]; then
echo "ERROR: $dockerfile references $src_path but it does not exist"
errors=$((errors + 1))
fi
done
done
if [ "$errors" -gt 0 ]; then
echo ""
echo "$errors error(s) in Dockerfiles."
exit 1
fi
echo "All Dockerfile references verified."