528 lines
18 KiB
TypeScript
528 lines
18 KiB
TypeScript
import { Pool, PoolConfig, QueryResult, QueryResultRow } from "pg";
|
|
import crypto from "crypto";
|
|
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();
|
|
if (!match) {
|
|
return {
|
|
id: String(params[0] ?? ""),
|
|
slug: String(params[0] ?? ""),
|
|
owner_user_id: null,
|
|
race_date: "",
|
|
title: "",
|
|
distance_km: "0",
|
|
status: null,
|
|
official_url: null,
|
|
cover_image_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 ?? crypto.randomUUID()),
|
|
slug: String(row.slug ?? row.id ?? ""),
|
|
owner_user_id: row.owner_user_id != null ? String(row.owner_user_id) : null,
|
|
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,
|
|
cover_image_url: row.cover_image_url != null ? String(row.cover_image_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 users = new Map<string, any>();
|
|
const sessions = new Map<string, any>();
|
|
const verificationTokens = new Map<string, any>();
|
|
const resetTokens = new Map<string, any>();
|
|
const appSettings = new Map<string, string>();
|
|
|
|
const result = <T extends QueryResultRow>(rows: T[], command = "SELECT"): QueryResult<T> =>
|
|
({ rows, rowCount: rows.length, command, oid: 0, fields: [] }) as QueryResult<T>;
|
|
|
|
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 === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK" || sql.includes("pg_advisory_xact_lock")) {
|
|
return result<T>([]);
|
|
}
|
|
|
|
if (sql.includes("SELECT COUNT(*)::text AS count FROM users")) {
|
|
return result([{ count: String(users.size) } as unknown as T]);
|
|
}
|
|
|
|
if (sql.includes("SELECT COUNT(*)::text AS count FROM sessions")) {
|
|
const tokenHash = p[0] != null ? String(p[0]) : null;
|
|
const count = Array.from(sessions.values()).filter((row) => !tokenHash || row.token_hash === tokenHash).length;
|
|
return result([{ count: String(count) } as unknown as T]);
|
|
}
|
|
|
|
if (sql.includes("SELECT COUNT(*)::text AS count FROM email_verification_tokens")) {
|
|
const tokenHash = p[0] != null ? String(p[0]) : null;
|
|
const count = Array.from(verificationTokens.values()).filter((row) => !tokenHash || row.token_hash === tokenHash).length;
|
|
return result([{ count: String(count) } as unknown as T]);
|
|
}
|
|
|
|
if (sql.includes("SELECT COUNT(*)::text AS count FROM password_reset_tokens")) {
|
|
const tokenHash = p[0] != null ? String(p[0]) : null;
|
|
const count = Array.from(resetTokens.values()).filter((row) => !tokenHash || row.token_hash === tokenHash).length;
|
|
return result([{ count: String(count) } as unknown as T]);
|
|
}
|
|
|
|
if (sql.includes("SELECT id FROM users WHERE LOWER(BTRIM(email))")) {
|
|
const email = String(p[0] ?? "");
|
|
const user = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email);
|
|
return user ? result([user as T]) : emptyResult();
|
|
}
|
|
|
|
if (sql.includes("SELECT id, email, password_hash, email_verified_at FROM users WHERE LOWER(BTRIM(email))")) {
|
|
const email = String(p[0] ?? "");
|
|
const user = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email);
|
|
return user ? result([user as T]) : emptyResult();
|
|
}
|
|
|
|
if (sql.includes("INSERT INTO users")) {
|
|
const email = String(p[0] ?? "").trim().toLowerCase();
|
|
const existing = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email);
|
|
if (existing) {
|
|
const err = new Error("duplicate key") as Error & { code?: string };
|
|
err.code = "23505";
|
|
throw err;
|
|
}
|
|
const id = crypto.randomUUID();
|
|
const row = {
|
|
id,
|
|
email: String(p[0] ?? ""),
|
|
password_hash: String(p[1] ?? ""),
|
|
email_verified_at: null,
|
|
created_at: new Date(),
|
|
updated_at: null,
|
|
};
|
|
users.set(id, row);
|
|
return result([row as unknown as T], "INSERT");
|
|
}
|
|
|
|
if (sql.includes("INSERT INTO email_verification_tokens")) {
|
|
const id = crypto.randomUUID();
|
|
const row = {
|
|
id,
|
|
user_id: String(p[0] ?? ""),
|
|
token_hash: String(p[1] ?? ""),
|
|
expires_at: p[2] ?? new Date(),
|
|
used_at: null,
|
|
created_at: new Date(),
|
|
};
|
|
verificationTokens.set(id, row);
|
|
return result([row as unknown as T], "INSERT");
|
|
}
|
|
|
|
if (sql.includes("UPDATE email_verification_tokens SET used_at = NOW() WHERE id")) {
|
|
const id = String(p[0] ?? "");
|
|
const row = verificationTokens.get(id);
|
|
if (!row || row.used_at != null) {
|
|
return result<T>([], "UPDATE");
|
|
}
|
|
row.used_at = new Date();
|
|
return result([{ id: row.id } as unknown as T], "UPDATE");
|
|
}
|
|
|
|
if (sql.includes("UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id")) {
|
|
const userId = String(p[0] ?? "");
|
|
const exceptId = p[1] != null ? String(p[1]) : null;
|
|
for (const row of verificationTokens.values()) {
|
|
if (row.user_id === userId && row.id !== exceptId && row.used_at == null) {
|
|
row.used_at = new Date();
|
|
}
|
|
}
|
|
return result<T>([], "UPDATE");
|
|
}
|
|
|
|
if (sql.includes("FROM email_verification_tokens") && sql.includes("token_hash =")) {
|
|
const tokenHash = String(p[0] ?? "");
|
|
const now = Date.now();
|
|
return result(
|
|
Array.from(verificationTokens.values()).filter(
|
|
(row) => row.token_hash === tokenHash && !row.used_at && new Date(row.expires_at).getTime() > now,
|
|
) as T[],
|
|
);
|
|
}
|
|
|
|
if (sql.includes("FROM email_verification_tokens") && sql.includes("WHERE used_at IS NULL")) {
|
|
const now = Date.now();
|
|
return result(
|
|
Array.from(verificationTokens.values()).filter((row) => !row.used_at && new Date(row.expires_at).getTime() > now) as T[],
|
|
);
|
|
}
|
|
|
|
if (sql.includes("INSERT INTO password_reset_tokens")) {
|
|
const id = crypto.randomUUID();
|
|
const row = {
|
|
id,
|
|
user_id: String(p[0] ?? ""),
|
|
token_hash: String(p[1] ?? ""),
|
|
expires_at: p[2] ?? new Date(),
|
|
used_at: null,
|
|
created_at: new Date(),
|
|
};
|
|
resetTokens.set(id, row);
|
|
return result([row as unknown as T], "INSERT");
|
|
}
|
|
|
|
if (sql.includes("UPDATE password_reset_tokens SET used_at = NOW() WHERE id")) {
|
|
const id = String(p[0] ?? "");
|
|
const row = resetTokens.get(id);
|
|
if (!row || row.used_at != null) {
|
|
return result<T>([], "UPDATE");
|
|
}
|
|
row.used_at = new Date();
|
|
return result([{ id: row.id } as unknown as T], "UPDATE");
|
|
}
|
|
|
|
if (sql.includes("UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id")) {
|
|
const userId = String(p[0] ?? "");
|
|
const exceptId = p[1] != null ? String(p[1]) : null;
|
|
for (const row of resetTokens.values()) {
|
|
if (row.user_id === userId && row.id !== exceptId && row.used_at == null) {
|
|
row.used_at = new Date();
|
|
}
|
|
}
|
|
return result<T>([], "UPDATE");
|
|
}
|
|
|
|
if (sql.includes("FROM password_reset_tokens") && sql.includes("token_hash =")) {
|
|
const tokenHash = String(p[0] ?? "");
|
|
const now = Date.now();
|
|
return result(
|
|
Array.from(resetTokens.values()).filter(
|
|
(row) => row.token_hash === tokenHash && !row.used_at && new Date(row.expires_at).getTime() > now,
|
|
) as T[],
|
|
);
|
|
}
|
|
|
|
if (sql.includes("FROM password_reset_tokens") && sql.includes("WHERE used_at IS NULL")) {
|
|
const now = Date.now();
|
|
return result(
|
|
Array.from(resetTokens.values()).filter((row) => !row.used_at && new Date(row.expires_at).getTime() > now) as T[],
|
|
);
|
|
}
|
|
|
|
if (sql.includes("UPDATE users SET email_verified_at")) {
|
|
const user = users.get(String(p[0] ?? ""));
|
|
if (user) {
|
|
user.email_verified_at = user.email_verified_at ?? new Date();
|
|
user.updated_at = new Date();
|
|
}
|
|
return result<T>([], "UPDATE");
|
|
}
|
|
|
|
if (sql.includes("UPDATE users SET password_hash")) {
|
|
const user = users.get(String(p[0] ?? ""));
|
|
if (user) {
|
|
user.password_hash = String(p[1] ?? "");
|
|
user.updated_at = new Date();
|
|
}
|
|
return result<T>([], "UPDATE");
|
|
}
|
|
|
|
if (sql.includes("INSERT INTO sessions")) {
|
|
const id = crypto.randomUUID();
|
|
const user = users.get(String(p[0] ?? ""));
|
|
const row = {
|
|
id,
|
|
user_id: String(p[0] ?? ""),
|
|
token_hash: String(p[1] ?? ""),
|
|
csrf_token_hash: String(p[2] ?? ""),
|
|
expires_at: p[3] ?? new Date(),
|
|
email: user?.email ?? "",
|
|
email_verified_at: user?.email_verified_at ?? null,
|
|
revoked_at: null,
|
|
created_at: new Date(),
|
|
last_seen_at: new Date(),
|
|
};
|
|
sessions.set(id, row);
|
|
return result([row as unknown as T], "INSERT");
|
|
}
|
|
|
|
if (sql.includes("FROM sessions s JOIN users u")) {
|
|
const tokenHash = String(p[0] ?? "");
|
|
const now = Date.now();
|
|
const row = Array.from(sessions.values()).find(
|
|
(item) => item.token_hash === tokenHash && !item.revoked_at && new Date(item.expires_at).getTime() > now,
|
|
);
|
|
if (!row) {
|
|
return emptyResult();
|
|
}
|
|
const user = users.get(row.user_id);
|
|
return result([{ ...row, email: user?.email ?? "", email_verified_at: user?.email_verified_at ?? null } as unknown as T]);
|
|
}
|
|
|
|
if (sql.includes("UPDATE sessions SET csrf_token_hash")) {
|
|
const tokenHash = String(p[0] ?? "");
|
|
const row = Array.from(sessions.values()).find((item) => item.token_hash === tokenHash && !item.revoked_at);
|
|
if (row) {
|
|
row.csrf_token_hash = String(p[1] ?? "");
|
|
row.last_seen_at = new Date();
|
|
}
|
|
return result<T>(row ? ([{} as unknown as T]) : [], "UPDATE");
|
|
}
|
|
|
|
if (sql.includes("UPDATE sessions SET last_seen_at")) {
|
|
return result<T>([], "UPDATE");
|
|
}
|
|
|
|
if (sql.includes("UPDATE sessions SET revoked_at = NOW() WHERE token_hash")) {
|
|
const tokenHash = String(p[0] ?? "");
|
|
for (const row of sessions.values()) {
|
|
if (row.token_hash === tokenHash) {
|
|
row.revoked_at = new Date();
|
|
}
|
|
}
|
|
return result<T>([], "UPDATE");
|
|
}
|
|
|
|
if (sql.includes("UPDATE sessions SET revoked_at = NOW() WHERE user_id")) {
|
|
const userId = String(p[0] ?? "");
|
|
for (const row of sessions.values()) {
|
|
if (row.user_id === userId && !row.revoked_at) {
|
|
row.revoked_at = new Date();
|
|
}
|
|
}
|
|
return result<T>([], "UPDATE");
|
|
}
|
|
|
|
if (sql.includes("DELETE FROM sessions WHERE expires_at <= NOW()")) {
|
|
const now = Date.now();
|
|
let deleted = 0;
|
|
for (const [id, row] of sessions.entries()) {
|
|
const revokedAt = row.revoked_at ? new Date(row.revoked_at).getTime() : null;
|
|
const staleRevoked = revokedAt != null && revokedAt < now - 30 * 24 * 60 * 60 * 1000;
|
|
if (new Date(row.expires_at).getTime() <= now || staleRevoked) {
|
|
sessions.delete(id);
|
|
deleted += 1;
|
|
}
|
|
}
|
|
return result<T>(Array.from({ length: deleted }, () => ({} as unknown as T)), "DELETE");
|
|
}
|
|
|
|
if (sql.includes("DELETE FROM email_verification_tokens WHERE expires_at <= NOW()")) {
|
|
const now = Date.now();
|
|
let deleted = 0;
|
|
for (const [id, row] of verificationTokens.entries()) {
|
|
if (new Date(row.expires_at).getTime() <= now || row.used_at != null) {
|
|
verificationTokens.delete(id);
|
|
deleted += 1;
|
|
}
|
|
}
|
|
return result<T>(Array.from({ length: deleted }, () => ({} as unknown as T)), "DELETE");
|
|
}
|
|
|
|
if (sql.includes("DELETE FROM password_reset_tokens WHERE expires_at <= NOW()")) {
|
|
const now = Date.now();
|
|
let deleted = 0;
|
|
for (const [id, row] of resetTokens.entries()) {
|
|
if (new Date(row.expires_at).getTime() <= now || row.used_at != null) {
|
|
resetTokens.delete(id);
|
|
deleted += 1;
|
|
}
|
|
}
|
|
return result<T>(Array.from({ length: deleted }, () => ({} as unknown as T)), "DELETE");
|
|
}
|
|
|
|
if (sql.includes("SELECT value FROM app_settings")) {
|
|
const value = appSettings.get("orphan_races_claimed_by_user_id");
|
|
return value ? result([{ value } as unknown as T]) : emptyResult();
|
|
}
|
|
|
|
if (sql.includes("INSERT INTO app_settings")) {
|
|
appSettings.set("orphan_races_claimed_by_user_id", String(p[0] ?? ""));
|
|
return result<T>([], "INSERT");
|
|
}
|
|
|
|
if (sql.includes("UPDATE races SET owner_user_id")) {
|
|
const userId = String(p[0] ?? "");
|
|
for (const race of store.values()) {
|
|
if (!race.owner_user_id) {
|
|
race.owner_user_id = userId;
|
|
race.updated_at = new Date();
|
|
}
|
|
}
|
|
return result<T>([], "UPDATE");
|
|
}
|
|
|
|
if (sql.includes("INSERT INTO races") && sql.includes("RETURNING")) {
|
|
const row = mockRowFromInsert(text, p);
|
|
const conflict = Array.from(store.values()).find(
|
|
(item) => item.owner_user_id && item.owner_user_id === row.owner_user_id && item.slug === row.slug,
|
|
);
|
|
if (conflict) {
|
|
const err = new Error("duplicate key") as Error & { code?: string };
|
|
err.code = "23505";
|
|
throw err;
|
|
}
|
|
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 ownerId = p[1] != null ? String(p[1]) : null;
|
|
const existing = store.get(id);
|
|
const existed = Boolean(existing && (!ownerId || existing.owner_user_id === ownerId));
|
|
if (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 - 2] ?? p[p.length - 1] ?? "");
|
|
const ownerId = p[p.length - 1] != null ? String(p[p.length - 1]) : null;
|
|
const existing = store.get(id);
|
|
if (!existing || (ownerId && existing.owner_user_id !== ownerId)) {
|
|
return emptyResult();
|
|
}
|
|
const setMatch = sql.match(/UPDATE races SET (.+) WHERE id =/);
|
|
const updated = { ...existing, updated_at: new Date() };
|
|
const setColumns =
|
|
setMatch?.[1]
|
|
.split(",")
|
|
.map((part) => part.trim())
|
|
.filter((part) => !part.startsWith("updated_at"))
|
|
.map((part) => part.split("=")[0]?.trim())
|
|
.filter((col): col is string => Boolean(col)) ?? [];
|
|
|
|
setColumns.forEach((col, index) => {
|
|
(updated as unknown as Record<string, unknown>)[col] = p[index] ?? null;
|
|
});
|
|
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 ownerId = p[1] != null ? String(p[1]) : null;
|
|
const row = store.get(id);
|
|
return row && (!ownerId || row.owner_user_id === ownerId)
|
|
? { rows: [row as unknown as T], rowCount: 1, command: "SELECT", oid: 0, fields: [] } as QueryResult<T>
|
|
: emptyResult();
|
|
}
|
|
|
|
if (sql.includes("SELECT * FROM races")) {
|
|
const ownerParam = p.find((value) => typeof value === "string" && /^[0-9a-f-]{36}$/i.test(value));
|
|
const rows = ownerParam
|
|
? Array.from(store.values()).filter((row) => row.owner_user_id === ownerParam)
|
|
: 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 () => {
|
|
return {
|
|
query: mockQuery,
|
|
release() {},
|
|
};
|
|
},
|
|
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;
|
|
}
|
|
}
|