Merge branch 'main' into deps/expo-sdk-56-packages

This commit is contained in:
Ullrich Schäfer 2026-05-24 21:11:19 +02:00 committed by GitHub
commit aa3afeeaec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 311 additions and 81 deletions

View file

@ -5,6 +5,7 @@ import { activities, routes, syncImports, users, follows } from "@trails-cool/db
import type { Visibility } from "@trails-cool/db/schema/journal";
import { validateGpx, writeGeom } from "./gpx-save.server.ts";
import type { GpxData } from "./gpx-save.server.ts";
import { enqueueOptional } from "./boss.server.ts";
export interface ActivityInput {
name: string;
@ -36,7 +37,6 @@ export async function updateActivityVisibility(
// idempotent, so toggling private→public→private→public won't spam
// followers (only the first transition per activity emits).
if (visibility === "public") {
const { enqueueOptional } = await import("./boss.server.ts");
await enqueueOptional("notifications-fanout", { activityId: id }, { source: "updateActivityVisibility" });
}
@ -91,7 +91,6 @@ export async function createActivity(ownerId: string, input: ActivityInput) {
// updateActivityVisibility path for the case where visibility is set
// up-front rather than flipped later).
if (input.visibility === "public") {
const { enqueueOptional } = await import("./boss.server.ts");
await enqueueOptional("notifications-fanout", { activityId: id }, { source: "createActivity" });
}

View file

@ -279,9 +279,13 @@ export async function createMagicToken(email: string): Promise<{ token: string;
export async function verifyLoginCode(email: string, code: string): Promise<string> {
const db = getDb();
// Consume atomically: a single UPDATE…RETURNING ensures only one
// concurrent request wins. The old select-then-update sequence was a
// TOCTOU window where two clients could both see `used_at IS NULL`
// and both succeed.
const [record] = await db
.select()
.from(magicTokens)
.update(magicTokens)
.set({ usedAt: new Date() })
.where(
and(
eq(magicTokens.email, email),
@ -290,15 +294,11 @@ export async function verifyLoginCode(email: string, code: string): Promise<stri
gt(magicTokens.expiresAt, new Date()),
isNull(magicTokens.usedAt),
),
);
)
.returning();
if (!record) throw new Error("Invalid or expired code");
await db
.update(magicTokens)
.set({ usedAt: new Date() })
.where(eq(magicTokens.id, record.id));
const [user] = await db.select().from(users).where(eq(users.email, record.email));
if (!user) throw new Error("User not found");
@ -332,9 +332,10 @@ export async function initiateEmailChange(userId: string, newEmail: string): Pro
export async function verifyMagicToken(token: string): Promise<string> {
const db = getDb();
// Atomic consume — see verifyLoginCode for rationale.
const [record] = await db
.select()
.from(magicTokens)
.update(magicTokens)
.set({ usedAt: new Date() })
.where(
and(
eq(magicTokens.token, token),
@ -342,15 +343,11 @@ export async function verifyMagicToken(token: string): Promise<string> {
gt(magicTokens.expiresAt, new Date()),
isNull(magicTokens.usedAt),
),
);
)
.returning();
if (!record) throw new Error("Invalid or expired magic link");
await db
.update(magicTokens)
.set({ usedAt: new Date() })
.where(eq(magicTokens.id, record.id));
const [user] = await db.select().from(users).where(eq(users.email, record.email));
if (!user) throw new Error("User not found");
@ -360,9 +357,14 @@ export async function verifyMagicToken(token: string): Promise<string> {
export async function verifyEmailChange(token: string, userId: string): Promise<string> {
const db = getDb();
// Atomic consume — see verifyLoginCode for rationale. The token is
// marked used regardless of whether the email is still available;
// re-checking availability after the consume keeps the token
// single-use even if the new email was claimed in between (the
// original implementation also marked it used in that case).
const [record] = await db
.select()
.from(magicTokens)
.update(magicTokens)
.set({ usedAt: new Date() })
.where(
and(
eq(magicTokens.token, token),
@ -370,25 +372,20 @@ export async function verifyEmailChange(token: string, userId: string): Promise<
gt(magicTokens.expiresAt, new Date()),
isNull(magicTokens.usedAt),
),
);
)
.returning();
if (!record) throw new Error("Invalid or expired verification link");
const newEmail = record.email;
// Re-check email availability at verification time — someone may have
// registered with this email between initiation and verification
// registered with this email between initiation and verification.
const [existing] = await db.select().from(users).where(eq(users.email, newEmail));
if (existing) {
await db.update(magicTokens).set({ usedAt: new Date() }).where(eq(magicTokens.id, record.id));
throw new Error("This email is now in use by another account");
}
await db
.update(magicTokens)
.set({ usedAt: new Date() })
.where(eq(magicTokens.id, record.id));
await db
.update(users)
.set({ email: newEmail })

View file

@ -4,6 +4,8 @@
// is bound to the Node process — startWorker calls boss.start(); the
// SIGTERM handler stops it.
import { logger } from "./logger.server.ts";
// Structurally typed (we only need `send`) so we don't have to pull
// pg-boss into the journal app's dep graph just for the typedef.
interface BossLike {
@ -43,9 +45,6 @@ export async function enqueueOptional(
const boss = getBoss();
await boss.send(queue, data);
} catch (err) {
// Lazy import to avoid cycles in test environments where logger.server
// pulls in env-dependent setup.
const { logger } = await import("./logger.server.ts");
logger.warn({ err, queue, ...ctx }, "boss.send failed; continuing");
}
}

View file

@ -8,6 +8,7 @@
import { decrypt } from "../../../crypto.server.ts";
import { fetchKomootTours, fetchKomootTourGpx } from "../../../komoot.server.ts";
import { isAlreadyImported, importActivity } from "../../../sync/imports.server.ts";
import { getServiceById } from "../../manager.ts";
import type {
CapabilityContext,
ImportableList,
@ -58,7 +59,6 @@ export const komootImporter: Importer = {
return ctx.withFreshCredentials(async (rawCreds) => {
const creds = rawCreds as KomootCreds;
const { getServiceById } = await import("../../manager.ts");
const service = await getServiceById(ctx.serviceId);
if (!service) throw new Error(`Connected service ${ctx.serviceId} not found`);

View file

@ -100,3 +100,106 @@ describe("wahooImporter.listImportable", () => {
expect(result.workouts.map((w) => w.id)).toEqual(["1"]);
});
});
describe("wahooImporter.importOne pagination", () => {
function fitToGpxMock() {
// The importer calls fitToGpx — short-circuit it so the test focuses
// on pagination, not FIT parsing.
return Promise.resolve("<gpx></gpx>");
}
it("paginates past page 1 until it finds the target workout", async () => {
vi.resetModules();
vi.doMock("../../fit.ts", () => ({ fitToGpx: fitToGpxMock }));
vi.doMock("../../../sync/imports.server.ts", () => ({
importActivity: vi.fn().mockResolvedValue({ activityId: "a-1" }),
isAlreadyImported: vi.fn().mockResolvedValue(false),
}));
vi.doMock("../../manager.ts", () => ({
getServiceById: vi.fn().mockResolvedValue({ id: "svc-1", userId: "u-1" }),
}));
// Page 1: workouts 1..30, Page 2: workouts 31..50 (the target = 42)
fetchSpy.mockImplementation((url) => {
const u = String(url);
if (u.includes("page=2")) {
return Promise.resolve(
new Response(
JSON.stringify({
workouts: [
{
id: 42,
name: "Old ride",
workout_type: "biking",
starts: "2025-12-01T07:00:00Z",
workout_summary: { file: { url: "https://cdn.example/42.fit" } },
},
],
total: 50,
page: 2,
per_page: 30,
}),
{ status: 200 },
),
);
}
if (u.includes("/42.fit")) {
return Promise.resolve(new Response(new ArrayBuffer(4), { status: 200 }));
}
// page 1 by default — does NOT contain id 42
return Promise.resolve(
new Response(
JSON.stringify({
workouts: [
{ id: 1, name: "Recent", workout_type: "biking", starts: "2026-05-01T07:00:00Z" },
],
total: 50,
page: 1,
per_page: 30,
}),
{ status: 200 },
),
);
});
const { wahooImporter } = await import("./importer.ts");
const result = await wahooImporter.importOne(ctxWith(), "42");
expect(result.activityId).toBe("a-1");
// Fetched at least pages 1 and 2 of the /v1/workouts endpoint
const workoutCalls = fetchSpy.mock.calls.filter(([u]) =>
String(u).includes("/v1/workouts?"),
);
expect(workoutCalls.length).toBeGreaterThanOrEqual(2);
});
it("throws a clear error when the workout is not found on any page", async () => {
vi.resetModules();
vi.doMock("../../fit.ts", () => ({ fitToGpx: fitToGpxMock }));
vi.doMock("../../../sync/imports.server.ts", () => ({
importActivity: vi.fn(),
isAlreadyImported: vi.fn().mockResolvedValue(false),
}));
vi.doMock("../../manager.ts", () => ({
getServiceById: vi.fn().mockResolvedValue({ id: "svc-1", userId: "u-1" }),
}));
fetchSpy.mockImplementation(() =>
Promise.resolve(
new Response(
JSON.stringify({
workouts: [
{ id: 1, name: "Recent", workout_type: "biking", starts: "2026-05-01T07:00:00Z" },
],
total: 1,
page: 1,
per_page: 30,
}),
{ status: 200 },
),
),
);
const { wahooImporter } = await import("./importer.ts");
await expect(wahooImporter.importOne(ctxWith(), "999")).rejects.toThrow(/not found/);
});
});

View file

@ -7,6 +7,7 @@
import { fitToGpx } from "../../fit.ts";
import { fetchWithTimeout } from "../../../http.server.ts";
import { importActivity, isAlreadyImported } from "../../../sync/imports.server.ts";
import { getServiceById } from "../../manager.ts";
import type {
CapabilityContext,
ImportableList,
@ -103,20 +104,34 @@ export const wahooImporter: Importer = {
ctx: CapabilityContext,
workoutId: string,
): Promise<ImportResult> {
// Look up the workout to get the file URL (Wahoo doesn't expose a
// direct /v1/workouts/<id> with file; we re-fetch the page).
// For simplicity we ask Wahoo for the workout directly; if that fails
// we fall back to scanning page 1.
const list = await ctx.withFreshCredentials((creds) =>
fetchWahooWorkoutPage(creds as OAuthCredentials, 1),
);
const workout = list.workouts.find((w) => String(w.id) === workoutId);
if (!workout) throw new Error(`Wahoo workout ${workoutId} not found on page 1`);
// Wahoo doesn't expose a direct /v1/workouts/<id> endpoint with file
// URL, so we paginate /v1/workouts looking for the target. Bound by
// the total / per_page Wahoo returns on page 1, with a hard ceiling
// so a misbehaving API can't loop us forever.
const MAX_PAGES = 100;
let workout: WahooWorkout | undefined;
let totalPages = 1;
for (let page = 1; page <= Math.min(totalPages, MAX_PAGES); page++) {
const list = await ctx.withFreshCredentials((creds) =>
fetchWahooWorkoutPage(creds as OAuthCredentials, page),
);
// perPage may not divide total cleanly; ceil so we don't stop one
// page short.
if (page === 1 && list.per_page > 0) {
totalPages = Math.ceil(list.total / list.per_page);
}
const found = list.workouts.find((w) => String(w.id) === workoutId);
if (found) {
workout = found;
break;
}
if (list.workouts.length === 0) break;
}
if (!workout) throw new Error(`Wahoo workout ${workoutId} not found`);
// Resolve the connected service's user id via the capability context.
// The caller (route handler) supplies userId out-of-band — for now the
// route handler bridges the gap. We use the manager's getServiceById.
const { getServiceById } = await import("../../manager.ts");
const service = await getServiceById(ctx.serviceId);
if (!service) throw new Error(`Connected service ${ctx.serviceId} not found`);

View file

@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { Writable } from "node:stream";
import pino from "pino";
import { AsyncLocalStorage } from "node:async_hooks";
// We test the *mixin contract* — that pino's `mixin` callback reads
// from the async-local store and tags every record. Constructing a
// fresh pino instance per test (vs. importing the module-level one)
// lets us capture stdout cleanly.
describe("logger mixin attaches requestId from async context", () => {
function captureLogger(als: AsyncLocalStorage<{ requestId: string }>) {
const lines: string[] = [];
const sink = new Writable({
write(chunk, _enc, cb) {
lines.push(chunk.toString());
cb();
},
});
const lg = pino(
{
level: "info",
mixin: () => {
const ctx = als.getStore();
return ctx ? { requestId: ctx.requestId } : {};
},
},
sink,
);
return { lg, lines };
}
it("tags log records with requestId when inside als.run()", () => {
const als = new AsyncLocalStorage<{ requestId: string }>();
const { lg, lines } = captureLogger(als);
als.run({ requestId: "abc-123" }, () => lg.info("hello"));
const record = JSON.parse(lines[0]!);
expect(record.requestId).toBe("abc-123");
expect(record.msg).toBe("hello");
});
it("omits requestId when no async context is active", () => {
const als = new AsyncLocalStorage<{ requestId: string }>();
const { lg, lines } = captureLogger(als);
lg.info("bare");
const record = JSON.parse(lines[0]!);
expect(record.requestId).toBeUndefined();
});
});

View file

@ -1,7 +1,27 @@
import pino from "pino";
import { AsyncLocalStorage } from "node:async_hooks";
/**
* Per-request context propagated through the async stack. The HTTP
* server wraps each request in `requestContext.run({ requestId }, ...)`
* so every downstream `logger.info(...)` automatically tags log lines
* with `requestId` no need to thread it through every call site.
*/
export interface RequestContext {
requestId: string;
}
export const requestContext = new AsyncLocalStorage<RequestContext>();
export const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
// Mixin runs on every log call and merges its return value into the
// emitted record. Reading from ALS here is what makes requestId
// automatic for every downstream logger.info/warn/error.
mixin: () => {
const ctx = requestContext.getStore();
return ctx ? { requestId: ctx.requestId } : {};
},
...(process.env.NODE_ENV !== "production"
? { transport: { target: "pino-pretty" } }
: {}),

View file

@ -5,12 +5,23 @@ import { browserSentryConfig } from "@trails-cool/sentry-config";
let initialized = false;
// Build-time DSN injection: `VITE_SENTRY_DSN` (if set during `pnpm build`)
// overrides the flagship default so self-hosters can ship their own
// Sentry project. Set it to `""` (empty string) to disable Sentry on
// the client entirely.
const FLAGSHIP_JOURNAL_DSN =
"https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728";
const CLIENT_DSN =
(import.meta.env as Record<string, string | undefined>).VITE_SENTRY_DSN ??
FLAGSHIP_JOURNAL_DSN;
export function initSentryClient() {
if (initialized) return;
initialized = true;
if (!CLIENT_DSN) return;
Sentry.init({
dsn: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728",
dsn: CLIENT_DSN,
integrations: [
Sentry.reactRouterV7BrowserTracingIntegration({
useEffect,

View file

@ -9,6 +9,7 @@ import { getSessionUser } from "~/lib/auth/session.server";
import { LocaleProvider } from "~/components/LocaleContext";
import { AlphaBanner } from "~/components/AlphaBanner";
import { useUnreadNotifications } from "~/hooks/useUnreadNotifications";
import { countUnread } from "~/lib/notifications.server";
import { Footer } from "~/components/Footer";
import { AccountDropdown } from "~/components/AccountDropdown";
import { MobileNavMenu } from "~/components/MobileNavMenu";
@ -69,12 +70,9 @@ export async function loader({ request }: Route.LoaderArgs) {
// Unread-notification count for the navbar bell badge. Pending follow
// requests are not separately counted here — each pending request
// creates an unread `follow_request_received` notification, so the
// unread count already covers them. Hidden behind a dynamic import so
// the root layout doesn't pull in the notifications module on
// anonymous renders.
// unread count already covers them.
let unreadNotifications = 0;
if (user) {
const { countUnread } = await import("./lib/notifications.server.ts");
unreadNotifications = await countUnread(user.id);
}

View file

@ -8,6 +8,7 @@ import { getRoute, getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/
import { getDb } from "~/lib/db";
import { syncPushes } from "@trails-cool/db/schema/journal";
import { getService } from "~/lib/connected-services";
import { computeDays, parseGpxAsync } from "@trails-cool/gpx";
export async function loadRouteDetail(request: Request, id: string | undefined) {
const routeId = id ?? "";
@ -32,7 +33,6 @@ export async function loadRouteDetail(request: Request, id: string | undefined)
let waypoints: Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean; note?: string; osmId?: number; poiTags?: Record<string, string> }> = [];
if (route.gpx) {
try {
const { computeDays, parseGpxAsync } = await import("@trails-cool/gpx");
const gpxData = await parseGpxAsync(route.gpx);
waypoints = gpxData.waypoints.map((w) => ({
lat: w.lat,

View file

@ -9,6 +9,7 @@ import {
} from "~/lib/connected-services";
import { getImportedIds, recordImport } from "~/lib/sync/imports.server";
import { createActivity } from "~/lib/activities.server";
import { generateGpx } from "@trails-cool/gpx";
export async function loadSyncImportProvider(request: Request, provider: string | undefined) {
const user = await requireSessionUser(request);
@ -77,9 +78,11 @@ export async function syncImportProviderAction(request: Request, provider: strin
if (!resp.ok) throw new Error(`Download failed: ${resp.status}`);
return Buffer.from(await resp.arrayBuffer());
});
// Lazy-load to avoid bundling fit-file-parser into all routes.
// Lazy-load fit-file-parser only — it's heavy and only the FIT
// ingestion path needs it. @trails-cool/gpx is already pulled in
// statically by other modules in the chunk, so the dynamic import
// there was ineffective.
const { default: FitParser } = await import("fit-file-parser");
const { generateGpx } = await import("@trails-cool/gpx");
const parsed = await new Promise<Record<string, unknown>>((resolve, reject) => {
const parser = new FitParser({ force: true });
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View file

@ -4,17 +4,27 @@ import { createRequestListener } from "@react-router/node";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { createReadStream, statSync } from "node:fs";
import { join, extname, resolve } from "node:path";
import { logger } from "./app/lib/logger.server.ts";
import { logger, requestContext } from "./app/lib/logger.server.ts";
import { randomUUID } from "node:crypto";
import { httpRequestDuration, registry } from "./app/lib/metrics.server.ts";
import { createBoss, startWorker } from "@trails-cool/jobs";
import { getDatabaseUrl } from "@trails-cool/db";
import postgres from "postgres";
import postgres, { type Sql } from "postgres";
Sentry.init({
dsn: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728",
...nodeSentryConfig("journal server"),
beforeSend: drop404s,
});
// Sentry DSN is read from env so self-hosted instances don't ship their
// errors to the trails.cool flagship Sentry by default. The flagship
// keeps its DSN as the fallback; setting SENTRY_DSN="" (or any other
// truthy value) overrides. SENTRY_DISABLED=true skips init entirely.
const FLAGSHIP_JOURNAL_SENTRY_DSN =
"https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728";
const sentryDsn = process.env.SENTRY_DSN ?? FLAGSHIP_JOURNAL_SENTRY_DSN;
if (process.env.SENTRY_DISABLED !== "true" && sentryDsn !== "") {
Sentry.init({
dsn: sentryDsn,
...nodeSentryConfig("journal server"),
beforeSend: drop404s,
});
}
const port = Number(process.env.PORT ?? 3000);
const CLIENT_DIR = resolve(import.meta.dirname, "build", "client");
@ -66,17 +76,27 @@ async function handleMetrics(_req: IncomingMessage, res: ServerResponse): Promis
const version = process.env.SENTRY_RELEASE ?? "dev";
// Module-level singleton postgres client dedicated to /api/health. The
// previous handler opened a fresh client + connection on every call,
// which OOM'd the process under monitoring load (probes hit /api/health
// every few seconds). `max: 2` is plenty for liveness checks; the main
// app DB pool is separate (via @trails-cool/db's createDb).
let healthClient: Sql | null = null;
function getHealthClient(): Sql {
if (!healthClient) {
healthClient = postgres(getDatabaseUrl(), { max: 2, idle_timeout: 30 });
}
return healthClient;
}
async function handleHealth(_req: IncomingMessage, res: ServerResponse): Promise<void> {
const client = postgres(getDatabaseUrl(), { max: 1 });
try {
await client`SELECT 1`;
await getHealthClient()`SELECT 1`;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok", version, db: "connected" }));
} catch {
res.writeHead(503, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "degraded", version, db: "unreachable" }));
} finally {
await client.end();
}
}
@ -84,22 +104,32 @@ const server = createServer((req, res) => {
const url = req.url ?? "/";
const start = Date.now();
if (!url.startsWith("/assets/") && url !== "/api/health" && url !== "/api/metrics") {
res.on("finish", () => {
const duration = Date.now() - start;
logger.info({ method: req.method, path: url, status: res.statusCode, duration }, "request");
httpRequestDuration.observe(
{ method: req.method ?? "GET", route: url.split("?")[0]!, status: String(res.statusCode) },
duration / 1000,
);
});
}
// Honor an inbound X-Request-Id header (e.g. from Caddy or a probe)
// so request IDs propagate across the proxy hop. Mint a fresh one if
// absent. Echo on the response so clients can correlate.
const inbound = req.headers["x-request-id"];
const requestId =
(Array.isArray(inbound) ? inbound[0] : inbound) || randomUUID();
res.setHeader("X-Request-Id", requestId);
if (url === "/api/health") { handleHealth(req, res); return; }
if (url === "/api/metrics") { handleMetrics(req, res); return; }
if (!serveStatic(req, res)) {
listener(req, res);
}
requestContext.run({ requestId }, () => {
if (!url.startsWith("/assets/") && url !== "/api/health" && url !== "/api/metrics") {
res.on("finish", () => {
const duration = Date.now() - start;
logger.info({ method: req.method, path: url, status: res.statusCode, duration }, "request");
httpRequestDuration.observe(
{ method: req.method ?? "GET", route: url.split("?")[0]!, status: String(res.statusCode) },
duration / 1000,
);
});
}
if (url === "/api/health") { handleHealth(req, res); return; }
if (url === "/api/metrics") { handleMetrics(req, res); return; }
if (!serveStatic(req, res)) {
listener(req, res);
}
});
});
server.listen(port, async () => {

View file

@ -11,11 +11,17 @@ import { createBoss, startWorker } from "@trails-cool/jobs";
import { expireSessionsJob } from "./app/jobs/expire-sessions.ts";
import postgres from "postgres";
Sentry.init({
dsn: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208",
...nodeSentryConfig("planner server"),
beforeSend: drop404s,
});
// See apps/journal/server.ts for the SENTRY_DSN / SENTRY_DISABLED contract.
const FLAGSHIP_PLANNER_SENTRY_DSN =
"https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208";
const sentryDsn = process.env.SENTRY_DSN ?? FLAGSHIP_PLANNER_SENTRY_DSN;
if (process.env.SENTRY_DISABLED !== "true" && sentryDsn !== "") {
Sentry.init({
dsn: sentryDsn,
...nodeSentryConfig("planner server"),
beforeSend: drop404s,
});
}
const port = Number(process.env.PORT ?? 3001);
const CLIENT_DIR = resolve(import.meta.dirname, "build", "client");