Some checks failed
CI / build-and-test (pull_request) Has been cancelled
- Add missing --space-1 CSS token used by filter and detail components - Fix active nav link losing styles on hover (CSS specificity) - Correct Russian day pluralization (21 день, 22 дня, 25 дней) - Show filter error banner even when stale race data is present - Add cross-env for Windows-compatible npm test - Add global JSON error handler in Express for malformed request bodies - Replace stateless mock DB with in-memory store for correct DELETE/UPDATE behavior Made-with: Cursor
176 lines
4.9 KiB
TypeScript
176 lines
4.9 KiB
TypeScript
import { Pool, PoolConfig, QueryResult, QueryResultRow } from "pg";
|
|
import { config } from "./config";
|
|
import type { RaceRow } from "./mappers/race";
|
|
|
|
const poolConfig: PoolConfig = {
|
|
host: config.db.host,
|
|
port: config.db.port,
|
|
database: config.db.database,
|
|
user: config.db.user,
|
|
password: config.db.password,
|
|
max: 10,
|
|
idleTimeoutMillis: 30_000,
|
|
connectionTimeoutMillis: 5_000,
|
|
};
|
|
|
|
function mockRowFromInsert(sql: string, params: unknown[]): RaceRow {
|
|
const match = sql.match(/INSERT INTO races\s*\(([^)]+)\)\s*VALUES/i);
|
|
const now = new Date().toISOString();
|
|
if (!match) {
|
|
return {
|
|
id: String(params[0] ?? ""),
|
|
race_date: "",
|
|
title: "",
|
|
distance_km: "0",
|
|
status: null,
|
|
official_url: null,
|
|
start_time: null,
|
|
cluster_schedule: null,
|
|
bib_pickup: null,
|
|
bib_number: null,
|
|
finish_time: null,
|
|
finish_place: null,
|
|
notes: null,
|
|
created_at: now,
|
|
updated_at: null,
|
|
};
|
|
}
|
|
const cols = match[1].split(",").map((c) => c.trim());
|
|
const row: Record<string, unknown> = {};
|
|
cols.forEach((col, i) => {
|
|
row[col] = params[i];
|
|
});
|
|
return {
|
|
id: String(row.id ?? ""),
|
|
race_date: String(row.race_date ?? ""),
|
|
title: String(row.title ?? ""),
|
|
distance_km: String(row.distance_km ?? "0"),
|
|
status: row.status != null ? String(row.status) : null,
|
|
official_url: row.official_url != null ? String(row.official_url) : null,
|
|
start_time: row.start_time != null ? String(row.start_time) : null,
|
|
cluster_schedule: row.cluster_schedule != null ? String(row.cluster_schedule) : null,
|
|
bib_pickup: row.bib_pickup != null ? String(row.bib_pickup) : null,
|
|
bib_number: row.bib_number != null ? String(row.bib_number) : null,
|
|
finish_time: row.finish_time != null ? String(row.finish_time) : null,
|
|
finish_place: row.finish_place != null ? String(row.finish_place) : null,
|
|
notes: row.notes != null ? String(row.notes) : null,
|
|
created_at: now,
|
|
updated_at: null,
|
|
};
|
|
}
|
|
|
|
function createMockPool(): Pool {
|
|
const emptyResult = <T extends QueryResultRow>(): QueryResult<T> =>
|
|
({
|
|
rows: [],
|
|
rowCount: 0,
|
|
command: "",
|
|
oid: 0,
|
|
fields: [],
|
|
}) as QueryResult<T>;
|
|
|
|
const store = new Map<string, RaceRow>();
|
|
|
|
const mockQuery = async <T extends QueryResultRow>(
|
|
text: string,
|
|
params?: unknown[],
|
|
): Promise<QueryResult<T>> => {
|
|
const sql = text.replace(/\s+/g, " ").trim();
|
|
const p = params ?? [];
|
|
|
|
if (sql.includes("INSERT INTO races") && sql.includes("RETURNING")) {
|
|
const row = mockRowFromInsert(text, p);
|
|
store.set(row.id, row);
|
|
return {
|
|
rows: [row as unknown as T],
|
|
rowCount: 1,
|
|
command: "INSERT",
|
|
oid: 0,
|
|
fields: [],
|
|
} as QueryResult<T>;
|
|
}
|
|
|
|
if (sql.includes("DELETE FROM races")) {
|
|
const id = String(p[0] ?? "");
|
|
const existed = store.delete(id);
|
|
return {
|
|
rows: [],
|
|
rowCount: existed ? 1 : 0,
|
|
command: "DELETE",
|
|
oid: 0,
|
|
fields: [],
|
|
} as QueryResult<T>;
|
|
}
|
|
|
|
if (sql.includes("UPDATE races") && sql.includes("RETURNING")) {
|
|
const id = String(p[p.length - 1] ?? "");
|
|
const existing = store.get(id);
|
|
if (!existing) {
|
|
return emptyResult();
|
|
}
|
|
const updated = { ...existing, updated_at: new Date().toISOString() };
|
|
store.set(id, updated);
|
|
return {
|
|
rows: [updated as unknown as T],
|
|
rowCount: 1,
|
|
command: "UPDATE",
|
|
oid: 0,
|
|
fields: [],
|
|
} as QueryResult<T>;
|
|
}
|
|
|
|
if (sql.includes("SELECT * FROM races WHERE id =")) {
|
|
const id = String(p[0] ?? "");
|
|
const row = store.get(id);
|
|
return row
|
|
? { rows: [row as unknown as T], rowCount: 1, command: "SELECT", oid: 0, fields: [] } as QueryResult<T>
|
|
: emptyResult();
|
|
}
|
|
|
|
if (sql.includes("SELECT * FROM races")) {
|
|
const rows = Array.from(store.values());
|
|
return { rows: rows as unknown as T[], rowCount: rows.length, command: "SELECT", oid: 0, fields: [] } as QueryResult<T>;
|
|
}
|
|
|
|
return emptyResult();
|
|
};
|
|
|
|
const mockPool = {
|
|
query: mockQuery,
|
|
connect: async () => {
|
|
throw new Error(
|
|
"CALENDAR_RUN_MOCK_DB is enabled: migrate/seed require a real database; unset CALENDAR_RUN_MOCK_DB and configure DB_*.",
|
|
);
|
|
},
|
|
end: async () => {},
|
|
on() {
|
|
return mockPool;
|
|
},
|
|
};
|
|
|
|
return mockPool as unknown as Pool;
|
|
}
|
|
|
|
export const pool = config.useMockDb ? createMockPool() : new Pool(poolConfig);
|
|
|
|
if (!config.useMockDb) {
|
|
pool.on("error", (err) => {
|
|
console.error("[db] Unexpected pool error:", err.message);
|
|
});
|
|
} else {
|
|
console.warn("[db] Mock database enabled (CALENDAR_RUN_MOCK_DB); no PostgreSQL connection is used.");
|
|
}
|
|
|
|
export async function checkDbConnection(): Promise<boolean> {
|
|
if (config.useMockDb) {
|
|
return true;
|
|
}
|
|
try {
|
|
const client = await pool.connect();
|
|
client.release();
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|