mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-15 07:36:27 -05:00
164 lines
4.9 KiB
TypeScript
164 lines
4.9 KiB
TypeScript
import { DatabaseSync } from "node:sqlite";
|
|
import { styleText } from "node:util";
|
|
import { Kysely, type LogEvent } from "kysely";
|
|
import { format } from "sql-formatter";
|
|
import { ServerConfig } from "~/config.server";
|
|
import { logger } from "~/utils/logger";
|
|
import { roundToNDecimalPlaces } from "~/utils/number";
|
|
import { EmptyValuesNoopPlugin } from "./empty-values-noop-plugin";
|
|
import { JSON_COLUMNS } from "./json-columns";
|
|
import { computedJsonColumns } from "./json-selections";
|
|
import { NodeSqliteDialect } from "./node-sqlite-dialect";
|
|
import type { DB } from "./tables";
|
|
import { WriteTrackerPlugin } from "./write-tracker";
|
|
|
|
const sql = new DatabaseSync(
|
|
ServerConfig.isTest ? ":memory:" : ServerConfig.dbPath,
|
|
);
|
|
|
|
if (ServerConfig.isTest) {
|
|
applyMigratedSchema(sql);
|
|
}
|
|
|
|
sql.exec("PRAGMA journal_mode = WAL");
|
|
// in WAL mode NORMAL only risks durability across a power loss (https://sqlite.org/pragma.html)
|
|
sql.exec("PRAGMA synchronous = NORMAL");
|
|
sql.exec("PRAGMA foreign_keys = ON");
|
|
sql.exec("PRAGMA busy_timeout = 5000");
|
|
// a checkpoint only rewinds the WAL, without this the file keeps the high water mark of the
|
|
// largest transaction ever written (a VACUUM rewrites the whole db through it) forever
|
|
sql.exec("PRAGMA journal_size_limit = 67108864");
|
|
// 64MB page cache (default is 2MB)
|
|
sql.exec("PRAGMA cache_size = -65536");
|
|
// reads straight from the OS page cache without read() syscalls (https://sqlite.org/mmap.html)
|
|
sql.exec("PRAGMA mmap_size = 3221225472");
|
|
// https://sqlite.org/pragma.html#pragma_optimize, paired with the periodic OptimizeDatabase routine
|
|
sql.exec("PRAGMA optimize = 0x10002");
|
|
|
|
// strips diacritics ("cafe" matches "Café"); with LIKE's ASCII case-insensitivity this also folds case
|
|
sql.function("unaccent", { deterministic: true }, (value) =>
|
|
typeof value === "string"
|
|
? value.normalize("NFD").replace(/\p{M}/gu, "")
|
|
: value,
|
|
);
|
|
|
|
export const db = new Kysely<DB>({
|
|
dialect: new NodeSqliteDialect({
|
|
database: sql,
|
|
cacheStatements: true,
|
|
jsonColumns: JSON_COLUMNS,
|
|
computedJsonColumns,
|
|
}),
|
|
log,
|
|
plugins: [new EmptyValuesNoopPlugin(), new WriteTrackerPlugin()],
|
|
});
|
|
|
|
// each test worker gets an in-memory db built by replaying the DDL of the migrated empty file from
|
|
// scripts/ensure-test-db.ts; unlike better-sqlite3, `node:sqlite` can't deserialize a db into memory
|
|
function applyMigratedSchema(target: DatabaseSync) {
|
|
const source = new DatabaseSync("db-test.sqlite3", {
|
|
readOnly: true,
|
|
timeout: 5000,
|
|
});
|
|
|
|
try {
|
|
// virtual tables create their own shadow tables (e.g. UserSearch_data)
|
|
const statements = source
|
|
.prepare(`
|
|
SELECT sql FROM sqlite_master m
|
|
WHERE sql IS NOT NULL
|
|
AND name NOT LIKE 'sqlite_%'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM sqlite_master AS vt
|
|
WHERE vt.sql LIKE 'CREATE VIRTUAL TABLE%'
|
|
AND m.name LIKE vt.name || '_%'
|
|
)
|
|
ORDER BY m.rowid
|
|
`)
|
|
.all() as Array<{ sql: string }>;
|
|
|
|
for (const statement of statements) {
|
|
target.exec(statement.sql);
|
|
}
|
|
} finally {
|
|
source.close();
|
|
}
|
|
}
|
|
|
|
function log(event: LogEvent) {
|
|
if (ServerConfig.sqlLog === "trunc" || ServerConfig.sqlLog === "full") {
|
|
logQuery(event);
|
|
} else {
|
|
logError(event);
|
|
}
|
|
}
|
|
|
|
function logQuery(event: LogEvent) {
|
|
const isSelectQuery = Boolean((event.query.query as any).from?.froms);
|
|
|
|
if (event.level === "query" && isSelectQuery) {
|
|
const from = () =>
|
|
(event.query.query as any).from.froms.map(
|
|
(f: any) => f.table?.identifier?.name ?? f.alias?.name ?? "unknown",
|
|
);
|
|
// biome-ignore lint/suspicious/noConsole: dev only
|
|
console.log(styleText("blue", `-- SQLITE QUERY to "${from()}" --`));
|
|
// biome-ignore lint/suspicious/noConsole: dev only
|
|
console.log(
|
|
styleText(
|
|
millisToColor(event.queryDurationMillis),
|
|
`${roundToNDecimalPlaces(event.queryDurationMillis, 1)}ms`,
|
|
),
|
|
);
|
|
// biome-ignore lint/suspicious/noConsole: dev only
|
|
console.log(formatSql(event.query.sql, event.query.parameters));
|
|
} else {
|
|
logError(event);
|
|
}
|
|
}
|
|
|
|
function logError(event: LogEvent) {
|
|
if (
|
|
event.level === "error" &&
|
|
// an error inside a transaction rolls it back implicitly, so kysely's explicit rollback fails after
|
|
!(event.error as any).message.includes("no transaction is active")
|
|
) {
|
|
logger.error(event.error);
|
|
}
|
|
}
|
|
|
|
function millisToColor(millis: number) {
|
|
if (millis < 1) {
|
|
return "bgGreen";
|
|
}
|
|
if (millis < 5) {
|
|
return "green";
|
|
}
|
|
if (millis < 50) {
|
|
return "yellow";
|
|
}
|
|
return "red";
|
|
}
|
|
|
|
function formatSql(query: string, params: readonly unknown[]) {
|
|
const formatted = format(query);
|
|
|
|
const lines = formatted.split("\n");
|
|
|
|
if (ServerConfig.sqlLog === "full" || lines.length <= 11) {
|
|
return addParams(formatted, params);
|
|
}
|
|
|
|
const linesNotShown = lines.length - 10;
|
|
|
|
return `${lines.slice(0, 10).join("\n")}\n... (${linesNotShown} more lines) ...\n`;
|
|
}
|
|
|
|
function addParams(query: string, params: readonly unknown[]) {
|
|
const coloredParams = params.map((param) =>
|
|
styleText("yellow", JSON.stringify(param)),
|
|
);
|
|
|
|
return query.replace(/\?/g, () => coloredParams.shift() || "");
|
|
}
|